Compare commits

..

28 Commits

Author SHA1 Message Date
Henry
4353b9620b refactor: move into api/db packages 2026-01-25 22:01:49 +00:00
Henry
327089e10e chore: update compose files and readme 2026-01-25 21:46:38 +00:00
Henry
c53c4b56c2 feat: exclude webhook route from rate limiting 2026-01-25 21:16:18 +00:00
Henry
c94d08b35c feat: wrap withRateLimit on remaining routes 2026-01-25 21:10:02 +00:00
Henry
c9dd45de39 feat: wrap trpc endpoint in withRateLimit 2026-01-25 21:05:25 +00:00
Henry
718a018480 feat: add withRateLimit wrapper 2026-01-25 21:03:34 +00:00
Henry
fae903f384 feat: setup redis client 2026-01-25 21:01:58 +00:00
Henry
bf6a06b7c7 feat: install rate-limiter-flexible and ioredis 2026-01-25 21:01:39 +00:00
Sachin Divekar
c42ee7cf2c fix: disable tooltips on touch devices (#333) 2026-01-25 13:41:59 +00:00
Henry
07f997c3ad fix: update localization for brazilian portuguese (#332)
* fix: update localization for brazilian portuguese

* compile
2026-01-24 22:51:32 +00:00
Sachin Divekar
d084d323bc feat: show user avatar on comments and activity (#331)
Display user profile pictures instead of initials on card comments
and activity items. Fetches the user image field from the database
and passes imageUrl to the Avatar component.

Co-authored-by: Sachin Divekar <sachin.divekar@remiges.tech>
2026-01-24 22:04:46 +00:00
Sachin Divekar
6729c2e228 fix: support virtual-hosted S3 URLs for avatar storage (#330)
Avatar images failed to load with 403 on Tigris and newer AWS S3 buckets
because getAvatarUrl only constructed path-style URLs. Modern S3-compatible
providers require virtual-hosted URLs (bucket.domain/key).

Add NEXT_PUBLIC_STORAGE_DOMAIN env var to signal URL style:
- If set: virtual-hosted URL (Tigris, AWS S3)
- If not set: path-style URL (MinIO, LocalStack) - backward compatible

Add vitest and tests for getAvatarUrl behavior.

Co-authored-by: Sachin Divekar <sachin.divekar@remiges.tech>
2026-01-24 22:02:41 +00:00
Charity
b18ef10313 refactor: simplify drag-to-scroll logic to enable dragging on any non-interactive element within the container (fixes my oopsie) (#328) 2026-01-24 21:43:12 +00:00
Charity
03bd2d771b Hide member emails from non-admins (#327)
* feat(workspace): sanitize member emails for non-admin users in workspace router

* feat(workspace): add email visibility setting and update member display logic

* fix(workspace): update default value for showEmailsToMembers column to true

* - Removed unnecessary type assertions
- Added anonymous name handling (anonymous_[publicId]) in the API
- Removed placeholder logic
- Reverted null check
- Avoided as unknown cast
2026-01-24 21:41:59 +00:00
Redox
ff7944a4a2 Show display name instead of email (#323)
* Show display name instead of email

* Properly pass displayName

* Show email on settings page too

* Show email on missing username
2026-01-24 21:34:38 +00:00
Redox
0ca9c1d0e6 Add NEXT_API_BODY_SIZE_LIMIT config (#325)
* Add `NEXT_API_BODY_SIZE_LIMIT` config

* Add NEXT_API_BODY_SIZE_LIMIT env var to more files
2026-01-24 21:22:32 +00:00
Charity
927bf2fe69 feat: implement horizontal drag-to-scroll functionality in board views (#326) 2026-01-22 19:49:25 +00:00
Redox
ccd84385a2 Add Discord CDN avatars to allowed image sources (#322) 2026-01-22 14:23:25 +00:00
Henry
58c5e92155 feat(cloud): handle cancelled subscriptions (#313)
* feat(cloud): handle cancelled subscriptions

* chore: refactor auth
2026-01-16 20:20:22 +00:00
Henry
885f119404 chore: unshallow git clone 2026-01-15 20:15:19 +00:00
Henry
b17a24455a chore: generate app version during build 2026-01-14 21:21:04 +00:00
Henry
0c9561467d feat: display app version in user menu (#310)
* feat: display app version

* feat: add fetch depth
2026-01-13 12:48:07 +00:00
Henry
47b6f06be4 fix: set min height for new card editor (#305) 2026-01-06 09:13:19 +00:00
Eliott Herbert-Byrnes
89c8961176 feat: add YouTube video embed support for TipTap editor (#294)
* feat: add YouTube video embed support for TipTap editor

- Paste YouTube URLs to auto-embed videos with metadata
- Edit video title and URL through dropdown menu
- Convert embed back to plain URL link
- Fetch video titles automatically via YouTube oEmbed API

* fix:
- reverted NewCardForm CSS properties back to max-h-48.
- Added boolean prop to TipTap Edtior for YouTube embed.
- Disabled YouTube embed feature for NewCardForm Editor

* chore: lint

* chore: translations

* chore: update translations

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
2026-01-05 20:15:07 +00:00
Henry
a5432c60b7 fix: recenter collapsed menu buttons (#303) 2026-01-03 20:55:37 +00:00
Henry
2b8fe3d3a2 fix: extend allowed card title length (#301)
* feat: update card title from varchar to text

* feat: add input length validation

* fix: slice title when longer than max chars during import
2025-12-29 22:50:16 +00:00
Henry
73c9b326a1 fix: map profile to user for OIDC (#297) 2025-12-29 21:45:35 +00:00
Henry
a55c8cd52b fix: update checklist when pressing enter on edit (#292)
* fix: update checklist when pressing enter on edit

* feat: open new checklist item form on enter
2025-12-19 21:40:24 +00:00
95 changed files with 8048 additions and 2413 deletions

View File

@@ -15,4 +15,4 @@ pnpm-debug.log
README.md
.next
.git
# .git

View File

@@ -37,10 +37,17 @@ NEXT_PUBLIC_STORAGE_DOMAIN=
NEXT_PUBLIC_ALLOW_CREDENTIALS=
NEXT_PUBLIC_DISABLE_SIGN_UP=
# API configuration (optional)
NEXT_API_BODY_SIZE_LIMIT= # e.g. 50mb (defaults to 1mb)
# Integration providers (optional)
TRELLO_APP_API_KEY=
TRELLO_APP_SECRET=
# Redis (optional - for rate limiting)
# If not provided, rate limiting will use in-memory storage
REDIS_URL= # e.g. redis://default:your_password@your_host:6379
# OAuth providers (optional)
BETTER_AUTH_TRUSTED_ORIGINS=
# Optional: Restrict OIDC/Social sign-ins to specific email domains (comma-separated)

View File

@@ -34,6 +34,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
# Install the cosign tool except on PR
# https://github.com/sigstore/cosign-installer
@@ -74,6 +76,42 @@ jobs:
type=semver,pattern={{major}}
type=raw,value=latest,enable={{is_default_branch}}
# Extract version from git tag or ref
# Uses git describe to get latest tag + commit hash in SemVer format: 1.2.3+abc1234
- name: Extract version
id: version
run: |
if [[ "${{ github.ref_type }}" == "tag" ]]; then
VERSION="${{ github.ref_name }}"
# Remove 'v' prefix if present
VERSION="${VERSION#v}"
else
# Use git describe and simplify: v1.2.3-5-gabc1234 -> 1.2.3+abc1234
GIT_DESCRIBE=$(git describe --tags --always --long 2>/dev/null || echo "")
if [[ -n "$GIT_DESCRIBE" ]]; then
# Match pattern: v1.2.3-5-gabc1234 (tag-commits-gcommit)
if [[ "$GIT_DESCRIBE" =~ ^v?([0-9]+\.[0-9]+\.[0-9]+)-[0-9]+-g([a-f0-9]+)$ ]]; then
# Format as tag+commit (SemVer build metadata)
TAG_VERSION="${BASH_REMATCH[1]}"
COMMIT_HASH="${BASH_REMATCH[2]}"
VERSION="${TAG_VERSION}+${COMMIT_HASH:0:7}"
elif [[ "$GIT_DESCRIBE" =~ ^v?([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
# Exactly on a tag
VERSION="${BASH_REMATCH[1]}"
else
# Fallback: just commit hash
COMMIT_SHA="${{ github.sha }}"
VERSION="${COMMIT_SHA:0:7}"
fi
else
# No tags exist, use commit hash
COMMIT_SHA="${{ github.sha }}"
VERSION="${COMMIT_SHA:0:7}"
fi
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Version: $VERSION"
# Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action
- name: Build and push Docker image
@@ -86,6 +124,8 @@ jobs:
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
APP_VERSION=${{ steps.version.outputs.version }}
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -141,6 +141,7 @@ pnpm dev
| Variable | Description | Required | Example |
| ----------------------------------------- | --------------------------------------------------------- | ------------------------------------- | ----------------------------------------------------------- |
| `POSTGRES_URL` | PostgreSQL connection URL | To use external database | `postgres://user:pass@localhost:5432/db` |
| `REDIS_URL` | Redis connection URL | For rate limiting (optional) | `redis://localhost:6379` or `redis://redis:6379` (Docker) |
| `EMAIL_FROM` | Sender email address | For Email | `"Kan <hello@mail.kan.bn>"` |
| `SMTP_HOST` | SMTP server hostname | For Email | `smtp.resend.com` |
| `SMTP_PORT` | SMTP server port | For Email | `465` |
@@ -150,6 +151,7 @@ pnpm dev
| `SMTP_REJECT_UNAUTHORIZED` | Reject invalid certificates (defaults to true if not set) | For Email | `false` |
| `NEXT_PUBLIC_DISABLE_EMAIL` | To disable all email features | For Email | `true` |
| `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` |
| `NEXT_API_BODY_SIZE_LIMIT` | Maximum API request body size (defaults to 1mb) | No | `50mb` |
| `BETTER_AUTH_ALLOWED_DOMAINS` | Comma-separated list of allowed domains for OIDC logins | For OIDC/Social login | `example.com,subsidiary.com` |
| `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | No | `http://localhost:3000,http://localhost:3001` |

View File

@@ -19,43 +19,45 @@ RUN pnpm config set store-dir ~/.pnpm-store
# 2. Prune projects
FROM base AS pruner
# https://stackoverflow.com/questions/49681984/how-to-get-version-value-of-package-json-inside-of-dockerfile
# RUN export VERSION=$(npm run version)
ARG PROJECT
# Set working directory
WORKDIR /app
RUN apk add --no-cache git
# It might be the path to <ROOT> turborepo
WORKDIR /app
COPY . .
# Generate a partial monorepo with a pruned lockfile for a target workspace.
# Assuming "@acme/nextjs" is the name entered in the project's package.json: { name: "@acme/nextjs" }
# Generate version from git (fallback if APP_VERSION not provided)
RUN git fetch --tags --unshallow 2>/dev/null || git fetch --tags 2>/dev/null || true && \
AUTO_VERSION=$(git describe --tags --always --long 2>/dev/null | \
sed -E 's/^v?([0-9]+\.[0-9]+\.[0-9]+)-[0-9]+-g([a-f0-9]{7}).*/\1+\2/' | \
sed 's/^v//' || \
git rev-parse --short HEAD 2>/dev/null | head -c 7 || \
echo "unknown") && \
echo "$AUTO_VERSION" > /app/AUTO_VERSION
RUN turbo prune --scope=${PROJECT} --scope=@kan/db --docker
# 3. Build the project
FROM base AS builder
ARG PROJECT
# Environment to skip .env validation on build
ENV CI=true
ARG APP_VERSION
WORKDIR /app
# Copy lockfile and package.json's of isolated subworkspace
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
COPY --from=pruner /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
COPY --from=pruner /app/out/json/ .
COPY --from=pruner /app/AUTO_VERSION /tmp/AUTO_VERSION
ENV CI=true
# First install the dependencies (as they change less often)
RUN --mount=type=cache,id=pnpm,target=~/.pnpm-store pnpm install --frozen-lockfile
# Copy source code of isolated subworkspace
COPY --from=pruner /app/out/full/ .
RUN pnpm build --filter=${PROJECT}
# Use provided APP_VERSION or auto-generated from pruner stage
RUN VERSION="${APP_VERSION:-$(cat /tmp/AUTO_VERSION 2>/dev/null | tr -d '\n\r' || echo 'unknown')}" && \
NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build --filter=${PROJECT}
# # Copy static files to standalone directory
# RUN mkdir -p apps/web/.next/standalone/.next && \

View File

@@ -2,7 +2,7 @@
"version": 0,
"locale": {
"source": "en",
"targets": ["fr", "de", "es", "it", "nl", "ru", "pl", "ptbr"]
"targets": ["fr", "de", "es", "it", "nl", "ru", "pl", "pt-BR"]
},
"buckets": {
"po": {

View File

@@ -39,6 +39,7 @@ checksums:
Adjust%20the%20square%20crop%20to%20fit%20your%20avatar./singular: a4df26bbce6f14c6962fac1324db00a8
Admin%20roles/singular: 32a5d78073b9bb9a246773afba8831df
All%20systems%20operational/singular: ee943a4046b09e6334cceeea9fda2bfc
Allow%20workspace%20members%20to%20see%20each%20other's%20email%20addresses/singular: 0077436d9f37bfd64f3ae076a5a05040
Already%20have%20an%20account%3F%20%3C0%3E%3C1%3ESign%20in%3C%2F1%3E%3C%2F0%3E/singular: 2959fd276248208b65cb27ed46b20135
An%20error%20occurred%20while%20disconnecting%20your%20Trello%20account./singular: 0aa3973b860c1faf8d9123aebf567e40
An%20unexpected%20error%20occurred.%20Please%20try%20again%20later./singular: 1b5749b0cca6a62d75a577bee6804074
@@ -123,6 +124,7 @@ checksums:
Continue%20with%20/singular: 8ed03cf7c5e60a6edf3470a4558ff058
Continue%20with%20%7B0%7D/singular: 2eaf6e1da91e208f7c5fb6bf862fe8a6
Control%20who%20can%20view%20and%20edit%20your%20boards./singular: 2a7e0bec29bac26280de707e2fe8bce5
Convert%20to%20link/singular: 66210d2889031426c07f0b2c6c4c09d7
Core%20features/singular: da95932e7a1465a5d21aa3b855a46dc2
Create%20%7B0%7D/singular: d37c29be6ccc0eb6062237f8508c3178
Create%20another/singular: 2de8a82a416eb78c0462aa36278edc9a
@@ -183,14 +185,18 @@ checksums:
Due%20next%20week/singular: 2f8fac5719df18d25a466ee913dc6487
Due%20today/singular: a14cb9dd0003485894d328bb803c80e5
Due%20tomorrow/singular: 0b6c8ad7aba0873b7e212d5f1949ca97
Edit/singular: eee7f39ff90b18852afc1671f21fbaa9
Edit%20board%20URL/singular: d8276dfc0189f371ec7d80a2047c07aa
Edit%20comment/singular: 7e4b46525fcb6b47b71798e31c46e374
Edit%20label/singular: 0309e0be1512b1e0b0ceb87c69a53d03
Edit%20workspace%20URL/singular: bbae5f2f8a442947d33099979bbbe899
Edit%20YouTube%20Video/singular: 4899d9e990d291eb6e71ee40a8ee314b
Editing/singular: 3449a7988cd69207b7c6929af1f4abf1
email/singular: f31eb214738e037d58e26149797739df
Email/singular: e7f34943a0c2fb849db1839ff6ef5cb5
Email%20visibility/singular: 81d41cf573a7109c376d30d905beb596
Enhancement/singular: 785fe23c0eef0a5b60b5b2a88151de31
Enter%20a%20custom%20title/singular: f002074db0bd51d4f28d2736e140370a
Enter%20your%20current%20password/singular: bfceabde4c0b6f2cb439015b76549651
Enter%20your%20current%20password%20and%20choose%20a%20new%20secure%20password./singular: 9bb88155b18e98ea799c0e939d16af64
Enter%20your%20email%20address/singular: 9bc008365ebe3e404e241c8ca876f56e
@@ -220,6 +226,7 @@ checksums:
Failed%20to%20accept%20invitation.%20Please%20try%20again%20later%2C%20or%20contact%20customer%20support./singular: e4505a9df3a81e93a8a8b103c6e3ebc4
Failed%20to%20copy%20invite%20link/singular: 635884d5ed8d6ee20b85a003939b4ae7
Failed%20to%20create%20board/singular: a746e2afe881c3bf0a8e82a931ca3495
Failed%20to%20fetch%20video%20information/singular: 1e3fd610e8ed3e6c4c66e6ce88fc8b7a
Failed%20to%20login%20with%20%7B0%7D.%20Please%20try%20again./singular: 669a4b4247a73f53fb9b8b16e42d166f
Failed%20to%20upload%20attachment.%20Please%20try%20again./singular: f8a50d1c8491404f73d3cf701e11f297
FAQ/singular: 47e0ee2eb40b4e7e732e05e2233fc71c
@@ -391,6 +398,7 @@ checksums:
Please%20enter%20a%20valid%20email%20address/singular: 8de4bc8832b11b380bc4cbcedc16e48b
Please%20enter%20a%20valid%20name/singular: f2d741f1b5cae722e35cb5206786f932
Please%20enter%20a%20valid%20password/singular: 4b32c17e19b79bcbf0bb092c06ba310f
Please%20enter%20a%20valid%20YouTube%20URL/singular: c16c69c3b742b1e19148378d50adf37f
Please%20select%20a%20file%20to%20upload./singular: de315bf594047f8ef9307a7fa9285844
Please%20try%20again%20later%2C%20or%20contact%20customer%20support./singular: 21ffcf0b00e7cd7b64f7454a95762e1d
Please%20try%20again%20later./singular: 325dea6dd0348a27a6818db2c1340c98
@@ -496,6 +504,7 @@ checksums:
This%20workspace%20URL%20has%20already%20been%20taken/singular: b455329e2a71da677acab91d3a00bad6
This%20workspace%20URL%20is%20reserved/singular: 7e47c892b93d4334c1606010c06e875e
This%20workspace%20username%20has%20already%20been%20taken/singular: b7eadb89c615874f416d9658d0428c4c
Title/singular: 344e64395eaff6822a57d18623853e1a
To%20Do/singular: d60813ea824f373462471e092d136eed
Toggle%20menu/singular: 29dea3e0b6238874f8c7a27619df8e36
Track%20all%20card%20changes%20with%20detailed%20activity%20history./singular: 0d3bac559c71ec4b8734f9f212320de5
@@ -617,3 +626,4 @@ checksums:
Your%20workspace%20has%20been%20deleted./singular: e7a3efcfc7dd18cb3e917acb67498292
Your%20workspace%20name%20has%20been%20updated./singular: a87ea3b0d71e6dc5dd525d77114a9322
Your%20workspace%20slug%20has%20been%20updated./singular: c808949b9b2b4a9aba2472f5d1050167
YouTube%20URL/singular: 0b48896061a1124501fdaba026804148

View File

@@ -1,7 +1,7 @@
import type { LinguiConfig } from "@lingui/conf";
const config: LinguiConfig = {
locales: ["en", "fr", "de", "es", "it", "nl", "ru", "pl","ptbr"],
locales: ["en", "fr", "de", "es", "it", "nl", "ru", "pl","pt-BR"],
sourceLocale: "en",
catalogs: [
{

View File

@@ -50,6 +50,11 @@ const config = {
protocol: "https",
hostname: "*.googleusercontent.com",
},
{
protocol: 'https',
hostname: 'cdn.discordapp.com',
pathname: '/avatars/**',
},
];
// Extract root domain from S3_ENDPOINT and add wildcard pattern
@@ -90,6 +95,12 @@ const config = {
swcPlugins: [["@lingui/swc-plugin", {}]],
},
api: {
bodyParser: {
sizeLimit: env("NEXT_API_BODY_SIZE_LIMIT") || '1mb',
},
},
async rewrites() {
return [
{

View File

@@ -10,6 +10,8 @@
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"start": "pnpm with-env next start",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"with-env": "dotenv -e ../../.env --",
"lingui:extract": "lingui extract",
@@ -86,7 +88,8 @@
"jiti": "^1.21.6",
"prettier": "catalog:",
"tailwindcss": "catalog:",
"typescript": "catalog:"
"typescript": "catalog:",
"vitest": "^3.0.0"
},
"prettier": "@kan/prettier-config"
}

View File

@@ -155,7 +155,7 @@ export default function Dashboard({
className={`fixed top-12 z-40 h-[calc(100dvh-3rem)] w-[calc(100vw-1.5rem)] transform transition-transform duration-300 ease-in-out md:relative md:top-0 md:h-full md:w-auto md:translate-x-0 ${isSideNavOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"} `}
>
<SideNavigation
user={{ email: session?.user.email, image: session?.user.image }}
user={{ displayName: session?.user.name, email: session?.user.email, image: session?.user.image }}
isLoading={sessionLoading}
onCloseSideNav={closeSideNav}
/>

View File

@@ -1,3 +1,4 @@
import type { Range as TiptapRange } from "@tiptap/core";
import type { Editor as TiptapEditor } from "@tiptap/react";
import type {
SuggestionKeyDownProps,
@@ -44,6 +45,7 @@ import { Markdown } from "tiptap-markdown";
import { getAvatarUrl } from "~/utils/helpers";
import Avatar from "./Avatar";
import { YouTubeNode } from "./YouTubeEmbed/YouTubeNode";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
@@ -56,7 +58,7 @@ declare module "@tiptap/core" {
export interface SlashCommandItem {
title: string;
icon?: React.ReactNode;
command?: (props: { editor: TiptapEditor; range: Range }) => void;
command?: (props: { editor: TiptapEditor; range: TiptapRange }) => void;
disabled?: boolean;
}
@@ -431,12 +433,14 @@ export default function Editor({
onBlur,
readOnly = false,
workspaceMembers,
enableYouTubeEmbed = true,
}: {
content: string | null;
onChange?: (value: string) => void;
onBlur?: () => void;
readOnly?: boolean;
workspaceMembers: WorkspaceMember[];
enableYouTubeEmbed?: boolean;
}) {
const containerRef = useRef<HTMLDivElement>(null);
@@ -484,10 +488,17 @@ export default function Editor({
}),
);
const q = query.toLowerCase();
return all.filter((u) => u.label.toLowerCase().includes(q));
return all.filter(
(u) =>
u.label &&
typeof u.label === "string" &&
u.label.toLowerCase().includes(q),
);
},
command: ({ editor, range, props }: any) => {
const mentionHTML = `<span data-type="mention" data-id="${props.id}" data-label="${props.label}">@${props.label}</span>&nbsp;`;
command: ({ editor, range, props }) => {
const id = props.id ?? "";
const label = props.label ?? "";
const mentionHTML = `<span data-type="mention" data-id="${id}" data-label="${label}">@${label}</span>&nbsp;`;
editor
.chain()
@@ -503,6 +514,7 @@ export default function Editor({
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`;
},
}),
...(enableYouTubeEmbed ? [YouTubeNode] : []),
],
content,
onUpdate: ({ editor }) => onChange?.(editor.getHTML()),
@@ -559,6 +571,9 @@ export default function Editor({
text-decoration: none;
font-weight: 500;
}
.tiptap [data-youtube] {
margin: 1rem 0;
}
`}</style>
{!readOnly && editor && <EditorBubbleMenu editor={editor} />}
<EditorContent

View File

@@ -46,7 +46,8 @@ const Button: React.FC<{
onMouseEnter={handleMouseEnter}
onClick={handleClick}
className={twMerge(
"group flex h-[34px] items-center justify-between rounded-md p-1.5 text-sm font-normal leading-6 hover:bg-light-200 hover:text-light-1000 dark:hover:bg-dark-200 dark:hover:text-dark-1000",
"group flex h-[34px] items-center rounded-md p-1.5 text-sm font-normal leading-6 hover:bg-light-200 hover:text-light-1000 dark:hover:bg-dark-200 dark:hover:text-dark-1000",
isCollapsed ? "md:justify-center" : "justify-between",
current
? "bg-light-200 text-light-1000 dark:bg-dark-200 dark:text-dark-1000"
: "text-neutral-600 dark:bg-dark-100 dark:text-dark-900",

View File

@@ -39,6 +39,7 @@ interface SideNavigationProps {
}
interface UserType {
displayName?: string | null | undefined;
email?: string | null | undefined;
image?: string | null | undefined;
}
@@ -206,7 +207,8 @@ export default function SideNavigation({
<div className="space-y-2">
<UserMenu
email={user.email ?? ""}
displayName={user.displayName ?? undefined}
email={user.email ?? "Email not provided?"}
imageUrl={user.image ?? undefined}
isLoading={isLoading}
isCollapsed={isCollapsed}

View File

@@ -35,6 +35,7 @@ export function Tooltip({
delay,
interactive: false,
theme: "tooltip",
touch: false,
});
return () => {

View File

@@ -9,6 +9,7 @@ import { twMerge } from "tailwind-merge";
import { authClient } from "@kan/auth/client";
import { env } from "~/env";
import { useIsMobile } from "~/hooks/useMediaQuery";
import { useKeyboardShortcuts } from "~/providers/keyboard-shortcuts";
import { useModal } from "~/providers/modal";
@@ -16,6 +17,7 @@ import { getAvatarUrl } from "~/utils/helpers";
interface UserMenuProps {
imageUrl: string | undefined;
displayName: string | undefined;
email: string;
isLoading: boolean;
isCollapsed?: boolean;
@@ -25,6 +27,7 @@ interface UserMenuProps {
export default function UserMenu({
imageUrl,
email,
displayName,
isLoading,
isCollapsed = false,
onCloseSideNav,
@@ -74,7 +77,7 @@ export default function UserMenu({
) : (
<Menu.Button
className="flex w-full items-center rounded-md p-1.5 text-neutral-900 hover:bg-light-200 dark:text-dark-900 dark:hover:bg-dark-200 dark:hover:text-dark-1000"
title={isCollapsed ? email : undefined}
title={isCollapsed ? displayName ?? email : undefined}
>
{avatarUrl ? (
<Image
@@ -101,7 +104,7 @@ export default function UserMenu({
isCollapsed && "md:hidden",
)}
>
{email}
{displayName ?? email}
</span>
</Menu.Button>
)}
@@ -225,6 +228,25 @@ export default function UserMenu({
</button>
</Menu.Item>
</div>
{env.NEXT_PUBLIC_APP_VERSION && (
<div className="light-border-600 border-t-[1px] p-1 dark:border-dark-600">
<Menu.Item>
<Link
href={
env.NEXT_PUBLIC_APP_VERSION.includes("+")
? `https://github.com/kanbn/kan/commit/${env.NEXT_PUBLIC_APP_VERSION.split("+")[1]}`
: `https://github.com/kanbn/kan/releases/tag/v${env.NEXT_PUBLIC_APP_VERSION}`
}
target="_blank"
rel="noreferrer"
onClick={handleLinkClick}
className="flex w-full items-center justify-center rounded-[5px] px-3 py-2 text-center text-xs text-light-900 hover:bg-light-200 dark:text-dark-900 dark:hover:bg-dark-400"
>
Version: {env.NEXT_PUBLIC_APP_VERSION}
</Link>
</Menu.Item>
</div>
)}
</div>
</Menu.Items>
</Transition>

View File

@@ -0,0 +1,189 @@
import { t } from "@lingui/core/macro";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { HiXMark } from "react-icons/hi2";
import Button from "~/components/Button";
import Input from "~/components/Input";
import { useModal } from "~/providers/modal";
import { fetchYouTubeMetadata, isYouTubeUrl } from "./utils";
interface EditYouTubeFormInput {
url: string;
title: string;
}
interface EditYouTubeModalState {
url: string;
title: string;
onSave: (url: string, title: string) => void;
}
export function EditYouTubeModal() {
const { closeModal, getModalState } = useModal();
const [isValidating, setIsValidating] = useState(false);
const [urlError, setUrlError] = useState<string | null>(null);
// Get initial values and callback from modal state
const modalState = getModalState("EDIT_YOUTUBE") as
| EditYouTubeModalState
| undefined;
const initialUrl = modalState?.url ?? "";
const initialTitle = modalState?.title ?? "";
const onSave = modalState?.onSave;
const { register, handleSubmit, watch, reset } =
useForm<EditYouTubeFormInput>({
defaultValues: {
url: initialUrl,
title: initialTitle,
},
});
// Reset form when modal state changes (when modal opens with new values)
useEffect(() => {
if (modalState) {
reset({
url: modalState.url,
title: modalState.title,
});
}
}, [modalState, reset]);
const currentUrl = watch("url");
const onSubmit = async (values: EditYouTubeFormInput) => {
// Validate URL
if (!isYouTubeUrl(values.url)) {
setUrlError(t`Please enter a valid YouTube URL`);
return;
}
setUrlError(null);
setIsValidating(true);
try {
// If title is empty and URL changed, fetch new title
let finalTitle = values.title;
if (!finalTitle.trim() && values.url !== initialUrl) {
const metadata = await fetchYouTubeMetadata(values.url);
finalTitle = metadata?.title ?? "YouTube Video";
} else if (!finalTitle.trim()) {
// Keep existing title if no new title provided and URL unchanged
finalTitle = initialTitle || "YouTube Video";
}
if (onSave) {
onSave(values.url, finalTitle);
}
closeModal();
} catch (error) {
console.error(error);
setUrlError(t`Failed to fetch video information`);
} finally {
setIsValidating(false);
}
};
// Auto-focus on title input (more useful for editing)
useEffect(() => {
const titleElement: HTMLElement | null =
document.querySelector<HTMLElement>("#youtube-title");
if (titleElement) titleElement.focus();
}, []);
// Validate URL on change
useEffect(() => {
if (currentUrl && !isYouTubeUrl(currentUrl)) {
setUrlError(t`Please enter a valid YouTube URL`);
} else {
setUrlError(null);
}
}, [currentUrl]);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="px-5 pt-5">
<div className="flex w-full items-center justify-between pb-4 text-neutral-900 dark:text-dark-1000">
<h2 className="text-sm font-medium">{t`Edit YouTube Video`}</h2>
<button
type="button"
className="rounded p-1 hover:bg-light-300 focus:outline-none dark:hover:bg-dark-300"
onClick={(e) => {
e.preventDefault();
closeModal();
}}
>
<HiXMark size={18} className="text-light-900 dark:text-dark-900" />
</button>
</div>
<div className="space-y-4">
<div>
<label
htmlFor="youtube-title"
className="mb-2 block text-xs font-medium text-light-900 dark:text-dark-900"
>
{t`Title`}
</label>
<Input
id="youtube-title"
placeholder={t`Enter a custom title`}
{...register("title")}
onKeyDown={async (e) => {
if (e.key === "Enter") {
e.preventDefault();
await handleSubmit(onSubmit)();
}
}}
/>
</div>
<div>
<label
htmlFor="youtube-url"
className="mb-2 block text-xs font-medium text-light-900 dark:text-dark-900"
>
{t`YouTube URL`}
</label>
<Input
id="youtube-url"
placeholder={t`https://www.youtube.com/watch?v=...`}
{...register("url", { required: true })}
onKeyDown={async (e) => {
if (e.key === "Enter") {
e.preventDefault();
await handleSubmit(onSubmit)();
}
}}
/>
{urlError && (
<p className="mt-1 text-xs text-red-600 dark:text-red-400">
{urlError}
</p>
)}
</div>
</div>
</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">
<div className="space-x-2">
<Button
type="button"
variant="secondary"
onClick={() => closeModal()}
>
{t`Cancel`}
</Button>
<Button
type="submit"
isLoading={isValidating}
disabled={!watch("url") || !!urlError}
>
{t`Save`}
</Button>
</div>
</div>
</form>
);
}

View File

@@ -0,0 +1,53 @@
import YouTubeDropdown from "./YouTubeDropdown";
interface YouTubeCardProps {
videoId: string;
url: string;
title: string;
showEmbed?: boolean;
onConvertToLink: () => void;
onDelete: () => void;
onUpdate: (url: string, title: string) => void;
}
const YouTubeCard = ({
videoId,
url,
title,
showEmbed = true,
onConvertToLink,
onDelete,
onUpdate,
}: YouTubeCardProps) => {
return (
<div className="w-full max-w-md rounded-lg border border-light-300 bg-light-50 dark:border-dark-300 dark:bg-dark-50">
<div className="flex items-center justify-between gap-6 p-0 px-6">
<h3 className="truncate text-sm font-medium">{title}</h3>
<div className="mt-3">
<YouTubeDropdown
url={url}
title={title}
onConvertToLink={onConvertToLink}
onDelete={onDelete}
onUpdate={onUpdate}
/>
</div>
</div>
{showEmbed && videoId && (
<div className="p-6 pt-1">
<iframe
src={`https://www.youtube.com/embed/${videoId}?rel=0`}
title={title}
className="aspect-video w-full rounded-lg"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerPolicy="strict-origin-when-cross-origin"
allowFullScreen
/>
</div>
)}
</div>
);
};
export default YouTubeCard;

View File

@@ -0,0 +1,63 @@
import { t } from "@lingui/core/macro";
import {
HiEllipsisHorizontal,
HiLink,
HiPencil,
HiTrash,
} from "react-icons/hi2";
import { useModal } from "~/providers/modal";
import Dropdown from "../Dropdown";
interface YouTubeDropdownProps {
url: string;
title: string;
onConvertToLink: () => void;
onDelete: () => void;
onUpdate: (url: string, title: string) => void;
}
const YouTubeDropdown = ({
url,
title,
onConvertToLink,
onDelete,
onUpdate,
}: YouTubeDropdownProps) => {
const { openModal, setModalState } = useModal();
const handleEdit = () => {
setModalState("EDIT_YOUTUBE", {
url,
title,
onSave: onUpdate,
});
openModal("EDIT_YOUTUBE");
};
return (
<Dropdown
items={[
{
label: t`Edit`,
action: handleEdit,
icon: <HiPencil className="h-4 w-4 text-dark-900" />,
},
{
label: t`Convert to link`,
action: onConvertToLink,
icon: <HiLink className="h-4 w-4 text-dark-900" />,
},
{
label: t`Delete`,
action: onDelete,
icon: <HiTrash className="h-4 w-4 text-dark-900" />,
},
]}
>
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
</Dropdown>
);
};
export default YouTubeDropdown;

View File

@@ -0,0 +1,196 @@
import { mergeAttributes, Node } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { extractVideoId, fetchYouTubeMetadata, isYouTubeUrl } from "./utils";
import YouTubeNodeView from "./YouTubeNodeView";
export interface YouTubeOptions {
inline: boolean;
HTMLAttributes: Record<string, undefined>;
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
youTube: {
setYouTubeEmbed: (options: {
videoId: string;
url: string;
title: string;
thumbnailUrl?: string;
showEmbed?: boolean;
}) => ReturnType;
};
}
}
export const YouTubeNode = Node.create<YouTubeOptions>({
name: "youtube",
group: "block",
atom: true,
addOptions() {
return {
inline: false,
HTMLAttributes: {},
};
},
addAttributes() {
return {
videoId: {
default: null,
parseHTML: (element) => element.getAttribute("data-video-id"),
renderHTML: (attributes) => {
if (!attributes.videoId) return {};
return { "data-video-id": attributes.videoId as string };
},
},
url: {
default: null,
parseHTML: (element) => element.getAttribute("data-url"),
renderHTML: (attributes) => {
if (!attributes.url) return {};
return { "data-url": attributes.url as string };
},
},
title: {
default: "YouTube Video",
parseHTML: (element) => element.getAttribute("data-title"),
renderHTML: (attributes) => {
return { "data-title": attributes.title as string };
},
},
thumbnailUrl: {
default: null,
parseHTML: (element) => element.getAttribute("data-thumbnail"),
renderHTML: (attributes) => {
if (!attributes.thumbnailUrl) return {};
return { "data-thumbnail": attributes.thumbnailUrl as string };
},
},
showEmbed: {
default: true,
parseHTML: (element) => element.getAttribute("data-show-embed"),
renderHTML: (attributes) => {
return { "data-show-embed": attributes.showEmbed as string };
},
},
};
},
parseHTML() {
return [
{
tag: "div[data-youtube]",
},
];
},
renderHTML({ HTMLAttributes }) {
return [
"div",
mergeAttributes(
{ "data-youtube": "" },
this.options.HTMLAttributes,
HTMLAttributes,
),
];
},
addNodeView() {
return ReactNodeViewRenderer(YouTubeNodeView);
},
addProseMirrorPlugins() {
const nodeType = this.type;
return [
new Plugin({
key: new PluginKey("youtubePaste"),
props: {
handlePaste: (view, event) => {
const text = event.clipboardData?.getData("text/plain");
if (!text) return false;
// Check if the pasted text is a YouTube URL
const youtubeRegex =
/(https?:\/\/)?(www\.)?(youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([\w-]+)/;
const match = youtubeRegex.exec(text);
if (!match || !isYouTubeUrl(text)) return false;
const videoId = extractVideoId(text);
if (!videoId) return false;
const { state, dispatch } = view;
const { tr } = state;
const node = nodeType.create({
videoId,
url: text,
title: "Loading...",
showEmbed: true,
});
tr.replaceSelectionWith(node);
dispatch(tr);
// Fetch metadata asynchronously and update the node
void fetchYouTubeMetadata(text).then((metadata) => {
const { state: newState, dispatch: newDispatch } = view;
const { tr: newTr } = newState;
newState.doc.descendants((n, pos) => {
if (
n.type.name === "youtube" &&
n.attrs.videoId === videoId &&
n.attrs.title === "Loading..."
) {
newTr.setNodeMarkup(pos, undefined, {
...n.attrs,
title: metadata?.title ?? "YouTube Video",
thumbnailUrl: metadata?.thumbnail_url ?? null,
});
return false;
}
return true;
});
if (newTr.docChanged) {
newDispatch(newTr);
}
});
return true;
},
},
}),
];
},
// commands for the YouTube embed node
addCommands() {
return {
setYouTubeEmbed:
(options: {
videoId: string;
url: string;
title?: string;
thumbnailUrl?: string;
showEmbed?: boolean;
}) =>
({ editor }) => {
return editor.commands.insertContent({
type: this.name,
attrs: {
videoId: options.videoId,
url: options.url,
title: options.title ?? "YouTube Video",
thumbnailUrl: options.thumbnailUrl,
showEmbed: options.showEmbed ?? true,
},
});
},
};
},
});

View File

@@ -0,0 +1,82 @@
import type { NodeViewProps } from "@tiptap/react";
import { NodeViewWrapper } from "@tiptap/react";
import { extractVideoId } from "./utils";
import YouTubeCard from "./YouTubeCard";
export default function YouTubeNodeView({
node,
editor,
getPos,
deleteNode,
updateAttributes,
}: NodeViewProps) {
const { videoId, title, url, showEmbed } = node.attrs as {
videoId: string;
title: string;
url: string;
showEmbed: boolean;
};
const handleConvertToLink = () => {
const pos = getPos();
if (typeof pos !== "number") return;
const linkContent = {
type: "paragraph",
content: [
{
type: "text",
text: url,
marks: [
{
type: "link",
attrs: {
href: url,
target: "_blank",
},
},
],
},
],
};
editor
.chain()
.focus()
.deleteRange({ from: pos, to: pos + node.nodeSize })
.insertContentAt(pos, linkContent)
.run();
};
const handleDelete = () => {
deleteNode();
};
const handleUpdate = (newUrl: string, newTitle: string) => {
if (newUrl !== url) {
const newVideoId = extractVideoId(newUrl);
updateAttributes({
url: newUrl,
title: newTitle,
videoId: newVideoId,
});
} else {
updateAttributes({ title: newTitle });
}
};
return (
<NodeViewWrapper>
<YouTubeCard
videoId={videoId}
url={url}
title={title}
showEmbed={showEmbed}
onConvertToLink={handleConvertToLink}
onDelete={handleDelete}
onUpdate={handleUpdate}
/>
</NodeViewWrapper>
);
}

View File

@@ -0,0 +1,61 @@
export function extractVideoId(url: string): string | null {
if (!url) return null;
url = url.trim();
// youtube.com/watch?v=VIDEO_ID
const watchMatch = /(?:youtube\.com\/watch\?v=)([a-zA-Z0-9_-]{11})/.exec(url);
if (watchMatch) return watchMatch[1] ?? null;
// youtu.be/VIDEO_ID
const shortMatch = /(?:youtu\.be\/)([a-zA-Z0-9_-]{11})/.exec(url);
if (shortMatch) return shortMatch[1] ?? null;
// youtube.com/embed/VIDEO_ID
const embedMatch = /(?:youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/.exec(url);
if (embedMatch) return embedMatch[1] ?? null;
return null;
}
/**
* Check if a URL is a valid YouTube link
*/
export function isYouTubeUrl(url: string): boolean {
if (!url) return false;
const videoId = extractVideoId(url);
return videoId !== null;
}
/**
* Fetch YouTube video metadata using oEmbed API
* Returns title, author, thumbnail URL, etc.
* No API key required
*/
export async function fetchYouTubeMetadata(url: string): Promise<{
title: string;
author_name: string;
thumbnail_url: string;
} | null> {
try {
const oembedUrl = `https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`;
const response = await fetch(oembedUrl);
if (!response.ok) {
return null;
}
const data = (await response.json()) as {
title: string;
author_name: string;
thumbnail_url: string;
};
return {
title: data.title || "YouTube Video",
author_name: data.author_name || "",
thumbnail_url: data.thumbnail_url || "",
};
} catch (error) {
console.error("Failed to fetch YouTube metadata:", error);
return null;
}
}

View File

@@ -78,6 +78,7 @@ export const env = createEnv({
S3_ENDPOINT: z.string().optional(),
S3_FORCE_PATH_STYLE: z.string().optional(),
EMAIL_FROM: z.string().optional(),
REDIS_URL: z.string().url().optional(),
},
/**
@@ -95,6 +96,7 @@ export const env = createEnv({
NEXT_PUBLIC_AVATAR_BUCKET_NAME: z.string().optional(),
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME: z.string().optional(),
NEXT_PUBLIC_STORAGE_DOMAIN: z.string().optional(),
NEXT_PUBLIC_APP_VERSION: z.string().optional(),
NEXT_PUBLIC_ALLOW_CREDENTIALS: z
.string()
.transform((s) => (s === "" ? undefined : s))
@@ -132,6 +134,7 @@ export const env = createEnv({
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME:
process.env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME,
NEXT_PUBLIC_STORAGE_DOMAIN: process.env.NEXT_PUBLIC_STORAGE_DOMAIN,
NEXT_PUBLIC_APP_VERSION: process.env.NEXT_PUBLIC_APP_VERSION,
NEXT_PUBLIC_ALLOW_CREDENTIALS: process.env.NEXT_PUBLIC_ALLOW_CREDENTIALS,
NEXT_PUBLIC_DISABLE_SIGN_UP: process.env.NEXT_PUBLIC_DISABLE_SIGN_UP,
NEXT_PUBLIC_USE_STANDALONE_OUTPUT:

View File

@@ -0,0 +1,101 @@
import { useEffect, useRef, useState } from "react";
interface UseDragToScrollOptions {
/**
* Whether drag-to-scroll is enabled
*/
enabled?: boolean;
/**
* The direction to scroll
*/
direction?: "horizontal" | "vertical" | "both";
}
/**
* Hook to enable drag-to-scroll functionality on a scrollable element
* @param options Configuration options
* @returns Ref to attach to the scrollable element and mouse event handlers
*/
export function useDragToScroll({
enabled = true,
direction = "horizontal",
}: UseDragToScrollOptions = {}) {
const scrollRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const startPosRef = useRef({ x: 0, y: 0 });
const scrollStartRef = useRef({ x: 0, y: 0 });
useEffect(() => {
if (!enabled || !isDragging) return;
const handleMouseMove = (e: MouseEvent) => {
if (!scrollRef.current) return;
const deltaX = e.clientX - startPosRef.current.x;
const deltaY = e.clientY - startPosRef.current.y;
if (direction === "horizontal" || direction === "both") {
scrollRef.current.scrollLeft = scrollStartRef.current.x - deltaX;
}
if (direction === "vertical" || direction === "both") {
scrollRef.current.scrollTop = scrollStartRef.current.y - deltaY;
}
};
const handleMouseUp = () => {
setIsDragging(false);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
document.body.style.cursor = "grabbing";
document.body.style.userSelect = "none";
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
document.body.style.cursor = "";
document.body.style.userSelect = "";
};
}, [enabled, isDragging, direction]);
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
if (!scrollRef.current) return;
const target = e.target as HTMLElement;
const container = scrollRef.current;
// Check if the click is on an interactive or draggable element
// We need to be careful not to interfere with react-beautiful-dnd dragging
const isInteractiveElement =
target.closest("a") ||
target.closest("button") ||
target.closest("input") ||
target.closest("textarea") ||
target.closest("[role='button']") ||
target.closest("[draggable='true']") ||
target.closest(".react-beautiful-dnd-drag-handle") ||
target.closest("[data-rbd-drag-handle-draggable-id]") ||
target.closest("[data-rbd-draggable-id]");
// Don't start dragging if clicking on interactive elements
if (isInteractiveElement) return;
// Enable drag-to-scroll for any non-interactive element within the container
if (container.contains(target)) {
e.preventDefault();
setIsDragging(true);
startPosRef.current = { x: e.clientX, y: e.clientY };
scrollStartRef.current = {
x: container.scrollLeft,
y: container.scrollTop,
};
}
};
return {
ref: scrollRef,
onMouseDown: handleMouseDown,
isDragging,
};
}

View File

@@ -19,7 +19,7 @@ export function useLocalisation() {
nl,
ru,
pl,
ptbr: ptBR,
"pt-BR": ptBR,
};
const currentDateLocale = dateLocaleMap[locale] ?? enGB;

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

View File

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

File diff suppressed because it is too large Load Diff

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

View File

@@ -2,9 +2,17 @@ import { toNodeHandler } from "better-auth/node";
import { initAuth } from "@kan/auth/server";
import { createDrizzleClient } from "@kan/db/client";
import { withRateLimit } from "@kan/api/utils/rateLimit";
export const config = { api: { bodyParser: false } };
export const auth = initAuth(createDrizzleClient());
export default toNodeHandler(auth.handler);
const authHandler = toNodeHandler(auth.handler);
export default withRateLimit(
{ points: 100, duration: 60 },
async (req, res) => {
return await authHandler(req, res);
},
);

View File

@@ -1,9 +1,10 @@
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
import { withRateLimit } from "@kan/api/utils/rateLimit";
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" });
}
@@ -44,4 +45,5 @@ export default async function handler(
console.error("Error downloading attachment:", error);
return res.status(500).json({ message: "Failed to download attachment" });
}
}
},
);

View File

@@ -1,9 +1,10 @@
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
import { withRateLimit } from "@kan/api/utils/rateLimit";
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" });
}
@@ -20,4 +21,5 @@ export default async function handler(
console.error("Error fetching OSS friends:", error);
return res.status(500).json({ message: "Failed to fetch OSS friends" });
}
}
},
);

View File

@@ -3,11 +3,11 @@ import { env } from "next-runtime-env";
import { createNextApiContext } from "@kan/api/trpc";
import { createStripeClient } from "@kan/stripe";
import { withRateLimit } from "@kan/api/utils/rateLimit";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
export default withRateLimit(
{ points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => {
const stripe = createStripeClient();
if (req.method !== "POST") {
@@ -31,4 +31,5 @@ export default async function handler(
console.error("Error:", error);
return res.status(500).json({ error: "Error creating portal session" });
}
}
},
);

View File

@@ -6,6 +6,7 @@ import { createNextApiContext } from "@kan/api/trpc";
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { createStripeClient } from "@kan/stripe";
import { withRateLimit } from "@kan/api/utils/rateLimit";
const workspaceSlugSchema = z
.string()
@@ -21,10 +22,9 @@ interface CheckoutSessionRequest {
stripeCustomerId: string;
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
export default withRateLimit(
{ points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => {
const stripe = createStripeClient();
if (req.method !== "POST") {
@@ -115,4 +115,5 @@ export default async function handler(
console.error("Error:", error);
return res.status(500).json({ error: "Error creating checkout session" });
}
}
},
);

View File

@@ -3,11 +3,11 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { createNextApiContext } from "@kan/api/trpc";
import { integrations } from "@kan/db/schema";
import { addYears } from "date-fns";
import { withRateLimit } from "@kan/api/utils/rateLimit";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
export default withRateLimit(
{ points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "POST") {
return res.status(405).json({ message: "Method not allowed" });
}
@@ -48,4 +48,5 @@ export default async function handler(
console.error("Trello authentication error:", err);
return res.status(400).json({ message: "Trello authentication failed" });
}
}
},
);

View File

@@ -3,12 +3,14 @@ import { createNextApiHandler } from "@trpc/server/adapters/next";
import { appRouter } from "@kan/api/root";
import { createTRPCContext } from "@kan/api/trpc";
import { env } from "~/env";
import { withRateLimit } from "@kan/api/utils/rateLimit";
const nextApiHandler = createNextApiHandler({
router: appRouter,
createContext: createTRPCContext,
onError:
process.env.NODE_ENV === "development"
env.NODE_ENV === "development"
? ({ path, error }) => {
console.error(
`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`,
@@ -17,11 +19,16 @@ const nextApiHandler = createNextApiHandler({
: undefined,
});
export default function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "OPTIONS") {
res.writeHead(200);
return res.end();
}
export default withRateLimit(
{ points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === "OPTIONS") {
res.writeHead(200);
res.end();
return;
}
return nextApiHandler(req, res);
}
const result = await nextApiHandler(req, res);
return result;
},
);

View File

@@ -4,6 +4,7 @@ import { jwtVerify } from "jose";
import { z } from "zod";
import { env } from "~/env";
import { withRateLimit } from "@kan/api/utils/rateLimit";
const requestSchema = z.object({
token: z.string().min(1),
@@ -19,10 +20,9 @@ type ResponseData =
const textEncoder = new TextEncoder();
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<ResponseData>,
) {
export default withRateLimit(
{ points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse<ResponseData>) => {
if (process.env.NEXT_PUBLIC_KAN_ENV !== "cloud") {
return res.status(404).json({
success: false,
@@ -101,4 +101,5 @@ export default async function handler(
}
return res.status(200).json({ success: true });
}
},
);

View File

@@ -6,13 +6,13 @@ import { env as nextRuntimeEnv } from "next-runtime-env";
import { createNextApiContext } from "@kan/api/trpc";
import { env } from "~/env";
import { withRateLimit } from "@kan/api/utils/rateLimit";
const allowedContentTypes = ["image/jpeg", "image/png"];
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
export default withRateLimit(
{ points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
@@ -71,4 +71,5 @@ export default async function handler(
} catch (error) {
return res.status(500).json({ error: (error as Error).message });
}
}
},
);

View File

@@ -6,25 +6,26 @@ import { appRouter } from "@kan/api";
import { createRESTContext } from "@kan/api/trpc";
import { env } from "~/env";
import { withRateLimit } from "@kan/api/utils/rateLimit";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
await cors(req, res);
export default withRateLimit(
{ points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => {
await cors(req, res);
const openApiHandler = createOpenApiNextHandler({
router: appRouter,
createContext: createRESTContext,
onError:
env.NODE_ENV === "development"
? ({ path, error }) => {
console.error(
`❌ REST failed on ${path ?? "<no-path>"}: ${error.message}`,
);
}
: undefined,
});
const openApiHandler = createOpenApiNextHandler({
router: appRouter,
createContext: createRESTContext,
onError:
env.NODE_ENV === "development"
? ({ path, error }) => {
console.error(
`❌ REST failed on ${path ?? "<no-path>"}: ${error.message}`,
);
}
: undefined,
});
return await openApiHandler(req, res);
}
return await openApiHandler(req, res);
},
);

View File

@@ -1,9 +1,11 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { openApiDocument } from "@kan/api/openapi";
import { withRateLimit } from "@kan/api/utils/rateLimit";
const handler = (req: NextApiRequest, res: NextApiResponse) => {
res.status(200).send(openApiDocument);
};
export default handler;
export default withRateLimit(
{ points: 100, duration: 60 },
(req: NextApiRequest, res: NextApiResponse) => {
res.status(200).send(openApiDocument);
},
);

View File

@@ -0,0 +1,69 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("next-runtime-env", () => ({
env: vi.fn(),
}));
import { env } from "next-runtime-env";
import { getAvatarUrl } from "./helpers";
const mockEnv = env as ReturnType<typeof vi.fn>;
describe("getAvatarUrl", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns empty string for null input", () => {
expect(getAvatarUrl(null)).toBe("");
});
it("returns empty string for empty string input", () => {
expect(getAvatarUrl("")).toBe("");
});
it("returns URL unchanged if already absolute http", () => {
expect(getAvatarUrl("http://example.com/avatar.jpg")).toBe(
"http://example.com/avatar.jpg",
);
});
it("returns URL unchanged if already absolute https", () => {
expect(getAvatarUrl("https://example.com/avatar.jpg")).toBe(
"https://example.com/avatar.jpg",
);
});
describe("path-style URLs (MinIO/LocalStack)", () => {
it("constructs path-style URL when STORAGE_DOMAIN is not set", () => {
mockEnv.mockImplementation((key: string) => {
const vars: Record<string, string> = {
NEXT_PUBLIC_STORAGE_URL: "http://s3.localtest.me:9000",
NEXT_PUBLIC_AVATAR_BUCKET_NAME: "kan",
};
return vars[key];
});
expect(getAvatarUrl("user123/avatar.jpg")).toBe(
"http://s3.localtest.me:9000/kan/user123/avatar.jpg",
);
});
});
describe("virtual-hosted URLs (Tigris/AWS S3)", () => {
it("constructs virtual-hosted URL when STORAGE_DOMAIN is set", () => {
mockEnv.mockImplementation((key: string) => {
const vars: Record<string, string> = {
NEXT_PUBLIC_STORAGE_DOMAIN: "fly.storage.tigris.dev",
NEXT_PUBLIC_AVATAR_BUCKET_NAME: "kan-avatars",
NEXT_PUBLIC_STORAGE_URL: "https://fly.storage.tigris.dev",
};
return vars[key];
});
expect(getAvatarUrl("user123/avatar.jpg")).toBe(
"https://kan-avatars.fly.storage.tigris.dev/user123/avatar.jpg",
);
});
});
});

View File

@@ -52,5 +52,13 @@ export const getAvatarUrl = (imageOrKey: string | null) => {
return imageOrKey;
}
return `${env("NEXT_PUBLIC_STORAGE_URL")}/${env("NEXT_PUBLIC_AVATAR_BUCKET_NAME")}/${imageOrKey}`;
const bucket = env("NEXT_PUBLIC_AVATAR_BUCKET_NAME");
const storageDomain = env("NEXT_PUBLIC_STORAGE_DOMAIN");
if (storageDomain) {
return `https://${bucket}.${storageDomain}/${imageOrKey}`;
}
const storageUrl = env("NEXT_PUBLIC_STORAGE_URL");
return `${storageUrl}/${bucket}/${imageOrKey}`;
};

View File

@@ -22,8 +22,8 @@ const loadMessages = async (locale: Locale) => {
return (await import("~/locales/ru/messages")).messages;
case "pl":
return (await import("~/locales/pl/messages")).messages;
case "ptbr":
return (await import("~/locales/ptbr/messages")).messages;
case "pt-BR":
return (await import("~/locales/pt-BR/messages")).messages;
default:
return enMessages;
}

View File

@@ -335,7 +335,7 @@ export function NewCardForm({
saveFormState({ ...formState, description: value });
}}
workspaceMembers={
boardData?.workspace.members?.map(
boardData?.workspace.members.map(
(member): WorkspaceMember => ({
publicId: member.publicId,
email: member.email,
@@ -349,6 +349,7 @@ export function NewCardForm({
}),
) ?? []
}
enableYouTubeEmbed={false}
/>
</div>
</div>

View File

@@ -24,6 +24,8 @@ import { PageHead } from "~/components/PageHead";
import PatternedBackground from "~/components/PatternedBackground";
import { StrictModeDroppable as Droppable } from "~/components/StrictModeDroppable";
import { Tooltip } from "~/components/Tooltip";
import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal";
import { useDragToScroll } from "~/hooks/useDragToScroll";
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
@@ -55,6 +57,11 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
const [selectedPublicListId, setSelectedPublicListId] =
useState<PublicListId>("");
const [isInitialLoading, setIsInitialLoading] = useState(true);
const { ref: scrollRef, onMouseDown } = useDragToScroll({
enabled: true,
direction: "horizontal",
});
const { tooltipContent: createListShortcutTooltipContent } =
useKeyboardShortcut({
@@ -372,6 +379,13 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
sourceBoardName={boardData?.name ?? ""}
/>
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "EDIT_YOUTUBE"}
>
<EditYouTubeModal />
</Modal>
</>
);
};
@@ -471,7 +485,11 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
</div>
</div>
<div className="scrollbar-w-none scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-h-[8px] z-0 flex-1 overflow-y-hidden overflow-x-scroll overscroll-contain scrollbar scrollbar-track-light-200 scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-300">
<div
ref={scrollRef}
onMouseDown={onMouseDown}
className={`scrollbar-w-none scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-h-[8px] z-0 flex-1 overflow-y-hidden overflow-x-scroll overscroll-contain scrollbar scrollbar-track-light-200 scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-300`}
>
{isLoading ? (
<div className="ml-[2rem] flex">
<div className="0 mr-5 h-[500px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />

View File

@@ -25,6 +25,7 @@ import { authClient } from "@kan/auth/client";
import Avatar from "~/components/Avatar";
import { useLocalisation } from "~/hooks/useLocalisation";
import { api } from "~/utils/api";
import { getAvatarUrl } from "~/utils/helpers";
import Comment from "./Comment";
type ActivityType =
@@ -471,6 +472,7 @@ const ActivityList = ({
cardPublicId={cardPublicId}
name={activity.user?.name ?? ""}
email={activity.user?.email ?? ""}
image={activity.user?.image ?? null}
isLoading={isLoading}
createdAt={activity.createdAt.toISOString()}
comment={activity.comment?.comment}
@@ -493,6 +495,7 @@ const ActivityList = ({
size="sm"
name={activity.user?.name ?? ""}
email={activity.user?.email ?? ""}
imageUrl={getAvatarUrl(activity.user?.image ?? null) || undefined}
icon={getActivityIcon(
activity.type,
activity.fromList?.index,

View File

@@ -12,6 +12,7 @@ import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
import { getAvatarUrl } from "~/utils/helpers";
interface FormValues {
comment: string;
@@ -22,6 +23,7 @@ const Comment = ({
cardPublicId,
name,
email,
image,
isLoading,
createdAt,
comment,
@@ -34,6 +36,7 @@ const Comment = ({
cardPublicId: string;
name: string;
email: string;
image: string | null;
isLoading: boolean;
createdAt: string;
comment: string | undefined;
@@ -108,6 +111,7 @@ const Comment = ({
size="sm"
name={name ?? ""}
email={email ?? ""}
imageUrl={getAvatarUrl(image) || undefined}
isLoading={isLoading}
/>

View File

@@ -13,6 +13,7 @@ import LabelIcon from "~/components/LabelIcon";
import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import { PageHead } from "~/components/PageHead";
import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { useWorkspace } from "~/providers/workspace";
@@ -483,6 +484,13 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
checklistPublicId={entityId}
/>
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "EDIT_YOUTUBE"}
>
<EditYouTubeModal />
</Modal>
</>
</div>
</>

View File

@@ -54,6 +54,7 @@ export default function MembersPage() {
memberStatus,
isLastRow,
showSkeleton,
showPendingIcon,
}: {
memberPublicId?: string;
memberId?: string | null | undefined;
@@ -64,6 +65,7 @@ export default function MembersPage() {
memberStatus?: string;
isLastRow?: boolean;
showSkeleton?: boolean;
showPendingIcon?: boolean;
}) => {
return (
<tr className="rounded-b-lg">
@@ -82,6 +84,7 @@ export default function MembersPage() {
name={memberName ?? ""}
email={memberEmail ?? ""}
imageUrl={memberImage ? getAvatarUrl(memberImage) : undefined}
icon={showPendingIcon ? "?" : undefined}
/>
)}
</div>
@@ -93,20 +96,26 @@ export default function MembersPage() {
"mr-2 truncate text-xs font-medium text-neutral-900 dark:text-dark-1000 sm:text-sm",
showSkeleton &&
"md mb-2 h-3 w-[125px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
showPendingIcon &&
"italic text-neutral-500 dark:text-dark-900",
)}
>
{memberName}
</p>
</div>
<p
className={twMerge(
"truncate text-xs text-dark-900 sm:text-sm",
showSkeleton &&
"h-3 w-[175px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
)}
>
{memberEmail}
</p>
{((workspace.role === "admin" ||
data?.showEmailsToMembers === true) ||
showSkeleton) && (
<p
className={twMerge(
"truncate text-xs text-dark-900 sm:text-sm",
showSkeleton &&
"h-3 w-[175px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
)}
>
{memberEmail}
</p>
)}
</div>
</div>
</div>
@@ -246,19 +255,24 @@ export default function MembersPage() {
</thead>
<tbody className="divide-y divide-light-600 overflow-visible bg-light-50 dark:divide-dark-600 dark:bg-dark-100">
{!isLoading &&
data?.members.map((member, index) => (
<TableRow
key={member.publicId}
memberPublicId={member.publicId}
memberId={member.user?.id}
memberName={member.user?.name}
memberEmail={member.user?.email ?? member.email}
memberImage={member.user?.image}
memberRole={member.role}
memberStatus={member.status}
isLastRow={index === data.members.length - 1}
/>
))}
data?.members.map((member, index) => {
const isPendingInvite = member.status === "invited";
return (
<TableRow
key={member.publicId}
memberPublicId={member.publicId}
memberId={member.user?.id}
memberName={member.user?.name}
memberEmail={member.user?.email ?? member.email}
memberImage={member.user?.image}
memberRole={member.role}
memberStatus={member.status}
isLastRow={index === data.members.length - 1}
showPendingIcon={isPendingInvite}
/>
);
})}
{isLoading && (
<>

View File

@@ -12,6 +12,7 @@ import { PageHead } from "~/components/PageHead";
import PatternedBackground from "~/components/PatternedBackground";
import Popup from "~/components/Popup";
import ThemeToggle from "~/components/ThemeToggle";
import { useDragToScroll } from "~/hooks/useDragToScroll";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
@@ -29,6 +30,11 @@ export default function PublicBoardView() {
const { showPopup } = usePopup();
const [isRouteLoaded, setIsRouteLoaded] = useState(false);
const { openModal } = useModal();
const { ref: scrollRef, onMouseDown } = useDragToScroll({
enabled: true,
direction: "horizontal",
});
const boardSlug = Array.isArray(router.query.boardSlug)
? router.query.boardSlug[0]
@@ -151,7 +157,11 @@ export default function PublicBoardView() {
)}
</div>
<div className="scrollbar-w-none scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-h-[8px] relative h-full flex-1 overflow-y-hidden overflow-x-scroll overscroll-contain scrollbar scrollbar-track-light-200 scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-300">
<div
ref={scrollRef}
onMouseDown={onMouseDown}
className="scrollbar-w-none scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-h-[8px] relative h-full flex-1 overflow-y-hidden overflow-x-scroll overscroll-contain scrollbar scrollbar-track-light-200 scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-300"
>
{isLoading || !router.isReady ? (
<div className="ml-[2rem] flex">
<div className="0 mr-5 h-[500px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />

View File

@@ -37,6 +37,13 @@ export default function AccountSettings() {
<UpdateDisplayNameForm displayName={data?.name ?? ""} />
</div>
<div className="mb-4">
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
{t`Email`}
</h2>
<p className="text-sm text-neutral-700 dark:text-dark-900">{data?.email}</p>
</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`Language`}

View File

@@ -17,6 +17,7 @@ import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
import { DeleteWorkspaceConfirmation } from "./components/DeleteWorkspaceConfirmation";
import UpdateWorkspaceDescriptionForm from "./components/UpdateWorkspaceDescriptionForm";
import UpdateWorkspaceEmailVisibilityForm from "./components/UpdateWorkspaceEmailVisibilityForm";
import UpdateWorkspaceNameForm from "./components/UpdateWorkspaceNameForm";
import UpdateWorkspaceUrlForm from "./components/UpdateWorkspaceUrlForm";
import { UpgradeToProConfirmation } from "./components/UpgradeToProConfirmation";
@@ -79,6 +80,14 @@ export default function WorkspaceSettings() {
workspaceDescription={workspace.description ?? ""}
/>
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
{t`Email visibility`}
</h2>
<UpdateWorkspaceEmailVisibilityForm
workspacePublicId={workspace.publicId}
showEmailsToMembers={workspaceData?.showEmailsToMembers ?? false}
/>
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
!hasActiveSubscription(subscriptions, "pro") &&
!hasActiveSubscription(subscriptions, "team") && (

View File

@@ -0,0 +1,53 @@
import { t } from "@lingui/core/macro";
import { useEffect, useState } from "react";
import Toggle from "~/components/Toggle";
import { api } from "~/utils/api";
export default function UpdateWorkspaceEmailVisibilityForm({
workspacePublicId,
showEmailsToMembers,
}: {
workspacePublicId: string;
showEmailsToMembers: boolean;
}) {
const utils = api.useUtils();
const [isChecked, setIsChecked] = useState(showEmailsToMembers);
useEffect(() => {
setIsChecked(showEmailsToMembers);
}, [showEmailsToMembers]);
const updateWorkspace = api.workspace.update.useMutation({
onSuccess: () => {
void utils.workspace.byId.invalidate({
workspacePublicId,
});
},
});
const handleToggle = () => {
const newValue = !isChecked;
setIsChecked(newValue);
updateWorkspace.mutate({
workspacePublicId,
showEmailsToMembers: newValue,
});
};
return (
<div className="mb-8 flex items-center justify-between">
<div className="flex-1">
<p className="text-sm text-neutral-500 dark:text-dark-900">
{t`Allow workspace members to see each other's email addresses`}
</p>
</div>
<Toggle
isChecked={isChecked}
onChange={handleToggle}
label=""
disabled={updateWorkspace.isPending}
/>
</div>
);
}

View File

@@ -9,6 +9,8 @@ services:
build:
context: ..
dockerfile: apps/web/Dockerfile
args:
APP_VERSION: ${APP_VERSION:-}
env_file:
- .env
restart: unless-stopped
@@ -19,6 +21,7 @@ services:
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
- POSTGRES_URL=${POSTGRES_URL}
- NEXT_PUBLIC_USE_STANDALONE_OUTPUT=${NEXT_PUBLIC_USE_STANDALONE_OUTPUT}
- REDIS_URL=${REDIS_URL}
# Stripe
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
@@ -54,10 +57,13 @@ services:
- NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME=${NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME}
- NEXT_PUBLIC_STORAGE_DOMAIN=${NEXT_PUBLIC_STORAGE_DOMAIN}
# Auth config
# Auth config (optional)
- NEXT_PUBLIC_ALLOW_CREDENTIALS=${NEXT_PUBLIC_ALLOW_CREDENTIALS}
- NEXT_PUBLIC_DISABLE_SIGN_UP=${NEXT_PUBLIC_DISABLE_SIGN_UP}
# API configuration (optional)
- NEXT_API_BODY_SIZE_LIMIT=${NEXT_API_BODY_SIZE_LIMIT}
# Integration providers
- TRELLO_APP_API_KEY=${TRELLO_APP_API_KEY}
- TRELLO_APP_SECRET=${TRELLO_APP_SECRET}

View File

@@ -18,6 +18,9 @@ services:
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
- POSTGRES_URL=${POSTGRES_URL}
# Redis (optional - for rate limiting)
- REDIS_URL=${REDIS_URL}
# Admin API key (optional)
- KAN_ADMIN_API_KEY=${KAN_ADMIN_API_KEY}
@@ -50,6 +53,9 @@ services:
- NEXT_PUBLIC_ALLOW_CREDENTIALS=${NEXT_PUBLIC_ALLOW_CREDENTIALS}
- NEXT_PUBLIC_DISABLE_SIGN_UP=${NEXT_PUBLIC_DISABLE_SIGN_UP}
# API configuration (optional)
- NEXT_API_BODY_SIZE_LIMIT=${NEXT_API_BODY_SIZE_LIMIT}
# Integration providers (optional)
- TRELLO_APP_API_KEY=${TRELLO_APP_API_KEY}
- TRELLO_APP_SECRET=${TRELLO_APP_SECRET}

View File

@@ -23,6 +23,10 @@
"./openapi": {
"types": "./dist/openapi.d.ts",
"default": "./src/openapi.ts"
},
"./utils/rateLimit": {
"types": "./dist/utils/rateLimit.d.ts",
"default": "./src/utils/rateLimit.ts"
}
},
"license": "GPL-3.0",
@@ -43,6 +47,7 @@
"@kan/shared": "workspace:^",
"@kan/stripe": "workspace:^",
"@trpc/server": "catalog:",
"rate-limiter-flexible": "^9.0.1",
"superjson": "2.2.1",
"trpc-to-openapi": "^2.3.2",
"zod": "catalog:"

View File

@@ -27,7 +27,7 @@ export const cardRouter = createTRPCRouter({
})
.input(
z.object({
title: z.string().min(1),
title: z.string().min(1).max(2000),
description: z.string().max(10000),
listPublicId: z.string().min(12),
labelPublicIds: z.array(z.string().min(12)),
@@ -760,7 +760,7 @@ export const cardRouter = createTRPCRouter({
.input(
z.object({
cardPublicId: z.string().min(12),
title: z.string().min(1).optional(),
title: z.string().min(1).max(2000).optional(),
description: z.string().optional(),
index: z.number().optional(),
listPublicId: z.string().min(12).optional(),

View File

@@ -52,7 +52,7 @@ interface TrelloCheckItem {
interface TrelloCard {
id: string;
name: string;
name: string | null;
desc: string;
idList: string;
labels: TrelloLabel[];
@@ -290,7 +290,7 @@ export const importRouter = createTRPCRouter({
if (list.cards.length && newListId) {
const cardsInsert = list.cards.map((card, index) => ({
publicId: generateUID(),
title: card.name,
title: (card.name?.trim() ?? "Untitled Card").slice(0, 2000),
description: card.description,
createdBy: userId,
listId: newListId,

View File

@@ -77,6 +77,49 @@ export const workspaceRouter = createTRPCRouter({
await assertUserInWorkspace(ctx.db, userId, result.id);
// Check if user is an admin
const userMember = result.members.find(
(member) => member.user?.id === userId,
);
const isAdmin = userMember?.role === "admin";
// Show emails if user is admin OR workspace setting allows it
const shouldShowEmails = isAdmin || result.showEmailsToMembers === true;
// If emails should be hidden, filter them out
if (!shouldShowEmails) {
const sanitizedMembers = result.members.map((member) => {
// If user doesn't have a display name, use anonymous identifier
const displayName =
member.user?.name?.trim() ?? `anonymous_${member.publicId}`;
const { email: _memberEmail, ...memberWithoutEmail } = member;
const sanitizedUser = member.user
? (() => {
const { email: _userEmail, ...userWithoutEmail } = member.user;
return {
...userWithoutEmail,
name: displayName,
};
})()
: {
id: null,
name: displayName,
image: null,
};
return {
...memberWithoutEmail,
user: sanitizedUser,
};
});
return {
...result,
members: sanitizedMembers,
} as Awaited<ReturnType<typeof workspaceRepo.getByPublicIdWithMembers>>;
}
return result;
}),
bySlug: publicProcedure
@@ -230,6 +273,7 @@ export const workspaceRouter = createTRPCRouter({
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/)
.optional(),
description: z.string().min(3).max(280).optional(),
showEmailsToMembers: z.boolean().optional(),
}),
)
.output(z.custom<Awaited<ReturnType<typeof workspaceRepo.update>>>())
@@ -291,9 +335,16 @@ export const workspaceRouter = createTRPCRouter({
name: input.name,
slug: input.slug,
description: input.description,
showEmailsToMembers: input.showEmailsToMembers,
},
);
if (!result)
throw new TRPCError({
message: `Unable to delete workspace`,
code: "INTERNAL_SERVER_ERROR",
});
return result;
}),
delete: protectedProcedure
@@ -336,12 +387,6 @@ export const workspaceRouter = createTRPCRouter({
input.workspacePublicId,
);
if (!result)
throw new TRPCError({
message: `Unable to delete workspace`,
code: "INTERNAL_SERVER_ERROR",
});
return result;
}),
checkSlugAvailability: publicProcedure

View File

@@ -0,0 +1,99 @@
import type { NextApiRequest, NextApiResponse } from "next";
import {
RateLimiterRedis,
RateLimiterMemory,
} from "rate-limiter-flexible";
import { getRedisClient } from "@kan/db/redis";
export interface RateLimitOptions {
points?: number;
duration?: number;
identifier?: (req: NextApiRequest) => string | Promise<string>;
errorMessage?: string;
}
const defaultIdentifier = (req: NextApiRequest): string => {
// Try to identify the IP address of the request
const forwardedFor = req.headers["x-forwarded-for"];
const realIp = req.headers["x-real-ip"];
const cfConnectingIp = req.headers["cf-connecting-ip"];
const ip =
(typeof forwardedFor === "string"
? forwardedFor.split(",")[0]?.trim()
: null) ??
(typeof realIp === "string" ? realIp : null) ??
(typeof cfConnectingIp === "string" ? cfConnectingIp : null) ??
req.socket.remoteAddress ??
"unknown";
return ip;
};
const DEFAULT_OPTIONS = {
points: 100,
duration: 60,
errorMessage: "Too many requests, please try again later.",
identifier: defaultIdentifier,
} as const;
function createRateLimiter(options: RateLimitOptions = {}) {
const redis = getRedisClient();
const points = options.points ?? DEFAULT_OPTIONS.points;
const duration = options.duration ?? DEFAULT_OPTIONS.duration;
// Use Redis if available, otherwise fall back to in-memory storage
if (redis) {
console.log("Using Redis for rate limiting");
return new RateLimiterRedis({
storeClient: redis,
points,
duration,
});
}
console.log("Using in-memory for rate limiting");
return new RateLimiterMemory({
points,
duration,
});
}
export function withRateLimit(
options: RateLimitOptions,
handler: (
req: NextApiRequest,
res: NextApiResponse,
) => Promise<unknown> | unknown,
) {
const rateLimiter = createRateLimiter(options);
const identifier = options.identifier ?? DEFAULT_OPTIONS.identifier;
const errorMessage = options.errorMessage ?? DEFAULT_OPTIONS.errorMessage;
return async (req: NextApiRequest, res: NextApiResponse) => {
try {
const id = await identifier(req);
const key = `ratelimit_${id}`;
await rateLimiter.consume(key);
return await handler(req, res);
} catch (error) {
// rate-limiter-flexible throws an error with msBeforeNext or remainingPoints
// when limit is exceeded. Check for these properties directly.
if (
error &&
typeof error === "object" &&
("msBeforeNext" in error || "remainingPoints" in error)
) {
return res.status(429).json({
message: errorMessage,
});
}
return await handler(req, res);
}
};
}

View File

@@ -1,147 +1,23 @@
import type { Subscription } from "@better-auth/stripe";
import type Stripe from "stripe";
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { stripe } from "@better-auth/stripe";
import { ChatOrPushProviderEnum } from "@novu/api/models/components";
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { createAuthEndpoint, createAuthMiddleware } from "better-auth/api";
import { apiKey, genericOAuth } from "better-auth/plugins";
import { magicLink } from "better-auth/plugins/magic-link";
import { socialProviderList } from "better-auth/social-providers";
import { env } from "next-runtime-env";
import type { dbClient } from "@kan/db/client";
import * as memberRepo from "@kan/db/repository/member.repo";
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
import * as userRepo from "@kan/db/repository/user.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import * as schema from "@kan/db/schema";
import { notificationClient, sendEmail } from "@kan/email";
import { createEmailUnsubscribeLink } from "@kan/shared";
import { createStripeClient } from "@kan/stripe";
import { sendEmail } from "@kan/email";
export const configuredProviders = socialProviderList.reduce<
Record<
string,
{
clientId: string;
clientSecret: string;
appBundleIdentifier?: string;
tenantId?: string;
requireSelectAccount?: boolean;
clientKey?: string;
issuer?: string;
// Google-specific optional hints
hostedDomain?: string;
hd?: string;
}
>
>((acc, provider) => {
const id = process.env[`${provider.toUpperCase()}_CLIENT_ID`];
const secret = process.env[`${provider.toUpperCase()}_CLIENT_SECRET`];
if (id && id.length > 0 && secret && secret.length > 0) {
acc[provider] = { clientId: id, clientSecret: secret };
}
if (
provider === "apple" &&
Object.keys(acc).includes("apple") &&
acc[provider]
) {
const bundleId =
process.env[`${provider.toUpperCase()}_APP_BUNDLE_IDENTIFIER`];
if (bundleId && bundleId.length > 0) {
acc[provider].appBundleIdentifier = bundleId;
}
}
if (
provider === "gitlab" &&
Object.keys(acc).includes("gitlab") &&
acc[provider]
) {
const issuer = process.env[`${provider.toUpperCase()}_ISSUER`];
if (issuer && issuer.length > 0) {
acc[provider].issuer = issuer;
}
}
if (
provider === "microsoft" &&
Object.keys(acc).includes("microsoft") &&
acc[provider]
) {
acc[provider].tenantId = "common";
acc[provider].requireSelectAccount = true;
}
// Add Google domain hint if allowed domains is configured
if (
provider === "google" &&
Object.keys(acc).includes("google") &&
acc[provider]
) {
const allowed = process.env.BETTER_AUTH_ALLOWED_DOMAINS?.split(",")
.map((d) => d.trim().toLowerCase())
.filter(Boolean);
if (allowed && allowed.length > 0) {
// Use the first domain as an authorization hint
acc[provider].hostedDomain = allowed[0];
acc[provider].hd = allowed[0];
}
}
if (
provider === "tiktok" &&
Object.keys(acc).includes("tiktok") &&
acc[provider]
) {
const key = process.env[`${provider.toUpperCase()}_CLIENT_KEY`];
if (key && key.length > 0) {
acc[provider].clientKey = key;
}
}
return acc;
}, {});
export const socialProvidersPlugin = () => ({
id: "social-providers-plugin",
endpoints: {
getSocialProviders: createAuthEndpoint(
"/social-providers",
{
method: "GET",
},
async (ctx) => {
const providers = ctx.context.socialProviders.map((p) =>
p.id.toLowerCase(),
);
// Add OIDC provider if configured
if (
process.env.OIDC_CLIENT_ID &&
process.env.OIDC_CLIENT_SECRET &&
process.env.OIDC_DISCOVERY_URL
) {
providers.push("oidc");
}
return ctx.json(providers);
},
),
},
});
async function downloadImage(url: string): Promise<Buffer> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to download image: ${response.statusText}`);
}
return Buffer.from(await response.arrayBuffer());
}
import { createDatabaseHooks, createMiddlewareHooks } from "./hooks";
import { createPlugins } from "./plugins";
import { configuredProviders } from "./providers";
export const initAuth = (db: dbClient) => {
return betterAuth({
secret: process.env.BETTER_AUTH_SECRET!,
secret: env("BETTER_AUTH_SECRET"),
baseURL: env("NEXT_PUBLIC_BASE_URL"),
trustedOrigins: process.env.BETTER_AUTH_TRUSTED_ORIGINS
trustedOrigins: env("BETTER_AUTH_TRUSTED_ORIGINS")
? [
env("NEXT_PUBLIC_BASE_URL") ?? "",
...process.env.BETTER_AUTH_TRUSTED_ORIGINS.split(","),
...(env("BETTER_AUTH_TRUSTED_ORIGINS")?.split(",") ?? []),
]
: [env("NEXT_PUBLIC_BASE_URL") ?? ""],
database: drizzleAdapter(db, {
@@ -181,329 +57,9 @@ export const initAuth = (db: dbClient) => {
},
},
},
plugins: [
socialProvidersPlugin(),
...(process.env.NEXT_PUBLIC_KAN_ENV === "cloud"
? [
stripe({
stripeClient: createStripeClient(),
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
createCustomerOnSignUp: true,
subscription: {
enabled: true,
plans: [
{
name: "team",
priceId: process.env.STRIPE_TEAM_PLAN_MONTHLY_PRICE_ID!,
annualDiscountPriceId:
process.env.STRIPE_TEAM_PLAN_YEARLY_PRICE_ID!,
freeTrial: {
days: 14,
onTrialStart: async (subscription) => {
await triggerWorkflow(db, "trial-start", subscription);
},
onTrialEnd: async ({ subscription }) => {
await triggerWorkflow(db, "trial-end", subscription);
},
onTrialExpired: async (subscription) => {
await triggerWorkflow(
db,
"trial-expired",
subscription,
);
},
},
},
{
name: "pro",
priceId: process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID!,
annualDiscountPriceId:
process.env.STRIPE_PRO_PLAN_YEARLY_PRICE_ID!,
freeTrial: {
days: 14,
onTrialStart: async (subscription) => {
await triggerWorkflow(db, "trial-start", subscription);
},
onTrialEnd: async ({ subscription }) => {
await triggerWorkflow(db, "trial-end", subscription);
},
onTrialExpired: async (subscription) => {
await triggerWorkflow(
db,
"trial-expired",
subscription,
);
},
},
},
],
authorizeReference: async (data) => {
const workspace = await workspaceRepo.getByPublicId(
db,
data.referenceId,
);
if (!workspace) {
return Promise.resolve(false);
}
const isUserInWorkspace =
await workspaceRepo.isUserInWorkspace(
db,
data.user.id,
workspace.id,
);
return isUserInWorkspace;
},
getCheckoutSessionParams: () => {
return {
params: {
allow_promotion_codes: true,
},
};
},
onSubscriptionComplete: async ({
subscription,
stripeSubscription,
}) => {
// Set unlimited seats to true for pro plans
if (subscription.plan === "pro") {
await subscriptionRepo.updateByStripeSubscriptionId(
db,
stripeSubscription.id,
{
unlimitedSeats: true,
},
);
console.log(
`Pro subscription ${stripeSubscription.id} activated with unlimited seats`,
);
const workspace = await workspaceRepo.getByPublicId(
db,
subscription.referenceId,
);
if (workspace?.id) {
await memberRepo.unpauseAllMembers(db, workspace.id);
console.log(
`Unpausing all members for workspace ${workspace.id}`,
);
}
}
},
},
}),
]
: []),
apiKey({
enableSessionForAPIKeys: true,
rateLimit: {
enabled: true,
timeWindow: 1000 * 60, // 1 minute
maxRequests: 100, // 100 requests per minute
},
}),
magicLink({
expiresIn: 60 * 60 * 24 * 7, // 7 days
sendMagicLink: async ({ email, url }) => {
if (url.includes("type=invite")) {
await sendEmail(
email,
"Invitation to join workspace",
"JOIN_WORKSPACE",
{
magicLoginUrl: url,
},
);
} else {
await sendEmail(email, "Sign in to kan.bn", "MAGIC_LINK", {
magicLoginUrl: url,
});
}
},
}),
// Generic OIDC provider
...(process.env.OIDC_CLIENT_ID &&
process.env.OIDC_CLIENT_SECRET &&
process.env.OIDC_DISCOVERY_URL
? [
genericOAuth({
config: [
{
providerId: "oidc",
clientId: process.env.OIDC_CLIENT_ID,
clientSecret: process.env.OIDC_CLIENT_SECRET,
discoveryUrl: process.env.OIDC_DISCOVERY_URL,
scopes: ["openid", "email", "profile"],
pkce: true,
},
],
}),
]
: []),
],
databaseHooks: {
user: {
create: {
async before(user) {
if (env("NEXT_PUBLIC_DISABLE_SIGN_UP")?.toLowerCase() === "true") {
const pendingInvitation = await memberRepo.getByEmailAndStatus(
db,
user.email,
"invited",
);
if (!pendingInvitation) {
return Promise.resolve(false);
}
// Fall through to any additional checks below
}
// Enforce allowed domains (OIDC/social) if configured
const allowed = process.env.BETTER_AUTH_ALLOWED_DOMAINS?.split(",")
.map((d) => d.trim().toLowerCase())
.filter(Boolean);
if (allowed && allowed.length > 0) {
const domain = user.email.split("@")[1]?.toLowerCase();
if (!domain || !allowed.includes(domain)) {
return Promise.resolve(false);
}
}
return Promise.resolve(true);
},
async after(user) {
let avatarKey = user.image;
if (
user.image &&
!user.image.includes(process.env.NEXT_PUBLIC_STORAGE_DOMAIN!)
) {
try {
const credentials =
env("S3_ACCESS_KEY_ID") && env("S3_SECRET_ACCESS_KEY")
? {
accessKeyId: env("S3_ACCESS_KEY_ID")!,
secretAccessKey: env("S3_SECRET_ACCESS_KEY")!,
}
: undefined;
const client = new S3Client({
region: env("S3_REGION") ?? "",
endpoint: env("S3_ENDPOINT") ?? "",
forcePathStyle: env("S3_FORCE_PATH_STYLE") === "true",
credentials,
});
const allowedFileExtensions = ["jpg", "jpeg", "png", "webp"];
const fileExtension =
user.image.split(".").pop()?.split("?")[0] || "jpg";
const key = `${user.id}/avatar.${!allowedFileExtensions.includes(fileExtension) ? "jpg" : fileExtension}`;
const imageBuffer = await downloadImage(user.image);
await client.send(
new PutObjectCommand({
Bucket: env("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "",
Key: key,
Body: imageBuffer,
ContentType: `image/${!allowedFileExtensions.includes(fileExtension) ? "jpeg" : fileExtension}`,
ACL: "public-read",
}),
);
avatarKey = key;
await userRepo.update(db, user.id, {
image: key,
});
} catch (error) {
console.error(error);
}
}
if (notificationClient) {
try {
const [firstName, ...rest] = user.name
.split(" ")
.filter(Boolean);
const lastName = rest.length ? rest.join(" ") : undefined;
const avatarUrl = avatarKey
? `${env("NEXT_PUBLIC_STORAGE_URL")}/${env("NEXT_PUBLIC_AVATAR_BUCKET_NAME")}/${avatarKey}`
: undefined;
const unsubscribeUrl = await createEmailUnsubscribeLink(
user.id,
);
await notificationClient.trigger({
to: {
subscriberId: user.id,
firstName: firstName,
lastName: lastName,
email: user.email,
avatar: avatarUrl,
data: {
emailVerified: user.emailVerified,
stripeCustomerId: user.stripeCustomerId,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
},
},
payload: {
emailUnsubscribeUrl: unsubscribeUrl,
},
workflowId: "user-signup",
});
await notificationClient.subscribers.credentials.update(
{
providerId: ChatOrPushProviderEnum.Discord,
credentials: {
webhookUrl: process.env.DISCORD_WEBHOOK_URL!,
},
integrationIdentifier: "discord",
},
user.id,
);
} catch (error) {
console.error(
"Error adding user to notification client",
error,
);
}
}
},
},
},
},
hooks: {
after: createAuthMiddleware(async (ctx) => {
if (
ctx.path === "/magic-link/verify" &&
(ctx.query?.callbackURL as string | undefined)?.includes(
"type=invite",
)
) {
const userId = ctx.context.newSession?.session.userId;
const callbackURL = ctx.query?.callbackURL as string | undefined;
const memberPublicId = callbackURL?.split("memberPublicId=")[1];
if (userId && memberPublicId) {
const member = await memberRepo.getByPublicId(db, memberPublicId);
if (member?.id) {
await memberRepo.acceptInvite(db, {
memberId: member.id,
userId,
});
}
}
}
}),
},
plugins: createPlugins(db),
databaseHooks: createDatabaseHooks(db),
hooks: createMiddlewareHooks(db),
advanced: {
cookiePrefix: "kan",
database: {
@@ -512,37 +68,3 @@ export const initAuth = (db: dbClient) => {
},
});
};
async function triggerWorkflow(
db: dbClient,
workflowId: string,
subscription: Subscription,
cancellationDetails?: Stripe.Subscription.CancellationDetails | null,
) {
try {
if (!subscription.stripeCustomerId || !notificationClient) return;
const user = await userRepo.getByStripeCustomerId(
db,
subscription.stripeCustomerId,
);
if (!user || !notificationClient) return;
const unsubscribeUrl = await createEmailUnsubscribeLink(user.id);
await notificationClient.trigger({
to: {
subscriberId: user.id,
},
payload: {
...subscription,
cancellationDetails,
emailUnsubscribeUrl: unsubscribeUrl,
},
workflowId,
});
} catch (error) {
console.error("Error triggering workflow", error);
}
}

View File

@@ -8,7 +8,7 @@ import {
} from "better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";
import type { socialProvidersPlugin } from "./auth";
import type { socialProvidersPlugin } from "./providers";
const socialProvidersPluginClient = {
id: "social-providers-plugin",

183
packages/auth/src/hooks.ts Normal file
View File

@@ -0,0 +1,183 @@
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { ChatOrPushProviderEnum } from "@novu/api/models/components";
import { createAuthMiddleware } from "better-auth/api";
import { env } from "next-runtime-env";
import type { dbClient } from "@kan/db/client";
import * as memberRepo from "@kan/db/repository/member.repo";
import * as userRepo from "@kan/db/repository/user.repo";
import { notificationClient } from "@kan/email";
import { createEmailUnsubscribeLink } from "@kan/shared";
import { downloadImage } from "./utils";
type BetterAuthUser = {
id: string;
createdAt: Date;
updatedAt: Date;
email: string;
emailVerified: boolean;
name: string;
image?: string | null | undefined;
stripeCustomerId?: string | null | undefined;
} & Record<string, unknown>;
export function createDatabaseHooks(db: dbClient) {
return {
user: {
create: {
async before(user: BetterAuthUser, _context: unknown) {
if (env("NEXT_PUBLIC_DISABLE_SIGN_UP")?.toLowerCase() === "true") {
const pendingInvitation = await memberRepo.getByEmailAndStatus(
db,
user.email,
"invited",
);
if (!pendingInvitation) {
return Promise.resolve(false);
}
// Fall through to any additional checks below
}
// Enforce allowed domains (OIDC/social) if configured
const allowed = process.env.BETTER_AUTH_ALLOWED_DOMAINS?.split(",")
.map((d) => d.trim().toLowerCase())
.filter(Boolean);
if (allowed && allowed.length > 0) {
const domain = user.email.split("@")[1]?.toLowerCase();
if (!domain || !allowed.includes(domain)) {
return Promise.resolve(false);
}
}
return Promise.resolve(true);
},
async after(user: BetterAuthUser, _context: unknown) {
let avatarKey = user.image;
const storageDomain = process.env.NEXT_PUBLIC_STORAGE_DOMAIN;
if (
user.image &&
storageDomain &&
!user.image.includes(storageDomain)
) {
try {
const credentials =
env("S3_ACCESS_KEY_ID") && env("S3_SECRET_ACCESS_KEY")
? {
accessKeyId: env("S3_ACCESS_KEY_ID")!,
secretAccessKey: env("S3_SECRET_ACCESS_KEY")!,
}
: undefined;
const client = new S3Client({
region: env("S3_REGION") ?? "",
endpoint: env("S3_ENDPOINT") ?? "",
forcePathStyle: env("S3_FORCE_PATH_STYLE") === "true",
credentials,
});
const allowedFileExtensions = ["jpg", "jpeg", "png", "webp"];
const fileExtension =
user.image.split(".").pop()?.split("?")[0] ?? "jpg";
const key = `${user.id}/avatar.${!allowedFileExtensions.includes(fileExtension) ? "jpg" : fileExtension}`;
const imageBuffer = await downloadImage(user.image);
await client.send(
new PutObjectCommand({
Bucket: env("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "",
Key: key,
Body: imageBuffer,
ContentType: `image/${!allowedFileExtensions.includes(fileExtension) ? "jpeg" : fileExtension}`,
ACL: "public-read",
}),
);
avatarKey = key;
await userRepo.update(db, user.id, {
image: key,
});
} catch (error) {
console.error(error);
}
}
if (notificationClient) {
try {
const [firstName, ...rest] = (user.name || "")
.split(" ")
.filter(Boolean);
const lastName = rest.length ? rest.join(" ") : undefined;
const avatarUrl = avatarKey
? `${env("NEXT_PUBLIC_STORAGE_URL")}/${env("NEXT_PUBLIC_AVATAR_BUCKET_NAME")}/${avatarKey}`
: undefined;
const unsubscribeUrl = await createEmailUnsubscribeLink(user.id);
await notificationClient.trigger({
to: {
subscriberId: user.id,
firstName: firstName,
lastName: lastName,
email: user.email,
avatar: avatarUrl,
data: {
emailVerified: user.emailVerified,
stripeCustomerId: user.stripeCustomerId,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
},
},
payload: {
emailUnsubscribeUrl: unsubscribeUrl,
},
workflowId: "user-signup",
});
await notificationClient.subscribers.credentials.update(
{
providerId: ChatOrPushProviderEnum.Discord,
credentials: {
webhookUrl: env("DISCORD_WEBHOOK_URL"),
},
integrationIdentifier: "discord",
},
user.id,
);
} catch (error) {
console.error("Error adding user to notification client", error);
}
}
},
},
},
};
}
export function createMiddlewareHooks(db: dbClient) {
return {
after: createAuthMiddleware(async (ctx) => {
if (
ctx.path === "/magic-link/verify" &&
(ctx.query?.callbackURL as string | undefined)?.includes("type=invite")
) {
const userId = ctx.context.newSession?.session.userId;
const callbackURL = ctx.query?.callbackURL as string | undefined;
const memberPublicId = callbackURL?.split("memberPublicId=")[1];
if (userId && memberPublicId) {
const member = await memberRepo.getByPublicId(db, memberPublicId);
if (member?.id) {
await memberRepo.acceptInvite(db, {
memberId: member.id,
userId,
});
}
}
}
}),
};
}

View File

@@ -0,0 +1,225 @@
import { stripe } from "@better-auth/stripe";
import { apiKey, genericOAuth } from "better-auth/plugins";
import { magicLink } from "better-auth/plugins/magic-link";
import type { dbClient } from "@kan/db/client";
import * as memberRepo from "@kan/db/repository/member.repo";
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { sendEmail } from "@kan/email";
import { createStripeClient } from "@kan/stripe";
import { socialProvidersPlugin } from "./providers";
import { triggerWorkflow } from "./utils";
export function createPlugins(db: dbClient) {
return [
socialProvidersPlugin(),
...(process.env.NEXT_PUBLIC_KAN_ENV === "cloud"
? [
stripe({
stripeClient: createStripeClient(),
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
createCustomerOnSignUp: true,
subscription: {
enabled: true,
plans: [
{
name: "team",
priceId: process.env.STRIPE_TEAM_PLAN_MONTHLY_PRICE_ID!,
annualDiscountPriceId:
process.env.STRIPE_TEAM_PLAN_YEARLY_PRICE_ID!,
freeTrial: {
days: 14,
onTrialStart: async (subscription) => {
await triggerWorkflow(db, "trial-start", subscription);
},
onTrialEnd: async ({ subscription }) => {
await triggerWorkflow(db, "trial-end", subscription);
},
onTrialExpired: async (subscription) => {
await triggerWorkflow(db, "trial-expired", subscription);
},
},
},
{
name: "pro",
priceId: process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID!,
annualDiscountPriceId:
process.env.STRIPE_PRO_PLAN_YEARLY_PRICE_ID!,
freeTrial: {
days: 14,
onTrialStart: async (subscription) => {
await triggerWorkflow(db, "trial-start", subscription);
},
onTrialEnd: async ({ subscription }) => {
await triggerWorkflow(db, "trial-end", subscription);
},
onTrialExpired: async (subscription) => {
await triggerWorkflow(db, "trial-expired", subscription);
},
},
},
],
authorizeReference: async (data) => {
const workspace = await workspaceRepo.getByPublicId(
db,
data.referenceId,
);
if (!workspace) {
return Promise.resolve(false);
}
const isUserInWorkspace = await workspaceRepo.isUserInWorkspace(
db,
data.user.id,
workspace.id,
);
return isUserInWorkspace;
},
getCheckoutSessionParams: () => {
return {
params: {
allow_promotion_codes: true,
},
};
},
onSubscriptionComplete: async ({
subscription,
stripeSubscription,
}) => {
// Set unlimited seats to true for pro plans
if (subscription.plan === "pro") {
await subscriptionRepo.updateByStripeSubscriptionId(
db,
stripeSubscription.id,
{
unlimitedSeats: true,
},
);
console.log(
`Pro subscription ${stripeSubscription.id} activated with unlimited seats`,
);
const workspace = await workspaceRepo.getByPublicId(
db,
subscription.referenceId,
);
if (workspace?.id) {
await memberRepo.unpauseAllMembers(db, workspace.id);
}
}
},
onSubscriptionCancel: async ({
subscription,
cancellationDetails,
}) => {
await triggerWorkflow(
db,
"subscription-canceled",
subscription,
cancellationDetails,
);
// for cancelled subscriptions, we need to pause all members and set their workspace plan to free
const workspace = await workspaceRepo.getByPublicId(
db,
subscription.referenceId,
);
if (workspace?.id) {
await memberRepo.pauseAllMembers(db, workspace.id);
await workspaceRepo.update(db, subscription.referenceId, {
plan: "free",
});
}
},
onSubscriptionUpdate: async ({ subscription }) => {
await triggerWorkflow(db, "subscription-updated", subscription);
},
},
}),
]
: []),
apiKey({
enableSessionForAPIKeys: true,
rateLimit: {
enabled: true,
timeWindow: 1000 * 60, // 1 minute
maxRequests: 100, // 100 requests per minute
},
}),
magicLink({
expiresIn: 60 * 60 * 24 * 7, // 7 days
sendMagicLink: async ({ email, url }) => {
if (url.includes("type=invite")) {
await sendEmail(
email,
"Invitation to join workspace",
"JOIN_WORKSPACE",
{
magicLoginUrl: url,
},
);
} else {
await sendEmail(email, "Sign in to kan.bn", "MAGIC_LINK", {
magicLoginUrl: url,
});
}
},
}),
// Generic OIDC provider
...(process.env.OIDC_CLIENT_ID &&
process.env.OIDC_CLIENT_SECRET &&
process.env.OIDC_DISCOVERY_URL
? [
genericOAuth({
config: [
{
providerId: "oidc",
clientId: process.env.OIDC_CLIENT_ID,
clientSecret: process.env.OIDC_CLIENT_SECRET,
discoveryUrl: process.env.OIDC_DISCOVERY_URL,
scopes: ["openid", "email", "profile"],
pkce: true,
mapProfileToUser: (profile: {
name?: string;
display_name?: string;
preferred_username?: string;
given_name?: string;
family_name?: string;
email?: string;
email_verified?: boolean;
sub?: string;
picture?: string;
avatar?: string;
}) => {
console.log("OIDC profile:", profile);
const name =
profile.name ??
profile.display_name ??
profile.preferred_username ??
(profile.given_name && profile.family_name
? `${profile.given_name} ${profile.family_name}`.trim()
: (profile.given_name ?? profile.family_name)) ??
profile.sub ??
"";
return {
email: profile.email,
name: name,
emailVerified: profile.email_verified ?? false,
image: profile.picture ?? profile.avatar ?? null,
};
},
},
],
}),
]
: []),
];
}

View File

@@ -0,0 +1,107 @@
import { createAuthEndpoint } from "better-auth/api";
import { socialProviderList } from "better-auth/social-providers";
export const configuredProviders = socialProviderList.reduce<
Record<
string,
{
clientId: string;
clientSecret: string;
appBundleIdentifier?: string;
tenantId?: string;
requireSelectAccount?: boolean;
clientKey?: string;
issuer?: string;
// Google-specific optional hints
hostedDomain?: string;
hd?: string;
}
>
>((acc, provider) => {
const id = process.env[`${provider.toUpperCase()}_CLIENT_ID`];
const secret = process.env[`${provider.toUpperCase()}_CLIENT_SECRET`];
if (id && id.length > 0 && secret && secret.length > 0) {
acc[provider] = { clientId: id, clientSecret: secret };
}
if (
provider === "apple" &&
Object.keys(acc).includes("apple") &&
acc[provider]
) {
const bundleId =
process.env[`${provider.toUpperCase()}_APP_BUNDLE_IDENTIFIER`];
if (bundleId && bundleId.length > 0) {
acc[provider].appBundleIdentifier = bundleId;
}
}
if (
provider === "gitlab" &&
Object.keys(acc).includes("gitlab") &&
acc[provider]
) {
const issuer = process.env[`${provider.toUpperCase()}_ISSUER`];
if (issuer && issuer.length > 0) {
acc[provider].issuer = issuer;
}
}
if (
provider === "microsoft" &&
Object.keys(acc).includes("microsoft") &&
acc[provider]
) {
acc[provider].tenantId = "common";
acc[provider].requireSelectAccount = true;
}
// Add Google domain hint if allowed domains is configured
if (
provider === "google" &&
Object.keys(acc).includes("google") &&
acc[provider]
) {
const allowed = process.env.BETTER_AUTH_ALLOWED_DOMAINS?.split(",")
.map((d) => d.trim().toLowerCase())
.filter(Boolean);
if (allowed && allowed.length > 0) {
// Use the first domain as an authorization hint
acc[provider].hostedDomain = allowed[0];
acc[provider].hd = allowed[0];
}
}
if (
provider === "tiktok" &&
Object.keys(acc).includes("tiktok") &&
acc[provider]
) {
const key = process.env[`${provider.toUpperCase()}_CLIENT_KEY`];
if (key && key.length > 0) {
acc[provider].clientKey = key;
}
}
return acc;
}, {});
export const socialProvidersPlugin = () => ({
id: "social-providers-plugin",
endpoints: {
getSocialProviders: createAuthEndpoint(
"/social-providers",
{
method: "GET",
},
async (ctx) => {
const providers = ctx.context.socialProviders.map((p) =>
p.id.toLowerCase(),
);
// Add OIDC provider if configured
if (
process.env.OIDC_CLIENT_ID &&
process.env.OIDC_CLIENT_SECRET &&
process.env.OIDC_DISCOVERY_URL
) {
providers.push("oidc");
}
return ctx.json(providers);
},
),
},
});

View File

@@ -0,0 +1,49 @@
import type { Subscription } from "@better-auth/stripe";
import type Stripe from "stripe";
import type { dbClient } from "@kan/db/client";
import * as userRepo from "@kan/db/repository/user.repo";
import { notificationClient } from "@kan/email";
import { createEmailUnsubscribeLink } from "@kan/shared";
export async function downloadImage(url: string): Promise<Buffer> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to download image: ${response.statusText}`);
}
return Buffer.from(await response.arrayBuffer());
}
export async function triggerWorkflow(
db: dbClient,
workflowId: string,
subscription: Subscription,
cancellationDetails?: Stripe.Subscription.CancellationDetails | null,
) {
try {
if (!subscription.stripeCustomerId || !notificationClient) return;
const user = await userRepo.getByStripeCustomerId(
db,
subscription.stripeCustomerId,
);
if (!user || !notificationClient) return;
const unsubscribeUrl = await createEmailUnsubscribeLink(user.id);
await notificationClient.trigger({
to: {
subscriberId: user.id,
},
payload: {
...subscription,
cancellationDetails,
emailUnsubscribeUrl: unsubscribeUrl,
},
workflowId,
});
} catch (error) {
console.error("Error triggering workflow", error);
}
}

View File

@@ -0,0 +1,3 @@
ALTER TABLE "card_activity" ALTER COLUMN "fromTitle" SET DATA TYPE text;--> statement-breakpoint
ALTER TABLE "card_activity" ALTER COLUMN "toTitle" SET DATA TYPE text;--> statement-breakpoint
ALTER TABLE "card" ALTER COLUMN "title" SET DATA TYPE text;

View File

@@ -0,0 +1 @@
ALTER TABLE "workspace" ADD COLUMN "showEmailsToMembers" boolean NOT NULL DEFAULT true;

File diff suppressed because it is too large Load Diff

View File

@@ -148,6 +148,20 @@
"when": 1764621815672,
"tag": "20251201204335_AddCardDueDates",
"breakpoints": true
},
{
"idx": 21,
"version": "7",
"when": 1767045713686,
"tag": "20251229220153_UpdateCardTitleFromVarcharToText",
"breakpoints": true
},
{
"idx": 22,
"version": "7",
"when": 1768858977000,
"tag": "20260119164257_AddShowEmailsToMembersToWorkspace",
"breakpoints": true
}
]
}

View File

@@ -23,6 +23,10 @@
"./repository/*": {
"types": "./dist/repository/*.d.ts",
"default": "./src/repository/*.ts"
},
"./redis": {
"types": "./dist/redis.d.ts",
"default": "./src/redis.ts"
}
},
"license": "GPL-3.0",
@@ -43,6 +47,7 @@
"@kan/shared": "workspace:^",
"drizzle-orm": "^0.42.0",
"drizzle-zod": "^0.5.1",
"ioredis": "^5.9.2",
"pg": "^8.11.3",
"uuid": "^11.1.0",
"zod": "catalog:"

31
packages/db/src/redis.ts Normal file
View File

@@ -0,0 +1,31 @@
import Redis from "ioredis";
let redisClient: Redis | null = null;
export function getRedisClient(): Redis | null {
if (redisClient) {
return redisClient;
}
const redisUrl = process.env.REDIS_URL;
if (!redisUrl) {
return null;
}
redisClient = new Redis(redisUrl, {
maxRetriesPerRequest: 3,
enableReadyCheck: true,
lazyConnect: true,
});
return redisClient;
}
export async function closeRedisClient(): Promise<void> {
if (redisClient) {
await redisClient.quit();
redisClient = null;
}
}

View File

@@ -169,6 +169,7 @@ export const getPaginatedActivities = async (
id: true,
name: true,
email: true,
image: true,
},
},
},
@@ -178,6 +179,7 @@ export const getPaginatedActivities = async (
id: true,
name: true,
email: true,
image: true,
},
},
comment: {

View File

@@ -121,3 +121,15 @@ export const unpauseAllMembers = async (db: dbClient, workspaceId: number) => {
),
);
};
export const pauseAllMembers = async (db: dbClient, workspaceId: number) => {
await db
.update(workspaceMembers)
.set({ status: "paused" })
.where(
and(
eq(workspaceMembers.workspaceId, workspaceId),
eq(workspaceMembers.status, "active"),
),
);
};

View File

@@ -82,6 +82,7 @@ export const update = async (
slug?: string;
plan?: "free" | "pro" | "enterprise";
description?: string;
showEmailsToMembers?: boolean;
},
) => {
const [result] = await db
@@ -91,6 +92,7 @@ export const update = async (
slug: workspaceInput.slug,
plan: workspaceInput.plan,
description: workspaceInput.description,
showEmailsToMembers: workspaceInput.showEmailsToMembers,
})
.where(eq(workspaces.publicId, workspacePublicId))
.returning({
@@ -100,6 +102,7 @@ export const update = async (
slug: workspaces.slug,
description: workspaces.description,
plan: workspaces.plan,
showEmailsToMembers: workspaces.showEmailsToMembers,
});
return result;
@@ -139,6 +142,7 @@ export const getByPublicIdWithMembers = (
columns: {
id: true,
publicId: true,
showEmailsToMembers: true,
},
with: {
members: {

View File

@@ -57,7 +57,7 @@ export const activityTypeEnum = pgEnum("card_activity_type", activityTypes);
export const cards = pgTable("card", {
id: bigserial("id", { mode: "number" }).primaryKey(),
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
title: varchar("title", { length: 255 }).notNull(),
title: text("title").notNull(),
description: text("description"),
index: integer("index").notNull(),
createdBy: uuid("createdBy").references(() => users.id, {
@@ -127,8 +127,8 @@ export const cardActivities = pgTable("card_activity", {
workspaceMemberId: bigint("workspaceMemberId", {
mode: "number",
}).references(() => workspaceMembers.id, { onDelete: "set null" }),
fromTitle: varchar("fromTitle", { length: 255 }),
toTitle: varchar("toTitle", { length: 255 }),
fromTitle: text("fromTitle"),
toTitle: text("toTitle"),
fromDescription: text("fromDescription"),
toDescription: text("toDescription"),
createdBy: uuid("createdBy").references(() => users.id, {

View File

@@ -43,6 +43,7 @@ export const workspaces = pgTable("workspace", {
description: text("description"),
slug: varchar("slug", { length: 255 }).notNull().unique(),
plan: workspacePlanEnum("plan").notNull().default("free"),
showEmailsToMembers: boolean("showEmailsToMembers").notNull().default(true),
createdBy: uuid("createdBy").references(() => users.id, {
onDelete: "set null",
}),

1002
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -114,6 +114,7 @@
"NEXT_PUBLIC_AVATAR_BUCKET_NAME",
"NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME",
"NEXT_PUBLIC_ALLOW_CREDENTIALS",
"NEXT_API_BODY_SIZE_LIMIT",
"NEXT_PUBLIC_DISABLE_SIGN_UP",
"NEXT_PUBLIC_USE_STANDALONE_OUTPUT",
"S3_REGION",
@@ -125,7 +126,8 @@
"BETTER_AUTH_SECRET",
"BETTER_AUTH_TRUSTED_ORIGINS",
"NOVU_API_KEY",
"EMAIL_UNSUBSCRIBE_SECRET"
"EMAIL_UNSUBSCRIBE_SECRET",
"REDIS_URL"
],
"globalPassThroughEnv": [
"NODE_ENV",