Compare commits

...

25 Commits

Author SHA1 Message Date
Henry
641237de83 feat: add url validation to download attatchment endpoint 2026-03-10 21:54:35 +00:00
Henry
a6780e3e28 fix: show correct highlighting in pricing comparison table 2026-03-03 22:31:08 +00:00
hjball
2c5195a2b8 chore: update translations 2026-03-03 22:17:28 +00:00
Henry
793fa30162 fix: adjust icon size in CardPage component 2026-03-03 22:16:09 +00:00
Henry
af63719de5 fix: swap latest to only publish on version tags and add edge for builds (#428) 2026-03-03 22:14:35 +00:00
hjball
52fd624b81 chore: update translations 2026-03-03 21:37:46 +00:00
Henry
b4d4810f45 fix: migration order (#427) 2026-03-03 21:36:31 +00:00
hjball
239d152340 chore: compile translations 2026-03-01 22:41:43 +00:00
hjball
f294760747 chore: update translations 2026-03-01 22:41:39 +00:00
Morfixx
280d8f66dd feat: initial github integration with importing projects (#421)
* feat: initial github integration with importing projects

* fix: remove unused args

* chore: remove duplicate col

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
2026-03-01 22:39:25 +00:00
Henry
eeae23a24c docs: add migrate service to self hosting guide (#425)
* docs: add migrate service to self hosting guide

* chore: remove build from readme compose
2026-02-28 23:08:59 +00:00
Henry
d81950b8bd fix: publish migrate image (#423) 2026-02-27 22:38:01 +00:00
hjball
e530f39360 chore: update translations 2026-02-27 13:16:47 +00:00
Nick Meinhold
bd25fb33f7 feat(api): add webhook delivery utility and card event integration (#392)
* feat(api): add webhook delivery utility and card event integration

Add the core webhook delivery logic and wire it into card mutations:

- Add sendWebhookToUrl() with HMAC-SHA256 signing, 10s timeout
- Add sendWebhooksForWorkspace() for fan-out delivery (fire-and-forget)
- Add createCardWebhookPayload() for building webhook payloads
- Fire webhooks on card create, update, move, and delete events
- Add unit tests for webhook utility functions

Depends on #391 (DB schema & repository).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(api): use correct boardId in webhook payloads and add rejection safety

- Fix bug where workspaceId was incorrectly passed as boardId in all
  webhook payloads — now uses board's publicId via boardPublicId
- Replace void sendWebhooksForWorkspace() with .catch() to prevent
  unhandled promise rejections if the DB query inside fails

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(api): add SSRF protection to webhook delivery

Block webhook URLs targeting internal networks:
- Require HTTPS (reject HTTP)
- Block localhost, 127.0.0.1, ::1, 0.0.0.0
- Block cloud metadata endpoints (169.254.169.254, metadata.google.internal)
- Block private IP ranges (10.x, 172.16-31.x, 192.168.x)
- Add tests for all blocked URL patterns

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(api): use WebhookEvent type from schema instead of duplicating

Replace the hardcoded WebhookEventType union with the canonical
WebhookEvent type from @kan/db/schema, addressing reviewer feedback
on PR #392.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(api): improve webhook delivery safety and validation

Cherry-pick delivery-related changes from b2cc9ac:
- Extract URL validation into reusable webhookUrlSchema zod validator
  for SSRF checks
- Wrap sendWebhooksForWorkspace in try/catch to prevent unhandled
  promise rejections
- Document SSRF risk mitigation on sendWebhookToUrl
- Add corresponding tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 13:15:35 +00:00
hjball
93f2816b37 chore: compile translations 2026-02-26 21:41:09 +00:00
hjball
2292b972c4 chore: update translations 2026-02-26 21:41:05 +00:00
Eliott Herbert-Byrnes
af36fb133a feat: archived boards (#386)
* init: schema migration, isArchived added to
board table. refactor: board repo for isArchived filtering

* init: archived, unarchived API procedures. refactor: all query

* fix: migration error

* feat: add tabbed navigation for boards view

* Implemented a Listbox for mobile and a tabbed navigation for desktop to switch between "Boards" and "Archived" views.
* Introduced state management for active tab selection.
* Updated UI components to reflect the new navigation structure.

* init: frontend/boards lists & tabs

* chore: fixed font styling and spacing

* init:boardDropdown / boardView.

* chore:added translations

* Remove .cursor plan file from repo

* fix:build erros

* revert: remove locales changes

* fix:reverted changes under locales, replaced the archive and unarchive endpoints. Reorder migrations

* fix:migration issue

* fix: update journal.json

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
2026-02-26 21:39:15 +00:00
hjball
9cb1fb5218 chore: update translations 2026-02-26 21:26:17 +00:00
Nick Meinhold
5f190b92de fix: allow invited users to sign up when registration is disabled (#418)
* fix: allow invited users to sign up when registration is disabled

Move sign-up restriction logic from better-auth's disableSignUp config
to the existing user.create.before database hook, which already checks
for pending invitations. The frontend signup and login pages now detect
invite flows (?next=/invite/...) and bypass the disabled UI accordingly.

Closes #411

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: add regression tests for sign-up hook invite bypass

Verify that the user.create.before database hook correctly:
- allows sign-up when registration is not disabled
- blocks sign-up when disabled and no invitation exists
- allows sign-up when disabled but a pending invitation exists
- respects BETTER_AUTH_ALLOWED_DOMAINS in combination with invites

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: add OIDC/social sign-up path coverage for invite bypass

Address review suggestion: add explicit tests verifying the
user.create.before hook handles OIDC/social sign-ups the same way as
email/password — invited users are allowed, uninvited users are blocked.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 21:24:58 +00:00
Nick Meinhold
48a1f39588 fix(api): add missing openapi meta to checklist.update (#417)
The checklist.update procedure was inheriting the default
protectedProcedure meta (GET /protected) instead of declaring
its own OpenAPI route, making it unreachable via the REST API.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 21:12:02 +00:00
hjball
c7fce10fa7 chore: update translations 2026-02-26 21:05:45 +00:00
Matej
094e1d139d fix: make links clickable in comments (#376) (#415)
Move Link extension before Markdown in the TipTap extensions array
to ensure URL detection happens before markdown processing. Also
explicitly enable linkOnPaste for paste detection.

Fixes #376

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-26 21:04:31 +00:00
Henry
fae169c691 fix: ensure deps are installed during translate workflow 2026-02-23 23:11:52 +00:00
Henry
91bc319cf1 fix: prevent cross-workspace access on member and permission endpoints (#413) 2026-02-23 22:56:36 +00:00
Henry
d42540b9ef chore: update translations to json (#408)
* chore: update translations format

* chore: compile to ts
2026-02-20 21:38:01 +00:00
75 changed files with 73457 additions and 29763 deletions

View File

@@ -6,8 +6,6 @@ name: Docker
# documentation.
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
@@ -26,6 +24,7 @@ env:
REGISTRY: ghcr.io
# github.repository as <account>/<repo>
IMAGE_NAME: ${{ github.repository }}
MIGRATE_IMAGE_NAME: ${{ github.repository }}-migrate
jobs:
build:
@@ -83,7 +82,22 @@ jobs:
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=latest,enable=${{ github.ref_type == 'tag' }}
type=raw,value=edge,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=${{ github.ref_type == 'tag' }}
type=raw,value=edge,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
@@ -138,6 +152,22 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Build and push migrate Docker image
id: build-and-push-migrate
uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0
with:
context: .
file: apps/web/Dockerfile
target: migrate
push: ${{ github.event_name != 'pull_request' }}
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta-migrate.outputs.tags }}
labels: ${{ steps.meta-migrate.outputs.labels }}
build-args: |
APP_VERSION=${{ steps.version.outputs.version }}
cache-from: type=gha
cache-to: type=gha,mode=max
# Sign the resulting Docker image digest except on PRs.
# This will only write to the public Rekor transparency log when the Docker
# repository is public to avoid leaking data. If you would like to publish
@@ -152,3 +182,10 @@ jobs:
# This step uses the identity token to provision an ephemeral certificate
# against the sigstore community Fulcio instance.
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
- name: Sign the published migrate Docker image
if: ${{ github.event_name != 'pull_request' }}
env:
TAGS: ${{ steps.meta-migrate.outputs.tags }}
DIGEST: ${{ steps.build-and-push-migrate.outputs.digest }}
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}

View File

@@ -14,16 +14,24 @@ jobs:
- 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: npx --yes @lingui/cli extract
run: pnpm lingui:extract
- name: Lingo.dev
uses: lingodotdev/lingo.dev@main
with:
@@ -33,11 +41,11 @@ jobs:
commit-author-name: "${{ github.actor }}"
commit-author-email: "${{ github.actor }}@users.noreply.github.com"
parallel: true
- name: Compile translations
working-directory: apps/web
run: npx --yes @lingui/cli compile --typescript
run: pnpm lingui:compile
- name: Commit compiled translations
run: |
git config --local user.email "${{ github.actor }}@users.noreply.github.com"
@@ -46,4 +54,3 @@ jobs:
git add apps/web/src/locales/*/messages.ts
git diff --staged --quiet || (git commit -m "chore: compile translations" && git push)
fi

View File

@@ -57,39 +57,61 @@ 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.
Alternatively, you can self-host Kan with Docker Compose. This will set up everything for you including your postgres database and automatically run migrations.
1. Create a new file called `docker-compose.yml` and paste the following configuration:
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:
```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:
- "3000:3000"
- "${WEB_PORT:-3000}:3000"
networks:
- kan-network
env_file:
- .env
environment:
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
- NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL}
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
- POSTGRES_URL=${POSTGRES_URL}
- NEXT_PUBLIC_ALLOW_CREDENTIALS=true
depends_on:
- postgres
migrate:
condition: service_completed_successfully
restart: unless-stopped
postgres:
image: postgres:15
container_name: kan-db
environment:
POSTGRES_DB: kan_db
POSTGRES_USER: kan
POSTGRES_PASSWORD: your_postgres_password
- POSTGRES_DB=kan_db
- POSTGRES_USER=kan
- POSTGRES_PASSWORD=${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
@@ -101,23 +123,23 @@ volumes:
kan_postgres_data:
```
2. Start the containers in detached mode:
3. Start the containers in detached mode:
```bash
docker compose up -d
```
3. Access Kan at http://localhost:3000
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`).
The application will be running in the background. You can manage the containers using these commands:
**Managing containers:**
- 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, 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.
For the complete Docker Compose configuration with all optional features, see [docker-compose.yml](./docker-compose.yml) in the repository.
## Local Development 🧑‍💻

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -79,6 +79,7 @@
"@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",

View File

@@ -463,13 +463,6 @@ 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: {
@@ -479,6 +472,14 @@ 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),

View File

@@ -19,6 +19,7 @@ interface UsePermissionsResult {
canCreateBoard: boolean;
canEditBoard: boolean;
canDeleteBoard: boolean;
canArchiveBoard: boolean;
canViewComment: boolean;
canCreateComment: boolean;
canEditComment: boolean;
@@ -33,7 +34,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 = {
@@ -51,6 +52,7 @@ export function usePermissions(): UsePermissionsResult {
canCreateBoard: false,
canEditBoard: false,
canDeleteBoard: false,
canArchiveBoard: false,
canViewComment: false,
canCreateComment: false,
canEditComment: false,
@@ -95,6 +97,7 @@ 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

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -2,48 +2,74 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { withRateLimit } from "@kan/api/utils/rateLimit";
import { env } from "~/env";
export default withRateLimit(
{ points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "GET") {
return res.status(405).json({ message: "Method not allowed" });
}
const { url, filename } = req.query;
if (!url || typeof url !== "string") {
return res.status(400).json({
message: "url parameter is required",
});
}
try {
const downloadFilename = typeof filename === "string"
? encodeURIComponent(filename)
: "attachment";
const upstream = await fetch(url);
if (!upstream.ok) {
return res.status(upstream.status).json({
message: "Failed to fetch attachment",
});
if (req.method !== "GET") {
return res.status(405).json({ message: "Method not allowed" });
}
const contentType =
upstream.headers.get("Content-Type") ?? "application/octet-stream";
const { url, filename } = req.query;
res.setHeader("Content-Type", contentType);
res.setHeader(
"Content-Disposition",
`attachment; filename="${downloadFilename}"; filename*=UTF-8''${downloadFilename}`,
);
if (!url || typeof url !== "string") {
return res.status(400).json({ message: "url parameter is required" });
}
const buffer = await upstream.arrayBuffer();
return res.send(Buffer.from(buffer));
} catch (error) {
console.error("Error downloading attachment:", error);
return res.status(500).json({ message: "Failed to download attachment" });
}
const s3Endpoint = env.S3_ENDPOINT;
if (s3Endpoint) {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return res.status(400).json({ message: "Invalid URL" });
}
const hostname = parsed.hostname.toLowerCase();
let allowedHost: string;
try {
allowedHost = new URL(s3Endpoint).hostname.toLowerCase();
} catch {
return res.status(500).json({ message: "Storage endpoint misconfigured" });
}
if (hostname !== allowedHost && !hostname.endsWith(`.${allowedHost}`)) {
return res.status(403).json({ message: "URL not allowed" });
}
}
try {
const downloadFilename =
typeof filename === "string"
? encodeURIComponent(filename)
: "attachment";
const upstream = await fetch(url);
if (!upstream.ok) {
return res
.status(upstream.status)
.json({ message: "Failed to fetch attachment" });
}
const contentType =
upstream.headers.get("Content-Type") ?? "application/octet-stream";
res.setHeader("Content-Type", contentType);
res.setHeader(
"Content-Disposition",
`attachment; filename="${downloadFilename}"; filename*=UTF-8''${downloadFilename}`,
);
const buffer = await upstream.arrayBuffer();
return res.send(Buffer.from(buffer));
} catch (error) {
console.error("Error downloading attachment:", error);
return res
.status(500)
.json({ message: "Failed to download attachment" });
}
},
);

View File

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

View File

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

View File

@@ -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,6 +17,7 @@ import { api } from "~/utils/api";
export default function BoardDropdown({
isTemplate,
isLoading,
isArchived,
boardPublicId,
isFavorite,
boardName,
@@ -24,28 +25,29 @@ 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();
// Show popup notification
if (variables.favorite !== undefined) {
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) {
showPopup({
header: variables.favorite
? t`Added to favorites`
@@ -65,27 +67,54 @@ 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" />,
},
]
{
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: isFavorite
@@ -100,22 +129,26 @@ 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} items={items}>
<Dropdown
disabled={isLoading || isArchiveActionPending}
items={items}
>
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
</Dropdown>
);

View File

@@ -494,7 +494,7 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
isTemplate={!!isTemplate}
isLoading={!boardData}
boardPublicId={boardId ?? ""}
workspacePublicId={workspace.publicId}
isArchived={boardData?.isArchived ?? false}
isFavorite={boardData?.favorite}
boardName={boardData?.name}
/>
@@ -598,13 +598,12 @@ 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}

View File

@@ -10,7 +10,7 @@ import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
export function BoardsList({ isTemplate, archived = false }: { isTemplate?: boolean; archived?: boolean }) {
const { workspace } = useWorkspace();
const { openModal } = useModal();
const { canCreateBoard } = usePermissions();
@@ -26,6 +26,7 @@ export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
{
workspacePublicId: workspace.publicId,
type: isTemplate ? "template" : "regular",
archived: archived,
},
{ enabled: workspace.publicId ? true : false },
);
@@ -59,10 +60,10 @@ export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
<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">
{t`No ${isTemplate ? "templates" : "boards"}`}
{archived ? t`No archived boards` : t`No ${isTemplate ? "templates" : "boards"}`}
</p>
<p className="text-[14px] text-light-900 dark:text-dark-900">
{t`Get started by creating a new ${isTemplate ? "template" : "board"}`}
{archived ? t`Boards you archive will appear here.` : t`Get started by creating a new ${isTemplate ? "template" : "board"}`}
</p>
</div>
<Tooltip
@@ -109,18 +110,18 @@ export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
>
<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>

View File

@@ -4,7 +4,7 @@ import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro";
import { Fragment, useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { FaTrello } from "react-icons/fa";
import { FaGithub, FaTrello } from "react-icons/fa";
import {
HiChevronUpDown,
HiMiniArrowTopRightOnSquare,
@@ -27,12 +27,23 @@ const integrationProviders: Record<
name: "Trello",
icon: <FaTrello />,
},
github: {
name: "GitHub",
icon: <FaGithub />,
},
};
const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
const SelectSource = ({
handleNextStep,
}: {
handleNextStep: (provider: string) => void;
}) => {
const { data: integrations, refetch: refetchIntegrations } =
api.integration.providers.useQuery();
const { control, handleSubmit } = useForm({
const { data: githubStatus, refetch: refetchGithubStatus } =
api.integration.getGitHubStatus.useQuery();
const { control, handleSubmit, watch } = useForm({
defaultValues: {
source: integrations?.[0]?.provider ?? "trello",
},
@@ -47,23 +58,36 @@ const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
},
);
const hasIntegrations = integrations && integrations.length > 0;
const availableIntegrations = [
...(integrations ?? []),
...(githubStatus?.connected ? [{ provider: "github" }] : []),
];
const hasIntegrations = availableIntegrations.length > 0;
useEffect(() => {
const handleFocus = () => {
refetchIntegrations();
void refetchIntegrations();
void refetchGithubStatus();
};
window.addEventListener("focus", handleFocus);
return () => {
window.removeEventListener("focus", handleFocus);
};
}, [refetchIntegrations]);
}, [refetchIntegrations, refetchGithubStatus]);
const onSubmit = () => {
if (!hasIntegrations && trelloUrl) {
window.open(trelloUrl.url, "trello_auth", "height=800,width=600");
const selected = watch("source");
if (
selected === "trello" &&
!integrations?.some((i) => i.provider === "trello")
) {
if (trelloUrl)
window.open(trelloUrl.url, "trello_auth", "height=800,width=600");
} else if (selected === "github" && !githubStatus?.connected) {
window.open("/settings/integrations", "_blank");
} else {
handleNextStep();
handleNextStep(selected);
}
};
@@ -102,7 +126,7 @@ const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
>
<Listbox.Options className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-light-50 py-1 text-base text-neutral-900 shadow-lg ring-1 ring-light-600 ring-opacity-5 focus:outline-none dark:bg-dark-300 dark:text-dark-1000 sm:text-sm">
{hasIntegrations ? (
integrations.map((integration, index) => (
availableIntegrations.map((integration, index) => (
<Listbox.Option
key={`source_${index}`}
className="relative cursor-default select-none px-1"
@@ -123,18 +147,32 @@ const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
</Listbox.Option>
))
) : (
<Listbox.Option
key="trello_placeholder"
className="font-sm relative cursor-default select-none px-1"
value="trello"
>
<div className="flex items-center rounded-[5px] p-1 text-sm hover:bg-light-200 dark:hover:bg-dark-400">
{integrationProviders.trello?.icon}
<span className="ml-2 block truncate text-sm">
{integrationProviders.trello?.name}
</span>
</div>
</Listbox.Option>
<>
<Listbox.Option
key="trello_placeholder"
className="font-sm relative cursor-default select-none px-1"
value="trello"
>
<div className="flex items-center rounded-[5px] p-1 text-sm hover:bg-light-200 dark:hover:bg-dark-400">
{integrationProviders.trello?.icon}
<span className="ml-2 block truncate text-sm">
{integrationProviders.trello?.name}
</span>
</div>
</Listbox.Option>
<Listbox.Option
key="github_placeholder"
className="font-sm relative cursor-default select-none px-1"
value="github"
>
<div className="flex items-center rounded-[5px] p-1 text-sm hover:bg-light-200 dark:hover:bg-dark-400">
{integrationProviders.github?.icon}
<span className="ml-2 block truncate text-sm">
{integrationProviders.github?.name}
</span>
</div>
</Listbox.Option>
</>
)}
</Listbox.Options>
</Transition>
@@ -154,7 +192,159 @@ const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
!hasIntegrations ? <HiMiniArrowTopRightOnSquare /> : undefined
}
>
{hasIntegrations ? t`Select source` : t`Connect Trello`}
{hasIntegrations ? t`Select source` : t`Connect`}
</Button>
</div>
</div>
</form>
);
};
const ImportGithub: React.FC = () => {
const utils = api.useUtils();
const { closeModal } = useModal();
const { workspace } = useWorkspace();
const { showPopup } = usePopup();
const [isSelectAllEnabled, setIsSelectAllEnabled] = useState(false);
const refetchBoards = () => utils.board.all.refetch();
const { data: projects, isLoading: projectsLoading } =
api.import.github.getProjects.useQuery();
const {
register: registerProjects,
handleSubmit: handleSubmitProjects,
setValue,
watch,
} = useForm({
defaultValues: Object.fromEntries(
projects?.map((project) => [project.id, true]) ?? [],
),
});
const importProjects = api.import.github.importProjects.useMutation({
onSuccess: async () => {
showPopup({
header: t`Import complete`,
message: t`Your projects have been imported.`,
icon: "success",
});
try {
await refetchBoards();
closeModal();
} catch (e) {
console.log(e);
}
},
onError: () => {
showPopup({
header: t`Import failed`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
});
const projectWatchers = projects?.map((project) => ({
id: project.id,
value: watch(project.id),
}));
const projectCount =
projectWatchers?.filter((w) => w.value === true).length ?? 0;
const onSubmitProjects = (values: Record<string, boolean>) => {
const projectIds = Object.keys(values).filter(
(key) => values[key] === true,
);
importProjects.mutate({
projectIds,
workspacePublicId: workspace.publicId,
});
};
const renderContent = () => {
if (projectsLoading) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-1">
<div className="h-[30px] w-full animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-300" />
<div className="h-[30px] w-full animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-300" />
<div className="h-[30px] w-full animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-300" />
</div>
);
}
if (!projects?.length) {
return (
<div className="flex h-full w-full items-center justify-center">
<p className="text-sm text-neutral-500 dark:text-dark-900">
{t`No projects found`}
</p>
</div>
);
}
return projects.map((project) => (
<div key={project.id}>
<label
className="flex cursor-pointer items-center rounded-[5px] p-2 hover:bg-light-100 dark:hover:bg-dark-300"
htmlFor={project.id}
>
<input
id={project.id}
type="checkbox"
className="h-[14px] w-[14px] rounded bg-transparent ring-0 focus:outline-none focus:ring-0 focus:ring-offset-0"
{...registerProjects(project.id)}
/>
<span className="ml-3 text-sm text-neutral-900 dark:text-dark-1000">
{project.name}
</span>
</label>
</div>
));
};
return (
<form onSubmit={handleSubmitProjects(onSubmitProjects)}>
<div className="h-[105px] overflow-auto px-5">{renderContent()}</div>
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<Toggle
label={t`Select all`}
isChecked={!!isSelectAllEnabled}
onChange={() => {
const newState = !isSelectAllEnabled;
setIsSelectAllEnabled(newState);
for (const project of projects ?? []) {
setValue(project.id, newState);
}
}}
/>
<div className="space-x-2">
<Button
type="submit"
isLoading={importProjects.isPending}
disabled={
importProjects.isPending ||
projectsLoading ||
!projects?.length ||
!projects.some(
(project) =>
projectWatchers?.find((w) => w.id === project.id)?.value ===
true,
)
}
>
<Trans>
<Plural
value={projectCount}
one={`Import project (1)`}
other={`Import projects (${projectCount})`}
/>
</Trans>
</Button>
</div>
</div>
@@ -213,7 +403,7 @@ const ImportTrello: React.FC = () => {
value: watch(board.id),
}));
const boardCount = boardWatchers?.filter((w) => w.value === true).length || 0;
const boardCount = boardWatchers?.filter((w) => w.value === true).length ?? 0;
const onSubmitBoards = (values: Record<string, boolean>) => {
const boardIds = Object.keys(values).filter((key) => values[key] === true);
@@ -267,7 +457,7 @@ const ImportTrello: React.FC = () => {
return (
<form onSubmit={handleSubmitBoards(onSubmitBoards)}>
<div className="h-[105px] overflow-scroll px-5">{renderContent()}</div>
<div className="h-[105px] overflow-auto px-5">{renderContent()}</div>
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<Toggle
@@ -277,7 +467,7 @@ const ImportTrello: React.FC = () => {
const newState = !isSelectAllEnabled;
setIsSelectAllEnabled(newState);
for (const board of boards || []) {
for (const board of boards ?? []) {
setValue(board.id, newState);
}
}}
@@ -313,6 +503,7 @@ const ImportTrello: React.FC = () => {
export function ImportBoardsForm() {
const { closeModal } = useModal();
const [step, setStep] = useState(1);
const [provider, setProvider] = useState<string | null>(null);
return (
<div>
@@ -339,8 +530,16 @@ export function ImportBoardsForm() {
</button>
</div>
{step === 1 && <SelectSource handleNextStep={() => setStep(step + 1)} />}
{step === 2 && <ImportTrello />}
{step === 1 && (
<SelectSource
handleNextStep={(p) => {
setProvider(p);
setStep(step + 1);
}}
/>
)}
{step === 2 && provider === "trello" && <ImportTrello />}
{step === 2 && provider === "github" && <ImportGithub />}
</div>
);
}

View File

@@ -1,5 +1,12 @@
import {
Listbox,
ListboxButton,
ListboxOption,
ListboxOptions,
} from "@headlessui/react";
import { t } from "@lingui/core/macro";
import { HiArrowDownTray, HiOutlinePlusSmall } from "react-icons/hi2";
import { HiArrowDownTray, HiChevronDown, HiOutlinePlusSmall } from "react-icons/hi2";
import { useState } from "react";
import Button from "~/components/Button";
import FeedbackModal from "~/components/FeedbackModal";
@@ -15,9 +22,15 @@ 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 } =
@@ -34,7 +47,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-6 px-5 md:px-28 md:py-12">
<div className="m-auto h-full max-w-[1100px] p-8 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"}`}
@@ -115,9 +128,79 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
</Modal>
</>
<div className="flex h-full flex-row">
<BoardsList isTemplate={!!isTemplate} />
</div>
{!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>
</>
);

View File

@@ -3,8 +3,8 @@ import { useRouter } from "next/router";
import { t } from "@lingui/core/macro";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { IoChevronForwardSharp } from "react-icons/io5";
import { HiXMark } from "react-icons/hi2";
import { IoChevronForwardSharp } from "react-icons/io5";
import { authClient } from "@kan/auth/client";
@@ -302,7 +302,6 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
if (!cardId) return <></>;
return (
<>
<PageHead
@@ -345,7 +344,7 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
className="flex h-7 w-7 items-center justify-center rounded-[5px] text-light-900 hover:bg-light-200 dark:text-dark-900 dark:hover:bg-dark-200"
aria-label={t`Close`}
>
<HiXMark className="h-5 w-5" />
<HiXMark className="h-4 w-4" />
</Link>
</div>
</>

View File

@@ -8,10 +8,10 @@ type FrequencyValue = "monthly" | "annually";
interface PlanFeature {
key: string;
label: string;
free: string | boolean | { text: string; highlight?: string };
teams: string | boolean | { text: string; highlight?: string };
pro: string | boolean | { text: string; highlight?: string };
enterprise: string | boolean | { text: string; highlight?: string };
free: string | boolean | { text: string; highlight?: boolean; highlightFirstWord?: boolean };
teams: string | boolean | { text: string; highlight?: boolean; highlightFirstWord?: boolean };
pro: string | boolean | { text: string; highlight?: boolean; highlightFirstWord?: boolean };
enterprise: string | boolean | { text: string; highlight?: boolean; highlightFirstWord?: boolean };
}
interface FeatureSection {
@@ -71,34 +71,34 @@ const FeatureComparisonTable = ({
{
key: "boards",
label: t`Boards`,
free: { text: t`Unlimited boards`, highlight: "Unlimited" },
teams: { text: t`Unlimited boards`, highlight: "Unlimited" },
pro: { text: t`Unlimited boards`, highlight: "Unlimited" },
enterprise: { text: t`Unlimited boards`, highlight: "Unlimited" },
free: { text: t`Unlimited boards`, highlightFirstWord: true },
teams: { text: t`Unlimited boards`, highlightFirstWord: true },
pro: { text: t`Unlimited boards`, highlightFirstWord: true },
enterprise: { text: t`Unlimited boards`, highlightFirstWord: true },
},
{
key: "members",
label: t`Members`,
free: { text: t`1 user`, highlight: "1" },
teams: { text: t`Per-seat pricing`, highlight: "Per-seat" },
pro: { text: t`Unlimited members`, highlight: "Unlimited" },
enterprise: { text: t`Unlimited members`, highlight: "Unlimited" },
free: { text: t`1 user`, highlightFirstWord: true },
teams: { text: t`Per-seat pricing`, highlightFirstWord: true },
pro: { text: t`Unlimited members`, highlightFirstWord: true },
enterprise: { text: t`Unlimited members`, highlightFirstWord: true },
},
{
key: "file-uploads",
label: t`File uploads`,
free: { text: t`10mb file uploads`, highlight: "10mb" },
teams: { text: t`Unlimited file uploads`, highlight: "Unlimited" },
pro: { text: t`Unlimited file uploads`, highlight: "Unlimited" },
enterprise: { text: t`Unlimited file uploads`, highlight: "Unlimited" },
free: { text: t`10mb file uploads`, highlightFirstWord: true },
teams: { text: t`Unlimited file uploads`, highlightFirstWord: true },
pro: { text: t`Unlimited file uploads`, highlightFirstWord: true },
enterprise: { text: t`Unlimited file uploads`, highlightFirstWord: true },
},
{
key: "workspace-username",
label: t`Workspace username`,
free: { text: t`Default username`, highlight: "Default" },
teams: { text: t`Default username`, highlight: "Default" },
pro: { text: t`Custom username`, highlight: "Custom" },
enterprise: { text: t`Custom username`, highlight: "Custom" },
free: { text: t`Default username`, highlightFirstWord: true },
teams: { text: t`Default username`, highlightFirstWord: true },
pro: { text: t`Custom username`, highlightFirstWord: true },
enterprise: { text: t`Custom username`, highlightFirstWord: true },
},
];
@@ -236,9 +236,9 @@ const FeatureComparisonTable = ({
key: "rest-api",
label: t`API`,
free: { text: t`Limited API access` },
teams: { text: t`Full API access` },
pro: { text: t`Full API access` },
enterprise: { text: t`Full API access` },
teams: { text: t`Full API access`, highlight: true },
pro: { text: t`Full API access`, highlight: true },
enterprise: { text: t`Full API access`, highlight: true },
},
{
key: "self-hostable",
@@ -254,18 +254,18 @@ const FeatureComparisonTable = ({
{
key: "sso",
label: t`SSO`,
free: { text: t`Google SSO` },
teams: { text: t`Google SSO` },
pro: { text: t`Google SSO` },
enterprise: { text: t`Google SSO + SAML` },
free: { text: t`Google SSO`, highlight: true },
teams: { text: t`Google SSO`, highlight: true },
pro: { text: t`Google SSO`, highlight: true },
enterprise: { text: t`Google SSO + SAML`, highlight: true },
},
{
key: "admin-roles",
label: t`Admin roles`,
free: { text: t`Admin roles` },
teams: { text: t`Admin roles` },
pro: { text: t`Admin roles` },
enterprise: { text: t`Advanced admin roles` },
free: { text: t`Admin roles`, highlight: true },
teams: { text: t`Admin roles`, highlight: true },
pro: { text: t`Admin roles`, highlight: true },
enterprise: { text: t`Advanced admin roles`, highlight: true },
},
];
@@ -334,59 +334,37 @@ const FeatureComparisonTable = ({
{ id: "enterprise", name: t`Enterprise`, tierId: "tier-enterprise" },
];
const isHighlightValue = (
value: string | boolean | { text: string; highlight?: string },
): value is { text: string; highlight?: string } => {
return typeof value === "object" && value !== null && "text" in value;
};
const shouldHighlight = (text: string): boolean => {
const lowerText = text.toLowerCase();
return ["unlimited", "multiple", "per-seat", "custom", "mentions", "full", "sso", "admin"].some(
(keyword) => lowerText.includes(keyword),
);
};
const CellValue = ({
value,
label,
}: {
value: string | boolean | { text: string; highlight?: string };
value: string | boolean | { text: string; highlight?: boolean; highlightFirstWord?: boolean };
label: string;
}) => {
// Handle object with text and highlight
if (isHighlightValue(value)) {
const { text, highlight } = value;
const isUnlimited = shouldHighlight(text);
let displayText;
if (highlight) {
const parts = text.split(new RegExp(`(${highlight})`, "gi"));
displayText = (
<>
{parts.map((part, index) =>
part.toLowerCase() === highlight.toLowerCase() ? (
<span key={index} className="text-light-1000 dark:text-dark-1000">
{part}
</span>
) : (
<span key={index} className="text-light-950 dark:text-dark-800">
{part}
</span>
),
)}
</>
if (typeof value === "object" && value !== null) {
const { text, highlight, highlightFirstWord } = value;
if (highlightFirstWord) {
const spaceIndex = text.indexOf(" ");
const firstWord = spaceIndex === -1 ? text : text.slice(0, spaceIndex);
const rest = spaceIndex === -1 ? "" : text.slice(spaceIndex);
return (
<div className="flex items-center gap-2.5">
<HiCheckCircle className="mt-0.5 h-4 w-4 shrink-0 text-light-1000 dark:text-dark-1000" />
<span className="text-sm font-medium">
<span className="text-light-1000 dark:text-dark-1000">{firstWord}</span>
<span className="text-light-950 dark:text-dark-800">{rest}</span>
</span>
</div>
);
} else {
displayText = text;
}
return (
<div className="flex items-center gap-2.5">
<HiCheckCircle
className={twMerge(
"mt-0.5 h-4 w-4 shrink-0",
isUnlimited
highlight
? "text-light-1000 dark:text-dark-1000"
: "text-light-400 dark:text-dark-600",
)}
@@ -394,44 +372,18 @@ const FeatureComparisonTable = ({
<span
className={twMerge(
"text-sm font-medium",
isUnlimited
highlight
? "text-light-1000 dark:text-dark-950"
: "text-light-950 dark:text-dark-800",
)}
>
{displayText}
</span>
</div>
);
}
if (typeof value === "string") {
const isUnlimited = shouldHighlight(value);
return (
<div className="flex items-center gap-2.5">
<HiCheckCircle
className={twMerge(
"mt-0.5 h-4 w-4 shrink-0",
isUnlimited
? "text-light-1000 dark:text-dark-1000"
: "text-light-400 dark:text-dark-600",
)}
/>
<span
className={twMerge(
"text-sm font-medium",
isUnlimited
? "text-light-1000 dark:text-dark-950"
: "text-light-950 dark:text-dark-800",
)}
>
{value}
{text}
</span>
</div>
);
}
if (value) {
// Boolean true - show checkmark icon
return (
<div className="flex items-center gap-2.5">
<HiCheckCircle className="mt-0.5 h-4 w-4 shrink-0 text-light-1000 dark:text-dark-1000" />
@@ -441,7 +393,7 @@ const FeatureComparisonTable = ({
</div>
);
}
// Boolean false - show cross icon
return (
<div className="flex items-center gap-2.5">
<HiXCircle className="mt-0.5 h-4 w-4 shrink-0 text-light-400 dark:text-dark-600" />
@@ -455,7 +407,7 @@ const FeatureComparisonTable = ({
const getFeatureValue = (
feature: PlanFeature,
planId: string,
): string | boolean | { text: string; highlight?: string } => {
): string | boolean | { text: string; highlight?: boolean; highlightFirstWord?: boolean } => {
switch (planId) {
case "free":
return feature.free;

View File

@@ -1,9 +1,13 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { t } from "@lingui/core/macro";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { HiMiniArrowTopRightOnSquare } from "react-icons/hi2";
import { z } from "zod";
import Button from "~/components/Button";
import FeedbackModal from "~/components/FeedbackModal";
import Input from "~/components/Input";
import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import { PageHead } from "~/components/PageHead";
@@ -11,10 +15,28 @@ import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
const githubTokenSchema = z.object({
token: z.string().min(1, { message: t`Token is required` }),
});
type GitHubTokenFormValues = z.infer<typeof githubTokenSchema>;
export default function IntegrationsSettings() {
const { modalContentType, isOpen } = useModal();
const { showPopup } = usePopup();
const {
register,
handleSubmit,
formState: { isDirty, errors },
reset,
} = useForm<GitHubTokenFormValues>({
resolver: zodResolver(githubTokenSchema),
defaultValues: {
token: "",
},
});
const {
data: integrations,
refetch: refetchIntegrations,
@@ -34,21 +56,25 @@ export default function IntegrationsSettings() {
},
);
const { data: githubStatus, refetch: refetchGithubStatus } =
api.integration.getGitHubStatus.useQuery();
useEffect(() => {
const handleFocus = () => {
refetchIntegrations();
void refetchIntegrations();
void refetchGithubStatus();
};
window.addEventListener("focus", handleFocus);
return () => {
window.removeEventListener("focus", handleFocus);
};
}, [refetchIntegrations]);
}, [refetchIntegrations, refetchGithubStatus]);
const { mutateAsync: disconnectTrello } =
api.integration.disconnect.useMutation({
onSuccess: () => {
refetchIntegrations();
refetchTrelloUrl();
void refetchIntegrations();
void refetchTrelloUrl();
showPopup({
header: t`Trello disconnected`,
message: t`Your Trello account has been disconnected.`,
@@ -64,6 +90,49 @@ export default function IntegrationsSettings() {
},
});
const { mutateAsync: saveGithubToken, isPending: isSavingGithubToken } =
api.integration.saveGitHubToken.useMutation({
onSuccess: () => {
void refetchGithubStatus();
reset();
showPopup({
header: t`GitHub connected`,
message: t`Your GitHub account has been connected.`,
icon: "success",
});
},
onError: () => {
showPopup({
header: t`Error connecting GitHub`,
message: t`An error occurred while connecting your GitHub account.`,
icon: "error",
});
},
});
const onSubmitGithubToken = (data: GitHubTokenFormValues) => {
void saveGithubToken({ token: data.token });
};
const { mutateAsync: disconnectGithub } =
api.integration.disconnectGitHub.useMutation({
onSuccess: () => {
void refetchGithubStatus();
showPopup({
header: t`GitHub disconnected`,
message: t`Your GitHub account has been disconnected.`,
icon: "success",
});
},
onError: () => {
showPopup({
header: t`Error disconnecting GitHub`,
message: t`An error occurred while disconnecting your GitHub account.`,
icon: "error",
});
},
});
return (
<>
<PageHead title={t`Settings | Integrations`} />
@@ -112,6 +181,51 @@ export default function IntegrationsSettings() {
)}
</div>
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
{t`GitHub`}
</h2>
{!githubStatus?.connected ? (
<>
<p className="mb-4 text-sm text-neutral-500 dark:text-dark-900">
{t`Connect your GitHub account to import projects.`}
</p>
<form
onSubmit={handleSubmit(onSubmitGithubToken)}
className="flex gap-2"
>
<div className="mb-4 flex w-full max-w-[325px] items-center gap-2">
<Input
type="password"
placeholder="Personal Access Token"
{...register("token")}
errorMessage={errors.token?.message}
/>
</div>
<div>
<Button
variant="secondary"
type="submit"
disabled={!isDirty || isSavingGithubToken}
isLoading={isSavingGithubToken}
>
{t`Connect GitHub`}
</Button>
</div>
</form>
</>
) : (
<>
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
{t`Your GitHub account is connected.`}
</p>
<Button variant="secondary" onClick={() => disconnectGithub()}>
{t`Disconnect GitHub`}
</Button>
</>
)}
</div>
{/* Global modals */}
<Modal
modalSize="md"

View File

@@ -34,6 +34,7 @@ export const boardRouter = createTRPCRouter({
z.object({
workspacePublicId: z.string().min(12),
type: z.enum(["regular", "template"]).optional(),
archived: z.boolean().optional(),
}),
)
.output(
@@ -65,7 +66,10 @@ export const boardRouter = createTRPCRouter({
ctx.db,
workspace.id,
userId,
{ type: input.type }
{
type: input.type,
archived: input.archived ?? false,
}
);
return result;
@@ -153,24 +157,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
@@ -461,7 +465,8 @@ export const boardRouter = createTRPCRouter({
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/)
.optional(),
visibility: z.enum(["public", "private"]).optional(),
favorite: z.boolean().optional()
favorite: z.boolean().optional(),
isArchived: z.boolean().optional(),
}),
)
.output(z.object({ success: z.boolean() }).or(z.custom<Awaited<ReturnType<typeof boardRepo.update>>>()))
@@ -503,7 +508,7 @@ export const boardRouter = createTRPCRouter({
}
// Handle other updates (name, slug, visibility)
const hasOtherUpdates = input.name || input.slug || input.visibility !== undefined;
const hasOtherUpdates = input.name || input.slug || input.visibility !== undefined || input.isArchived !== undefined;
if (!hasOtherUpdates) {
// Only favorite was updated, return success
@@ -530,6 +535,7 @@ export const boardRouter = createTRPCRouter({
slug: input.slug,
boardPublicId: input.boardPublicId,
visibility: input.visibility,
isArchived: input.isArchived,
});
if (!result)

View File

@@ -13,6 +13,10 @@ 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
@@ -165,6 +169,32 @@ 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
@@ -1007,6 +1037,59 @@ 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
@@ -1054,6 +1137,9 @@ 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, {
@@ -1068,6 +1154,34 @@ 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 };
}),
});

View File

@@ -81,6 +81,16 @@ 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),

View File

@@ -11,10 +11,12 @@ import * as labelRepo from "@kan/db/repository/label.repo";
import * as listRepo from "@kan/db/repository/list.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { colours } from "@kan/shared/constants";
import { generateUID } from "@kan/shared/utils";
import { generateSlug, generateUID } from "@kan/shared/utils";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { assertPermission } from "../utils/permissions";
import { assertUserInWorkspace } from "../utils/auth";
import { decryptToken } from "../utils/encryption";
import { apiKeys, urls } from "./integration";
export interface TrelloBoard {
@@ -50,6 +52,51 @@ interface TrelloCheckItem {
pos: number;
}
interface GitHubProjectsResponse {
data: {
viewer: {
projectsV2: {
nodes?: { id: string; title: string }[];
};
organizations: {
nodes: {
projectsV2: {
nodes?: { id: string; title: string }[];
};
}[];
};
};
};
errors?: unknown[];
}
interface GitHubGraphQLResponse {
data?: {
node?: GitHubProjectV2Node;
};
errors?: unknown[];
}
interface GitHubProjectV2Node {
title: string;
field?: {
options?: { id: string; name: string }[];
};
areaField?: {
options?: { id: string; name: string; color: string }[];
};
items?: {
nodes: {
fieldValueByName?: { name: string };
areaValue?: { name: string };
content?: {
title?: string;
body?: string;
};
}[];
};
}
interface TrelloCard {
id: string;
name: string | null;
@@ -464,4 +511,436 @@ export const importRouter = createTRPCRouter({
return { boardsCreated };
}),
}),
github: createTRPCRouter({
getProjects: protectedProcedure
.meta({
openapi: {
summary: "Get projects from GitHub",
method: "GET",
path: "/integrations/github/projects",
description: "Retrieves all projects from GitHub",
tags: ["Integrations"],
protect: true,
},
})
.input(z.void())
.output(z.array(z.object({ id: z.string(), name: z.string() })))
.query(async ({ ctx }) => {
const user = ctx.user;
if (!user)
throw new TRPCError({
message: "User not authenticated",
code: "UNAUTHORIZED",
});
const integration = await integrationsRepo.getProviderForUser(
ctx.db,
user.id,
"github",
);
if (!integration)
throw new TRPCError({
message: "GitHub token not found",
code: "UNAUTHORIZED",
});
const token = decryptToken(integration.accessToken);
// GraphQL query to fetch Projects V2 for the user and their organizations
const query = `
query {
viewer {
projectsV2(first: 20) {
nodes {
id
title
}
}
organizations(first: 10) {
nodes {
projectsV2(first: 10) {
nodes {
id
title
}
}
}
}
}
}
`;
const response = await fetch("https://api.github.com/graphql", {
method: "POST",
headers: {
Authorization: `token ${token}`,
"Content-Type": "application/json",
"User-Agent": "Kan-App",
},
body: JSON.stringify({ query }),
});
if (!response.ok) {
const errorText = await response.text();
console.error(
`GitHub API Error: ${response.status} ${response.statusText}`,
);
console.error(`GitHub API Response: ${errorText}`);
throw new TRPCError({
message: `Failed to fetch GitHub projects: ${response.status} ${response.statusText}`,
code: "INTERNAL_SERVER_ERROR",
});
}
const result = (await response.json()) as GitHubProjectsResponse;
if (result.errors) {
console.error("GitHub GraphQL Errors:", result.errors);
throw new TRPCError({
message: "Failed to fetch GitHub projects (GraphQL Error)",
code: "INTERNAL_SERVER_ERROR",
});
}
const userProjects = result.data.viewer.projectsV2.nodes ?? [];
const orgProjects = result.data.viewer.organizations.nodes.flatMap(
(org) => org.projectsV2.nodes ?? [],
);
const allProjects = [...userProjects, ...orgProjects];
return allProjects.map((project) => ({
id: project.id,
name: project.title,
}));
}),
importProjects: protectedProcedure
.meta({
openapi: {
summary: "Import projects from GitHub",
method: "POST",
path: "/imports/github/projects",
description: "Imports projects from GitHub",
tags: ["Imports"],
protect: true,
},
})
.input(
z.object({
projectIds: z.array(z.string()),
workspacePublicId: z.string().min(12),
}),
)
.output(z.object({ projectsImported: z.number() }))
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId) throw new TRPCError({ code: "UNAUTHORIZED" });
const integration = await integrationsRepo.getProviderForUser(
ctx.db,
userId,
"github",
);
if (!integration)
throw new TRPCError({
code: "UNAUTHORIZED",
message: "GitHub token not found",
});
const token = decryptToken(integration.accessToken);
const workspace = await workspaceRepo.getByPublicId(
ctx.db,
input.workspacePublicId,
);
if (!workspace)
throw new TRPCError({
code: "NOT_FOUND",
message: "Workspace not found",
});
await assertUserInWorkspace(ctx.db, userId, workspace.id);
const newImport = await importRepo.create(ctx.db, {
source: "github",
createdBy: userId,
});
if (!newImport) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to create import record",
});
}
const newImportId = newImport.id;
let projectsImported = 0;
for (const projectId of input.projectIds) {
// GraphQL query to fetch Project V2 details, status options, area options, and items
const query = `
query($id: ID!) {
node(id: $id) {
... on ProjectV2 {
title
field(name: "Status") {
... on ProjectV2SingleSelectField {
options {
id
name
}
}
}
areaField: field(name: "Area") {
... on ProjectV2SingleSelectField {
options {
id
name
color
}
}
}
items(first: 100) {
nodes {
fieldValueByName(name: "Status") {
... on ProjectV2ItemFieldSingleSelectValue {
name
}
}
areaValue: fieldValueByName(name: "Area") {
... on ProjectV2ItemFieldSingleSelectValue {
name
}
}
content {
... on Issue {
title
body
}
... on PullRequest {
title
body
}
... on DraftIssue {
title
body
}
}
}
}
}
}
}
`;
const response = await fetch("https://api.github.com/graphql", {
method: "POST",
headers: {
Authorization: `token ${token}`,
"Content-Type": "application/json",
"User-Agent": "Kan-App",
},
body: JSON.stringify({ query, variables: { id: projectId } }),
});
const result = (await response.json()) as GitHubGraphQLResponse;
if (result.errors || !result.data?.node) continue;
const projectData = result.data.node;
const statusOptions = projectData.field?.options ?? [];
const areaOptions = projectData.areaField?.options ?? [];
const items = projectData.items?.nodes ?? [];
const boardPublicId = generateUID();
const board = await boardRepo.create(ctx.db, {
publicId: boardPublicId,
name: projectData.title,
workspaceId: workspace.id,
slug: generateSlug(projectData.title),
createdBy: userId,
importId: newImportId,
});
if (!board) continue;
// Prepare Labels
const labelsInsert = areaOptions.map((option) => {
let colourCode = "#0d9488"; // Default Teal
const ghColor = option.color;
// Map GitHub colors to Kan colors
if (ghColor === "BLUE") colourCode = "#0284c7";
else if (ghColor === "GREEN") colourCode = "#65a30d";
else if (ghColor === "YELLOW") colourCode = "#ca8a04";
else if (ghColor === "ORANGE") colourCode = "#ea580c";
else if (ghColor === "RED") colourCode = "#dc2626";
else if (ghColor === "PINK") colourCode = "#db2777";
else if (ghColor === "PURPLE") colourCode = "#4f46e5";
else if (ghColor === "GRAY") colourCode = "#0d9488";
return {
publicId: generateUID(),
name: option.name,
colourCode,
createdBy: userId,
boardId: board.id,
importId: newImportId,
};
});
const createdLabels = await labelRepo.bulkCreate(
ctx.db,
labelsInsert,
);
const labelMap = new Map<string, number>();
createdLabels.forEach((label, index) => {
const originalName = areaOptions[index]?.name;
if (originalName) {
labelMap.set(originalName, label.id);
}
});
// Prepare Lists
const listsInsert: {
publicId: string;
name: string;
createdBy: string;
boardId: number;
index: number;
importId: number;
}[] = [];
if (statusOptions.length === 0) {
listsInsert.push({
publicId: generateUID(),
name: "To Do",
createdBy: userId,
boardId: board.id,
index: 0,
importId: newImportId,
});
} else {
statusOptions.forEach((option, index) => {
listsInsert.push({
publicId: generateUID(),
name: option.name,
createdBy: userId,
boardId: board.id,
index: index,
importId: newImportId,
});
});
}
const createdLists = await listRepo.bulkCreate(ctx.db, listsInsert);
const listIdMap = new Map<string, number>();
createdLists.forEach((list, index) => {
const originalName = listsInsert[index]?.name;
if (originalName) {
listIdMap.set(originalName, list.id);
}
});
// Prepare Cards
const itemsToInsert: {
item: NonNullable<
NonNullable<
NonNullable<GitHubProjectV2Node["items"]>["nodes"]
>[number]
>;
listId: number;
title: string;
description: string;
}[] = [];
for (const item of items) {
const statusName = item.fieldValueByName?.name;
const content = item.content ?? {};
const title = content.title ?? "Untitled Card";
const description = content.body ?? "";
let listId = statusName ? listIdMap.get(statusName) : undefined;
// Fallback to first list
if (!listId && createdLists.length > 0) {
listId = createdLists[0]?.id;
}
if (listId) {
itemsToInsert.push({
item,
listId,
title,
description,
});
}
}
const cardsInput = itemsToInsert.map((data, index) => ({
publicId: generateUID(),
title: data.title,
description: data.description,
createdBy: userId,
listId: data.listId,
index: index,
importId: newImportId,
}));
const createdCards = await cardRepo.bulkCreate(ctx.db, cardsInput);
// Create Activities
const activities = createdCards.map((card) => ({
type: "card.created" as const,
cardId: card.id,
createdBy: userId,
}));
if (activities.length > 0) {
await cardActivityRepo.bulkCreate(ctx.db, activities);
}
// Link Labels
const cardLabelRelations: { cardId: number; labelId: number }[] = [];
createdCards.forEach((card, index) => {
const originalItem = itemsToInsert[index]?.item;
const areaName = originalItem?.areaValue?.name;
if (areaName) {
const labelId = labelMap.get(areaName);
if (labelId) {
cardLabelRelations.push({
cardId: card.id,
labelId: labelId,
});
}
}
});
if (cardLabelRelations.length > 0) {
await cardRepo.bulkCreateCardLabelRelationships(
ctx.db,
cardLabelRelations,
);
}
projectsImported++;
}
if (projectsImported > 0 && newImportId) {
await importRepo.update(
ctx.db,
{ status: "success" },
{ importId: newImportId },
);
}
return { projectsImported };
}),
}),
});

