Compare commits
23 Commits
v0.5.2
...
feat/virtu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
575526a341 | ||
|
|
befe7ab7f4 | ||
|
|
53a33c68fc | ||
|
|
3bae03613d | ||
|
|
c42ee7cf2c | ||
|
|
07f997c3ad | ||
|
|
d084d323bc | ||
|
|
6729c2e228 | ||
|
|
b18ef10313 | ||
|
|
03bd2d771b | ||
|
|
ff7944a4a2 | ||
|
|
0ca9c1d0e6 | ||
|
|
927bf2fe69 | ||
|
|
ccd84385a2 | ||
|
|
58c5e92155 | ||
|
|
885f119404 | ||
|
|
b17a24455a | ||
|
|
0c9561467d | ||
|
|
47b6f06be4 | ||
|
|
89c8961176 | ||
|
|
a5432c60b7 | ||
|
|
2b8fe3d3a2 | ||
|
|
73c9b326a1 |
@@ -15,4 +15,4 @@ pnpm-debug.log
|
||||
|
||||
README.md
|
||||
.next
|
||||
.git
|
||||
# .git
|
||||
@@ -32,15 +32,23 @@ 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
|
||||
|
||||
# OAuth providers (optional)
|
||||
BETTER_AUTH_TRUSTED_ORIGINS=
|
||||
# Optional: Restrict OIDC/Social sign-ins to specific email domains (comma-separated)
|
||||
|
||||
40
.github/workflows/docker-publish.yml
vendored
40
.github/workflows/docker-publish.yml
vendored
@@ -34,6 +34,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# Install the cosign tool except on PR
|
||||
# https://github.com/sigstore/cosign-installer
|
||||
@@ -74,6 +76,42 @@ jobs:
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
# Extract version from git tag or ref
|
||||
# Uses git describe to get latest tag + commit hash in SemVer format: 1.2.3+abc1234
|
||||
- name: Extract version
|
||||
id: version
|
||||
run: |
|
||||
if [[ "${{ github.ref_type }}" == "tag" ]]; then
|
||||
VERSION="${{ github.ref_name }}"
|
||||
# Remove 'v' prefix if present
|
||||
VERSION="${VERSION#v}"
|
||||
else
|
||||
# Use git describe and simplify: v1.2.3-5-gabc1234 -> 1.2.3+abc1234
|
||||
GIT_DESCRIBE=$(git describe --tags --always --long 2>/dev/null || echo "")
|
||||
if [[ -n "$GIT_DESCRIBE" ]]; then
|
||||
# Match pattern: v1.2.3-5-gabc1234 (tag-commits-gcommit)
|
||||
if [[ "$GIT_DESCRIBE" =~ ^v?([0-9]+\.[0-9]+\.[0-9]+)-[0-9]+-g([a-f0-9]+)$ ]]; then
|
||||
# Format as tag+commit (SemVer build metadata)
|
||||
TAG_VERSION="${BASH_REMATCH[1]}"
|
||||
COMMIT_HASH="${BASH_REMATCH[2]}"
|
||||
VERSION="${TAG_VERSION}+${COMMIT_HASH:0:7}"
|
||||
elif [[ "$GIT_DESCRIBE" =~ ^v?([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
|
||||
# Exactly on a tag
|
||||
VERSION="${BASH_REMATCH[1]}"
|
||||
else
|
||||
# Fallback: just commit hash
|
||||
COMMIT_SHA="${{ github.sha }}"
|
||||
VERSION="${COMMIT_SHA:0:7}"
|
||||
fi
|
||||
else
|
||||
# No tags exist, use commit hash
|
||||
COMMIT_SHA="${{ github.sha }}"
|
||||
VERSION="${COMMIT_SHA:0:7}"
|
||||
fi
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Version: $VERSION"
|
||||
|
||||
# Build and push Docker image with Buildx (don't push on PR)
|
||||
# https://github.com/docker/build-push-action
|
||||
- name: Build and push Docker image
|
||||
@@ -86,6 +124,8 @@ jobs:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
APP_VERSION=${{ steps.version.outputs.version }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
|
||||
315
AGENTS.md
Normal file
315
AGENTS.md
Normal file
@@ -0,0 +1,315 @@
|
||||
# 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
|
||||
- Show popup notifications for user-facing errors
|
||||
|
||||
## 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
|
||||
|
||||
## 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
|
||||
@@ -141,6 +141,7 @@ pnpm dev
|
||||
| Variable | Description | Required | Example |
|
||||
| ----------------------------------------- | --------------------------------------------------------- | ------------------------------------- | ----------------------------------------------------------- |
|
||||
| `POSTGRES_URL` | PostgreSQL connection URL | To use external database | `postgres://user:pass@localhost:5432/db` |
|
||||
| `REDIS_URL` | Redis connection URL | For rate limiting (optional) | `redis://localhost:6379` or `redis://redis:6379` (Docker) |
|
||||
| `EMAIL_FROM` | Sender email address | For Email | `"Kan <hello@mail.kan.bn>"` |
|
||||
| `SMTP_HOST` | SMTP server hostname | For Email | `smtp.resend.com` |
|
||||
| `SMTP_PORT` | SMTP server port | For Email | `465` |
|
||||
@@ -150,6 +151,7 @@ pnpm dev
|
||||
| `SMTP_REJECT_UNAUTHORIZED` | Reject invalid certificates (defaults to true if not set) | For Email | `false` |
|
||||
| `NEXT_PUBLIC_DISABLE_EMAIL` | To disable all email features | For Email | `true` |
|
||||
| `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` |
|
||||
| `NEXT_API_BODY_SIZE_LIMIT` | Maximum API request body size (defaults to 1mb) | No | `50mb` |
|
||||
| `BETTER_AUTH_ALLOWED_DOMAINS` | Comma-separated list of allowed domains for OIDC logins | For OIDC/Social login | `example.com,subsidiary.com` |
|
||||
| `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
|
||||
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | No | `http://localhost:3000,http://localhost:3001` |
|
||||
@@ -171,6 +173,7 @@ pnpm dev
|
||||
| `S3_FORCE_PATH_STYLE` | Use path-style URLs for S3 | For file uploads | `true` |
|
||||
| `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` |
|
||||
|
||||
@@ -19,43 +19,45 @@ RUN pnpm config set store-dir ~/.pnpm-store
|
||||
|
||||
# 2. Prune projects
|
||||
FROM base AS pruner
|
||||
# https://stackoverflow.com/questions/49681984/how-to-get-version-value-of-package-json-inside-of-dockerfile
|
||||
# RUN export VERSION=$(npm run version)
|
||||
|
||||
ARG PROJECT
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache git
|
||||
|
||||
# It might be the path to <ROOT> turborepo
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
|
||||
# Generate a partial monorepo with a pruned lockfile for a target workspace.
|
||||
# Assuming "@acme/nextjs" is the name entered in the project's package.json: { name: "@acme/nextjs" }
|
||||
|
||||
# Generate version from git (fallback if APP_VERSION not provided)
|
||||
RUN git fetch --tags --unshallow 2>/dev/null || git fetch --tags 2>/dev/null || true && \
|
||||
AUTO_VERSION=$(git describe --tags --always --long 2>/dev/null | \
|
||||
sed -E 's/^v?([0-9]+\.[0-9]+\.[0-9]+)-[0-9]+-g([a-f0-9]{7}).*/\1+\2/' | \
|
||||
sed 's/^v//' || \
|
||||
git rev-parse --short HEAD 2>/dev/null | head -c 7 || \
|
||||
echo "unknown") && \
|
||||
echo "$AUTO_VERSION" > /app/AUTO_VERSION
|
||||
|
||||
RUN turbo prune --scope=${PROJECT} --scope=@kan/db --docker
|
||||
|
||||
# 3. Build the project
|
||||
FROM base AS builder
|
||||
ARG PROJECT
|
||||
|
||||
# Environment to skip .env validation on build
|
||||
ENV CI=true
|
||||
ARG APP_VERSION
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy lockfile and package.json's of isolated subworkspace
|
||||
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
COPY --from=pruner /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
|
||||
COPY --from=pruner /app/out/json/ .
|
||||
COPY --from=pruner /app/AUTO_VERSION /tmp/AUTO_VERSION
|
||||
|
||||
ENV CI=true
|
||||
|
||||
# First install the dependencies (as they change less often)
|
||||
RUN --mount=type=cache,id=pnpm,target=~/.pnpm-store pnpm install --frozen-lockfile
|
||||
|
||||
# Copy source code of isolated subworkspace
|
||||
COPY --from=pruner /app/out/full/ .
|
||||
|
||||
|
||||
RUN pnpm build --filter=${PROJECT}
|
||||
# Use provided APP_VERSION or auto-generated from pruner stage
|
||||
RUN VERSION="${APP_VERSION:-$(cat /tmp/AUTO_VERSION 2>/dev/null | tr -d '\n\r' || echo 'unknown')}" && \
|
||||
NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build --filter=${PROJECT}
|
||||
|
||||
# # Copy static files to standalone directory
|
||||
# RUN mkdir -p apps/web/.next/standalone/.next && \
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"version": 0,
|
||||
"locale": {
|
||||
"source": "en",
|
||||
"targets": ["fr", "de", "es", "it", "nl", "ru", "pl", "ptbr"]
|
||||
"targets": ["fr", "de", "es", "it", "nl", "ru", "pl", "pt-BR"]
|
||||
},
|
||||
"buckets": {
|
||||
"po": {
|
||||
|
||||
@@ -39,6 +39,7 @@ checksums:
|
||||
Adjust%20the%20square%20crop%20to%20fit%20your%20avatar./singular: a4df26bbce6f14c6962fac1324db00a8
|
||||
Admin%20roles/singular: 32a5d78073b9bb9a246773afba8831df
|
||||
All%20systems%20operational/singular: ee943a4046b09e6334cceeea9fda2bfc
|
||||
Allow%20workspace%20members%20to%20see%20each%20other's%20email%20addresses/singular: 0077436d9f37bfd64f3ae076a5a05040
|
||||
Already%20have%20an%20account%3F%20%3C0%3E%3C1%3ESign%20in%3C%2F1%3E%3C%2F0%3E/singular: 2959fd276248208b65cb27ed46b20135
|
||||
An%20error%20occurred%20while%20disconnecting%20your%20Trello%20account./singular: 0aa3973b860c1faf8d9123aebf567e40
|
||||
An%20unexpected%20error%20occurred.%20Please%20try%20again%20later./singular: 1b5749b0cca6a62d75a577bee6804074
|
||||
@@ -123,6 +124,7 @@ checksums:
|
||||
Continue%20with%20/singular: 8ed03cf7c5e60a6edf3470a4558ff058
|
||||
Continue%20with%20%7B0%7D/singular: 2eaf6e1da91e208f7c5fb6bf862fe8a6
|
||||
Control%20who%20can%20view%20and%20edit%20your%20boards./singular: 2a7e0bec29bac26280de707e2fe8bce5
|
||||
Convert%20to%20link/singular: 66210d2889031426c07f0b2c6c4c09d7
|
||||
Core%20features/singular: da95932e7a1465a5d21aa3b855a46dc2
|
||||
Create%20%7B0%7D/singular: d37c29be6ccc0eb6062237f8508c3178
|
||||
Create%20another/singular: 2de8a82a416eb78c0462aa36278edc9a
|
||||
@@ -183,14 +185,18 @@ checksums:
|
||||
Due%20next%20week/singular: 2f8fac5719df18d25a466ee913dc6487
|
||||
Due%20today/singular: a14cb9dd0003485894d328bb803c80e5
|
||||
Due%20tomorrow/singular: 0b6c8ad7aba0873b7e212d5f1949ca97
|
||||
Edit/singular: eee7f39ff90b18852afc1671f21fbaa9
|
||||
Edit%20board%20URL/singular: d8276dfc0189f371ec7d80a2047c07aa
|
||||
Edit%20comment/singular: 7e4b46525fcb6b47b71798e31c46e374
|
||||
Edit%20label/singular: 0309e0be1512b1e0b0ceb87c69a53d03
|
||||
Edit%20workspace%20URL/singular: bbae5f2f8a442947d33099979bbbe899
|
||||
Edit%20YouTube%20Video/singular: 4899d9e990d291eb6e71ee40a8ee314b
|
||||
Editing/singular: 3449a7988cd69207b7c6929af1f4abf1
|
||||
email/singular: f31eb214738e037d58e26149797739df
|
||||
Email/singular: e7f34943a0c2fb849db1839ff6ef5cb5
|
||||
Email%20visibility/singular: 81d41cf573a7109c376d30d905beb596
|
||||
Enhancement/singular: 785fe23c0eef0a5b60b5b2a88151de31
|
||||
Enter%20a%20custom%20title/singular: f002074db0bd51d4f28d2736e140370a
|
||||
Enter%20your%20current%20password/singular: bfceabde4c0b6f2cb439015b76549651
|
||||
Enter%20your%20current%20password%20and%20choose%20a%20new%20secure%20password./singular: 9bb88155b18e98ea799c0e939d16af64
|
||||
Enter%20your%20email%20address/singular: 9bc008365ebe3e404e241c8ca876f56e
|
||||
@@ -220,6 +226,7 @@ checksums:
|
||||
Failed%20to%20accept%20invitation.%20Please%20try%20again%20later%2C%20or%20contact%20customer%20support./singular: e4505a9df3a81e93a8a8b103c6e3ebc4
|
||||
Failed%20to%20copy%20invite%20link/singular: 635884d5ed8d6ee20b85a003939b4ae7
|
||||
Failed%20to%20create%20board/singular: a746e2afe881c3bf0a8e82a931ca3495
|
||||
Failed%20to%20fetch%20video%20information/singular: 1e3fd610e8ed3e6c4c66e6ce88fc8b7a
|
||||
Failed%20to%20login%20with%20%7B0%7D.%20Please%20try%20again./singular: 669a4b4247a73f53fb9b8b16e42d166f
|
||||
Failed%20to%20upload%20attachment.%20Please%20try%20again./singular: f8a50d1c8491404f73d3cf701e11f297
|
||||
FAQ/singular: 47e0ee2eb40b4e7e732e05e2233fc71c
|
||||
@@ -391,6 +398,7 @@ checksums:
|
||||
Please%20enter%20a%20valid%20email%20address/singular: 8de4bc8832b11b380bc4cbcedc16e48b
|
||||
Please%20enter%20a%20valid%20name/singular: f2d741f1b5cae722e35cb5206786f932
|
||||
Please%20enter%20a%20valid%20password/singular: 4b32c17e19b79bcbf0bb092c06ba310f
|
||||
Please%20enter%20a%20valid%20YouTube%20URL/singular: c16c69c3b742b1e19148378d50adf37f
|
||||
Please%20select%20a%20file%20to%20upload./singular: de315bf594047f8ef9307a7fa9285844
|
||||
Please%20try%20again%20later%2C%20or%20contact%20customer%20support./singular: 21ffcf0b00e7cd7b64f7454a95762e1d
|
||||
Please%20try%20again%20later./singular: 325dea6dd0348a27a6818db2c1340c98
|
||||
@@ -496,6 +504,7 @@ checksums:
|
||||
This%20workspace%20URL%20has%20already%20been%20taken/singular: b455329e2a71da677acab91d3a00bad6
|
||||
This%20workspace%20URL%20is%20reserved/singular: 7e47c892b93d4334c1606010c06e875e
|
||||
This%20workspace%20username%20has%20already%20been%20taken/singular: b7eadb89c615874f416d9658d0428c4c
|
||||
Title/singular: 344e64395eaff6822a57d18623853e1a
|
||||
To%20Do/singular: d60813ea824f373462471e092d136eed
|
||||
Toggle%20menu/singular: 29dea3e0b6238874f8c7a27619df8e36
|
||||
Track%20all%20card%20changes%20with%20detailed%20activity%20history./singular: 0d3bac559c71ec4b8734f9f212320de5
|
||||
@@ -617,3 +626,4 @@ checksums:
|
||||
Your%20workspace%20has%20been%20deleted./singular: e7a3efcfc7dd18cb3e917acb67498292
|
||||
Your%20workspace%20name%20has%20been%20updated./singular: a87ea3b0d71e6dc5dd525d77114a9322
|
||||
Your%20workspace%20slug%20has%20been%20updated./singular: c808949b9b2b4a9aba2472f5d1050167
|
||||
YouTube%20URL/singular: 0b48896061a1124501fdaba026804148
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { LinguiConfig } from "@lingui/conf";
|
||||
|
||||
const config: LinguiConfig = {
|
||||
locales: ["en", "fr", "de", "es", "it", "nl", "ru", "pl","ptbr"],
|
||||
locales: ["en", "fr", "de", "es", "it", "nl", "ru", "pl","pt-BR"],
|
||||
sourceLocale: "en",
|
||||
catalogs: [
|
||||
{
|
||||
|
||||
@@ -50,6 +50,11 @@ const config = {
|
||||
protocol: "https",
|
||||
hostname: "*.googleusercontent.com",
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'cdn.discordapp.com',
|
||||
pathname: '/avatars/**',
|
||||
},
|
||||
];
|
||||
|
||||
// Extract root domain from S3_ENDPOINT and add wildcard pattern
|
||||
@@ -90,6 +95,12 @@ const config = {
|
||||
swcPlugins: [["@lingui/swc-plugin", {}]],
|
||||
},
|
||||
|
||||
api: {
|
||||
bodyParser: {
|
||||
sizeLimit: env("NEXT_API_BODY_SIZE_LIMIT") || '1mb',
|
||||
},
|
||||
},
|
||||
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
"lint": "eslint",
|
||||
"start": "pnpm with-env next start",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"with-env": "dotenv -e ../../.env --",
|
||||
"lingui:extract": "lingui extract",
|
||||
@@ -86,7 +88,8 @@
|
||||
"jiti": "^1.21.6",
|
||||
"prettier": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
"typescript": "catalog:",
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"prettier": "@kan/prettier-config"
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@ export default function Dashboard({
|
||||
className={`fixed top-12 z-40 h-[calc(100dvh-3rem)] w-[calc(100vw-1.5rem)] transform transition-transform duration-300 ease-in-out md:relative md:top-0 md:h-full md:w-auto md:translate-x-0 ${isSideNavOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"} `}
|
||||
>
|
||||
<SideNavigation
|
||||
user={{ email: session?.user.email, image: session?.user.image }}
|
||||
user={{ displayName: session?.user.name, email: session?.user.email, image: session?.user.image }}
|
||||
isLoading={sessionLoading}
|
||||
onCloseSideNav={closeSideNav}
|
||||
/>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Range as TiptapRange } from "@tiptap/core";
|
||||
import type { Editor as TiptapEditor } from "@tiptap/react";
|
||||
import type {
|
||||
SuggestionKeyDownProps,
|
||||
@@ -44,6 +45,7 @@ import { Markdown } from "tiptap-markdown";
|
||||
|
||||
import { getAvatarUrl } from "~/utils/helpers";
|
||||
import Avatar from "./Avatar";
|
||||
import { YouTubeNode } from "./YouTubeEmbed/YouTubeNode";
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
@@ -56,7 +58,7 @@ declare module "@tiptap/core" {
|
||||
export interface SlashCommandItem {
|
||||
title: string;
|
||||
icon?: React.ReactNode;
|
||||
command?: (props: { editor: TiptapEditor; range: Range }) => void;
|
||||
command?: (props: { editor: TiptapEditor; range: TiptapRange }) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -431,12 +433,14 @@ export default function Editor({
|
||||
onBlur,
|
||||
readOnly = false,
|
||||
workspaceMembers,
|
||||
enableYouTubeEmbed = true,
|
||||
}: {
|
||||
content: string | null;
|
||||
onChange?: (value: string) => void;
|
||||
onBlur?: () => void;
|
||||
readOnly?: boolean;
|
||||
workspaceMembers: WorkspaceMember[];
|
||||
enableYouTubeEmbed?: boolean;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -484,10 +488,17 @@ export default function Editor({
|
||||
}),
|
||||
);
|
||||
const q = query.toLowerCase();
|
||||
return all.filter((u) => u.label.toLowerCase().includes(q));
|
||||
return all.filter(
|
||||
(u) =>
|
||||
u.label &&
|
||||
typeof u.label === "string" &&
|
||||
u.label.toLowerCase().includes(q),
|
||||
);
|
||||
},
|
||||
command: ({ editor, range, props }: any) => {
|
||||
const mentionHTML = `<span data-type="mention" data-id="${props.id}" data-label="${props.label}">@${props.label}</span> `;
|
||||
command: ({ editor, range, props }) => {
|
||||
const id = props.id ?? "";
|
||||
const label = props.label ?? "";
|
||||
const mentionHTML = `<span data-type="mention" data-id="${id}" data-label="${label}">@${label}</span> `;
|
||||
|
||||
editor
|
||||
.chain()
|
||||
@@ -503,6 +514,7 @@ export default function Editor({
|
||||
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`;
|
||||
},
|
||||
}),
|
||||
...(enableYouTubeEmbed ? [YouTubeNode] : []),
|
||||
],
|
||||
content,
|
||||
onUpdate: ({ editor }) => onChange?.(editor.getHTML()),
|
||||
@@ -559,6 +571,9 @@ export default function Editor({
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
.tiptap [data-youtube] {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
`}</style>
|
||||
{!readOnly && editor && <EditorBubbleMenu editor={editor} />}
|
||||
<EditorContent
|
||||
|
||||
@@ -46,7 +46,8 @@ const Button: React.FC<{
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onClick={handleClick}
|
||||
className={twMerge(
|
||||
"group flex h-[34px] items-center justify-between rounded-md p-1.5 text-sm font-normal leading-6 hover:bg-light-200 hover:text-light-1000 dark:hover:bg-dark-200 dark:hover:text-dark-1000",
|
||||
"group flex h-[34px] items-center rounded-md p-1.5 text-sm font-normal leading-6 hover:bg-light-200 hover:text-light-1000 dark:hover:bg-dark-200 dark:hover:text-dark-1000",
|
||||
isCollapsed ? "md:justify-center" : "justify-between",
|
||||
current
|
||||
? "bg-light-200 text-light-1000 dark:bg-dark-200 dark:text-dark-1000"
|
||||
: "text-neutral-600 dark:bg-dark-100 dark:text-dark-900",
|
||||
|
||||
@@ -39,6 +39,7 @@ interface SideNavigationProps {
|
||||
}
|
||||
|
||||
interface UserType {
|
||||
displayName?: string | null | undefined;
|
||||
email?: string | null | undefined;
|
||||
image?: string | null | undefined;
|
||||
}
|
||||
@@ -206,7 +207,8 @@ export default function SideNavigation({
|
||||
|
||||
<div className="space-y-2">
|
||||
<UserMenu
|
||||
email={user.email ?? ""}
|
||||
displayName={user.displayName ?? undefined}
|
||||
email={user.email ?? "Email not provided?"}
|
||||
imageUrl={user.image ?? undefined}
|
||||
isLoading={isLoading}
|
||||
isCollapsed={isCollapsed}
|
||||
|
||||
@@ -35,6 +35,7 @@ export function Tooltip({
|
||||
delay,
|
||||
interactive: false,
|
||||
theme: "tooltip",
|
||||
touch: false,
|
||||
});
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import { env } from "~/env";
|
||||
import { useIsMobile } from "~/hooks/useMediaQuery";
|
||||
import { useKeyboardShortcuts } from "~/providers/keyboard-shortcuts";
|
||||
import { useModal } from "~/providers/modal";
|
||||
@@ -16,6 +17,7 @@ import { getAvatarUrl } from "~/utils/helpers";
|
||||
|
||||
interface UserMenuProps {
|
||||
imageUrl: string | undefined;
|
||||
displayName: string | undefined;
|
||||
email: string;
|
||||
isLoading: boolean;
|
||||
isCollapsed?: boolean;
|
||||
@@ -25,6 +27,7 @@ interface UserMenuProps {
|
||||
export default function UserMenu({
|
||||
imageUrl,
|
||||
email,
|
||||
displayName,
|
||||
isLoading,
|
||||
isCollapsed = false,
|
||||
onCloseSideNav,
|
||||
@@ -74,7 +77,7 @@ export default function UserMenu({
|
||||
) : (
|
||||
<Menu.Button
|
||||
className="flex w-full items-center rounded-md p-1.5 text-neutral-900 hover:bg-light-200 dark:text-dark-900 dark:hover:bg-dark-200 dark:hover:text-dark-1000"
|
||||
title={isCollapsed ? email : undefined}
|
||||
title={isCollapsed ? displayName ?? email : undefined}
|
||||
>
|
||||
{avatarUrl ? (
|
||||
<Image
|
||||
@@ -101,7 +104,7 @@ export default function UserMenu({
|
||||
isCollapsed && "md:hidden",
|
||||
)}
|
||||
>
|
||||
{email}
|
||||
{displayName ?? email}
|
||||
</span>
|
||||
</Menu.Button>
|
||||
)}
|
||||
@@ -225,6 +228,25 @@ export default function UserMenu({
|
||||
</button>
|
||||
</Menu.Item>
|
||||
</div>
|
||||
{env.NEXT_PUBLIC_APP_VERSION && (
|
||||
<div className="light-border-600 border-t-[1px] p-1 dark:border-dark-600">
|
||||
<Menu.Item>
|
||||
<Link
|
||||
href={
|
||||
env.NEXT_PUBLIC_APP_VERSION.includes("+")
|
||||
? `https://github.com/kanbn/kan/commit/${env.NEXT_PUBLIC_APP_VERSION.split("+")[1]}`
|
||||
: `https://github.com/kanbn/kan/releases/tag/v${env.NEXT_PUBLIC_APP_VERSION}`
|
||||
}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={handleLinkClick}
|
||||
className="flex w-full items-center justify-center rounded-[5px] px-3 py-2 text-center text-xs text-light-900 hover:bg-light-200 dark:text-dark-900 dark:hover:bg-dark-400"
|
||||
>
|
||||
Version: {env.NEXT_PUBLIC_APP_VERSION}
|
||||
</Link>
|
||||
</Menu.Item>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
|
||||
189
apps/web/src/components/YouTubeEmbed/EditYouTubeModal.tsx
Normal file
189
apps/web/src/components/YouTubeEmbed/EditYouTubeModal.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { HiXMark } from "react-icons/hi2";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import Input from "~/components/Input";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { fetchYouTubeMetadata, isYouTubeUrl } from "./utils";
|
||||
|
||||
interface EditYouTubeFormInput {
|
||||
url: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface EditYouTubeModalState {
|
||||
url: string;
|
||||
title: string;
|
||||
onSave: (url: string, title: string) => void;
|
||||
}
|
||||
|
||||
export function EditYouTubeModal() {
|
||||
const { closeModal, getModalState } = useModal();
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const [urlError, setUrlError] = useState<string | null>(null);
|
||||
|
||||
// Get initial values and callback from modal state
|
||||
const modalState = getModalState("EDIT_YOUTUBE") as
|
||||
| EditYouTubeModalState
|
||||
| undefined;
|
||||
const initialUrl = modalState?.url ?? "";
|
||||
const initialTitle = modalState?.title ?? "";
|
||||
const onSave = modalState?.onSave;
|
||||
|
||||
const { register, handleSubmit, watch, reset } =
|
||||
useForm<EditYouTubeFormInput>({
|
||||
defaultValues: {
|
||||
url: initialUrl,
|
||||
title: initialTitle,
|
||||
},
|
||||
});
|
||||
|
||||
// Reset form when modal state changes (when modal opens with new values)
|
||||
useEffect(() => {
|
||||
if (modalState) {
|
||||
reset({
|
||||
url: modalState.url,
|
||||
title: modalState.title,
|
||||
});
|
||||
}
|
||||
}, [modalState, reset]);
|
||||
|
||||
const currentUrl = watch("url");
|
||||
|
||||
const onSubmit = async (values: EditYouTubeFormInput) => {
|
||||
// Validate URL
|
||||
if (!isYouTubeUrl(values.url)) {
|
||||
setUrlError(t`Please enter a valid YouTube URL`);
|
||||
return;
|
||||
}
|
||||
|
||||
setUrlError(null);
|
||||
setIsValidating(true);
|
||||
|
||||
try {
|
||||
// If title is empty and URL changed, fetch new title
|
||||
let finalTitle = values.title;
|
||||
if (!finalTitle.trim() && values.url !== initialUrl) {
|
||||
const metadata = await fetchYouTubeMetadata(values.url);
|
||||
finalTitle = metadata?.title ?? "YouTube Video";
|
||||
} else if (!finalTitle.trim()) {
|
||||
// Keep existing title if no new title provided and URL unchanged
|
||||
finalTitle = initialTitle || "YouTube Video";
|
||||
}
|
||||
|
||||
if (onSave) {
|
||||
onSave(values.url, finalTitle);
|
||||
}
|
||||
closeModal();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setUrlError(t`Failed to fetch video information`);
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-focus on title input (more useful for editing)
|
||||
useEffect(() => {
|
||||
const titleElement: HTMLElement | null =
|
||||
document.querySelector<HTMLElement>("#youtube-title");
|
||||
if (titleElement) titleElement.focus();
|
||||
}, []);
|
||||
|
||||
// Validate URL on change
|
||||
useEffect(() => {
|
||||
if (currentUrl && !isYouTubeUrl(currentUrl)) {
|
||||
setUrlError(t`Please enter a valid YouTube URL`);
|
||||
} else {
|
||||
setUrlError(null);
|
||||
}
|
||||
}, [currentUrl]);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="px-5 pt-5">
|
||||
<div className="flex w-full items-center justify-between pb-4 text-neutral-900 dark:text-dark-1000">
|
||||
<h2 className="text-sm font-medium">{t`Edit YouTube Video`}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-1 hover:bg-light-300 focus:outline-none dark:hover:bg-dark-300"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
closeModal();
|
||||
}}
|
||||
>
|
||||
<HiXMark size={18} className="text-light-900 dark:text-dark-900" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="youtube-title"
|
||||
className="mb-2 block text-xs font-medium text-light-900 dark:text-dark-900"
|
||||
>
|
||||
{t`Title`}
|
||||
</label>
|
||||
<Input
|
||||
id="youtube-title"
|
||||
placeholder={t`Enter a custom title`}
|
||||
{...register("title")}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
await handleSubmit(onSubmit)();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="youtube-url"
|
||||
className="mb-2 block text-xs font-medium text-light-900 dark:text-dark-900"
|
||||
>
|
||||
{t`YouTube URL`}
|
||||
</label>
|
||||
<Input
|
||||
id="youtube-url"
|
||||
placeholder={t`https://www.youtube.com/watch?v=...`}
|
||||
{...register("url", { required: true })}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
await handleSubmit(onSubmit)();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{urlError && (
|
||||
<p className="mt-1 text-xs text-red-600 dark:text-red-400">
|
||||
{urlError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<div className="space-x-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => closeModal()}
|
||||
>
|
||||
{t`Cancel`}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={isValidating}
|
||||
disabled={!watch("url") || !!urlError}
|
||||
>
|
||||
{t`Save`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
53
apps/web/src/components/YouTubeEmbed/YouTubeCard.tsx
Normal file
53
apps/web/src/components/YouTubeEmbed/YouTubeCard.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import YouTubeDropdown from "./YouTubeDropdown";
|
||||
|
||||
interface YouTubeCardProps {
|
||||
videoId: string;
|
||||
url: string;
|
||||
title: string;
|
||||
showEmbed?: boolean;
|
||||
onConvertToLink: () => void;
|
||||
onDelete: () => void;
|
||||
onUpdate: (url: string, title: string) => void;
|
||||
}
|
||||
|
||||
const YouTubeCard = ({
|
||||
videoId,
|
||||
url,
|
||||
title,
|
||||
showEmbed = true,
|
||||
onConvertToLink,
|
||||
onDelete,
|
||||
onUpdate,
|
||||
}: YouTubeCardProps) => {
|
||||
return (
|
||||
<div className="w-full max-w-md rounded-lg border border-light-300 bg-light-50 dark:border-dark-300 dark:bg-dark-50">
|
||||
<div className="flex items-center justify-between gap-6 p-0 px-6">
|
||||
<h3 className="truncate text-sm font-medium">{title}</h3>
|
||||
<div className="mt-3">
|
||||
<YouTubeDropdown
|
||||
url={url}
|
||||
title={title}
|
||||
onConvertToLink={onConvertToLink}
|
||||
onDelete={onDelete}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showEmbed && videoId && (
|
||||
<div className="p-6 pt-1">
|
||||
<iframe
|
||||
src={`https://www.youtube.com/embed/${videoId}?rel=0`}
|
||||
title={title}
|
||||
className="aspect-video w-full rounded-lg"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default YouTubeCard;
|
||||
63
apps/web/src/components/YouTubeEmbed/YouTubeDropdown.tsx
Normal file
63
apps/web/src/components/YouTubeEmbed/YouTubeDropdown.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import {
|
||||
HiEllipsisHorizontal,
|
||||
HiLink,
|
||||
HiPencil,
|
||||
HiTrash,
|
||||
} from "react-icons/hi2";
|
||||
|
||||
import { useModal } from "~/providers/modal";
|
||||
import Dropdown from "../Dropdown";
|
||||
|
||||
interface YouTubeDropdownProps {
|
||||
url: string;
|
||||
title: string;
|
||||
onConvertToLink: () => void;
|
||||
onDelete: () => void;
|
||||
onUpdate: (url: string, title: string) => void;
|
||||
}
|
||||
|
||||
const YouTubeDropdown = ({
|
||||
url,
|
||||
title,
|
||||
onConvertToLink,
|
||||
onDelete,
|
||||
onUpdate,
|
||||
}: YouTubeDropdownProps) => {
|
||||
const { openModal, setModalState } = useModal();
|
||||
|
||||
const handleEdit = () => {
|
||||
setModalState("EDIT_YOUTUBE", {
|
||||
url,
|
||||
title,
|
||||
onSave: onUpdate,
|
||||
});
|
||||
openModal("EDIT_YOUTUBE");
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: t`Edit`,
|
||||
action: handleEdit,
|
||||
icon: <HiPencil className="h-4 w-4 text-dark-900" />,
|
||||
},
|
||||
{
|
||||
label: t`Convert to link`,
|
||||
action: onConvertToLink,
|
||||
icon: <HiLink className="h-4 w-4 text-dark-900" />,
|
||||
},
|
||||
{
|
||||
label: t`Delete`,
|
||||
action: onDelete,
|
||||
icon: <HiTrash className="h-4 w-4 text-dark-900" />,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default YouTubeDropdown;
|
||||
196
apps/web/src/components/YouTubeEmbed/YouTubeNode.tsx
Normal file
196
apps/web/src/components/YouTubeEmbed/YouTubeNode.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import { mergeAttributes, Node } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
|
||||
import { extractVideoId, fetchYouTubeMetadata, isYouTubeUrl } from "./utils";
|
||||
import YouTubeNodeView from "./YouTubeNodeView";
|
||||
|
||||
export interface YouTubeOptions {
|
||||
inline: boolean;
|
||||
HTMLAttributes: Record<string, undefined>;
|
||||
}
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
youTube: {
|
||||
setYouTubeEmbed: (options: {
|
||||
videoId: string;
|
||||
url: string;
|
||||
title: string;
|
||||
thumbnailUrl?: string;
|
||||
showEmbed?: boolean;
|
||||
}) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const YouTubeNode = Node.create<YouTubeOptions>({
|
||||
name: "youtube",
|
||||
group: "block",
|
||||
atom: true,
|
||||
addOptions() {
|
||||
return {
|
||||
inline: false,
|
||||
HTMLAttributes: {},
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
videoId: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-video-id"),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.videoId) return {};
|
||||
return { "data-video-id": attributes.videoId as string };
|
||||
},
|
||||
},
|
||||
url: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-url"),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.url) return {};
|
||||
return { "data-url": attributes.url as string };
|
||||
},
|
||||
},
|
||||
title: {
|
||||
default: "YouTube Video",
|
||||
parseHTML: (element) => element.getAttribute("data-title"),
|
||||
renderHTML: (attributes) => {
|
||||
return { "data-title": attributes.title as string };
|
||||
},
|
||||
},
|
||||
thumbnailUrl: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-thumbnail"),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.thumbnailUrl) return {};
|
||||
return { "data-thumbnail": attributes.thumbnailUrl as string };
|
||||
},
|
||||
},
|
||||
showEmbed: {
|
||||
default: true,
|
||||
parseHTML: (element) => element.getAttribute("data-show-embed"),
|
||||
renderHTML: (attributes) => {
|
||||
return { "data-show-embed": attributes.showEmbed as string };
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "div[data-youtube]",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"div",
|
||||
mergeAttributes(
|
||||
{ "data-youtube": "" },
|
||||
this.options.HTMLAttributes,
|
||||
HTMLAttributes,
|
||||
),
|
||||
];
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(YouTubeNodeView);
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const nodeType = this.type;
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("youtubePaste"),
|
||||
props: {
|
||||
handlePaste: (view, event) => {
|
||||
const text = event.clipboardData?.getData("text/plain");
|
||||
if (!text) return false;
|
||||
|
||||
// Check if the pasted text is a YouTube URL
|
||||
const youtubeRegex =
|
||||
/(https?:\/\/)?(www\.)?(youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([\w-]+)/;
|
||||
const match = youtubeRegex.exec(text);
|
||||
|
||||
if (!match || !isYouTubeUrl(text)) return false;
|
||||
|
||||
const videoId = extractVideoId(text);
|
||||
if (!videoId) return false;
|
||||
|
||||
const { state, dispatch } = view;
|
||||
const { tr } = state;
|
||||
|
||||
const node = nodeType.create({
|
||||
videoId,
|
||||
url: text,
|
||||
title: "Loading...",
|
||||
showEmbed: true,
|
||||
});
|
||||
|
||||
tr.replaceSelectionWith(node);
|
||||
dispatch(tr);
|
||||
|
||||
// Fetch metadata asynchronously and update the node
|
||||
void fetchYouTubeMetadata(text).then((metadata) => {
|
||||
const { state: newState, dispatch: newDispatch } = view;
|
||||
const { tr: newTr } = newState;
|
||||
|
||||
newState.doc.descendants((n, pos) => {
|
||||
if (
|
||||
n.type.name === "youtube" &&
|
||||
n.attrs.videoId === videoId &&
|
||||
n.attrs.title === "Loading..."
|
||||
) {
|
||||
newTr.setNodeMarkup(pos, undefined, {
|
||||
...n.attrs,
|
||||
title: metadata?.title ?? "YouTube Video",
|
||||
thumbnailUrl: metadata?.thumbnail_url ?? null,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (newTr.docChanged) {
|
||||
newDispatch(newTr);
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
|
||||
// commands for the YouTube embed node
|
||||
addCommands() {
|
||||
return {
|
||||
setYouTubeEmbed:
|
||||
(options: {
|
||||
videoId: string;
|
||||
url: string;
|
||||
title?: string;
|
||||
thumbnailUrl?: string;
|
||||
showEmbed?: boolean;
|
||||
}) =>
|
||||
({ editor }) => {
|
||||
return editor.commands.insertContent({
|
||||
type: this.name,
|
||||
attrs: {
|
||||
videoId: options.videoId,
|
||||
url: options.url,
|
||||
title: options.title ?? "YouTube Video",
|
||||
thumbnailUrl: options.thumbnailUrl,
|
||||
showEmbed: options.showEmbed ?? true,
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
82
apps/web/src/components/YouTubeEmbed/YouTubeNodeView.tsx
Normal file
82
apps/web/src/components/YouTubeEmbed/YouTubeNodeView.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { NodeViewProps } from "@tiptap/react";
|
||||
import { NodeViewWrapper } from "@tiptap/react";
|
||||
|
||||
import { extractVideoId } from "./utils";
|
||||
import YouTubeCard from "./YouTubeCard";
|
||||
|
||||
export default function YouTubeNodeView({
|
||||
node,
|
||||
editor,
|
||||
getPos,
|
||||
deleteNode,
|
||||
updateAttributes,
|
||||
}: NodeViewProps) {
|
||||
const { videoId, title, url, showEmbed } = node.attrs as {
|
||||
videoId: string;
|
||||
title: string;
|
||||
url: string;
|
||||
showEmbed: boolean;
|
||||
};
|
||||
|
||||
const handleConvertToLink = () => {
|
||||
const pos = getPos();
|
||||
if (typeof pos !== "number") return;
|
||||
|
||||
const linkContent = {
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: url,
|
||||
marks: [
|
||||
{
|
||||
type: "link",
|
||||
attrs: {
|
||||
href: url,
|
||||
target: "_blank",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange({ from: pos, to: pos + node.nodeSize })
|
||||
.insertContentAt(pos, linkContent)
|
||||
.run();
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
deleteNode();
|
||||
};
|
||||
|
||||
const handleUpdate = (newUrl: string, newTitle: string) => {
|
||||
if (newUrl !== url) {
|
||||
const newVideoId = extractVideoId(newUrl);
|
||||
updateAttributes({
|
||||
url: newUrl,
|
||||
title: newTitle,
|
||||
videoId: newVideoId,
|
||||
});
|
||||
} else {
|
||||
updateAttributes({ title: newTitle });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
<YouTubeCard
|
||||
videoId={videoId}
|
||||
url={url}
|
||||
title={title}
|
||||
showEmbed={showEmbed}
|
||||
onConvertToLink={handleConvertToLink}
|
||||
onDelete={handleDelete}
|
||||
onUpdate={handleUpdate}
|
||||
/>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
61
apps/web/src/components/YouTubeEmbed/utils.ts
Normal file
61
apps/web/src/components/YouTubeEmbed/utils.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
export function extractVideoId(url: string): string | null {
|
||||
if (!url) return null;
|
||||
url = url.trim();
|
||||
|
||||
// youtube.com/watch?v=VIDEO_ID
|
||||
const watchMatch = /(?:youtube\.com\/watch\?v=)([a-zA-Z0-9_-]{11})/.exec(url);
|
||||
if (watchMatch) return watchMatch[1] ?? null;
|
||||
|
||||
// youtu.be/VIDEO_ID
|
||||
const shortMatch = /(?:youtu\.be\/)([a-zA-Z0-9_-]{11})/.exec(url);
|
||||
if (shortMatch) return shortMatch[1] ?? null;
|
||||
|
||||
// youtube.com/embed/VIDEO_ID
|
||||
const embedMatch = /(?:youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/.exec(url);
|
||||
if (embedMatch) return embedMatch[1] ?? null;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a URL is a valid YouTube link
|
||||
*/
|
||||
export function isYouTubeUrl(url: string): boolean {
|
||||
if (!url) return false;
|
||||
const videoId = extractVideoId(url);
|
||||
return videoId !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch YouTube video metadata using oEmbed API
|
||||
* Returns title, author, thumbnail URL, etc.
|
||||
* No API key required
|
||||
*/
|
||||
export async function fetchYouTubeMetadata(url: string): Promise<{
|
||||
title: string;
|
||||
author_name: string;
|
||||
thumbnail_url: string;
|
||||
} | null> {
|
||||
try {
|
||||
const oembedUrl = `https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`;
|
||||
const response = await fetch(oembedUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
title: string;
|
||||
author_name: string;
|
||||
thumbnail_url: string;
|
||||
};
|
||||
return {
|
||||
title: data.title || "YouTube Video",
|
||||
author_name: data.author_name || "",
|
||||
thumbnail_url: data.thumbnail_url || "",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch YouTube metadata:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,7 @@ export const env = createEnv({
|
||||
S3_ENDPOINT: z.string().optional(),
|
||||
S3_FORCE_PATH_STYLE: z.string().optional(),
|
||||
EMAIL_FROM: z.string().optional(),
|
||||
REDIS_URL: z.string().url().optional(),
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -95,6 +96,14 @@ 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()
|
||||
.transform((s) => (s === "" ? undefined : s))
|
||||
@@ -132,6 +141,9 @@ 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,
|
||||
NEXT_PUBLIC_USE_STANDALONE_OUTPUT:
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -19,7 +19,7 @@ export function useLocalisation() {
|
||||
nl,
|
||||
ru,
|
||||
pl,
|
||||
ptbr: ptBR,
|
||||
"pt-BR": ptBR,
|
||||
};
|
||||
|
||||
const currentDateLocale = dateLocaleMap[locale] ?? enGB;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -7,7 +7,7 @@ export const locales = [
|
||||
"nl",
|
||||
"ru",
|
||||
"pl",
|
||||
"ptbr"
|
||||
"pt-BR"
|
||||
] as const;
|
||||
|
||||
export type Locale = (typeof locales)[number];
|
||||
@@ -23,5 +23,5 @@ export const localeNames: Record<Locale, string> = {
|
||||
nl: "Nederlands",
|
||||
ru: "Русский",
|
||||
pl: "Polski",
|
||||
ptbr: "Português",
|
||||
"pt-BR": "Português",
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because 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
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -2,9 +2,17 @@ import { toNodeHandler } from "better-auth/node";
|
||||
|
||||
import { initAuth } from "@kan/auth/server";
|
||||
import { createDrizzleClient } from "@kan/db/client";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
|
||||
export const config = { api: { bodyParser: false } };
|
||||
|
||||
export const auth = initAuth(createDrizzleClient());
|
||||
|
||||
export default toNodeHandler(auth.handler);
|
||||
const authHandler = toNodeHandler(auth.handler);
|
||||
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req, res) => {
|
||||
return await authHandler(req, res);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method !== "GET") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
@@ -44,4 +45,5 @@ export default async function handler(
|
||||
console.error("Error downloading attachment:", error);
|
||||
return res.status(500).json({ message: "Failed to download attachment" });
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method !== "GET") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
@@ -20,4 +21,5 @@ export default async function handler(
|
||||
console.error("Error fetching OSS friends:", error);
|
||||
return res.status(500).json({ message: "Failed to fetch OSS friends" });
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,11 +3,11 @@ import { env } from "next-runtime-env";
|
||||
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
import { createStripeClient } from "@kan/stripe";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const stripe = createStripeClient();
|
||||
|
||||
if (req.method !== "POST") {
|
||||
@@ -31,4 +31,5 @@ export default async function handler(
|
||||
console.error("Error:", error);
|
||||
return res.status(500).json({ error: "Error creating portal session" });
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createNextApiContext } from "@kan/api/trpc";
|
||||
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import { createStripeClient } from "@kan/stripe";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
|
||||
const workspaceSlugSchema = z
|
||||
.string()
|
||||
@@ -21,10 +22,9 @@ interface CheckoutSessionRequest {
|
||||
stripeCustomerId: string;
|
||||
}
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const stripe = createStripeClient();
|
||||
|
||||
if (req.method !== "POST") {
|
||||
@@ -115,4 +115,5 @@ export default async function handler(
|
||||
console.error("Error:", error);
|
||||
return res.status(500).json({ error: "Error creating checkout session" });
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,11 +3,11 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
import { integrations } from "@kan/db/schema";
|
||||
import { addYears } from "date-fns";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
@@ -48,4 +48,5 @@ export default async function handler(
|
||||
console.error("Trello authentication error:", err);
|
||||
return res.status(400).json({ message: "Trello authentication failed" });
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { jwtVerify } from "jose";
|
||||
import { z } from "zod";
|
||||
|
||||
import { env } from "~/env";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
|
||||
const requestSchema = z.object({
|
||||
token: z.string().min(1),
|
||||
@@ -19,10 +20,9 @@ type ResponseData =
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<ResponseData>,
|
||||
) {
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req: NextApiRequest, res: NextApiResponse<ResponseData>) => {
|
||||
if (process.env.NEXT_PUBLIC_KAN_ENV !== "cloud") {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
@@ -101,4 +101,5 @@ export default async function handler(
|
||||
}
|
||||
|
||||
return res.status(200).json({ success: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -6,13 +6,13 @@ import { env as nextRuntimeEnv } from "next-runtime-env";
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
|
||||
import { env } from "~/env";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
|
||||
const allowedContentTypes = ["image/jpeg", "image/png"];
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ error: "Method not allowed" });
|
||||
}
|
||||
@@ -71,4 +71,5 @@ export default async function handler(
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
101
apps/web/src/utils/helpers.test.ts
Normal file
101
apps/web/src/utils/helpers.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("next-runtime-env", () => ({
|
||||
env: vi.fn(),
|
||||
}));
|
||||
|
||||
import { env } from "next-runtime-env";
|
||||
import { getAvatarUrl } from "./helpers";
|
||||
|
||||
const mockEnv = env as ReturnType<typeof vi.fn>;
|
||||
|
||||
describe("getAvatarUrl", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns empty string for null input", () => {
|
||||
expect(getAvatarUrl(null)).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string for empty string input", () => {
|
||||
expect(getAvatarUrl("")).toBe("");
|
||||
});
|
||||
|
||||
it("returns URL unchanged if already absolute http", () => {
|
||||
expect(getAvatarUrl("http://example.com/avatar.jpg")).toBe(
|
||||
"http://example.com/avatar.jpg",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns URL unchanged if already absolute https", () => {
|
||||
expect(getAvatarUrl("https://example.com/avatar.jpg")).toBe(
|
||||
"https://example.com/avatar.jpg",
|
||||
);
|
||||
});
|
||||
|
||||
describe("path-style URLs (MinIO/LocalStack)", () => {
|
||||
it("constructs path-style URL when STORAGE_DOMAIN is not set", () => {
|
||||
mockEnv.mockImplementation((key: string) => {
|
||||
const vars: Record<string, string> = {
|
||||
NEXT_PUBLIC_STORAGE_URL: "http://s3.localtest.me:9000",
|
||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME: "kan",
|
||||
};
|
||||
return vars[key];
|
||||
});
|
||||
|
||||
expect(getAvatarUrl("user123/avatar.jpg")).toBe(
|
||||
"http://s3.localtest.me:9000/kan/user123/avatar.jpg",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("virtual-hosted URLs (Tigris/AWS S3)", () => {
|
||||
it("constructs virtual-hosted URL when USE_VIRTUAL_HOSTED_URLS is true and STORAGE_DOMAIN is set", () => {
|
||||
mockEnv.mockImplementation((key: string) => {
|
||||
const vars: Record<string, string> = {
|
||||
NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS: "true",
|
||||
NEXT_PUBLIC_STORAGE_DOMAIN: "fly.storage.tigris.dev",
|
||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME: "kan-avatars",
|
||||
NEXT_PUBLIC_STORAGE_URL: "https://fly.storage.tigris.dev",
|
||||
};
|
||||
return vars[key];
|
||||
});
|
||||
|
||||
expect(getAvatarUrl("user123/avatar.jpg")).toBe(
|
||||
"https://kan-avatars.fly.storage.tigris.dev/user123/avatar.jpg",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses path-style URL when USE_VIRTUAL_HOSTED_URLS is false even if STORAGE_DOMAIN is set", () => {
|
||||
mockEnv.mockImplementation((key: string) => {
|
||||
const vars: Record<string, string> = {
|
||||
NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS: "false",
|
||||
NEXT_PUBLIC_STORAGE_DOMAIN: "fly.storage.tigris.dev",
|
||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME: "kan-avatars",
|
||||
NEXT_PUBLIC_STORAGE_URL: "https://fly.storage.tigris.dev",
|
||||
};
|
||||
return vars[key];
|
||||
});
|
||||
|
||||
expect(getAvatarUrl("user123/avatar.jpg")).toBe(
|
||||
"https://fly.storage.tigris.dev/kan-avatars/user123/avatar.jpg",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses path-style URL when USE_VIRTUAL_HOSTED_URLS is not set even if STORAGE_DOMAIN is set", () => {
|
||||
mockEnv.mockImplementation((key: string) => {
|
||||
const vars: Record<string, string> = {
|
||||
NEXT_PUBLIC_STORAGE_DOMAIN: "fly.storage.tigris.dev",
|
||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME: "kan-avatars",
|
||||
NEXT_PUBLIC_STORAGE_URL: "https://fly.storage.tigris.dev",
|
||||
};
|
||||
return vars[key];
|
||||
});
|
||||
|
||||
expect(getAvatarUrl("user123/avatar.jpg")).toBe(
|
||||
"https://fly.storage.tigris.dev/kan-avatars/user123/avatar.jpg",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -52,5 +52,14 @@ export const getAvatarUrl = (imageOrKey: string | null) => {
|
||||
return imageOrKey;
|
||||
}
|
||||
|
||||
return `${env("NEXT_PUBLIC_STORAGE_URL")}/${env("NEXT_PUBLIC_AVATAR_BUCKET_NAME")}/${imageOrKey}`;
|
||||
const bucket = env("NEXT_PUBLIC_AVATAR_BUCKET_NAME");
|
||||
const useVirtualHostedUrls = env("NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS");
|
||||
const storageDomain = env("NEXT_PUBLIC_STORAGE_DOMAIN");
|
||||
|
||||
if (useVirtualHostedUrls === "true" && storageDomain) {
|
||||
return `https://${bucket}.${storageDomain}/${imageOrKey}`;
|
||||
}
|
||||
|
||||
const storageUrl = env("NEXT_PUBLIC_STORAGE_URL");
|
||||
return `${storageUrl}/${bucket}/${imageOrKey}`;
|
||||
};
|
||||
|
||||
@@ -22,8 +22,8 @@ const loadMessages = async (locale: Locale) => {
|
||||
return (await import("~/locales/ru/messages")).messages;
|
||||
case "pl":
|
||||
return (await import("~/locales/pl/messages")).messages;
|
||||
case "ptbr":
|
||||
return (await import("~/locales/ptbr/messages")).messages;
|
||||
case "pt-BR":
|
||||
return (await import("~/locales/pt-BR/messages")).messages;
|
||||
default:
|
||||
return enMessages;
|
||||
}
|
||||
|
||||
@@ -335,7 +335,7 @@ export function NewCardForm({
|
||||
saveFormState({ ...formState, description: value });
|
||||
}}
|
||||
workspaceMembers={
|
||||
boardData?.workspace.members?.map(
|
||||
boardData?.workspace.members.map(
|
||||
(member): WorkspaceMember => ({
|
||||
publicId: member.publicId,
|
||||
email: member.email,
|
||||
@@ -349,6 +349,7 @@ export function NewCardForm({
|
||||
}),
|
||||
) ?? []
|
||||
}
|
||||
enableYouTubeEmbed={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,8 @@ import { PageHead } from "~/components/PageHead";
|
||||
import PatternedBackground from "~/components/PatternedBackground";
|
||||
import { StrictModeDroppable as Droppable } from "~/components/StrictModeDroppable";
|
||||
import { Tooltip } from "~/components/Tooltip";
|
||||
import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal";
|
||||
import { useDragToScroll } from "~/hooks/useDragToScroll";
|
||||
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
@@ -55,6 +57,11 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
const [selectedPublicListId, setSelectedPublicListId] =
|
||||
useState<PublicListId>("");
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
|
||||
const { ref: scrollRef, onMouseDown } = useDragToScroll({
|
||||
enabled: true,
|
||||
direction: "horizontal",
|
||||
});
|
||||
|
||||
const { tooltipContent: createListShortcutTooltipContent } =
|
||||
useKeyboardShortcut({
|
||||
@@ -372,6 +379,13 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
sourceBoardName={boardData?.name ?? ""}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "EDIT_YOUTUBE"}
|
||||
>
|
||||
<EditYouTubeModal />
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -471,7 +485,11 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="scrollbar-w-none scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-h-[8px] z-0 flex-1 overflow-y-hidden overflow-x-scroll overscroll-contain scrollbar scrollbar-track-light-200 scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-300">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onMouseDown={onMouseDown}
|
||||
className={`scrollbar-w-none scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-h-[8px] z-0 flex-1 overflow-y-hidden overflow-x-scroll overscroll-contain scrollbar scrollbar-track-light-200 scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-300`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="ml-[2rem] flex">
|
||||
<div className="0 mr-5 h-[500px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
|
||||
|
||||
@@ -25,6 +25,7 @@ import { authClient } from "@kan/auth/client";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import { useLocalisation } from "~/hooks/useLocalisation";
|
||||
import { api } from "~/utils/api";
|
||||
import { getAvatarUrl } from "~/utils/helpers";
|
||||
import Comment from "./Comment";
|
||||
|
||||
type ActivityType =
|
||||
@@ -471,6 +472,7 @@ const ActivityList = ({
|
||||
cardPublicId={cardPublicId}
|
||||
name={activity.user?.name ?? ""}
|
||||
email={activity.user?.email ?? ""}
|
||||
image={activity.user?.image ?? null}
|
||||
isLoading={isLoading}
|
||||
createdAt={activity.createdAt.toISOString()}
|
||||
comment={activity.comment?.comment}
|
||||
@@ -493,6 +495,7 @@ const ActivityList = ({
|
||||
size="sm"
|
||||
name={activity.user?.name ?? ""}
|
||||
email={activity.user?.email ?? ""}
|
||||
imageUrl={getAvatarUrl(activity.user?.image ?? null) || undefined}
|
||||
icon={getActivityIcon(
|
||||
activity.type,
|
||||
activity.fromList?.index,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
||||
import { getAvatarUrl } from "~/utils/helpers";
|
||||
|
||||
interface FormValues {
|
||||
comment: string;
|
||||
@@ -22,6 +23,7 @@ const Comment = ({
|
||||
cardPublicId,
|
||||
name,
|
||||
email,
|
||||
image,
|
||||
isLoading,
|
||||
createdAt,
|
||||
comment,
|
||||
@@ -34,6 +36,7 @@ const Comment = ({
|
||||
cardPublicId: string;
|
||||
name: string;
|
||||
email: string;
|
||||
image: string | null;
|
||||
isLoading: boolean;
|
||||
createdAt: string;
|
||||
comment: string | undefined;
|
||||
@@ -108,6 +111,7 @@ const Comment = ({
|
||||
size="sm"
|
||||
name={name ?? ""}
|
||||
email={email ?? ""}
|
||||
imageUrl={getAvatarUrl(image) || undefined}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import LabelIcon from "~/components/LabelIcon";
|
||||
import Modal from "~/components/modal";
|
||||
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
@@ -483,6 +484,13 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
checklistPublicId={entityId}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "EDIT_YOUTUBE"}
|
||||
>
|
||||
<EditYouTubeModal />
|
||||
</Modal>
|
||||
</>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -54,6 +54,7 @@ export default function MembersPage() {
|
||||
memberStatus,
|
||||
isLastRow,
|
||||
showSkeleton,
|
||||
showPendingIcon,
|
||||
}: {
|
||||
memberPublicId?: string;
|
||||
memberId?: string | null | undefined;
|
||||
@@ -64,6 +65,7 @@ export default function MembersPage() {
|
||||
memberStatus?: string;
|
||||
isLastRow?: boolean;
|
||||
showSkeleton?: boolean;
|
||||
showPendingIcon?: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<tr className="rounded-b-lg">
|
||||
@@ -82,6 +84,7 @@ export default function MembersPage() {
|
||||
name={memberName ?? ""}
|
||||
email={memberEmail ?? ""}
|
||||
imageUrl={memberImage ? getAvatarUrl(memberImage) : undefined}
|
||||
icon={showPendingIcon ? "?" : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -93,20 +96,26 @@ export default function MembersPage() {
|
||||
"mr-2 truncate text-xs font-medium text-neutral-900 dark:text-dark-1000 sm:text-sm",
|
||||
showSkeleton &&
|
||||
"md mb-2 h-3 w-[125px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
|
||||
showPendingIcon &&
|
||||
"italic text-neutral-500 dark:text-dark-900",
|
||||
)}
|
||||
>
|
||||
{memberName}
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
className={twMerge(
|
||||
"truncate text-xs text-dark-900 sm:text-sm",
|
||||
showSkeleton &&
|
||||
"h-3 w-[175px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
|
||||
)}
|
||||
>
|
||||
{memberEmail}
|
||||
</p>
|
||||
{((workspace.role === "admin" ||
|
||||
data?.showEmailsToMembers === true) ||
|
||||
showSkeleton) && (
|
||||
<p
|
||||
className={twMerge(
|
||||
"truncate text-xs text-dark-900 sm:text-sm",
|
||||
showSkeleton &&
|
||||
"h-3 w-[175px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
|
||||
)}
|
||||
>
|
||||
{memberEmail}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -246,19 +255,24 @@ export default function MembersPage() {
|
||||
</thead>
|
||||
<tbody className="divide-y divide-light-600 overflow-visible bg-light-50 dark:divide-dark-600 dark:bg-dark-100">
|
||||
{!isLoading &&
|
||||
data?.members.map((member, index) => (
|
||||
<TableRow
|
||||
key={member.publicId}
|
||||
memberPublicId={member.publicId}
|
||||
memberId={member.user?.id}
|
||||
memberName={member.user?.name}
|
||||
memberEmail={member.user?.email ?? member.email}
|
||||
memberImage={member.user?.image}
|
||||
memberRole={member.role}
|
||||
memberStatus={member.status}
|
||||
isLastRow={index === data.members.length - 1}
|
||||
/>
|
||||
))}
|
||||
data?.members.map((member, index) => {
|
||||
const isPendingInvite = member.status === "invited";
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={member.publicId}
|
||||
memberPublicId={member.publicId}
|
||||
memberId={member.user?.id}
|
||||
memberName={member.user?.name}
|
||||
memberEmail={member.user?.email ?? member.email}
|
||||
memberImage={member.user?.image}
|
||||
memberRole={member.role}
|
||||
memberStatus={member.status}
|
||||
isLastRow={index === data.members.length - 1}
|
||||
showPendingIcon={isPendingInvite}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{isLoading && (
|
||||
<>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { PageHead } from "~/components/PageHead";
|
||||
import PatternedBackground from "~/components/PatternedBackground";
|
||||
import Popup from "~/components/Popup";
|
||||
import ThemeToggle from "~/components/ThemeToggle";
|
||||
import { useDragToScroll } from "~/hooks/useDragToScroll";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
@@ -29,6 +30,11 @@ export default function PublicBoardView() {
|
||||
const { showPopup } = usePopup();
|
||||
const [isRouteLoaded, setIsRouteLoaded] = useState(false);
|
||||
const { openModal } = useModal();
|
||||
|
||||
const { ref: scrollRef, onMouseDown } = useDragToScroll({
|
||||
enabled: true,
|
||||
direction: "horizontal",
|
||||
});
|
||||
|
||||
const boardSlug = Array.isArray(router.query.boardSlug)
|
||||
? router.query.boardSlug[0]
|
||||
@@ -151,7 +157,11 @@ export default function PublicBoardView() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="scrollbar-w-none scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-h-[8px] relative h-full flex-1 overflow-y-hidden overflow-x-scroll overscroll-contain scrollbar scrollbar-track-light-200 scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-300">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onMouseDown={onMouseDown}
|
||||
className="scrollbar-w-none scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-h-[8px] relative h-full flex-1 overflow-y-hidden overflow-x-scroll overscroll-contain scrollbar scrollbar-track-light-200 scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-300"
|
||||
>
|
||||
{isLoading || !router.isReady ? (
|
||||
<div className="ml-[2rem] flex">
|
||||
<div className="0 mr-5 h-[500px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
|
||||
|
||||
@@ -37,6 +37,13 @@ export default function AccountSettings() {
|
||||
<UpdateDisplayNameForm displayName={data?.name ?? ""} />
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
|
||||
{t`Email`}
|
||||
</h2>
|
||||
<p className="text-sm text-neutral-700 dark:text-dark-900">{data?.email}</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
|
||||
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
|
||||
{t`Language`}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
import { DeleteWorkspaceConfirmation } from "./components/DeleteWorkspaceConfirmation";
|
||||
import UpdateWorkspaceDescriptionForm from "./components/UpdateWorkspaceDescriptionForm";
|
||||
import UpdateWorkspaceEmailVisibilityForm from "./components/UpdateWorkspaceEmailVisibilityForm";
|
||||
import UpdateWorkspaceNameForm from "./components/UpdateWorkspaceNameForm";
|
||||
import UpdateWorkspaceUrlForm from "./components/UpdateWorkspaceUrlForm";
|
||||
import { UpgradeToProConfirmation } from "./components/UpgradeToProConfirmation";
|
||||
@@ -79,6 +80,14 @@ export default function WorkspaceSettings() {
|
||||
workspaceDescription={workspace.description ?? ""}
|
||||
/>
|
||||
|
||||
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
|
||||
{t`Email visibility`}
|
||||
</h2>
|
||||
<UpdateWorkspaceEmailVisibilityForm
|
||||
workspacePublicId={workspace.publicId}
|
||||
showEmailsToMembers={workspaceData?.showEmailsToMembers ?? false}
|
||||
/>
|
||||
|
||||
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||
!hasActiveSubscription(subscriptions, "pro") &&
|
||||
!hasActiveSubscription(subscriptions, "team") && (
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import Toggle from "~/components/Toggle";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
export default function UpdateWorkspaceEmailVisibilityForm({
|
||||
workspacePublicId,
|
||||
showEmailsToMembers,
|
||||
}: {
|
||||
workspacePublicId: string;
|
||||
showEmailsToMembers: boolean;
|
||||
}) {
|
||||
const utils = api.useUtils();
|
||||
const [isChecked, setIsChecked] = useState(showEmailsToMembers);
|
||||
|
||||
useEffect(() => {
|
||||
setIsChecked(showEmailsToMembers);
|
||||
}, [showEmailsToMembers]);
|
||||
|
||||
const updateWorkspace = api.workspace.update.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.workspace.byId.invalidate({
|
||||
workspacePublicId,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleToggle = () => {
|
||||
const newValue = !isChecked;
|
||||
setIsChecked(newValue);
|
||||
updateWorkspace.mutate({
|
||||
workspacePublicId,
|
||||
showEmailsToMembers: newValue,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-neutral-500 dark:text-dark-900">
|
||||
{t`Allow workspace members to see each other's email addresses`}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
isChecked={isChecked}
|
||||
onChange={handleToggle}
|
||||
label=""
|
||||
disabled={updateWorkspace.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,8 @@ services:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: apps/web/Dockerfile
|
||||
args:
|
||||
APP_VERSION: ${APP_VERSION:-}
|
||||
env_file:
|
||||
- .env
|
||||
restart: unless-stopped
|
||||
@@ -19,6 +21,7 @@ services:
|
||||
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
|
||||
- POSTGRES_URL=${POSTGRES_URL}
|
||||
- NEXT_PUBLIC_USE_STANDALONE_OUTPUT=${NEXT_PUBLIC_USE_STANDALONE_OUTPUT}
|
||||
- REDIS_URL=${REDIS_URL}
|
||||
|
||||
# Stripe
|
||||
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
|
||||
@@ -53,11 +56,15 @@ services:
|
||||
- NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME}
|
||||
- NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME=${NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME}
|
||||
- NEXT_PUBLIC_STORAGE_DOMAIN=${NEXT_PUBLIC_STORAGE_DOMAIN}
|
||||
- NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS=${NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS}
|
||||
|
||||
# Auth config
|
||||
# Auth config (optional)
|
||||
- NEXT_PUBLIC_ALLOW_CREDENTIALS=${NEXT_PUBLIC_ALLOW_CREDENTIALS}
|
||||
- NEXT_PUBLIC_DISABLE_SIGN_UP=${NEXT_PUBLIC_DISABLE_SIGN_UP}
|
||||
|
||||
# API configuration (optional)
|
||||
- NEXT_API_BODY_SIZE_LIMIT=${NEXT_API_BODY_SIZE_LIMIT}
|
||||
|
||||
# Integration providers
|
||||
- TRELLO_APP_API_KEY=${TRELLO_APP_API_KEY}
|
||||
- TRELLO_APP_SECRET=${TRELLO_APP_SECRET}
|
||||
|
||||
@@ -18,6 +18,9 @@ services:
|
||||
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
|
||||
- POSTGRES_URL=${POSTGRES_URL}
|
||||
|
||||
# Redis (optional - for rate limiting)
|
||||
- REDIS_URL=${REDIS_URL}
|
||||
|
||||
# Admin API key (optional)
|
||||
- KAN_ADMIN_API_KEY=${KAN_ADMIN_API_KEY}
|
||||
|
||||
@@ -42,6 +45,7 @@ services:
|
||||
- NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME}
|
||||
- NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME=${NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME}
|
||||
- NEXT_PUBLIC_STORAGE_DOMAIN=${NEXT_PUBLIC_STORAGE_DOMAIN}
|
||||
- NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS=${NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS}
|
||||
|
||||
# White label
|
||||
- NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY=${NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY}
|
||||
@@ -50,6 +54,9 @@ services:
|
||||
- NEXT_PUBLIC_ALLOW_CREDENTIALS=${NEXT_PUBLIC_ALLOW_CREDENTIALS}
|
||||
- NEXT_PUBLIC_DISABLE_SIGN_UP=${NEXT_PUBLIC_DISABLE_SIGN_UP}
|
||||
|
||||
# API configuration (optional)
|
||||
- NEXT_API_BODY_SIZE_LIMIT=${NEXT_API_BODY_SIZE_LIMIT}
|
||||
|
||||
# Integration providers (optional)
|
||||
- TRELLO_APP_API_KEY=${TRELLO_APP_API_KEY}
|
||||
- TRELLO_APP_SECRET=${TRELLO_APP_SECRET}
|
||||
|
||||
@@ -23,6 +23,10 @@
|
||||
"./openapi": {
|
||||
"types": "./dist/openapi.d.ts",
|
||||
"default": "./src/openapi.ts"
|
||||
},
|
||||
"./utils/rateLimit": {
|
||||
"types": "./dist/utils/rateLimit.d.ts",
|
||||
"default": "./src/utils/rateLimit.ts"
|
||||
}
|
||||
},
|
||||
"license": "GPL-3.0",
|
||||
@@ -43,6 +47,7 @@
|
||||
"@kan/shared": "workspace:^",
|
||||
"@kan/stripe": "workspace:^",
|
||||
"@trpc/server": "catalog:",
|
||||
"rate-limiter-flexible": "^9.0.1",
|
||||
"superjson": "2.2.1",
|
||||
"trpc-to-openapi": "^2.3.2",
|
||||
"zod": "catalog:"
|
||||
|
||||
@@ -27,7 +27,7 @@ export const cardRouter = createTRPCRouter({
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
title: z.string().min(1),
|
||||
title: z.string().min(1).max(2000),
|
||||
description: z.string().max(10000),
|
||||
listPublicId: z.string().min(12),
|
||||
labelPublicIds: z.array(z.string().min(12)),
|
||||
@@ -760,7 +760,7 @@ export const cardRouter = createTRPCRouter({
|
||||
.input(
|
||||
z.object({
|
||||
cardPublicId: z.string().min(12),
|
||||
title: z.string().min(1).optional(),
|
||||
title: z.string().min(1).max(2000).optional(),
|
||||
description: z.string().optional(),
|
||||
index: z.number().optional(),
|
||||
listPublicId: z.string().min(12).optional(),
|
||||
|
||||
@@ -52,7 +52,7 @@ interface TrelloCheckItem {
|
||||
|
||||
interface TrelloCard {
|
||||
id: string;
|
||||
name: string;
|
||||
name: string | null;
|
||||
desc: string;
|
||||
idList: string;
|
||||
labels: TrelloLabel[];
|
||||
@@ -290,7 +290,7 @@ export const importRouter = createTRPCRouter({
|
||||
if (list.cards.length && newListId) {
|
||||
const cardsInsert = list.cards.map((card, index) => ({
|
||||
publicId: generateUID(),
|
||||
title: card.name,
|
||||
title: (card.name?.trim() ?? "Untitled Card").slice(0, 2000),
|
||||
description: card.description,
|
||||
createdBy: userId,
|
||||
listId: newListId,
|
||||
|
||||
@@ -77,6 +77,49 @@ export const workspaceRouter = createTRPCRouter({
|
||||
|
||||
await assertUserInWorkspace(ctx.db, userId, result.id);
|
||||
|
||||
// Check if user is an admin
|
||||
const userMember = result.members.find(
|
||||
(member) => member.user?.id === userId,
|
||||
);
|
||||
const isAdmin = userMember?.role === "admin";
|
||||
|
||||
// Show emails if user is admin OR workspace setting allows it
|
||||
const shouldShowEmails = isAdmin || result.showEmailsToMembers === true;
|
||||
|
||||
// If emails should be hidden, filter them out
|
||||
if (!shouldShowEmails) {
|
||||
const sanitizedMembers = result.members.map((member) => {
|
||||
// If user doesn't have a display name, use anonymous identifier
|
||||
const displayName =
|
||||
member.user?.name?.trim() ?? `anonymous_${member.publicId}`;
|
||||
|
||||
const { email: _memberEmail, ...memberWithoutEmail } = member;
|
||||
const sanitizedUser = member.user
|
||||
? (() => {
|
||||
const { email: _userEmail, ...userWithoutEmail } = member.user;
|
||||
return {
|
||||
...userWithoutEmail,
|
||||
name: displayName,
|
||||
};
|
||||
})()
|
||||
: {
|
||||
id: null,
|
||||
name: displayName,
|
||||
image: null,
|
||||
};
|
||||
|
||||
return {
|
||||
...memberWithoutEmail,
|
||||
user: sanitizedUser,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...result,
|
||||
members: sanitizedMembers,
|
||||
} as Awaited<ReturnType<typeof workspaceRepo.getByPublicIdWithMembers>>;
|
||||
}
|
||||
|
||||
return result;
|
||||
}),
|
||||
bySlug: publicProcedure
|
||||
@@ -230,6 +273,7 @@ export const workspaceRouter = createTRPCRouter({
|
||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/)
|
||||
.optional(),
|
||||
description: z.string().min(3).max(280).optional(),
|
||||
showEmailsToMembers: z.boolean().optional(),
|
||||
}),
|
||||
)
|
||||
.output(z.custom<Awaited<ReturnType<typeof workspaceRepo.update>>>())
|
||||
@@ -291,9 +335,16 @@ export const workspaceRouter = createTRPCRouter({
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
description: input.description,
|
||||
showEmailsToMembers: input.showEmailsToMembers,
|
||||
},
|
||||
);
|
||||
|
||||
if (!result)
|
||||
throw new TRPCError({
|
||||
message: `Unable to delete workspace`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
return result;
|
||||
}),
|
||||
delete: protectedProcedure
|
||||
@@ -336,12 +387,6 @@ export const workspaceRouter = createTRPCRouter({
|
||||
input.workspacePublicId,
|
||||
);
|
||||
|
||||
if (!result)
|
||||
throw new TRPCError({
|
||||
message: `Unable to delete workspace`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
return result;
|
||||
}),
|
||||
checkSlugAvailability: publicProcedure
|
||||
|
||||
99
packages/api/src/utils/rateLimit.ts
Normal file
99
packages/api/src/utils/rateLimit.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import {
|
||||
RateLimiterRedis,
|
||||
RateLimiterMemory,
|
||||
} from "rate-limiter-flexible";
|
||||
|
||||
import { getRedisClient } from "@kan/db/redis";
|
||||
|
||||
export interface RateLimitOptions {
|
||||
points?: number;
|
||||
duration?: number;
|
||||
identifier?: (req: NextApiRequest) => string | Promise<string>;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
const defaultIdentifier = (req: NextApiRequest): string => {
|
||||
// Try to identify the IP address of the request
|
||||
const forwardedFor = req.headers["x-forwarded-for"];
|
||||
const realIp = req.headers["x-real-ip"];
|
||||
const cfConnectingIp = req.headers["cf-connecting-ip"];
|
||||
|
||||
const ip =
|
||||
(typeof forwardedFor === "string"
|
||||
? forwardedFor.split(",")[0]?.trim()
|
||||
: null) ??
|
||||
(typeof realIp === "string" ? realIp : null) ??
|
||||
(typeof cfConnectingIp === "string" ? cfConnectingIp : null) ??
|
||||
req.socket.remoteAddress ??
|
||||
"unknown";
|
||||
|
||||
return ip;
|
||||
};
|
||||
|
||||
const DEFAULT_OPTIONS = {
|
||||
points: 100,
|
||||
duration: 60,
|
||||
errorMessage: "Too many requests, please try again later.",
|
||||
identifier: defaultIdentifier,
|
||||
} as const;
|
||||
|
||||
function createRateLimiter(options: RateLimitOptions = {}) {
|
||||
const redis = getRedisClient();
|
||||
const points = options.points ?? DEFAULT_OPTIONS.points;
|
||||
const duration = options.duration ?? DEFAULT_OPTIONS.duration;
|
||||
|
||||
// Use Redis if available, otherwise fall back to in-memory storage
|
||||
if (redis) {
|
||||
console.log("Using Redis for rate limiting");
|
||||
return new RateLimiterRedis({
|
||||
storeClient: redis,
|
||||
points,
|
||||
duration,
|
||||
});
|
||||
}
|
||||
|
||||
console.log("Using in-memory for rate limiting");
|
||||
return new RateLimiterMemory({
|
||||
points,
|
||||
duration,
|
||||
});
|
||||
}
|
||||
|
||||
export function withRateLimit(
|
||||
options: RateLimitOptions,
|
||||
handler: (
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) => Promise<unknown> | unknown,
|
||||
) {
|
||||
const rateLimiter = createRateLimiter(options);
|
||||
const identifier = options.identifier ?? DEFAULT_OPTIONS.identifier;
|
||||
const errorMessage = options.errorMessage ?? DEFAULT_OPTIONS.errorMessage;
|
||||
|
||||
return async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
try {
|
||||
const id = await identifier(req);
|
||||
const key = `ratelimit_${id}`;
|
||||
|
||||
await rateLimiter.consume(key);
|
||||
|
||||
return await handler(req, res);
|
||||
} catch (error) {
|
||||
// rate-limiter-flexible throws an error with msBeforeNext or remainingPoints
|
||||
// when limit is exceeded. Check for these properties directly.
|
||||
if (
|
||||
error &&
|
||||
typeof error === "object" &&
|
||||
("msBeforeNext" in error || "remainingPoints" in error)
|
||||
) {
|
||||
return res.status(429).json({
|
||||
message: errorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
return await handler(req, res);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,147 +1,23 @@
|
||||
import type { Subscription } from "@better-auth/stripe";
|
||||
import type Stripe from "stripe";
|
||||
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
||||
import { stripe } from "@better-auth/stripe";
|
||||
import { ChatOrPushProviderEnum } from "@novu/api/models/components";
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { createAuthEndpoint, createAuthMiddleware } from "better-auth/api";
|
||||
import { apiKey, genericOAuth } from "better-auth/plugins";
|
||||
import { magicLink } from "better-auth/plugins/magic-link";
|
||||
import { socialProviderList } from "better-auth/social-providers";
|
||||
import { env } from "next-runtime-env";
|
||||
|
||||
import type { dbClient } from "@kan/db/client";
|
||||
import * as memberRepo from "@kan/db/repository/member.repo";
|
||||
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
||||
import * as userRepo from "@kan/db/repository/user.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import * as schema from "@kan/db/schema";
|
||||
import { notificationClient, sendEmail } from "@kan/email";
|
||||
import { createEmailUnsubscribeLink } from "@kan/shared";
|
||||
import { createStripeClient } from "@kan/stripe";
|
||||
import { sendEmail } from "@kan/email";
|
||||
|
||||
export const configuredProviders = socialProviderList.reduce<
|
||||
Record<
|
||||
string,
|
||||
{
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
appBundleIdentifier?: string;
|
||||
tenantId?: string;
|
||||
requireSelectAccount?: boolean;
|
||||
clientKey?: string;
|
||||
issuer?: string;
|
||||
// Google-specific optional hints
|
||||
hostedDomain?: string;
|
||||
hd?: string;
|
||||
}
|
||||
>
|
||||
>((acc, provider) => {
|
||||
const id = process.env[`${provider.toUpperCase()}_CLIENT_ID`];
|
||||
const secret = process.env[`${provider.toUpperCase()}_CLIENT_SECRET`];
|
||||
if (id && id.length > 0 && secret && secret.length > 0) {
|
||||
acc[provider] = { clientId: id, clientSecret: secret };
|
||||
}
|
||||
if (
|
||||
provider === "apple" &&
|
||||
Object.keys(acc).includes("apple") &&
|
||||
acc[provider]
|
||||
) {
|
||||
const bundleId =
|
||||
process.env[`${provider.toUpperCase()}_APP_BUNDLE_IDENTIFIER`];
|
||||
if (bundleId && bundleId.length > 0) {
|
||||
acc[provider].appBundleIdentifier = bundleId;
|
||||
}
|
||||
}
|
||||
if (
|
||||
provider === "gitlab" &&
|
||||
Object.keys(acc).includes("gitlab") &&
|
||||
acc[provider]
|
||||
) {
|
||||
const issuer = process.env[`${provider.toUpperCase()}_ISSUER`];
|
||||
if (issuer && issuer.length > 0) {
|
||||
acc[provider].issuer = issuer;
|
||||
}
|
||||
}
|
||||
if (
|
||||
provider === "microsoft" &&
|
||||
Object.keys(acc).includes("microsoft") &&
|
||||
acc[provider]
|
||||
) {
|
||||
acc[provider].tenantId = "common";
|
||||
acc[provider].requireSelectAccount = true;
|
||||
}
|
||||
// Add Google domain hint if allowed domains is configured
|
||||
if (
|
||||
provider === "google" &&
|
||||
Object.keys(acc).includes("google") &&
|
||||
acc[provider]
|
||||
) {
|
||||
const allowed = process.env.BETTER_AUTH_ALLOWED_DOMAINS?.split(",")
|
||||
.map((d) => d.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
if (allowed && allowed.length > 0) {
|
||||
// Use the first domain as an authorization hint
|
||||
acc[provider].hostedDomain = allowed[0];
|
||||
acc[provider].hd = allowed[0];
|
||||
}
|
||||
}
|
||||
if (
|
||||
provider === "tiktok" &&
|
||||
Object.keys(acc).includes("tiktok") &&
|
||||
acc[provider]
|
||||
) {
|
||||
const key = process.env[`${provider.toUpperCase()}_CLIENT_KEY`];
|
||||
if (key && key.length > 0) {
|
||||
acc[provider].clientKey = key;
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
export const socialProvidersPlugin = () => ({
|
||||
id: "social-providers-plugin",
|
||||
endpoints: {
|
||||
getSocialProviders: createAuthEndpoint(
|
||||
"/social-providers",
|
||||
{
|
||||
method: "GET",
|
||||
},
|
||||
async (ctx) => {
|
||||
const providers = ctx.context.socialProviders.map((p) =>
|
||||
p.id.toLowerCase(),
|
||||
);
|
||||
// Add OIDC provider if configured
|
||||
if (
|
||||
process.env.OIDC_CLIENT_ID &&
|
||||
process.env.OIDC_CLIENT_SECRET &&
|
||||
process.env.OIDC_DISCOVERY_URL
|
||||
) {
|
||||
providers.push("oidc");
|
||||
}
|
||||
return ctx.json(providers);
|
||||
},
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
async function downloadImage(url: string): Promise<Buffer> {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download image: ${response.statusText}`);
|
||||
}
|
||||
return Buffer.from(await response.arrayBuffer());
|
||||
}
|
||||
import { createDatabaseHooks, createMiddlewareHooks } from "./hooks";
|
||||
import { createPlugins } from "./plugins";
|
||||
import { configuredProviders } from "./providers";
|
||||
|
||||
export const initAuth = (db: dbClient) => {
|
||||
return betterAuth({
|
||||
secret: process.env.BETTER_AUTH_SECRET!,
|
||||
secret: env("BETTER_AUTH_SECRET"),
|
||||
baseURL: env("NEXT_PUBLIC_BASE_URL"),
|
||||
trustedOrigins: process.env.BETTER_AUTH_TRUSTED_ORIGINS
|
||||
trustedOrigins: env("BETTER_AUTH_TRUSTED_ORIGINS")
|
||||
? [
|
||||
env("NEXT_PUBLIC_BASE_URL") ?? "",
|
||||
...process.env.BETTER_AUTH_TRUSTED_ORIGINS.split(","),
|
||||
...(env("BETTER_AUTH_TRUSTED_ORIGINS")?.split(",") ?? []),
|
||||
]
|
||||
: [env("NEXT_PUBLIC_BASE_URL") ?? ""],
|
||||
database: drizzleAdapter(db, {
|
||||
@@ -181,329 +57,9 @@ export const initAuth = (db: dbClient) => {
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
socialProvidersPlugin(),
|
||||
...(process.env.NEXT_PUBLIC_KAN_ENV === "cloud"
|
||||
? [
|
||||
stripe({
|
||||
stripeClient: createStripeClient(),
|
||||
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
|
||||
createCustomerOnSignUp: true,
|
||||
subscription: {
|
||||
enabled: true,
|
||||
plans: [
|
||||
{
|
||||
name: "team",
|
||||
priceId: process.env.STRIPE_TEAM_PLAN_MONTHLY_PRICE_ID!,
|
||||
annualDiscountPriceId:
|
||||
process.env.STRIPE_TEAM_PLAN_YEARLY_PRICE_ID!,
|
||||
freeTrial: {
|
||||
days: 14,
|
||||
onTrialStart: async (subscription) => {
|
||||
await triggerWorkflow(db, "trial-start", subscription);
|
||||
},
|
||||
onTrialEnd: async ({ subscription }) => {
|
||||
await triggerWorkflow(db, "trial-end", subscription);
|
||||
},
|
||||
onTrialExpired: async (subscription) => {
|
||||
await triggerWorkflow(
|
||||
db,
|
||||
"trial-expired",
|
||||
subscription,
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "pro",
|
||||
priceId: process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID!,
|
||||
annualDiscountPriceId:
|
||||
process.env.STRIPE_PRO_PLAN_YEARLY_PRICE_ID!,
|
||||
freeTrial: {
|
||||
days: 14,
|
||||
onTrialStart: async (subscription) => {
|
||||
await triggerWorkflow(db, "trial-start", subscription);
|
||||
},
|
||||
onTrialEnd: async ({ subscription }) => {
|
||||
await triggerWorkflow(db, "trial-end", subscription);
|
||||
},
|
||||
onTrialExpired: async (subscription) => {
|
||||
await triggerWorkflow(
|
||||
db,
|
||||
"trial-expired",
|
||||
subscription,
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
authorizeReference: async (data) => {
|
||||
const workspace = await workspaceRepo.getByPublicId(
|
||||
db,
|
||||
data.referenceId,
|
||||
);
|
||||
|
||||
if (!workspace) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
const isUserInWorkspace =
|
||||
await workspaceRepo.isUserInWorkspace(
|
||||
db,
|
||||
data.user.id,
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
return isUserInWorkspace;
|
||||
},
|
||||
getCheckoutSessionParams: () => {
|
||||
return {
|
||||
params: {
|
||||
allow_promotion_codes: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
onSubscriptionComplete: async ({
|
||||
subscription,
|
||||
stripeSubscription,
|
||||
}) => {
|
||||
// Set unlimited seats to true for pro plans
|
||||
if (subscription.plan === "pro") {
|
||||
await subscriptionRepo.updateByStripeSubscriptionId(
|
||||
db,
|
||||
stripeSubscription.id,
|
||||
{
|
||||
unlimitedSeats: true,
|
||||
},
|
||||
);
|
||||
console.log(
|
||||
`Pro subscription ${stripeSubscription.id} activated with unlimited seats`,
|
||||
);
|
||||
|
||||
const workspace = await workspaceRepo.getByPublicId(
|
||||
db,
|
||||
subscription.referenceId,
|
||||
);
|
||||
|
||||
if (workspace?.id) {
|
||||
await memberRepo.unpauseAllMembers(db, workspace.id);
|
||||
|
||||
console.log(
|
||||
`Unpausing all members for workspace ${workspace.id}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
apiKey({
|
||||
enableSessionForAPIKeys: true,
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
timeWindow: 1000 * 60, // 1 minute
|
||||
maxRequests: 100, // 100 requests per minute
|
||||
},
|
||||
}),
|
||||
magicLink({
|
||||
expiresIn: 60 * 60 * 24 * 7, // 7 days
|
||||
sendMagicLink: async ({ email, url }) => {
|
||||
if (url.includes("type=invite")) {
|
||||
await sendEmail(
|
||||
email,
|
||||
"Invitation to join workspace",
|
||||
"JOIN_WORKSPACE",
|
||||
{
|
||||
magicLoginUrl: url,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
await sendEmail(email, "Sign in to kan.bn", "MAGIC_LINK", {
|
||||
magicLoginUrl: url,
|
||||
});
|
||||
}
|
||||
},
|
||||
}),
|
||||
// Generic OIDC provider
|
||||
...(process.env.OIDC_CLIENT_ID &&
|
||||
process.env.OIDC_CLIENT_SECRET &&
|
||||
process.env.OIDC_DISCOVERY_URL
|
||||
? [
|
||||
genericOAuth({
|
||||
config: [
|
||||
{
|
||||
providerId: "oidc",
|
||||
clientId: process.env.OIDC_CLIENT_ID,
|
||||
clientSecret: process.env.OIDC_CLIENT_SECRET,
|
||||
discoveryUrl: process.env.OIDC_DISCOVERY_URL,
|
||||
scopes: ["openid", "email", "profile"],
|
||||
pkce: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
databaseHooks: {
|
||||
user: {
|
||||
create: {
|
||||
async before(user) {
|
||||
if (env("NEXT_PUBLIC_DISABLE_SIGN_UP")?.toLowerCase() === "true") {
|
||||
const pendingInvitation = await memberRepo.getByEmailAndStatus(
|
||||
db,
|
||||
user.email,
|
||||
"invited",
|
||||
);
|
||||
|
||||
if (!pendingInvitation) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
// Fall through to any additional checks below
|
||||
}
|
||||
// Enforce allowed domains (OIDC/social) if configured
|
||||
const allowed = process.env.BETTER_AUTH_ALLOWED_DOMAINS?.split(",")
|
||||
.map((d) => d.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
if (allowed && allowed.length > 0) {
|
||||
const domain = user.email.split("@")[1]?.toLowerCase();
|
||||
if (!domain || !allowed.includes(domain)) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
}
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
async after(user) {
|
||||
let avatarKey = user.image;
|
||||
if (
|
||||
user.image &&
|
||||
!user.image.includes(process.env.NEXT_PUBLIC_STORAGE_DOMAIN!)
|
||||
) {
|
||||
try {
|
||||
const credentials =
|
||||
env("S3_ACCESS_KEY_ID") && env("S3_SECRET_ACCESS_KEY")
|
||||
? {
|
||||
accessKeyId: env("S3_ACCESS_KEY_ID")!,
|
||||
secretAccessKey: env("S3_SECRET_ACCESS_KEY")!,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const client = new S3Client({
|
||||
region: env("S3_REGION") ?? "",
|
||||
endpoint: env("S3_ENDPOINT") ?? "",
|
||||
forcePathStyle: env("S3_FORCE_PATH_STYLE") === "true",
|
||||
credentials,
|
||||
});
|
||||
|
||||
const allowedFileExtensions = ["jpg", "jpeg", "png", "webp"];
|
||||
|
||||
const fileExtension =
|
||||
user.image.split(".").pop()?.split("?")[0] || "jpg";
|
||||
const key = `${user.id}/avatar.${!allowedFileExtensions.includes(fileExtension) ? "jpg" : fileExtension}`;
|
||||
|
||||
const imageBuffer = await downloadImage(user.image);
|
||||
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: env("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "",
|
||||
Key: key,
|
||||
Body: imageBuffer,
|
||||
ContentType: `image/${!allowedFileExtensions.includes(fileExtension) ? "jpeg" : fileExtension}`,
|
||||
ACL: "public-read",
|
||||
}),
|
||||
);
|
||||
|
||||
avatarKey = key;
|
||||
|
||||
await userRepo.update(db, user.id, {
|
||||
image: key,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (notificationClient) {
|
||||
try {
|
||||
const [firstName, ...rest] = user.name
|
||||
.split(" ")
|
||||
.filter(Boolean);
|
||||
const lastName = rest.length ? rest.join(" ") : undefined;
|
||||
const avatarUrl = avatarKey
|
||||
? `${env("NEXT_PUBLIC_STORAGE_URL")}/${env("NEXT_PUBLIC_AVATAR_BUCKET_NAME")}/${avatarKey}`
|
||||
: undefined;
|
||||
|
||||
const unsubscribeUrl = await createEmailUnsubscribeLink(
|
||||
user.id,
|
||||
);
|
||||
|
||||
await notificationClient.trigger({
|
||||
to: {
|
||||
subscriberId: user.id,
|
||||
firstName: firstName,
|
||||
lastName: lastName,
|
||||
email: user.email,
|
||||
avatar: avatarUrl,
|
||||
data: {
|
||||
emailVerified: user.emailVerified,
|
||||
stripeCustomerId: user.stripeCustomerId,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
},
|
||||
},
|
||||
payload: {
|
||||
emailUnsubscribeUrl: unsubscribeUrl,
|
||||
},
|
||||
workflowId: "user-signup",
|
||||
});
|
||||
|
||||
await notificationClient.subscribers.credentials.update(
|
||||
{
|
||||
providerId: ChatOrPushProviderEnum.Discord,
|
||||
credentials: {
|
||||
webhookUrl: process.env.DISCORD_WEBHOOK_URL!,
|
||||
},
|
||||
integrationIdentifier: "discord",
|
||||
},
|
||||
user.id,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Error adding user to notification client",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
hooks: {
|
||||
after: createAuthMiddleware(async (ctx) => {
|
||||
if (
|
||||
ctx.path === "/magic-link/verify" &&
|
||||
(ctx.query?.callbackURL as string | undefined)?.includes(
|
||||
"type=invite",
|
||||
)
|
||||
) {
|
||||
const userId = ctx.context.newSession?.session.userId;
|
||||
const callbackURL = ctx.query?.callbackURL as string | undefined;
|
||||
const memberPublicId = callbackURL?.split("memberPublicId=")[1];
|
||||
|
||||
if (userId && memberPublicId) {
|
||||
const member = await memberRepo.getByPublicId(db, memberPublicId);
|
||||
|
||||
if (member?.id) {
|
||||
await memberRepo.acceptInvite(db, {
|
||||
memberId: member.id,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
},
|
||||
plugins: createPlugins(db),
|
||||
databaseHooks: createDatabaseHooks(db),
|
||||
hooks: createMiddlewareHooks(db),
|
||||
advanced: {
|
||||
cookiePrefix: "kan",
|
||||
database: {
|
||||
@@ -512,37 +68,3 @@ export const initAuth = (db: dbClient) => {
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
async function triggerWorkflow(
|
||||
db: dbClient,
|
||||
workflowId: string,
|
||||
subscription: Subscription,
|
||||
cancellationDetails?: Stripe.Subscription.CancellationDetails | null,
|
||||
) {
|
||||
try {
|
||||
if (!subscription.stripeCustomerId || !notificationClient) return;
|
||||
|
||||
const user = await userRepo.getByStripeCustomerId(
|
||||
db,
|
||||
subscription.stripeCustomerId,
|
||||
);
|
||||
|
||||
if (!user || !notificationClient) return;
|
||||
|
||||
const unsubscribeUrl = await createEmailUnsubscribeLink(user.id);
|
||||
|
||||
await notificationClient.trigger({
|
||||
to: {
|
||||
subscriberId: user.id,
|
||||
},
|
||||
payload: {
|
||||
...subscription,
|
||||
cancellationDetails,
|
||||
emailUnsubscribeUrl: unsubscribeUrl,
|
||||
},
|
||||
workflowId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error triggering workflow", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "better-auth/client/plugins";
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
|
||||
import type { socialProvidersPlugin } from "./auth";
|
||||
import type { socialProvidersPlugin } from "./providers";
|
||||
|
||||
const socialProvidersPluginClient = {
|
||||
id: "social-providers-plugin",
|
||||
|
||||
183
packages/auth/src/hooks.ts
Normal file
183
packages/auth/src/hooks.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
||||
import { ChatOrPushProviderEnum } from "@novu/api/models/components";
|
||||
import { createAuthMiddleware } from "better-auth/api";
|
||||
import { env } from "next-runtime-env";
|
||||
|
||||
import type { dbClient } from "@kan/db/client";
|
||||
import * as memberRepo from "@kan/db/repository/member.repo";
|
||||
import * as userRepo from "@kan/db/repository/user.repo";
|
||||
import { notificationClient } from "@kan/email";
|
||||
import { createEmailUnsubscribeLink } from "@kan/shared";
|
||||
|
||||
import { downloadImage } from "./utils";
|
||||
|
||||
type BetterAuthUser = {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
email: string;
|
||||
emailVerified: boolean;
|
||||
name: string;
|
||||
image?: string | null | undefined;
|
||||
stripeCustomerId?: string | null | undefined;
|
||||
} & Record<string, unknown>;
|
||||
|
||||
export function createDatabaseHooks(db: dbClient) {
|
||||
return {
|
||||
user: {
|
||||
create: {
|
||||
async before(user: BetterAuthUser, _context: unknown) {
|
||||
if (env("NEXT_PUBLIC_DISABLE_SIGN_UP")?.toLowerCase() === "true") {
|
||||
const pendingInvitation = await memberRepo.getByEmailAndStatus(
|
||||
db,
|
||||
user.email,
|
||||
"invited",
|
||||
);
|
||||
|
||||
if (!pendingInvitation) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
// Fall through to any additional checks below
|
||||
}
|
||||
// Enforce allowed domains (OIDC/social) if configured
|
||||
const allowed = process.env.BETTER_AUTH_ALLOWED_DOMAINS?.split(",")
|
||||
.map((d) => d.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
if (allowed && allowed.length > 0) {
|
||||
const domain = user.email.split("@")[1]?.toLowerCase();
|
||||
if (!domain || !allowed.includes(domain)) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
}
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
async after(user: BetterAuthUser, _context: unknown) {
|
||||
let avatarKey = user.image;
|
||||
const storageDomain = process.env.NEXT_PUBLIC_STORAGE_DOMAIN;
|
||||
if (
|
||||
user.image &&
|
||||
storageDomain &&
|
||||
!user.image.includes(storageDomain)
|
||||
) {
|
||||
try {
|
||||
const credentials =
|
||||
env("S3_ACCESS_KEY_ID") && env("S3_SECRET_ACCESS_KEY")
|
||||
? {
|
||||
accessKeyId: env("S3_ACCESS_KEY_ID")!,
|
||||
secretAccessKey: env("S3_SECRET_ACCESS_KEY")!,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const client = new S3Client({
|
||||
region: env("S3_REGION") ?? "",
|
||||
endpoint: env("S3_ENDPOINT") ?? "",
|
||||
forcePathStyle: env("S3_FORCE_PATH_STYLE") === "true",
|
||||
credentials,
|
||||
});
|
||||
|
||||
const allowedFileExtensions = ["jpg", "jpeg", "png", "webp"];
|
||||
|
||||
const fileExtension =
|
||||
user.image.split(".").pop()?.split("?")[0] ?? "jpg";
|
||||
const key = `${user.id}/avatar.${!allowedFileExtensions.includes(fileExtension) ? "jpg" : fileExtension}`;
|
||||
|
||||
const imageBuffer = await downloadImage(user.image);
|
||||
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: env("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "",
|
||||
Key: key,
|
||||
Body: imageBuffer,
|
||||
ContentType: `image/${!allowedFileExtensions.includes(fileExtension) ? "jpeg" : fileExtension}`,
|
||||
ACL: "public-read",
|
||||
}),
|
||||
);
|
||||
|
||||
avatarKey = key;
|
||||
|
||||
await userRepo.update(db, user.id, {
|
||||
image: key,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (notificationClient) {
|
||||
try {
|
||||
const [firstName, ...rest] = (user.name || "")
|
||||
.split(" ")
|
||||
.filter(Boolean);
|
||||
const lastName = rest.length ? rest.join(" ") : undefined;
|
||||
const avatarUrl = avatarKey
|
||||
? `${env("NEXT_PUBLIC_STORAGE_URL")}/${env("NEXT_PUBLIC_AVATAR_BUCKET_NAME")}/${avatarKey}`
|
||||
: undefined;
|
||||
|
||||
const unsubscribeUrl = await createEmailUnsubscribeLink(user.id);
|
||||
|
||||
await notificationClient.trigger({
|
||||
to: {
|
||||
subscriberId: user.id,
|
||||
firstName: firstName,
|
||||
lastName: lastName,
|
||||
email: user.email,
|
||||
avatar: avatarUrl,
|
||||
data: {
|
||||
emailVerified: user.emailVerified,
|
||||
stripeCustomerId: user.stripeCustomerId,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
},
|
||||
},
|
||||
payload: {
|
||||
emailUnsubscribeUrl: unsubscribeUrl,
|
||||
},
|
||||
workflowId: "user-signup",
|
||||
});
|
||||
|
||||
await notificationClient.subscribers.credentials.update(
|
||||
{
|
||||
providerId: ChatOrPushProviderEnum.Discord,
|
||||
credentials: {
|
||||
webhookUrl: env("DISCORD_WEBHOOK_URL"),
|
||||
},
|
||||
integrationIdentifier: "discord",
|
||||
},
|
||||
user.id,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error adding user to notification client", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createMiddlewareHooks(db: dbClient) {
|
||||
return {
|
||||
after: createAuthMiddleware(async (ctx) => {
|
||||
if (
|
||||
ctx.path === "/magic-link/verify" &&
|
||||
(ctx.query?.callbackURL as string | undefined)?.includes("type=invite")
|
||||
) {
|
||||
const userId = ctx.context.newSession?.session.userId;
|
||||
const callbackURL = ctx.query?.callbackURL as string | undefined;
|
||||
const memberPublicId = callbackURL?.split("memberPublicId=")[1];
|
||||
|
||||
if (userId && memberPublicId) {
|
||||
const member = await memberRepo.getByPublicId(db, memberPublicId);
|
||||
|
||||
if (member?.id) {
|
||||
await memberRepo.acceptInvite(db, {
|
||||
memberId: member.id,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
};
|
||||
}
|
||||
241
packages/auth/src/plugins.ts
Normal file
241
packages/auth/src/plugins.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import { stripe } from "@better-auth/stripe";
|
||||
import { apiKey, genericOAuth } from "better-auth/plugins";
|
||||
import { magicLink } from "better-auth/plugins/magic-link";
|
||||
|
||||
import type { dbClient } from "@kan/db/client";
|
||||
import * as memberRepo from "@kan/db/repository/member.repo";
|
||||
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import { generateUID } from "@kan/shared/utils";
|
||||
import { sendEmail } from "@kan/email";
|
||||
import { createStripeClient } from "@kan/stripe";
|
||||
|
||||
import { socialProvidersPlugin } from "./providers";
|
||||
import { triggerWorkflow } from "./utils";
|
||||
|
||||
export function createPlugins(db: dbClient) {
|
||||
return [
|
||||
socialProvidersPlugin(),
|
||||
...(process.env.NEXT_PUBLIC_KAN_ENV === "cloud"
|
||||
? [
|
||||
stripe({
|
||||
stripeClient: createStripeClient(),
|
||||
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
|
||||
createCustomerOnSignUp: true,
|
||||
subscription: {
|
||||
enabled: true,
|
||||
plans: [
|
||||
{
|
||||
name: "team",
|
||||
priceId: process.env.STRIPE_TEAM_PLAN_MONTHLY_PRICE_ID!,
|
||||
annualDiscountPriceId:
|
||||
process.env.STRIPE_TEAM_PLAN_YEARLY_PRICE_ID!,
|
||||
freeTrial: {
|
||||
days: 14,
|
||||
onTrialStart: async (subscription) => {
|
||||
await triggerWorkflow(db, "trial-start", subscription);
|
||||
},
|
||||
onTrialEnd: async ({ subscription }) => {
|
||||
await triggerWorkflow(db, "trial-end", subscription);
|
||||
},
|
||||
onTrialExpired: async (subscription) => {
|
||||
await triggerWorkflow(db, "trial-expired", subscription);
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "pro",
|
||||
priceId: process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID!,
|
||||
annualDiscountPriceId:
|
||||
process.env.STRIPE_PRO_PLAN_YEARLY_PRICE_ID!,
|
||||
freeTrial: {
|
||||
days: 14,
|
||||
onTrialStart: async (subscription) => {
|
||||
await triggerWorkflow(db, "trial-start", subscription);
|
||||
},
|
||||
onTrialEnd: async ({ subscription }) => {
|
||||
await triggerWorkflow(db, "trial-end", subscription);
|
||||
},
|
||||
onTrialExpired: async (subscription) => {
|
||||
await triggerWorkflow(db, "trial-expired", subscription);
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
authorizeReference: async (data) => {
|
||||
const workspace = await workspaceRepo.getByPublicId(
|
||||
db,
|
||||
data.referenceId,
|
||||
);
|
||||
|
||||
if (!workspace) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
const isUserInWorkspace = await workspaceRepo.isUserInWorkspace(
|
||||
db,
|
||||
data.user.id,
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
return isUserInWorkspace;
|
||||
},
|
||||
getCheckoutSessionParams: () => {
|
||||
return {
|
||||
params: {
|
||||
allow_promotion_codes: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
onSubscriptionComplete: async ({
|
||||
subscription,
|
||||
stripeSubscription,
|
||||
}) => {
|
||||
// Set unlimited seats to true for pro plans
|
||||
if (subscription.plan === "pro") {
|
||||
await subscriptionRepo.updateByStripeSubscriptionId(
|
||||
db,
|
||||
stripeSubscription.id,
|
||||
{
|
||||
unlimitedSeats: true,
|
||||
},
|
||||
);
|
||||
console.log(
|
||||
`Pro subscription ${stripeSubscription.id} activated with unlimited seats`,
|
||||
);
|
||||
|
||||
const workspace = await workspaceRepo.getByPublicId(
|
||||
db,
|
||||
subscription.referenceId,
|
||||
);
|
||||
|
||||
if (workspace?.id) {
|
||||
await memberRepo.unpauseAllMembers(db, workspace.id);
|
||||
}
|
||||
}
|
||||
},
|
||||
onSubscriptionCancel: async ({
|
||||
subscription,
|
||||
cancellationDetails,
|
||||
}) => {
|
||||
await triggerWorkflow(
|
||||
db,
|
||||
"subscription-canceled",
|
||||
subscription,
|
||||
cancellationDetails,
|
||||
);
|
||||
|
||||
// for cancelled subscriptions, we need to pause all members and set their workspace plan to free
|
||||
const workspace = await workspaceRepo.getByPublicId(
|
||||
db,
|
||||
subscription.referenceId,
|
||||
);
|
||||
|
||||
if (workspace?.id) {
|
||||
await memberRepo.pauseAllMembers(db, workspace.id);
|
||||
|
||||
// Reset slug to publicId, or generate a UID if publicId is taken
|
||||
let newSlug = workspace.publicId;
|
||||
|
||||
if (workspace.slug !== workspace.publicId) {
|
||||
const isPublicIdAvailable = await workspaceRepo.isWorkspaceSlugAvailable(
|
||||
db,
|
||||
workspace.publicId,
|
||||
);
|
||||
if (!isPublicIdAvailable) {
|
||||
newSlug = generateUID();
|
||||
}
|
||||
}
|
||||
|
||||
await workspaceRepo.update(db, subscription.referenceId, {
|
||||
plan: "free",
|
||||
slug: newSlug,
|
||||
});
|
||||
}
|
||||
},
|
||||
onSubscriptionUpdate: async ({ subscription }) => {
|
||||
await triggerWorkflow(db, "subscription-updated", subscription);
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
apiKey({
|
||||
enableSessionForAPIKeys: true,
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
timeWindow: 1000 * 60, // 1 minute
|
||||
maxRequests: 100, // 100 requests per minute
|
||||
},
|
||||
}),
|
||||
magicLink({
|
||||
expiresIn: 60 * 60 * 24 * 7, // 7 days
|
||||
sendMagicLink: async ({ email, url }) => {
|
||||
if (url.includes("type=invite")) {
|
||||
await sendEmail(
|
||||
email,
|
||||
"Invitation to join workspace",
|
||||
"JOIN_WORKSPACE",
|
||||
{
|
||||
magicLoginUrl: url,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
await sendEmail(email, "Sign in to kan.bn", "MAGIC_LINK", {
|
||||
magicLoginUrl: url,
|
||||
});
|
||||
}
|
||||
},
|
||||
}),
|
||||
// Generic OIDC provider
|
||||
...(process.env.OIDC_CLIENT_ID &&
|
||||
process.env.OIDC_CLIENT_SECRET &&
|
||||
process.env.OIDC_DISCOVERY_URL
|
||||
? [
|
||||
genericOAuth({
|
||||
config: [
|
||||
{
|
||||
providerId: "oidc",
|
||||
clientId: process.env.OIDC_CLIENT_ID,
|
||||
clientSecret: process.env.OIDC_CLIENT_SECRET,
|
||||
discoveryUrl: process.env.OIDC_DISCOVERY_URL,
|
||||
scopes: ["openid", "email", "profile"],
|
||||
pkce: true,
|
||||
mapProfileToUser: (profile: {
|
||||
name?: string;
|
||||
display_name?: string;
|
||||
preferred_username?: string;
|
||||
given_name?: string;
|
||||
family_name?: string;
|
||||
email?: string;
|
||||
email_verified?: boolean;
|
||||
sub?: string;
|
||||
picture?: string;
|
||||
avatar?: string;
|
||||
}) => {
|
||||
console.log("OIDC profile:", profile);
|
||||
|
||||
const name =
|
||||
profile.name ??
|
||||
profile.display_name ??
|
||||
profile.preferred_username ??
|
||||
(profile.given_name && profile.family_name
|
||||
? `${profile.given_name} ${profile.family_name}`.trim()
|
||||
: (profile.given_name ?? profile.family_name)) ??
|
||||
profile.sub ??
|
||||
"";
|
||||
|
||||
return {
|
||||
email: profile.email,
|
||||
name: name,
|
||||
emailVerified: profile.email_verified ?? false,
|
||||
image: profile.picture ?? profile.avatar ?? null,
|
||||
};
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
107
packages/auth/src/providers.ts
Normal file
107
packages/auth/src/providers.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { createAuthEndpoint } from "better-auth/api";
|
||||
import { socialProviderList } from "better-auth/social-providers";
|
||||
|
||||
export const configuredProviders = socialProviderList.reduce<
|
||||
Record<
|
||||
string,
|
||||
{
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
appBundleIdentifier?: string;
|
||||
tenantId?: string;
|
||||
requireSelectAccount?: boolean;
|
||||
clientKey?: string;
|
||||
issuer?: string;
|
||||
// Google-specific optional hints
|
||||
hostedDomain?: string;
|
||||
hd?: string;
|
||||
}
|
||||
>
|
||||
>((acc, provider) => {
|
||||
const id = process.env[`${provider.toUpperCase()}_CLIENT_ID`];
|
||||
const secret = process.env[`${provider.toUpperCase()}_CLIENT_SECRET`];
|
||||
if (id && id.length > 0 && secret && secret.length > 0) {
|
||||
acc[provider] = { clientId: id, clientSecret: secret };
|
||||
}
|
||||
if (
|
||||
provider === "apple" &&
|
||||
Object.keys(acc).includes("apple") &&
|
||||
acc[provider]
|
||||
) {
|
||||
const bundleId =
|
||||
process.env[`${provider.toUpperCase()}_APP_BUNDLE_IDENTIFIER`];
|
||||
if (bundleId && bundleId.length > 0) {
|
||||
acc[provider].appBundleIdentifier = bundleId;
|
||||
}
|
||||
}
|
||||
if (
|
||||
provider === "gitlab" &&
|
||||
Object.keys(acc).includes("gitlab") &&
|
||||
acc[provider]
|
||||
) {
|
||||
const issuer = process.env[`${provider.toUpperCase()}_ISSUER`];
|
||||
if (issuer && issuer.length > 0) {
|
||||
acc[provider].issuer = issuer;
|
||||
}
|
||||
}
|
||||
if (
|
||||
provider === "microsoft" &&
|
||||
Object.keys(acc).includes("microsoft") &&
|
||||
acc[provider]
|
||||
) {
|
||||
acc[provider].tenantId = "common";
|
||||
acc[provider].requireSelectAccount = true;
|
||||
}
|
||||
// Add Google domain hint if allowed domains is configured
|
||||
if (
|
||||
provider === "google" &&
|
||||
Object.keys(acc).includes("google") &&
|
||||
acc[provider]
|
||||
) {
|
||||
const allowed = process.env.BETTER_AUTH_ALLOWED_DOMAINS?.split(",")
|
||||
.map((d) => d.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
if (allowed && allowed.length > 0) {
|
||||
// Use the first domain as an authorization hint
|
||||
acc[provider].hostedDomain = allowed[0];
|
||||
acc[provider].hd = allowed[0];
|
||||
}
|
||||
}
|
||||
if (
|
||||
provider === "tiktok" &&
|
||||
Object.keys(acc).includes("tiktok") &&
|
||||
acc[provider]
|
||||
) {
|
||||
const key = process.env[`${provider.toUpperCase()}_CLIENT_KEY`];
|
||||
if (key && key.length > 0) {
|
||||
acc[provider].clientKey = key;
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
export const socialProvidersPlugin = () => ({
|
||||
id: "social-providers-plugin",
|
||||
endpoints: {
|
||||
getSocialProviders: createAuthEndpoint(
|
||||
"/social-providers",
|
||||
{
|
||||
method: "GET",
|
||||
},
|
||||
async (ctx) => {
|
||||
const providers = ctx.context.socialProviders.map((p) =>
|
||||
p.id.toLowerCase(),
|
||||
);
|
||||
// Add OIDC provider if configured
|
||||
if (
|
||||
process.env.OIDC_CLIENT_ID &&
|
||||
process.env.OIDC_CLIENT_SECRET &&
|
||||
process.env.OIDC_DISCOVERY_URL
|
||||
) {
|
||||
providers.push("oidc");
|
||||
}
|
||||
return ctx.json(providers);
|
||||
},
|
||||
),
|
||||
},
|
||||
});
|
||||
49
packages/auth/src/utils.ts
Normal file
49
packages/auth/src/utils.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { Subscription } from "@better-auth/stripe";
|
||||
import type Stripe from "stripe";
|
||||
|
||||
import type { dbClient } from "@kan/db/client";
|
||||
import * as userRepo from "@kan/db/repository/user.repo";
|
||||
import { notificationClient } from "@kan/email";
|
||||
import { createEmailUnsubscribeLink } from "@kan/shared";
|
||||
|
||||
export async function downloadImage(url: string): Promise<Buffer> {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download image: ${response.statusText}`);
|
||||
}
|
||||
return Buffer.from(await response.arrayBuffer());
|
||||
}
|
||||
|
||||
export async function triggerWorkflow(
|
||||
db: dbClient,
|
||||
workflowId: string,
|
||||
subscription: Subscription,
|
||||
cancellationDetails?: Stripe.Subscription.CancellationDetails | null,
|
||||
) {
|
||||
try {
|
||||
if (!subscription.stripeCustomerId || !notificationClient) return;
|
||||
|
||||
const user = await userRepo.getByStripeCustomerId(
|
||||
db,
|
||||
subscription.stripeCustomerId,
|
||||
);
|
||||
|
||||
if (!user || !notificationClient) return;
|
||||
|
||||
const unsubscribeUrl = await createEmailUnsubscribeLink(user.id);
|
||||
|
||||
await notificationClient.trigger({
|
||||
to: {
|
||||
subscriberId: user.id,
|
||||
},
|
||||
payload: {
|
||||
...subscription,
|
||||
cancellationDetails,
|
||||
emailUnsubscribeUrl: unsubscribeUrl,
|
||||
},
|
||||
workflowId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error triggering workflow", error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "card_activity" ALTER COLUMN "fromTitle" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "card_activity" ALTER COLUMN "toTitle" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "card" ALTER COLUMN "title" SET DATA TYPE text;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "workspace" ADD COLUMN "showEmailsToMembers" boolean NOT NULL DEFAULT true;
|
||||
2975
packages/db/migrations/meta/20251229220153_snapshot.json
Normal file
2975
packages/db/migrations/meta/20251229220153_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -148,6 +148,20 @@
|
||||
"when": 1764621815672,
|
||||
"tag": "20251201204335_AddCardDueDates",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 21,
|
||||
"version": "7",
|
||||
"when": 1767045713686,
|
||||
"tag": "20251229220153_UpdateCardTitleFromVarcharToText",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 22,
|
||||
"version": "7",
|
||||
"when": 1768858977000,
|
||||
"tag": "20260119164257_AddShowEmailsToMembersToWorkspace",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -23,6 +23,10 @@
|
||||
"./repository/*": {
|
||||
"types": "./dist/repository/*.d.ts",
|
||||
"default": "./src/repository/*.ts"
|
||||
},
|
||||
"./redis": {
|
||||
"types": "./dist/redis.d.ts",
|
||||
"default": "./src/redis.ts"
|
||||
}
|
||||
},
|
||||
"license": "GPL-3.0",
|
||||
@@ -43,6 +47,7 @@
|
||||
"@kan/shared": "workspace:^",
|
||||
"drizzle-orm": "^0.42.0",
|
||||
"drizzle-zod": "^0.5.1",
|
||||
"ioredis": "^5.9.2",
|
||||
"pg": "^8.11.3",
|
||||
"uuid": "^11.1.0",
|
||||
"zod": "catalog:"
|
||||
|
||||
31
packages/db/src/redis.ts
Normal file
31
packages/db/src/redis.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import Redis from "ioredis";
|
||||
|
||||
let redisClient: Redis | null = null;
|
||||
|
||||
export function getRedisClient(): Redis | null {
|
||||
if (redisClient) {
|
||||
return redisClient;
|
||||
}
|
||||
|
||||
const redisUrl = process.env.REDIS_URL;
|
||||
|
||||
if (!redisUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
redisClient = new Redis(redisUrl, {
|
||||
maxRetriesPerRequest: 3,
|
||||
enableReadyCheck: true,
|
||||
lazyConnect: true,
|
||||
});
|
||||
|
||||
return redisClient;
|
||||
}
|
||||
|
||||
export async function closeRedisClient(): Promise<void> {
|
||||
if (redisClient) {
|
||||
await redisClient.quit();
|
||||
redisClient = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,6 +169,7 @@ export const getPaginatedActivities = async (
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
image: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -178,6 +179,7 @@ export const getPaginatedActivities = async (
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
image: true,
|
||||
},
|
||||
},
|
||||
comment: {
|
||||
|
||||
@@ -121,3 +121,15 @@ export const unpauseAllMembers = async (db: dbClient, workspaceId: number) => {
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
export const pauseAllMembers = async (db: dbClient, workspaceId: number) => {
|
||||
await db
|
||||
.update(workspaceMembers)
|
||||
.set({ status: "paused" })
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceMembers.workspaceId, workspaceId),
|
||||
eq(workspaceMembers.status, "active"),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -82,6 +82,7 @@ export const update = async (
|
||||
slug?: string;
|
||||
plan?: "free" | "pro" | "enterprise";
|
||||
description?: string;
|
||||
showEmailsToMembers?: boolean;
|
||||
},
|
||||
) => {
|
||||
const [result] = await db
|
||||
@@ -91,6 +92,7 @@ export const update = async (
|
||||
slug: workspaceInput.slug,
|
||||
plan: workspaceInput.plan,
|
||||
description: workspaceInput.description,
|
||||
showEmailsToMembers: workspaceInput.showEmailsToMembers,
|
||||
})
|
||||
.where(eq(workspaces.publicId, workspacePublicId))
|
||||
.returning({
|
||||
@@ -100,6 +102,7 @@ export const update = async (
|
||||
slug: workspaces.slug,
|
||||
description: workspaces.description,
|
||||
plan: workspaces.plan,
|
||||
showEmailsToMembers: workspaces.showEmailsToMembers,
|
||||
});
|
||||
|
||||
return result;
|
||||
@@ -139,6 +142,7 @@ export const getByPublicIdWithMembers = (
|
||||
columns: {
|
||||
id: true,
|
||||
publicId: true,
|
||||
showEmailsToMembers: true,
|
||||
},
|
||||
with: {
|
||||
members: {
|
||||
|
||||
@@ -57,7 +57,7 @@ export const activityTypeEnum = pgEnum("card_activity_type", activityTypes);
|
||||
export const cards = pgTable("card", {
|
||||
id: bigserial("id", { mode: "number" }).primaryKey(),
|
||||
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
|
||||
title: varchar("title", { length: 255 }).notNull(),
|
||||
title: text("title").notNull(),
|
||||
description: text("description"),
|
||||
index: integer("index").notNull(),
|
||||
createdBy: uuid("createdBy").references(() => users.id, {
|
||||
@@ -127,8 +127,8 @@ export const cardActivities = pgTable("card_activity", {
|
||||
workspaceMemberId: bigint("workspaceMemberId", {
|
||||
mode: "number",
|
||||
}).references(() => workspaceMembers.id, { onDelete: "set null" }),
|
||||
fromTitle: varchar("fromTitle", { length: 255 }),
|
||||
toTitle: varchar("toTitle", { length: 255 }),
|
||||
fromTitle: text("fromTitle"),
|
||||
toTitle: text("toTitle"),
|
||||
fromDescription: text("fromDescription"),
|
||||
toDescription: text("toDescription"),
|
||||
createdBy: uuid("createdBy").references(() => users.id, {
|
||||
|
||||
@@ -43,6 +43,7 @@ export const workspaces = pgTable("workspace", {
|
||||
description: text("description"),
|
||||
slug: varchar("slug", { length: 255 }).notNull().unique(),
|
||||
plan: workspacePlanEnum("plan").notNull().default("free"),
|
||||
showEmailsToMembers: boolean("showEmailsToMembers").notNull().default(true),
|
||||
createdBy: uuid("createdBy").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
|
||||
1002
pnpm-lock.yaml
generated
1002
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -111,9 +111,11 @@
|
||||
"STRIPE_TEAM_PLAN_YEARLY_PRICE_ID",
|
||||
"NEXT_PUBLIC_STORAGE_DOMAIN",
|
||||
"NEXT_PUBLIC_STORAGE_URL",
|
||||
"NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS",
|
||||
"NEXT_PUBLIC_AVATAR_BUCKET_NAME",
|
||||
"NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME",
|
||||
"NEXT_PUBLIC_ALLOW_CREDENTIALS",
|
||||
"NEXT_API_BODY_SIZE_LIMIT",
|
||||
"NEXT_PUBLIC_DISABLE_SIGN_UP",
|
||||
"NEXT_PUBLIC_USE_STANDALONE_OUTPUT",
|
||||
"S3_REGION",
|
||||
@@ -125,7 +127,8 @@
|
||||
"BETTER_AUTH_SECRET",
|
||||
"BETTER_AUTH_TRUSTED_ORIGINS",
|
||||
"NOVU_API_KEY",
|
||||
"EMAIL_UNSUBSCRIBE_SECRET"
|
||||
"EMAIL_UNSUBSCRIBE_SECRET",
|
||||
"REDIS_URL"
|
||||
],
|
||||
"globalPassThroughEnv": [
|
||||
"NODE_ENV",
|
||||
|
||||
Reference in New Issue
Block a user