Compare commits
192 Commits
feat/displ
...
fix/select
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6878c5af5a | ||
|
|
edb88525c0 | ||
|
|
d69051611c | ||
|
|
cf566e04c6 | ||
|
|
6ea622e7f7 | ||
|
|
0e7b7a072c | ||
|
|
f801d2a7eb | ||
|
|
d9157b6218 | ||
|
|
711ff7f610 | ||
|
|
742e8a1fab | ||
|
|
69ed5fd8f2 | ||
|
|
e21830138d | ||
|
|
f38533590d | ||
|
|
7d52e4eb88 | ||
|
|
692123433b | ||
|
|
8436644966 | ||
|
|
0c2b670baf | ||
|
|
584fe13244 | ||
|
|
cc6804c21d | ||
|
|
8512946102 | ||
|
|
6bebb14855 | ||
|
|
0adb68d547 | ||
|
|
e26c122d76 | ||
|
|
95321c1c0d | ||
|
|
f459c489f9 | ||
|
|
460008d340 | ||
|
|
4371fe2d4d | ||
|
|
60d8e1bd83 | ||
|
|
4459fbb4b2 | ||
|
|
507c553778 | ||
|
|
3d9e859b31 | ||
|
|
54dcd08d9a | ||
|
|
468aeef8b6 | ||
|
|
71205b67dc | ||
|
|
607672718c | ||
|
|
dd262ee50f | ||
|
|
82c8eef0c2 | ||
|
|
ea116268ff | ||
|
|
74384d841e | ||
|
|
c0962e9f70 | ||
|
|
3e6aec2d6b | ||
|
|
4658b2961c | ||
|
|
bc8b5463b3 | ||
|
|
9eee59a877 | ||
|
|
e32e400976 | ||
|
|
b4b07f3120 | ||
|
|
e39faa1172 | ||
|
|
b46979f539 | ||
|
|
f4237fffe6 | ||
|
|
1d9bb96367 | ||
|
|
bcb449e394 | ||
|
|
ae4bc77407 | ||
|
|
6056167063 | ||
|
|
adf91c7dfd | ||
|
|
bba9a94e45 | ||
|
|
e36eb64c59 | ||
|
|
201fe05493 | ||
|
|
25490b2223 | ||
|
|
b3108942db | ||
|
|
2ba759966e | ||
|
|
7045cd8e25 | ||
|
|
603cc46b7d | ||
|
|
e9d338384a | ||
|
|
8a2a9ecef5 | ||
|
|
3a44957662 | ||
|
|
20e6c99e32 | ||
|
|
c0aae5449f | ||
|
|
596f346181 | ||
|
|
7b59e2ac07 | ||
|
|
2a9b1fd2b5 | ||
|
|
a2d4314e14 | ||
|
|
f94e4d6da2 | ||
|
|
97f4defc0e | ||
|
|
dd29dc89fd | ||
|
|
486cdf8313 | ||
|
|
67cf523ee8 | ||
|
|
0b45ceff7f | ||
|
|
76b2c58461 | ||
|
|
b8d1fe230d | ||
|
|
bd652ba6c9 | ||
|
|
1fb56aafd0 | ||
|
|
91deaca6ec | ||
|
|
ab81db0f56 | ||
|
|
29080fa851 | ||
|
|
2360c5075d | ||
|
|
79c2b5f3d5 | ||
|
|
8e7a95ff2b | ||
|
|
94fd2cb11f | ||
|
|
2a8af514d1 | ||
|
|
0b405e0456 | ||
|
|
f1f3a00c17 | ||
|
|
0fb2bac102 | ||
|
|
a7b71ae764 | ||
|
|
a246514b0b | ||
|
|
53397d8e81 | ||
|
|
1f9f07df20 | ||
|
|
1d5e3a936c | ||
|
|
0b49f502a9 | ||
|
|
400dcec56d | ||
|
|
dfcdc5e47e | ||
|
|
32e77e0291 | ||
|
|
a6780e3e28 | ||
|
|
2c5195a2b8 | ||
|
|
793fa30162 | ||
|
|
af63719de5 | ||
|
|
52fd624b81 | ||
|
|
b4d4810f45 | ||
|
|
239d152340 | ||
|
|
f294760747 | ||
|
|
280d8f66dd | ||
|
|
eeae23a24c | ||
|
|
d81950b8bd | ||
|
|
e530f39360 | ||
|
|
bd25fb33f7 | ||
|
|
93f2816b37 | ||
|
|
2292b972c4 | ||
|
|
af36fb133a | ||
|
|
9cb1fb5218 | ||
|
|
5f190b92de | ||
|
|
48a1f39588 | ||
|
|
c7fce10fa7 | ||
|
|
094e1d139d | ||
|
|
fae169c691 | ||
|
|
91bc319cf1 | ||
|
|
d42540b9ef | ||
|
|
71ecf2e6d3 | ||
|
|
8a9cb8fabf | ||
|
|
37f6be52ee | ||
|
|
b72449bef1 | ||
|
|
5cb47a588f | ||
|
|
d5c2404715 | ||
|
|
0276c51ac7 | ||
|
|
1eee819c9d | ||
|
|
2113cc7873 | ||
|
|
32714ccedf | ||
|
|
e72b34b541 | ||
|
|
b44a1296a0 | ||
|
|
3bf132286b | ||
|
|
c9c3365c0a | ||
|
|
7c2937a7b7 | ||
|
|
f27a031ea8 | ||
|
|
05a0245988 | ||
|
|
b6b9423823 | ||
|
|
1696aab43b | ||
|
|
33d2f23955 | ||
|
|
961219835e | ||
|
|
031a42b9eb | ||
|
|
8a1189d011 | ||
|
|
53f6ada4d3 | ||
|
|
6ea02b1db1 | ||
|
|
737b1383d0 | ||
|
|
38d0e9495c | ||
|
|
19798ddbc9 | ||
|
|
08ba965921 | ||
|
|
008b5fd1a0 | ||
|
|
97cfd60b59 | ||
|
|
24e283e22c | ||
|
|
07db7409cb | ||
|
|
346ddc16be | ||
|
|
f9eee53ed0 | ||
|
|
1ab4e24c96 | ||
|
|
34165b390f | ||
|
|
ef0d53db8f | ||
|
|
78b9de869f | ||
|
|
81c03b51e2 | ||
|
|
0d3d9a358f | ||
|
|
2269d23c94 | ||
|
|
e437d075e3 | ||
|
|
b422907b53 | ||
|
|
672dfe6540 | ||
|
|
ef5bf87fdf | ||
|
|
ec37f5480a | ||
|
|
7f5a1ab513 | ||
|
|
5f2d409773 | ||
|
|
210e44db5c | ||
|
|
befe7ab7f4 | ||
|
|
53a33c68fc | ||
|
|
3bae03613d | ||
|
|
c42ee7cf2c | ||
|
|
07f997c3ad | ||
|
|
d084d323bc | ||
|
|
6729c2e228 | ||
|
|
b18ef10313 | ||
|
|
03bd2d771b | ||
|
|
ff7944a4a2 | ||
|
|
0ca9c1d0e6 | ||
|
|
927bf2fe69 | ||
|
|
ccd84385a2 | ||
|
|
58c5e92155 | ||
|
|
885f119404 | ||
|
|
b17a24455a | ||
|
|
0c9561467d |
@@ -1,18 +1,62 @@
|
||||
# Environment
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
docker-compose.override.yml
|
||||
|
||||
Dockerfile
|
||||
./**/*/Dockerfile
|
||||
|
||||
# Docker
|
||||
docker-compose*.yml
|
||||
.dockerignore
|
||||
|
||||
# Dependencies (rebuilt in Docker)
|
||||
node_modules
|
||||
./**/*/node_modules
|
||||
**/node_modules
|
||||
|
||||
# Build outputs (rebuilt in Docker)
|
||||
**/.next
|
||||
**/dist
|
||||
**/out
|
||||
**/.turbo
|
||||
**/.cache
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# IDE
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# CI/CD
|
||||
.github
|
||||
.husky
|
||||
.changeset
|
||||
|
||||
# Documentation (not needed in build)
|
||||
*.md
|
||||
!packages/db/migrations/**
|
||||
LICENSE
|
||||
CONTRIBUTING.md
|
||||
AGENTS.md
|
||||
CHANGELOG.md
|
||||
|
||||
# Test files
|
||||
**/*.test.ts
|
||||
**/*.test.tsx
|
||||
**/*.spec.ts
|
||||
**/*.spec.tsx
|
||||
**/__tests__
|
||||
**/coverage
|
||||
|
||||
# Logs
|
||||
pnpm-debug.log
|
||||
./**/*/pnpm-debug.log
|
||||
**/pnpm-debug.log
|
||||
|
||||
README.md
|
||||
.next
|
||||
.git
|
||||
# Cloud compose (separate deployment)
|
||||
cloud/
|
||||
|
||||
19
.env.example
19
.env.example
@@ -2,7 +2,7 @@
|
||||
|
||||
# Required environment variables
|
||||
NEXT_PUBLIC_BASE_URL= # e.g. https://kan.bn
|
||||
BETTER_AUTH_SECRET= # Random 32+ char string (can gen with: openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 32)
|
||||
BETTER_AUTH_SECRET= # Random 32+ char string (can gen with: openssl rand -base64 26 | tr -dc 'a-zA-Z0-9' | head -c 32)
|
||||
|
||||
# Fill if you want to use an external database
|
||||
POSTGRES_URL= # e.g. postgresql://kan:your_password@your_host:5432/kan
|
||||
@@ -23,24 +23,37 @@ SMTP_REJECT_UNAUTHORIZED= # set to "false" to accept invalid certs
|
||||
NEXT_PUBLIC_DISABLE_EMAIL=
|
||||
|
||||
# S3 storage (optional)
|
||||
S3_REGION=
|
||||
S3_ENDPOINT=
|
||||
S3_REGION= # required for AWS S3 (e.g. us-east-1); ignored by most S3-compatible providers — defaults to us-east-1 if unset
|
||||
S3_ENDPOINT= # set for S3-compatible providers (MinIO, R2, Spaces, etc.); leave blank for AWS S3
|
||||
S3_ACCESS_KEY_ID=
|
||||
S3_SECRET_ACCESS_KEY=
|
||||
S3_FORCE_PATH_STYLE=
|
||||
# S3_AVATAR_UPLOAD_LIMIT=2097152 # Avatar upload size limit in bytes (default: 2MB)
|
||||
NEXT_PUBLIC_STORAGE_URL=
|
||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME=
|
||||
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME=
|
||||
NEXT_PUBLIC_STORAGE_DOMAIN=
|
||||
NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS=
|
||||
|
||||
|
||||
# Auth config (optional)
|
||||
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
|
||||
|
||||
# Logging (optional - debug, info, warn, error; defaults to debug in dev, info in prod)
|
||||
LOG_LEVEL=
|
||||
|
||||
# OAuth providers (optional)
|
||||
BETTER_AUTH_TRUSTED_ORIGINS=
|
||||
# Optional: Restrict OIDC/Social sign-ins to specific email domains (comma-separated)
|
||||
|
||||
56
.github/workflows/docker-publish.yml
vendored
56
.github/workflows/docker-publish.yml
vendored
@@ -6,23 +6,31 @@ name: Docker
|
||||
# documentation.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "21 21 * * *"
|
||||
push:
|
||||
branches: ["main"]
|
||||
# Publish semver tags as releases.
|
||||
# Only trigger on tags (releases), not main branch pushes
|
||||
# Main branch builds are handled by workflow_run after Translate completes
|
||||
tags: ["v*.*.*"]
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
workflow_run:
|
||||
workflows: ["Translate"]
|
||||
types: [completed]
|
||||
branches: [main]
|
||||
# Docker builds after Translate completes (success or failure)
|
||||
# This ensures Docker always has the latest translations
|
||||
|
||||
env:
|
||||
# Use docker.io for Docker Hub if empty
|
||||
REGISTRY: ghcr.io
|
||||
# github.repository as <account>/<repo>
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
MIGRATE_IMAGE_NAME: ${{ github.repository }}-migrate
|
||||
|
||||
jobs:
|
||||
build:
|
||||
concurrency:
|
||||
group: docker-build-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -74,7 +82,22 @@ jobs:
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=raw,value=latest,enable=${{ github.ref_type == 'tag' }}
|
||||
type=raw,value=edge,enable={{is_default_branch}}
|
||||
|
||||
- name: Extract Docker metadata (migrate)
|
||||
id: meta-migrate
|
||||
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.MIGRATE_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value=latest,enable=${{ github.ref_type == 'tag' }}
|
||||
type=raw,value=edge,enable={{is_default_branch}}
|
||||
|
||||
# Extract version from git tag or ref
|
||||
# Uses git describe to get latest tag + commit hash in SemVer format: 1.2.3+abc1234
|
||||
@@ -129,6 +152,22 @@ jobs:
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Build and push migrate Docker image
|
||||
id: build-and-push-migrate
|
||||
uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0
|
||||
with:
|
||||
context: .
|
||||
file: apps/web/Dockerfile
|
||||
target: migrate
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta-migrate.outputs.tags }}
|
||||
labels: ${{ steps.meta-migrate.outputs.labels }}
|
||||
build-args: |
|
||||
APP_VERSION=${{ steps.version.outputs.version }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# Sign the resulting Docker image digest except on PRs.
|
||||
# This will only write to the public Rekor transparency log when the Docker
|
||||
# repository is public to avoid leaking data. If you would like to publish
|
||||
@@ -143,3 +182,10 @@ jobs:
|
||||
# This step uses the identity token to provision an ephemeral certificate
|
||||
# against the sigstore community Fulcio instance.
|
||||
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
|
||||
|
||||
- name: Sign the published migrate Docker image
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
env:
|
||||
TAGS: ${{ steps.meta-migrate.outputs.tags }}
|
||||
DIGEST: ${{ steps.build-and-push-migrate.outputs.digest }}
|
||||
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
|
||||
|
||||
56
.github/workflows/translate.yml
vendored
Normal file
56
.github/workflows/translate.yml
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
name: Translate
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
translate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --filter @kan/web...
|
||||
|
||||
- name: Extract translations
|
||||
working-directory: apps/web
|
||||
run: pnpm lingui:extract
|
||||
|
||||
- name: Lingo.dev
|
||||
uses: lingodotdev/lingo.dev@main
|
||||
with:
|
||||
api-key: ${{ secrets.LINGODOTDEV_API_KEY }}
|
||||
commit-message: "chore: update translations"
|
||||
working-directory: apps/web
|
||||
commit-author-name: "${{ github.actor }}"
|
||||
commit-author-email: "${{ github.actor }}@users.noreply.github.com"
|
||||
parallel: true
|
||||
|
||||
- name: Compile translations
|
||||
working-directory: apps/web
|
||||
run: pnpm lingui:compile
|
||||
|
||||
- name: Commit compiled translations
|
||||
run: |
|
||||
git config --local user.email "${{ github.actor }}@users.noreply.github.com"
|
||||
git config --local user.name "${{ github.actor }}"
|
||||
if ls apps/web/src/locales/*/messages.ts 1> /dev/null 2>&1; then
|
||||
git add apps/web/src/locales/*/messages.ts
|
||||
git diff --staged --quiet || (git commit -m "chore: compile translations" && git push)
|
||||
fi
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -54,3 +54,5 @@ i18n.cache
|
||||
|
||||
# pgdata
|
||||
/apps/web/pgdata
|
||||
|
||||
.claude
|
||||
333
AGENTS.md
Normal file
333
AGENTS.md
Normal file
@@ -0,0 +1,333 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Project Overview
|
||||
|
||||
Kan is an open-source project management tool (Trello alternative) built with:
|
||||
|
||||
- **Frontend**: Next.js, React, TypeScript, Tailwind CSS
|
||||
- **Backend**: tRPC, Node.js
|
||||
- **Database**: PostgreSQL with Drizzle ORM
|
||||
- **Monorepo**: pnpm workspaces with Turbo
|
||||
- **Auth**: Better Auth
|
||||
- **Internationalization**: Lingui
|
||||
|
||||
## Setup Commands
|
||||
|
||||
- Install deps: `pnpm install`
|
||||
- Start dev server: `pnpm dev`
|
||||
- Create migrations: `cd packages/db && pnpm drizzle-kit generate --name "AddFieldToTable"`
|
||||
- Run database migrations: `pnpm db:migrate`
|
||||
- Run linter: `pnpm lint`
|
||||
- Run type check: `pnpm typecheck`
|
||||
- Format code: `pnpm format:fix`
|
||||
- Extract i18n strings: `pnpm lingui:extract`
|
||||
|
||||
## Project Structure
|
||||
|
||||
- `apps/web/` - Next.js web application
|
||||
- `packages/api/` - tRPC API routers
|
||||
- `packages/db/` - Database schema, migrations, and repositories
|
||||
- `packages/auth/` - Authentication package
|
||||
- `packages/shared/` - Shared utilities
|
||||
- `packages/email/` - Email templates and sending
|
||||
- `packages/stripe/` - Stripe integration
|
||||
- `tooling/` - Shared tooling configs (ESLint, Prettier, TypeScript)
|
||||
|
||||
## Code Style
|
||||
|
||||
### TypeScript
|
||||
|
||||
- Use TypeScript strictly - avoid `any` types
|
||||
- Prefer explicit types over inference when it improves clarity
|
||||
- Use `as const` for literal types when appropriate
|
||||
- Follow existing patterns for type definitions
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
- **Files**: kebab-case for files (e.g., `card-repo.ts`)
|
||||
- **Components**: PascalCase for React components
|
||||
- **Functions**: camelCase for functions
|
||||
- **Constants**: UPPER_SNAKE_CASE for constants
|
||||
- **Types/Interfaces**: PascalCase
|
||||
|
||||
### Database Layer (`packages/db/`)
|
||||
|
||||
- **Schema**: Define schemas in `src/schema/` using Drizzle ORM
|
||||
- **Migrations**: Create migrations with `cd packages/db && pnpm drizzle-kit generate --name "MigrationName"`, then run with `pnpm db:migrate`
|
||||
- **Repositories**: Put database queries in `src/repository/` files
|
||||
- **Soft Deletes**: Use `deletedAt` timestamp for soft deletion (not hard deletes)
|
||||
- **Index Management**: Cards have `index` fields that must be maintained sequentially per list
|
||||
- **Activity Logging**: Use `card_activity` table to track all card changes
|
||||
|
||||
### API Layer (`packages/api/`)
|
||||
|
||||
- **Routers**: Create tRPC routers in `src/routers/`
|
||||
- **Procedures**: Use `protectedProcedure` for authenticated endpoints, `publicProcedure` for public
|
||||
- **Validation**: Use Zod schemas for input validation
|
||||
- **Error Handling**: Use `TRPCError` with appropriate error codes
|
||||
- **OpenAPI**: Add OpenAPI metadata for all endpoints
|
||||
- **Authorization**: Always check workspace membership with `assertUserInWorkspace`
|
||||
|
||||
### Frontend (`apps/web/`)
|
||||
|
||||
- **Components**: React components in `src/components/`
|
||||
- **Views**: Page-level components in `src/views/`
|
||||
- **Hooks**: Custom hooks in `src/hooks/`
|
||||
- **i18n**: Use `t` template literal for translations (Lingui)
|
||||
- **Styling**: Use Tailwind CSS classes
|
||||
- **State Management**: Use tRPC React Query hooks for server state
|
||||
- **Modals**: Use `useModal` hook for modal management
|
||||
- **Popups**: Use `usePopup` hook for toast notifications
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### Cards
|
||||
|
||||
- Cards are the main entity in Kan
|
||||
- Cards belong to Lists, which belong to Boards
|
||||
- Cards have: title, description, labels, members, checklists, comments, attachments, due dates
|
||||
- Cards use soft deletion (`deletedAt` field)
|
||||
- Cards have an `index` field that must be maintained sequentially per list
|
||||
- All card changes are tracked in `card_activity` table
|
||||
|
||||
### Activity Tracking
|
||||
|
||||
- Every significant card change creates an activity record
|
||||
- Activity types include: created, updated (various fields), etc.
|
||||
- Activities are displayed in card activity feeds
|
||||
|
||||
### Workspaces & Boards
|
||||
|
||||
- Users belong to Workspaces
|
||||
- Boards belong to Workspaces
|
||||
- Workspace members have different permission levels
|
||||
- Boards can be public or private
|
||||
|
||||
### Soft Deletion Pattern
|
||||
|
||||
- Entities use `deletedAt` timestamp for soft deletion
|
||||
- Queries filter with `isNull(table.deletedAt)` to exclude deleted items
|
||||
|
||||
## File Locations Reference
|
||||
|
||||
### Database
|
||||
|
||||
- Schema: `packages/db/src/schema/*.ts`
|
||||
- Repositories: `packages/db/src/repository/*.repo.ts`
|
||||
- Migrations: `packages/db/migrations/`
|
||||
|
||||
### API
|
||||
|
||||
- Routers: `packages/api/src/routers/*.ts`
|
||||
- Utils: `packages/api/src/utils/`
|
||||
- Types: `packages/api/src/types/`
|
||||
|
||||
### Frontend
|
||||
|
||||
- Components: `apps/web/src/components/`
|
||||
- Views: `apps/web/src/views/`
|
||||
- Pages: `apps/web/src/pages/`
|
||||
- Hooks: `apps/web/src/hooks/`
|
||||
- Utils: `apps/web/src/utils/`
|
||||
- Locales: `apps/web/src/locales/`
|
||||
|
||||
## Database Patterns
|
||||
|
||||
- **Soft Deletes**: Always filter with `isNull(table.deletedAt)` in queries
|
||||
- **Public IDs**: Use 12-character public IDs (`publicId`) for all user-facing entities
|
||||
- **Internal IDs**: Never expose internal database IDs (e.g., `id`, `cardId`, `listId`) in API responses or URLs - always use `publicId` externally
|
||||
- **Transactions**: Use database transactions for multi-step operations
|
||||
- **Index Management**: When deleting/moving cards, maintain sequential indices
|
||||
- **Activity Tracking**: Create activity records for all significant changes
|
||||
|
||||
## API Patterns
|
||||
|
||||
- **Input Validation**: Always validate inputs with Zod
|
||||
- **Error Messages**: Provide clear, user-friendly error messages
|
||||
- **Optimistic Updates**: Use tRPC's `onMutate` for optimistic UI updates
|
||||
- **Cache Invalidation**: Properly invalidate queries after mutations
|
||||
- **ID Exposure**: Never expose internal database IDs (`id`, `cardId`, `listId`, etc.) in API responses, URLs, or frontend code - always use `publicId` for external communication
|
||||
|
||||
## Important Patterns
|
||||
|
||||
### Card Index Management
|
||||
|
||||
When cards are created, moved, or deleted, their indices must be maintained:
|
||||
|
||||
- New cards: Append to end (max index + 1) or insert at position
|
||||
- Moving cards: Adjust indices of affected cards
|
||||
- Deleting cards: Decrement indices of cards after deleted one
|
||||
- Always use transactions for index updates
|
||||
|
||||
### Activity Logging
|
||||
|
||||
Create activity records for:
|
||||
|
||||
- Card creation
|
||||
- Card updates (title, description, list, etc.)
|
||||
- Label/member additions/removals
|
||||
- Comments
|
||||
- Checklists and items
|
||||
- Attachments
|
||||
- Due dates
|
||||
|
||||
### Authorization
|
||||
|
||||
Always check:
|
||||
|
||||
1. User is authenticated
|
||||
2. User has access to workspace
|
||||
3. User has permission for the operation
|
||||
|
||||
Use `assertUserInWorkspace` helper for workspace checks.
|
||||
|
||||
### Error Handling
|
||||
|
||||
- Use TRPCError with appropriate codes (UNAUTHORIZED, NOT_FOUND, etc.)
|
||||
- Provide user-friendly error messages
|
||||
- Log errors appropriately using the `@kan/logger` package
|
||||
- Show popup notifications for user-facing errors
|
||||
|
||||
### Logging
|
||||
|
||||
- Import from `@kan/logger`: `import { createLogger } from "@kan/logger"`
|
||||
- Create a module-scoped logger: `const logger = createLogger("module-name")`
|
||||
- Log level is controlled by `LOG_LEVEL` env var (debug, info, warn, error)
|
||||
- Defaults to `debug` in development, `info` in production
|
||||
- Never use `console.log` — always use the logger
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Creating a Card
|
||||
|
||||
1. Create card in repository with proper index management
|
||||
2. Create `card.created` activity
|
||||
3. Handle label/member relationships if provided
|
||||
4. Return the created card
|
||||
|
||||
### Updating a Card
|
||||
|
||||
1. Validate user has workspace access
|
||||
2. Update card fields
|
||||
3. Create appropriate activity records
|
||||
4. Invalidate relevant queries
|
||||
|
||||
### Querying Cards
|
||||
|
||||
- Always filter by `isNull(cards.deletedAt)` in queries
|
||||
- Include related data (labels, members, checklists) via Drizzle relations
|
||||
- Order by `index` for proper card ordering
|
||||
|
||||
## Adding a New Feature
|
||||
|
||||
1. **Database**: Update schema in `packages/db/src/schema/`
|
||||
2. **Migration**: Create migration with `cd packages/db && pnpm drizzle-kit generate --name "MigrationName"`, then run with `pnpm db:migrate`
|
||||
3. **Repository**: Add repository functions in `packages/db/src/repository/`
|
||||
4. **API**: Add tRPC router procedures in `packages/api/src/routers/`
|
||||
5. **Frontend**: Add UI components in `apps/web/src/`
|
||||
6. **i18n**: Add translations for new strings
|
||||
|
||||
## Adding a New Environment Variable
|
||||
|
||||
Update all of the following:
|
||||
|
||||
1. `.env.example` — add the variable with an empty value and a comment explaining it
|
||||
2. `turbo.json` — add to `globalEnv` (or `globalPassThroughEnv` for CI/platform vars)
|
||||
3. `docker-compose.yml` — add to the `web` service `environment` section
|
||||
4. `cloud/docker-compose.yml` — add to the `web` service `environment` section
|
||||
5. `README.md` — add a row to the Environment Variables table
|
||||
|
||||
## Database Changes
|
||||
|
||||
- Always create migrations (never modify existing migrations)
|
||||
- Create migrations with: `cd packages/db && pnpm drizzle-kit generate --name "MigrationName"`
|
||||
- Run migrations with: `pnpm db:migrate`
|
||||
- Update schema files in `packages/db/src/schema/`
|
||||
- Test migrations on development database first
|
||||
- Update TypeScript types after schema changes
|
||||
- Consider index management for card operations
|
||||
|
||||
## API Endpoints
|
||||
|
||||
- Use tRPC procedures (not REST)
|
||||
- Add OpenAPI metadata for documentation
|
||||
- Validate inputs with Zod
|
||||
- Check workspace permissions
|
||||
- Create activity records for significant changes
|
||||
|
||||
## Frontend Components
|
||||
|
||||
- Use Tailwind for styling
|
||||
- Follow existing component patterns
|
||||
- Use tRPC hooks for data fetching
|
||||
- Implement optimistic updates where appropriate
|
||||
- Add proper loading and error states
|
||||
|
||||
## Testing Instructions
|
||||
|
||||
- Test database operations in transactions that rollback
|
||||
- Test authorization checks
|
||||
- Test index management when moving/deleting cards
|
||||
- Test activity logging
|
||||
- Test UI interactions
|
||||
- Run `pnpm lint` and `pnpm typecheck` before committing
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- Use database indexes appropriately
|
||||
- Batch operations when possible
|
||||
- Avoid N+1 queries
|
||||
- Use transactions for related operations
|
||||
- Implement optimistic updates in UI
|
||||
|
||||
## Security
|
||||
|
||||
- Always check workspace membership before operations
|
||||
- Validate all inputs
|
||||
- Never expose internal database IDs (`id`, `cardId`, `listId`, etc.) in API responses, URLs, or frontend code - always use `publicId` externally
|
||||
- Sanitize user input
|
||||
|
||||
## Internationalization
|
||||
|
||||
- All user-facing strings must use `t` template literal
|
||||
- Add translations to locale files in `apps/web/src/locales/`
|
||||
- Run `pnpm lingui:extract` to update translation files
|
||||
- Update locale files for all languages
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Use workspace dependencies (`workspace:*`) for internal packages
|
||||
- Keep dependencies up to date
|
||||
- Use catalog for shared dependency versions
|
||||
|
||||
## When Implementing Features
|
||||
|
||||
### Adding New Card Fields
|
||||
|
||||
1. Update schema in `packages/db/src/schema/cards.ts`
|
||||
2. Create migration
|
||||
3. Update repository functions
|
||||
4. Add API endpoints
|
||||
5. Update frontend components
|
||||
6. Add activity tracking if needed
|
||||
|
||||
### Adding New Activity Types
|
||||
|
||||
1. Add to `activityTypes` array in schema
|
||||
2. Create migration to update enum
|
||||
3. Use in activity creation code
|
||||
4. Update activity display components if needed
|
||||
|
||||
## Git & Commits
|
||||
|
||||
- Use conventional commits: `feat:`, `fix:`, `refactor:`, `docs:`, etc.
|
||||
- Keep commits focused on single changes
|
||||
- Reference issue numbers when applicable
|
||||
|
||||
## PR Instructions
|
||||
|
||||
- Title format: `feat: description` or `fix: description`
|
||||
- Always run `pnpm lint` and `pnpm typecheck` before committing
|
||||
- Provide clear description of changes
|
||||
- Include screenshots for UI changes
|
||||
- Keep PRs focused on a single feature/fix
|
||||
77
README.md
77
README.md
@@ -47,39 +47,71 @@ See our [roadmap](https://kan.bn/kan/roadmap) for upcoming features.
|
||||
|
||||
## Self Hosting 🐳
|
||||
|
||||
The easiest way to self-host Kan is with Docker Compose. This will set up everything for you including your postgres database.
|
||||
### One-click Deployments
|
||||
|
||||
1. Create a new file called `docker-compose.yml` and paste the following configuration:
|
||||
The easiest way to deploy Kan is through Railway. We've partnered with Railway to maintain an official template that supports the development of the project.
|
||||
|
||||
<a href="https://railway.com/deploy/kan?referralCode=bZPsr2&utm_medium=integration&utm_source=template&utm_campaign=generic">
|
||||
<img src="https://railway.app/button.svg" alt="Deploy on Railway" height="40" />
|
||||
</a>
|
||||
|
||||
### Docker Compose
|
||||
|
||||
Alternatively, you can self-host Kan with Docker Compose. This will set up everything for you including your postgres database and automatically run migrations.
|
||||
|
||||
1. Create a `.env` file with your environment variables (see [Environment Variables](#environment-variables-) section below)
|
||||
|
||||
2. Use the provided `docker-compose.yml` file or create your own with the following configuration:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
migrate:
|
||||
image: ghcr.io/kanbn/kan-migrate:latest
|
||||
container_name: kan-migrate
|
||||
networks:
|
||||
- kan-network
|
||||
environment:
|
||||
- POSTGRES_URL=${POSTGRES_URL}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
restart: "no"
|
||||
|
||||
web:
|
||||
image: ghcr.io/kanbn/kan:latest
|
||||
container_name: kan-web
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "${WEB_PORT:-3000}:3000"
|
||||
networks:
|
||||
- kan-network
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
NEXT_PUBLIC_BASE_URL: http://localhost:3000
|
||||
BETTER_AUTH_SECRET: your_auth_secret
|
||||
POSTGRES_URL: postgresql://kan:your_postgres_password@postgres:5432/kan_db
|
||||
NEXT_PUBLIC_ALLOW_CREDENTIALS: true
|
||||
- NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL}
|
||||
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
|
||||
- POSTGRES_URL=${POSTGRES_URL}
|
||||
- NEXT_PUBLIC_ALLOW_CREDENTIALS=true
|
||||
depends_on:
|
||||
- postgres
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
restart: unless-stopped
|
||||
|
||||
postgres:
|
||||
image: postgres:15
|
||||
container_name: kan-db
|
||||
environment:
|
||||
POSTGRES_DB: kan_db
|
||||
POSTGRES_USER: kan
|
||||
POSTGRES_PASSWORD: your_postgres_password
|
||||
- POSTGRES_DB=kan_db
|
||||
- POSTGRES_USER=kan
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||
ports:
|
||||
- 5432:5432
|
||||
volumes:
|
||||
- kan_postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U kan -d kan_db"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- kan-network
|
||||
@@ -91,23 +123,23 @@ volumes:
|
||||
kan_postgres_data:
|
||||
```
|
||||
|
||||
2. Start the containers in detached mode:
|
||||
3. Start the containers in detached mode:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
3. Access Kan at http://localhost:3000
|
||||
The `migrate` service will automatically run database migrations before the web service starts. The application will be available at http://localhost:3000 (or the port specified in `WEB_PORT`).
|
||||
|
||||
The application will be running in the background. You can manage the containers using these commands:
|
||||
**Managing containers:**
|
||||
|
||||
- To stop the containers: `docker compose down`
|
||||
- To view logs: `docker compose logs -f`
|
||||
- To view logs for a specific service: `docker compose logs -f web` or `docker compose logs -f migrate`
|
||||
- To restart the containers: `docker compose restart`
|
||||
- To rebuild after code changes: `docker compose up -d --build`
|
||||
|
||||
For the complete Docker Compose configuration, see [docker-compose.yml](./docker-compose.yml) in the repository.
|
||||
|
||||
> **Note**: The Docker Compose configuration shown above is a minimal example. For a complete setup with all features (email, OAuth, file uploads, etc.), you'll need to create a `.env` file with the required environment variables. See the Environment Variables section below for the full list of available options.
|
||||
For the complete Docker Compose configuration with all optional features, see [docker-compose.yml](./docker-compose.yml) in the repository.
|
||||
|
||||
## Local Development 🧑💻
|
||||
|
||||
@@ -141,6 +173,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 +183,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` |
|
||||
@@ -169,14 +203,17 @@ pnpm dev
|
||||
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads (optional with IRSA) | `xxx` |
|
||||
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads (optional with IRSA) | `xxx` |
|
||||
| `S3_FORCE_PATH_STYLE` | Use path-style URLs for S3 | For file uploads | `true` |
|
||||
| `S3_AVATAR_UPLOAD_LIMIT` | Maximum avatar file size in bytes | For file uploads | `2097152` (2MB) |
|
||||
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
|
||||
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` |
|
||||
| `NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS` | Use virtual-hosted style URLs (bucket.domain.com) | For file uploads (optional) | `true` |
|
||||
| `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` |
|
||||
| `NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME` | S3 bucket name for attachments | For file uploads | `attachments` |
|
||||
| `NEXT_PUBLIC_ALLOW_CREDENTIALS` | Allow email & password login | For authentication | `true` |
|
||||
| `NEXT_PUBLIC_DISABLE_SIGN_UP` | Disable sign up | For authentication | `false` |
|
||||
| `NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY` | Hide “Powered by kan.bn” on public boards (self-host) | For white labelling | `true` |
|
||||
| `KAN_ADMIN_API_KEY` | Admin API key for stats and admin endpoints | For admin/monitoring | `your-secret-admin-key` |
|
||||
| `LOG_LEVEL` | Log verbosity level (debug, info, warn, error) | No (defaults to debug in dev, info in prod) | `info` |
|
||||
|
||||
See `.env.example` for a complete list of supported environment variables.
|
||||
|
||||
@@ -190,6 +227,12 @@ We welcome contributions! Please read our [contribution guidelines](CONTRIBUTING
|
||||
<img src="https://contrib.rocks/image?repo=kanbn/kan" />
|
||||
</a>
|
||||
|
||||
## Sponsors ❤️
|
||||
|
||||
[<img height="100" alt="image" src="https://github.com/user-attachments/assets/e331c71f-ac86-46a6-bceb-ce276de094b0" />](https://www.testmuai.com)
|
||||
|
||||
Proudly sponsored by [TestMu AI (formerly LambdaTest)](https://www.testmuai.com) - an AI-native testing cloud platform built for modern engineering teams. Covering everything from autonomous test creation and fast execution to testing AI agents like chatbots and voice assistants. If you're serious about testing, go check them out.
|
||||
|
||||
## License 📝
|
||||
|
||||
Kan is licensed under the [AGPLv3 license](LICENSE).
|
||||
|
||||
@@ -11,10 +11,10 @@ https://kan.bn/api/v1
|
||||
|
||||
## Authentication
|
||||
|
||||
Most endpoints require authentication using your API key. You can create one in the [settings page](https://kan.bn/settings) of your account. Include this key in the `x-api-key` header of each request.
|
||||
Most endpoints require authentication using your API key. You can create one in the [settings page](https://kan.bn/settings) of your account. Include this key as a Bearer token in the `Authorization` header of each request.
|
||||
|
||||
```
|
||||
'x-api-key': kan_123456789
|
||||
'Authorization': 'Bearer kan_123456789'
|
||||
```
|
||||
|
||||
## Response codes
|
||||
|
||||
@@ -1,87 +1,122 @@
|
||||
ARG NODE_VERSION=20
|
||||
ARG APP_DIRNAME=web
|
||||
ARG PROJECT=@kan/web
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
# 1. Alpine image
|
||||
ARG NODE_VERSION=20
|
||||
ARG DISTROLESS_NODE_IMAGE=gcr.io/distroless/nodejs${NODE_VERSION}-debian12
|
||||
|
||||
# ============================================
|
||||
# Stage 1: Alpine base with pnpm and turbo
|
||||
# ============================================
|
||||
FROM node:${NODE_VERSION}-alpine AS alpine
|
||||
RUN apk update && \
|
||||
apk add --no-cache --virtual .build-deps libc6-compat python3 make g++ && \
|
||||
rm -rf /var/cache/apk/* /tmp/* || true
|
||||
apk add --no-cache libc6-compat && \
|
||||
rm -rf /var/cache/apk/* /tmp/* || true && \
|
||||
corepack enable && \
|
||||
corepack prepare pnpm@9.14.2 --activate && \
|
||||
npm install turbo@2.3.1 --global && \
|
||||
pnpm config set store-dir ~/.pnpm-store
|
||||
|
||||
# Setup pnpm and turbo on the alpine base
|
||||
FROM alpine AS base
|
||||
RUN corepack enable
|
||||
# Replace <your-major-version> with the major version installed in your repository. For example:
|
||||
# RUN npm install turbo@2.1.3 --global
|
||||
RUN npm install turbo@2.3.1 --global
|
||||
# ============================================
|
||||
# Stage 2: Prune the monorepo
|
||||
# ============================================
|
||||
FROM alpine AS pruner
|
||||
|
||||
RUN pnpm config set store-dir ~/.pnpm-store
|
||||
RUN apk add --no-cache git
|
||||
|
||||
# 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
|
||||
|
||||
# It might be the path to <ROOT> turborepo
|
||||
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" }
|
||||
RUN turbo prune --scope=${PROJECT} --scope=@kan/db --docker
|
||||
|
||||
# 3. Build the project
|
||||
FROM base AS builder
|
||||
ARG PROJECT
|
||||
ARG APP_VERSION
|
||||
|
||||
# Environment to skip .env validation on build
|
||||
ENV CI=true
|
||||
ENV NEXT_PUBLIC_APP_VERSION=${APP_VERSION}
|
||||
# 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=@kan/web --scope=@kan/db --docker
|
||||
|
||||
# ============================================
|
||||
# Stage 3: Install dependencies
|
||||
# ============================================
|
||||
FROM alpine AS deps
|
||||
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/ .
|
||||
|
||||
# First install the dependencies (as they change less often)
|
||||
ENV CI=true
|
||||
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}
|
||||
|
||||
# # Copy static files to standalone directory
|
||||
# RUN mkdir -p apps/web/.next/standalone/.next && \
|
||||
# mv apps/web/public apps/web/.next/standalone/ && \
|
||||
# mv apps/web/.next/static apps/web/.next/standalone/.next/
|
||||
|
||||
# 4. Final image - runner stage to run the application
|
||||
FROM base AS runner
|
||||
ARG APP_DIRNAME
|
||||
|
||||
# Don't run production as root
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
USER nextjs
|
||||
# ============================================
|
||||
# Stage 4: Build the web application
|
||||
# ============================================
|
||||
FROM alpine AS builder
|
||||
ARG APP_VERSION
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=deps /app/ ./
|
||||
COPY --from=pruner /app/out/full/ .
|
||||
COPY --from=pruner /app/AUTO_VERSION /tmp/AUTO_VERSION
|
||||
|
||||
# Force standalone output for production Docker image
|
||||
ENV NEXT_PUBLIC_USE_STANDALONE_OUTPUT=true
|
||||
ENV CI=true
|
||||
|
||||
RUN VERSION="${APP_VERSION:-$(cat /tmp/AUTO_VERSION 2>/dev/null | tr -d '\n\r' || echo 'unknown')}" && \
|
||||
NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build --filter=@kan/web
|
||||
|
||||
# ============================================
|
||||
# Stage 5: Migration image (run-once container)
|
||||
# ============================================
|
||||
FROM node:${NODE_VERSION}-alpine AS migrate
|
||||
WORKDIR /db
|
||||
|
||||
COPY packages/db/drizzle.config.ts ./drizzle.config.ts
|
||||
COPY packages/db/migrations/ ./migrations/
|
||||
|
||||
RUN npm init -y && \
|
||||
npm install drizzle-kit drizzle-orm pg --save-exact && \
|
||||
# Strip unnecessary files from node_modules
|
||||
find node_modules -type f \( \
|
||||
-name '*.d.ts' -o \
|
||||
-name '*.d.mts' -o \
|
||||
-name '*.d.cts' -o \
|
||||
-name '*.map' -o \
|
||||
-name '*.md' -o \
|
||||
-name '*.txt' -o \
|
||||
-name 'LICENSE*' -o \
|
||||
-name 'CHANGELOG*' -o \
|
||||
-name 'README*' -o \
|
||||
-name '.eslint*' -o \
|
||||
-name '.prettier*' -o \
|
||||
-name 'tsconfig*.json' \
|
||||
\) -delete && \
|
||||
find node_modules -type d -empty -delete && \
|
||||
rm -rf /root/.npm /tmp/*
|
||||
|
||||
CMD ["npx", "drizzle-kit", "migrate"]
|
||||
|
||||
# ============================================
|
||||
# Stage 6: Production web image (distroless)
|
||||
# ============================================
|
||||
FROM ${DISTROLESS_NODE_IMAGE} AS web
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
|
||||
COPY --chown=nextjs:nodejs --from=builder /app/ ./
|
||||
WORKDIR /app/apps/${APP_DIRNAME}
|
||||
# Copy the standalone Next.js server
|
||||
COPY --from=builder /app/apps/web/.next/standalone/ ./
|
||||
# Copy static assets and public files
|
||||
COPY --from=builder /app/apps/web/.next/static/ ./apps/web/.next/static/
|
||||
COPY --from=builder /app/apps/web/public/ ./apps/web/public/
|
||||
|
||||
ARG PORT=3000
|
||||
ENV PORT=${PORT}
|
||||
EXPOSE ${PORT}
|
||||
# Copy bootstrap script for runtime env var injection
|
||||
COPY apps/web/bootstrap.cjs ./bootstrap.cjs
|
||||
|
||||
CMD ["sh", "-c", "if [ -n \"$POSTGRES_URL\" ]; then cd /app && pnpm db:migrate && cd /app/apps/web; fi && pnpm start"]
|
||||
EXPOSE 3000
|
||||
CMD ["bootstrap.cjs"]
|
||||
|
||||
44
apps/web/bootstrap.cjs
Normal file
44
apps/web/bootstrap.cjs
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Bootstrap script for the distroless production image.
|
||||
*
|
||||
* Distroless images have no shell, so this Node.js script handles two tasks
|
||||
* that would normally be done in an entrypoint.sh:
|
||||
*
|
||||
* 1. Regenerate `public/__ENV.js` with the current runtime NEXT_PUBLIC_*
|
||||
* environment variables. The file was originally created at build time by
|
||||
* next-runtime-env's `configureRuntimeEnv()`, but in a Docker deployment the
|
||||
* env vars are provided at *run* time via docker-compose / docker run.
|
||||
*
|
||||
* 2. Start the Next.js standalone server.
|
||||
*/
|
||||
|
||||
const { writeFileSync, existsSync, mkdirSync } = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Inject runtime NEXT_PUBLIC_* env vars into __ENV.js
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const publicDir = path.join(__dirname, "apps", "web", "public");
|
||||
|
||||
if (!existsSync(publicDir)) {
|
||||
mkdirSync(publicDir, { recursive: true });
|
||||
}
|
||||
|
||||
const envVars = {};
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (key.startsWith("NEXT_PUBLIC_")) {
|
||||
envVars[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(
|
||||
path.join(publicDir, "__ENV.js"),
|
||||
`self.__ENV = ${JSON.stringify(envVars)};`,
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Start the Next.js standalone server
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
require("./apps/web/server.js");
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"version": 0,
|
||||
"version": "1.10",
|
||||
"locale": {
|
||||
"source": "en",
|
||||
"targets": ["fr", "de", "es", "it", "nl", "ru", "pl", "ptbr"]
|
||||
"targets": ["fr", "de", "es", "it", "nl", "ru", "pl", "pt-BR"]
|
||||
},
|
||||
"buckets": {
|
||||
"po": {
|
||||
"include": ["src/locales/[locale]/messages.po"]
|
||||
"json": {
|
||||
"include": ["src/locales/[locale]/messages.json"]
|
||||
}
|
||||
},
|
||||
"$schema": "https://lingo.dev/schema/i18n.json"
|
||||
|
||||
3752
apps/web/i18n.lock
3752
apps/web/i18n.lock
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,9 @@
|
||||
import type { LinguiConfig } from "@lingui/conf";
|
||||
import { defineConfig } from "@lingui/cli";
|
||||
import { formatter } from "@lingui/format-json";
|
||||
|
||||
const config: LinguiConfig = {
|
||||
locales: ["en", "fr", "de", "es", "it", "nl", "ru", "pl","ptbr"],
|
||||
|
||||
export default defineConfig({
|
||||
locales: ["en", "fr", "de", "es", "it", "nl", "ru", "pl", "pt-BR"],
|
||||
sourceLocale: "en",
|
||||
catalogs: [
|
||||
{
|
||||
@@ -10,6 +12,5 @@ const config: LinguiConfig = {
|
||||
exclude: ["**/node_modules/**"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default config;
|
||||
format: formatter({ style: "lingui" }),
|
||||
});
|
||||
|
||||
@@ -16,6 +16,19 @@ const config = {
|
||||
: undefined,
|
||||
reactStrictMode: true,
|
||||
|
||||
/** Exclude build tools and dev-only packages from the standalone output */
|
||||
outputFileTracingExcludes: {
|
||||
"**/*": [
|
||||
"@esbuild/**",
|
||||
"esbuild/**",
|
||||
"typescript/**",
|
||||
"webpack/**",
|
||||
"uglify-js/**",
|
||||
"terser/**",
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
/** Enables hot reloading for local packages without a build step */
|
||||
transpilePackages: [
|
||||
"@kan/api",
|
||||
@@ -35,45 +48,13 @@ const config = {
|
||||
remotePatterns: (() => {
|
||||
/** @type {Array<{protocol: "http" | "https", hostname: string}>} */
|
||||
const patterns = [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname:
|
||||
env("S3_FORCE_PATH_STYLE") === "true"
|
||||
? `${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`
|
||||
: `*.${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`,
|
||||
},
|
||||
{ protocol: "https", hostname: "**" },
|
||||
{
|
||||
protocol: "http",
|
||||
hostname: "localhost",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "*.googleusercontent.com",
|
||||
},
|
||||
];
|
||||
|
||||
// Extract root domain from S3_ENDPOINT and add wildcard pattern
|
||||
const s3Endpoint = env("S3_ENDPOINT");
|
||||
if (s3Endpoint) {
|
||||
try {
|
||||
const url = new URL(s3Endpoint);
|
||||
const hostname = url.hostname;
|
||||
const protocol = url.protocol.replace(":", "");
|
||||
|
||||
// Extract root domain (last 2 parts: e.g. cloudflarestorage.com)
|
||||
const parts = hostname.split(".");
|
||||
if (parts.length >= 2) {
|
||||
const rootDomain = parts.slice(-2).join(".");
|
||||
patterns.push({
|
||||
protocol: protocol === "http" ? "http" : "https",
|
||||
hostname: `*.${rootDomain}`,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// If S3_ENDPOINT is not a valid URL, ignore it
|
||||
}
|
||||
}
|
||||
|
||||
return patterns;
|
||||
})(),
|
||||
},
|
||||
@@ -85,6 +66,8 @@ const config = {
|
||||
},
|
||||
},
|
||||
},
|
||||
serverExternalPackages: ["pino"],
|
||||
|
||||
experimental: {
|
||||
// instrumentationHook: true,
|
||||
swcPlugins: [["@lingui/swc-plugin", {}]],
|
||||
|
||||
@@ -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",
|
||||
@@ -18,12 +20,14 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.802.0",
|
||||
"@aws-sdk/lib-storage": "^3.802.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.812.0",
|
||||
"@headlessui/react": "^2.2.0",
|
||||
"@hookform/resolvers": "^3.3.4",
|
||||
"@kan/api": "workspace:*",
|
||||
"@kan/auth": "workspace:*",
|
||||
"@kan/db": "workspace:^",
|
||||
"@kan/logger": "workspace:^",
|
||||
"@kan/shared": "workspace:^",
|
||||
"@lingui/babel-preset-react": "^2.9.2",
|
||||
"@lingui/conf": "^5.3.2",
|
||||
@@ -36,6 +40,7 @@
|
||||
"@tiptap/extension-link": "^2.22.2",
|
||||
"@tiptap/extension-mention": "^3.0.9",
|
||||
"@tiptap/extension-placeholder": "^2.14.0",
|
||||
"@tiptap/extension-typography": "^3.18.0",
|
||||
"@tiptap/pm": "^2.14.0",
|
||||
"@tiptap/react": "^2.14.0",
|
||||
"@tiptap/starter-kit": "^2.14.0",
|
||||
@@ -45,6 +50,7 @@
|
||||
"@trpc/react-query": "catalog:",
|
||||
"@trpc/server": "catalog:",
|
||||
"date-fns": "^4.1.0",
|
||||
"framer-motion": "^12.26.2",
|
||||
"geist": "^1.3.1",
|
||||
"jose": "^6.1.2",
|
||||
"next": "15.5.9",
|
||||
@@ -74,6 +80,7 @@
|
||||
"@kan/tailwind-config": "workspace:*",
|
||||
"@kan/tsconfig": "workspace:*",
|
||||
"@lingui/cli": "^5.3.2",
|
||||
"@lingui/format-json": "^5.9.1",
|
||||
"@lingui/loader": "^5.3.2",
|
||||
"@lingui/swc-plugin": "^5.5.2",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
@@ -86,7 +93,8 @@
|
||||
"jiti": "^1.21.6",
|
||||
"prettier": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
"typescript": "catalog:",
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"prettier": "@kan/prettier-config"
|
||||
}
|
||||
|
||||
BIN
apps/web/public/icon-512.png
Normal file
BIN
apps/web/public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
39
apps/web/public/manifest.json
Normal file
39
apps/web/public/manifest.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"background_color": "#000000",
|
||||
"categories": [
|
||||
"business",
|
||||
"productivity",
|
||||
"utilities"
|
||||
],
|
||||
"description": "The open source Trello alternative.",
|
||||
"display": "standalone",
|
||||
"icons": [
|
||||
{
|
||||
"sizes": "512x512",
|
||||
"src": "/icon-512.png",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
}
|
||||
],
|
||||
"id": "/",
|
||||
"screenshots": [
|
||||
{
|
||||
"sizes": "1624x1561",
|
||||
"src": "/screenshot-wide.png",
|
||||
"type": "image/png",
|
||||
"form_factor": "wide"
|
||||
},
|
||||
{
|
||||
"sizes": "1290x2796",
|
||||
"src": "/screenshot-narrow.png",
|
||||
"type": "image/png",
|
||||
"form_factor": "narrow"
|
||||
}
|
||||
],
|
||||
"orientation": "portrait-primary",
|
||||
"name": "Kan.bn | The open source alternative to Trello",
|
||||
"scope": "/",
|
||||
"short_name": "Kan.bn",
|
||||
"start_url": "/",
|
||||
"theme_color": "#000000"
|
||||
}
|
||||
BIN
apps/web/public/screenshot-narrow.png
Normal file
BIN
apps/web/public/screenshot-narrow.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 194 KiB |
BIN
apps/web/public/screenshot-wide.png
Normal file
BIN
apps/web/public/screenshot-wide.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 99 KiB |
@@ -46,6 +46,7 @@ interface FormValues {
|
||||
interface AuthProps {
|
||||
setIsMagicLinkSent: (value: boolean, recipient: string) => void;
|
||||
isSignUp?: boolean;
|
||||
callbackURL?: string;
|
||||
}
|
||||
|
||||
const EmailSchema = z.object({
|
||||
@@ -152,7 +153,11 @@ const availableSocialProviders = {
|
||||
},
|
||||
};
|
||||
|
||||
export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
export function Auth({
|
||||
setIsMagicLinkSent,
|
||||
isSignUp,
|
||||
callbackURL: callbackURLProp,
|
||||
}: AuthProps) {
|
||||
const [isCloudEnv, setIsCloudEnv] = useState(false);
|
||||
const [isLoginWithProviderPending, setIsLoginWithProviderPending] =
|
||||
useState<null | AuthProvider>(null);
|
||||
@@ -165,7 +170,7 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
const passwordRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const redirect = useSearchParams().get("next");
|
||||
const callbackURL = redirect ?? "/boards";
|
||||
const callbackURL = callbackURLProp ?? redirect ?? "/boards";
|
||||
|
||||
// Safely get environment variables on client side to avoid hydration mismatch
|
||||
useEffect(() => {
|
||||
@@ -361,8 +366,8 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
{!isCredentialsEnabled && socialProviders?.length === 0 && (
|
||||
{!(isCredentialsEnabled || isMagicLinkAvailable) &&
|
||||
socialProviders?.length === 0 && (
|
||||
<div className="flex w-full items-center gap-4">
|
||||
<div className="h-[1px] w-1/3 bg-light-600 dark:bg-dark-600" />
|
||||
<span className="text-center text-sm text-light-900 dark:text-dark-900">
|
||||
@@ -371,65 +376,62 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
<div className="h-[1px] w-1/3 bg-light-600 dark:bg-dark-600" />
|
||||
</div>
|
||||
)}
|
||||
{!isCredentialsEnabled && socialProviders?.length !== 0 && (
|
||||
<div className="mb-[1.5rem] flex w-full items-center gap-4">
|
||||
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
||||
<span className="text-sm text-light-900 dark:text-dark-900">
|
||||
{t`or`}
|
||||
</span>
|
||||
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{isSignUp && isCredentialsEnabled && (
|
||||
<div>
|
||||
<Input
|
||||
{...register("name", { required: true })}
|
||||
placeholder={t`Enter your name`}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="mt-2 text-xs text-red-400">
|
||||
{t`Please enter a valid name`}
|
||||
</p>
|
||||
)}
|
||||
{(isCredentialsEnabled || isMagicLinkAvailable) && (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
{socialProviders?.length !== 0 && (
|
||||
<div className="mb-[1.5rem] flex w-full items-center gap-4">
|
||||
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
||||
<span className="text-sm text-light-900 dark:text-dark-900">
|
||||
{t`or`}
|
||||
</span>
|
||||
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
||||
</div>
|
||||
)}
|
||||
{(isCredentialsEnabled || isMagicLinkAvailable) && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
{isSignUp && isCredentialsEnabled && (
|
||||
<div>
|
||||
<Input
|
||||
{...register("email", { required: true })}
|
||||
placeholder={t`Enter your email address`}
|
||||
{...register("name", { required: true })}
|
||||
placeholder={t`Enter your name`}
|
||||
/>
|
||||
{errors.email && (
|
||||
{errors.name && (
|
||||
<p className="mt-2 text-xs text-red-400">
|
||||
{t`Please enter a valid email address`}
|
||||
{t`Please enter a valid name`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isCredentialsEnabled && (
|
||||
<div>
|
||||
<Input
|
||||
type="password"
|
||||
{...register("password", { required: true })}
|
||||
placeholder={t`Enter your password`}
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="mt-2 text-xs text-red-400">
|
||||
{errors.password.message ??
|
||||
t`Please enter a valid password`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Input
|
||||
{...register("email", { required: true })}
|
||||
placeholder={t`Enter your email address`}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-2 text-xs text-red-400">
|
||||
{t`Please enter a valid email address`}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{loginError && (
|
||||
<p className="mt-2 text-xs text-red-400">{loginError}</p>
|
||||
)}
|
||||
</div>
|
||||
{(isCredentialsEnabled || isMagicLinkAvailable) && (
|
||||
</div>
|
||||
|
||||
{isCredentialsEnabled && (
|
||||
<div>
|
||||
<Input
|
||||
type="password"
|
||||
{...register("password", { required: true })}
|
||||
placeholder={t`Enter your password`}
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="mt-2 text-xs text-red-400">
|
||||
{errors.password.message ??
|
||||
t`Please enter a valid password`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{loginError && (
|
||||
<p className="mt-2 text-xs text-red-400">{loginError}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-[1.5rem] flex items-center gap-4">
|
||||
<Button
|
||||
isLoading={isLoginWithEmailPending}
|
||||
@@ -441,8 +443,11 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
{isMagicLinkMode ? t`magic link` : t`email`}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</form>
|
||||
)}
|
||||
{!(isCredentialsEnabled || isMagicLinkAvailable) && loginError && (
|
||||
<p className="mt-2 text-xs text-red-400">{loginError}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ const Avatar = ({
|
||||
icon?: React.ReactNode;
|
||||
isLoading?: boolean;
|
||||
}) => {
|
||||
const initials = name
|
||||
const initials = name?.trim()
|
||||
? getInitialsFromName(name)
|
||||
: inferInitialsFromEmail(email);
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ interface CheckboxDropdownProps {
|
||||
handleEdit?: (key: string) => void;
|
||||
handleCreate?: () => void;
|
||||
asChild?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function CheckboxDropdown({
|
||||
@@ -44,6 +45,7 @@ export default function CheckboxDropdown({
|
||||
handleEdit,
|
||||
handleCreate,
|
||||
asChild = true,
|
||||
disabled = false,
|
||||
}: CheckboxDropdownProps) {
|
||||
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
|
||||
|
||||
@@ -58,13 +60,13 @@ export default function CheckboxDropdown({
|
||||
{items.length > 0 ? (
|
||||
items.map((item) => (
|
||||
<Menu.Item key={item.key}>
|
||||
<div
|
||||
className="group flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleSelect(groupKey, { key: item.key, value: item.value });
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="group flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleSelect(groupKey, { key: item.key, value: item.value });
|
||||
}}
|
||||
>
|
||||
<input
|
||||
id={item.key}
|
||||
name={item.key}
|
||||
@@ -132,7 +134,8 @@ export default function CheckboxDropdown({
|
||||
<>
|
||||
<Menu.Button
|
||||
as={asChild ? "div" : undefined}
|
||||
className="h-full w-full cursor-pointer focus-visible:outline-none"
|
||||
disabled={disabled}
|
||||
className="h-full w-full cursor-pointer focus-visible:outline-none disabled:cursor-not-allowed"
|
||||
>
|
||||
{children}
|
||||
</Menu.Button>
|
||||
|
||||
@@ -33,6 +33,7 @@ type SearchResult =
|
||||
boardPublicId: string;
|
||||
boardName: string;
|
||||
listName: string;
|
||||
cardNumber: number | null;
|
||||
updatedAt: Date | null;
|
||||
createdAt: Date;
|
||||
type: "card";
|
||||
@@ -145,7 +146,6 @@ export default function CommandPallette({
|
||||
value={result}
|
||||
className="cursor-pointer select-none px-4 py-3 data-[focus]:bg-light-200 hover:bg-light-200 focus:outline-none dark:data-[focus]:bg-dark-200 dark:hover:bg-dark-200"
|
||||
onClick={() => {
|
||||
console.log("clicked", url);
|
||||
void router.push(url);
|
||||
onClose();
|
||||
setQuery("");
|
||||
@@ -160,8 +160,17 @@ export default function CommandPallette({
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<div className="truncate text-sm font-bold text-light-900 dark:text-dark-900">
|
||||
{result.title}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="truncate text-sm font-bold text-light-900 dark:text-dark-900">
|
||||
{result.title}
|
||||
</div>
|
||||
{result.type === "card" &&
|
||||
result.cardNumber != null &&
|
||||
workspace.cardPrefix && (
|
||||
<span className="flex-shrink-0 text-xs text-light-600 dark:text-dark-600">
|
||||
{workspace.cardPrefix}-{result.cardNumber}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{result.type === "card" && (
|
||||
<div className="truncate text-xs text-light-700 dark:text-dark-700">
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
@@ -11,7 +14,12 @@ import { authClient } from "@kan/auth/client";
|
||||
|
||||
import { useClickOutside } from "~/hooks/useClickOutside";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { useWorkspace, WorkspaceProvider } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
import { ChangePasswordFormConfirmation } from "~/views/settings/components/ChangePasswordConfirmation";
|
||||
import Button from "./Button";
|
||||
import Modal from "./modal";
|
||||
import SideNavigation from "./SideNavigation";
|
||||
|
||||
interface DashboardProps {
|
||||
@@ -40,10 +48,19 @@ export default function Dashboard({
|
||||
hasRightPanel = false,
|
||||
}: DashboardProps) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const { openModal } = useModal();
|
||||
const { openModal, closeModal, modalContentType } = useModal();
|
||||
const { availableWorkspaces, hasLoaded } = useWorkspace();
|
||||
const { showPopup } = usePopup();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const { data: session, isPending: sessionLoading } = authClient.useSession();
|
||||
const { data: user, isLoading: userLoading } = api.user.getUser.useQuery(
|
||||
undefined,
|
||||
{
|
||||
enabled: !!session?.user,
|
||||
},
|
||||
);
|
||||
|
||||
const [isSideNavOpen, setIsSideNavOpen] = useState(false);
|
||||
const [isRightPanelOpen, setIsRightPanelOpen] = useState(false);
|
||||
@@ -90,10 +107,65 @@ export default function Dashboard({
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (hasLoaded && availableWorkspaces.length === 0) {
|
||||
openModal("NEW_WORKSPACE", undefined, undefined, false);
|
||||
const partnerActivated = searchParams.get("partner_activated");
|
||||
const partnerError = searchParams.get("partner_error");
|
||||
|
||||
if (partnerActivated) {
|
||||
showPopup({
|
||||
header: t`License activated`,
|
||||
message: t`Your license has been activated successfully.`,
|
||||
icon: "success",
|
||||
});
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete("partner_activated");
|
||||
router.replace(`?${params.toString()}`);
|
||||
} else if (partnerError) {
|
||||
const messages: Record<string, string> = {
|
||||
invalid_license: t`That license key could not be found. Please contact support.`,
|
||||
license_inactive: t`Your license is not active. Please check your account.`,
|
||||
missing_license: t`No license key was provided. Please try activating again.`,
|
||||
};
|
||||
showPopup({
|
||||
header: t`License activation failed`,
|
||||
message:
|
||||
messages[partnerError] ??
|
||||
t`Something went wrong during license activation.`,
|
||||
icon: "error",
|
||||
});
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete("partner_error");
|
||||
router.replace(`?${params.toString()}`);
|
||||
}
|
||||
}, [hasLoaded, availableWorkspaces.length, openModal]);
|
||||
}, [searchParams, showPopup, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasLoaded && availableWorkspaces.length === 0) {
|
||||
if (env("NEXT_PUBLIC_KAN_ENV") === "cloud") {
|
||||
router.push(
|
||||
`/onboarding/select-plan?returnUrl=${encodeURIComponent(window.location.pathname)}`,
|
||||
);
|
||||
} else {
|
||||
openModal("NEW_WORKSPACE", undefined, undefined, false);
|
||||
}
|
||||
}
|
||||
}, [hasLoaded, availableWorkspaces.length, openModal, router]);
|
||||
|
||||
useEffect(() => {
|
||||
const isCredentialsEnabled =
|
||||
env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true";
|
||||
|
||||
if (
|
||||
!userLoading &&
|
||||
user &&
|
||||
isCredentialsEnabled &&
|
||||
user.hasMagicLinkAccount &&
|
||||
!user.hasPassword &&
|
||||
!sessionStorage.getItem("set_password_prompted")
|
||||
) {
|
||||
sessionStorage.setItem("set_password_prompted", "1");
|
||||
openModal("SET_PASSWORD_PROMPT");
|
||||
}
|
||||
}, [user, userLoading, openModal]);
|
||||
|
||||
const isDarkMode = resolvedTheme === "dark";
|
||||
|
||||
@@ -155,8 +227,12 @@ 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 }}
|
||||
isLoading={sessionLoading}
|
||||
user={{
|
||||
displayName: user?.name ?? session?.user.name,
|
||||
email: user?.email ?? session?.user.email ?? "",
|
||||
image: user?.image ?? undefined,
|
||||
}}
|
||||
isLoading={sessionLoading || userLoading}
|
||||
onCloseSideNav={closeSideNav}
|
||||
/>
|
||||
</div>
|
||||
@@ -185,6 +261,30 @@ export default function Dashboard({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={modalContentType === "SET_PASSWORD_PROMPT"}
|
||||
>
|
||||
{user?.hasPassword ? (
|
||||
<div className="p-5">
|
||||
<h2 className="pb-4 text-base font-medium dark:text-white">{t`Password already set`}</h2>
|
||||
<p className="mb-6 text-sm text-light-900">
|
||||
{t`Your account already has a password. You can change it from your account settings.`}
|
||||
</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={closeModal}
|
||||
fullWidth
|
||||
size="lg"
|
||||
>
|
||||
{t`Close`}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<ChangePasswordFormConfirmation hasPassword={false} />
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,9 +17,14 @@ import { twMerge } from "tailwind-merge";
|
||||
interface DateSelectorProps {
|
||||
selectedDate?: Date | null;
|
||||
onDateSelect?: (date: Date | undefined) => void;
|
||||
weekStartsOn?: 0 | 1 | 6;
|
||||
}
|
||||
|
||||
const DateSelector = ({ selectedDate, onDateSelect }: DateSelectorProps) => {
|
||||
const DateSelector = ({
|
||||
selectedDate,
|
||||
onDateSelect,
|
||||
weekStartsOn = 1,
|
||||
}: DateSelectorProps) => {
|
||||
const [currentMonth, setCurrentMonth] = useState(() => {
|
||||
return selectedDate ? startOfMonth(selectedDate) : startOfMonth(new Date());
|
||||
});
|
||||
@@ -28,18 +33,18 @@ const DateSelector = ({ selectedDate, onDateSelect }: DateSelectorProps) => {
|
||||
const year = format(currentMonth, "yyyy");
|
||||
|
||||
const dayHeaders = useMemo(() => {
|
||||
const weekStart = startOfWeek(new Date(), { weekStartsOn: 1 }); // Monday
|
||||
const weekStart = startOfWeek(new Date(), { weekStartsOn });
|
||||
return eachDayOfInterval({
|
||||
start: weekStart,
|
||||
end: new Date(weekStart.getTime() + 6 * 24 * 60 * 60 * 1000),
|
||||
}).map((date) => format(date, "EEEEEE")); // Shortest localized day name
|
||||
}, []);
|
||||
}, [weekStartsOn]);
|
||||
|
||||
const days = useMemo(() => {
|
||||
const monthStart = startOfMonth(currentMonth);
|
||||
const monthEnd = endOfMonth(currentMonth);
|
||||
const calendarStart = startOfWeek(monthStart, { weekStartsOn: 1 }); // Monday
|
||||
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 1 }); // Monday
|
||||
const calendarStart = startOfWeek(monthStart, { weekStartsOn });
|
||||
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn });
|
||||
|
||||
return eachDayOfInterval({ start: calendarStart, end: calendarEnd }).map(
|
||||
(date) => {
|
||||
@@ -53,7 +58,7 @@ const DateSelector = ({ selectedDate, onDateSelect }: DateSelectorProps) => {
|
||||
};
|
||||
},
|
||||
);
|
||||
}, [currentMonth, selectedDate]);
|
||||
}, [currentMonth, selectedDate, weekStartsOn]);
|
||||
|
||||
const handlePreviousMonth = () => {
|
||||
setCurrentMonth(subMonths(currentMonth, 1));
|
||||
|
||||
@@ -6,7 +6,7 @@ export default function Dropdown({
|
||||
children,
|
||||
disabled,
|
||||
}: {
|
||||
items: { label: string; action: () => void; icon?: React.ReactNode }[];
|
||||
items: { label: string; action?: () => void; icon?: React.ReactNode; disabled?: boolean }[];
|
||||
children: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
@@ -30,13 +30,14 @@ export default function Dropdown({
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items className="absolute right-0 z-50 mt-2 w-56 origin-top-right rounded-md border border-light-200 bg-light-50 p-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-400 dark:bg-dark-300">
|
||||
<Menu.Items className="absolute right-0 z-[100] isolate mt-2 w-56 origin-top-right rounded-md border border-light-200 bg-white p-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-400 dark:bg-dark-300">
|
||||
<div className="flex flex-col">
|
||||
{items.map((item) => (
|
||||
<Menu.Item key={item.label}>
|
||||
<Menu.Item key={item.label} disabled={item.disabled}>
|
||||
<button
|
||||
onClick={item.action}
|
||||
className="flex w-auto items-center gap-2 rounded-[5px] px-2.5 py-1.5 text-left text-sm text-neutral-900 hover:bg-light-200 dark:text-dark-950 dark:hover:bg-dark-400"
|
||||
disabled={item.disabled ?? !item.action}
|
||||
className="flex w-auto items-center gap-2 rounded-[5px] px-2.5 py-1.5 text-left text-sm text-neutral-900 hover:bg-light-200 disabled:cursor-not-allowed disabled:opacity-60 dark:text-dark-950 dark:hover:bg-dark-400"
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
ReactRenderer,
|
||||
useEditor,
|
||||
} from "@tiptap/react";
|
||||
import Typography from "@tiptap/extension-typography";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Suggestion from "@tiptap/suggestion";
|
||||
import {
|
||||
@@ -84,7 +85,7 @@ export interface RenderSuggestionsProps {
|
||||
export interface WorkspaceMember {
|
||||
publicId: string;
|
||||
user: {
|
||||
id: string;
|
||||
id: string | null;
|
||||
name: string | null;
|
||||
image: string | null;
|
||||
} | null;
|
||||
@@ -386,46 +387,54 @@ export interface SlashNodeAttrs {
|
||||
label?: string | null;
|
||||
}
|
||||
|
||||
const CommandItems: SlashCommandItem[] = [
|
||||
{
|
||||
title: "Heading 1",
|
||||
icon: <HiH1 />,
|
||||
command: ({ editor }) =>
|
||||
editor.chain().focus().setHeading({ level: 1 }).run(),
|
||||
},
|
||||
{
|
||||
title: "Heading 2",
|
||||
icon: <HiH2 />,
|
||||
command: ({ editor }) =>
|
||||
editor.chain().focus().setHeading({ level: 2 }).run(),
|
||||
},
|
||||
{
|
||||
title: "Heading 3",
|
||||
icon: <HiH3 />,
|
||||
command: ({ editor }) =>
|
||||
editor.chain().focus().setHeading({ level: 3 }).run(),
|
||||
},
|
||||
{
|
||||
title: "Bullet List",
|
||||
icon: <HiOutlineListBullet />,
|
||||
command: ({ editor }) => editor.chain().focus().toggleBulletList().run(),
|
||||
},
|
||||
{
|
||||
title: "Ordered List",
|
||||
icon: <HiOutlineNumberedList />,
|
||||
command: ({ editor }) => editor.chain().focus().toggleOrderedList().run(),
|
||||
},
|
||||
{
|
||||
title: "Blockquote",
|
||||
icon: <HiOutlineChatBubbleLeftEllipsis />,
|
||||
command: ({ editor }) => editor.chain().focus().toggleBlockquote().run(),
|
||||
},
|
||||
{
|
||||
title: "Code Block",
|
||||
icon: <HiOutlineCodeBracketSquare />,
|
||||
command: ({ editor }) => editor.chain().focus().toggleCodeBlock().run(),
|
||||
},
|
||||
];
|
||||
const getCommandItems = (disableHeadings: boolean): SlashCommandItem[] => {
|
||||
const headingCommands: SlashCommandItem[] = disableHeadings
|
||||
? []
|
||||
: [
|
||||
{
|
||||
title: "Heading 1",
|
||||
icon: <HiH1 />,
|
||||
command: ({ editor }) =>
|
||||
editor.chain().focus().setHeading({ level: 1 }).run(),
|
||||
},
|
||||
{
|
||||
title: "Heading 2",
|
||||
icon: <HiH2 />,
|
||||
command: ({ editor }) =>
|
||||
editor.chain().focus().setHeading({ level: 2 }).run(),
|
||||
},
|
||||
{
|
||||
title: "Heading 3",
|
||||
icon: <HiH3 />,
|
||||
command: ({ editor }) =>
|
||||
editor.chain().focus().setHeading({ level: 3 }).run(),
|
||||
},
|
||||
];
|
||||
|
||||
return [
|
||||
...headingCommands,
|
||||
{
|
||||
title: "Bullet List",
|
||||
icon: <HiOutlineListBullet />,
|
||||
command: ({ editor }) => editor.chain().focus().toggleBulletList().run(),
|
||||
},
|
||||
{
|
||||
title: "Ordered List",
|
||||
icon: <HiOutlineNumberedList />,
|
||||
command: ({ editor }) => editor.chain().focus().toggleOrderedList().run(),
|
||||
},
|
||||
{
|
||||
title: "Blockquote",
|
||||
icon: <HiOutlineChatBubbleLeftEllipsis />,
|
||||
command: ({ editor }) => editor.chain().focus().toggleBlockquote().run(),
|
||||
},
|
||||
{
|
||||
title: "Code Block",
|
||||
icon: <HiOutlineCodeBracketSquare />,
|
||||
command: ({ editor }) => editor.chain().focus().toggleCodeBlock().run(),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export default function Editor({
|
||||
content,
|
||||
@@ -434,6 +443,8 @@ export default function Editor({
|
||||
readOnly = false,
|
||||
workspaceMembers,
|
||||
enableYouTubeEmbed = true,
|
||||
placeholder,
|
||||
disableHeadings = false,
|
||||
}: {
|
||||
content: string | null;
|
||||
onChange?: (value: string) => void;
|
||||
@@ -441,18 +452,16 @@ export default function Editor({
|
||||
readOnly?: boolean;
|
||||
workspaceMembers: WorkspaceMember[];
|
||||
enableYouTubeEmbed?: boolean;
|
||||
placeholder?: string;
|
||||
disableHeadings?: boolean;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const editor = useEditor(
|
||||
{
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Markdown,
|
||||
Placeholder.configure({
|
||||
placeholder: readOnly
|
||||
? ""
|
||||
: t`Add description... (type '/' to open commands or '@' to mention)`,
|
||||
StarterKit.configure({
|
||||
heading: disableHeadings ? false : undefined,
|
||||
}),
|
||||
Link.configure({
|
||||
openOnClick: true,
|
||||
@@ -463,12 +472,20 @@ export default function Editor({
|
||||
},
|
||||
validate: (href) => /^https?:\/\//.test(href),
|
||||
autolink: true,
|
||||
linkOnPaste: true,
|
||||
}),
|
||||
Markdown.configure({ transformPastedText: true }),
|
||||
Placeholder.configure({
|
||||
placeholder: readOnly
|
||||
? ""
|
||||
: placeholder ??
|
||||
t`Add description... (type '/' to open commands or '@' to mention)`,
|
||||
}),
|
||||
SlashCommands.configure({
|
||||
commandItems: CommandItems,
|
||||
commandItems: getCommandItems(disableHeadings),
|
||||
suggestion: {
|
||||
items: ({ query }: { query: string }) =>
|
||||
filterSlashCommandItems(CommandItems, query),
|
||||
filterSlashCommandItems(getCommandItems(disableHeadings), query),
|
||||
startOfLine: true,
|
||||
char: "/",
|
||||
},
|
||||
@@ -480,20 +497,28 @@ export default function Editor({
|
||||
suggestion: {
|
||||
char: "@",
|
||||
items: ({ query }: { query: string }) => {
|
||||
const all: MentionItem[] = workspaceMembers.map(
|
||||
(member: WorkspaceMember) => ({
|
||||
id: member.publicId,
|
||||
label: member?.user?.name ?? member.email,
|
||||
image: member?.user?.image ?? null,
|
||||
}),
|
||||
const withEmail = workspaceMembers.filter((member) => member.email);
|
||||
|
||||
const mapped = withEmail.map((member: WorkspaceMember) => ({
|
||||
id: member.publicId,
|
||||
label: member?.user?.name?.trim() || member.email || "",
|
||||
image: member?.user?.image ?? null,
|
||||
}));
|
||||
|
||||
const all: MentionItem[] = mapped.filter(
|
||||
(item) => item.label && item.label.length > 0,
|
||||
);
|
||||
const q = query.toLowerCase();
|
||||
return all.filter(
|
||||
(u) =>
|
||||
u.label &&
|
||||
typeof u.label === "string" &&
|
||||
u.label.toLowerCase().includes(q),
|
||||
|
||||
const q = query.toLowerCase().trim();
|
||||
|
||||
if (q === "") {
|
||||
return all;
|
||||
}
|
||||
|
||||
const filtered = all.filter((u) =>
|
||||
u.label.toLowerCase().includes(q),
|
||||
);
|
||||
return filtered;
|
||||
},
|
||||
command: ({ editor, range, props }) => {
|
||||
const id = props.id ?? "";
|
||||
@@ -514,6 +539,17 @@ export default function Editor({
|
||||
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`;
|
||||
},
|
||||
}),
|
||||
Typography.configure({
|
||||
openDoubleQuote: false,
|
||||
closeDoubleQuote: false,
|
||||
openSingleQuote: false,
|
||||
closeSingleQuote: false,
|
||||
oneHalf: false,
|
||||
oneQuarter: false,
|
||||
threeQuarters: false,
|
||||
superscriptTwo: false,
|
||||
superscriptThree: false,
|
||||
}),
|
||||
...(enableYouTubeEmbed ? [YouTubeNode] : []),
|
||||
],
|
||||
content,
|
||||
|
||||
32
apps/web/src/components/FontSizeSelector.tsx
Normal file
32
apps/web/src/components/FontSizeSelector.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { HiOutlineAdjustmentsHorizontal } from "react-icons/hi2";
|
||||
|
||||
import { useFontSize, type FontSize } from "~/providers/font-size";
|
||||
|
||||
const fontSizeOptions: { value: FontSize; label: () => string }[] = [
|
||||
{ value: "small", label: () => t`Small` },
|
||||
{ value: "medium", label: () => t`Medium` },
|
||||
{ value: "large", label: () => t`Large` },
|
||||
];
|
||||
|
||||
export function FontSizeSelector() {
|
||||
const { fontSize, setFontSize } = useFontSize();
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<HiOutlineAdjustmentsHorizontal className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<select
|
||||
id="font-size-select"
|
||||
value={fontSize}
|
||||
onChange={(e) => setFontSize(e.target.value as FontSize)}
|
||||
className="block w-full max-w-[180px] rounded-lg border-0 bg-light-50 pl-10 text-sm shadow-sm ring-1 ring-inset ring-light-300 focus:ring-2 focus:ring-inset focus:ring-light-400 dark:bg-dark-50 dark:text-dark-1000 dark:ring-dark-300 dark:focus:ring-dark-500"
|
||||
>
|
||||
{fontSizeOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label()}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -88,7 +88,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
</button>
|
||||
)}
|
||||
{iconRight && type !== "password" && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 leading-[0]">
|
||||
{iconRight}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -39,7 +39,7 @@ export function LabelForm({
|
||||
labelPublicId: entityId,
|
||||
},
|
||||
{
|
||||
enabled: isEdit && !!entityId,
|
||||
enabled: !!isEdit && entityId.length >= 12,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -75,7 +75,7 @@ export function LabelForm({
|
||||
reset(newFormState);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -207,7 +207,7 @@ export function LabelForm({
|
||||
/>
|
||||
</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="mt-12 flex items-center justify-end space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
{!isEdit && (
|
||||
<Toggle
|
||||
label={t`Create another`}
|
||||
@@ -231,9 +231,7 @@ export function LabelForm({
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={updateLabel.isPending || createLabel.isPending}
|
||||
disabled={
|
||||
!watch("name")
|
||||
}
|
||||
disabled={!watch("name")}
|
||||
>
|
||||
{isEdit ? t`Update label` : t`Create label`}
|
||||
</Button>
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import {
|
||||
HiBolt,
|
||||
HiCheck,
|
||||
HiCheckBadge,
|
||||
HiInformationCircle,
|
||||
HiXMark,
|
||||
} from "react-icons/hi2";
|
||||
import { HiCheck, HiXMark } from "react-icons/hi2";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { z } from "zod";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import Input from "~/components/Input";
|
||||
import Toggle from "~/components/Toggle";
|
||||
import { useDebounce } from "~/hooks/useDebounce";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
@@ -33,7 +26,7 @@ const schema = z.object({
|
||||
.min(3, {
|
||||
message: t`URL must be at least 3 characters long`,
|
||||
})
|
||||
.max(24, { message: t`URL cannot exceed 24 characters` })
|
||||
.max(64, { message: t`URL cannot exceed 64 characters` })
|
||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/, {
|
||||
message: t`URL can only contain letters, numbers, and hyphens`,
|
||||
})
|
||||
@@ -54,7 +47,6 @@ export function NewWorkspaceForm() {
|
||||
watch,
|
||||
trigger,
|
||||
clearErrors,
|
||||
setValue,
|
||||
} = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
@@ -67,16 +59,10 @@ export function NewWorkspaceForm() {
|
||||
|
||||
const hasAvailableWorkspaces = availableWorkspaces.length > 0;
|
||||
|
||||
const isCloudEnv = env("NEXT_PUBLIC_KAN_ENV") === "cloud";
|
||||
|
||||
const slug = watch("slug");
|
||||
const [debouncedSlug] = useDebounce(slug, 500);
|
||||
const isTyping = slug !== debouncedSlug;
|
||||
|
||||
// Pro toggle state management
|
||||
const [isProToggleEnabled, setIsProToggleEnabled] = useState(false);
|
||||
const [lastAvailableSlug, setLastAvailableSlug] = useState<string>("");
|
||||
|
||||
// Validate slug only after debounce
|
||||
useEffect(() => {
|
||||
if (isTyping) {
|
||||
@@ -101,7 +87,7 @@ export function NewWorkspaceForm() {
|
||||
const isWorkspaceSlugAvailable = checkWorkspaceSlugAvailability.data;
|
||||
|
||||
const createWorkspace = api.workspace.create.useMutation({
|
||||
onSuccess: async (values, variables) => {
|
||||
onSuccess: (values) => {
|
||||
if (values.publicId && values.name) {
|
||||
void utils.workspace.all.invalidate();
|
||||
switchWorkspace({
|
||||
@@ -110,45 +96,11 @@ export function NewWorkspaceForm() {
|
||||
description: values.description,
|
||||
slug: values.slug,
|
||||
plan: values.plan,
|
||||
cardPrefix: values.cardPrefix,
|
||||
role: "admin",
|
||||
weekStartDay: 1,
|
||||
});
|
||||
|
||||
// If in cloud and Pro toggle is enabled, create checkout session for pro
|
||||
if (env("NEXT_PUBLIC_KAN_ENV") === "cloud" && isProToggleEnabled) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
"/api/stripe/create_checkout_session",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
slug: slug || undefined,
|
||||
workspacePublicId: values.publicId,
|
||||
cancelUrl: "/settings/workspace?upgrade=pro",
|
||||
successUrl: "/boards",
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
const url = (data as { url: string }).url;
|
||||
|
||||
if (url) {
|
||||
window.location.href = url;
|
||||
return; // Don't close modal if redirecting to checkout
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error creating checkout session:", error);
|
||||
showPopup({
|
||||
header: t`Error upgrading to Pro`,
|
||||
message: t`Workspace created successfully. You can upgrade later in settings.`,
|
||||
icon: "warning",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
closeModal();
|
||||
}
|
||||
},
|
||||
@@ -167,57 +119,8 @@ export function NewWorkspaceForm() {
|
||||
if (nameElement) nameElement.focus();
|
||||
}, []);
|
||||
|
||||
const [shouldShowBenefits, setShouldShowBenefits] = useState(false);
|
||||
|
||||
const isValidSlug = slug && slug.length >= 3 && !errors.slug;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
isCloudEnv &&
|
||||
!checkWorkspaceSlugAvailability.isPending &&
|
||||
isValidSlug
|
||||
) {
|
||||
const isAvailable = isWorkspaceSlugAvailable?.isAvailable === true;
|
||||
setShouldShowBenefits(isAvailable);
|
||||
|
||||
// Automatically enable Pro toggle when slug is available
|
||||
if (isAvailable) {
|
||||
setIsProToggleEnabled(true);
|
||||
setLastAvailableSlug(slug);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isValidSlug,
|
||||
isWorkspaceSlugAvailable?.isAvailable,
|
||||
checkWorkspaceSlugAvailability.isPending,
|
||||
slug,
|
||||
isCloudEnv,
|
||||
]);
|
||||
|
||||
// Reset benefits when slug becomes invalid
|
||||
useEffect(() => {
|
||||
if (!isValidSlug) {
|
||||
setShouldShowBenefits(false);
|
||||
}
|
||||
}, [isValidSlug]);
|
||||
|
||||
// Handle Pro toggle changes
|
||||
const handleProToggleChange = () => {
|
||||
if (isProToggleEnabled) {
|
||||
// Turning off: clear the slug and disable Pro
|
||||
setValue("slug", "");
|
||||
setIsProToggleEnabled(false);
|
||||
} else {
|
||||
// Turning on: restore the last available slug if we have one
|
||||
if (lastAvailableSlug) {
|
||||
setValue("slug", lastAvailableSlug);
|
||||
}
|
||||
setIsProToggleEnabled(true);
|
||||
}
|
||||
};
|
||||
|
||||
const showProBenefits = isProToggleEnabled;
|
||||
|
||||
const onSubmit = (values: FormValues) => {
|
||||
// Don't submit if slug is provided but not available
|
||||
if (values.slug && isWorkspaceSlugAvailable?.isAvailable === false) {
|
||||
@@ -226,7 +129,7 @@ export function NewWorkspaceForm() {
|
||||
|
||||
createWorkspace.mutate({
|
||||
name: values.name,
|
||||
slug: !isCloudEnv && values.slug ? values.slug : undefined,
|
||||
slug: values.slug,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -289,11 +192,7 @@ export function NewWorkspaceForm() {
|
||||
? t`This workspace URL is reserved`
|
||||
: undefined)
|
||||
}
|
||||
prefix={
|
||||
env("NEXT_PUBLIC_KAN_ENV") === "cloud"
|
||||
? "kan.bn/"
|
||||
: `${env("NEXT_PUBLIC_BASE_URL")}/`
|
||||
}
|
||||
prefix={`${env("NEXT_PUBLIC_BASE_URL")}/`}
|
||||
iconRight={
|
||||
slug && slug.length >= 3 && !errors.slug ? (
|
||||
isWorkspaceSlugAvailable?.isAvailable ? (
|
||||
@@ -310,68 +209,9 @@ export function NewWorkspaceForm() {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{slug && slug.length >= 3 && shouldShowBenefits && (
|
||||
<div className="mt-2 flex items-center gap-1">
|
||||
<HiInformationCircle className="h-4 w-4 text-dark-900" />
|
||||
<p className="text-xs text-gray-500 dark:text-dark-900">
|
||||
{t`Custom URLs require upgrading to a Pro plan`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showProBenefits && (
|
||||
<div className="mt-6">
|
||||
<div className="rounded-md bg-light-100 p-3 text-xs text-light-900 dark:bg-dark-200 dark:text-dark-900">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
<HiCheckBadge className="h-[18px] w-[18px] flex-shrink-0 text-light-1000 dark:text-dark-950" />
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-xs text-neutral-900 dark:text-dark-1000">
|
||||
{t`Unlimited members`}
|
||||
</span>
|
||||
<span className="inline-flex items-center rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-600 ring-1 ring-inset ring-emerald-500/20 dark:text-emerald-400 sm:text-[10px]">
|
||||
{t`Launch offer`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<HiCheckBadge className="h-[18px] w-[18px] flex-shrink-0 text-light-1000 dark:text-dark-950" />
|
||||
<span className="text-xs text-neutral-900 dark:text-dark-1000">
|
||||
{t`Custom workspace URL`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<HiCheckBadge className="h-[18px] w-[18px] flex-shrink-0 text-light-1000 dark:text-dark-950" />
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-xs text-neutral-900 dark:text-dark-1000">
|
||||
{t`Board analytics`}
|
||||
</span>
|
||||
<span className="inline-flex items-center rounded-full bg-gray-500/10 px-2 py-0.5 text-[10px] font-medium text-gray-600 ring-1 ring-inset ring-gray-500/20 dark:text-gray-400 sm:text-[10px]">
|
||||
{t`Coming soon`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={twMerge(
|
||||
"mt-6 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600",
|
||||
!showProBenefits && "mt-12",
|
||||
)}
|
||||
>
|
||||
{/* Pro Toggle - only show in cloud environment */}
|
||||
{isCloudEnv && (
|
||||
<Toggle
|
||||
isChecked={isProToggleEnabled}
|
||||
onChange={handleProToggleChange}
|
||||
label={t`Upgrade to Pro ($29/month)`}
|
||||
/>
|
||||
)}
|
||||
<div className="mt-6 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<div>
|
||||
<Button
|
||||
type="submit"
|
||||
|
||||
@@ -8,6 +8,7 @@ export const PageHead = ({ title }: { title: string }) => {
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1"
|
||||
/>
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
</Head>
|
||||
);
|
||||
};
|
||||
|
||||
130
apps/web/src/components/PlainTextEditor.tsx
Normal file
130
apps/web/src/components/PlainTextEditor.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import { EditorContent, useEditor } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
interface PlainTextEditorProps {
|
||||
content: string;
|
||||
onChange?: (value: string) => void;
|
||||
onBlur?: (value: string) => void;
|
||||
onEnter?: (value: string) => void;
|
||||
onEscape?: () => void;
|
||||
readOnly?: boolean;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function PlainTextEditor({
|
||||
content,
|
||||
onChange,
|
||||
onBlur,
|
||||
onEnter,
|
||||
onEscape,
|
||||
readOnly = false,
|
||||
placeholder,
|
||||
className,
|
||||
}: PlainTextEditorProps) {
|
||||
const onEnterRef = useRef(onEnter);
|
||||
const onEscapeRef = useRef(onEscape);
|
||||
const onBlurRef = useRef(onBlur);
|
||||
const onChangeRef = useRef(onChange);
|
||||
const contentRef = useRef(content);
|
||||
|
||||
useEffect(() => {
|
||||
onEnterRef.current = onEnter;
|
||||
}, [onEnter]);
|
||||
useEffect(() => {
|
||||
onEscapeRef.current = onEscape;
|
||||
}, [onEscape]);
|
||||
useEffect(() => {
|
||||
onBlurRef.current = onBlur;
|
||||
}, [onBlur]);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
}, [onChange]);
|
||||
useEffect(() => {
|
||||
contentRef.current = content;
|
||||
}, [content]);
|
||||
|
||||
const editor = useEditor(
|
||||
{
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
bold: false,
|
||||
italic: false,
|
||||
strike: false,
|
||||
code: false,
|
||||
codeBlock: false,
|
||||
blockquote: false,
|
||||
heading: false,
|
||||
bulletList: false,
|
||||
orderedList: false,
|
||||
listItem: false,
|
||||
horizontalRule: false,
|
||||
hardBreak: false,
|
||||
}),
|
||||
Placeholder.configure({ placeholder }),
|
||||
],
|
||||
content,
|
||||
editable: !readOnly,
|
||||
onUpdate: ({ editor }) => onChangeRef.current?.(editor.getText()),
|
||||
onBlur: ({ editor }) => onBlurRef.current?.(editor.getText()),
|
||||
editorProps: {
|
||||
handleKeyDown: (view, event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
onEnterRef.current?.(view.state.doc.textContent.trim());
|
||||
return true;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
// Reset to original content before calling the callback
|
||||
editor?.commands.setContent(contentRef.current, false);
|
||||
editor?.commands.blur();
|
||||
onEscapeRef.current?.();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
attributes: {
|
||||
class: "outline-none focus:outline-none focus-visible:ring-0",
|
||||
},
|
||||
},
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
if (content !== editor.getText()) {
|
||||
editor.commands.setContent(content, false);
|
||||
}
|
||||
}, [content, editor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
editor.setEditable(!readOnly);
|
||||
}, [readOnly, editor]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<style jsx global>{`
|
||||
.plain-text-editor p.is-empty::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
color: var(--placeholder-color, #9ca3af);
|
||||
}
|
||||
.plain-text-editor .tiptap p {
|
||||
margin: 0 !important;
|
||||
}
|
||||
`}</style>
|
||||
<EditorContent
|
||||
editor={editor}
|
||||
className={twMerge("plain-text-editor", className)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -25,7 +25,7 @@ const Popup: React.FC = () => {
|
||||
return (
|
||||
<div
|
||||
aria-live="assertive"
|
||||
className="pointer-events-none fixed inset-0 z-10 flex items-end p-3 sm:items-end"
|
||||
className="pointer-events-none fixed inset-0 z-10 flex items-end p-3 sm:items-end m-3"
|
||||
>
|
||||
<div className="flex w-full flex-col items-center space-y-4 sm:items-end">
|
||||
<Transition
|
||||
@@ -37,43 +37,43 @@ const Popup: React.FC = () => {
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<div className="pointer-events-auto w-full max-w-sm overflow-hidden rounded-lg border border-light-400 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 transition data-[closed]:data-[enter]:translate-y-2 data-[enter]:transform data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-100 data-[enter]:ease-out data-[leave]:ease-in dark:border-dark-300 dark:bg-dark-200 data-[closed]:data-[enter]:sm:translate-x-2 data-[closed]:data-[enter]:sm:translate-y-0">
|
||||
<div className="p-4">
|
||||
<div className="pointer-events-auto w-full max-w-[350px] overflow-hidden rounded-xl border border-light-400 bg-light-50 shadow-lg ring-opacity-5 transition data-[closed]:data-[enter]:translate-y-2 data-[enter]:transform data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-100 data-[enter]:ease-out data-[leave]:ease-in dark:border-dark-300 dark:bg-dark-100 data-[closed]:data-[enter]:sm:translate-x-2 data-[closed]:data-[enter]:sm:translate-y-0">
|
||||
<div className="p-4 relative">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
{popupIcon === "success" && (
|
||||
<HiOutlineCheckCircle
|
||||
aria-hidden="true"
|
||||
className="h-6 w-6 text-green-400"
|
||||
className="h-5 w-5 text-green-400"
|
||||
/>
|
||||
)}
|
||||
{popupIcon === "error" && (
|
||||
<HiOutlineExclamationCircle
|
||||
aria-hidden="true"
|
||||
className="h-6 w-6 text-red-400"
|
||||
className="h-5 w-5 text-red-400"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-3 w-0 flex-1 pt-0.5">
|
||||
<p className="text-sm font-medium text-neutral-900 dark:text-dark-1000">
|
||||
<p className="text-[12px] font-bold text-neutral-900 dark:text-dark-950">
|
||||
{popupHeader}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-neutral-500 dark:text-dark-900">
|
||||
<p className="mt-1 text-[12px] text-neutral-500 dark:text-dark-900">
|
||||
{popupMessage}
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-4 flex flex-shrink-0">
|
||||
<div className="ml-4 flex flex-shrink-0 absolute right-3 top-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
hidePopup();
|
||||
}}
|
||||
className="mx-1 inline-flex h-fit items-center rounded-md p-1 px-1 text-sm font-semibold text-dark-50 hover:bg-light-100 dark:hover:bg-dark-400"
|
||||
className="inline-flex h-fit items-center rounded-md p-1 px-1 text-sm font-semibold text-dark-50 hover:bg-light-100 dark:hover:bg-dark-200"
|
||||
>
|
||||
<span className="sr-only">Close</span>
|
||||
<HiXMark
|
||||
aria-hidden="true"
|
||||
className="h-5 w-5 text-dark-900"
|
||||
className="h-4 w-4 text-dark-900"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -12,10 +12,14 @@ import { useEffect, useState } from "react";
|
||||
import {
|
||||
HiChevronDown,
|
||||
HiOutlineBanknotes,
|
||||
HiOutlineBolt,
|
||||
HiOutlineCodeBracketSquare,
|
||||
HiOutlineRectangleGroup,
|
||||
HiOutlineShieldCheck,
|
||||
HiOutlineUser,
|
||||
} from "react-icons/hi2";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
|
||||
interface SettingsLayoutProps {
|
||||
children: React.ReactNode;
|
||||
@@ -24,8 +28,12 @@ interface SettingsLayoutProps {
|
||||
|
||||
export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
|
||||
const router = useRouter();
|
||||
const { workspace } = useWorkspace();
|
||||
const { canViewWorkspace, canEditWorkspace } = usePermissions();
|
||||
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
|
||||
|
||||
const isAdmin = workspace.role === "admin";
|
||||
|
||||
const settingsTabs = [
|
||||
{
|
||||
key: "account",
|
||||
@@ -37,13 +45,19 @@ export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
|
||||
key: "workspace",
|
||||
icon: <HiOutlineRectangleGroup />,
|
||||
label: t`Workspace`,
|
||||
condition: true,
|
||||
condition: canViewWorkspace,
|
||||
},
|
||||
{
|
||||
key: "permissions",
|
||||
icon: <HiOutlineShieldCheck />,
|
||||
label: t`Permissions`,
|
||||
condition: isAdmin,
|
||||
},
|
||||
{
|
||||
key: "billing",
|
||||
label: t`Billing`,
|
||||
icon: <HiOutlineBanknotes />,
|
||||
condition: env("NEXT_PUBLIC_KAN_ENV") === "cloud",
|
||||
condition: env("NEXT_PUBLIC_KAN_ENV") === "cloud" && isAdmin,
|
||||
},
|
||||
{
|
||||
key: "api",
|
||||
@@ -51,11 +65,17 @@ export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
|
||||
label: t`API`,
|
||||
condition: true,
|
||||
},
|
||||
{
|
||||
key: "webhooks",
|
||||
icon: <HiOutlineBolt />,
|
||||
label: t`Webhooks`,
|
||||
condition: isAdmin,
|
||||
},
|
||||
{
|
||||
key: "integrations",
|
||||
icon: <HiOutlineCodeBracketSquare />,
|
||||
label: t`Integrations`,
|
||||
condition: true,
|
||||
condition: canEditWorkspace,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -97,7 +117,7 @@ export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
|
||||
>
|
||||
<div className="relative mb-4">
|
||||
<ListboxButton className="w-full appearance-none rounded-lg border-0 bg-light-50 py-2 pl-3 pr-10 text-left text-sm text-light-1000 shadow-sm ring-1 ring-inset ring-light-300 focus:ring-2 focus:ring-inset focus:ring-light-400 dark:bg-dark-50 dark:text-dark-1000 dark:ring-dark-300 dark:focus:ring-dark-500">
|
||||
{availableTabs[selectedTabIndex]?.label || "Select a tab"}
|
||||
{availableTabs[selectedTabIndex]?.label ?? "Select a tab"}
|
||||
<HiChevronDown
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-light-900 dark:text-dark-900"
|
||||
|
||||
@@ -28,7 +28,6 @@ import ButtonComponent from "~/components/Button";
|
||||
import ReactiveButton from "~/components/ReactiveButton";
|
||||
import UserMenu from "~/components/UserMenu";
|
||||
import WorkspaceMenu from "~/components/WorkspaceMenu";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
@@ -39,6 +38,7 @@ interface SideNavigationProps {
|
||||
}
|
||||
|
||||
interface UserType {
|
||||
displayName?: string | null | undefined;
|
||||
email?: string | null | undefined;
|
||||
image?: string | null | undefined;
|
||||
}
|
||||
@@ -52,11 +52,11 @@ export default function SideNavigation({
|
||||
const { workspace } = useWorkspace();
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const [isInitialised, setIsInitialised] = useState(false);
|
||||
const { openModal } = useModal();
|
||||
|
||||
const { data: workspaceData } = api.workspace.byId.useQuery({
|
||||
workspacePublicId: workspace.publicId,
|
||||
});
|
||||
const { data: workspaceData } = api.workspace.byId.useQuery(
|
||||
{ workspacePublicId: workspace.publicId },
|
||||
{ enabled: !!workspace.publicId && workspace.publicId.length >= 12 },
|
||||
);
|
||||
|
||||
const subscriptions = workspaceData?.subscriptions as
|
||||
| Subscription[]
|
||||
@@ -206,7 +206,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}
|
||||
@@ -220,21 +221,19 @@ export default function SideNavigation({
|
||||
<ButtonComponent
|
||||
iconLeft={<HiBolt />}
|
||||
variant="secondary"
|
||||
href="/settings/workspace?upgrade=pro"
|
||||
aria-label="Upgrade to Pro"
|
||||
title="Upgrade to Pro"
|
||||
href={`/upgrade/select-plan?plan=pro&workspacePublicId=${workspace.publicId}&returnUrl=${encodeURIComponent("/settings/billing")}`}
|
||||
aria-label={t`Start free trial`}
|
||||
title={t`Start free trial`}
|
||||
iconOnly
|
||||
onClick={() => openModal("UPGRADE_TO_PRO")}
|
||||
/>
|
||||
) : (
|
||||
<ButtonComponent
|
||||
iconLeft={<HiBolt />}
|
||||
fullWidth
|
||||
variant="secondary"
|
||||
href="/settings/workspace?upgrade=pro"
|
||||
onClick={() => openModal("UPGRADE_TO_PRO")}
|
||||
href={`/upgrade/select-plan?plan=pro&workspacePublicId=${workspace.publicId}&returnUrl=${encodeURIComponent("/settings/billing")}`}
|
||||
>
|
||||
{t`Upgrade to Pro`}
|
||||
{t`Start free trial`}
|
||||
</ButtonComponent>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -6,16 +6,22 @@ const Toggle = ({
|
||||
onChange,
|
||||
label,
|
||||
disabled,
|
||||
showLabel = true,
|
||||
labelPosition = "before",
|
||||
}: {
|
||||
isChecked: boolean;
|
||||
onChange: () => void;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
showLabel?: boolean;
|
||||
labelPosition?: "before" | "after";
|
||||
}) => (
|
||||
<div className="mr-4 flex items-center justify-end">
|
||||
<span className="mr-2 text-xs text-light-900 dark:text-dark-900">
|
||||
{label}
|
||||
</span>
|
||||
<div className="flex items-center">
|
||||
{showLabel && labelPosition === "before" && (
|
||||
<span className="mr-2 text-xs text-light-900 dark:text-dark-900">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
<Switch
|
||||
checked={isChecked}
|
||||
onChange={onChange}
|
||||
@@ -34,6 +40,11 @@ const Toggle = ({
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
{showLabel && labelPosition === "after" && (
|
||||
<span className="ml-2 text-xs text-light-900 dark:text-dark-900">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import tippy from "tippy.js";
|
||||
|
||||
interface TooltipProps {
|
||||
children: ReactNode;
|
||||
content: ReactNode;
|
||||
content?: ReactNode;
|
||||
placement?: Placement;
|
||||
delay?: number | [number, number];
|
||||
}
|
||||
@@ -24,6 +24,8 @@ export function Tooltip({
|
||||
useEffect(() => {
|
||||
if (!triggerRef.current) return;
|
||||
|
||||
if (!content) return;
|
||||
|
||||
const container = document.createElement("div");
|
||||
const root = createRoot(container);
|
||||
rootRef.current = root;
|
||||
@@ -35,6 +37,7 @@ export function Tooltip({
|
||||
delay,
|
||||
interactive: false,
|
||||
theme: "tooltip",
|
||||
touch: false,
|
||||
});
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -17,6 +17,7 @@ import { getAvatarUrl } from "~/utils/helpers";
|
||||
|
||||
interface UserMenuProps {
|
||||
imageUrl: string | undefined;
|
||||
displayName: string | undefined;
|
||||
email: string;
|
||||
isLoading: boolean;
|
||||
isCollapsed?: boolean;
|
||||
@@ -26,6 +27,7 @@ interface UserMenuProps {
|
||||
export default function UserMenu({
|
||||
imageUrl,
|
||||
email,
|
||||
displayName,
|
||||
isLoading,
|
||||
isCollapsed = false,
|
||||
onCloseSideNav,
|
||||
@@ -75,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
|
||||
@@ -102,7 +104,7 @@ export default function UserMenu({
|
||||
isCollapsed && "md:hidden",
|
||||
)}
|
||||
>
|
||||
{email}
|
||||
{displayName || email}
|
||||
</span>
|
||||
</Menu.Button>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Button, Menu, Transition } from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { env } from "next-runtime-env";
|
||||
import { Fragment, useState } from "react";
|
||||
import { HiCheck, HiMagnifyingGlass } from "react-icons/hi2";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
@@ -18,6 +20,7 @@ export default function WorkspaceMenu({
|
||||
const { workspace, isLoading, availableWorkspaces, switchWorkspace } =
|
||||
useWorkspace();
|
||||
const { openModal } = useModal();
|
||||
const router = useRouter();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const { tooltipContent: commandPaletteShortcutTooltipContent } =
|
||||
@@ -147,7 +150,11 @@ export default function WorkspaceMenu({
|
||||
<div className="border-t-[1px] border-light-600 p-1 dark:border-dark-500">
|
||||
<Menu.Item>
|
||||
<button
|
||||
onClick={() => openModal("NEW_WORKSPACE")}
|
||||
onClick={() =>
|
||||
env("NEXT_PUBLIC_KAN_ENV") === "cloud"
|
||||
? router.push(`/onboarding/select-plan?returnUrl=${encodeURIComponent(window.location.pathname)}`)
|
||||
: openModal("NEW_WORKSPACE")
|
||||
}
|
||||
className="flex w-full items-center justify-between rounded-[5px] px-3 py-2 text-left text-xs text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
|
||||
>
|
||||
{t`Create workspace`}
|
||||
|
||||
@@ -9,6 +9,7 @@ interface Props {
|
||||
positionFromTop?: "sm" | "md" | "lg";
|
||||
isVisible?: boolean;
|
||||
closeOnClickOutside?: boolean;
|
||||
centered?: boolean;
|
||||
}
|
||||
|
||||
const Modal: React.FC<Props> = ({
|
||||
@@ -17,6 +18,7 @@ const Modal: React.FC<Props> = ({
|
||||
positionFromTop = "md",
|
||||
isVisible,
|
||||
closeOnClickOutside,
|
||||
centered = false,
|
||||
}) => {
|
||||
const {
|
||||
isOpen,
|
||||
@@ -60,7 +62,7 @@ const Modal: React.FC<Props> = ({
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-50 w-screen overflow-y-auto">
|
||||
<div className="flex min-h-full items-start justify-center p-4 text-center sm:items-start sm:p-0">
|
||||
<div className={`flex min-h-full justify-center p-4 text-center sm:p-0 ${centered ? "items-center" : "items-start sm:items-start"}`}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
@@ -71,7 +73,7 @@ const Modal: React.FC<Props> = ({
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel
|
||||
className={`relative ${positionFromTopMap[positionFromTop]} w-full transform rounded-lg border border-light-600 bg-white/90 text-left shadow-3xl-light backdrop-blur-[6px] transition-all dark:border-dark-600 dark:bg-dark-100/90 dark:shadow-3xl-dark ${modalSizeMap[modalSize]}`}
|
||||
className={`relative ${centered ? "" : positionFromTopMap[positionFromTop]} w-full transform rounded-lg border border-light-600 bg-white/90 text-left shadow-3xl-light backdrop-blur-[6px] transition-all dark:border-dark-600 dark:bg-dark-100/90 dark:shadow-3xl-dark ${modalSizeMap[modalSize]}`}
|
||||
>
|
||||
{children}
|
||||
</Dialog.Panel>
|
||||
|
||||
@@ -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().or(z.literal("")),
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -95,6 +96,13 @@ 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_USE_VIRTUAL_HOSTED_URLS: z
|
||||
.string()
|
||||
.transform((s) => (s === "" ? undefined : s))
|
||||
.refine(
|
||||
(s) => !s || s.toLowerCase() === "true" || s.toLowerCase() === "false",
|
||||
)
|
||||
.optional(),
|
||||
NEXT_PUBLIC_APP_VERSION: z.string().optional(),
|
||||
NEXT_PUBLIC_ALLOW_CREDENTIALS: z
|
||||
.string()
|
||||
@@ -133,6 +141,8 @@ 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_USE_VIRTUAL_HOSTED_URLS:
|
||||
process.env.NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS,
|
||||
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,
|
||||
|
||||
101
apps/web/src/hooks/useDragToScroll.ts
Normal file
101
apps/web/src/hooks/useDragToScroll.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Locale as DateFnsLocale } from "date-fns";
|
||||
import { useLingui } from "@lingui/react";
|
||||
import { de, enGB, es, fr, it, nl, pl, ru, ptBR} from "date-fns/locale";
|
||||
import { de, enGB, es, fr, it, nl, pl, ptBR, ru } from "date-fns/locale";
|
||||
|
||||
import type { Locale } from "~/locales";
|
||||
import { useLinguiContext } from "~/providers/lingui";
|
||||
|
||||
112
apps/web/src/hooks/usePermissions.ts
Normal file
112
apps/web/src/hooks/usePermissions.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import type { Permission } from "@kan/shared";
|
||||
import { useContext } from "react";
|
||||
|
||||
import { WorkspaceContext } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
interface UsePermissionsResult {
|
||||
permissions: Permission[];
|
||||
role: string | null;
|
||||
isLoading: boolean;
|
||||
hasPermission: (permission: Permission) => boolean;
|
||||
canViewCard: boolean;
|
||||
canCreateCard: boolean;
|
||||
canEditCard: boolean;
|
||||
canDeleteCard: boolean;
|
||||
canCreateList: boolean;
|
||||
canEditList: boolean;
|
||||
canDeleteList: boolean;
|
||||
canCreateBoard: boolean;
|
||||
canEditBoard: boolean;
|
||||
canDeleteBoard: boolean;
|
||||
canArchiveBoard: boolean;
|
||||
canViewComment: boolean;
|
||||
canCreateComment: boolean;
|
||||
canEditComment: boolean;
|
||||
canDeleteComment: boolean;
|
||||
canInviteMember: boolean;
|
||||
canEditMember: boolean;
|
||||
canRemoveMember: boolean;
|
||||
canViewWorkspace: boolean;
|
||||
canEditWorkspace: boolean;
|
||||
}
|
||||
|
||||
export function usePermissions(): UsePermissionsResult {
|
||||
// Check if WorkspaceProvider is available (for public board views, it may not be)
|
||||
const workspaceContext = useContext(WorkspaceContext);
|
||||
|
||||
// If WorkspaceProvider is not available, return safe defaults
|
||||
if (!workspaceContext) {
|
||||
const emptyPermissions: UsePermissionsResult = {
|
||||
permissions: [],
|
||||
role: null,
|
||||
isLoading: false,
|
||||
hasPermission: () => false,
|
||||
canViewCard: false,
|
||||
canCreateCard: false,
|
||||
canEditCard: false,
|
||||
canDeleteCard: false,
|
||||
canCreateList: false,
|
||||
canEditList: false,
|
||||
canDeleteList: false,
|
||||
canCreateBoard: false,
|
||||
canEditBoard: false,
|
||||
canDeleteBoard: false,
|
||||
canArchiveBoard: false,
|
||||
canViewComment: false,
|
||||
canCreateComment: false,
|
||||
canEditComment: false,
|
||||
canDeleteComment: false,
|
||||
canInviteMember: false,
|
||||
canEditMember: false,
|
||||
canRemoveMember: false,
|
||||
canViewWorkspace: false,
|
||||
canEditWorkspace: false,
|
||||
};
|
||||
return emptyPermissions;
|
||||
}
|
||||
|
||||
const { workspace } = workspaceContext;
|
||||
|
||||
const { data, isLoading } = api.permission.getMyPermissions.useQuery(
|
||||
{ workspacePublicId: workspace.publicId },
|
||||
{
|
||||
enabled: !!workspace.publicId,
|
||||
},
|
||||
);
|
||||
|
||||
const permissions = (data?.permissions ?? []) as Permission[];
|
||||
const role = data?.role ?? null;
|
||||
|
||||
const hasPermission = (permission: Permission): boolean => {
|
||||
return permissions.includes(permission);
|
||||
};
|
||||
|
||||
return {
|
||||
permissions,
|
||||
role,
|
||||
isLoading,
|
||||
hasPermission,
|
||||
canViewCard: hasPermission("card:view"),
|
||||
canCreateCard: hasPermission("card:create"),
|
||||
canEditCard: hasPermission("card:edit"),
|
||||
canDeleteCard: hasPermission("card:delete"),
|
||||
canCreateList: hasPermission("list:create"),
|
||||
canEditList: hasPermission("list:edit"),
|
||||
canDeleteList: hasPermission("list:delete"),
|
||||
canCreateBoard: hasPermission("board:create"),
|
||||
canEditBoard: hasPermission("board:edit"),
|
||||
canDeleteBoard: hasPermission("board:delete"),
|
||||
canArchiveBoard: hasPermission("board:edit"),
|
||||
canViewComment: hasPermission("comment:view"),
|
||||
canCreateComment: hasPermission("comment:create"),
|
||||
canEditComment: hasPermission("comment:edit"),
|
||||
canDeleteComment: hasPermission("comment:delete"),
|
||||
canInviteMember: hasPermission("member:invite"),
|
||||
canEditMember: hasPermission("member:edit"),
|
||||
canRemoveMember: hasPermission("member:remove"),
|
||||
canViewWorkspace: hasPermission("workspace:view"),
|
||||
canEditWorkspace: hasPermission("workspace:edit"),
|
||||
};
|
||||
}
|
||||
|
||||
44
apps/web/src/hooks/useScrollRestore.ts
Normal file
44
apps/web/src/hooks/useScrollRestore.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { RefObject } from "react";
|
||||
import type { NextRouter } from "next/router";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
const scrollPositions = new Map<string, number>();
|
||||
|
||||
export function useScrollRestore(
|
||||
boardId: string | null | undefined,
|
||||
scrollRef: RefObject<HTMLElement | null>,
|
||||
router: NextRouter,
|
||||
isReady: boolean,
|
||||
) {
|
||||
const restored = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!boardId) return;
|
||||
|
||||
restored.current = false;
|
||||
|
||||
const saveScrollPosition = () => {
|
||||
if (scrollRef.current) {
|
||||
scrollPositions.set(boardId, scrollRef.current.scrollLeft);
|
||||
}
|
||||
};
|
||||
|
||||
router.events.on("routeChangeStart", saveScrollPosition);
|
||||
return () => router.events.off("routeChangeStart", saveScrollPosition);
|
||||
}, [boardId, router.events, scrollRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (restored.current || !isReady || !boardId) return;
|
||||
restored.current = true;
|
||||
|
||||
const saved = scrollPositions.get(boardId);
|
||||
if (saved === undefined) return;
|
||||
|
||||
// StrictModeDroppable delays rendering by one requestAnimationFrame
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (scrollRef.current) scrollRef.current.scrollLeft = saved;
|
||||
});
|
||||
});
|
||||
}, [isReady, scrollRef, boardId]);
|
||||
}
|
||||
7390
apps/web/src/locales/de/messages.json
Normal file
7390
apps/web/src/locales/de/messages.json
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
12508
apps/web/src/locales/en/messages.json
Normal file
12508
apps/web/src/locales/en/messages.json
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
7390
apps/web/src/locales/es/messages.json
Normal file
7390
apps/web/src/locales/es/messages.json
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
7390
apps/web/src/locales/fr/messages.json
Normal file
7390
apps/web/src/locales/fr/messages.json
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
7390
apps/web/src/locales/it/messages.json
Normal file
7390
apps/web/src/locales/it/messages.json
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
7390
apps/web/src/locales/nl/messages.json
Normal file
7390
apps/web/src/locales/nl/messages.json
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
7390
apps/web/src/locales/pl/messages.json
Normal file
7390
apps/web/src/locales/pl/messages.json
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
7390
apps/web/src/locales/pt-BR/messages.json
Normal file
7390
apps/web/src/locales/pt-BR/messages.json
Normal file
File diff suppressed because it is too large
Load Diff
1
apps/web/src/locales/pt-BR/messages.ts
Normal file
1
apps/web/src/locales/pt-BR/messages.ts
Normal file
File diff suppressed because one or more lines are too long
@@ -13,7 +13,7 @@ msgstr ""
|
||||
"Language-Team: \n"
|
||||
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
|
||||
|
||||
#: src/views/card/components/Comment.tsx:124
|
||||
#: src/views/card/components/Comment.tsx:123
|
||||
msgid " (edited)"
|
||||
msgstr " (editado)"
|
||||
|
||||
@@ -31,17 +31,17 @@ msgstr "{0}"
|
||||
#. placeholder {1}: board?.name ?? t`Board`
|
||||
#. placeholder {1}: workspace.name ?? t`Workspace`
|
||||
#: src/views/boards/index.tsx:33
|
||||
#: src/views/card/index.tsx:273
|
||||
#: src/views/card/index.tsx:272
|
||||
msgid "{0} | {1}"
|
||||
msgstr "{0} | {1}"
|
||||
|
||||
#. placeholder {0}: labelPublicIds.length
|
||||
#: src/views/board/components/NewCardForm.tsx:453
|
||||
#: src/views/board/components/NewCardForm.tsx:452
|
||||
msgid "{0} labels"
|
||||
msgstr "{0} etiquetas"
|
||||
|
||||
#. placeholder {0}: isTemplate ? "Template" : "Board"
|
||||
#: src/views/board/index.tsx:416
|
||||
#: src/views/board/index.tsx:412
|
||||
msgid "{0} not found"
|
||||
msgstr "{0} não encontrado"
|
||||
|
||||
@@ -95,7 +95,7 @@ msgstr "A conta foi excluída"
|
||||
msgid "Actions"
|
||||
msgstr "Ações"
|
||||
|
||||
#: src/views/card/index.tsx:387
|
||||
#: src/views/card/index.tsx:386
|
||||
#: src/views/public/board/CardModal.tsx:158
|
||||
msgid "Activity"
|
||||
msgstr "Atividade"
|
||||
@@ -112,12 +112,12 @@ msgstr "Registros de atividade"
|
||||
msgid "Add a card"
|
||||
msgstr "Adicionar um cartão"
|
||||
|
||||
#: src/views/card/components/Comment.tsx:147
|
||||
#: src/views/card/components/NewCommentForm.tsx:55
|
||||
#: src/views/card/components/Comment.tsx:146
|
||||
#: src/views/card/components/NewCommentForm.tsx:57
|
||||
msgid "Add a comment..."
|
||||
msgstr "Adicionar um comentário..."
|
||||
|
||||
#: src/views/card/components/NewChecklistItemForm.tsx:141
|
||||
#: src/views/card/components/NewChecklistItemForm.tsx:140
|
||||
msgid "Add an item..."
|
||||
msgstr "Adicionar um item..."
|
||||
|
||||
@@ -125,7 +125,7 @@ msgstr "Adicionar um item..."
|
||||
msgid "Add checklist"
|
||||
msgstr "Adicionar checklist"
|
||||
|
||||
#: src/components/Editor.tsx:455
|
||||
#: src/components/Editor.tsx:451
|
||||
msgid "Add description... (type '/' to open commands or '@' to mention)"
|
||||
msgstr "Adicionar descrição... (digite '/' para abrir comandos ou '@' para mencionar)"
|
||||
|
||||
@@ -133,53 +133,48 @@ msgstr "Adicionar descrição... (digite '/' para abrir comandos ou '@' para men
|
||||
#~ msgid "Add description... (type '/' to open commands)"
|
||||
#~ msgstr "Adicionar descrição... (digite '/' para abrir comandos)"
|
||||
|
||||
#: src/views/card/components/ChecklistItemRow.tsx:200
|
||||
#: src/views/card/components/ChecklistItemRow.tsx:172
|
||||
msgid "Add details..."
|
||||
msgstr "Adicionar detalhes..."
|
||||
|
||||
#: src/views/card/components/LabelSelector.tsx:110
|
||||
#: src/views/card/components/LabelSelector.tsx:115
|
||||
#: src/views/card/components/LabelSelector.tsx:109
|
||||
#: src/views/card/components/LabelSelector.tsx:114
|
||||
msgid "Add label"
|
||||
msgstr "Adicionar etiqueta"
|
||||
|
||||
#: src/views/card/components/MemberSelector.tsx:131
|
||||
#: src/views/card/components/MemberSelector.tsx:130
|
||||
#: src/views/members/components/InviteMemberForm.tsx:257
|
||||
msgid "Add member"
|
||||
msgstr "Adicionar membro"
|
||||
|
||||
#. placeholder {0}: mergedLabels.length
|
||||
#: src/views/card/components/ActivityList.tsx:77
|
||||
msgid "added {0} labels: <0>{labelList}</0>"
|
||||
msgstr ""
|
||||
|
||||
#: src/views/card/components/ActivityList.tsx:107
|
||||
#: src/views/card/components/ActivityList.tsx:66
|
||||
msgid "added a checklist"
|
||||
msgstr "adicionou uma checklist"
|
||||
|
||||
#: src/views/card/components/ActivityList.tsx:110
|
||||
#: src/views/card/components/ActivityList.tsx:69
|
||||
msgid "added a checklist item"
|
||||
msgstr "adicionou um item à checklist"
|
||||
|
||||
#: src/views/card/components/ActivityList.tsx:103
|
||||
#: src/views/card/components/ActivityList.tsx:62
|
||||
msgid "added a label to the card"
|
||||
msgstr "adicionou uma etiqueta ao cartão"
|
||||
|
||||
#: src/views/card/components/ActivityList.tsx:105
|
||||
#: src/views/card/components/ActivityList.tsx:64
|
||||
msgid "added a member to the card"
|
||||
msgstr "adicionou um membro ao cartão"
|
||||
|
||||
#. placeholder {0}: truncate(toTitle)
|
||||
#: src/views/card/components/ActivityList.tsx:181
|
||||
#: src/views/card/components/ActivityList.tsx:146
|
||||
msgid "added checklist <0>{0}</0>"
|
||||
msgstr "adicionou a checklist <0>{0}</0>"
|
||||
|
||||
#. placeholder {0}: truncate(toTitle)
|
||||
#: src/views/card/components/ActivityList.tsx:205
|
||||
#: src/views/card/components/ActivityList.tsx:170
|
||||
msgid "added checklist item <0>{0}</0>"
|
||||
msgstr "adicionou o item <0>{0}</0> à checklist"
|
||||
|
||||
#. placeholder {0}: truncate(label)
|
||||
#: src/views/card/components/ActivityList.tsx:165
|
||||
#: src/views/card/components/ActivityList.tsx:130
|
||||
msgid "added label <0>{0}</0>"
|
||||
msgstr "adicionou a etiqueta <0>{0}</0>"
|
||||
|
||||
@@ -282,11 +277,11 @@ msgstr "Tem certeza de que deseja excluir este {0}?"
|
||||
msgid "Are you sure you want to delete this card?"
|
||||
msgstr "Tem certeza de que deseja excluir este cartão?"
|
||||
|
||||
#: src/views/card/components/DeleteChecklistConfirmation.tsx:56
|
||||
#: src/views/card/components/DeleteChecklistConfirmation.tsx:55
|
||||
msgid "Are you sure you want to delete this checklist?"
|
||||
msgstr "Tem certeza de que deseja excluir esta checklist?"
|
||||
|
||||
#: src/views/card/components/DeleteCommentConfirmation.tsx:66
|
||||
#: src/views/card/components/DeleteCommentConfirmation.tsx:65
|
||||
msgid "Are you sure you want to delete this comment?"
|
||||
msgstr "Tem certeza de que deseja excluir este comentário?"
|
||||
|
||||
@@ -303,7 +298,7 @@ msgstr "Tem certeza de que deseja excluir sua conta?"
|
||||
#~ msgstr "around the world"
|
||||
|
||||
#. placeholder {0}: truncate(memberName)
|
||||
#: src/views/card/components/ActivityList.tsx:145
|
||||
#: src/views/card/components/ActivityList.tsx:110
|
||||
msgid "assigned <0>{0}</0> to the card"
|
||||
msgstr "atribuído <0>{0}</0> ao cartão"
|
||||
|
||||
@@ -315,7 +310,7 @@ msgstr "atribuído <0>{0}</0> ao cartão"
|
||||
msgid "Assignees"
|
||||
msgstr "Responsáveis"
|
||||
|
||||
#: src/views/card/components/AttachmentUpload.tsx:26
|
||||
#: src/views/card/components/AttachmentUpload.tsx:25
|
||||
msgid "Attachment uploaded"
|
||||
msgstr "Anexo enviado"
|
||||
|
||||
@@ -357,9 +352,9 @@ msgstr "Portal de faturamento"
|
||||
msgid "Blog Post"
|
||||
msgstr "Post de blog"
|
||||
|
||||
#: src/views/board/index.tsx:390
|
||||
#: src/views/card/index.tsx:273
|
||||
#: src/views/public/board/index.tsx:114
|
||||
#: src/views/board/index.tsx:386
|
||||
#: src/views/card/index.tsx:272
|
||||
#: src/views/public/board/index.tsx:113
|
||||
msgid "Board"
|
||||
msgstr "Quadro"
|
||||
|
||||
@@ -385,7 +380,7 @@ msgstr "O nome do quadro não pode exceder 100 caracteres"
|
||||
msgid "Board name is required"
|
||||
msgstr "O nome do quadro é obrigatório"
|
||||
|
||||
#: src/views/public/board/index.tsx:166
|
||||
#: src/views/public/board/index.tsx:165
|
||||
msgid "Board not found"
|
||||
msgstr "Quadro não encontrado"
|
||||
|
||||
@@ -401,7 +396,7 @@ msgstr "A URL do quadro só pode conter letras, números e hífens"
|
||||
msgid "Board URL cannot exceed 60 characters"
|
||||
msgstr "A URL do quadro não pode exceder 60 caracteres"
|
||||
|
||||
#: src/views/public/board/index.tsx:80
|
||||
#: src/views/public/board/index.tsx:79
|
||||
msgid "Board URL copied to clipboard"
|
||||
msgstr "URL do quadro copiada para a área de transferência"
|
||||
|
||||
@@ -443,12 +438,11 @@ msgid "Bug Report"
|
||||
msgstr "Relatório de bug"
|
||||
|
||||
#: src/components/DeleteLabelConfirmation.tsx:49
|
||||
#: src/components/YouTubeEmbed/EditYouTubeModal.tsx:176
|
||||
#: src/views/board/components/DeleteBoardConfirmation.tsx:44
|
||||
#: src/views/card/components/Comment.tsx:159
|
||||
#: src/views/card/components/Comment.tsx:158
|
||||
#: src/views/card/components/DeleteCardConfirmation.tsx:86
|
||||
#: src/views/card/components/DeleteChecklistConfirmation.tsx:64
|
||||
#: src/views/card/components/DeleteCommentConfirmation.tsx:77
|
||||
#: src/views/card/components/DeleteChecklistConfirmation.tsx:63
|
||||
#: src/views/card/components/DeleteCommentConfirmation.tsx:76
|
||||
#: src/views/members/components/DeleteMemberConfirmation.tsx:55
|
||||
#: src/views/settings/components/Avatar.tsx:306
|
||||
#: src/views/settings/components/ChangePasswordConfirmation.tsx:168
|
||||
@@ -458,12 +452,12 @@ msgstr "Relatório de bug"
|
||||
msgid "Cancel"
|
||||
msgstr "Cancelar"
|
||||
|
||||
#: src/views/card/index.tsx:273
|
||||
#: src/views/card/index.tsx:272
|
||||
msgid "Card"
|
||||
msgstr "Cartão"
|
||||
|
||||
#: src/views/card/index.tsx:307
|
||||
#: src/views/card/index.tsx:343
|
||||
#: src/views/card/index.tsx:306
|
||||
#: src/views/card/index.tsx:342
|
||||
msgid "Card not found"
|
||||
msgstr "Cartão não encontrado"
|
||||
|
||||
@@ -486,8 +480,8 @@ msgstr "Alterar senha"
|
||||
msgid "Change your language preferences."
|
||||
msgstr "Altere suas preferências de idioma."
|
||||
|
||||
#: src/views/card/components/ActivityList.tsx:255
|
||||
#: src/views/card/components/ActivityList.tsx:269
|
||||
#: src/views/card/components/ActivityList.tsx:220
|
||||
#: src/views/card/components/ActivityList.tsx:234
|
||||
msgid "changed the due date to <0>{formattedDate}</0>"
|
||||
msgstr "alterou a data de vencimento para <0>{formattedDate}</0>"
|
||||
|
||||
@@ -500,7 +494,7 @@ msgstr "Confira alguns dos nossos projetos de código aberto favoritos."
|
||||
msgid "Check your inbox"
|
||||
msgstr "Verifique sua caixa de entrada"
|
||||
|
||||
#: src/views/card/components/NewChecklistForm.tsx:119
|
||||
#: src/views/card/components/NewChecklistForm.tsx:118
|
||||
msgid "Checklist name"
|
||||
msgstr "Nome da checklist"
|
||||
|
||||
@@ -561,12 +555,12 @@ msgstr "Concluído"
|
||||
msgid "Complete control and ownership:"
|
||||
msgstr "Controle e propriedade completos:"
|
||||
|
||||
#: src/views/card/components/ActivityList.tsx:112
|
||||
#: src/views/card/components/ActivityList.tsx:71
|
||||
msgid "completed a checklist item"
|
||||
msgstr "concluiu um item da checklist"
|
||||
|
||||
#. placeholder {0}: truncate(toTitle)
|
||||
#: src/views/card/components/ActivityList.tsx:222
|
||||
#: src/views/card/components/ActivityList.tsx:187
|
||||
msgid "completed checklist item <0>{0}</0>"
|
||||
msgstr "concluiu o item <0>{0}</0> da checklist"
|
||||
|
||||
@@ -621,10 +615,6 @@ msgstr "Continuar com {0}"
|
||||
msgid "Control who can view and edit your boards."
|
||||
msgstr "Controle quem pode ver e editar seus quadros."
|
||||
|
||||
#: src/components/YouTubeEmbed/YouTubeDropdown.tsx:47
|
||||
msgid "Convert to link"
|
||||
msgstr ""
|
||||
|
||||
#: src/views/pricing/components/FeatureComparisonTable.tsx:38
|
||||
msgid "Core features"
|
||||
msgstr "Recursos principais"
|
||||
@@ -635,7 +625,7 @@ msgid "Create {0}"
|
||||
msgstr "Criar {0}"
|
||||
|
||||
#: src/components/LabelForm.tsx:213
|
||||
#: src/views/board/components/NewCardForm.tsx:517
|
||||
#: src/views/board/components/NewCardForm.tsx:516
|
||||
#: src/views/board/components/NewListForm.tsx:141
|
||||
msgid "Create another"
|
||||
msgstr "Criar outro"
|
||||
@@ -652,11 +642,11 @@ msgstr "Criar chave de API"
|
||||
#~ msgid "Create board"
|
||||
#~ msgstr "Create board"
|
||||
|
||||
#: src/views/board/components/NewCardForm.tsx:527
|
||||
#: src/views/board/components/NewCardForm.tsx:526
|
||||
msgid "Create card"
|
||||
msgstr "Criar cartão"
|
||||
|
||||
#: src/views/card/components/NewChecklistForm.tsx:135
|
||||
#: src/views/card/components/NewChecklistForm.tsx:134
|
||||
msgid "Create checklist"
|
||||
msgstr "Criar checklist"
|
||||
|
||||
@@ -686,13 +676,13 @@ msgstr "Criar novo {0}"
|
||||
msgid "Create new key"
|
||||
msgstr "Criar nova chave"
|
||||
|
||||
#: src/views/board/components/NewCardForm.tsx:415
|
||||
#: src/views/card/components/LabelSelector.tsx:98
|
||||
#: src/views/board/components/NewCardForm.tsx:414
|
||||
#: src/views/card/components/LabelSelector.tsx:97
|
||||
msgid "Create new label"
|
||||
msgstr "Criar nova etiqueta"
|
||||
|
||||
#: src/views/board/index.tsx:65
|
||||
#: src/views/board/index.tsx:507
|
||||
#: src/views/board/index.tsx:503
|
||||
msgid "Create new list"
|
||||
msgstr "Criar nova lista"
|
||||
|
||||
@@ -709,7 +699,7 @@ msgstr "Criar template"
|
||||
msgid "Create workspace"
|
||||
msgstr "Criar workspace"
|
||||
|
||||
#: src/views/card/components/ActivityList.tsx:99
|
||||
#: src/views/card/components/ActivityList.tsx:58
|
||||
msgid "created the card"
|
||||
msgstr "criou o cartão"
|
||||
|
||||
@@ -773,11 +763,10 @@ msgstr "Desativar link de convite"
|
||||
|
||||
#: src/components/DeleteLabelConfirmation.tsx:51
|
||||
#: src/components/LabelForm.tsx:228
|
||||
#: src/components/YouTubeEmbed/YouTubeDropdown.tsx:52
|
||||
#: src/views/board/components/DeleteBoardConfirmation.tsx:47
|
||||
#: src/views/card/components/DeleteCardConfirmation.tsx:92
|
||||
#: src/views/card/components/DeleteChecklistConfirmation.tsx:67
|
||||
#: src/views/card/components/DeleteCommentConfirmation.tsx:83
|
||||
#: src/views/card/components/DeleteChecklistConfirmation.tsx:66
|
||||
#: src/views/card/components/DeleteCommentConfirmation.tsx:82
|
||||
msgid "Delete"
|
||||
msgstr "Excluir"
|
||||
|
||||
@@ -795,7 +784,7 @@ msgstr "Excluir quadro"
|
||||
msgid "Delete card"
|
||||
msgstr "Excluir cartão"
|
||||
|
||||
#: src/views/card/components/Comment.tsx:92
|
||||
#: src/views/card/components/Comment.tsx:91
|
||||
msgid "Delete comment"
|
||||
msgstr "Excluir comentário"
|
||||
|
||||
@@ -813,21 +802,21 @@ msgstr "Excluir template"
|
||||
msgid "Delete workspace"
|
||||
msgstr "Excluir workspace"
|
||||
|
||||
#: src/views/card/components/ActivityList.tsx:109
|
||||
#: src/views/card/components/ActivityList.tsx:68
|
||||
msgid "deleted a checklist"
|
||||
msgstr "excluiu uma checklist"
|
||||
|
||||
#: src/views/card/components/ActivityList.tsx:114
|
||||
#: src/views/card/components/ActivityList.tsx:73
|
||||
msgid "deleted a checklist item"
|
||||
msgstr "excluiu um item da checklist"
|
||||
|
||||
#. placeholder {0}: truncate(fromTitle)
|
||||
#: src/views/card/components/ActivityList.tsx:197
|
||||
#: src/views/card/components/ActivityList.tsx:162
|
||||
msgid "deleted checklist <0>{0}</0>"
|
||||
msgstr "excluiu a checklist <0>{0}</0>"
|
||||
|
||||
#. placeholder {0}: truncate(fromTitle)
|
||||
#: src/views/card/components/ActivityList.tsx:240
|
||||
#: src/views/card/components/ActivityList.tsx:205
|
||||
msgid "deleted checklist item <0>{0}</0>"
|
||||
msgstr "excluiu o item <0>{0}</0> da checklist"
|
||||
|
||||
@@ -893,13 +882,13 @@ msgstr "Não tem uma conta? <0><1>Cadastre-se</1></0>"
|
||||
msgid "Done"
|
||||
msgstr "Concluído"
|
||||
|
||||
#: src/views/card/components/AttachmentThumbnails.tsx:142
|
||||
#: src/views/card/components/AttachmentThumbnails.tsx:141
|
||||
msgid "Download failed"
|
||||
msgstr "Falha no download"
|
||||
|
||||
#: src/views/board/components/Filters.tsx:171
|
||||
#: src/views/board/components/NewCardForm.tsx:470
|
||||
#: src/views/card/index.tsx:140
|
||||
#: src/views/board/components/NewCardForm.tsx:469
|
||||
#: src/views/card/index.tsx:138
|
||||
msgid "Due date"
|
||||
msgstr "Data de vencimento"
|
||||
|
||||
@@ -919,16 +908,12 @@ msgstr "Vence hoje"
|
||||
msgid "Due tomorrow"
|
||||
msgstr "Vence amanhã"
|
||||
|
||||
#: src/components/YouTubeEmbed/YouTubeDropdown.tsx:42
|
||||
msgid "Edit"
|
||||
msgstr ""
|
||||
|
||||
#: src/views/board/components/BoardDropdown.tsx:41
|
||||
#: src/views/board/components/UpdateBoardSlugForm.tsx:116
|
||||
msgid "Edit board URL"
|
||||
msgstr "Editar URL do quadro"
|
||||
|
||||
#: src/views/card/components/Comment.tsx:83
|
||||
#: src/views/card/components/Comment.tsx:82
|
||||
msgid "Edit comment"
|
||||
msgstr "Editar comentário"
|
||||
|
||||
@@ -940,10 +925,6 @@ msgstr "Editar etiqueta"
|
||||
msgid "Edit workspace URL"
|
||||
msgstr "Editar URL do workspace"
|
||||
|
||||
#: src/components/YouTubeEmbed/EditYouTubeModal.tsx:108
|
||||
msgid "Edit YouTube Video"
|
||||
msgstr ""
|
||||
|
||||
#: src/views/boards/components/TemplateBoards.tsx:32
|
||||
msgid "Editing"
|
||||
msgstr "Edição"
|
||||
@@ -960,10 +941,6 @@ msgstr "E-mail"
|
||||
msgid "Enhancement"
|
||||
msgstr "Melhoria"
|
||||
|
||||
#: src/components/YouTubeEmbed/EditYouTubeModal.tsx:131
|
||||
msgid "Enter a custom title"
|
||||
msgstr ""
|
||||
|
||||
#: src/views/settings/components/ChangePasswordConfirmation.tsx:122
|
||||
msgid "Enter your current password"
|
||||
msgstr "Digite sua senha atual"
|
||||
@@ -1088,17 +1065,13 @@ msgstr "Falha ao copiar link de convite"
|
||||
msgid "Failed to create board"
|
||||
msgstr "Falha ao criar quadro"
|
||||
|
||||
#: src/components/YouTubeEmbed/EditYouTubeModal.tsx:82
|
||||
msgid "Failed to fetch video information"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: provider.at(0)?.toUpperCase() + provider.slice(1)
|
||||
#: src/components/AuthForm.tsx:291
|
||||
msgid "Failed to login with {0}. Please try again."
|
||||
msgstr "Falha ao fazer login com {0}. Tente novamente."
|
||||
|
||||
#: src/views/card/components/AttachmentUpload.tsx:34
|
||||
#: src/views/card/components/AttachmentUpload.tsx:80
|
||||
#: src/views/card/components/AttachmentUpload.tsx:33
|
||||
#: src/views/card/components/AttachmentUpload.tsx:79
|
||||
msgid "Failed to upload attachment. Please try again."
|
||||
msgstr "Falha ao enviar o anexo. Tente novamente."
|
||||
|
||||
@@ -1217,7 +1190,7 @@ msgstr "Comece criando um novo {0}"
|
||||
#~ msgid "Get started by creating a new board"
|
||||
#~ msgstr "Get started by creating a new board"
|
||||
|
||||
#: src/views/board/index.tsx:499
|
||||
#: src/views/board/index.tsx:495
|
||||
msgid "Get started by creating a new list"
|
||||
msgstr "Comece criando uma nova lista"
|
||||
|
||||
@@ -1300,10 +1273,6 @@ msgstr "Como faço auto-hospedagem?"
|
||||
msgid "How is the project funded?"
|
||||
msgstr "Como o projeto é financiado?"
|
||||
|
||||
#: src/components/YouTubeEmbed/EditYouTubeModal.tsx:151
|
||||
msgid "https://www.youtube.com/watch?v=..."
|
||||
msgstr ""
|
||||
|
||||
#: src/views/settings/components/DeleteAccountConfirmation.tsx:82
|
||||
msgid "I acknowledge that all of my account data will be permanently deleted and want to proceed."
|
||||
msgstr "Reconheço que todos os dados da minha conta serão excluídos permanentemente e desejo prosseguir."
|
||||
@@ -1416,7 +1385,7 @@ msgstr "Links de convite"
|
||||
msgid "Invite links require a Team or Pro subscription. Please upgrade your workspace."
|
||||
msgstr "Links de convite exigem uma assinatura Team ou Pro. Faça upgrade do seu workspace."
|
||||
|
||||
#: src/views/card/components/MemberSelector.tsx:112
|
||||
#: src/views/card/components/MemberSelector.tsx:111
|
||||
#: src/views/members/components/InviteMemberForm.tsx:367
|
||||
msgid "Invite member"
|
||||
msgstr "Convidar membro"
|
||||
@@ -1464,8 +1433,8 @@ msgid "Keyboard Shortcuts"
|
||||
msgstr "Atalhos de teclado"
|
||||
|
||||
#: src/views/board/components/Filters.tsx:155
|
||||
#: src/views/board/components/NewCardForm.tsx:419
|
||||
#: src/views/card/index.tsx:122
|
||||
#: src/views/board/components/NewCardForm.tsx:418
|
||||
#: src/views/card/index.tsx:120
|
||||
msgid "Labels"
|
||||
msgstr "Etiquetas"
|
||||
|
||||
@@ -1515,11 +1484,11 @@ msgstr "Licença"
|
||||
msgid "Light"
|
||||
msgstr "Claro"
|
||||
|
||||
#: src/views/public/board/index.tsx:78
|
||||
#: src/views/public/board/index.tsx:77
|
||||
msgid "Link copied"
|
||||
msgstr "Link copiado"
|
||||
|
||||
#: src/views/card/index.tsx:114
|
||||
#: src/views/card/index.tsx:112
|
||||
msgid "List"
|
||||
msgstr "Lista"
|
||||
|
||||
@@ -1531,14 +1500,6 @@ msgstr "Nome da lista"
|
||||
msgid "Lists"
|
||||
msgstr "Listas"
|
||||
|
||||
#: src/views/card/components/ActivityList.tsx:530
|
||||
msgid "Load more activities"
|
||||
msgstr ""
|
||||
|
||||
#: src/views/card/components/ActivityList.tsx:530
|
||||
msgid "Loading..."
|
||||
msgstr ""
|
||||
|
||||
#: src/views/auth/login/index.tsx:33
|
||||
msgid "Login | kan.bn"
|
||||
msgstr "Login | kan.bn"
|
||||
@@ -1567,12 +1528,12 @@ msgstr "link mágico"
|
||||
msgid "Make template"
|
||||
msgstr "Transformar em template"
|
||||
|
||||
#: src/views/card/components/ActivityList.tsx:113
|
||||
#: src/views/card/components/ActivityList.tsx:72
|
||||
msgid "marked a checklist item as incomplete"
|
||||
msgstr "marcou um item da checklist como incompleto"
|
||||
|
||||
#. placeholder {0}: truncate(toTitle)
|
||||
#: src/views/card/components/ActivityList.tsx:231
|
||||
#: src/views/card/components/ActivityList.tsx:196
|
||||
msgid "marked checklist item <0>{0}</0> as incomplete"
|
||||
msgstr "marcou o item <0>{0}</0> da checklist como incompleto"
|
||||
|
||||
@@ -1582,8 +1543,8 @@ msgstr "Prioridade média"
|
||||
|
||||
#: src/components/SideNavigation.tsx:121
|
||||
#: src/views/board/components/Filters.tsx:147
|
||||
#: src/views/board/components/NewCardForm.tsx:377
|
||||
#: src/views/card/index.tsx:131
|
||||
#: src/views/board/components/NewCardForm.tsx:376
|
||||
#: src/views/card/index.tsx:129
|
||||
#: src/views/members/index.tsx:178
|
||||
msgid "Members"
|
||||
msgstr "Membros"
|
||||
@@ -1604,7 +1565,7 @@ msgstr "cobrança mensal"
|
||||
|
||||
#. placeholder {0}: truncate(fromList)
|
||||
#. placeholder {1}: truncate(toList)
|
||||
#: src/views/card/components/ActivityList.tsx:133
|
||||
#: src/views/card/components/ActivityList.tsx:98
|
||||
msgid "moved the card from <0>{0}</0> to<1>{1}</1>"
|
||||
msgstr "moveu o cartão de <0>{0}</0> para <1>{1}</1>"
|
||||
|
||||
@@ -1612,7 +1573,7 @@ msgstr "moveu o cartão de <0>{0}</0> para <1>{1}</1>"
|
||||
#~ msgid "moved the card from <0>{fromList}</0> to<1>{toList}</1>"
|
||||
#~ msgstr "moved the card from <0>{fromList}</0> to<1>{toList}</1>"
|
||||
|
||||
#: src/views/card/components/ActivityList.tsx:102
|
||||
#: src/views/card/components/ActivityList.tsx:61
|
||||
msgid "moved the card to another list"
|
||||
msgstr "moveu o cartão para outra lista"
|
||||
|
||||
@@ -1656,7 +1617,7 @@ msgstr "Nova chave de API"
|
||||
msgid "New card"
|
||||
msgstr "Novo cartão"
|
||||
|
||||
#: src/views/card/components/NewChecklistForm.tsx:103
|
||||
#: src/views/card/components/NewChecklistForm.tsx:102
|
||||
msgid "New checklist"
|
||||
msgstr "Nova checklist"
|
||||
|
||||
@@ -1669,7 +1630,7 @@ msgid "New label"
|
||||
msgstr "Nova etiqueta"
|
||||
|
||||
#: src/views/board/components/NewListForm.tsx:113
|
||||
#: src/views/board/index.tsx:470
|
||||
#: src/views/board/index.tsx:466
|
||||
msgid "New list"
|
||||
msgstr "Nova lista"
|
||||
|
||||
@@ -1730,7 +1691,7 @@ msgstr "Nenhum cartão de crédito é necessário"
|
||||
msgid "No dates"
|
||||
msgstr "Sem datas"
|
||||
|
||||
#: src/views/card/components/AttachmentThumbnails.tsx:143
|
||||
#: src/views/card/components/AttachmentThumbnails.tsx:142
|
||||
msgid "No download URL available for this attachment."
|
||||
msgstr "Nenhuma URL de download disponível para este anexo."
|
||||
|
||||
@@ -1738,7 +1699,7 @@ msgstr "Nenhuma URL de download disponível para este anexo."
|
||||
msgid "No keyboard shortcuts registered."
|
||||
msgstr "Nenhum atalho de teclado registrado."
|
||||
|
||||
#: src/views/board/index.tsx:496
|
||||
#: src/views/board/index.tsx:492
|
||||
msgid "No lists"
|
||||
msgstr "Nenhuma lista"
|
||||
|
||||
@@ -1876,11 +1837,6 @@ msgstr "Digite um nome válido"
|
||||
msgid "Please enter a valid password"
|
||||
msgstr "Digite uma senha válida"
|
||||
|
||||
#: src/components/YouTubeEmbed/EditYouTubeModal.tsx:58
|
||||
#: src/components/YouTubeEmbed/EditYouTubeModal.tsx:98
|
||||
msgid "Please enter a valid YouTube URL"
|
||||
msgstr ""
|
||||
|
||||
#: src/views/settings/components/Avatar.tsx:92
|
||||
msgid "Please select a file to upload."
|
||||
msgstr "Selecione um arquivo para enviar."
|
||||
@@ -1894,27 +1850,26 @@ msgstr "Selecione um arquivo para enviar."
|
||||
#: src/views/board/components/NewTemplateForm.tsx:77
|
||||
#: src/views/board/components/UpdateBoardSlugForm.tsx:73
|
||||
#: src/views/board/components/VisibilityButton.tsx:54
|
||||
#: src/views/board/index.tsx:171
|
||||
#: src/views/board/index.tsx:227
|
||||
#: src/views/board/index.tsx:174
|
||||
#: src/views/board/index.tsx:230
|
||||
#: src/views/boards/components/ImportBoardsForm.tsx:205
|
||||
#: src/views/card/components/AttachmentThumbnails.tsx:74
|
||||
#: src/views/card/components/ChecklistItemRow.tsx:69
|
||||
#: src/views/card/components/ChecklistItemRow.tsx:97
|
||||
#: src/views/card/components/ChecklistNameInput.tsx:47
|
||||
#: src/views/card/components/Checklists.tsx:81
|
||||
#: src/views/card/components/Comment.tsx:65
|
||||
#: src/views/card/components/AttachmentThumbnails.tsx:73
|
||||
#: src/views/card/components/ChecklistItemRow.tsx:60
|
||||
#: src/views/card/components/ChecklistItemRow.tsx:88
|
||||
#: src/views/card/components/ChecklistNameInput.tsx:46
|
||||
#: src/views/card/components/Comment.tsx:64
|
||||
#: src/views/card/components/DeleteCardConfirmation.tsx:52
|
||||
#: src/views/card/components/DeleteChecklistConfirmation.tsx:38
|
||||
#: src/views/card/components/DeleteCommentConfirmation.tsx:46
|
||||
#: src/views/card/components/DueDateSelector.tsx:60
|
||||
#: src/views/card/components/LabelSelector.tsx:73
|
||||
#: src/views/card/components/ListSelector.tsx:53
|
||||
#: src/views/card/components/MemberSelector.tsx:80
|
||||
#: src/views/card/components/NewChecklistForm.tsx:71
|
||||
#: src/views/card/components/NewChecklistItemForm.tsx:90
|
||||
#: src/views/card/components/NewCommentForm.tsx:32
|
||||
#: src/views/card/index.tsx:188
|
||||
#: src/views/card/index.tsx:201
|
||||
#: src/views/card/components/DeleteChecklistConfirmation.tsx:37
|
||||
#: src/views/card/components/DeleteCommentConfirmation.tsx:45
|
||||
#: src/views/card/components/DueDateSelector.tsx:59
|
||||
#: src/views/card/components/LabelSelector.tsx:72
|
||||
#: src/views/card/components/ListSelector.tsx:52
|
||||
#: src/views/card/components/MemberSelector.tsx:79
|
||||
#: src/views/card/components/NewChecklistForm.tsx:70
|
||||
#: src/views/card/components/NewChecklistItemForm.tsx:89
|
||||
#: src/views/card/components/NewCommentForm.tsx:31
|
||||
#: src/views/card/index.tsx:187
|
||||
#: src/views/card/index.tsx:200
|
||||
#: src/views/members/components/DeleteMemberConfirmation.tsx:28
|
||||
#: src/views/members/components/InviteMemberForm.tsx:104
|
||||
#: src/views/members/components/InviteMemberForm.tsx:241
|
||||
@@ -2014,21 +1969,16 @@ msgstr "Remover"
|
||||
msgid "Remove member"
|
||||
msgstr "Remover membro"
|
||||
|
||||
#. placeholder {0}: mergedLabels.length
|
||||
#: src/views/card/components/ActivityList.tsx:91
|
||||
msgid "removed {0} labels: <0>{labelList}</0>"
|
||||
msgstr ""
|
||||
|
||||
#: src/views/card/components/ActivityList.tsx:104
|
||||
#: src/views/card/components/ActivityList.tsx:63
|
||||
msgid "removed a label from the card"
|
||||
msgstr "removeu uma etiqueta do cartão"
|
||||
|
||||
#: src/views/card/components/ActivityList.tsx:106
|
||||
#: src/views/card/components/ActivityList.tsx:65
|
||||
msgid "removed a member from the card"
|
||||
msgstr "removeu um membro do cartão"
|
||||
|
||||
#. placeholder {0}: truncate(label)
|
||||
#: src/views/card/components/ActivityList.tsx:173
|
||||
#: src/views/card/components/ActivityList.tsx:138
|
||||
msgid "removed label <0>{0}</0>"
|
||||
msgstr "removeu a etiqueta <0>{0}</0>"
|
||||
|
||||
@@ -2036,22 +1986,22 @@ msgstr "removeu a etiqueta <0>{0}</0>"
|
||||
#~ msgid "removed label <0>{label}</0>"
|
||||
#~ msgstr "removed label <0>{label}</0>"
|
||||
|
||||
#: src/views/card/components/ActivityList.tsx:117
|
||||
#: src/views/card/components/ActivityList.tsx:276
|
||||
#: src/views/card/components/ActivityList.tsx:76
|
||||
#: src/views/card/components/ActivityList.tsx:241
|
||||
msgid "removed the due date"
|
||||
msgstr "removeu a data de vencimento"
|
||||
|
||||
#: src/views/card/components/ActivityList.tsx:108
|
||||
#: src/views/card/components/ActivityList.tsx:67
|
||||
msgid "renamed a checklist"
|
||||
msgstr "renomeou uma checklist"
|
||||
|
||||
#. placeholder {0}: truncate(toTitle)
|
||||
#: src/views/card/components/ActivityList.tsx:189
|
||||
#: src/views/card/components/ActivityList.tsx:154
|
||||
msgid "renamed checklist <0>{0}</0>"
|
||||
msgstr "renomeou a checklist <0>{0}</0>"
|
||||
|
||||
#. placeholder {0}: truncate(toTitle)
|
||||
#: src/views/card/components/ActivityList.tsx:213
|
||||
#: src/views/card/components/ActivityList.tsx:178
|
||||
msgid "renamed checklist item to <0>{0}</0>"
|
||||
msgstr "renomeou o item da checklist para <0>{0}</0>"
|
||||
|
||||
@@ -2094,8 +2044,7 @@ msgstr "Função"
|
||||
msgid "Run on your own infrastructure"
|
||||
msgstr "Execute na sua própria infraestrutura"
|
||||
|
||||
#: src/components/YouTubeEmbed/EditYouTubeModal.tsx:183
|
||||
#: src/views/card/components/Comment.tsx:166
|
||||
#: src/views/card/components/Comment.tsx:165
|
||||
#: src/views/settings/components/Avatar.tsx:309
|
||||
msgid "Save"
|
||||
msgstr "Salvar"
|
||||
@@ -2133,7 +2082,7 @@ msgstr "Auto-hospedar"
|
||||
msgid "Self host with Github"
|
||||
msgstr "Auto-hospedar com Github"
|
||||
|
||||
#: src/views/card/components/ActivityList.tsx:142
|
||||
#: src/views/card/components/ActivityList.tsx:107
|
||||
msgid "self-assigned the card"
|
||||
msgstr "auto-atribuiu o cartão"
|
||||
|
||||
@@ -2153,11 +2102,11 @@ msgstr "Enviar feedback"
|
||||
msgid "Senior"
|
||||
msgstr "Sênior"
|
||||
|
||||
#: src/views/card/components/DueDateSelector.tsx:117
|
||||
#: src/views/card/components/DueDateSelector.tsx:116
|
||||
msgid "Set due date"
|
||||
msgstr "Definir data de vencimento"
|
||||
|
||||
#: src/views/card/components/ActivityList.tsx:115
|
||||
#: src/views/card/components/ActivityList.tsx:74
|
||||
msgid "set the due date"
|
||||
msgstr "definiu a data de vencimento"
|
||||
|
||||
@@ -2290,8 +2239,8 @@ msgstr "Plano Team"
|
||||
msgid "Teams"
|
||||
msgstr "Equipes"
|
||||
|
||||
#: src/views/board/index.tsx:390
|
||||
#: src/views/board/index.tsx:425
|
||||
#: src/views/board/index.tsx:386
|
||||
#: src/views/board/index.tsx:421
|
||||
msgid "Template"
|
||||
msgstr "Template"
|
||||
|
||||
@@ -2356,8 +2305,8 @@ msgstr "Eles não poderão acessar este workspace."
|
||||
#: src/components/DeleteLabelConfirmation.tsx:44
|
||||
#: src/views/board/components/DeleteBoardConfirmation.tsx:39
|
||||
#: src/views/card/components/DeleteCardConfirmation.tsx:78
|
||||
#: src/views/card/components/DeleteChecklistConfirmation.tsx:59
|
||||
#: src/views/card/components/DeleteCommentConfirmation.tsx:69
|
||||
#: src/views/card/components/DeleteChecklistConfirmation.tsx:58
|
||||
#: src/views/card/components/DeleteCommentConfirmation.tsx:68
|
||||
msgid "This action can't be undone."
|
||||
msgstr "Essa ação não pode ser desfeita."
|
||||
|
||||
@@ -2365,7 +2314,7 @@ msgstr "Essa ação não pode ser desfeita."
|
||||
msgid "This API key will only be shown once. Please save it in a secure location."
|
||||
msgstr "Esta chave de API será exibida apenas uma vez. Salve-a em um local seguro."
|
||||
|
||||
#: src/views/public/board/index.tsx:169
|
||||
#: src/views/public/board/index.tsx:168
|
||||
msgid "This board is private or does not exist"
|
||||
msgstr "Este quadro é privado ou não existe"
|
||||
|
||||
@@ -2397,10 +2346,6 @@ msgstr "Esta URL de workspace está reservada"
|
||||
msgid "This workspace username has already been taken"
|
||||
msgstr "Este nome de usuário do workspace já está em uso"
|
||||
|
||||
#: src/components/YouTubeEmbed/EditYouTubeModal.tsx:127
|
||||
msgid "Title"
|
||||
msgstr ""
|
||||
|
||||
#: src/views/boards/components/TemplateBoards.tsx:17
|
||||
#: src/views/boards/components/TemplateBoards.tsx:23
|
||||
msgid "To Do"
|
||||
@@ -2439,19 +2384,19 @@ msgstr "Triagem"
|
||||
#~ msgid "Trusted by fast-moving teams"
|
||||
#~ msgstr "Trusted by fast-moving teams"
|
||||
|
||||
#: src/views/home/components/Logos.tsx:67
|
||||
#: src/views/home/components/Logos.tsx:73
|
||||
msgid "Trusted by fast-moving teams<0/>around the world"
|
||||
msgstr "Confiado por equipes ágeis<0/>ao redor do mundo"
|
||||
|
||||
#: src/views/card/components/NewChecklistItemForm.tsx:89
|
||||
#: src/views/card/components/NewChecklistItemForm.tsx:88
|
||||
msgid "Unable to add checklist item"
|
||||
msgstr "Não foi possível adicionar o item da checklist"
|
||||
|
||||
#: src/views/card/components/NewCommentForm.tsx:31
|
||||
#: src/views/card/components/NewCommentForm.tsx:30
|
||||
msgid "Unable to add comment"
|
||||
msgstr "Não foi possível adicionar comentário"
|
||||
|
||||
#: src/views/card/index.tsx:200
|
||||
#: src/views/card/index.tsx:199
|
||||
msgid "Unable to add label"
|
||||
msgstr ""
|
||||
|
||||
@@ -2459,7 +2404,7 @@ msgstr ""
|
||||
msgid "Unable to create card"
|
||||
msgstr "Não foi possível criar o cartão"
|
||||
|
||||
#: src/views/card/components/NewChecklistForm.tsx:70
|
||||
#: src/views/card/components/NewChecklistForm.tsx:69
|
||||
msgid "Unable to create checklist"
|
||||
msgstr "Não foi possível criar a checklist"
|
||||
|
||||
@@ -2476,7 +2421,7 @@ msgstr "Não foi possível criar o template"
|
||||
msgid "Unable to create workspace"
|
||||
msgstr "Não foi possível criar o workspace"
|
||||
|
||||
#: src/views/card/components/AttachmentThumbnails.tsx:73
|
||||
#: src/views/card/components/AttachmentThumbnails.tsx:72
|
||||
msgid "Unable to delete attachment"
|
||||
msgstr "Não foi possível excluir o anexo"
|
||||
|
||||
@@ -2484,15 +2429,15 @@ msgstr "Não foi possível excluir o anexo"
|
||||
msgid "Unable to delete card"
|
||||
msgstr "Não foi possível excluir o cartão"
|
||||
|
||||
#: src/views/card/components/DeleteChecklistConfirmation.tsx:37
|
||||
#: src/views/card/components/DeleteChecklistConfirmation.tsx:36
|
||||
msgid "Unable to delete checklist"
|
||||
msgstr "Não foi possível excluir a checklist"
|
||||
|
||||
#: src/views/card/components/ChecklistItemRow.tsx:96
|
||||
#: src/views/card/components/ChecklistItemRow.tsx:87
|
||||
msgid "Unable to delete checklist item"
|
||||
msgstr "Não foi possível excluir o item da checklist"
|
||||
|
||||
#: src/views/card/components/DeleteCommentConfirmation.tsx:45
|
||||
#: src/views/card/components/DeleteCommentConfirmation.tsx:44
|
||||
msgid "Unable to delete comment"
|
||||
msgstr "Não foi possível excluir o comentário"
|
||||
|
||||
@@ -2500,10 +2445,6 @@ msgstr "Não foi possível excluir o comentário"
|
||||
msgid "Unable to remove member"
|
||||
msgstr "Não foi possível remover o membro"
|
||||
|
||||
#: src/views/card/components/Checklists.tsx:80
|
||||
msgid "Unable to reorder checklist item"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/FeedbackModal.tsx:40
|
||||
msgid "Unable to send feedback"
|
||||
msgstr "Não foi possível enviar o feedback"
|
||||
@@ -2516,42 +2457,42 @@ msgstr "Não foi possível atualizar a URL do quadro"
|
||||
msgid "Unable to update board visibility"
|
||||
msgstr "Não foi possível atualizar a visibilidade do quadro"
|
||||
|
||||
#: src/views/board/index.tsx:226
|
||||
#: src/views/card/index.tsx:187
|
||||
#: src/views/board/index.tsx:229
|
||||
#: src/views/card/index.tsx:186
|
||||
msgid "Unable to update card"
|
||||
msgstr "Não foi possível atualizar o cartão"
|
||||
|
||||
#: src/views/card/components/ChecklistNameInput.tsx:46
|
||||
#: src/views/card/components/ChecklistNameInput.tsx:45
|
||||
msgid "Unable to update checklist"
|
||||
msgstr "Não foi possível atualizar a checklist"
|
||||
|
||||
#: src/views/card/components/ChecklistItemRow.tsx:68
|
||||
#: src/views/card/components/ChecklistItemRow.tsx:59
|
||||
msgid "Unable to update checklist item"
|
||||
msgstr "Não foi possível atualizar o item da checklist"
|
||||
|
||||
#: src/views/card/components/Comment.tsx:64
|
||||
#: src/views/card/components/Comment.tsx:63
|
||||
msgid "Unable to update comment"
|
||||
msgstr "Não foi possível atualizar o comentário"
|
||||
|
||||
#: src/views/card/components/DueDateSelector.tsx:59
|
||||
#: src/views/card/components/DueDateSelector.tsx:58
|
||||
msgid "Unable to update due date"
|
||||
msgstr "Não foi possível atualizar a data de vencimento"
|
||||
|
||||
#: src/views/card/components/LabelSelector.tsx:72
|
||||
#: src/views/card/components/LabelSelector.tsx:71
|
||||
msgid "Unable to update labels"
|
||||
msgstr "Não foi possível atualizar as etiquetas"
|
||||
|
||||
#: src/views/board/index.tsx:170
|
||||
#: src/views/card/components/ListSelector.tsx:52
|
||||
#: src/views/board/index.tsx:173
|
||||
#: src/views/card/components/ListSelector.tsx:51
|
||||
msgid "Unable to update list"
|
||||
msgstr "Não foi possível atualizar a lista"
|
||||
|
||||
#: src/views/card/components/MemberSelector.tsx:79
|
||||
#: src/views/card/components/MemberSelector.tsx:78
|
||||
msgid "Unable to update members"
|
||||
msgstr "Não foi possível atualizar os membros"
|
||||
|
||||
#. placeholder {0}: truncate(memberName)
|
||||
#: src/views/card/components/ActivityList.tsx:156
|
||||
#: src/views/card/components/ActivityList.tsx:121
|
||||
msgid "unassigned <0>{0}</0> from the card"
|
||||
msgstr "removeu a atribuição de <0>{0}</0> do cartão"
|
||||
|
||||
@@ -2559,7 +2500,7 @@ msgstr "removeu a atribuição de <0>{0}</0> do cartão"
|
||||
#~ msgid "unassigned <0>{memberName}</0> from the card"
|
||||
#~ msgstr "unassigned <0>{memberName}</0> from the card"
|
||||
|
||||
#: src/views/card/components/ActivityList.tsx:153
|
||||
#: src/views/card/components/ActivityList.tsx:118
|
||||
msgid "unassigned themselves from the card"
|
||||
msgstr "removeu a própria atribuição do cartão"
|
||||
|
||||
@@ -2618,24 +2559,24 @@ msgstr "Atualizar"
|
||||
msgid "Update label"
|
||||
msgstr "Atualizar etiqueta"
|
||||
|
||||
#: src/views/card/components/ActivityList.tsx:111
|
||||
#: src/views/card/components/ActivityList.tsx:70
|
||||
msgid "updated a checklist item"
|
||||
msgstr "atualizou um item da checklist"
|
||||
|
||||
#: src/views/card/components/ActivityList.tsx:101
|
||||
#: src/views/card/components/ActivityList.tsx:60
|
||||
msgid "updated the description"
|
||||
msgstr "atualizou a descrição"
|
||||
|
||||
#: src/views/card/components/ActivityList.tsx:116
|
||||
#: src/views/card/components/ActivityList.tsx:75
|
||||
msgid "updated the due date"
|
||||
msgstr "atualizou a data de vencimento"
|
||||
|
||||
#: src/views/card/components/ActivityList.tsx:100
|
||||
#: src/views/card/components/ActivityList.tsx:59
|
||||
msgid "updated the title"
|
||||
msgstr "atualizou o título"
|
||||
|
||||
#. placeholder {0}: truncate(toTitle)
|
||||
#: src/views/card/components/ActivityList.tsx:125
|
||||
#: src/views/card/components/ActivityList.tsx:90
|
||||
msgid "updated the title to <0>{0}</0>"
|
||||
msgstr "atualizou o título para <0>{0}</0>"
|
||||
|
||||
@@ -2661,8 +2602,8 @@ msgstr "Fazer upgrade para Pro (US$29/mês)"
|
||||
#~ msgid "Upgrade to Team Plan"
|
||||
#~ msgstr "Upgrade to Team Plan"
|
||||
|
||||
#: src/views/card/components/AttachmentUpload.tsx:33
|
||||
#: src/views/card/components/AttachmentUpload.tsx:79
|
||||
#: src/views/card/components/AttachmentUpload.tsx:32
|
||||
#: src/views/card/components/AttachmentUpload.tsx:78
|
||||
msgid "Upload failed"
|
||||
msgstr "Falha no upload"
|
||||
|
||||
@@ -2715,7 +2656,7 @@ msgstr "Veja e gerencie seu faturamento e assinatura."
|
||||
msgid "View docs"
|
||||
msgstr "Ver documentação"
|
||||
|
||||
#: src/views/public/board/index.tsx:142
|
||||
#: src/views/public/board/index.tsx:141
|
||||
msgid "View only"
|
||||
msgstr "Somente visualização"
|
||||
|
||||
@@ -2723,7 +2664,7 @@ msgstr "Somente visualização"
|
||||
msgid "View our roadmap."
|
||||
msgstr "Veja nosso roadmap."
|
||||
|
||||
#: src/views/public/board/index.tsx:172
|
||||
#: src/views/public/board/index.tsx:171
|
||||
msgid "View workspace"
|
||||
msgstr "Ver workspace"
|
||||
|
||||
@@ -2776,10 +2717,10 @@ msgid "Why make an open source Trello?"
|
||||
msgstr "Por que criar um Trello open source?"
|
||||
|
||||
#: src/components/SettingsLayout.tsx:39
|
||||
#: src/views/board/index.tsx:390
|
||||
#: src/views/board/index.tsx:386
|
||||
#: src/views/boards/index.tsx:33
|
||||
#: src/views/members/index.tsx:173
|
||||
#: src/views/public/board/index.tsx:114
|
||||
#: src/views/public/board/index.tsx:113
|
||||
#: src/views/public/boards/index.tsx:66
|
||||
msgid "Workspace"
|
||||
msgstr "Workspace"
|
||||
@@ -2917,7 +2858,7 @@ msgstr "Seus quadros foram importados."
|
||||
msgid "Your display name has been updated."
|
||||
msgstr "Seu nome de exibição foi atualizado."
|
||||
|
||||
#: src/views/card/components/AttachmentUpload.tsx:27
|
||||
#: src/views/card/components/AttachmentUpload.tsx:26
|
||||
msgid "Your file has been uploaded successfully."
|
||||
msgstr "Seu arquivo foi enviado com sucesso."
|
||||
|
||||
@@ -2956,7 +2897,3 @@ msgstr "O nome do seu workspace foi atualizado."
|
||||
#: src/views/settings/components/UpdateWorkspaceUrlForm.tsx:61
|
||||
msgid "Your workspace slug has been updated."
|
||||
msgstr "O slug do seu workspace foi atualizado."
|
||||
|
||||
#: src/components/YouTubeEmbed/EditYouTubeModal.tsx:147
|
||||
msgid "YouTube URL"
|
||||
msgstr ""
|
||||
|
||||
File diff suppressed because one or more lines are too long
7390
apps/web/src/locales/ru/messages.json
Normal file
7390
apps/web/src/locales/ru/messages.json
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
55
apps/web/src/pages/404.tsx
Normal file
55
apps/web/src/pages/404.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import Link from "next/link";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import PatternedBackground from "~/components/PatternedBackground";
|
||||
|
||||
export default function NotFoundPage() {
|
||||
return (
|
||||
<>
|
||||
<PageHead title={t`404 - Page Not Found | kan.bn`} />
|
||||
<main className="h-screen bg-light-100 pt-20 dark:bg-dark-50 sm:pt-0">
|
||||
<div className="justify-top flex h-full flex-col items-center px-4 sm:justify-center">
|
||||
<div className="z-10 flex w-full flex-col items-center">
|
||||
<Link href="/">
|
||||
<h1 className="mb-6 text-lg font-bold tracking-tight text-light-1000 dark:text-dark-1000">
|
||||
kan.bn
|
||||
</h1>
|
||||
</Link>
|
||||
<p className="mb-4 text-8xl font-bold tracking-tight text-light-1000 dark:text-dark-1000">
|
||||
404
|
||||
</p>
|
||||
<p className="mb-10 text-3xl font-bold tracking-tight text-light-1000 dark:text-dark-1000">
|
||||
<Trans>Page not found</Trans>
|
||||
</p>
|
||||
<div className="w-full rounded-lg border border-light-500 bg-light-300 px-4 py-10 dark:border-dark-400 dark:bg-dark-200 sm:max-w-md lg:px-10">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
|
||||
<p className="mb-6 text-center text-light-900 dark:text-dark-900">
|
||||
<Trans>
|
||||
The page you're looking for doesn't exist or has been moved.
|
||||
</Trans>
|
||||
</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex w-full justify-center rounded-md bg-light-1000 px-3 py-2 text-sm font-semibold text-light-100 shadow-sm hover:bg-light-900 dark:bg-dark-1000 dark:text-dark-100 dark:hover:bg-dark-900"
|
||||
>
|
||||
<Trans>Go to homepage</Trans>
|
||||
</Link>
|
||||
<Link
|
||||
href="/boards"
|
||||
className="flex w-full justify-center rounded-md border border-light-500 bg-light-100 px-3 py-2 text-sm font-semibold text-light-1000 shadow-sm hover:bg-light-200 dark:border-dark-400 dark:bg-dark-100 dark:text-dark-1000 dark:hover:bg-dark-300"
|
||||
>
|
||||
<Trans>Go to boards</Trans>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PatternedBackground />
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import posthog from "posthog-js";
|
||||
import { PostHogProvider } from "posthog-js/react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { FontSizeProvider } from "~/providers/font-size";
|
||||
import { KeyboardShortcutProvider } from "~/providers/keyboard-shortcuts";
|
||||
import { LinguiProviderWrapper } from "~/providers/lingui";
|
||||
import { ModalProvider } from "~/providers/modal";
|
||||
@@ -83,19 +84,21 @@ const MyApp: AppType = ({ Component, pageProps }: AppPropsWithLayout) => {
|
||||
<main className="font-sans">
|
||||
<KeyboardShortcutProvider>
|
||||
<LinguiProviderWrapper>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<ModalProvider>
|
||||
<PopupProvider>
|
||||
{posthogKey ? (
|
||||
<PostHogProvider client={posthog}>
|
||||
{getLayout(<Component {...pageProps} />)}
|
||||
</PostHogProvider>
|
||||
) : (
|
||||
getLayout(<Component {...pageProps} />)
|
||||
)}
|
||||
</PopupProvider>
|
||||
</ModalProvider>
|
||||
</ThemeProvider>
|
||||
<FontSizeProvider>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<ModalProvider>
|
||||
<PopupProvider>
|
||||
{posthogKey ? (
|
||||
<PostHogProvider client={posthog}>
|
||||
{getLayout(<Component {...pageProps} />)}
|
||||
</PostHogProvider>
|
||||
) : (
|
||||
getLayout(<Component {...pageProps} />)
|
||||
)}
|
||||
</PopupProvider>
|
||||
</ModalProvider>
|
||||
</ThemeProvider>
|
||||
</FontSizeProvider>
|
||||
</LinguiProviderWrapper>
|
||||
</KeyboardShortcutProvider>
|
||||
</main>
|
||||
|
||||
@@ -2,9 +2,34 @@ 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) => {
|
||||
/**
|
||||
* Better-auth behind proxies (Nginx/Cloudflare) can sometimes fail to parse the protocol
|
||||
* if headers are incorrectly set or if there are multiple values in X-Forwarded-Proto.
|
||||
* We sanitize these headers here to ensure better-auth gets a clean protocol and host.
|
||||
*/
|
||||
const forwardedProto = req.headers["x-forwarded-proto"];
|
||||
if (forwardedProto) {
|
||||
const p = Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto;
|
||||
req.headers["x-forwarded-proto"] = p?.split(",")[0]?.trim();
|
||||
}
|
||||
|
||||
const forwardedHost = req.headers["x-forwarded-host"];
|
||||
if (forwardedHost) {
|
||||
const h = Array.isArray(forwardedHost) ? forwardedHost[0] : forwardedHost;
|
||||
req.headers["host"] = h?.split(",")[0]?.trim();
|
||||
}
|
||||
|
||||
return await authHandler(req, res);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,47 +1,75 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
if (req.method !== "GET") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
|
||||
const { url, filename } = req.query;
|
||||
import { env } from "~/env";
|
||||
|
||||
if (!url || typeof url !== "string") {
|
||||
return res.status(400).json({
|
||||
message: "url parameter is required",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const downloadFilename = typeof filename === "string"
|
||||
? encodeURIComponent(filename)
|
||||
: "attachment";
|
||||
|
||||
const upstream = await fetch(url);
|
||||
|
||||
if (!upstream.ok) {
|
||||
return res.status(upstream.status).json({
|
||||
message: "Failed to fetch attachment",
|
||||
});
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method !== "GET") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
|
||||
const contentType =
|
||||
upstream.headers.get("Content-Type") ?? "application/octet-stream";
|
||||
const { url, filename } = req.query;
|
||||
|
||||
res.setHeader("Content-Type", contentType);
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="${downloadFilename}"; filename*=UTF-8''${downloadFilename}`,
|
||||
);
|
||||
if (!url || typeof url !== "string") {
|
||||
return res.status(400).json({ message: "url parameter is required" });
|
||||
}
|
||||
|
||||
const buffer = await upstream.arrayBuffer();
|
||||
return res.send(Buffer.from(buffer));
|
||||
} catch (error) {
|
||||
console.error("Error downloading attachment:", error);
|
||||
return res.status(500).json({ message: "Failed to download attachment" });
|
||||
}
|
||||
}
|
||||
const s3Endpoint = env.S3_ENDPOINT;
|
||||
|
||||
if (s3Endpoint) {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return res.status(400).json({ message: "Invalid URL" });
|
||||
}
|
||||
|
||||
const hostname = parsed.hostname.toLowerCase();
|
||||
let allowedHost: string;
|
||||
try {
|
||||
allowedHost = new URL(s3Endpoint).hostname.toLowerCase();
|
||||
} catch {
|
||||
return res
|
||||
.status(500)
|
||||
.json({ message: "Storage endpoint misconfigured" });
|
||||
}
|
||||
|
||||
if (hostname !== allowedHost && !hostname.endsWith(`.${allowedHost}`)) {
|
||||
return res.status(403).json({ message: "URL not allowed" });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const downloadFilename =
|
||||
typeof filename === "string"
|
||||
? encodeURIComponent(filename)
|
||||
: "attachment";
|
||||
|
||||
const upstream = await fetch(url);
|
||||
|
||||
if (!upstream.ok) {
|
||||
return res
|
||||
.status(upstream.status)
|
||||
.json({ message: "Failed to fetch attachment" });
|
||||
}
|
||||
|
||||
const contentType =
|
||||
upstream.headers.get("Content-Type") ?? "application/octet-stream";
|
||||
|
||||
res.setHeader("Content-Type", contentType);
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="${downloadFilename}"; filename*=UTF-8''${downloadFilename}`,
|
||||
);
|
||||
|
||||
const buffer = await upstream.arrayBuffer();
|
||||
return res.send(Buffer.from(buffer));
|
||||
} catch (error) {
|
||||
return res.status(500).json({ message: "Failed to download attachment" });
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
if (req.method !== "GET") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
|
||||
try {
|
||||
const response = await fetch("https://formbricks.com/api/oss-friends");
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch from Formbricks");
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method !== "GET") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
return res.status(200).json(data);
|
||||
} catch (error) {
|
||||
console.error("Error fetching OSS friends:", error);
|
||||
return res.status(500).json({ message: "Failed to fetch OSS friends" });
|
||||
}
|
||||
}
|
||||
try {
|
||||
const response = await fetch("https://formbricks.com/api/oss-friends");
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch from Formbricks");
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
return res.status(200).json(data);
|
||||
} catch (error) {
|
||||
return res.status(500).json({ message: "Failed to fetch OSS friends" });
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
19
apps/web/src/pages/api/partner/_utils.ts
Normal file
19
apps/web/src/pages/api/partner/_utils.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export type WorkspacePlan = "free" | "team" | "pro" | "enterprise";
|
||||
|
||||
export interface TierConfig {
|
||||
plan: WorkspacePlan;
|
||||
seats: number | null;
|
||||
unlimitedSeats: boolean;
|
||||
}
|
||||
|
||||
const TIER_MAP: Record<number, TierConfig> = {
|
||||
1: { plan: "team", seats: 5, unlimitedSeats: false },
|
||||
2: { plan: "pro", seats: 15, unlimitedSeats: false },
|
||||
3: { plan: "pro", seats: null, unlimitedSeats: true },
|
||||
};
|
||||
|
||||
export function tierConfig(tier: number): TierConfig {
|
||||
const config = TIER_MAP[tier];
|
||||
if (!config) throw new Error(`Unknown partner tier: ${tier}`);
|
||||
return config;
|
||||
}
|
||||
168
apps/web/src/pages/api/partner/callback.ts
Normal file
168
apps/web/src/pages/api/partner/callback.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import { createLogger } from "@kan/logger";
|
||||
|
||||
import { tierConfig } from "./_utils";
|
||||
|
||||
const log = createLogger("api");
|
||||
|
||||
interface TokenResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
}
|
||||
|
||||
interface OAuthLicenseResponse {
|
||||
license_key: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface LicenseDetailResponse {
|
||||
license_key: string;
|
||||
status: string;
|
||||
tier: number;
|
||||
}
|
||||
|
||||
async function exchangeCodeForToken(code: string): Promise<TokenResponse> {
|
||||
const tokenUrl = process.env.PARTNER_TOKEN_URL;
|
||||
const clientId = process.env.PARTNER_CLIENT_ID;
|
||||
const clientSecret = process.env.PARTNER_CLIENT_SECRET;
|
||||
const redirectUrl = process.env.PARTNER_REDIRECT_URL;
|
||||
if (!tokenUrl) throw new Error("PARTNER_TOKEN_URL not configured");
|
||||
if (!clientId) throw new Error("PARTNER_CLIENT_ID not configured");
|
||||
if (!clientSecret) throw new Error("PARTNER_CLIENT_SECRET not configured");
|
||||
if (!redirectUrl) throw new Error("PARTNER_REDIRECT_URL not configured");
|
||||
|
||||
const res = await fetch(tokenUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
redirect_uri: redirectUrl,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`Token exchange failed: ${res.status}`);
|
||||
return res.json() as Promise<TokenResponse>;
|
||||
}
|
||||
|
||||
async function fetchOAuthLicense(
|
||||
accessToken: string,
|
||||
): Promise<OAuthLicenseResponse> {
|
||||
const oauthLicenseUrl = process.env.PARTNER_OAUTH_LICENSE_URL;
|
||||
if (!oauthLicenseUrl)
|
||||
throw new Error("PARTNER_OAUTH_LICENSE_URL not configured");
|
||||
|
||||
const res = await fetch(`${oauthLicenseUrl}?access_token=${accessToken}`);
|
||||
if (!res.ok) throw new Error(`OAuth license fetch failed: ${res.status}`);
|
||||
return res.json() as Promise<OAuthLicenseResponse>;
|
||||
}
|
||||
|
||||
async function fetchLicenseDetail(
|
||||
licenseKey: string,
|
||||
): Promise<LicenseDetailResponse> {
|
||||
const licenseApiUrl = process.env.PARTNER_LICENSE_API_URL;
|
||||
const apiKey = process.env.PARTNER_API_KEY;
|
||||
if (!licenseApiUrl) throw new Error("PARTNER_LICENSE_API_URL not configured");
|
||||
if (!apiKey) throw new Error("PARTNER_API_KEY not configured");
|
||||
|
||||
const apiKeyHeader = process.env.PARTNER_API_KEY_HEADER;
|
||||
if (!apiKeyHeader) throw new Error("PARTNER_API_KEY_HEADER not configured");
|
||||
|
||||
const res = await fetch(`${licenseApiUrl}/${licenseKey}`, {
|
||||
headers: { [apiKeyHeader]: apiKey },
|
||||
});
|
||||
if (!res.ok) throw new Error(`License detail fetch failed: ${res.status}`);
|
||||
return res.json() as Promise<LicenseDetailResponse>;
|
||||
}
|
||||
|
||||
export default withRateLimit(
|
||||
{ points: 20, duration: 60 },
|
||||
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method !== "GET") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
|
||||
const { code } = req.query;
|
||||
|
||||
if (!code || typeof code !== "string") {
|
||||
return res.status(200).json({ message: "OK" });
|
||||
}
|
||||
|
||||
let license: LicenseDetailResponse;
|
||||
|
||||
try {
|
||||
const tokenData = await exchangeCodeForToken(code);
|
||||
const oauthLicense = await fetchOAuthLicense(tokenData.access_token);
|
||||
license = await fetchLicenseDetail(oauthLicense.license_key);
|
||||
} catch (err) {
|
||||
log.error({ err }, "Partner OAuth flow failed");
|
||||
return res.redirect(`/partner/activate?error=oauth_failed`);
|
||||
}
|
||||
|
||||
const { db, user } = await createNextApiContext(req);
|
||||
|
||||
const cfg = tierConfig(license.tier);
|
||||
const isActive = license.status === "active";
|
||||
const status = isActive ? "active" : "inactive";
|
||||
|
||||
if (!user) {
|
||||
await subscriptionRepo.upsertByPartnerLicenseKey(
|
||||
db,
|
||||
license.license_key,
|
||||
{
|
||||
plan: cfg.plan,
|
||||
status,
|
||||
partnerTier: license.tier,
|
||||
seats: cfg.seats,
|
||||
unlimitedSeats: cfg.unlimitedSeats,
|
||||
},
|
||||
);
|
||||
return res.redirect(
|
||||
`/partner/activate?license_key=${encodeURIComponent(license.license_key)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const memberships = await workspaceRepo.getAllByUserId(db, user.id);
|
||||
const workspace = memberships?.[0]?.workspace;
|
||||
|
||||
if (!workspace) {
|
||||
await subscriptionRepo.upsertByPartnerLicenseKey(
|
||||
db,
|
||||
license.license_key,
|
||||
{
|
||||
plan: cfg.plan,
|
||||
status,
|
||||
partnerTier: license.tier,
|
||||
seats: cfg.seats,
|
||||
unlimitedSeats: cfg.unlimitedSeats,
|
||||
},
|
||||
);
|
||||
return res.redirect(
|
||||
`/onboarding/workspace?license_key=${encodeURIComponent(license.license_key)}`,
|
||||
);
|
||||
}
|
||||
|
||||
await subscriptionRepo.upsertByPartnerLicenseKey(db, license.license_key, {
|
||||
plan: cfg.plan,
|
||||
status,
|
||||
partnerTier: license.tier,
|
||||
seats: cfg.seats,
|
||||
unlimitedSeats: cfg.unlimitedSeats,
|
||||
referenceId: workspace.publicId,
|
||||
});
|
||||
|
||||
if (isActive) {
|
||||
await workspaceRepo.update(db, workspace.publicId, { plan: cfg.plan });
|
||||
}
|
||||
|
||||
return res.redirect(`/?partner_activated=1`);
|
||||
}),
|
||||
);
|
||||
64
apps/web/src/pages/api/partner/link.ts
Normal file
64
apps/web/src/pages/api/partner/link.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
|
||||
export default withRateLimit(
|
||||
{ points: 20, duration: 60 },
|
||||
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method !== "GET") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
|
||||
const { license_key } = req.query;
|
||||
|
||||
if (!license_key || typeof license_key !== "string") {
|
||||
return res.redirect("/boards?partner_error=missing_license");
|
||||
}
|
||||
|
||||
const { db, user } = await createNextApiContext(req);
|
||||
|
||||
if (!user) {
|
||||
return res.redirect(
|
||||
`/login?next=${encodeURIComponent(`/api/partner/link?license_key=${license_key}`)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const sub = await subscriptionRepo.getByPartnerLicenseKey(db, license_key);
|
||||
|
||||
if (!sub) {
|
||||
return res.redirect("/boards?partner_error=invalid_license");
|
||||
}
|
||||
|
||||
if (sub.status !== "active") {
|
||||
return res.redirect("/boards?partner_error=license_inactive");
|
||||
}
|
||||
|
||||
const memberships = await workspaceRepo.getAllByUserId(db, user.id);
|
||||
const workspace = memberships?.[0]?.workspace;
|
||||
|
||||
if (!workspace) {
|
||||
return res.redirect(
|
||||
`/onboarding/workspace?license_key=${encodeURIComponent(license_key)}`,
|
||||
);
|
||||
}
|
||||
|
||||
await subscriptionRepo.upsertByPartnerLicenseKey(db, license_key, {
|
||||
plan: sub.plan,
|
||||
status: sub.status,
|
||||
partnerTier: sub.partnerTier ?? 1,
|
||||
seats: sub.seats ?? null,
|
||||
unlimitedSeats: sub.unlimitedSeats,
|
||||
referenceId: workspace.publicId,
|
||||
});
|
||||
|
||||
await workspaceRepo.update(db, workspace.publicId, {
|
||||
plan: sub.plan as "free" | "team" | "pro" | "enterprise",
|
||||
});
|
||||
|
||||
return res.redirect("/boards?partner_activated=1");
|
||||
}),
|
||||
);
|
||||
212
apps/web/src/pages/api/partner/webhook.ts
Normal file
212
apps/web/src/pages/api/partner/webhook.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { createHmac, timingSafeEqual } from "crypto";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import type { Readable } from "node:stream";
|
||||
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import { createLogger } from "@kan/logger";
|
||||
import { getActiveSubscriptions } from "@kan/shared/utils";
|
||||
|
||||
import { tierConfig } from "./_utils";
|
||||
|
||||
const log = createLogger("api");
|
||||
|
||||
async function buffer(readable: Readable) {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of readable) {
|
||||
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
function verifySignature(
|
||||
rawBody: string,
|
||||
signature: string,
|
||||
timestamp: string,
|
||||
): boolean {
|
||||
const secret = process.env.PARTNER_API_KEY;
|
||||
if (!secret) return false;
|
||||
|
||||
const ts = Number(timestamp);
|
||||
if (isNaN(ts) || Date.now() - ts > 300_000) return false;
|
||||
|
||||
const payload = `${timestamp}${rawBody}`;
|
||||
const expected = createHmac("sha256", secret).update(payload).digest("hex");
|
||||
|
||||
try {
|
||||
return timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
interface WebhookPayload {
|
||||
event:
|
||||
| "purchase"
|
||||
| "activate"
|
||||
| "deactivate"
|
||||
| "upgrade"
|
||||
| "downgrade"
|
||||
| "migrate";
|
||||
license_key: string;
|
||||
license_status: string;
|
||||
tier: number;
|
||||
prev_license_key?: string;
|
||||
}
|
||||
|
||||
export default withApiLogging(
|
||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
|
||||
const signatureHeader = process.env.PARTNER_SIGNATURE_HEADER;
|
||||
const timestampHeader = process.env.PARTNER_TIMESTAMP_HEADER;
|
||||
|
||||
if (!signatureHeader || !timestampHeader) {
|
||||
log.error("Webhook signature headers not configured");
|
||||
return res.status(500).json({ message: "Server misconfiguration" });
|
||||
}
|
||||
|
||||
const signature = req.headers[signatureHeader] as string | undefined;
|
||||
const timestamp = req.headers[timestampHeader] as string | undefined;
|
||||
|
||||
if (!signature || !timestamp) {
|
||||
return res.status(400).json({ message: "Missing signature headers" });
|
||||
}
|
||||
|
||||
const buf = await buffer(req as unknown as Readable);
|
||||
const rawBody = buf.toString("utf8");
|
||||
|
||||
if (!verifySignature(rawBody, signature, timestamp)) {
|
||||
return res.status(401).json({ message: "Invalid signature" });
|
||||
}
|
||||
|
||||
const payload = JSON.parse(rawBody) as WebhookPayload;
|
||||
const { event, license_key, license_status, tier, prev_license_key } =
|
||||
payload;
|
||||
|
||||
log.info({ headers: req.headers, payload }, "partner webhook received");
|
||||
|
||||
const { db } = await createNextApiContext(req);
|
||||
|
||||
switch (event) {
|
||||
case "purchase": {
|
||||
const cfg = tierConfig(tier);
|
||||
await subscriptionRepo.upsertByPartnerLicenseKey(db, license_key, {
|
||||
plan: cfg.plan,
|
||||
status: license_status,
|
||||
partnerTier: tier,
|
||||
seats: cfg.seats,
|
||||
unlimitedSeats: cfg.unlimitedSeats,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "activate": {
|
||||
const cfg = tierConfig(tier);
|
||||
await subscriptionRepo.upsertByPartnerLicenseKey(db, license_key, {
|
||||
plan: cfg.plan,
|
||||
status: "active",
|
||||
partnerTier: tier,
|
||||
seats: cfg.seats,
|
||||
unlimitedSeats: cfg.unlimitedSeats,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "deactivate": {
|
||||
const sub = await subscriptionRepo.getByPartnerLicenseKey(
|
||||
db,
|
||||
license_key,
|
||||
);
|
||||
if (sub) {
|
||||
const [, allSubs] = await Promise.all([
|
||||
subscriptionRepo.updateById(db, sub.id, {
|
||||
plan: "free",
|
||||
status: "inactive",
|
||||
}),
|
||||
sub.referenceId
|
||||
? subscriptionRepo.getByReferenceId(db, sub.referenceId)
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
if (sub.referenceId) {
|
||||
const hasActiveSub = getActiveSubscriptions(allSubs).some(
|
||||
(s) => s.id !== sub.id,
|
||||
);
|
||||
if (!hasActiveSub) {
|
||||
await workspaceRepo.update(db, sub.referenceId, { plan: "free" });
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "upgrade":
|
||||
case "downgrade": {
|
||||
const lookupKey = prev_license_key ?? license_key;
|
||||
const sub = await subscriptionRepo.getByPartnerLicenseKey(
|
||||
db,
|
||||
lookupKey,
|
||||
);
|
||||
if (sub) {
|
||||
const cfg = tierConfig(tier);
|
||||
await subscriptionRepo.upsertByPartnerLicenseKey(db, license_key, {
|
||||
plan: cfg.plan,
|
||||
status: "active",
|
||||
partnerTier: tier,
|
||||
seats: cfg.seats,
|
||||
unlimitedSeats: cfg.unlimitedSeats,
|
||||
referenceId: sub.referenceId ?? undefined,
|
||||
});
|
||||
if (prev_license_key) {
|
||||
await subscriptionRepo.updateById(db, sub.id, {
|
||||
status: "inactive",
|
||||
});
|
||||
}
|
||||
if (sub.referenceId) {
|
||||
await workspaceRepo.update(db, sub.referenceId, { plan: cfg.plan });
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "migrate": {
|
||||
if (prev_license_key) {
|
||||
const sub = await subscriptionRepo.getByPartnerLicenseKey(
|
||||
db,
|
||||
prev_license_key,
|
||||
);
|
||||
if (sub) {
|
||||
const cfg = tierConfig(tier);
|
||||
await subscriptionRepo.upsertByPartnerLicenseKey(db, license_key, {
|
||||
plan: cfg.plan,
|
||||
status: "active",
|
||||
partnerTier: tier,
|
||||
seats: cfg.seats,
|
||||
unlimitedSeats: cfg.unlimitedSeats,
|
||||
referenceId: sub.referenceId ?? undefined,
|
||||
});
|
||||
await subscriptionRepo.updateById(db, sub.id, {
|
||||
status: "inactive",
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
log.warn({ event }, "Unhandled partner webhook event");
|
||||
}
|
||||
|
||||
return res.status(200).json({ success: true, event });
|
||||
},
|
||||
);
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
@@ -2,33 +2,34 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { env } from "next-runtime-env";
|
||||
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
import { createStripeClient } from "@kan/stripe";
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
const stripe = createStripeClient();
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const stripe = createStripeClient();
|
||||
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ error: "Method not allowed" });
|
||||
}
|
||||
|
||||
try {
|
||||
const { user } = await createNextApiContext(req);
|
||||
|
||||
if (!user?.stripeCustomerId) {
|
||||
return res.status(404).json({ error: "No billing account found" });
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ error: "Method not allowed" });
|
||||
}
|
||||
|
||||
const session = await stripe.billingPortal.sessions.create({
|
||||
customer: user.stripeCustomerId,
|
||||
return_url: `${env("NEXT_PUBLIC_BASE_URL")}/settings`,
|
||||
});
|
||||
try {
|
||||
const { user } = await createNextApiContext(req);
|
||||
|
||||
return res.status(200).json({ url: session.url });
|
||||
} catch (error) {
|
||||
console.error("Error:", error);
|
||||
return res.status(500).json({ error: "Error creating portal session" });
|
||||
}
|
||||
}
|
||||
if (!user?.stripeCustomerId) {
|
||||
return res.status(404).json({ error: "No billing account found" });
|
||||
}
|
||||
|
||||
const session = await stripe.billingPortal.sessions.create({
|
||||
customer: user.stripeCustomerId,
|
||||
return_url: `${env("NEXT_PUBLIC_BASE_URL")}/settings`,
|
||||
});
|
||||
|
||||
return res.status(200).json({ url: session.url });
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: "Error creating portal session" });
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -3,35 +3,41 @@ import { env } from "next-runtime-env";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||
import { assertPermission } from "@kan/api/utils/permissions";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import { generateUID } from "@kan/shared/utils";
|
||||
import { createStripeClient } from "@kan/stripe";
|
||||
|
||||
const workspaceSlugSchema = z
|
||||
.string()
|
||||
.min(3)
|
||||
.max(24)
|
||||
.max(64)
|
||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/);
|
||||
|
||||
interface CheckoutSessionRequest {
|
||||
successUrl: string;
|
||||
cancelUrl: string;
|
||||
slug: string;
|
||||
workspacePublicId: string;
|
||||
stripeCustomerId: string;
|
||||
plan: "team" | "pro";
|
||||
billing?: string;
|
||||
workspacePublicId?: string;
|
||||
slug?: string;
|
||||
workspaceName?: string;
|
||||
workspaceDescription?: string;
|
||||
workspaceSlug?: string;
|
||||
}
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
const stripe = createStripeClient();
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const stripe = createStripeClient();
|
||||
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ error: "Method not allowed" });
|
||||
}
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ error: "Method not allowed" });
|
||||
}
|
||||
|
||||
try {
|
||||
const { user, db } = await createNextApiContext(req);
|
||||
|
||||
if (!user) {
|
||||
@@ -39,80 +45,107 @@ export default async function handler(
|
||||
}
|
||||
|
||||
const body = req.body as CheckoutSessionRequest;
|
||||
const { successUrl, cancelUrl, slug, workspacePublicId } = body;
|
||||
const {
|
||||
successUrl,
|
||||
cancelUrl,
|
||||
plan,
|
||||
billing,
|
||||
workspacePublicId,
|
||||
slug,
|
||||
workspaceName,
|
||||
workspaceDescription,
|
||||
workspaceSlug,
|
||||
} = body;
|
||||
|
||||
if (!successUrl || !cancelUrl || !workspacePublicId) {
|
||||
if (!successUrl || !cancelUrl || !plan) {
|
||||
return res.status(400).json({ error: "Missing required fields" });
|
||||
}
|
||||
|
||||
if (slug) {
|
||||
const slugResult = workspaceSlugSchema.safeParse(slug);
|
||||
if (!workspacePublicId && !workspaceName) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Must provide workspacePublicId or workspaceName" });
|
||||
}
|
||||
|
||||
const resolvedSlug = slug ?? workspaceSlug;
|
||||
|
||||
if (resolvedSlug) {
|
||||
const slugResult = workspaceSlugSchema.safeParse(resolvedSlug);
|
||||
if (!slugResult.success) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Invalid workspace slug" }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
return res.status(400).json({ error: "Invalid workspace slug" });
|
||||
}
|
||||
}
|
||||
|
||||
const workspace = await workspaceRepo.getAllByUserId(db, user.id);
|
||||
let resolvedWorkspacePublicId = workspacePublicId;
|
||||
let subscriptionId: number | undefined;
|
||||
|
||||
const isMemberOfWorkspace = workspace.some(
|
||||
({ workspace }) => workspace.publicId === body.workspacePublicId,
|
||||
);
|
||||
if (workspacePublicId) {
|
||||
// Existing workspace upgrade
|
||||
const workspace = await workspaceRepo.getByPublicId(
|
||||
db,
|
||||
workspacePublicId,
|
||||
);
|
||||
|
||||
if (!isMemberOfWorkspace) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 403,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
if (!workspace) {
|
||||
return res.status(404).json({ error: "Workspace not found" });
|
||||
}
|
||||
|
||||
try {
|
||||
await assertPermission(db, user.id, workspace.id, "workspace:manage");
|
||||
} catch {
|
||||
return res.status(403).json({ error: "Unauthorized" });
|
||||
}
|
||||
|
||||
const subscription = await subscriptionRepo.create(db, {
|
||||
plan: plan,
|
||||
referenceId: workspacePublicId,
|
||||
userId: user.id,
|
||||
stripeCustomerId: user.stripeCustomerId ?? "",
|
||||
status: "incomplete",
|
||||
});
|
||||
|
||||
subscriptionId = subscription?.id;
|
||||
|
||||
if (!subscriptionId) {
|
||||
return res.status(500).json({ error: "Error creating subscription" });
|
||||
}
|
||||
} else {
|
||||
resolvedWorkspacePublicId = generateUID();
|
||||
}
|
||||
|
||||
const subscription = await subscriptionRepo.create(db, {
|
||||
plan: "pro",
|
||||
referenceId: workspacePublicId,
|
||||
userId: user.id,
|
||||
stripeCustomerId: user.stripeCustomerId ?? "",
|
||||
status: "incomplete",
|
||||
});
|
||||
|
||||
const subscriptionId = subscription?.id;
|
||||
|
||||
if (!subscriptionId) {
|
||||
return res.status(500).json({ error: "Error creating subscription" });
|
||||
}
|
||||
const isTeam = body.plan === "team";
|
||||
const annualPriceId = isTeam
|
||||
? (process.env.STRIPE_TEAM_PLAN_YEARLY_PRICE_ID ??
|
||||
process.env.STRIPE_TEAM_PLAN_MONTHLY_PRICE_ID)
|
||||
: (process.env.STRIPE_PRO_PLAN_YEARLY_PRICE_ID ??
|
||||
process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID);
|
||||
const monthlyPriceId = isTeam
|
||||
? process.env.STRIPE_TEAM_PLAN_MONTHLY_PRICE_ID
|
||||
: process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID;
|
||||
const priceId = billing === "annual" ? annualPriceId : monthlyPriceId;
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: "subscription",
|
||||
payment_method_collection: "always",
|
||||
line_items: [
|
||||
{
|
||||
price: process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
subscription_data: {
|
||||
trial_period_days: 14,
|
||||
},
|
||||
success_url: `${env("NEXT_PUBLIC_BASE_URL")}${successUrl}`,
|
||||
line_items: [{ price: priceId, quantity: 1 }],
|
||||
subscription_data: { trial_period_days: 14 },
|
||||
success_url: `${env("NEXT_PUBLIC_BASE_URL")}${successUrl}?workspacePublicId=${resolvedWorkspacePublicId}`,
|
||||
cancel_url: `${env("NEXT_PUBLIC_BASE_URL")}${cancelUrl}`,
|
||||
client_reference_id: workspacePublicId,
|
||||
client_reference_id: resolvedWorkspacePublicId,
|
||||
customer: user.stripeCustomerId ?? undefined,
|
||||
metadata: {
|
||||
...(slug && { workspaceSlug: slug }),
|
||||
workspacePublicId,
|
||||
workspacePublicId: resolvedWorkspacePublicId!,
|
||||
userId: user.id,
|
||||
subscriptionId,
|
||||
userEmail: user.email ?? "",
|
||||
...(resolvedSlug && { workspaceSlug: resolvedSlug }),
|
||||
...(workspaceName && { workspaceName }),
|
||||
...(workspaceDescription && { workspaceDescription }),
|
||||
...(subscriptionId && { subscriptionId: String(subscriptionId) }),
|
||||
plan: plan,
|
||||
isNewWorkspace: workspaceName ? "true" : "false",
|
||||
},
|
||||
});
|
||||
|
||||
return res.status(200).json({ url: session.url });
|
||||
} catch (error) {
|
||||
console.error("Error:", error);
|
||||
return res.status(500).json({ error: "Error creating checkout session" });
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import type { Readable } from "node:stream";
|
||||
|
||||
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 { createLogger } from "@kan/logger";
|
||||
import { createStripeClient } from "@kan/stripe";
|
||||
|
||||
const log = createLogger("api");
|
||||
|
||||
async function buffer(readable: Readable) {
|
||||
const chunks = [];
|
||||
for await (const chunk of readable) {
|
||||
@@ -18,6 +23,9 @@ export default async function handler(
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
const stripe = createStripeClient();
|
||||
const start = Date.now();
|
||||
const requestId = randomUUID();
|
||||
const procedure = req.url?.split("?")[0] ?? "/api/stripe/webhook";
|
||||
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
@@ -44,24 +52,85 @@ export default async function handler(
|
||||
switch (event.type) {
|
||||
case "checkout.session.completed": {
|
||||
const checkoutSession = event.data.object;
|
||||
const meta = checkoutSession.metadata;
|
||||
|
||||
const metaData = checkoutSession.metadata;
|
||||
if (!meta?.workspacePublicId) break;
|
||||
|
||||
if (metaData?.workspacePublicId) {
|
||||
await workspaceRepo.update(db, metaData.workspacePublicId, {
|
||||
...(metaData.workspaceSlug && { slug: metaData.workspaceSlug }),
|
||||
plan: "pro",
|
||||
const plan = meta.plan === "team" ? "team" : ("pro" as const);
|
||||
|
||||
if (
|
||||
meta.isNewWorkspace === "true" &&
|
||||
meta.workspaceName &&
|
||||
meta.userId &&
|
||||
meta.userEmail
|
||||
) {
|
||||
const existing = await workspaceRepo.getByPublicId(
|
||||
db,
|
||||
meta.workspacePublicId,
|
||||
);
|
||||
|
||||
if (!existing) {
|
||||
const slug = meta.workspaceSlug ?? meta.workspacePublicId;
|
||||
|
||||
await workspaceRepo.create(db, {
|
||||
publicId: meta.workspacePublicId,
|
||||
name: meta.workspaceName,
|
||||
slug,
|
||||
plan,
|
||||
createdBy: meta.userId,
|
||||
createdByEmail: meta.userEmail,
|
||||
...(meta.workspaceDescription && {
|
||||
description: meta.workspaceDescription,
|
||||
}),
|
||||
});
|
||||
|
||||
await subscriptionRepo.create(db, {
|
||||
plan,
|
||||
referenceId: meta.workspacePublicId,
|
||||
userId: meta.userId,
|
||||
stripeCustomerId: checkoutSession.customer as string,
|
||||
status: "active",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await workspaceRepo.update(db, meta.workspacePublicId, {
|
||||
plan,
|
||||
...(plan === "pro" &&
|
||||
meta.workspaceSlug && { slug: meta.workspaceSlug }),
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.log(`Unhandled event type: ${event.type}`);
|
||||
log.warn({ eventType: event.type }, "Unhandled Stripe event type");
|
||||
}
|
||||
|
||||
log.info(
|
||||
{
|
||||
requestId,
|
||||
procedure,
|
||||
transport: "rest",
|
||||
duration: Date.now() - start,
|
||||
status: 200,
|
||||
input: { eventType: event.type, eventId: event.id },
|
||||
},
|
||||
"API OK",
|
||||
);
|
||||
|
||||
return res.status(200).json({ received: true });
|
||||
} catch (err) {
|
||||
console.error("Webhook error:", err);
|
||||
log.error(
|
||||
{
|
||||
requestId,
|
||||
procedure,
|
||||
transport: "rest",
|
||||
duration: Date.now() - start,
|
||||
status: 400,
|
||||
err,
|
||||
},
|
||||
"API error",
|
||||
);
|
||||
return res.status(400).json({ message: "Webhook handler failed" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,51 +1,58 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
import { integrations } from "@kan/db/schema";
|
||||
import { addYears } from "date-fns";
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
import { integrations } from "@kan/db/schema";
|
||||
|
||||
const { user } = await createNextApiContext(req);
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
|
||||
if (!user)
|
||||
return res.status(401).json({ message: "User not authenticated" });
|
||||
const { user } = await createNextApiContext(req);
|
||||
|
||||
const apiKey = process.env.TRELLO_APP_API_KEY;
|
||||
if (!user)
|
||||
return res.status(401).json({ message: "User not authenticated" });
|
||||
|
||||
if (!apiKey)
|
||||
return res.status(500).json({ message: "Trello API key not set in Environment Variables" });
|
||||
const apiKey = process.env.TRELLO_APP_API_KEY;
|
||||
|
||||
const token = req.body.token;
|
||||
if (!apiKey)
|
||||
return res
|
||||
.status(500)
|
||||
.json({ message: "Trello API key not set in Environment Variables" });
|
||||
|
||||
if (!token)
|
||||
return res.status(400).json({ message: "No token found" });
|
||||
const token = req.body.token;
|
||||
|
||||
try {
|
||||
const { db } = await createNextApiContext(req);
|
||||
if (!token) return res.status(400).json({ message: "No token found" });
|
||||
|
||||
await db.insert(integrations).values({
|
||||
provider: "trello",
|
||||
userId: user.id,
|
||||
accessToken: token,
|
||||
expiresAt: addYears(new Date(), 1),
|
||||
}).onConflictDoUpdate({
|
||||
set: {
|
||||
accessToken: token,
|
||||
expiresAt: addYears(new Date(), 1),
|
||||
},
|
||||
target: [integrations.userId, integrations.provider],
|
||||
});
|
||||
try {
|
||||
const { db } = await createNextApiContext(req);
|
||||
|
||||
return res.status(200).json({ message: "Trello authentication successful" });
|
||||
} catch (err) {
|
||||
console.error("Trello authentication error:", err);
|
||||
return res.status(400).json({ message: "Trello authentication failed" });
|
||||
}
|
||||
}
|
||||
await db
|
||||
.insert(integrations)
|
||||
.values({
|
||||
provider: "trello",
|
||||
userId: user.id,
|
||||
accessToken: token,
|
||||
expiresAt: addYears(new Date(), 1),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
set: {
|
||||
accessToken: token,
|
||||
expiresAt: addYears(new Date(), 1),
|
||||
},
|
||||
target: [integrations.userId, integrations.provider],
|
||||
});
|
||||
|
||||
return res
|
||||
.status(200)
|
||||
.json({ message: "Trello authentication successful" });
|
||||
} catch (err) {
|
||||
return res.status(400).json({ message: "Trello authentication failed" });
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,6 +3,9 @@ import { Novu } from "@novu/api";
|
||||
import { jwtVerify } from "jose";
|
||||
import { z } from "zod";
|
||||
|
||||
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
|
||||
import { env } from "~/env";
|
||||
|
||||
const requestSchema = z.object({
|
||||
@@ -19,86 +22,87 @@ type ResponseData =
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<ResponseData>,
|
||||
) {
|
||||
if (process.env.NEXT_PUBLIC_KAN_ENV !== "cloud") {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: "Unsubscribe endpoint is not available.",
|
||||
code: "UNAVAILABLE",
|
||||
});
|
||||
}
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
withApiLogging(
|
||||
async (req: NextApiRequest, res: NextApiResponse<ResponseData>) => {
|
||||
if (process.env.NEXT_PUBLIC_KAN_ENV !== "cloud") {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: "Unsubscribe endpoint is not available.",
|
||||
code: "UNAVAILABLE",
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method !== "POST") {
|
||||
res.setHeader("Allow", "POST");
|
||||
return res.status(405).json({
|
||||
success: false,
|
||||
error: "Method not allowed.",
|
||||
code: "METHOD_NOT_ALLOWED",
|
||||
});
|
||||
}
|
||||
if (req.method !== "POST") {
|
||||
res.setHeader("Allow", "POST");
|
||||
return res.status(405).json({
|
||||
success: false,
|
||||
error: "Method not allowed.",
|
||||
code: "METHOD_NOT_ALLOWED",
|
||||
});
|
||||
}
|
||||
|
||||
const parsedBody = requestSchema.safeParse(req.body);
|
||||
const parsedBody = requestSchema.safeParse(req.body);
|
||||
|
||||
if (!parsedBody.success) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "Invalid request payload.",
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
}
|
||||
if (!parsedBody.success) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "Invalid request payload.",
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
}
|
||||
|
||||
if (!env.EMAIL_UNSUBSCRIBE_SECRET || !env.NOVU_API_KEY) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: "Unsubscribe service is not configured.",
|
||||
code: "NOT_CONFIGURED",
|
||||
});
|
||||
}
|
||||
if (!env.EMAIL_UNSUBSCRIBE_SECRET || !env.NOVU_API_KEY) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: "Unsubscribe service is not configured.",
|
||||
code: "NOT_CONFIGURED",
|
||||
});
|
||||
}
|
||||
|
||||
let payload: z.infer<typeof tokenPayloadSchema>;
|
||||
let payload: z.infer<typeof tokenPayloadSchema>;
|
||||
|
||||
try {
|
||||
const verified = await jwtVerify(
|
||||
parsedBody.data.token,
|
||||
textEncoder.encode(env.EMAIL_UNSUBSCRIBE_SECRET),
|
||||
{
|
||||
// We intentionally do not use exp/iat claims –
|
||||
// tokens are long-lived and validated only by signature + payload.
|
||||
clockTolerance: "0s",
|
||||
},
|
||||
);
|
||||
payload = tokenPayloadSchema.parse(verified.payload);
|
||||
} catch {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: "Your unsubscribe link is invalid or has expired.",
|
||||
code: "INVALID_TOKEN",
|
||||
});
|
||||
}
|
||||
try {
|
||||
const verified = await jwtVerify(
|
||||
parsedBody.data.token,
|
||||
textEncoder.encode(env.EMAIL_UNSUBSCRIBE_SECRET),
|
||||
{
|
||||
// We intentionally do not use exp/iat claims –
|
||||
// tokens are long-lived and validated only by signature + payload.
|
||||
clockTolerance: "0s",
|
||||
},
|
||||
);
|
||||
payload = tokenPayloadSchema.parse(verified.payload);
|
||||
} catch {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: "Your unsubscribe link is invalid or has expired.",
|
||||
code: "INVALID_TOKEN",
|
||||
});
|
||||
}
|
||||
|
||||
const novu = new Novu({ secretKey: env.NOVU_API_KEY });
|
||||
const novu = new Novu({ secretKey: env.NOVU_API_KEY });
|
||||
|
||||
try {
|
||||
await novu.subscribers.preferences.update(
|
||||
{
|
||||
channels: {
|
||||
email: false,
|
||||
},
|
||||
},
|
||||
payload.subscriberId,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to update Novu preferences", error);
|
||||
return res.status(502).json({
|
||||
success: false,
|
||||
error:
|
||||
"We could not update your email preferences right now. Please try again later.",
|
||||
code: "NOVU_ERROR",
|
||||
});
|
||||
}
|
||||
try {
|
||||
await novu.subscribers.preferences.update(
|
||||
{
|
||||
channels: {
|
||||
email: false,
|
||||
},
|
||||
},
|
||||
payload.subscriberId,
|
||||
);
|
||||
} catch (error) {
|
||||
return res.status(502).json({
|
||||
success: false,
|
||||
error:
|
||||
"We could not update your email preferences right now. Please try again later.",
|
||||
code: "NOVU_ERROR",
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json({ success: true });
|
||||
}
|
||||
return res.status(200).json({ success: true });
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
140
apps/web/src/pages/api/upload/attachment.ts
Normal file
140
apps/web/src/pages/api/upload/attachment.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { Upload } from "@aws-sdk/lib-storage";
|
||||
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||
import { assertPermission } from "@kan/api/utils/permissions";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
import * as cardRepo from "@kan/db/repository/card.repo";
|
||||
import * as cardActivityRepo from "@kan/db/repository/cardActivity.repo";
|
||||
import * as cardAttachmentRepo from "@kan/db/repository/cardAttachment.repo";
|
||||
import { createS3Client, generateUID } from "@kan/shared/utils";
|
||||
|
||||
import { env } from "~/env";
|
||||
|
||||
// FIXME: Respect the environment variable: NEXT_API_BODY_SIZE_LIMIT
|
||||
const MAX_SIZE_BYTES = 50 * 1024 * 1024; // 50MB
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ error: "Method not allowed" });
|
||||
}
|
||||
|
||||
try {
|
||||
const { user, db } = await createNextApiContext(req);
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: "Unauthorized" });
|
||||
}
|
||||
|
||||
const bucket = env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME;
|
||||
if (!bucket) {
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: "Attachments bucket not configured" });
|
||||
}
|
||||
|
||||
const cardPublicId = req.query.cardPublicId;
|
||||
if (typeof cardPublicId !== "string" || cardPublicId.length < 12) {
|
||||
return res.status(400).json({ error: "Invalid cardPublicId" });
|
||||
}
|
||||
|
||||
const contentType = req.headers["content-type"];
|
||||
const contentLengthHeader = req.headers["content-length"];
|
||||
const contentLength = contentLengthHeader
|
||||
? Number.parseInt(contentLengthHeader, 10)
|
||||
: NaN;
|
||||
|
||||
if (typeof contentType !== "string") {
|
||||
return res.status(400).json({ error: "Missing content type" });
|
||||
}
|
||||
|
||||
if (!Number.isFinite(contentLength) || contentLength <= 0) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Missing or invalid content length" });
|
||||
}
|
||||
|
||||
if (contentLength > MAX_SIZE_BYTES) {
|
||||
return res.status(400).json({ error: "File too large" });
|
||||
}
|
||||
|
||||
const originalFilenameHeader =
|
||||
(req.headers["x-original-filename"] as string | undefined) ?? "file";
|
||||
|
||||
const sanitizedFilename = originalFilenameHeader
|
||||
.replace(/[^a-zA-Z0-9._-]/g, "_")
|
||||
.substring(0, 200);
|
||||
|
||||
// Get card and check permissions
|
||||
const card = await cardRepo.getWorkspaceAndCardIdByCardPublicId(
|
||||
db,
|
||||
cardPublicId,
|
||||
);
|
||||
|
||||
if (!card) {
|
||||
return res.status(404).json({ error: "Card not found" });
|
||||
}
|
||||
|
||||
// Check if user has permission to edit the card
|
||||
try {
|
||||
await assertPermission(db, user.id, card.workspaceId, "card:edit");
|
||||
} catch {
|
||||
return res.status(403).json({ error: "Permission denied" });
|
||||
}
|
||||
|
||||
const s3Key = `${card.workspaceId}/${cardPublicId}/${generateUID()}-${sanitizedFilename}`;
|
||||
|
||||
const client = createS3Client();
|
||||
|
||||
const upload = new Upload({
|
||||
client,
|
||||
params: {
|
||||
Bucket: bucket,
|
||||
Key: s3Key,
|
||||
Body: req,
|
||||
ContentType: contentType,
|
||||
ContentLength: contentLength,
|
||||
},
|
||||
leavePartsOnError: false,
|
||||
});
|
||||
|
||||
await upload.done();
|
||||
|
||||
// Create attachment record and log activity
|
||||
const attachment = await cardAttachmentRepo.create(db, {
|
||||
cardId: card.id,
|
||||
filename: sanitizedFilename,
|
||||
originalFilename: originalFilenameHeader,
|
||||
contentType,
|
||||
size: contentLength,
|
||||
s3Key,
|
||||
createdBy: user.id,
|
||||
});
|
||||
|
||||
if (!attachment) {
|
||||
return res.status(500).json({ error: "Failed to create attachment" });
|
||||
}
|
||||
|
||||
await cardActivityRepo.create(db, {
|
||||
type: "card.updated.attachment.added",
|
||||
cardId: card.id,
|
||||
attachmentId: attachment.id,
|
||||
toTitle: originalFilenameHeader,
|
||||
createdBy: user.id,
|
||||
});
|
||||
|
||||
return res.status(200).json({ attachment });
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
}),
|
||||
);
|
||||
105
apps/web/src/pages/api/upload/avatar.ts
Normal file
105
apps/web/src/pages/api/upload/avatar.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { PutObjectCommand } from "@aws-sdk/client-s3";
|
||||
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
import * as userRepo from "@kan/db/repository/user.repo";
|
||||
import { createS3Client } from "@kan/shared/utils";
|
||||
|
||||
import { env } from "~/env";
|
||||
|
||||
const MAX_SIZE_BYTES = parseInt(
|
||||
process.env.S3_AVATAR_UPLOAD_LIMIT || "2097152",
|
||||
10,
|
||||
); // Default 2MB
|
||||
const allowedContentTypes = ["image/jpeg", "image/png", "image/webp"];
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ error: "Method not allowed" });
|
||||
}
|
||||
|
||||
try {
|
||||
const { user, db } = await createNextApiContext(req);
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: "Unauthorized" });
|
||||
}
|
||||
|
||||
const bucket = env.NEXT_PUBLIC_AVATAR_BUCKET_NAME;
|
||||
if (!bucket) {
|
||||
return res.status(500).json({ error: "Avatar bucket not configured" });
|
||||
}
|
||||
|
||||
const contentType = req.headers["content-type"];
|
||||
const contentLengthHeader = req.headers["content-length"];
|
||||
const contentLength = contentLengthHeader
|
||||
? Number.parseInt(contentLengthHeader, 10)
|
||||
: NaN;
|
||||
|
||||
if (typeof contentType !== "string") {
|
||||
return res.status(400).json({ error: "Missing content type" });
|
||||
}
|
||||
|
||||
if (!allowedContentTypes.includes(contentType)) {
|
||||
return res.status(400).json({ error: "Invalid content type" });
|
||||
}
|
||||
|
||||
if (!Number.isFinite(contentLength) || contentLength <= 0) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Missing or invalid content length" });
|
||||
}
|
||||
|
||||
if (contentLength > MAX_SIZE_BYTES) {
|
||||
return res.status(400).json({ error: "File too large" });
|
||||
}
|
||||
|
||||
const originalFilenameHeader =
|
||||
(req.headers["x-original-filename"] as string | undefined) ?? "file";
|
||||
|
||||
const sanitizedFilename = originalFilenameHeader
|
||||
.replace(/[^a-zA-Z0-9._-]/g, "_")
|
||||
.substring(0, 200);
|
||||
|
||||
const s3Key = `${user.id}/${sanitizedFilename}`;
|
||||
|
||||
const client = createS3Client();
|
||||
|
||||
// Upload the file to S3
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: s3Key,
|
||||
Body: req,
|
||||
ContentType: contentType,
|
||||
ContentLength: contentLength,
|
||||
}),
|
||||
);
|
||||
|
||||
// Update user image in database
|
||||
const updatedUser = await userRepo.update(db, user.id, {
|
||||
image: s3Key,
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
key: s3Key,
|
||||
filename: sanitizedFilename,
|
||||
contentType,
|
||||
size: contentLength,
|
||||
user: updatedUser,
|
||||
});
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -1,74 +0,0 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
import { env as nextRuntimeEnv } from "next-runtime-env";
|
||||
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
|
||||
import { env } from "~/env";
|
||||
|
||||
const allowedContentTypes = ["image/jpeg", "image/png"];
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ error: "Method not allowed" });
|
||||
}
|
||||
|
||||
try {
|
||||
const { user } = await createNextApiContext(req);
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: "Unauthorized" });
|
||||
}
|
||||
|
||||
const { filename, contentType } = req.body as {
|
||||
filename: string;
|
||||
contentType: string;
|
||||
};
|
||||
|
||||
// Specific to avatar uploads for now
|
||||
const filenameRegex = /^[a-f0-9\-]+\/[a-zA-Z0-9_\-]+(\.jpg|\.jpeg|\.png)$/;
|
||||
|
||||
if (!filenameRegex.test(filename)) {
|
||||
return res.status(400).json({ error: "Invalid filename" });
|
||||
}
|
||||
|
||||
if (
|
||||
typeof contentType !== "string" ||
|
||||
!allowedContentTypes.includes(contentType)
|
||||
) {
|
||||
return res.status(400).json({ error: "Invalid content type" });
|
||||
}
|
||||
|
||||
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 signedUrl = await getSignedUrl(
|
||||
client,
|
||||
new PutObjectCommand({
|
||||
Bucket: nextRuntimeEnv("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "",
|
||||
Key: filename,
|
||||
ACL: "public-read",
|
||||
}),
|
||||
);
|
||||
|
||||
return res.status(200).json({ url: signedUrl, key: filename });
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
31
apps/web/src/pages/onboarding/select-plan.tsx
Normal file
31
apps/web/src/pages/onboarding/select-plan.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { env } from "next-runtime-env";
|
||||
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import SelectPlanView from "~/views/onboarding/select-plan";
|
||||
|
||||
export default function SelectPlanPage() {
|
||||
const router = useRouter();
|
||||
const { data: session, isPending } = authClient.useSession();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPending && !session?.user) {
|
||||
router.push("/login");
|
||||
}
|
||||
if (!isPending && env("NEXT_PUBLIC_KAN_ENV") !== "cloud") {
|
||||
router.push("/boards");
|
||||
}
|
||||
}, [session, isPending, router]);
|
||||
|
||||
if (isPending || !session?.user) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title="Select plan | kan.bn" />
|
||||
<SelectPlanView />
|
||||
</>
|
||||
);
|
||||
}
|
||||
28
apps/web/src/pages/onboarding/workspace.tsx
Normal file
28
apps/web/src/pages/onboarding/workspace.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import WorkspaceDetailsView from "~/views/onboarding/workspace-details";
|
||||
|
||||
export default function WorkspaceDetailsPage() {
|
||||
const router = useRouter();
|
||||
const { data: session, isPending } = authClient.useSession();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPending && !session?.user) router.push("/login");
|
||||
if (!isPending && env("NEXT_PUBLIC_KAN_ENV") !== "cloud")
|
||||
router.push("/boards");
|
||||
}, [session, isPending, router]);
|
||||
|
||||
if (isPending || !session?.user) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title="Create workspace | kan.bn" />
|
||||
<WorkspaceDetailsView />
|
||||
</>
|
||||
);
|
||||
}
|
||||
117
apps/web/src/pages/partner/activate.tsx
Normal file
117
apps/web/src/pages/partner/activate.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import { Auth } from "~/components/AuthForm";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import PatternedBackground from "~/components/PatternedBackground";
|
||||
|
||||
export default function PartnerActivatePage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const licenseKey = searchParams.get("license_key");
|
||||
const error = searchParams.get("error");
|
||||
|
||||
const partnerName = env("NEXT_PUBLIC_PARTNER_NAME");
|
||||
|
||||
const { data: session, isPending } = authClient.useSession();
|
||||
const [isMagicLinkSent, setIsMagicLinkSent] = useState(false);
|
||||
const [magicLinkRecipient, setMagicLinkRecipient] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (licenseKey) {
|
||||
localStorage.setItem("partnerLicenseKey", licenseKey);
|
||||
}
|
||||
}, [licenseKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPending && session?.user && licenseKey) {
|
||||
router.push(
|
||||
`/api/partner/link?license_key=${encodeURIComponent(licenseKey)}`,
|
||||
);
|
||||
} else if (!isPending && session?.user && !licenseKey) {
|
||||
router.push("/boards");
|
||||
}
|
||||
}, [session, isPending, licenseKey, router]);
|
||||
|
||||
if (isPending || (session?.user && licenseKey)) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={t`Activate | kan.bn`} />
|
||||
<main className="h-screen bg-light-100 pt-20 dark:bg-dark-50 sm:pt-0">
|
||||
<div className="justify-top flex h-full flex-col items-center px-4 sm:justify-center">
|
||||
<div className="z-10 flex w-full flex-col items-center">
|
||||
<Link href="/">
|
||||
<h1 className="mb-6 text-lg font-bold tracking-tight text-light-1000 dark:text-dark-1000">
|
||||
kan.bn
|
||||
</h1>
|
||||
</Link>
|
||||
<p className="mb-2 text-3xl font-bold tracking-tight text-light-1000 dark:text-dark-1000">
|
||||
{isMagicLinkSent ? t`Check your inbox` : t`Activate your account`}
|
||||
</p>
|
||||
<p className="mb-10 text-sm text-light-800 dark:text-dark-800">
|
||||
{isMagicLinkSent ? (
|
||||
<Trans>We sent a link to {magicLinkRecipient}</Trans>
|
||||
) : partnerName ? (
|
||||
<Trans>
|
||||
Sign in or create an account to activate your {partnerName}{" "}
|
||||
license
|
||||
</Trans>
|
||||
) : (
|
||||
t`Sign in or create an account to activate your license`
|
||||
)}
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 w-full rounded-md bg-red-50 px-4 py-3 text-sm text-red-700 dark:bg-red-900/20 dark:text-red-400 sm:max-w-md">
|
||||
{t`Something went wrong during activation. Please try again.`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isMagicLinkSent && (
|
||||
<div className="w-full rounded-lg border border-light-500 bg-light-300 px-4 py-10 dark:border-dark-400 dark:bg-dark-200 sm:max-w-md lg:px-10">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
|
||||
<Auth
|
||||
setIsMagicLinkSent={(val, recipient) => {
|
||||
setIsMagicLinkSent(val);
|
||||
setMagicLinkRecipient(recipient);
|
||||
}}
|
||||
callbackURL={
|
||||
licenseKey
|
||||
? `/api/partner/link?license_key=${encodeURIComponent(licenseKey)}`
|
||||
: "/boards"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-4 text-sm text-light-1000 dark:text-dark-1000">
|
||||
<Trans>
|
||||
Already have an account?{" "}
|
||||
<span className="underline">
|
||||
<Link
|
||||
href={
|
||||
licenseKey
|
||||
? `/login?next=${encodeURIComponent(`/api/partner/link?license_key=${licenseKey}`)}`
|
||||
: "/login"
|
||||
}
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</span>
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<PatternedBackground />
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,11 +2,13 @@ import type { NextPageWithLayout } from "~/pages/_app";
|
||||
import { getDashboardLayout } from "~/components/Dashboard";
|
||||
import { SettingsLayout } from "~/components/SettingsLayout";
|
||||
import ApiSettings from "~/views/settings/ApiSettings";
|
||||
import Popup from "~/components/Popup";
|
||||
|
||||
const ApiSettingsPage: NextPageWithLayout = () => {
|
||||
return (
|
||||
<SettingsLayout currentTab="api">
|
||||
<ApiSettings />
|
||||
<Popup />
|
||||
</SettingsLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,11 +2,13 @@ import type { NextPageWithLayout } from "~/pages/_app";
|
||||
import { getDashboardLayout } from "~/components/Dashboard";
|
||||
import { SettingsLayout } from "~/components/SettingsLayout";
|
||||
import BillingSettings from "~/views/settings/BillingSettings";
|
||||
import Popup from "~/components/Popup";
|
||||
|
||||
const BillingSettingsPage: NextPageWithLayout = () => {
|
||||
return (
|
||||
<SettingsLayout currentTab="billing">
|
||||
<BillingSettings />
|
||||
<Popup />
|
||||
</SettingsLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,11 +2,13 @@ import type { NextPageWithLayout } from "~/pages/_app";
|
||||
import { getDashboardLayout } from "~/components/Dashboard";
|
||||
import { SettingsLayout } from "~/components/SettingsLayout";
|
||||
import IntegrationsSettings from "~/views/settings/IntegrationsSettings";
|
||||
import Popup from "~/components/Popup";
|
||||
|
||||
const IntegrationsSettingsPage: NextPageWithLayout = () => {
|
||||
return (
|
||||
<SettingsLayout currentTab="integrations">
|
||||
<IntegrationsSettings />
|
||||
<Popup />
|
||||
</SettingsLayout>
|
||||
);
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user