View File

@@ -14,7 +14,65 @@ export const apiKeys = {
trello: process.env.TRELLO_APP_API_KEY,
};
import { encryptToken } from "../utils/encryption";
export const integrationRouter = createTRPCRouter({
saveGitHubToken: protectedProcedure
.input(z.object({ token: z.string() }))
.mutation(async ({ ctx, input }) => {
const user = ctx.user;
if (!user)
throw new TRPCError({
message: "User not authenticated",
code: "UNAUTHORIZED",
});
const encryptedToken = encryptToken(input.token);
const expiresAt = new Date();
expiresAt.setFullYear(expiresAt.getFullYear() + 1);
await integrationsRepo.createOrUpdateProvider(ctx.db, {
provider: "github",
userId: user.id,
accessToken: encryptedToken,
expiresAt,
});
return { success: true };
}),
disconnectGitHub: protectedProcedure.mutation(async ({ ctx }) => {
const user = ctx.user;
if (!user)
throw new TRPCError({
message: "User not authenticated",
code: "UNAUTHORIZED",
});
await integrationsRepo.deleteProviderForUser(ctx.db, user.id, "github");
return { success: true };
}),
getGitHubStatus: protectedProcedure.query(async ({ ctx }) => {
const user = ctx.user;
if (!user)
throw new TRPCError({
message: "User not authenticated",
code: "UNAUTHORIZED",
});
const connected = await integrationsRepo.isProviderAvailableForUser(
ctx.db,
user.id,
"github",
);
return { connected };
}),
providers: protectedProcedure
.meta({
openapi: {
@@ -67,7 +125,7 @@ export const integrationRouter = createTRPCRouter({
protect: true,
},
})
.input(z.object({ provider: z.enum(["trello"]) }))
.input(z.object({ provider: z.enum(["trello", "github"]) }))
.output(z.object({}))
.mutation(async ({ ctx, input }) => {
const user = ctx.user;

View File

@@ -210,11 +210,12 @@ export const memberRouter = createTRPCRouter({
input.memberPublicId,
);
if (!member)
if (!member || member.workspaceId !== workspace.id) {
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,
@@ -720,7 +721,7 @@ export const memberRouter = createTRPCRouter({
input.memberPublicId,
);
if (!member) {
if (!member || member.workspaceId !== workspace.id) {
throw new TRPCError({
message: "Member not found",
code: "NOT_FOUND",

View File

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

View File

@@ -0,0 +1,53 @@
import crypto from "crypto";
const ALGORITHM = "aes-256-gcm";
const SECRET_KEY = process.env.BETTER_AUTH_SECRET;
if (!SECRET_KEY) {
throw new Error("Encryption key is missing. Set BETTER_AUTH_SECRET.");
}
// Ensure the key is exactly 32 bytes
const key = crypto.createHash("sha256").update(String(SECRET_KEY)).digest();
export const encryptToken = (text: string) => {
const iv = crypto.randomBytes(12); // 12 bytes is the recommended IV size for GCM
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
// buffer concat is faster/cleaner for raw binary manipulation
const encrypted = Buffer.concat([
cipher.update(text, "utf8"),
cipher.final(),
]);
const authTag = cipher.getAuthTag();
// Combine IV + AuthTag + EncryptedData into one buffer
// This saves space compared to storing them as separate hex strings
const combined = Buffer.concat([iv, authTag, encrypted]);
// Return as URL-safe Base64 (ideal for cookies)
return combined.toString("base64url");
};
export const decryptToken = (text: string) => {
// Convert URL-safe Base64 back to a Buffer
const combined = Buffer.from(text, "base64url");
// Extract the parts based on fixed lengths
// IV is 12 bytes (standard for GCM)
// AuthTag is 16 bytes (standard for GCM)
const iv = combined.subarray(0, 12);
const authTag = combined.subarray(12, 28);
const encryptedText = combined.subarray(28);
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
// If the cookie was tampered with, this will throw an error
const decrypted = Buffer.concat([
decipher.update(encryptedText),
decipher.final(),
]);
return decrypted.toString("utf8");
};

View File

@@ -0,0 +1,528 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
vi.mock("@kan/db/repository/webhook.repo", () => ({
getActiveByWorkspaceId: vi.fn(),
}));
import * as webhookRepo from "@kan/db/repository/webhook.repo";
import {
sendWebhookToUrl,
sendWebhooksForWorkspace,
createCardWebhookPayload,
webhookUrlSchema,
type WebhookPayload,
} from "./webhook";
const mockGetActiveByWorkspaceId = webhookRepo.getActiveByWorkspaceId as ReturnType<typeof vi.fn>;
describe("webhook utilities", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
vi.setSystemTime(new Date("2024-01-15T12:00:00.000Z"));
});
afterEach(() => {
vi.useRealTimers();
});
describe("createCardWebhookPayload", () => {
it("creates a payload with required card fields", () => {
const payload = createCardWebhookPayload(
"card.created",
{
id: "card-123",
title: "Test Card",
listId: "list-456",
},
{
boardId: "board-789",
},
);
expect(payload.event).toBe("card.created");
expect(payload.timestamp).toBe("2024-01-15T12:00:00.000Z");
expect(payload.data.card).toEqual({
id: "card-123",
title: "Test Card",
description: undefined,
dueDate: null,
listId: "list-456",
boardId: "board-789",
});
});
it("includes optional card fields when provided", () => {
const dueDate = new Date("2024-02-01T10:00:00.000Z");
const payload = createCardWebhookPayload(
"card.updated",
{
id: "card-123",
title: "Test Card",
description: "A description",
dueDate,
listId: "list-456",
},
{
boardId: "board-789",
},
);
expect(payload.data.card.description).toBe("A description");
expect(payload.data.card.dueDate).toBe("2024-02-01T10:00:00.000Z");
});
it("includes board context when provided", () => {
const payload = createCardWebhookPayload(
"card.created",
{
id: "card-123",
title: "Test Card",
listId: "list-456",
},
{
boardId: "board-789",
boardName: "My Board",
},
);
expect(payload.data.board).toEqual({
id: "board-789",
name: "My Board",
});
});
it("includes list context when provided", () => {
const payload = createCardWebhookPayload(
"card.created",
{
id: "card-123",
title: "Test Card",
listId: "list-456",
},
{
boardId: "board-789",
listName: "To Do",
},
);
expect(payload.data.list).toEqual({
id: "list-456",
name: "To Do",
});
});
it("includes user context when provided", () => {
const payload = createCardWebhookPayload(
"card.created",
{
id: "card-123",
title: "Test Card",
listId: "list-456",
},
{
boardId: "board-789",
user: {
id: "user-111",
name: "John Doe",
},
},
);
expect(payload.data.user).toEqual({
id: "user-111",
name: "John Doe",
});
});
it("includes changes for card.updated events", () => {
const payload = createCardWebhookPayload(
"card.updated",
{
id: "card-123",
title: "Updated Title",
listId: "list-456",
},
{
boardId: "board-789",
changes: {
title: { from: "Old Title", to: "Updated Title" },
},
},
);
expect(payload.data.changes).toEqual({
title: { from: "Old Title", to: "Updated Title" },
});
});
});
describe("sendWebhookToUrl", () => {
const originalFetch = global.fetch;
beforeEach(() => {
global.fetch = vi.fn();
});
afterEach(() => {
global.fetch = originalFetch;
});
const mockPayload: WebhookPayload = {
event: "card.created",
timestamp: "2024-01-15T12:00:00.000Z",
data: {
card: {
id: "card-123",
title: "Test Card",
listId: "list-456",
boardId: "board-789",
},
},
};
describe("SSRF protection", () => {
it("blocks HTTP URLs", async () => {
const result = await sendWebhookToUrl("http://example.com/webhook", undefined, mockPayload);
expect(result.success).toBe(false);
expect(result.error).toContain("HTTPS");
expect(global.fetch).not.toHaveBeenCalled();
});
it("blocks localhost", async () => {
const result = await sendWebhookToUrl("https://localhost/webhook", undefined, mockPayload);
expect(result.success).toBe(false);
expect(result.error).toContain("Localhost");
expect(global.fetch).not.toHaveBeenCalled();
});
it("blocks 127.0.0.1", async () => {
const result = await sendWebhookToUrl("https://127.0.0.1/webhook", undefined, mockPayload);
expect(result.success).toBe(false);
expect(global.fetch).not.toHaveBeenCalled();
});
it("blocks cloud metadata endpoint", async () => {
const result = await sendWebhookToUrl("https://169.254.169.254/latest/meta-data/", undefined, mockPayload);
expect(result.success).toBe(false);
expect(result.error).toContain("metadata");
expect(global.fetch).not.toHaveBeenCalled();
});
it("blocks private 10.x.x.x IPs", async () => {
const result = await sendWebhookToUrl("https://10.0.0.1/webhook", undefined, mockPayload);
expect(result.success).toBe(false);
expect(result.error).toContain("Private");
expect(global.fetch).not.toHaveBeenCalled();
});
it("blocks private 192.168.x.x IPs", async () => {
const result = await sendWebhookToUrl("https://192.168.1.1/webhook", undefined, mockPayload);
expect(result.success).toBe(false);
expect(global.fetch).not.toHaveBeenCalled();
});
it("allows valid HTTPS URLs", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true, status: 200 });
const result = await sendWebhookToUrl("https://example.com/webhook", undefined, mockPayload);
expect(result.success).toBe(true);
expect(global.fetch).toHaveBeenCalled();
});
});
it("sends POST request with correct headers", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
status: 200,
});
await sendWebhookToUrl("https://example.com/webhook", undefined, mockPayload);
expect(global.fetch).toHaveBeenCalledWith(
"https://example.com/webhook",
expect.objectContaining({
method: "POST",
headers: expect.objectContaining({
"Content-Type": "application/json",
"X-Webhook-Event": "card.created",
"X-Webhook-Timestamp": "2024-01-15T12:00:00.000Z",
}),
body: JSON.stringify(mockPayload),
}),
);
});
it("includes signature header when secret is provided", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
status: 200,
});
await sendWebhookToUrl("https://example.com/webhook", "my-secret", mockPayload);
expect(global.fetch).toHaveBeenCalledWith(
"https://example.com/webhook",
expect.objectContaining({
headers: expect.objectContaining({
"X-Webhook-Signature": expect.stringMatching(/^[a-f0-9]{64}$/),
}),
}),
);
});
it("returns success for 2xx responses", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
status: 200,
});
const result = await sendWebhookToUrl(
"https://example.com/webhook",
undefined,
mockPayload,
);
expect(result).toEqual({ success: true, statusCode: 200 });
});
it("returns failure for non-2xx responses", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
status: 500,
statusText: "Internal Server Error",
});
const result = await sendWebhookToUrl(
"https://example.com/webhook",
undefined,
mockPayload,
);
expect(result).toEqual({
success: false,
statusCode: 500,
error: "500 Internal Server Error",
});
});
it("returns failure on network error", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
new Error("Network error"),
);
const result = await sendWebhookToUrl(
"https://example.com/webhook",
undefined,
mockPayload,
);
expect(result).toEqual({
success: false,
error: "Network error",
});
});
it("returns timeout error when request takes too long", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockImplementationOnce(
() =>
new Promise((_, reject) => {
setTimeout(() => {
const error = new Error("Aborted");
error.name = "AbortError";
reject(error);
}, 15000);
}),
);
const resultPromise = sendWebhookToUrl(
"https://example.com/webhook",
undefined,
mockPayload,
);
// Advance timers to trigger the abort
vi.advanceTimersByTime(15000);
const result = await resultPromise;
expect(result).toEqual({
success: false,
error: "Request timed out",
});
});
});
describe("sendWebhooksForWorkspace", () => {
const originalFetch = global.fetch;
beforeEach(() => {
global.fetch = vi.fn();
});
afterEach(() => {
global.fetch = originalFetch;
});
const mockDb = {} as Parameters<typeof sendWebhooksForWorkspace>[0];
const mockPayload: WebhookPayload = {
event: "card.created",
timestamp: "2024-01-15T12:00:00.000Z",
data: {
card: {
id: "card-123",
title: "Test Card",
listId: "list-456",
boardId: "board-789",
},
},
};
it("sends to all active webhooks subscribed to the event", async () => {
mockGetActiveByWorkspaceId.mockResolvedValueOnce([
{
id: 1,
publicId: "wh-1",
url: "https://example.com/webhook1",
secret: "secret1",
events: ["card.created", "card.updated"],
active: true,
},
{
id: 2,
publicId: "wh-2",
url: "https://example.com/webhook2",
secret: null,
events: ["card.created"],
active: true,
},
]);
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
status: 200,
});
await sendWebhooksForWorkspace(mockDb, 1, mockPayload);
// Event filtering now happens at DB level
expect(mockGetActiveByWorkspaceId).toHaveBeenCalledWith(
mockDb,
1,
"card.created",
);
expect(global.fetch).toHaveBeenCalledTimes(2);
expect(global.fetch).toHaveBeenCalledWith(
"https://example.com/webhook1",
expect.any(Object),
);
expect(global.fetch).toHaveBeenCalledWith(
"https://example.com/webhook2",
expect.any(Object),
);
});
it("does not send when no webhooks match the event (DB-level filtering)", async () => {
// DB-level event filter returns empty array when no webhooks match
mockGetActiveByWorkspaceId.mockResolvedValueOnce([]);
await sendWebhooksForWorkspace(mockDb, 1, mockPayload);
expect(mockGetActiveByWorkspaceId).toHaveBeenCalledWith(
mockDb,
1,
"card.created",
);
expect(global.fetch).not.toHaveBeenCalled();
});
it("continues sending to other webhooks when one fails", async () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
mockGetActiveByWorkspaceId.mockResolvedValueOnce([
{
id: 1,
publicId: "wh-1",
url: "https://example.com/webhook1",
secret: null,
events: ["card.created"],
active: true,
},
{
id: 2,
publicId: "wh-2",
url: "https://example.com/webhook2",
secret: null,
events: ["card.created"],
active: true,
},
]);
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: false, status: 500, statusText: "Error" })
.mockResolvedValueOnce({ ok: true, status: 200 });
await sendWebhooksForWorkspace(mockDb, 1, mockPayload);
expect(global.fetch).toHaveBeenCalledTimes(2);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("Webhook delivery failed"),
);
consoleSpy.mockRestore();
});
it("handles empty webhook list", async () => {
mockGetActiveByWorkspaceId.mockResolvedValueOnce([]);
await sendWebhooksForWorkspace(mockDb, 1, mockPayload);
expect(global.fetch).not.toHaveBeenCalled();
});
it("catches and logs DB errors without throwing", async () => {
const consoleSpy = vi
.spyOn(console, "error")
.mockImplementation(() => {});
mockGetActiveByWorkspaceId.mockRejectedValueOnce(
new Error("DB connection failed"),
);
// Should not throw — the try/catch absorbs the error
await expect(
sendWebhooksForWorkspace(mockDb, 1, mockPayload),
).resolves.toBeUndefined();
expect(consoleSpy).toHaveBeenCalledWith(
"Failed to send webhooks for workspace:",
expect.any(Error),
);
consoleSpy.mockRestore();
});
});
describe("webhookUrlSchema", () => {
it("accepts valid HTTPS URLs", () => {
expect(webhookUrlSchema.safeParse("https://example.com/webhook").success).toBe(true);
});
it("rejects HTTP URLs", () => {
const result = webhookUrlSchema.safeParse("http://example.com/webhook");
expect(result.success).toBe(false);
});
it("rejects localhost", () => {
const result = webhookUrlSchema.safeParse("https://localhost/webhook");
expect(result.success).toBe(false);
});
it("rejects private IPs", () => {
expect(webhookUrlSchema.safeParse("https://10.0.0.1/webhook").success).toBe(false);
expect(webhookUrlSchema.safeParse("https://192.168.1.1/webhook").success).toBe(false);
});
it("rejects cloud metadata endpoints", () => {
expect(webhookUrlSchema.safeParse("https://169.254.169.254/latest").success).toBe(false);
});
});
});

