Compare commits
3 Commits
docs/updat
...
feat/publi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be8795e34f | ||
|
|
cbf347dce0 | ||
|
|
cb9736134d |
78
.github/workflows/docker-publish.yml
vendored
78
.github/workflows/docker-publish.yml
vendored
@@ -9,30 +9,20 @@ on:
|
||||
schedule:
|
||||
- cron: "21 21 * * *"
|
||||
push:
|
||||
# Only trigger on tags (releases), not main branch pushes
|
||||
# Main branch builds are handled by workflow_run after Translate completes
|
||||
branches: ["main"]
|
||||
# Publish semver tags as releases.
|
||||
tags: ["v*.*.*"]
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
workflow_run:
|
||||
workflows: ["Translate"]
|
||||
types: [completed]
|
||||
branches: [main]
|
||||
# Docker builds after Translate completes (success or failure)
|
||||
# This ensures Docker always has the latest translations
|
||||
|
||||
env:
|
||||
# Use docker.io for Docker Hub if empty
|
||||
REGISTRY: ghcr.io
|
||||
# github.repository as <account>/<repo>
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
MIGRATE_IMAGE_NAME: ${{ github.repository }}-migrate
|
||||
|
||||
jobs:
|
||||
build:
|
||||
concurrency:
|
||||
group: docker-build-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -71,34 +61,6 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Extract metadata (tags, labels) for Docker
|
||||
# https://github.com/docker/metadata-action
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.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}}
|
||||
|
||||
- name: Extract Docker metadata (migrate)
|
||||
id: meta-migrate
|
||||
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.MIGRATE_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
# Extract version from git tag or ref
|
||||
# Uses git describe to get latest tag + commit hash in SemVer format: 1.2.3+abc1234
|
||||
- name: Extract version
|
||||
@@ -135,6 +97,39 @@ jobs:
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Version: $VERSION"
|
||||
|
||||
# Extract metadata (tags, labels) for Docker
|
||||
# https://github.com/docker/metadata-action
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.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}}
|
||||
labels: |
|
||||
org.opencontainers.image.version=${{ steps.version.outputs.version }}
|
||||
|
||||
# Extract metadata for migrate image
|
||||
- name: Extract Docker metadata for migrate
|
||||
id: meta-migrate
|
||||
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-migrate
|
||||
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}}
|
||||
labels: |
|
||||
org.opencontainers.image.version=${{ steps.version.outputs.version }}
|
||||
|
||||
# Build and push Docker image with Buildx (don't push on PR)
|
||||
# https://github.com/docker/build-push-action
|
||||
- name: Build and push Docker image
|
||||
@@ -152,6 +147,8 @@ jobs:
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# Build and push migrate Docker image with Buildx (don't push on PR)
|
||||
# https://github.com/docker/build-push-action
|
||||
- name: Build and push migrate Docker image
|
||||
id: build-and-push-migrate
|
||||
uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0
|
||||
@@ -183,6 +180,7 @@ jobs:
|
||||
# against the sigstore community Fulcio instance.
|
||||
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
|
||||
|
||||
# Sign the migrate Docker image digest except on PRs.
|
||||
- name: Sign the published migrate Docker image
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
env:
|
||||
|
||||
56
.github/workflows/translate.yml
vendored
56
.github/workflows/translate.yml
vendored
@@ -1,56 +0,0 @@
|
||||
name: Translate
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
translate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --filter @kan/web...
|
||||
|
||||
- name: Extract translations
|
||||
working-directory: apps/web
|
||||
run: pnpm lingui:extract
|
||||
|
||||
- name: Lingo.dev
|
||||
uses: lingodotdev/lingo.dev@main
|
||||
with:
|
||||
api-key: ${{ secrets.LINGODOTDEV_API_KEY }}
|
||||
commit-message: "chore: update translations"
|
||||
working-directory: apps/web
|
||||
commit-author-name: "${{ github.actor }}"
|
||||
commit-author-email: "${{ github.actor }}@users.noreply.github.com"
|
||||
parallel: true
|
||||
|
||||
- name: Compile translations
|
||||
working-directory: apps/web
|
||||
run: pnpm lingui:compile
|
||||
|
||||
- name: Commit compiled translations
|
||||
run: |
|
||||
git config --local user.email "${{ github.actor }}@users.noreply.github.com"
|
||||
git config --local user.name "${{ github.actor }}"
|
||||
if ls apps/web/src/locales/*/messages.ts 1> /dev/null 2>&1; then
|
||||
git add apps/web/src/locales/*/messages.ts
|
||||
git diff --staged --quiet || (git commit -m "chore: compile translations" && git push)
|
||||
fi
|
||||
56
README.md
56
README.md
@@ -57,61 +57,39 @@ The easiest way to deploy Kan is through Railway. We've partnered with Railway t
|
||||
|
||||
### Docker Compose
|
||||
|
||||
Alternatively, you can self-host Kan with Docker Compose. This will set up everything for you including your postgres database and automatically run migrations.
|
||||
Alternatively, you can self-host Kan with Docker Compose. This will set up everything for you including your postgres database.
|
||||
|
||||
1. Create a `.env` file with your environment variables (see [Environment Variables](#environment-variables-) section below)
|
||||
|
||||
2. Use the provided `docker-compose.yml` file or create your own with the following configuration:
|
||||
1. Create a new file called `docker-compose.yml` and paste the following configuration:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
migrate:
|
||||
image: ghcr.io/kanbn/kan-migrate:latest
|
||||
container_name: kan-migrate
|
||||
networks:
|
||||
- kan-network
|
||||
environment:
|
||||
- POSTGRES_URL=${POSTGRES_URL}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
restart: "no"
|
||||
|
||||
web:
|
||||
image: ghcr.io/kanbn/kan:latest
|
||||
container_name: kan-web
|
||||
ports:
|
||||
- "${WEB_PORT:-3000}:3000"
|
||||
- "3000:3000"
|
||||
networks:
|
||||
- kan-network
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL}
|
||||
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
|
||||
- POSTGRES_URL=${POSTGRES_URL}
|
||||
- NEXT_PUBLIC_ALLOW_CREDENTIALS=true
|
||||
NEXT_PUBLIC_BASE_URL: http://localhost:3000
|
||||
BETTER_AUTH_SECRET: your_auth_secret
|
||||
POSTGRES_URL: postgresql://kan:your_postgres_password@postgres:5432/kan_db
|
||||
NEXT_PUBLIC_ALLOW_CREDENTIALS: true
|
||||
depends_on:
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
- postgres
|
||||
restart: unless-stopped
|
||||
|
||||
postgres:
|
||||
image: postgres:15
|
||||
container_name: kan-db
|
||||
environment:
|
||||
- POSTGRES_DB=kan_db
|
||||
- POSTGRES_USER=kan
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: kan_db
|
||||
POSTGRES_USER: kan
|
||||
POSTGRES_PASSWORD: your_postgres_password
|
||||
ports:
|
||||
- 5432:5432
|
||||
volumes:
|
||||
- kan_postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U kan -d kan_db"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- kan-network
|
||||
@@ -123,23 +101,23 @@ volumes:
|
||||
kan_postgres_data:
|
||||
```
|
||||
|
||||
3. Start the containers in detached mode:
|
||||
2. Start the containers in detached mode:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The `migrate` service will automatically run database migrations before the web service starts. The application will be available at http://localhost:3000 (or the port specified in `WEB_PORT`).
|
||||
3. Access Kan at http://localhost:3000
|
||||
|
||||
**Managing containers:**
|
||||
The application will be running in the background. You can manage the containers using these commands:
|
||||
|
||||
- To stop the containers: `docker compose down`
|
||||
- To view logs: `docker compose logs -f`
|
||||
- To view logs for a specific service: `docker compose logs -f web` or `docker compose logs -f migrate`
|
||||
- To restart the containers: `docker compose restart`
|
||||
- To rebuild after code changes: `docker compose up -d --build`
|
||||
|
||||
For the complete Docker Compose configuration with all optional features, see [docker-compose.yml](./docker-compose.yml) in the repository.
|
||||
For the complete Docker Compose configuration, see [docker-compose.yml](./docker-compose.yml) in the repository.
|
||||
|
||||
> **Note**: The Docker Compose configuration shown above is a minimal example. For a complete setup with all features (email, OAuth, file uploads, etc.), you'll need to create a `.env` file with the required environment variables. See the Environment Variables section below for the full list of available options.
|
||||
|
||||
## Local Development 🧑💻
|
||||
|
||||
|
||||
@@ -69,8 +69,11 @@ RUN VERSION="${APP_VERSION:-$(cat /tmp/AUTO_VERSION 2>/dev/null | tr -d '\n\r' |
|
||||
|
||||
# ============================================
|
||||
# Stage 5: Migration image (run-once container)
|
||||
# Note: Use a Debian-based Node image instead of Alpine here because
|
||||
# Alpine under QEMU has caused Illegal instruction (signal 4) crashes
|
||||
# during npm installs on arm64 multi-arch builds.
|
||||
# ============================================
|
||||
FROM node:${NODE_VERSION}-alpine AS migrate
|
||||
FROM node:${NODE_VERSION}-bullseye-slim AS migrate
|
||||
WORKDIR /db
|
||||
|
||||
COPY packages/db/drizzle.config.ts ./drizzle.config.ts
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"version": "1.10",
|
||||
"version": 0,
|
||||
"locale": {
|
||||
"source": "en",
|
||||
"targets": ["fr", "de", "es", "it", "nl", "ru", "pl", "pt-BR"]
|
||||
},
|
||||
"buckets": {
|
||||
"json": {
|
||||
"include": ["src/locales/[locale]/messages.json"]
|
||||
"po": {
|
||||
"include": ["src/locales/[locale]/messages.po"]
|
||||
}
|
||||
},
|
||||
"$schema": "https://lingo.dev/schema/i18n.json"
|
||||
|
||||
3291
apps/web/i18n.lock
3291
apps/web/i18n.lock
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,7 @@
|
||||
import { defineConfig } from "@lingui/cli";
|
||||
import { formatter } from "@lingui/format-json";
|
||||
import type { LinguiConfig } from "@lingui/conf";
|
||||
|
||||
|
||||
export default defineConfig({
|
||||
locales: ["en", "fr", "de", "es", "it", "nl", "ru", "pl", "pt-BR"],
|
||||
const config: LinguiConfig = {
|
||||
locales: ["en", "fr", "de", "es", "it", "nl", "ru", "pl","pt-BR"],
|
||||
sourceLocale: "en",
|
||||
catalogs: [
|
||||
{
|
||||
@@ -12,5 +10,6 @@ export default defineConfig({
|
||||
exclude: ["**/node_modules/**"],
|
||||
},
|
||||
],
|
||||
format: formatter({ style: "lingui" }),
|
||||
});
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -47,13 +47,49 @@ const config = {
|
||||
remotePatterns: (() => {
|
||||
/** @type {Array<{protocol: "http" | "https", hostname: string}>} */
|
||||
const patterns = [
|
||||
{ protocol: "https", hostname: "**" },
|
||||
{
|
||||
protocol: "https",
|
||||
hostname:
|
||||
env("S3_FORCE_PATH_STYLE") === "true"
|
||||
? `${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`
|
||||
: `*.${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`,
|
||||
},
|
||||
{
|
||||
protocol: "http",
|
||||
hostname: "localhost",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "*.googleusercontent.com",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "cdn.discordapp.com",
|
||||
},
|
||||
];
|
||||
|
||||
// Extract root domain from S3_ENDPOINT and add wildcard pattern
|
||||
const s3Endpoint = env("S3_ENDPOINT");
|
||||
if (s3Endpoint) {
|
||||
try {
|
||||
const url = new URL(s3Endpoint);
|
||||
const hostname = url.hostname;
|
||||
const protocol = url.protocol.replace(":", "");
|
||||
|
||||
// Extract root domain (last 2 parts: e.g. cloudflarestorage.com)
|
||||
const parts = hostname.split(".");
|
||||
if (parts.length >= 2) {
|
||||
const rootDomain = parts.slice(-2).join(".");
|
||||
patterns.push({
|
||||
protocol: protocol === "http" ? "http" : "https",
|
||||
hostname: `*.${rootDomain}`,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// If S3_ENDPOINT is not a valid URL, ignore it
|
||||
}
|
||||
}
|
||||
|
||||
return patterns;
|
||||
})(),
|
||||
},
|
||||
|
||||
@@ -79,7 +79,6 @@
|
||||
"@kan/tailwind-config": "workspace:*",
|
||||
"@kan/tsconfig": "workspace:*",
|
||||
"@lingui/cli": "^5.3.2",
|
||||
"@lingui/format-json": "^5.9.1",
|
||||
"@lingui/loader": "^5.3.2",
|
||||
"@lingui/swc-plugin": "^5.5.2",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
|
||||
@@ -463,6 +463,13 @@ export default function Editor({
|
||||
StarterKit.configure({
|
||||
heading: disableHeadings ? false : undefined,
|
||||
}),
|
||||
Markdown,
|
||||
Placeholder.configure({
|
||||
placeholder: readOnly
|
||||
? ""
|
||||
: placeholder ??
|
||||
t`Add description... (type '/' to open commands or '@' to mention)`,
|
||||
}),
|
||||
Link.configure({
|
||||
openOnClick: true,
|
||||
HTMLAttributes: {
|
||||
@@ -472,14 +479,6 @@ export default function Editor({
|
||||
},
|
||||
validate: (href) => /^https?:\/\//.test(href),
|
||||
autolink: true,
|
||||
linkOnPaste: true,
|
||||
}),
|
||||
Markdown,
|
||||
Placeholder.configure({
|
||||
placeholder: readOnly
|
||||
? ""
|
||||
: placeholder ??
|
||||
t`Add description... (type '/' to open commands or '@' to mention)`,
|
||||
}),
|
||||
SlashCommands.configure({
|
||||
commandItems: getCommandItems(disableHeadings),
|
||||
|
||||
@@ -19,7 +19,6 @@ interface UsePermissionsResult {
|
||||
canCreateBoard: boolean;
|
||||
canEditBoard: boolean;
|
||||
canDeleteBoard: boolean;
|
||||
canArchiveBoard: boolean;
|
||||
canViewComment: boolean;
|
||||
canCreateComment: boolean;
|
||||
canEditComment: boolean;
|
||||
@@ -34,7 +33,7 @@ interface UsePermissionsResult {
|
||||
export function usePermissions(): UsePermissionsResult {
|
||||
// Check if WorkspaceProvider is available (for public board views, it may not be)
|
||||
const workspaceContext = useContext(WorkspaceContext);
|
||||
|
||||
|
||||
// If WorkspaceProvider is not available, return safe defaults
|
||||
if (!workspaceContext) {
|
||||
const emptyPermissions: UsePermissionsResult = {
|
||||
@@ -52,7 +51,6 @@ export function usePermissions(): UsePermissionsResult {
|
||||
canCreateBoard: false,
|
||||
canEditBoard: false,
|
||||
canDeleteBoard: false,
|
||||
canArchiveBoard: false,
|
||||
canViewComment: false,
|
||||
canCreateComment: false,
|
||||
canEditComment: false,
|
||||
@@ -97,7 +95,6 @@ export function usePermissions(): UsePermissionsResult {
|
||||
canCreateBoard: hasPermission("board:create"),
|
||||
canEditBoard: hasPermission("board:edit"),
|
||||
canDeleteBoard: hasPermission("board:delete"),
|
||||
canArchiveBoard: hasPermission("board:edit"),
|
||||
canViewComment: hasPermission("comment:view"),
|
||||
canCreateComment: hasPermission("comment:create"),
|
||||
canEditComment: hasPermission("comment:edit"),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
3090
apps/web/src/locales/de/messages.po
Normal file
3090
apps/web/src/locales/de/messages.po
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
3275
apps/web/src/locales/en/messages.po
Normal file
3275
apps/web/src/locales/en/messages.po
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
3090
apps/web/src/locales/es/messages.po
Normal file
3090
apps/web/src/locales/es/messages.po
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
3090
apps/web/src/locales/fr/messages.po
Normal file
3090
apps/web/src/locales/fr/messages.po
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -7,7 +7,7 @@ export const locales = [
|
||||
"nl",
|
||||
"ru",
|
||||
"pl",
|
||||
"ptbr"
|
||||
"pt-BR"
|
||||
] as const;
|
||||
|
||||
export type Locale = (typeof locales)[number];
|
||||
@@ -23,5 +23,5 @@ export const localeNames: Record<Locale, string> = {
|
||||
nl: "Nederlands",
|
||||
ru: "Русский",
|
||||
pl: "Polski",
|
||||
ptbr: "Português",
|
||||
"pt-BR": "Português",
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
3090
apps/web/src/locales/it/messages.po
Normal file
3090
apps/web/src/locales/it/messages.po
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
3090
apps/web/src/locales/nl/messages.po
Normal file
3090
apps/web/src/locales/nl/messages.po
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
3095
apps/web/src/locales/pl/messages.po
Normal file
3095
apps/web/src/locales/pl/messages.po
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
3090
apps/web/src/locales/ru/messages.po
Normal file
3090
apps/web/src/locales/ru/messages.po
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -58,7 +58,7 @@ export default function LoginPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(!isSignUpDisabled || redirect?.startsWith("/invite/")) && (
|
||||
{!isSignUpDisabled && (
|
||||
<p className="mt-4 text-sm text-light-1000 dark:text-dark-1000">
|
||||
<Trans>
|
||||
Don't have an account?{" "}
|
||||
|
||||
@@ -28,9 +28,7 @@ export default function SignUpPage() {
|
||||
setMagicLinkRecipient(recipient);
|
||||
};
|
||||
|
||||
const isInviteFlow = redirect?.startsWith("/invite/");
|
||||
|
||||
if (isSignUpDisabled && !isInviteFlow) {
|
||||
if (isSignUpDisabled) {
|
||||
return (
|
||||
<>
|
||||
<PageHead title={t`Sign up | kan.bn`} />
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
HiOutlineStar,
|
||||
HiStar,
|
||||
} from "react-icons/hi2";
|
||||
import { IoArchiveOutline } from "react-icons/io5";
|
||||
|
||||
import Dropdown from "~/components/Dropdown";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { useModal } from "~/providers/modal";
|
||||
@@ -17,7 +17,6 @@ import { api } from "~/utils/api";
|
||||
export default function BoardDropdown({
|
||||
isTemplate,
|
||||
isLoading,
|
||||
isArchived,
|
||||
boardPublicId,
|
||||
isFavorite,
|
||||
boardName,
|
||||
@@ -25,29 +24,28 @@ export default function BoardDropdown({
|
||||
isTemplate: boolean;
|
||||
isLoading: boolean;
|
||||
boardPublicId: string;
|
||||
isArchived?: boolean;
|
||||
isFavorite?: boolean;
|
||||
boardName?: string;
|
||||
}) {
|
||||
const { openModal } = useModal();
|
||||
const { canEditBoard, canDeleteBoard, canCreateBoard } = usePermissions();
|
||||
const { showPopup } = usePopup();
|
||||
const { canEditBoard, canDeleteBoard, canCreateBoard, canArchiveBoard } =
|
||||
usePermissions();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const handleToggleFavorite = () => {
|
||||
updateBoard.mutate({
|
||||
boardPublicId,
|
||||
favorite: !isFavorite,
|
||||
});
|
||||
};
|
||||
|
||||
const updateBoard = api.board.update.useMutation({
|
||||
onSuccess: (_data, variables) => {
|
||||
onSuccess: (data, variables) => {
|
||||
void utils.board.all.invalidate();
|
||||
void utils.board.byId.invalidate();
|
||||
if (variables.isArchived !== undefined) {
|
||||
showPopup({
|
||||
header: variables.isArchived ? t`Board archived` : t`Board unarchived`,
|
||||
message: variables.isArchived
|
||||
? t`The board has been archived.`
|
||||
: t`The board has been unarchived.`,
|
||||
icon: "success",
|
||||
});
|
||||
} else if (variables.favorite !== undefined) {
|
||||
|
||||
// Show popup notification
|
||||
if (variables.favorite !== undefined) {
|
||||
showPopup({
|
||||
header: variables.favorite
|
||||
? t`Added to favorites`
|
||||
@@ -67,54 +65,27 @@ export default function BoardDropdown({
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleToggleFavorite = () => {
|
||||
updateBoard.mutate({
|
||||
boardPublicId,
|
||||
favorite: !isFavorite,
|
||||
});
|
||||
};
|
||||
|
||||
const handleArchiveOrUnarchive = () => {
|
||||
updateBoard.mutate({
|
||||
boardPublicId,
|
||||
isArchived: !isArchived,
|
||||
});
|
||||
};
|
||||
|
||||
const isArchiveActionPending = updateBoard.isPending;
|
||||
|
||||
|
||||
const items = [
|
||||
...(isTemplate && canCreateBoard
|
||||
? [
|
||||
{
|
||||
label: t`Make template`,
|
||||
action: () => openModal("CREATE_TEMPLATE"),
|
||||
icon: (
|
||||
<HiOutlineDocumentDuplicate className="h-[16px] w-[16px] text-dark-900" />
|
||||
),
|
||||
},
|
||||
]
|
||||
{
|
||||
label: t`Make template`,
|
||||
action: () => openModal("CREATE_TEMPLATE"),
|
||||
icon: (
|
||||
<HiOutlineDocumentDuplicate className="h-[16px] w-[16px] text-dark-900" />
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(!isTemplate && canEditBoard
|
||||
? [
|
||||
{
|
||||
label: t`Edit board URL`,
|
||||
action: () => openModal("UPDATE_BOARD_SLUG"),
|
||||
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(!isTemplate && canArchiveBoard
|
||||
? [
|
||||
{
|
||||
label: isArchived ? t`Unarchive board` : t`Archive board`,
|
||||
action: handleArchiveOrUnarchive,
|
||||
icon: (
|
||||
<IoArchiveOutline className="h-[16px] w-[16px] text-dark-900" />
|
||||
),
|
||||
},
|
||||
]
|
||||
{
|
||||
label: t`Edit board URL`,
|
||||
action: () => openModal("UPDATE_BOARD_SLUG"),
|
||||
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: isFavorite
|
||||
@@ -129,26 +100,22 @@ export default function BoardDropdown({
|
||||
},
|
||||
...(canDeleteBoard
|
||||
? [
|
||||
{
|
||||
label: isTemplate ? t`Delete template` : t`Delete board`,
|
||||
action: () => openModal("DELETE_BOARD"),
|
||||
icon: (
|
||||
<HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />
|
||||
),
|
||||
},
|
||||
]
|
||||
{
|
||||
label: isTemplate ? t`Delete template` : t`Delete board`,
|
||||
action: () => openModal("DELETE_BOARD"),
|
||||
icon: <HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
disabled={isLoading || isArchiveActionPending}
|
||||
items={items}
|
||||
>
|
||||
<Dropdown disabled={isLoading} items={items}>
|
||||
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
|
||||
</Dropdown>
|
||||
);
|
||||
|
||||
@@ -494,7 +494,7 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
isTemplate={!!isTemplate}
|
||||
isLoading={!boardData}
|
||||
boardPublicId={boardId ?? ""}
|
||||
isArchived={boardData?.isArchived ?? false}
|
||||
workspacePublicId={workspace.publicId}
|
||||
isFavorite={boardData?.favorite}
|
||||
boardName={boardData?.name}
|
||||
/>
|
||||
@@ -598,12 +598,13 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
? `/templates/${boardId}/cards/${card.publicId}`
|
||||
: `/cards/${card.publicId}`
|
||||
}
|
||||
className={`mb-2 flex !cursor-pointer flex-col ${card.publicId.startsWith(
|
||||
"PLACEHOLDER",
|
||||
)
|
||||
? "pointer-events-none"
|
||||
: ""
|
||||
}`}
|
||||
className={`mb-2 flex !cursor-pointer flex-col ${
|
||||
card.publicId.startsWith(
|
||||
"PLACEHOLDER",
|
||||
)
|
||||
? "pointer-events-none"
|
||||
: ""
|
||||
}`}
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useModal } from "~/providers/modal";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
export function BoardsList({ isTemplate, archived = false }: { isTemplate?: boolean; archived?: boolean }) {
|
||||
export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
|
||||
const { workspace } = useWorkspace();
|
||||
const { openModal } = useModal();
|
||||
const { canCreateBoard } = usePermissions();
|
||||
@@ -26,7 +26,6 @@ export function BoardsList({ isTemplate, archived = false }: { isTemplate?: bool
|
||||
{
|
||||
workspacePublicId: workspace.publicId,
|
||||
type: isTemplate ? "template" : "regular",
|
||||
archived: archived,
|
||||
},
|
||||
{ enabled: workspace.publicId ? true : false },
|
||||
);
|
||||
@@ -60,10 +59,10 @@ export function BoardsList({ isTemplate, archived = false }: { isTemplate?: bool
|
||||
<div className="flex flex-col items-center">
|
||||
<HiOutlineRectangleStack className="h-10 w-10 text-light-800 dark:text-dark-800" />
|
||||
<p className="mb-2 mt-4 text-[14px] font-bold text-light-1000 dark:text-dark-950">
|
||||
{archived ? t`No archived boards` : t`No ${isTemplate ? "templates" : "boards"}`}
|
||||
{t`No ${isTemplate ? "templates" : "boards"}`}
|
||||
</p>
|
||||
<p className="text-[14px] text-light-900 dark:text-dark-900">
|
||||
{archived ? t`Boards you archive will appear here.` : t`Get started by creating a new ${isTemplate ? "template" : "board"}`}
|
||||
{t`Get started by creating a new ${isTemplate ? "template" : "board"}`}
|
||||
</p>
|
||||
</div>
|
||||
<Tooltip
|
||||
@@ -110,18 +109,18 @@ export function BoardsList({ isTemplate, archived = false }: { isTemplate?: bool
|
||||
>
|
||||
<div className="group relative mr-5 flex h-[150px] w-full items-center justify-center rounded-md border border-dashed border-light-400 bg-light-50 shadow-sm hover:bg-light-200 dark:border-dark-600 dark:bg-dark-50 dark:hover:bg-dark-100">
|
||||
<PatternedBackground />
|
||||
<button
|
||||
onClick={(e) => handleToggleFavorite(e, board.publicId, board.favorite)}
|
||||
className={`absolute right-3 top-3 z-10 rounded p-1 transition-all hover:bg-light-300 dark:hover:bg-dark-200 ${board.favorite ? "" : "md:opacity-0 md:group-hover:opacity-100"
|
||||
}`}
|
||||
aria-label={board.favorite ? "Remove from favorites" : "Add to favorites"}
|
||||
>
|
||||
{board.favorite ? (
|
||||
<HiStar className="h-5 w-5 text-neutral-700 dark:text-dark-1000" />
|
||||
) : (
|
||||
<HiOutlineStar className="h-5 w-5 text-neutral-700 dark:text-dark-800" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => handleToggleFavorite(e, board.publicId, board.favorite)}
|
||||
className={`absolute right-3 top-3 z-10 rounded p-1 transition-all hover:bg-light-300 dark:hover:bg-dark-200 ${board.favorite ? "" : "md:opacity-0 md:group-hover:opacity-100"
|
||||
}`}
|
||||
aria-label={board.favorite ? "Remove from favorites" : "Add to favorites"}
|
||||
>
|
||||
{board.favorite ? (
|
||||
<HiStar className="h-5 w-5 text-neutral-700 dark:text-dark-1000" />
|
||||
) : (
|
||||
<HiOutlineStar className="h-5 w-5 text-neutral-700 dark:text-dark-800" />
|
||||
)}
|
||||
</button>
|
||||
<p className="px-4 text-[14px] font-bold text-neutral-700 dark:text-dark-1000">
|
||||
{board.name}
|
||||
</p>
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import {
|
||||
Listbox,
|
||||
ListboxButton,
|
||||
ListboxOption,
|
||||
ListboxOptions,
|
||||
} from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { HiArrowDownTray, HiChevronDown, HiOutlinePlusSmall } from "react-icons/hi2";
|
||||
import { useState } from "react";
|
||||
import { HiArrowDownTray, HiOutlinePlusSmall } from "react-icons/hi2";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import FeedbackModal from "~/components/FeedbackModal";
|
||||
@@ -22,15 +15,9 @@ import { BoardsList } from "./components/BoardsList";
|
||||
import { ImportBoardsForm } from "./components/ImportBoardsForm";
|
||||
import { NewBoardForm } from "./components/NewBoardForm";
|
||||
|
||||
const boardsTabs = [
|
||||
{ key: "boards" as const, label: t`Active` },
|
||||
{ key: "archived" as const, label: t`Archived` },
|
||||
];
|
||||
|
||||
export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
const { openModal, modalContentType, isOpen } = useModal();
|
||||
const { workspace } = useWorkspace();
|
||||
const [activeTab, setActiveTab] = useState<"boards" | "archived">("boards");
|
||||
const { canCreateBoard } = usePermissions();
|
||||
|
||||
const { tooltipContent: createModalShortcutTooltipContent } =
|
||||
@@ -47,7 +34,7 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
<PageHead
|
||||
title={t`${isTemplate ? "Templates" : "Boards"} | ${workspace.name ?? t`Workspace`}`}
|
||||
/>
|
||||
<div className="m-auto h-full max-w-[1100px] p-8 px-5 md:px-28 md:py-12">
|
||||
<div className="m-auto h-full max-w-[1100px] p-6 px-5 md:px-28 md:py-12">
|
||||
<div className="relative z-10 mb-8 flex w-full items-center justify-between">
|
||||
<h1 className="font-bold tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
|
||||
{t`${isTemplate ? "Templates" : "Boards"}`}
|
||||
@@ -128,79 +115,9 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
</Modal>
|
||||
</>
|
||||
|
||||
{!isTemplate ? (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<div className="focus:outline-none">
|
||||
<div className="sm:hidden">
|
||||
<Listbox
|
||||
value={activeTab}
|
||||
onChange={(tab) => setActiveTab(tab)}
|
||||
>
|
||||
<div className="relative mb-4">
|
||||
<ListboxButton className="w-full appearance-none rounded-md border-0 bg-light-50 py-3 pl-3 pr-10 text-left text-sm font-semibold text-light-1000 shadow-sm ring-1 ring-inset ring-light-300 dark:bg-dark-50 dark:text-dark-1000 dark:ring-dark-300 dark:focus:ring-dark-500">
|
||||
{boardsTabs.find((tab) => tab.key === activeTab)?.label ??
|
||||
"Select a tab"}
|
||||
<HiChevronDown
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-light-900 dark:text-dark-900"
|
||||
/>
|
||||
</ListboxButton>
|
||||
<ListboxOptions className="absolute z-10 mt-1 w-full rounded-md bg-light-50 py-1 text-sm shadow-lg ring-1 ring-inset ring-light-300 dark:bg-dark-50 dark:ring-dark-300">
|
||||
{boardsTabs.map((tab) => (
|
||||
<ListboxOption
|
||||
key={tab.key}
|
||||
value={tab.key}
|
||||
className={({ selected }) =>
|
||||
`relative cursor-pointer select-none py-2 pl-3 pr-9 ${selected
|
||||
? "font-bold text-light-1000 dark:text-dark-1000"
|
||||
: "font-normal text-light-1000 dark:text-dark-1000"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{tab.label}
|
||||
</ListboxOption>
|
||||
))}
|
||||
</ListboxOptions>
|
||||
</div>
|
||||
</Listbox>
|
||||
</div>
|
||||
<div className="hidden sm:block">
|
||||
<div>
|
||||
<nav
|
||||
aria-label="Tabs"
|
||||
className="-mb-px flex space-x-8 focus:outline-none"
|
||||
>
|
||||
{boardsTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`whitespace-nowrap px-1 py-0 mt-2 mb-8 text-sm font-semibold transition-colors focus:outline-none ${activeTab === tab.key
|
||||
? "border-light-1000 text-light-1000 dark:border-dark-1000 dark:text-dark-1000"
|
||||
: "border-transparent text-light-900 hover:border-light-950 hover:text-light-950 dark:text-dark-900 dark:hover:border-white/20 dark:hover:text-dark-950"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-full flex-row focus:outline-none">
|
||||
{activeTab === "boards" && (
|
||||
<BoardsList isTemplate={false} archived={false} />
|
||||
)}
|
||||
{activeTab === "archived" && (
|
||||
<BoardsList isTemplate={false} archived={true} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full flex-row">
|
||||
<BoardsList isTemplate={!!isTemplate} />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex h-full flex-row">
|
||||
<BoardsList isTemplate={!!isTemplate} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,17 +1,4 @@
|
||||
services:
|
||||
migrator:
|
||||
image: ghcr.io/kanbn/kan-migrate:latest
|
||||
container_name: ${MIGRATOR_CONTAINER_NAME:-kan-migrate}
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: apps/web/Dockerfile
|
||||
target: migrate
|
||||
networks:
|
||||
- dokploy-network
|
||||
environment:
|
||||
- POSTGRES_URL=${POSTGRES_URL}
|
||||
restart: "no"
|
||||
|
||||
web:
|
||||
image: ghcr.io/kanbn/kan:latest
|
||||
container_name: ${CONTAINER_NAME:-kan-web}
|
||||
@@ -22,7 +9,6 @@ services:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: apps/web/Dockerfile
|
||||
target: web
|
||||
args:
|
||||
APP_VERSION: ${APP_VERSION:-}
|
||||
env_file:
|
||||
@@ -93,9 +79,6 @@ services:
|
||||
- NEXT_PUBLIC_UMAMI_ID=${NEXT_PUBLIC_UMAMI_ID}
|
||||
- NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY}
|
||||
- NEXT_PUBLIC_POSTHOG_HOST=${NEXT_PUBLIC_POSTHOG_HOST}
|
||||
depends_on:
|
||||
migrator:
|
||||
condition: service_completed_successfully
|
||||
|
||||
networks:
|
||||
dokploy-network:
|
||||
|
||||
@@ -34,7 +34,6 @@ export const boardRouter = createTRPCRouter({
|
||||
z.object({
|
||||
workspacePublicId: z.string().min(12),
|
||||
type: z.enum(["regular", "template"]).optional(),
|
||||
archived: z.boolean().optional(),
|
||||
}),
|
||||
)
|
||||
.output(
|
||||
@@ -66,10 +65,7 @@ export const boardRouter = createTRPCRouter({
|
||||
ctx.db,
|
||||
workspace.id,
|
||||
userId,
|
||||
{
|
||||
type: input.type,
|
||||
archived: input.archived ?? false,
|
||||
}
|
||||
{ type: input.type }
|
||||
);
|
||||
|
||||
return result;
|
||||
@@ -157,24 +153,24 @@ export const boardRouter = createTRPCRouter({
|
||||
// Generate presigned URLs for workspace member avatars
|
||||
const workspaceWithAvatarUrls = result.workspace
|
||||
? {
|
||||
...result.workspace,
|
||||
members: await Promise.all(
|
||||
result.workspace.members.map(async (member) => {
|
||||
if (!member.user?.image) {
|
||||
return member;
|
||||
}
|
||||
...result.workspace,
|
||||
members: await Promise.all(
|
||||
result.workspace.members.map(async (member) => {
|
||||
if (!member.user?.image) {
|
||||
return member;
|
||||
}
|
||||
|
||||
const avatarUrl = await generateAvatarUrl(member.user.image);
|
||||
return {
|
||||
...member,
|
||||
user: {
|
||||
...member.user,
|
||||
image: avatarUrl,
|
||||
},
|
||||
};
|
||||
}),
|
||||
),
|
||||
}
|
||||
const avatarUrl = await generateAvatarUrl(member.user.image);
|
||||
return {
|
||||
...member,
|
||||
user: {
|
||||
...member.user,
|
||||
image: avatarUrl,
|
||||
},
|
||||
};
|
||||
}),
|
||||
),
|
||||
}
|
||||
: result.workspace;
|
||||
|
||||
// Generate presigned URLs for card member avatars
|
||||
@@ -465,8 +461,7 @@ export const boardRouter = createTRPCRouter({
|
||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/)
|
||||
.optional(),
|
||||
visibility: z.enum(["public", "private"]).optional(),
|
||||
favorite: z.boolean().optional(),
|
||||
isArchived: z.boolean().optional(),
|
||||
favorite: z.boolean().optional()
|
||||
}),
|
||||
)
|
||||
.output(z.object({ success: z.boolean() }).or(z.custom<Awaited<ReturnType<typeof boardRepo.update>>>()))
|
||||
@@ -508,7 +503,7 @@ export const boardRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
// Handle other updates (name, slug, visibility)
|
||||
const hasOtherUpdates = input.name || input.slug || input.visibility !== undefined || input.isArchived !== undefined;
|
||||
const hasOtherUpdates = input.name || input.slug || input.visibility !== undefined;
|
||||
|
||||
if (!hasOtherUpdates) {
|
||||
// Only favorite was updated, return success
|
||||
@@ -535,7 +530,6 @@ export const boardRouter = createTRPCRouter({
|
||||
slug: input.slug,
|
||||
boardPublicId: input.boardPublicId,
|
||||
visibility: input.visibility,
|
||||
isArchived: input.isArchived,
|
||||
});
|
||||
|
||||
if (!result)
|
||||
|
||||
@@ -13,10 +13,6 @@ import { mergeActivities } from "../utils/activities";
|
||||
import { sendMentionEmails } from "../utils/notifications";
|
||||
import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions";
|
||||
import { generateAttachmentUrl, generateAvatarUrl } from "@kan/shared/utils";
|
||||
import {
|
||||
createCardWebhookPayload,
|
||||
sendWebhooksForWorkspace,
|
||||
} from "../utils/webhook";
|
||||
|
||||
export const cardRouter = createTRPCRouter({
|
||||
create: protectedProcedure
|
||||
@@ -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;
|
||||
}),
|
||||
addComment: protectedProcedure
|
||||
@@ -1037,59 +1007,6 @@ export const cardRouter = createTRPCRouter({
|
||||
await cardActivityRepo.bulkCreate(ctx.db, activities);
|
||||
}
|
||||
|
||||
// Build changes object for webhook
|
||||
const webhookChanges: Record<string, { from: unknown; to: unknown }> = {};
|
||||
if (input.title && existingCard.title !== input.title) {
|
||||
webhookChanges.title = { from: existingCard.title, to: input.title };
|
||||
}
|
||||
if (input.description && existingCard.description !== input.description) {
|
||||
webhookChanges.description = {
|
||||
from: existingCard.description,
|
||||
to: input.description,
|
||||
};
|
||||
}
|
||||
if (
|
||||
input.dueDate !== undefined &&
|
||||
previousDueDate?.getTime() !== input.dueDate?.getTime()
|
||||
) {
|
||||
webhookChanges.dueDate = { from: previousDueDate, to: input.dueDate };
|
||||
}
|
||||
if (newListId && existingCard.listId !== newListId) {
|
||||
webhookChanges.listId = { from: existingCard.listId, to: newListId };
|
||||
}
|
||||
|
||||
// Fire webhooks (non-blocking)
|
||||
sendWebhooksForWorkspace(
|
||||
ctx.db,
|
||||
card.workspaceId,
|
||||
createCardWebhookPayload(
|
||||
newListId && existingCard.listId !== newListId
|
||||
? "card.moved"
|
||||
: "card.updated",
|
||||
{
|
||||
id: String(result.id),
|
||||
title: result.title,
|
||||
description: result.description,
|
||||
dueDate: result.dueDate,
|
||||
listId: String(newListId ?? existingCard.listId),
|
||||
},
|
||||
{
|
||||
boardId: card.boardPublicId,
|
||||
boardName: card.boardName,
|
||||
listName: card.listName,
|
||||
user: ctx.user
|
||||
? { id: ctx.user.id, name: ctx.user.name }
|
||||
: undefined,
|
||||
changes:
|
||||
Object.keys(webhookChanges).length > 0
|
||||
? webhookChanges
|
||||
: undefined,
|
||||
},
|
||||
),
|
||||
).catch((error) => {
|
||||
console.error("Webhook delivery failed:", error);
|
||||
});
|
||||
|
||||
return result;
|
||||
}),
|
||||
delete: protectedProcedure
|
||||
@@ -1137,9 +1054,6 @@ export const cardRouter = createTRPCRouter({
|
||||
card.createdBy,
|
||||
);
|
||||
|
||||
// Fetch full card data before delete for webhook
|
||||
const fullCard = await cardRepo.getByPublicId(ctx.db, input.cardPublicId);
|
||||
|
||||
const deletedAt = new Date();
|
||||
|
||||
await cardRepo.softDelete(ctx.db, {
|
||||
@@ -1154,34 +1068,6 @@ export const cardRouter = createTRPCRouter({
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
// Fire webhooks (non-blocking)
|
||||
if (fullCard) {
|
||||
sendWebhooksForWorkspace(
|
||||
ctx.db,
|
||||
card.workspaceId,
|
||||
createCardWebhookPayload(
|
||||
"card.deleted",
|
||||
{
|
||||
id: String(fullCard.id),
|
||||
title: fullCard.title,
|
||||
description: fullCard.description,
|
||||
dueDate: fullCard.dueDate,
|
||||
listId: String(fullCard.listId),
|
||||
},
|
||||
{
|
||||
boardId: card.boardPublicId,
|
||||
boardName: card.boardName,
|
||||
listName: card.listName,
|
||||
user: ctx.user
|
||||
? { id: ctx.user.id, name: ctx.user.name }
|
||||
: undefined,
|
||||
},
|
||||
),
|
||||
).catch((error) => {
|
||||
console.error("Webhook delivery failed:", error);
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -81,16 +81,6 @@ export const checklistRouter = createTRPCRouter({
|
||||
return newChecklist;
|
||||
}),
|
||||
update: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Update a checklist",
|
||||
method: "PUT",
|
||||
path: "/checklists/{checklistPublicId}",
|
||||
description: "Updates a checklist by its public ID",
|
||||
tags: ["Cards"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
checklistPublicId: z.string().length(12),
|
||||
|
||||
@@ -210,12 +210,11 @@ export const memberRouter = createTRPCRouter({
|
||||
input.memberPublicId,
|
||||
);
|
||||
|
||||
if (!member || member.workspaceId !== workspace.id) {
|
||||
if (!member)
|
||||
throw new TRPCError({
|
||||
message: `Member with public ID ${input.memberPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
}
|
||||
|
||||
const deletedMember = await memberRepo.softDelete(ctx.db, {
|
||||
memberId: member.id,
|
||||
@@ -721,7 +720,7 @@ export const memberRouter = createTRPCRouter({
|
||||
input.memberPublicId,
|
||||
);
|
||||
|
||||
if (!member || member.workspaceId !== workspace.id) {
|
||||
if (!member) {
|
||||
throw new TRPCError({
|
||||
message: "Member not found",
|
||||
code: "NOT_FOUND",
|
||||
|
||||
@@ -133,7 +133,7 @@ export const permissionRouter = createTRPCRouter({
|
||||
input.memberPublicId,
|
||||
);
|
||||
|
||||
if (!member || member.workspaceId !== workspace.id) {
|
||||
if (!member) {
|
||||
throw new TRPCError({
|
||||
message: "Member not found",
|
||||
code: "NOT_FOUND",
|
||||
@@ -209,7 +209,7 @@ export const permissionRouter = createTRPCRouter({
|
||||
input.memberPublicId,
|
||||
);
|
||||
|
||||
if (!member || member.workspaceId !== workspace.id) {
|
||||
if (!member) {
|
||||
throw new TRPCError({
|
||||
message: "Member not found",
|
||||
code: "NOT_FOUND",
|
||||
@@ -274,7 +274,7 @@ export const permissionRouter = createTRPCRouter({
|
||||
input.memberPublicId,
|
||||
);
|
||||
|
||||
if (!member || member.workspaceId !== workspace.id) {
|
||||
if (!member) {
|
||||
throw new TRPCError({
|
||||
message: "Member not found",
|
||||
code: "NOT_FOUND",
|
||||
@@ -339,7 +339,7 @@ export const permissionRouter = createTRPCRouter({
|
||||
input.memberPublicId,
|
||||
);
|
||||
|
||||
if (!member || member.workspaceId !== workspace.id) {
|
||||
if (!member) {
|
||||
throw new TRPCError({
|
||||
message: "Member not found",
|
||||
code: "NOT_FOUND",
|
||||
|
||||
@@ -1,528 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
vi.mock("@kan/db/repository/webhook.repo", () => ({
|
||||
getActiveByWorkspaceId: vi.fn(),
|
||||
}));
|
||||
|
||||
import * as webhookRepo from "@kan/db/repository/webhook.repo";
|
||||
import {
|
||||
sendWebhookToUrl,
|
||||
sendWebhooksForWorkspace,
|
||||
createCardWebhookPayload,
|
||||
webhookUrlSchema,
|
||||
type WebhookPayload,
|
||||
} from "./webhook";
|
||||
|
||||
const mockGetActiveByWorkspaceId = webhookRepo.getActiveByWorkspaceId as ReturnType<typeof vi.fn>;
|
||||
|
||||
describe("webhook utilities", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2024-01-15T12:00:00.000Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("createCardWebhookPayload", () => {
|
||||
it("creates a payload with required card fields", () => {
|
||||
const payload = createCardWebhookPayload(
|
||||
"card.created",
|
||||
{
|
||||
id: "card-123",
|
||||
title: "Test Card",
|
||||
listId: "list-456",
|
||||
},
|
||||
{
|
||||
boardId: "board-789",
|
||||
},
|
||||
);
|
||||
|
||||
expect(payload.event).toBe("card.created");
|
||||
expect(payload.timestamp).toBe("2024-01-15T12:00:00.000Z");
|
||||
expect(payload.data.card).toEqual({
|
||||
id: "card-123",
|
||||
title: "Test Card",
|
||||
description: undefined,
|
||||
dueDate: null,
|
||||
listId: "list-456",
|
||||
boardId: "board-789",
|
||||
});
|
||||
});
|
||||
|
||||
it("includes optional card fields when provided", () => {
|
||||
const dueDate = new Date("2024-02-01T10:00:00.000Z");
|
||||
const payload = createCardWebhookPayload(
|
||||
"card.updated",
|
||||
{
|
||||
id: "card-123",
|
||||
title: "Test Card",
|
||||
description: "A description",
|
||||
dueDate,
|
||||
listId: "list-456",
|
||||
},
|
||||
{
|
||||
boardId: "board-789",
|
||||
},
|
||||
);
|
||||
|
||||
expect(payload.data.card.description).toBe("A description");
|
||||
expect(payload.data.card.dueDate).toBe("2024-02-01T10:00:00.000Z");
|
||||
});
|
||||
|
||||
it("includes board context when provided", () => {
|
||||
const payload = createCardWebhookPayload(
|
||||
"card.created",
|
||||
{
|
||||
id: "card-123",
|
||||
title: "Test Card",
|
||||
listId: "list-456",
|
||||
},
|
||||
{
|
||||
boardId: "board-789",
|
||||
boardName: "My Board",
|
||||
},
|
||||
);
|
||||
|
||||
expect(payload.data.board).toEqual({
|
||||
id: "board-789",
|
||||
name: "My Board",
|
||||
});
|
||||
});
|
||||
|
||||
it("includes list context when provided", () => {
|
||||
const payload = createCardWebhookPayload(
|
||||
"card.created",
|
||||
{
|
||||
id: "card-123",
|
||||
title: "Test Card",
|
||||
listId: "list-456",
|
||||
},
|
||||
{
|
||||
boardId: "board-789",
|
||||
listName: "To Do",
|
||||
},
|
||||
);
|
||||
|
||||
expect(payload.data.list).toEqual({
|
||||
id: "list-456",
|
||||
name: "To Do",
|
||||
});
|
||||
});
|
||||
|
||||
it("includes user context when provided", () => {
|
||||
const payload = createCardWebhookPayload(
|
||||
"card.created",
|
||||
{
|
||||
id: "card-123",
|
||||
title: "Test Card",
|
||||
listId: "list-456",
|
||||
},
|
||||
{
|
||||
boardId: "board-789",
|
||||
user: {
|
||||
id: "user-111",
|
||||
name: "John Doe",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(payload.data.user).toEqual({
|
||||
id: "user-111",
|
||||
name: "John Doe",
|
||||
});
|
||||
});
|
||||
|
||||
it("includes changes for card.updated events", () => {
|
||||
const payload = createCardWebhookPayload(
|
||||
"card.updated",
|
||||
{
|
||||
id: "card-123",
|
||||
title: "Updated Title",
|
||||
listId: "list-456",
|
||||
},
|
||||
{
|
||||
boardId: "board-789",
|
||||
changes: {
|
||||
title: { from: "Old Title", to: "Updated Title" },
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(payload.data.changes).toEqual({
|
||||
title: { from: "Old Title", to: "Updated Title" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendWebhookToUrl", () => {
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
global.fetch = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
const mockPayload: WebhookPayload = {
|
||||
event: "card.created",
|
||||
timestamp: "2024-01-15T12:00:00.000Z",
|
||||
data: {
|
||||
card: {
|
||||
id: "card-123",
|
||||
title: "Test Card",
|
||||
listId: "list-456",
|
||||
boardId: "board-789",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe("SSRF protection", () => {
|
||||
it("blocks HTTP URLs", async () => {
|
||||
const result = await sendWebhookToUrl("http://example.com/webhook", undefined, mockPayload);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("HTTPS");
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks localhost", async () => {
|
||||
const result = await sendWebhookToUrl("https://localhost/webhook", undefined, mockPayload);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Localhost");
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks 127.0.0.1", async () => {
|
||||
const result = await sendWebhookToUrl("https://127.0.0.1/webhook", undefined, mockPayload);
|
||||
expect(result.success).toBe(false);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks cloud metadata endpoint", async () => {
|
||||
const result = await sendWebhookToUrl("https://169.254.169.254/latest/meta-data/", undefined, mockPayload);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("metadata");
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks private 10.x.x.x IPs", async () => {
|
||||
const result = await sendWebhookToUrl("https://10.0.0.1/webhook", undefined, mockPayload);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Private");
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks private 192.168.x.x IPs", async () => {
|
||||
const result = await sendWebhookToUrl("https://192.168.1.1/webhook", undefined, mockPayload);
|
||||
expect(result.success).toBe(false);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows valid HTTPS URLs", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
const result = await sendWebhookToUrl("https://example.com/webhook", undefined, mockPayload);
|
||||
expect(result.success).toBe(true);
|
||||
expect(global.fetch).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("sends POST request with correct headers", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
await sendWebhookToUrl("https://example.com/webhook", undefined, mockPayload);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://example.com/webhook",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: expect.objectContaining({
|
||||
"Content-Type": "application/json",
|
||||
"X-Webhook-Event": "card.created",
|
||||
"X-Webhook-Timestamp": "2024-01-15T12:00:00.000Z",
|
||||
}),
|
||||
body: JSON.stringify(mockPayload),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("includes signature header when secret is provided", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
await sendWebhookToUrl("https://example.com/webhook", "my-secret", mockPayload);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://example.com/webhook",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"X-Webhook-Signature": expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns success for 2xx responses", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const result = await sendWebhookToUrl(
|
||||
"https://example.com/webhook",
|
||||
undefined,
|
||||
mockPayload,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ success: true, statusCode: 200 });
|
||||
});
|
||||
|
||||
it("returns failure for non-2xx responses", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: "Internal Server Error",
|
||||
});
|
||||
|
||||
const result = await sendWebhookToUrl(
|
||||
"https://example.com/webhook",
|
||||
undefined,
|
||||
mockPayload,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
statusCode: 500,
|
||||
error: "500 Internal Server Error",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns failure on network error", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
||||
new Error("Network error"),
|
||||
);
|
||||
|
||||
const result = await sendWebhookToUrl(
|
||||
"https://example.com/webhook",
|
||||
undefined,
|
||||
mockPayload,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: "Network error",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns timeout error when request takes too long", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((_, reject) => {
|
||||
setTimeout(() => {
|
||||
const error = new Error("Aborted");
|
||||
error.name = "AbortError";
|
||||
reject(error);
|
||||
}, 15000);
|
||||
}),
|
||||
);
|
||||
|
||||
const resultPromise = sendWebhookToUrl(
|
||||
"https://example.com/webhook",
|
||||
undefined,
|
||||
mockPayload,
|
||||
);
|
||||
|
||||
// Advance timers to trigger the abort
|
||||
vi.advanceTimersByTime(15000);
|
||||
|
||||
const result = await resultPromise;
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: "Request timed out",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendWebhooksForWorkspace", () => {
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
global.fetch = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
const mockDb = {} as Parameters<typeof sendWebhooksForWorkspace>[0];
|
||||
|
||||
const mockPayload: WebhookPayload = {
|
||||
event: "card.created",
|
||||
timestamp: "2024-01-15T12:00:00.000Z",
|
||||
data: {
|
||||
card: {
|
||||
id: "card-123",
|
||||
title: "Test Card",
|
||||
listId: "list-456",
|
||||
boardId: "board-789",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it("sends to all active webhooks subscribed to the event", async () => {
|
||||
mockGetActiveByWorkspaceId.mockResolvedValueOnce([
|
||||
{
|
||||
id: 1,
|
||||
publicId: "wh-1",
|
||||
url: "https://example.com/webhook1",
|
||||
secret: "secret1",
|
||||
events: ["card.created", "card.updated"],
|
||||
active: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
publicId: "wh-2",
|
||||
url: "https://example.com/webhook2",
|
||||
secret: null,
|
||||
events: ["card.created"],
|
||||
active: true,
|
||||
},
|
||||
]);
|
||||
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
await sendWebhooksForWorkspace(mockDb, 1, mockPayload);
|
||||
|
||||
// Event filtering now happens at DB level
|
||||
expect(mockGetActiveByWorkspaceId).toHaveBeenCalledWith(
|
||||
mockDb,
|
||||
1,
|
||||
"card.created",
|
||||
);
|
||||
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://example.com/webhook1",
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://example.com/webhook2",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not send when no webhooks match the event (DB-level filtering)", async () => {
|
||||
// DB-level event filter returns empty array when no webhooks match
|
||||
mockGetActiveByWorkspaceId.mockResolvedValueOnce([]);
|
||||
|
||||
await sendWebhooksForWorkspace(mockDb, 1, mockPayload);
|
||||
|
||||
expect(mockGetActiveByWorkspaceId).toHaveBeenCalledWith(
|
||||
mockDb,
|
||||
1,
|
||||
"card.created",
|
||||
);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("continues sending to other webhooks when one fails", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
mockGetActiveByWorkspaceId.mockResolvedValueOnce([
|
||||
{
|
||||
id: 1,
|
||||
publicId: "wh-1",
|
||||
url: "https://example.com/webhook1",
|
||||
secret: null,
|
||||
events: ["card.created"],
|
||||
active: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
publicId: "wh-2",
|
||||
url: "https://example.com/webhook2",
|
||||
secret: null,
|
||||
events: ["card.created"],
|
||||
active: true,
|
||||
},
|
||||
]);
|
||||
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({ ok: false, status: 500, statusText: "Error" })
|
||||
.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
await sendWebhooksForWorkspace(mockDb, 1, mockPayload);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Webhook delivery failed"),
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("handles empty webhook list", async () => {
|
||||
mockGetActiveByWorkspaceId.mockResolvedValueOnce([]);
|
||||
|
||||
await sendWebhooksForWorkspace(mockDb, 1, mockPayload);
|
||||
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("catches and logs DB errors without throwing", async () => {
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => {});
|
||||
mockGetActiveByWorkspaceId.mockRejectedValueOnce(
|
||||
new Error("DB connection failed"),
|
||||
);
|
||||
|
||||
// Should not throw — the try/catch absorbs the error
|
||||
await expect(
|
||||
sendWebhooksForWorkspace(mockDb, 1, mockPayload),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
"Failed to send webhooks for workspace:",
|
||||
expect.any(Error),
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("webhookUrlSchema", () => {
|
||||
it("accepts valid HTTPS URLs", () => {
|
||||
expect(webhookUrlSchema.safeParse("https://example.com/webhook").success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects HTTP URLs", () => {
|
||||
const result = webhookUrlSchema.safeParse("http://example.com/webhook");
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects localhost", () => {
|
||||
const result = webhookUrlSchema.safeParse("https://localhost/webhook");
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects private IPs", () => {
|
||||
expect(webhookUrlSchema.safeParse("https://10.0.0.1/webhook").success).toBe(false);
|
||||
expect(webhookUrlSchema.safeParse("https://192.168.1.1/webhook").success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects cloud metadata endpoints", () => {
|
||||
expect(webhookUrlSchema.safeParse("https://169.254.169.254/latest").success).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,258 +0,0 @@
|
||||
import crypto from "crypto";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { dbClient } from "@kan/db/client";
|
||||
import type { WebhookEvent } from "@kan/db/schema";
|
||||
import * as webhookRepo from "@kan/db/repository/webhook.repo";
|
||||
|
||||
export type WebhookEventType = WebhookEvent;
|
||||
|
||||
export interface WebhookPayload {
|
||||
event: WebhookEventType;
|
||||
timestamp: string;
|
||||
data: {
|
||||
card: {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
dueDate?: string | null; // ISO string after JSON serialization
|
||||
listId: string;
|
||||
boardId: string;
|
||||
};
|
||||
board?: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
list?: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
user?: {
|
||||
id: string;
|
||||
name: string | null;
|
||||
};
|
||||
changes?: Record<string, { from: unknown; to: unknown }>;
|
||||
};
|
||||
}
|
||||
|
||||
function generateSignature(payload: string, secret: string): string {
|
||||
return crypto.createHmac("sha256", secret).update(payload).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Zod schema for webhook URLs with SSRF mitigation.
|
||||
* Requires HTTPS and blocks private/internal IP ranges, localhost,
|
||||
* and cloud metadata endpoints.
|
||||
*/
|
||||
export const webhookUrlSchema = z
|
||||
.string()
|
||||
.url()
|
||||
.max(2048)
|
||||
.refine(
|
||||
(url) => {
|
||||
try {
|
||||
return new URL(url).protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ message: "Only HTTPS URLs are allowed" },
|
||||
)
|
||||
.refine(
|
||||
(url) => {
|
||||
try {
|
||||
const hostname = new URL(url).hostname.toLowerCase();
|
||||
return !(
|
||||
hostname === "localhost" ||
|
||||
hostname === "127.0.0.1" ||
|
||||
hostname === "::1" ||
|
||||
hostname === "0.0.0.0"
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ message: "Localhost URLs are not allowed" },
|
||||
)
|
||||
.refine(
|
||||
(url) => {
|
||||
try {
|
||||
const hostname = new URL(url).hostname.toLowerCase();
|
||||
return !(
|
||||
hostname === "169.254.169.254" ||
|
||||
hostname === "metadata.google.internal"
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ message: "Cloud metadata endpoints are not allowed" },
|
||||
)
|
||||
.refine(
|
||||
(url) => {
|
||||
try {
|
||||
const hostname = new URL(url).hostname.toLowerCase();
|
||||
const ipv4Match = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(hostname);
|
||||
if (ipv4Match) {
|
||||
const [, a, b] = ipv4Match.map(Number);
|
||||
if (
|
||||
a === 10 ||
|
||||
(a === 172 && b! >= 16 && b! <= 31) ||
|
||||
(a === 192 && b === 168)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ message: "Private IP addresses are not allowed" },
|
||||
);
|
||||
|
||||
/**
|
||||
* Send a webhook payload to a specific URL.
|
||||
*
|
||||
* SSRF note: URL validation (HTTPS-only, no private IPs, no cloud metadata)
|
||||
* is enforced both here at delivery time and at webhook creation via
|
||||
* webhookUrlSchema. This function is only reachable by workspace admins.
|
||||
*/
|
||||
export async function sendWebhookToUrl(
|
||||
url: string,
|
||||
secret: string | undefined,
|
||||
payload: WebhookPayload,
|
||||
): Promise<{ success: boolean; statusCode?: number; error?: string }> {
|
||||
const result = webhookUrlSchema.safeParse(url);
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error.issues[0]?.message };
|
||||
}
|
||||
|
||||
const body = JSON.stringify(payload);
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Webhook-Event": payload.event,
|
||||
"X-Webhook-Timestamp": payload.timestamp,
|
||||
};
|
||||
|
||||
if (secret) {
|
||||
headers["X-Webhook-Signature"] = generateSignature(body, secret);
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
statusCode: response.status,
|
||||
error: `${response.status} ${response.statusText}`,
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true, statusCode: response.status };
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
return { success: false, error: "Request timed out" };
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send webhook to all active webhooks for a workspace that are subscribed to the event.
|
||||
* Wrapped in try/catch so callers using fire-and-forget don't risk unhandled rejections.
|
||||
*/
|
||||
export async function sendWebhooksForWorkspace(
|
||||
db: dbClient,
|
||||
workspaceId: number,
|
||||
payload: WebhookPayload,
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Get active webhooks for this workspace
|
||||
const webhooks = await webhookRepo.getActiveByWorkspaceId(db, workspaceId);
|
||||
|
||||
// Filter webhooks that are subscribed to this specific event
|
||||
const webhooksForEvent = webhooks.filter((webhook) =>
|
||||
webhook.events.includes(payload.event),
|
||||
);
|
||||
|
||||
// Send to all subscribed webhooks in parallel (fire and forget)
|
||||
const promises = webhooksForEvent.map((webhook) =>
|
||||
sendWebhookToUrl(webhook.url, webhook.secret ?? undefined, payload).then(
|
||||
(result) => {
|
||||
if (!result.success) {
|
||||
console.error(
|
||||
`Webhook delivery failed to ${webhook.url}: ${result.error}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Wait for all to complete but don't block on failures
|
||||
await Promise.allSettled(promises);
|
||||
} catch (error) {
|
||||
console.error("Failed to send webhooks for workspace:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export function createCardWebhookPayload(
|
||||
event: WebhookEventType,
|
||||
card: {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
dueDate?: Date | null;
|
||||
listId: string;
|
||||
},
|
||||
context: {
|
||||
boardId: string;
|
||||
boardName?: string;
|
||||
listName?: string;
|
||||
user?: {
|
||||
id: string;
|
||||
name: string | null;
|
||||
};
|
||||
changes?: Record<string, { from: unknown; to: unknown }>;
|
||||
},
|
||||
): WebhookPayload {
|
||||
return {
|
||||
event,
|
||||
timestamp: new Date().toISOString(),
|
||||
data: {
|
||||
card: {
|
||||
id: card.id,
|
||||
title: card.title,
|
||||
description: card.description,
|
||||
dueDate: card.dueDate?.toISOString() ?? null,
|
||||
listId: card.listId,
|
||||
boardId: context.boardId,
|
||||
},
|
||||
board: context.boardName
|
||||
? { id: context.boardId, name: context.boardName }
|
||||
: undefined,
|
||||
list: context.listName
|
||||
? { id: card.listId, name: context.listName }
|
||||
: undefined,
|
||||
user: context.user,
|
||||
changes: context.changes,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -21,7 +21,6 @@
|
||||
"dev": "tsc",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
"lint": "eslint",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit --emitDeclarationOnly false"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -33,10 +33,8 @@ export const initAuth = (db: dbClient) => {
|
||||
},
|
||||
emailAndPassword: {
|
||||
enabled: env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true",
|
||||
// Sign-up restriction is handled by the user.create.before database
|
||||
// hook which checks for pending invitations, allowing invited users
|
||||
// to register even when public sign-up is disabled.
|
||||
disableSignUp: false,
|
||||
disableSignUp:
|
||||
env("NEXT_PUBLIC_DISABLE_SIGN_UP")?.toLowerCase() === "true",
|
||||
sendResetPassword: async (data) => {
|
||||
await sendEmail(data.user.email, "Reset Password", "RESET_PASSWORD", {
|
||||
resetPasswordUrl: data.url,
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("next-runtime-env", () => ({
|
||||
env: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@kan/db/repository/member.repo", () => ({
|
||||
getByEmailAndStatus: vi.fn(),
|
||||
getByPublicId: vi.fn(),
|
||||
acceptInvite: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@kan/db/repository/user.repo", () => ({
|
||||
update: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@kan/email", () => ({
|
||||
notificationClient: null,
|
||||
}));
|
||||
|
||||
vi.mock("@kan/shared", () => ({
|
||||
createEmailUnsubscribeLink: vi.fn(),
|
||||
createS3Client: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@aws-sdk/client-s3", () => ({
|
||||
PutObjectCommand: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@novu/api/models/components", () => ({
|
||||
ChatOrPushProviderEnum: { Discord: "discord" },
|
||||
}));
|
||||
|
||||
import { env } from "next-runtime-env";
|
||||
import * as memberRepo from "@kan/db/repository/member.repo";
|
||||
import { createDatabaseHooks } from "./hooks";
|
||||
|
||||
const mockEnv = env as ReturnType<typeof vi.fn>;
|
||||
const mockGetByEmailAndStatus =
|
||||
memberRepo.getByEmailAndStatus as ReturnType<typeof vi.fn>;
|
||||
|
||||
const db = {} as Parameters<typeof createDatabaseHooks>[0];
|
||||
|
||||
const fakeUser = {
|
||||
id: "user-1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
email: "test@example.com",
|
||||
emailVerified: false,
|
||||
name: "Test User",
|
||||
};
|
||||
|
||||
describe("createDatabaseHooks", () => {
|
||||
const hooks = createDatabaseHooks(db);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("user.create.before", () => {
|
||||
it("allows sign-up when DISABLE_SIGN_UP is not set", async () => {
|
||||
mockEnv.mockReturnValue(undefined);
|
||||
|
||||
const result = await hooks.user.create.before(fakeUser, {});
|
||||
expect(result).toBe(true);
|
||||
expect(mockGetByEmailAndStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows sign-up when DISABLE_SIGN_UP is false", async () => {
|
||||
mockEnv.mockImplementation((key: string) =>
|
||||
key === "NEXT_PUBLIC_DISABLE_SIGN_UP" ? "false" : undefined,
|
||||
);
|
||||
|
||||
const result = await hooks.user.create.before(fakeUser, {});
|
||||
expect(result).toBe(true);
|
||||
expect(mockGetByEmailAndStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks sign-up when disabled and user has no pending invitation", async () => {
|
||||
mockEnv.mockImplementation((key: string) =>
|
||||
key === "NEXT_PUBLIC_DISABLE_SIGN_UP" ? "true" : undefined,
|
||||
);
|
||||
mockGetByEmailAndStatus.mockResolvedValue(undefined);
|
||||
|
||||
const result = await hooks.user.create.before(fakeUser, {});
|
||||
expect(result).toBe(false);
|
||||
expect(mockGetByEmailAndStatus).toHaveBeenCalledWith(
|
||||
db,
|
||||
"test@example.com",
|
||||
"invited",
|
||||
);
|
||||
});
|
||||
|
||||
it("allows sign-up when disabled but user has a pending invitation", async () => {
|
||||
mockEnv.mockImplementation((key: string) =>
|
||||
key === "NEXT_PUBLIC_DISABLE_SIGN_UP" ? "true" : undefined,
|
||||
);
|
||||
mockGetByEmailAndStatus.mockResolvedValue({
|
||||
id: "member-1",
|
||||
email: "test@example.com",
|
||||
status: "invited",
|
||||
});
|
||||
|
||||
const result = await hooks.user.create.before(fakeUser, {});
|
||||
expect(result).toBe(true);
|
||||
expect(mockGetByEmailAndStatus).toHaveBeenCalledWith(
|
||||
db,
|
||||
"test@example.com",
|
||||
"invited",
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks sign-up when disabled and invitation exists but domain is not allowed", async () => {
|
||||
mockEnv.mockImplementation((key: string) =>
|
||||
key === "NEXT_PUBLIC_DISABLE_SIGN_UP" ? "true" : undefined,
|
||||
);
|
||||
process.env.BETTER_AUTH_ALLOWED_DOMAINS = "acme.com";
|
||||
mockGetByEmailAndStatus.mockResolvedValue({
|
||||
id: "member-1",
|
||||
email: "test@example.com",
|
||||
status: "invited",
|
||||
});
|
||||
|
||||
const result = await hooks.user.create.before(fakeUser, {});
|
||||
expect(result).toBe(false);
|
||||
|
||||
delete process.env.BETTER_AUTH_ALLOWED_DOMAINS;
|
||||
});
|
||||
|
||||
// The user.create.before hook fires for ALL sign-up paths including
|
||||
// OIDC/social — verify invite bypass works regardless of auth method.
|
||||
it("allows OIDC/social sign-up when disabled but user has a pending invitation", async () => {
|
||||
mockEnv.mockImplementation((key: string) =>
|
||||
key === "NEXT_PUBLIC_DISABLE_SIGN_UP" ? "true" : undefined,
|
||||
);
|
||||
const oidcUser = {
|
||||
...fakeUser,
|
||||
id: "user-oidc",
|
||||
email: "sso@corp.com",
|
||||
image: "https://provider.com/avatar.jpg",
|
||||
};
|
||||
mockGetByEmailAndStatus.mockResolvedValue({
|
||||
id: "member-2",
|
||||
email: "sso@corp.com",
|
||||
status: "invited",
|
||||
});
|
||||
|
||||
const result = await hooks.user.create.before(oidcUser, {});
|
||||
expect(result).toBe(true);
|
||||
expect(mockGetByEmailAndStatus).toHaveBeenCalledWith(
|
||||
db,
|
||||
"sso@corp.com",
|
||||
"invited",
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks OIDC/social sign-up when disabled and user has no pending invitation", async () => {
|
||||
mockEnv.mockImplementation((key: string) =>
|
||||
key === "NEXT_PUBLIC_DISABLE_SIGN_UP" ? "true" : undefined,
|
||||
);
|
||||
const oidcUser = {
|
||||
...fakeUser,
|
||||
id: "user-oidc",
|
||||
email: "random@external.com",
|
||||
image: "https://provider.com/avatar.jpg",
|
||||
};
|
||||
mockGetByEmailAndStatus.mockResolvedValue(undefined);
|
||||
|
||||
const result = await hooks.user.create.before(oidcUser, {});
|
||||
expect(result).toBe(false);
|
||||
expect(mockGetByEmailAndStatus).toHaveBeenCalledWith(
|
||||
db,
|
||||
"random@external.com",
|
||||
"invited",
|
||||
);
|
||||
});
|
||||
|
||||
it("allows sign-up when disabled, invitation exists, and domain is allowed", async () => {
|
||||
mockEnv.mockImplementation((key: string) =>
|
||||
key === "NEXT_PUBLIC_DISABLE_SIGN_UP" ? "true" : undefined,
|
||||
);
|
||||
process.env.BETTER_AUTH_ALLOWED_DOMAINS = "example.com";
|
||||
mockGetByEmailAndStatus.mockResolvedValue({
|
||||
id: "member-1",
|
||||
email: "test@example.com",
|
||||
status: "invited",
|
||||
});
|
||||
|
||||
const result = await hooks.user.create.before(fakeUser, {});
|
||||
expect(result).toBe(true);
|
||||
|
||||
delete process.env.BETTER_AUTH_ALLOWED_DOMAINS;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,8 +7,8 @@ import * as memberRepo from "@kan/db/repository/member.repo";
|
||||
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
||||
import * as userRepo from "@kan/db/repository/user.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import { sendEmail } from "@kan/email";
|
||||
import { generateUID } from "@kan/shared/utils";
|
||||
import { sendEmail } from "@kan/email";
|
||||
import { createStripeClient } from "@kan/stripe";
|
||||
|
||||
import { socialProvidersPlugin } from "./providers";
|
||||
@@ -139,11 +139,10 @@ export function createPlugins(db: dbClient) {
|
||||
let newSlug = workspace.publicId;
|
||||
|
||||
if (workspace.slug !== workspace.publicId) {
|
||||
const isPublicIdAvailable =
|
||||
await workspaceRepo.isWorkspaceSlugAvailable(
|
||||
db,
|
||||
workspace.publicId,
|
||||
);
|
||||
const isPublicIdAvailable = await workspaceRepo.isWorkspaceSlugAvailable(
|
||||
db,
|
||||
workspace.publicId,
|
||||
);
|
||||
if (!isPublicIdAvailable) {
|
||||
newSlug = generateUID();
|
||||
}
|
||||
@@ -173,77 +172,69 @@ export function createPlugins(db: dbClient) {
|
||||
magicLink({
|
||||
expiresIn: 60 * 60 * 24 * 7, // 7 days
|
||||
sendMagicLink: async ({ email, url }) => {
|
||||
try {
|
||||
const decodedUrl = decodeURIComponent(url);
|
||||
console.log("Sending magic link to:", email, "URL:", url);
|
||||
console.log(
|
||||
"Magic link contains invite:",
|
||||
decodedUrl.includes("type=invite"),
|
||||
);
|
||||
if (decodedUrl.includes("type=invite")) {
|
||||
let inviterName = "";
|
||||
let workspaceName = "";
|
||||
const decodedUrl = decodeURIComponent(url);
|
||||
console.log("Sending magic link to:", email, "URL:", url);
|
||||
console.log(
|
||||
"Magic link contains invite:",
|
||||
decodedUrl.includes("type=invite"),
|
||||
);
|
||||
if (decodedUrl.includes("type=invite")) {
|
||||
let inviterName = "";
|
||||
let workspaceName = "";
|
||||
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const callbackUrl = urlObj.searchParams.get("callbackURL");
|
||||
if (callbackUrl) {
|
||||
const callbackParams = new URL(
|
||||
callbackUrl,
|
||||
process.env.NEXT_PUBLIC_BASE_URL,
|
||||
).searchParams;
|
||||
const memberPublicId = callbackParams.get("memberPublicId");
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const callbackUrl = urlObj.searchParams.get("callbackURL");
|
||||
if (callbackUrl) {
|
||||
const callbackParams = new URL(
|
||||
callbackUrl,
|
||||
process.env.NEXT_PUBLIC_BASE_URL,
|
||||
).searchParams;
|
||||
const memberPublicId = callbackParams.get("memberPublicId");
|
||||
|
||||
if (memberPublicId) {
|
||||
const member = await memberRepo.getByPublicId(
|
||||
db,
|
||||
memberPublicId,
|
||||
);
|
||||
if (member) {
|
||||
const [workspace, inviter] = await Promise.all([
|
||||
workspaceRepo.getById(db, member.workspaceId),
|
||||
userRepo.getById(db, member.createdBy),
|
||||
]);
|
||||
if (memberPublicId) {
|
||||
const member = await memberRepo.getByPublicId(
|
||||
db,
|
||||
memberPublicId,
|
||||
);
|
||||
if (member) {
|
||||
const [workspace, inviter] = await Promise.all([
|
||||
workspaceRepo.getById(db, member.workspaceId),
|
||||
userRepo.getById(db, member.createdBy),
|
||||
]);
|
||||
|
||||
if (workspace) workspaceName = workspace.name;
|
||||
if (inviter) inviterName = inviter.name ?? "";
|
||||
}
|
||||
if (workspace) workspaceName = workspace.name;
|
||||
if (inviter) inviterName = inviter.name ?? "";
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch invite details:", error);
|
||||
}
|
||||
|
||||
await sendEmail(
|
||||
email,
|
||||
workspaceName
|
||||
? `Invitation to join the workspace ${workspaceName}`
|
||||
: "Invitation to join workspace",
|
||||
"JOIN_WORKSPACE",
|
||||
{
|
||||
magicLoginUrl: url,
|
||||
inviterName,
|
||||
workspaceName,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
await sendEmail(
|
||||
email,
|
||||
process.env.NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY === "true"
|
||||
? "Sign in to your account"
|
||||
: "Sign in to Kan",
|
||||
"MAGIC_LINK",
|
||||
{
|
||||
magicLoginUrl: url,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch invite details:", error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error sending magic link:", {
|
||||
|
||||
await sendEmail(
|
||||
email,
|
||||
url,
|
||||
error,
|
||||
});
|
||||
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,
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { resolve } from "path";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
root: __dirname,
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@kan/db": resolve(__dirname, "../db/src"),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,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
@@ -187,16 +187,16 @@
|
||||
{
|
||||
"idx": 26,
|
||||
"version": "7",
|
||||
"when": 1771192000000,
|
||||
"tag": "20260129210000_AddWorkspaceWebhooks",
|
||||
"when": 1770521594167,
|
||||
"tag": "20260208033314_AddAttachmentToActivity",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 27,
|
||||
"version": "7",
|
||||
"when": 1772049587901,
|
||||
"tag": "20260225195947_AddIsArchivedToBoard",
|
||||
"when": 1771192000000,
|
||||
"tag": "20260129210000_AddWorkspaceWebhooks",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,7 @@ export const getAllByWorkspaceId = async (
|
||||
db: dbClient,
|
||||
workspaceId: number,
|
||||
userId: string,
|
||||
opts?: { type?: "regular" | "template"; archived?: boolean },
|
||||
opts?: { type?: "regular" | "template" },
|
||||
) => {
|
||||
const boardsData = await db.query.boards.findMany({
|
||||
columns: {
|
||||
@@ -78,7 +78,6 @@ export const getAllByWorkspaceId = async (
|
||||
eq(boards.workspaceId, workspaceId),
|
||||
isNull(boards.deletedAt),
|
||||
opts?.type ? eq(boards.type, opts.type) : undefined,
|
||||
opts?.archived !== undefined ? eq(boards.isArchived, opts.archived) : undefined,
|
||||
),
|
||||
});
|
||||
|
||||
@@ -103,7 +102,6 @@ export const getIdByPublicId = async (db: dbClient, boardPublicId: string) => {
|
||||
columns: {
|
||||
id: true,
|
||||
type: true,
|
||||
isArchived: true,
|
||||
},
|
||||
where: eq(boards.publicId, boardPublicId),
|
||||
});
|
||||
@@ -197,7 +195,6 @@ export const getByPublicId = async (
|
||||
name: true,
|
||||
slug: true,
|
||||
visibility: true,
|
||||
isArchived: true,
|
||||
},
|
||||
with: {
|
||||
userFavorites: {
|
||||
@@ -631,7 +628,6 @@ export const update = async (
|
||||
slug: string | undefined;
|
||||
visibility: BoardVisibilityStatus | undefined;
|
||||
boardPublicId: string;
|
||||
isArchived?: boolean;
|
||||
},
|
||||
) => {
|
||||
const [result] = await db
|
||||
@@ -641,7 +637,6 @@ export const update = async (
|
||||
slug: boardInput.slug,
|
||||
visibility: boardInput.visibility,
|
||||
updatedAt: new Date(),
|
||||
...(boardInput.isArchived !== undefined && { isArchived: boardInput.isArchived })
|
||||
})
|
||||
.where(eq(boards.publicId, boardInput.boardPublicId))
|
||||
.returning({
|
||||
|
||||
@@ -30,22 +30,22 @@ const SYSTEM_ROLES: {
|
||||
description: string;
|
||||
hierarchyLevel: number;
|
||||
}[] = [
|
||||
{
|
||||
name: "admin",
|
||||
description: "Full access to all workspace features",
|
||||
hierarchyLevel: 100,
|
||||
},
|
||||
{
|
||||
name: "member",
|
||||
description: "Standard member with create and edit permissions",
|
||||
hierarchyLevel: 50,
|
||||
},
|
||||
{
|
||||
name: "guest",
|
||||
description: "View-only access",
|
||||
hierarchyLevel: 10,
|
||||
},
|
||||
];
|
||||
{
|
||||
name: "admin",
|
||||
description: "Full access to all workspace features",
|
||||
hierarchyLevel: 100,
|
||||
},
|
||||
{
|
||||
name: "member",
|
||||
description: "Standard member with create and edit permissions",
|
||||
hierarchyLevel: 50,
|
||||
},
|
||||
{
|
||||
name: "guest",
|
||||
description: "View-only access",
|
||||
hierarchyLevel: 10,
|
||||
},
|
||||
];
|
||||
|
||||
export const getCount = async (db: dbClient) => {
|
||||
const result = await db
|
||||
@@ -250,7 +250,7 @@ export const getBySlugWithBoards = (db: dbClient, workspaceSlug: string) => {
|
||||
slug: true,
|
||||
name: true,
|
||||
},
|
||||
where: and(isNull(boards.deletedAt), eq(boards.visibility, "public"), eq(boards.isArchived, false)),
|
||||
where: and(isNull(boards.deletedAt), eq(boards.visibility, "public")),
|
||||
orderBy: [asc(boards.name)]
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ import { relations, sql } from "drizzle-orm";
|
||||
import {
|
||||
bigint,
|
||||
bigserial,
|
||||
boolean,
|
||||
index,
|
||||
pgEnum,
|
||||
pgTable,
|
||||
@@ -11,8 +12,8 @@ import {
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
varchar,
|
||||
boolean,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
import { imports } from "./imports";
|
||||
import { labels } from "./labels";
|
||||
import { lists } from "./lists";
|
||||
@@ -55,11 +56,9 @@ export const boards = pgTable(
|
||||
.references(() => workspaces.id, { onDelete: "cascade" }),
|
||||
visibility: boardVisibilityEnum("visibility").notNull().default("private"),
|
||||
type: boardTypeEnum("type").notNull().default("regular"),
|
||||
isArchived: boolean("isArchived").notNull().default(false),
|
||||
sourceBoardId: bigint("sourceBoardId", { mode: "number" }),
|
||||
},
|
||||
(table) => [
|
||||
index("board_is_archived_idx").on(table.isArchived),
|
||||
index("board_visibility_idx").on(table.visibility),
|
||||
index("board_type_idx").on(table.type),
|
||||
index("board_source_idx").on(table.sourceBoardId),
|
||||
|
||||
143
pnpm-lock.yaml
generated
143
pnpm-lock.yaml
generated
@@ -254,9 +254,6 @@ importers:
|
||||
'@lingui/cli':
|
||||
specifier: ^5.3.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':
|
||||
specifier: ^5.3.2
|
||||
version: 5.4.1(typescript@5.9.2)(webpack@5.101.3)
|
||||
@@ -344,7 +341,7 @@ importers:
|
||||
version: link:../../tooling/typescript
|
||||
eslint:
|
||||
specifier: 'catalog:'
|
||||
version: 9.34.0(jiti@2.6.1)
|
||||
version: 9.34.0(jiti@2.4.2)
|
||||
prettier:
|
||||
specifier: 'catalog:'
|
||||
version: 3.6.2
|
||||
@@ -384,7 +381,7 @@ importers:
|
||||
version: link:../../tooling/typescript
|
||||
eslint:
|
||||
specifier: 'catalog:'
|
||||
version: 9.34.0(jiti@2.6.1)
|
||||
version: 9.34.0(jiti@2.4.2)
|
||||
prettier:
|
||||
specifier: 'catalog:'
|
||||
version: 3.6.2
|
||||
@@ -439,7 +436,7 @@ importers:
|
||||
version: 0.28.1
|
||||
eslint:
|
||||
specifier: 'catalog:'
|
||||
version: 9.34.0(jiti@2.6.1)
|
||||
version: 9.34.0(jiti@2.4.2)
|
||||
prettier:
|
||||
specifier: 'catalog:'
|
||||
version: 3.6.2
|
||||
@@ -476,7 +473,7 @@ importers:
|
||||
version: 6.4.19
|
||||
eslint:
|
||||
specifier: 'catalog:'
|
||||
version: 9.34.0(jiti@2.6.1)
|
||||
version: 9.34.0(jiti@2.4.2)
|
||||
prettier:
|
||||
specifier: 'catalog:'
|
||||
version: 3.6.2
|
||||
@@ -516,7 +513,7 @@ importers:
|
||||
version: link:../../tooling/typescript
|
||||
eslint:
|
||||
specifier: 'catalog:'
|
||||
version: 9.34.0(jiti@2.6.1)
|
||||
version: 9.34.0(jiti@2.4.2)
|
||||
prettier:
|
||||
specifier: 'catalog:'
|
||||
version: 3.6.2
|
||||
@@ -541,7 +538,7 @@ importers:
|
||||
version: link:../../tooling/typescript
|
||||
eslint:
|
||||
specifier: 'catalog:'
|
||||
version: 9.34.0(jiti@2.6.1)
|
||||
version: 9.34.0(jiti@2.4.2)
|
||||
prettier:
|
||||
specifier: 'catalog:'
|
||||
version: 3.6.2
|
||||
@@ -553,28 +550,28 @@ importers:
|
||||
dependencies:
|
||||
'@eslint/compat':
|
||||
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':
|
||||
specifier: ^14.2.15
|
||||
version: 14.2.32
|
||||
eslint-plugin-import:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
'@kan/prettier-config':
|
||||
specifier: workspace:*
|
||||
@@ -587,7 +584,7 @@ importers:
|
||||
version: 8.42.3
|
||||
eslint:
|
||||
specifier: 'catalog:'
|
||||
version: 9.34.0(jiti@2.6.1)
|
||||
version: 9.34.0(jiti@2.4.2)
|
||||
prettier:
|
||||
specifier: 'catalog:'
|
||||
version: 3.6.2
|
||||
@@ -640,7 +637,7 @@ importers:
|
||||
version: link:../typescript
|
||||
eslint:
|
||||
specifier: 'catalog:'
|
||||
version: 9.34.0(jiti@2.6.1)
|
||||
version: 9.34.0(jiti@2.4.2)
|
||||
prettier:
|
||||
specifier: 'catalog:'
|
||||
version: 3.6.2
|
||||
@@ -2676,10 +2673,6 @@ packages:
|
||||
resolution: {integrity: sha512-aDkj/bMSr/mCL8Nr1TS52v0GLCuVa4YqtRz+WvUCFZw/ovVInX0hKq1TClx/bSlhu60FzB/CbclxFMBw8aLVUg==}
|
||||
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':
|
||||
resolution: {integrity: sha512-4FeIh56PH5vziPg2BYo4XYWWOHE4XaY/XR8Jakwn0/qwtLpydWMNVpZOpGWi7nfPZtcLaJLmZKup6UNxEl1Pfw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -2692,10 +2685,6 @@ packages:
|
||||
babel-plugin-macros:
|
||||
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':
|
||||
resolution: {integrity: sha512-IBVq3RRLNEVRzNZcdEw0qpM5NKX4e9wDmvJMorkR2OYrgTbhWx5gDYhXpEZ9yqtuEVhILMdriVNjAAUnDAJibA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -6168,10 +6157,6 @@ packages:
|
||||
resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==}
|
||||
hasBin: true
|
||||
|
||||
jiti@2.6.1:
|
||||
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
||||
hasBin: true
|
||||
|
||||
jose@6.1.2:
|
||||
resolution: {integrity: sha512-MpcPtHLE5EmztuFIqB0vzHAWJPpmN1E6L4oo+kze56LIs3MyXIj9ZHMDxqOvkP38gBR7K1v3jqd4WU2+nrfONQ==}
|
||||
|
||||
@@ -10466,16 +10451,16 @@ snapshots:
|
||||
eslint: 9.34.0(jiti@1.21.7)
|
||||
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:
|
||||
eslint: 9.34.0(jiti@2.6.1)
|
||||
eslint: 9.34.0(jiti@2.4.2)
|
||||
eslint-visitor-keys: 3.4.3
|
||||
|
||||
'@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:
|
||||
eslint: 9.34.0(jiti@2.6.1)
|
||||
eslint: 9.34.0(jiti@2.4.2)
|
||||
|
||||
'@eslint/config-array@0.21.0':
|
||||
dependencies:
|
||||
@@ -10886,16 +10871,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- 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))':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.3
|
||||
@@ -10903,12 +10878,6 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@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)':
|
||||
dependencies:
|
||||
'@lingui/conf': 5.4.1(typescript@5.9.2)
|
||||
@@ -12570,15 +12539,15 @@ snapshots:
|
||||
dependencies:
|
||||
'@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:
|
||||
'@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/type-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.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.4.2))(typescript@5.9.2)
|
||||
'@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
|
||||
ignore: 7.0.5
|
||||
natural-compare: 1.4.0
|
||||
@@ -12587,14 +12556,14 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- 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:
|
||||
'@typescript-eslint/scope-manager': 8.41.0
|
||||
'@typescript-eslint/types': 8.41.0
|
||||
'@typescript-eslint/typescript-estree': 8.41.0(typescript@5.9.2)
|
||||
'@typescript-eslint/visitor-keys': 8.41.0
|
||||
debug: 4.4.3
|
||||
eslint: 9.34.0(jiti@2.6.1)
|
||||
eslint: 9.34.0(jiti@2.4.2)
|
||||
typescript: 5.9.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -12617,13 +12586,13 @@ snapshots:
|
||||
dependencies:
|
||||
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:
|
||||
'@typescript-eslint/types': 8.41.0
|
||||
'@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
|
||||
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)
|
||||
typescript: 5.9.2
|
||||
transitivePeerDependencies:
|
||||
@@ -12647,13 +12616,13 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- 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:
|
||||
'@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/types': 8.41.0
|
||||
'@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
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -13979,17 +13948,17 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- 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:
|
||||
debug: 3.2.7
|
||||
optionalDependencies:
|
||||
'@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-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-import-resolver-node: 0.3.9
|
||||
transitivePeerDependencies:
|
||||
- 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:
|
||||
'@rtsao/scc': 1.1.0
|
||||
array-includes: 3.1.9
|
||||
@@ -13998,9 +13967,9 @@ snapshots:
|
||||
array.prototype.flatmap: 1.3.3
|
||||
debug: 3.2.7
|
||||
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-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
|
||||
is-core-module: 2.16.1
|
||||
is-glob: 4.0.3
|
||||
@@ -14012,13 +13981,13 @@ snapshots:
|
||||
string.prototype.trimend: 1.0.9
|
||||
tsconfig-paths: 3.15.0
|
||||
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:
|
||||
- eslint-import-resolver-typescript
|
||||
- eslint-import-resolver-webpack
|
||||
- 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:
|
||||
aria-query: 5.3.2
|
||||
array-includes: 3.1.9
|
||||
@@ -14028,7 +13997,7 @@ snapshots:
|
||||
axobject-query: 4.1.0
|
||||
damerau-levenshtein: 1.0.8
|
||||
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
|
||||
jsx-ast-utils: 3.3.5
|
||||
language-tags: 1.0.9
|
||||
@@ -14037,11 +14006,11 @@ snapshots:
|
||||
safe-regex-test: 1.1.0
|
||||
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:
|
||||
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:
|
||||
array-includes: 3.1.9
|
||||
array.prototype.findlast: 1.2.5
|
||||
@@ -14049,7 +14018,7 @@ snapshots:
|
||||
array.prototype.tosorted: 1.1.4
|
||||
doctrine: 2.1.0
|
||||
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
|
||||
hasown: 2.0.2
|
||||
jsx-ast-utils: 3.3.5
|
||||
@@ -14063,10 +14032,10 @@ snapshots:
|
||||
string.prototype.matchall: 4.0.12
|
||||
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:
|
||||
dotenv: 16.0.3
|
||||
eslint: 9.34.0(jiti@2.6.1)
|
||||
eslint: 9.34.0(jiti@2.4.2)
|
||||
turbo: 2.5.6
|
||||
|
||||
eslint-scope@5.1.1:
|
||||
@@ -14125,9 +14094,9 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint@9.34.0(jiti@2.6.1):
|
||||
eslint@9.34.0(jiti@2.4.2):
|
||||
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/config-array': 0.21.0
|
||||
'@eslint/config-helpers': 0.3.1
|
||||
@@ -14163,7 +14132,7 @@ snapshots:
|
||||
natural-compare: 1.4.0
|
||||
optionator: 0.9.4
|
||||
optionalDependencies:
|
||||
jiti: 2.6.1
|
||||
jiti: 2.4.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -14954,8 +14923,6 @@ snapshots:
|
||||
|
||||
jiti@2.4.2: {}
|
||||
|
||||
jiti@2.6.1: {}
|
||||
|
||||
jose@6.1.2: {}
|
||||
|
||||
jose@6.1.3: {}
|
||||
@@ -17718,13 +17685,13 @@ snapshots:
|
||||
possible-typed-array-names: 1.1.0
|
||||
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:
|
||||
'@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/parser': 8.41.0(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.4.2))(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)
|
||||
eslint: 9.34.0(jiti@2.6.1)
|
||||
'@typescript-eslint/utils': 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
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
Reference in New Issue
Block a user