View File

@@ -0,0 +1,258 @@
import crypto from "crypto";
import { z } from "zod";
import type { dbClient } from "@kan/db/client";
import type { WebhookEvent } from "@kan/db/schema";
import * as webhookRepo from "@kan/db/repository/webhook.repo";
export type WebhookEventType = WebhookEvent;
export interface WebhookPayload {
event: WebhookEventType;
timestamp: string;
data: {
card: {
id: string;
title: string;
description?: string | null;
dueDate?: string | null; // ISO string after JSON serialization
listId: string;
boardId: string;
};
board?: {
id: string;
name: string;
};
list?: {
id: string;
name: string;
};
user?: {
id: string;
name: string | null;
};
changes?: Record<string, { from: unknown; to: unknown }>;
};
}
function generateSignature(payload: string, secret: string): string {
return crypto.createHmac("sha256", secret).update(payload).digest("hex");
}
/**
* Zod schema for webhook URLs with SSRF mitigation.
* Requires HTTPS and blocks private/internal IP ranges, localhost,
* and cloud metadata endpoints.
*/
export const webhookUrlSchema = z
.string()
.url()
.max(2048)
.refine(
(url) => {
try {
return new URL(url).protocol === "https:";
} catch {
return false;
}
},
{ message: "Only HTTPS URLs are allowed" },
)
.refine(
(url) => {
try {
const hostname = new URL(url).hostname.toLowerCase();
return !(
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname === "::1" ||
hostname === "0.0.0.0"
);
} catch {
return false;
}
},
{ message: "Localhost URLs are not allowed" },
)
.refine(
(url) => {
try {
const hostname = new URL(url).hostname.toLowerCase();
return !(
hostname === "169.254.169.254" ||
hostname === "metadata.google.internal"
);
} catch {
return false;
}
},
{ message: "Cloud metadata endpoints are not allowed" },
)
.refine(
(url) => {
try {
const hostname = new URL(url).hostname.toLowerCase();
const ipv4Match = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(hostname);
if (ipv4Match) {
const [, a, b] = ipv4Match.map(Number);
if (
a === 10 ||
(a === 172 && b! >= 16 && b! <= 31) ||
(a === 192 && b === 168)
) {
return false;
}
}
return true;
} catch {
return false;
}
},
{ message: "Private IP addresses are not allowed" },
);
/**
* Send a webhook payload to a specific URL.
*
* SSRF note: URL validation (HTTPS-only, no private IPs, no cloud metadata)
* is enforced both here at delivery time and at webhook creation via
* webhookUrlSchema. This function is only reachable by workspace admins.
*/
export async function sendWebhookToUrl(
url: string,
secret: string | undefined,
payload: WebhookPayload,
): Promise<{ success: boolean; statusCode?: number; error?: string }> {
const result = webhookUrlSchema.safeParse(url);
if (!result.success) {
return { success: false, error: result.error.issues[0]?.message };
}
const body = JSON.stringify(payload);
const headers: Record<string, string> = {
"Content-Type": "application/json",
"X-Webhook-Event": payload.event,
"X-Webhook-Timestamp": payload.timestamp,
};
if (secret) {
headers["X-Webhook-Signature"] = generateSignature(body, secret);
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
try {
const response = await fetch(url, {
method: "POST",
headers,
body,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
return {
success: false,
statusCode: response.status,
error: `${response.status} ${response.statusText}`,
};
}
return { success: true, statusCode: response.status };
} catch (error) {
clearTimeout(timeoutId);
if (error instanceof Error && error.name === "AbortError") {
return { success: false, error: "Request timed out" };
}
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
/**
* Send webhook to all active webhooks for a workspace that are subscribed to the event.
* Wrapped in try/catch so callers using fire-and-forget don't risk unhandled rejections.
*/
export async function sendWebhooksForWorkspace(
db: dbClient,
workspaceId: number,
payload: WebhookPayload,
): Promise<void> {
try {
// Get active webhooks for this workspace
const webhooks = await webhookRepo.getActiveByWorkspaceId(db, workspaceId);
// Filter webhooks that are subscribed to this specific event
const webhooksForEvent = webhooks.filter((webhook) =>
webhook.events.includes(payload.event),
);
// Send to all subscribed webhooks in parallel (fire and forget)
const promises = webhooksForEvent.map((webhook) =>
sendWebhookToUrl(webhook.url, webhook.secret ?? undefined, payload).then(
(result) => {
if (!result.success) {
console.error(
`Webhook delivery failed to ${webhook.url}: ${result.error}`,
);
}
},
),
);
// Wait for all to complete but don't block on failures
await Promise.allSettled(promises);
} catch (error) {
console.error("Failed to send webhooks for workspace:", error);
}
}
export function createCardWebhookPayload(
event: WebhookEventType,
card: {
id: string;
title: string;
description?: string | null;
dueDate?: Date | null;
listId: string;
},
context: {
boardId: string;
boardName?: string;
listName?: string;
user?: {
id: string;
name: string | null;
};
changes?: Record<string, { from: unknown; to: unknown }>;
},
): WebhookPayload {
return {
event,
timestamp: new Date().toISOString(),
data: {
card: {
id: card.id,
title: card.title,
description: card.description,
dueDate: card.dueDate?.toISOString() ?? null,
listId: card.listId,
boardId: context.boardId,
},
board: context.boardName
? { id: context.boardId, name: context.boardName }
: undefined,
list: context.listName
? { id: card.listId, name: context.listName }
: undefined,
user: context.user,
changes: context.changes,
},
};
}

View File

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

View File

@@ -33,8 +33,10 @@ export const initAuth = (db: dbClient) => {
},
emailAndPassword: {
enabled: env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true",
disableSignUp:
env("NEXT_PUBLIC_DISABLE_SIGN_UP")?.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,
sendResetPassword: async (data) => {
await sendEmail(data.user.email, "Reset Password", "RESET_PASSWORD", {
resetPasswordUrl: data.url,

View File

@@ -0,0 +1,195 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("next-runtime-env", () => ({
env: vi.fn(),
}));
vi.mock("@kan/db/repository/member.repo", () => ({
getByEmailAndStatus: vi.fn(),
getByPublicId: vi.fn(),
acceptInvite: vi.fn(),
}));
vi.mock("@kan/db/repository/user.repo", () => ({
update: vi.fn(),
}));
vi.mock("@kan/email", () => ({
notificationClient: null,
}));
vi.mock("@kan/shared", () => ({
createEmailUnsubscribeLink: vi.fn(),
createS3Client: vi.fn(),
}));
vi.mock("@aws-sdk/client-s3", () => ({
PutObjectCommand: vi.fn(),
}));
vi.mock("@novu/api/models/components", () => ({
ChatOrPushProviderEnum: { Discord: "discord" },
}));
import { env } from "next-runtime-env";
import * as memberRepo from "@kan/db/repository/member.repo";
import { createDatabaseHooks } from "./hooks";
const mockEnv = env as ReturnType<typeof vi.fn>;
const mockGetByEmailAndStatus =
memberRepo.getByEmailAndStatus as ReturnType<typeof vi.fn>;
const db = {} as Parameters<typeof createDatabaseHooks>[0];
const fakeUser = {
id: "user-1",
createdAt: new Date(),
updatedAt: new Date(),
email: "test@example.com",
emailVerified: false,
name: "Test User",
};
describe("createDatabaseHooks", () => {
const hooks = createDatabaseHooks(db);
beforeEach(() => {
vi.clearAllMocks();
});
describe("user.create.before", () => {
it("allows sign-up when DISABLE_SIGN_UP is not set", async () => {
mockEnv.mockReturnValue(undefined);
const result = await hooks.user.create.before(fakeUser, {});
expect(result).toBe(true);
expect(mockGetByEmailAndStatus).not.toHaveBeenCalled();
});
it("allows sign-up when DISABLE_SIGN_UP is false", async () => {
mockEnv.mockImplementation((key: string) =>
key === "NEXT_PUBLIC_DISABLE_SIGN_UP" ? "false" : undefined,
);
const result = await hooks.user.create.before(fakeUser, {});
expect(result).toBe(true);
expect(mockGetByEmailAndStatus).not.toHaveBeenCalled();
});
it("blocks sign-up when disabled and user has no pending invitation", async () => {
mockEnv.mockImplementation((key: string) =>
key === "NEXT_PUBLIC_DISABLE_SIGN_UP" ? "true" : undefined,
);
mockGetByEmailAndStatus.mockResolvedValue(undefined);
const result = await hooks.user.create.before(fakeUser, {});
expect(result).toBe(false);
expect(mockGetByEmailAndStatus).toHaveBeenCalledWith(
db,
"test@example.com",
"invited",
);
});
it("allows sign-up when disabled but user has a pending invitation", async () => {
mockEnv.mockImplementation((key: string) =>
key === "NEXT_PUBLIC_DISABLE_SIGN_UP" ? "true" : undefined,
);
mockGetByEmailAndStatus.mockResolvedValue({
id: "member-1",
email: "test@example.com",
status: "invited",
});
const result = await hooks.user.create.before(fakeUser, {});
expect(result).toBe(true);
expect(mockGetByEmailAndStatus).toHaveBeenCalledWith(
db,
"test@example.com",
"invited",
);
});
it("blocks sign-up when disabled and invitation exists but domain is not allowed", async () => {
mockEnv.mockImplementation((key: string) =>
key === "NEXT_PUBLIC_DISABLE_SIGN_UP" ? "true" : undefined,
);
process.env.BETTER_AUTH_ALLOWED_DOMAINS = "acme.com";
mockGetByEmailAndStatus.mockResolvedValue({
id: "member-1",
email: "test@example.com",
status: "invited",
});
const result = await hooks.user.create.before(fakeUser, {});
expect(result).toBe(false);
delete process.env.BETTER_AUTH_ALLOWED_DOMAINS;
});
// The user.create.before hook fires for ALL sign-up paths including
// OIDC/social — verify invite bypass works regardless of auth method.
it("allows OIDC/social sign-up when disabled but user has a pending invitation", async () => {
mockEnv.mockImplementation((key: string) =>
key === "NEXT_PUBLIC_DISABLE_SIGN_UP" ? "true" : undefined,
);
const oidcUser = {
...fakeUser,
id: "user-oidc",
email: "sso@corp.com",
image: "https://provider.com/avatar.jpg",
};
mockGetByEmailAndStatus.mockResolvedValue({
id: "member-2",
email: "sso@corp.com",
status: "invited",
});
const result = await hooks.user.create.before(oidcUser, {});
expect(result).toBe(true);
expect(mockGetByEmailAndStatus).toHaveBeenCalledWith(
db,
"sso@corp.com",
"invited",
);
});
it("blocks OIDC/social sign-up when disabled and user has no pending invitation", async () => {
mockEnv.mockImplementation((key: string) =>
key === "NEXT_PUBLIC_DISABLE_SIGN_UP" ? "true" : undefined,
);
const oidcUser = {
...fakeUser,
id: "user-oidc",
email: "random@external.com",
image: "https://provider.com/avatar.jpg",
};
mockGetByEmailAndStatus.mockResolvedValue(undefined);
const result = await hooks.user.create.before(oidcUser, {});
expect(result).toBe(false);
expect(mockGetByEmailAndStatus).toHaveBeenCalledWith(
db,
"random@external.com",
"invited",
);
});
it("allows sign-up when disabled, invitation exists, and domain is allowed", async () => {
mockEnv.mockImplementation((key: string) =>
key === "NEXT_PUBLIC_DISABLE_SIGN_UP" ? "true" : undefined,
);
process.env.BETTER_AUTH_ALLOWED_DOMAINS = "example.com";
mockGetByEmailAndStatus.mockResolvedValue({
id: "member-1",
email: "test@example.com",
status: "invited",
});
const result = await hooks.user.create.before(fakeUser, {});
expect(result).toBe(true);
delete process.env.BETTER_AUTH_ALLOWED_DOMAINS;
});
});
});

View File

@@ -0,0 +1,14 @@
import { defineConfig } from "vitest/config";
import { resolve } from "path";
export default defineConfig({
test: {
root: __dirname,
include: ["src/**/*.test.ts"],
},
resolve: {
alias: {
"@kan/db": resolve(__dirname, "../db/src"),
},
},
});

View File

@@ -0,0 +1,2 @@
ALTER TYPE "public"."source" ADD VALUE 'github';--> statement-breakpoint
ALTER TABLE "integration" ALTER COLUMN "accessToken" SET DATA TYPE text;--> statement-breakpoint

View File

@@ -0,0 +1,2 @@
ALTER TABLE "board" ADD COLUMN "isArchived" boolean DEFAULT false NOT NULL;--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "board_is_archived_idx" ON "board" USING btree ("isArchived");

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -197,6 +197,20 @@
"when": 1771192000000,
"tag": "20260129210000_AddWorkspaceWebhooks",
"breakpoints": true
},
{
"idx": 28,
"version": "7",
"when": 1772049587901,
"tag": "20260225195947_AddIsArchivedToBoard",
"breakpoints": true
},
{
"idx": 28,
"version": "7",
"when": 1771930355536,
"tag": "20260224105235_AddGitHubIntegrationSupport",
"breakpoints": true
}
]
}
}

View File

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

View File

@@ -46,6 +46,35 @@ export const getProvidersForUser = async (db: dbClient, userId: string) => {
return integration;
};
export const createOrUpdateProvider = async (
db: dbClient,
data: {
userId: string;
provider: string;
accessToken: string;
refreshToken?: string | null;
expiresAt: Date;
},
) => {
await db
.insert(integrations)
.values({
provider: data.provider,
userId: data.userId,
accessToken: data.accessToken,
refreshToken: data.refreshToken ?? null,
expiresAt: data.expiresAt,
})
.onConflictDoUpdate({
target: [integrations.userId, integrations.provider],
set: {
accessToken: data.accessToken,
refreshToken: data.refreshToken ?? null,
expiresAt: data.expiresAt,
},
});
};
export const deleteProviderForUser = async (
db: dbClient,
userId: string,

View File

@@ -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")),
where: and(isNull(boards.deletedAt), eq(boards.visibility, "public"), eq(boards.isArchived, false)),
orderBy: [asc(boards.name)]
},
},

View File

@@ -2,7 +2,6 @@ import { relations, sql } from "drizzle-orm";
import {
bigint,
bigserial,
boolean,
index,
pgEnum,
pgTable,
@@ -12,8 +11,8 @@ import {
uniqueIndex,
uuid,
varchar,
boolean,
} from "drizzle-orm/pg-core";
import { imports } from "./imports";
import { labels } from "./labels";
import { lists } from "./lists";
@@ -56,9 +55,11 @@ 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),

View File

@@ -14,7 +14,7 @@ import { labels } from "./labels";
import { lists } from "./lists";
import { users } from "./users";
export const importSourceEnum = pgEnum("source", ["trello"]);
export const importSourceEnum = pgEnum("source", ["trello", "github"]);
export const importStatusEnum = pgEnum("status", [
"started",
"success",

View File

@@ -2,6 +2,7 @@ import { relations } from "drizzle-orm";
import {
pgTable,
primaryKey,
text,
timestamp,
uuid,
varchar,
@@ -16,7 +17,7 @@ export const integrations = pgTable(
userId: uuid("userId")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
accessToken: varchar("accessToken", { length: 255 }).notNull(),
accessToken: text("accessToken").notNull(),
refreshToken: varchar("refreshToken", { length: 255 }),
expiresAt: timestamp("expiresAt").notNull(),
createdAt: timestamp("createdAt")

143
pnpm-lock.yaml generated
View File

@@ -254,6 +254,9 @@ 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)
@@ -341,7 +344,7 @@ importers:
version: link:../../tooling/typescript
eslint:
specifier: 'catalog:'
version: 9.34.0(jiti@2.4.2)
version: 9.34.0(jiti@2.6.1)
prettier:
specifier: 'catalog:'
version: 3.6.2
@@ -381,7 +384,7 @@ importers:
version: link:../../tooling/typescript
eslint:
specifier: 'catalog:'
version: 9.34.0(jiti@2.4.2)
version: 9.34.0(jiti@2.6.1)
prettier:
specifier: 'catalog:'
version: 3.6.2
@@ -436,7 +439,7 @@ importers:
version: 0.28.1
eslint:
specifier: 'catalog:'
version: 9.34.0(jiti@2.4.2)
version: 9.34.0(jiti@2.6.1)
prettier:
specifier: 'catalog:'
version: 3.6.2
@@ -473,7 +476,7 @@ importers:
version: 6.4.19
eslint:
specifier: 'catalog:'
version: 9.34.0(jiti@2.4.2)
version: 9.34.0(jiti@2.6.1)
prettier:
specifier: 'catalog:'
version: 3.6.2
@@ -513,7 +516,7 @@ importers:
version: link:../../tooling/typescript
eslint:
specifier: 'catalog:'
version: 9.34.0(jiti@2.4.2)
version: 9.34.0(jiti@2.6.1)
prettier:
specifier: 'catalog:'
version: 3.6.2
@@ -538,7 +541,7 @@ importers:
version: link:../../tooling/typescript
eslint:
specifier: 'catalog:'
version: 9.34.0(jiti@2.4.2)
version: 9.34.0(jiti@2.6.1)
prettier:
specifier: 'catalog:'
version: 3.6.2
@@ -550,28 +553,28 @@ importers:
dependencies:
'@eslint/compat':
specifier: ^1.2.3
version: 1.3.2(eslint@9.34.0(jiti@2.4.2))
version: 1.3.2(eslint@9.34.0(jiti@2.6.1))
'@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.4.2))(typescript@5.9.2))(eslint@9.34.0(jiti@2.4.2))
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))
eslint-plugin-jsx-a11y:
specifier: ^6.10.2
version: 6.10.2(eslint@9.34.0(jiti@2.4.2))
version: 6.10.2(eslint@9.34.0(jiti@2.6.1))
eslint-plugin-react:
specifier: ^7.37.2
version: 7.37.5(eslint@9.34.0(jiti@2.4.2))
version: 7.37.5(eslint@9.34.0(jiti@2.6.1))
eslint-plugin-react-hooks:
specifier: ^5.0.0
version: 5.2.0(eslint@9.34.0(jiti@2.4.2))
version: 5.2.0(eslint@9.34.0(jiti@2.6.1))
eslint-plugin-turbo:
specifier: ^2.3.1
version: 2.5.6(eslint@9.34.0(jiti@2.4.2))(turbo@2.5.6)
version: 2.5.6(eslint@9.34.0(jiti@2.6.1))(turbo@2.5.6)
typescript-eslint:
specifier: ^8.15.0
version: 8.41.0(eslint@9.34.0(jiti@2.4.2))(typescript@5.9.2)
version: 8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2)
devDependencies:
'@kan/prettier-config':
specifier: workspace:*
@@ -584,7 +587,7 @@ importers:
version: 8.42.3
eslint:
specifier: 'catalog:'
version: 9.34.0(jiti@2.4.2)
version: 9.34.0(jiti@2.6.1)
prettier:
specifier: 'catalog:'
version: 3.6.2
@@ -637,7 +640,7 @@ importers:
version: link:../typescript
eslint:
specifier: 'catalog:'
version: 9.34.0(jiti@2.4.2)
version: 9.34.0(jiti@2.6.1)
prettier:
specifier: 'catalog:'
version: 3.6.2
@@ -2673,6 +2676,10 @@ 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'}
@@ -2685,6 +2692,10 @@ 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'}
@@ -6157,6 +6168,10 @@ 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==}
@@ -10451,16 +10466,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.4.2))':
'@eslint-community/eslint-utils@4.7.0(eslint@9.34.0(jiti@2.6.1))':
dependencies:
eslint: 9.34.0(jiti@2.4.2)
eslint: 9.34.0(jiti@2.6.1)
eslint-visitor-keys: 3.4.3
'@eslint-community/regexpp@4.12.1': {}
'@eslint/compat@1.3.2(eslint@9.34.0(jiti@2.4.2))':
'@eslint/compat@1.3.2(eslint@9.34.0(jiti@2.6.1))':
optionalDependencies:
eslint: 9.34.0(jiti@2.4.2)
eslint: 9.34.0(jiti@2.6.1)
'@eslint/config-array@0.21.0':
dependencies:
@@ -10871,6 +10886,16 @@ 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
@@ -10878,6 +10903,12 @@ 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)
@@ -12539,15 +12570,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.4.2))(typescript@5.9.2))(eslint@9.34.0(jiti@2.4.2))(typescript@5.9.2)':
'@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)':
dependencies:
'@eslint-community/regexpp': 4.12.1
'@typescript-eslint/parser': 8.41.0(eslint@9.34.0(jiti@2.4.2))(typescript@5.9.2)
'@typescript-eslint/parser': 8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2)
'@typescript-eslint/scope-manager': 8.41.0
'@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/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/visitor-keys': 8.41.0
eslint: 9.34.0(jiti@2.4.2)
eslint: 9.34.0(jiti@2.6.1)
graphemer: 1.4.0
ignore: 7.0.5
natural-compare: 1.4.0
@@ -12556,14 +12587,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.4.2))(typescript@5.9.2)':
'@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2)':
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.4.2)
eslint: 9.34.0(jiti@2.6.1)
typescript: 5.9.2
transitivePeerDependencies:
- supports-color
@@ -12586,13 +12617,13 @@ snapshots:
dependencies:
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/type-utils@8.41.0(eslint@9.34.0(jiti@2.6.1))(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.4.2))(typescript@5.9.2)
'@typescript-eslint/utils': 8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2)
debug: 4.4.3
eslint: 9.34.0(jiti@2.4.2)
eslint: 9.34.0(jiti@2.6.1)
ts-api-utils: 2.1.0(typescript@5.9.2)
typescript: 5.9.2
transitivePeerDependencies:
@@ -12616,13 +12647,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/utils@8.41.0(eslint@9.34.0(jiti@2.4.2))(typescript@5.9.2)':
'@typescript-eslint/utils@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2)':
dependencies:
'@eslint-community/eslint-utils': 4.7.0(eslint@9.34.0(jiti@2.4.2))
'@eslint-community/eslint-utils': 4.7.0(eslint@9.34.0(jiti@2.6.1))
'@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.4.2)
eslint: 9.34.0(jiti@2.6.1)
typescript: 5.9.2
transitivePeerDependencies:
- supports-color
@@ -13948,17 +13979,17 @@ snapshots:
transitivePeerDependencies:
- supports-color
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)):
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)):
dependencies:
debug: 3.2.7
optionalDependencies:
'@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-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-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.4.2))(typescript@5.9.2))(eslint@9.34.0(jiti@2.4.2)):
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)):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
@@ -13967,9 +13998,9 @@ snapshots:
array.prototype.flatmap: 1.3.3
debug: 3.2.7
doctrine: 2.1.0
eslint: 9.34.0(jiti@2.4.2)
eslint: 9.34.0(jiti@2.6.1)
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.4.2))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint@9.34.0(jiti@2.4.2))
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))
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3
@@ -13981,13 +14012,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.4.2))(typescript@5.9.2)
'@typescript-eslint/parser': 8.41.0(eslint@9.34.0(jiti@2.6.1))(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.4.2)):
eslint-plugin-jsx-a11y@6.10.2(eslint@9.34.0(jiti@2.6.1)):
dependencies:
aria-query: 5.3.2
array-includes: 3.1.9
@@ -13997,7 +14028,7 @@ snapshots:
axobject-query: 4.1.0
damerau-levenshtein: 1.0.8
emoji-regex: 9.2.2
eslint: 9.34.0(jiti@2.4.2)
eslint: 9.34.0(jiti@2.6.1)
hasown: 2.0.2
jsx-ast-utils: 3.3.5
language-tags: 1.0.9
@@ -14006,11 +14037,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.4.2)):
eslint-plugin-react-hooks@5.2.0(eslint@9.34.0(jiti@2.6.1)):
dependencies:
eslint: 9.34.0(jiti@2.4.2)
eslint: 9.34.0(jiti@2.6.1)
eslint-plugin-react@7.37.5(eslint@9.34.0(jiti@2.4.2)):
eslint-plugin-react@7.37.5(eslint@9.34.0(jiti@2.6.1)):
dependencies:
array-includes: 3.1.9
array.prototype.findlast: 1.2.5
@@ -14018,7 +14049,7 @@ snapshots:
array.prototype.tosorted: 1.1.4
doctrine: 2.1.0
es-iterator-helpers: 1.2.1
eslint: 9.34.0(jiti@2.4.2)
eslint: 9.34.0(jiti@2.6.1)
estraverse: 5.3.0
hasown: 2.0.2
jsx-ast-utils: 3.3.5
@@ -14032,10 +14063,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.4.2))(turbo@2.5.6):
eslint-plugin-turbo@2.5.6(eslint@9.34.0(jiti@2.6.1))(turbo@2.5.6):
dependencies:
dotenv: 16.0.3
eslint: 9.34.0(jiti@2.4.2)
eslint: 9.34.0(jiti@2.6.1)
turbo: 2.5.6
eslint-scope@5.1.1:
@@ -14094,9 +14125,9 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint@9.34.0(jiti@2.4.2):
eslint@9.34.0(jiti@2.6.1):
dependencies:
'@eslint-community/eslint-utils': 4.7.0(eslint@9.34.0(jiti@2.4.2))
'@eslint-community/eslint-utils': 4.7.0(eslint@9.34.0(jiti@2.6.1))
'@eslint-community/regexpp': 4.12.1
'@eslint/config-array': 0.21.0
'@eslint/config-helpers': 0.3.1
@@ -14132,7 +14163,7 @@ snapshots:
natural-compare: 1.4.0
optionator: 0.9.4
optionalDependencies:
jiti: 2.4.2
jiti: 2.6.1
transitivePeerDependencies:
- supports-color
@@ -14923,6 +14954,8 @@ snapshots:
jiti@2.4.2: {}
jiti@2.6.1: {}
jose@6.1.2: {}
jose@6.1.3: {}
@@ -17685,13 +17718,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.4.2))(typescript@5.9.2):
typescript-eslint@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2):
dependencies:
'@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/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/typescript-estree': 8.41.0(typescript@5.9.2)
'@typescript-eslint/utils': 8.41.0(eslint@9.34.0(jiti@2.4.2))(typescript@5.9.2)
eslint: 9.34.0(jiti@2.4.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: 5.9.2
transitivePeerDependencies:
- supports-color