Compare commits
2 Commits
fix/react-
...
refactor/w
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7367379a48 | ||
|
|
b0ac3e11de |
@@ -23,8 +23,8 @@ SMTP_REJECT_UNAUTHORIZED= # set to "false" to accept invalid certs
|
||||
NEXT_PUBLIC_DISABLE_EMAIL=
|
||||
|
||||
# S3 storage (optional)
|
||||
S3_REGION= # required for AWS S3 (e.g. us-east-1); ignored by most S3-compatible providers — defaults to us-east-1 if unset
|
||||
S3_ENDPOINT= # set for S3-compatible providers (MinIO, R2, Spaces, etc.); leave blank for AWS S3
|
||||
S3_REGION=
|
||||
S3_ENDPOINT=
|
||||
S3_ACCESS_KEY_ID=
|
||||
S3_SECRET_ACCESS_KEY=
|
||||
S3_FORCE_PATH_STYLE=
|
||||
|
||||
3
.github/ISSUE_TEMPLATE/feature_request.md
vendored
3
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -7,9 +7,6 @@ assignees: ''
|
||||
|
||||
---
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Wait for a maintainer to approve this issue before opening a PR. We'll signal approval with a comment or label. Feature PRs without an approved issue will be closed.
|
||||
|
||||
## ✨ Feature Request
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
|
||||
24
.github/PULL_REQUEST_TEMPLATE.md
vendored
24
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,24 +0,0 @@
|
||||
## Description
|
||||
|
||||
<!-- What does this PR do? Why? -->
|
||||
|
||||
## Type of change
|
||||
|
||||
- [ ] Bug fix
|
||||
- [ ] Feature (requires an approved issue — see below)
|
||||
- [ ] Refactor / chore
|
||||
- [ ] Documentation
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] I have linked the related issue below
|
||||
- [ ] My code follows the existing style and conventions
|
||||
- [ ] I have tested my changes locally
|
||||
- [ ] I have included screenshots for any UI changes
|
||||
|
||||
## Linked issue
|
||||
|
||||
Closes #<!-- issue number -->
|
||||
|
||||
> **Feature PRs without a linked, approved issue will be closed without review.**
|
||||
> Open or comment on a [feature request](https://github.com/kanbn/kan/issues/new?template=feature_request.md) first and wait for maintainer approval before building.
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -55,7 +55,4 @@ i18n.cache
|
||||
# pgdata
|
||||
/apps/web/pgdata
|
||||
|
||||
# local scripts
|
||||
scripts/
|
||||
|
||||
.claude
|
||||
@@ -1,54 +1,52 @@
|
||||
# Contributing to Kan
|
||||
|
||||
Thank you for your interest in contributing to Kan!
|
||||
Thank you for your interest in contributing to Kan! This document provides guidelines and instructions for contributing to the project.
|
||||
|
||||
## Before you start
|
||||
|
||||
**Open an issue before writing code for a new feature.**
|
||||
|
||||
Feature PRs opened without a prior approved issue will be closed without review - not because the idea is bad, but because we can't review code for features we haven't aligned on yet. This protects your time as much as ours.
|
||||
|
||||
The process is:
|
||||
|
||||
1. Open a [feature request](https://github.com/kanbn/kan/issues/new?template=feature_request.md) and describe what you want to build
|
||||
2. Wait for a maintainer to respond and signal approval (we aim to respond within a few days)
|
||||
3. Once approved, open a PR that links the issue
|
||||
|
||||
Bug fixes and documentation improvements don't need prior approval - just open a PR.
|
||||
|
||||
---
|
||||
|
||||
## Getting started
|
||||
## Getting Started
|
||||
|
||||
1. Fork the repository
|
||||
2. Clone your fork: `git clone https://github.com/YOUR-USERNAME/kan.git`
|
||||
3. Create a branch: `git checkout -b feat/your-feature-name` or `fix/your-fix-name`
|
||||
3. Create a new branch: `git checkout -b feat/your-feature-name`
|
||||
4. Install dependencies: `pnpm install`
|
||||
5. Copy `.env.example` to `.env` and configure your environment variables
|
||||
6. Make your changes
|
||||
7. Commit using [conventional commits](https://www.conventionalcommits.org/): `git commit -m "feat: description"`
|
||||
8. Push and open a Pull Request
|
||||
7. Commit your changes: `git commit -m "feat: description of changes"`
|
||||
8. Push to your fork: `git push origin feat/your-feature-name`
|
||||
9. Open a Pull Request
|
||||
|
||||
## Pull request expectations
|
||||
## Development Guidelines
|
||||
|
||||
- Link the related issue (required for features)
|
||||
- Write a clear description of what changed and why
|
||||
- Include screenshots for any UI changes
|
||||
- Keep PRs focused - one feature or fix per PR
|
||||
- All CI checks must pass before review
|
||||
### Code Style
|
||||
|
||||
## Code style
|
||||
|
||||
- Follow the existing patterns in the codebase
|
||||
- Use meaningful names - avoid comments that just restate what the code does
|
||||
- Follow the existing code style
|
||||
- Use meaningful variable and function names
|
||||
- Add comments for complex logic
|
||||
- Keep functions focused and concise
|
||||
|
||||
## Need help?
|
||||
### Commits
|
||||
|
||||
- Join our [Discord server](https://discord.gg/e6ejRb6CmT)
|
||||
- Check existing issues and PRs before opening new ones
|
||||
- Use clear and descriptive commit messages
|
||||
- Reference issue numbers when applicable
|
||||
- Keep commits focused on single changes
|
||||
|
||||
### Pull Requests
|
||||
|
||||
- Provide a clear description of the changes
|
||||
- Include screenshots for UI changes
|
||||
- Keep PRs focused on a single feature/fix
|
||||
|
||||
## Need Help?
|
||||
|
||||
- Join our [Discord server](https://discord.gg/e6ejRb6CmT) for questions
|
||||
- Check existing issues and pull requests
|
||||
- Email [henry@kan.bn](mailto:henry@kan.bn) for major concerns
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
Please note that this project is released with a Contributor Code of Conduct. By participating in this project you agree to abide by its terms.
|
||||
|
||||
We aim to foster an inclusive and welcoming community. Harassment and abusive behavior will not be tolerated.
|
||||
|
||||
## License
|
||||
|
||||
By contributing to Kan, you agree that your contributions will be licensed under the AGPLv3 License.
|
||||
|
||||
221
README.md
221
README.md
@@ -170,180 +170,53 @@ pnpm dev
|
||||
|
||||
## Environment Variables 🔐
|
||||
|
||||
| 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` |
|
||||
| `SMTP_USER` | SMTP username/email | No | `resend` |
|
||||
| `SMTP_PASSWORD` | SMTP password/token | No | `re_xxxx` |
|
||||
| `SMTP_SECURE` | Use secure SMTP connection (defaults to true if not set) | For Email | `true` |
|
||||
| `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` |
|
||||
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` |
|
||||
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` |
|
||||
| `DISCORD_CLIENT_ID` | Discord OAuth client ID | For Discord login | `xxx` |
|
||||
| `DISCORD_CLIENT_SECRET` | Discord OAuth client secret | For Discord login | `xxx` |
|
||||
| `GITHUB_CLIENT_ID` | GitHub OAuth client ID | For GitHub login | `xxx` |
|
||||
| `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret | For GitHub login | `xxx` |
|
||||
| `OIDC_CLIENT_ID` | Generic OIDC client ID | For OIDC login | `xxx` |
|
||||
| `OIDC_CLIENT_SECRET` | Generic OIDC client secret | For OIDC login | `xxx` |
|
||||
| `OIDC_DISCOVERY_URL` | OIDC discovery URL | For OIDC login | `https://auth.example.com/.well-known/openid-configuration` |
|
||||
| `TRELLO_APP_API_KEY` | Trello app API key | For Trello import | `xxx` |
|
||||
| `TRELLO_APP_API_SECRET` | Trello app API secret | For Trello import | `xxx` |
|
||||
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
|
||||
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
|
||||
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads (optional with IRSA) | `xxx` |
|
||||
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads (optional with IRSA) | `xxx` |
|
||||
| `S3_FORCE_PATH_STYLE` | Use path-style URLs for S3 | For file uploads | `true` |
|
||||
| `S3_AVATAR_UPLOAD_LIMIT` | Maximum avatar file size in bytes | For file uploads | `2097152` (2MB) |
|
||||
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
|
||||
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` |
|
||||
| `NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS` | Use virtual-hosted style URLs (bucket.domain.com) | For file uploads (optional) | `true` |
|
||||
| `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` |
|
||||
| `NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME` | S3 bucket name for attachments | For file uploads | `attachments` |
|
||||
| `NEXT_PUBLIC_ALLOW_CREDENTIALS` | Allow email & password login | For authentication | `true` |
|
||||
| `NEXT_PUBLIC_DISABLE_SIGN_UP` | Disable sign up | For authentication | `false` |
|
||||
| `NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY` | Hide “Powered by kan.bn” on public boards (self-host) | For white labelling | `true` |
|
||||
| `KAN_ADMIN_API_KEY` | Admin API key for stats and admin endpoints | For admin/monitoring | `your-secret-admin-key` |
|
||||
| `LOG_LEVEL` | Log verbosity level (debug, info, warn, error) | No (defaults to debug in dev, info in prod) | `info` |
|
||||
| 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` |
|
||||
| `SMTP_USER` | SMTP username/email | No | `resend` |
|
||||
| `SMTP_PASSWORD` | SMTP password/token | No | `re_xxxx` |
|
||||
| `SMTP_SECURE` | Use secure SMTP connection (defaults to true if not set) | For Email | `true` |
|
||||
| `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` |
|
||||
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` |
|
||||
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` |
|
||||
| `DISCORD_CLIENT_ID` | Discord OAuth client ID | For Discord login | `xxx` |
|
||||
| `DISCORD_CLIENT_SECRET` | Discord OAuth client secret | For Discord login | `xxx` |
|
||||
| `GITHUB_CLIENT_ID` | GitHub OAuth client ID | For GitHub login | `xxx` |
|
||||
| `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret | For GitHub login | `xxx` |
|
||||
| `OIDC_CLIENT_ID` | Generic OIDC client ID | For OIDC login | `xxx` |
|
||||
| `OIDC_CLIENT_SECRET` | Generic OIDC client secret | For OIDC login | `xxx` |
|
||||
| `OIDC_DISCOVERY_URL` | OIDC discovery URL | For OIDC login | `https://auth.example.com/.well-known/openid-configuration` |
|
||||
| `TRELLO_APP_API_KEY` | Trello app API key | For Trello import | `xxx` |
|
||||
| `TRELLO_APP_API_SECRET` | Trello app API secret | For Trello import | `xxx` |
|
||||
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
|
||||
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
|
||||
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads (optional with IRSA) | `xxx` |
|
||||
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads (optional with IRSA) | `xxx` |
|
||||
| `S3_FORCE_PATH_STYLE` | Use path-style URLs for S3 | For file uploads | `true` |
|
||||
| `S3_AVATAR_UPLOAD_LIMIT` | Maximum avatar file size in bytes | For file uploads | `2097152` (2MB) |
|
||||
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
|
||||
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` |
|
||||
| `NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS` | Use virtual-hosted style URLs (bucket.domain.com) | For file uploads (optional) | `true` |
|
||||
| `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` |
|
||||
| `NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME` | S3 bucket name for attachments | For file uploads | `attachments` |
|
||||
| `NEXT_PUBLIC_ALLOW_CREDENTIALS` | Allow email & password login | For authentication | `true` |
|
||||
| `NEXT_PUBLIC_DISABLE_SIGN_UP` | Disable sign up | For authentication | `false` |
|
||||
| `NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY` | Hide “Powered by kan.bn” on public boards (self-host) | For white labelling | `true` |
|
||||
| `KAN_ADMIN_API_KEY` | Admin API key for stats and admin endpoints | For admin/monitoring | `your-secret-admin-key` |
|
||||
| `LOG_LEVEL` | Log verbosity level (debug, info, warn, error) | No (defaults to debug in dev, info in prod) | `info` |
|
||||
|
||||
See `.env.example` for a complete list of supported environment variables.
|
||||
|
||||
## MCP Server (AI Control) 🤖
|
||||
|
||||
Kan ships with a [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server that lets any MCP-compatible AI client — GitHub Copilot, Claude Desktop, Cursor, Codex, and others — read and control your Kan instance using natural language.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
- A running Kan instance (self-hosted or cloud)
|
||||
- A Kan API key (Settings → API Keys → Create key)
|
||||
|
||||
### Installation
|
||||
|
||||
You do **not** need to clone this repository. The recommended way is to use `npx`, which runs the server on-demand and always uses the latest version — no global install required:
|
||||
|
||||
```bash
|
||||
npx -y @kan/mcp
|
||||
```
|
||||
|
||||
Alternatively, install it globally:
|
||||
|
||||
```bash
|
||||
npm install -g @kan/mcp
|
||||
kan-mcp
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
The server is configured via two environment variables:
|
||||
|
||||
| Variable | Description | Example |
|
||||
| --------------- | ----------------------------------- | ------------------------------ |
|
||||
| `KAN_BASE_URL` | Base URL of your Kan instance | `https://your-kan.example.com` |
|
||||
| `KAN_API_TOKEN` | API key from your Kan user settings | `kan_xxxxxxxxxxxx` |
|
||||
|
||||
#### GitHub Copilot (VS Code)
|
||||
|
||||
Add the following to your VS Code `mcp.json` (open it via **MCP: Open User MCP Configuration** from the Command Palette):
|
||||
|
||||
```json
|
||||
{
|
||||
"servers": {
|
||||
"kan": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@kan/mcp"],
|
||||
"env": {
|
||||
"KAN_BASE_URL": "https://your-kan-instance.com",
|
||||
"KAN_API_TOKEN": "kan_your_api_key_here"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then use Copilot in **Agent mode** to interact with Kan.
|
||||
|
||||
#### Claude Desktop
|
||||
|
||||
Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"kan": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@kan/mcp"],
|
||||
"env": {
|
||||
"KAN_BASE_URL": "https://your-kan-instance.com",
|
||||
"KAN_API_TOKEN": "kan_your_api_key_here"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Cursor / Codex / other clients
|
||||
|
||||
Use the same `command` + `args` + `env` pattern above — all MCP stdio clients follow the same format.
|
||||
|
||||
### Example prompts
|
||||
|
||||
Once connected, you can ask your AI assistant things like:
|
||||
|
||||
**Browsing**
|
||||
|
||||
- _"List all my workspaces"_
|
||||
- _"Show me all boards in the Marketing workspace"_
|
||||
- _"What cards are in the Backlog list of the Q3 Planning board?"_
|
||||
- _"Get the full details of card X including comments and checklists"_
|
||||
|
||||
**Managing cards**
|
||||
|
||||
- _"Create a card called 'Fix login bug' in the To Do list of the Dev board"_
|
||||
- _"Move the 'API redesign' card to the In Progress list"_
|
||||
- _"Set a due date of next Friday on the 'Write docs' card"_
|
||||
- _"Add a comment to the 'Deploy to prod' card saying the deployment is blocked"_
|
||||
- _"Duplicate the 'Sprint template' card into the new Sprint 4 list"_
|
||||
- _"Mark the 'Setup CI' checklist item as complete"_
|
||||
|
||||
**Organisation**
|
||||
|
||||
- _"Add the 'urgent' label to all cards assigned to me in the Backend board"_
|
||||
- _"Create a 'Release checklist' checklist on the v2.0 card with items: smoke test, update changelog, tag release"_
|
||||
- _"What tasks are assigned to @alice in the Mechanics Rework board?"_
|
||||
|
||||
**Workspace management**
|
||||
|
||||
- _"Create a new workspace called 'Client Projects'"_
|
||||
- _"Invite bob@example.com to the Marketing workspace as a member"_
|
||||
- _"Create a new board called 'Sprint 5' in the Dev workspace with lists: Backlog, In Progress, Done"_
|
||||
- _"Search for anything related to 'authentication' across the Dev workspace"_
|
||||
|
||||
### Available tools
|
||||
|
||||
The MCP server exposes 46 tools across 7 resource types:
|
||||
|
||||
| Resource | Tools |
|
||||
| ----------------- | ------------------------------------------------------------------- |
|
||||
| Workspaces | list, find by name, get, create, update, delete, search, check slug |
|
||||
| Boards | list, find by name, get, get by slug, create, update, delete |
|
||||
| Lists | create, update, delete |
|
||||
| Cards | create, get, update, delete, duplicate, get activities |
|
||||
| Card interactions | add/update/delete comment, toggle label, toggle member |
|
||||
| Checklists | create, update, delete, create item, update item, delete item |
|
||||
| Labels | get, create, update, delete |
|
||||
| Members | invite, remove, update role, manage invite links |
|
||||
|
||||
## Contributing 🤝
|
||||
|
||||
We welcome contributions! Please read our [contribution guidelines](CONTRIBUTING.md) before submitting a pull request.
|
||||
@@ -354,12 +227,6 @@ We welcome contributions! Please read our [contribution guidelines](CONTRIBUTING
|
||||
<img src="https://contrib.rocks/image?repo=kanbn/kan" />
|
||||
</a>
|
||||
|
||||
## Sponsors ❤️
|
||||
|
||||
[<img height="100" alt="image" src="https://github.com/user-attachments/assets/e331c71f-ac86-46a6-bceb-ce276de094b0" />](https://www.testmuai.com)
|
||||
|
||||
Proudly sponsored by [TestMu AI (formerly LambdaTest)](https://www.testmuai.com) - an AI-native testing cloud platform built for modern engineering teams. Covering everything from autonomous test creation and fast execution to testing AI agents like chatbots and voice assistants. If you're serious about testing, go check them out.
|
||||
|
||||
## License 📝
|
||||
|
||||
Kan is licensed under the [AGPLv3 license](LICENSE).
|
||||
|
||||
@@ -11,7 +11,6 @@ RUN apk update && \
|
||||
apk add --no-cache libc6-compat && \
|
||||
rm -rf /var/cache/apk/* /tmp/* || true && \
|
||||
corepack enable && \
|
||||
corepack prepare pnpm@9.14.2 --activate && \
|
||||
npm install turbo@2.3.1 --global && \
|
||||
pnpm config set store-dir ~/.pnpm-store
|
||||
|
||||
@@ -110,15 +109,13 @@ ENV PORT=3000
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
|
||||
# Copy the standalone Next.js server
|
||||
COPY --chown=1000:1000 --from=builder /app/apps/web/.next/standalone/ ./
|
||||
COPY --from=builder /app/apps/web/.next/standalone/ ./
|
||||
# Copy static assets and public files
|
||||
COPY --chown=1000:1000 --from=builder /app/apps/web/.next/static/ ./apps/web/.next/static/
|
||||
COPY --chown=1000:1000 --from=builder /app/apps/web/public/ ./apps/web/public/
|
||||
COPY --from=builder /app/apps/web/.next/static/ ./apps/web/.next/static/
|
||||
COPY --from=builder /app/apps/web/public/ ./apps/web/public/
|
||||
|
||||
# Copy bootstrap script for runtime env var injection
|
||||
COPY --chown=1000:1000 apps/web/bootstrap.cjs ./bootstrap.cjs
|
||||
|
||||
USER 1000:1000
|
||||
COPY apps/web/bootstrap.cjs ./bootstrap.cjs
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["bootstrap.cjs"]
|
||||
|
||||
@@ -88,12 +88,6 @@ checksums:
|
||||
7L01XJ/message: c46571856723b03262fd33f511116298
|
||||
7L01XJ/origin/0/0: feacdaf2791aaddec128f463ffa2e807
|
||||
7L01XJ/translation: c46571856723b03262fd33f511116298
|
||||
JEt3q7/message: 233710e683076c7eb6400a5c5ddd1d2a
|
||||
JEt3q7/origin/0/0: 055278d1067f7350adbbc1eaf446e43c
|
||||
JEt3q7/translation: 233710e683076c7eb6400a5c5ddd1d2a
|
||||
mkehy7/message: 71c4ebc206483ae49a2bd3dd7b6038ba
|
||||
mkehy7/origin/0/0: 055278d1067f7350adbbc1eaf446e43c
|
||||
mkehy7/translation: 71c4ebc206483ae49a2bd3dd7b6038ba
|
||||
F6pfE9/message: 3e1ec025c4a50830bbb9ad57a176630a
|
||||
F6pfE9/origin/0/0: 4b563046ba98abb3777fb1c3560866aa
|
||||
F6pfE9/origin/1/0: f64cd62b620f454998523af2a4330278
|
||||
@@ -230,8 +224,7 @@ checksums:
|
||||
Z3krJD/origin/0/0: e9da4b3a0eedd68ed126437194a482fe
|
||||
Z3krJD/translation: 0077436d9f37bfd64f3ae076a5a05040
|
||||
WnEwDO/message: 2959fd276248208b65cb27ed46b20135
|
||||
WnEwDO/origin/0/0: 055278d1067f7350adbbc1eaf446e43c
|
||||
WnEwDO/origin/1/0: d6f5120f072a821219608624f361384e
|
||||
WnEwDO/origin/0/0: d6f5120f072a821219608624f361384e
|
||||
WnEwDO/translation: 2959fd276248208b65cb27ed46b20135
|
||||
j1%2BHPc/message: baf4c3cd580291f41458f9ed28102c49
|
||||
j1%2BHPc/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
|
||||
@@ -245,9 +238,9 @@ checksums:
|
||||
ZXSPJt/message: 1b5749b0cca6a62d75a577bee6804074
|
||||
ZXSPJt/origin/0/0: 0627e0040ca4939c9a57349e98c51797
|
||||
ZXSPJt/translation: 1b5749b0cca6a62d75a577bee6804074
|
||||
YkfDoz/translation: 5d4f1c56f4e9e579ec9967ca68cc64ac
|
||||
YkfDoz/message: 5d4f1c56f4e9e579ec9967ca68cc64ac
|
||||
YkfDoz/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
|
||||
YkfDoz/translation: 5d4f1c56f4e9e579ec9967ca68cc64ac
|
||||
3bqt9U/message: 2366ed295eb2c03c425559c24cb31606
|
||||
3bqt9U/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
3bqt9U/translation: 2366ed295eb2c03c425559c24cb31606
|
||||
@@ -329,9 +322,9 @@ checksums:
|
||||
1rWxEw/message: 122c108794f321c10e05a08f09b11457
|
||||
1rWxEw/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
1rWxEw/translation: 122c108794f321c10e05a08f09b11457
|
||||
iH8pgl/translation: f541015a827e37cb3b1234e56bc2aa3c
|
||||
iH8pgl/message: f541015a827e37cb3b1234e56bc2aa3c
|
||||
iH8pgl/origin/0/0: 69d8634b177fdf937764b406fd477547
|
||||
iH8pgl/translation: f541015a827e37cb3b1234e56bc2aa3c
|
||||
KNKCTb/message: 35919fd5b10ad212839a75e72829fe4e
|
||||
KNKCTb/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
KNKCTb/translation: 35919fd5b10ad212839a75e72829fe4e
|
||||
@@ -350,9 +343,9 @@ checksums:
|
||||
UoSI%2Bi/message: 47f1051a134e111930be7f94179df4c8
|
||||
UoSI%2Bi/origin/0/0: fadd5a5b6963ec5f261072f7e8d2140b
|
||||
UoSI%2Bi/translation: 47f1051a134e111930be7f94179df4c8
|
||||
zt%2F6cS/translation: aadb69a7f7421be6bb022fbde08a6d77
|
||||
zt%2F6cS/message: aadb69a7f7421be6bb022fbde08a6d77
|
||||
zt%2F6cS/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
|
||||
zt%2F6cS/translation: aadb69a7f7421be6bb022fbde08a6d77
|
||||
qaS%2B1%2F/message: 4e25347caf65e1b63bec90e101b86ddd
|
||||
qaS%2B1%2F/origin/0/0: fadd5a5b6963ec5f261072f7e8d2140b
|
||||
qaS%2B1%2F/translation: 4e25347caf65e1b63bec90e101b86ddd
|
||||
@@ -379,14 +372,12 @@ checksums:
|
||||
QD8opX/origin/2/0: faa8a2576972b4eb31565d8ea6098f43
|
||||
QD8opX/translation: 101ff39aab674c033c15ab978d0d8bac
|
||||
YhvWXb/message: 336a8c70a91adc49c15266167ee84cc0
|
||||
YhvWXb/origin/0/0: dbad0c8d7863cb41f5495264f82e7081
|
||||
YhvWXb/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
|
||||
YhvWXb/origin/1/0: dbad0c8d7863cb41f5495264f82e7081
|
||||
YhvWXb/translation: 336a8c70a91adc49c15266167ee84cc0
|
||||
i0ZMQl/message: f739058266a2cb6a3d7cca14de09c69a
|
||||
i0ZMQl/origin/0/0: 0ad2af981579a62b084b3d41462c5546
|
||||
i0ZMQl/translation: f739058266a2cb6a3d7cca14de09c69a
|
||||
wE3hGS/message: 1b3bccc465ad34111956b1c705c52031
|
||||
wE3hGS/origin/0/0: f8e87f3fc3dc63bc7961601feb860fa8
|
||||
wE3hGS/translation: 1b3bccc465ad34111956b1c705c52031
|
||||
wewm3j/message: 985c0177744ec56d8dac84f66ec284ca
|
||||
wewm3j/origin/0/0: bcd95e286f12800a3e7ee0a71feb3ca5
|
||||
wewm3j/translation: 985c0177744ec56d8dac84f66ec284ca
|
||||
@@ -539,34 +530,26 @@ checksums:
|
||||
dEgA5A/origin/1/0: a50c4fbde8da6e0a77c4e0aef0b44dea
|
||||
dEgA5A/origin/2/0: 97bd206a05414e2a571020d64e166093
|
||||
dEgA5A/origin/3/0: 522726bb62051656dc0d165185d6b451
|
||||
dEgA5A/origin/4/0: f8e87f3fc3dc63bc7961601feb860fa8
|
||||
dEgA5A/origin/5/0: b2059fa4b8b99cc10440da26475b234c
|
||||
dEgA5A/origin/6/0: 2a382c1cdf010f01a8e95c3e05f6df53
|
||||
dEgA5A/origin/7/0: d55f532bb5ae247e3a018978be0da463
|
||||
dEgA5A/origin/8/0: 4b4f55c033add8db69178563832227c6
|
||||
dEgA5A/origin/9/0: 68450df287fb46ea629e818732c1291a
|
||||
dEgA5A/origin/10/0: 15ea2dfd5eae4e05b226b1320103476e
|
||||
dEgA5A/origin/11/0: 03c00909b41be01231cd3e843c8ee44e
|
||||
dEgA5A/origin/12/0: 0627e0040ca4939c9a57349e98c51797
|
||||
dEgA5A/origin/13/0: 15792234f408027822ee71ccddde6f9b
|
||||
dEgA5A/origin/14/0: 5c60161e173f5eae9276d8e4c70b4ebb
|
||||
dEgA5A/origin/15/0: a1fde3fcce608870830f168f027727ff
|
||||
dEgA5A/origin/16/0: 83911e3eacbad4583e2b1647a784d154
|
||||
dEgA5A/origin/17/0: dbad0c8d7863cb41f5495264f82e7081
|
||||
dEgA5A/origin/4/0: b2059fa4b8b99cc10440da26475b234c
|
||||
dEgA5A/origin/5/0: 2a382c1cdf010f01a8e95c3e05f6df53
|
||||
dEgA5A/origin/6/0: d55f532bb5ae247e3a018978be0da463
|
||||
dEgA5A/origin/7/0: 4b4f55c033add8db69178563832227c6
|
||||
dEgA5A/origin/8/0: 68450df287fb46ea629e818732c1291a
|
||||
dEgA5A/origin/9/0: 15ea2dfd5eae4e05b226b1320103476e
|
||||
dEgA5A/origin/10/0: 03c00909b41be01231cd3e843c8ee44e
|
||||
dEgA5A/origin/11/0: 0627e0040ca4939c9a57349e98c51797
|
||||
dEgA5A/origin/12/0: 15792234f408027822ee71ccddde6f9b
|
||||
dEgA5A/origin/13/0: 5c60161e173f5eae9276d8e4c70b4ebb
|
||||
dEgA5A/origin/14/0: a1fde3fcce608870830f168f027727ff
|
||||
dEgA5A/origin/15/0: 83911e3eacbad4583e2b1647a784d154
|
||||
dEgA5A/origin/16/0: dbad0c8d7863cb41f5495264f82e7081
|
||||
dEgA5A/translation: 2e2a849c2223911717de8caa2c71bade
|
||||
kryGs%2B/message: bba0beaced7ea954ceb980f2b022ffee
|
||||
kryGs%2B/origin/0/0: 15fd9c05fc09ccbbb54aadc4aa5e5501
|
||||
kryGs%2B/translation: bba0beaced7ea954ceb980f2b022ffee
|
||||
sU2uWc/message: 3fe52244676faf64e5266b6713d01e07
|
||||
sU2uWc/origin/0/0: 97bd206a05414e2a571020d64e166093
|
||||
sU2uWc/origin/1/0: 1b05beba6bdd908015fa8e798717f024
|
||||
sU2uWc/translation: 3fe52244676faf64e5266b6713d01e07
|
||||
9trBXW/message: 0035c67fb6f70992effb263b0b30e6d4
|
||||
9trBXW/origin/0/0: 1b05beba6bdd908015fa8e798717f024
|
||||
9trBXW/translation: 0035c67fb6f70992effb263b0b30e6d4
|
||||
AgE2vR/message: a295a7050379531f9f56ada01c32e8e1
|
||||
AgE2vR/origin/0/0: f8e87f3fc3dc63bc7961601feb860fa8
|
||||
AgE2vR/translation: a295a7050379531f9f56ada01c32e8e1
|
||||
fEY2vP/message: 91509e2f92b0b3b11330b6983139fdbf
|
||||
fEY2vP/origin/0/0: 15fd9c05fc09ccbbb54aadc4aa5e5501
|
||||
fEY2vP/origin/1/0: 15fd9c05fc09ccbbb54aadc4aa5e5501
|
||||
@@ -600,9 +583,8 @@ checksums:
|
||||
pFGfsR/origin/0/0: 82383b95848d24b9311ceaef0ec94bb6
|
||||
pFGfsR/translation: b0d0be4f63c16552b3aa795af29240d1
|
||||
5IXWmO/message: e9a430fcd298def74212238df0f680d6
|
||||
5IXWmO/origin/0/0: 055278d1067f7350adbbc1eaf446e43c
|
||||
5IXWmO/origin/1/0: 4a1be3707d14c696a16f453d132fb248
|
||||
5IXWmO/origin/2/0: d6f5120f072a821219608624f361384e
|
||||
5IXWmO/origin/0/0: 4a1be3707d14c696a16f453d132fb248
|
||||
5IXWmO/origin/1/0: d6f5120f072a821219608624f361384e
|
||||
5IXWmO/translation: e9a430fcd298def74212238df0f680d6
|
||||
GnRmu4/message: 5eb5de823f7ca5a4d97bb41e6a3f675a
|
||||
GnRmu4/origin/0/0: a2dd1fc876bd42b9db7ca7aaa57ac5e2
|
||||
@@ -611,13 +593,9 @@ checksums:
|
||||
EH8IL3/origin/0/0: c0b59968b2b234167c488a05cc38710b
|
||||
EH8IL3/origin/1/0: fadd5a5b6963ec5f261072f7e8d2140b
|
||||
EH8IL3/translation: 6f79129c8f08ee54d858a2af57d16dd9
|
||||
zMquA7/translation: 38ff8d574865bb71d2f620bc4ba98c20
|
||||
zMquA7/message: 38ff8d574865bb71d2f620bc4ba98c20
|
||||
zMquA7/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
|
||||
zMquA7/translation: 38ff8d574865bb71d2f620bc4ba98c20
|
||||
5EMoSo/message: d5825bc247cfd3e52e233f7dab8051c6
|
||||
5EMoSo/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
5EMoSo/origin/1/0: 619b4be43b9080346366c4bf6ba2253d
|
||||
5EMoSo/translation: d5825bc247cfd3e52e233f7dab8051c6
|
||||
VHS0aJ/message: 31d0962985c83a29558c98a765bb1b16
|
||||
VHS0aJ/origin/0/0: 15792234f408027822ee71ccddde6f9b
|
||||
VHS0aJ/translation: 31d0962985c83a29558c98a765bb1b16
|
||||
@@ -637,10 +615,9 @@ checksums:
|
||||
RRn9jf/origin/1/0: d6f5120f072a821219608624f361384e
|
||||
RRn9jf/translation: 210b6ff8727f976182ec3f29ea3c7667
|
||||
yz7wBu/message: 2c2e22f8424a1031de89063bd0022e16
|
||||
yz7wBu/origin/0/0: 278a40eb45b1219f424703d0abc1d273
|
||||
yz7wBu/origin/1/0: 15fd9c05fc09ccbbb54aadc4aa5e5501
|
||||
yz7wBu/origin/2/0: afee530613110e248ffde6b56872ebed
|
||||
yz7wBu/origin/3/0: dcafca531d4dce1faef7788feabfb048
|
||||
yz7wBu/origin/0/0: 15fd9c05fc09ccbbb54aadc4aa5e5501
|
||||
yz7wBu/origin/1/0: afee530613110e248ffde6b56872ebed
|
||||
yz7wBu/origin/2/0: dcafca531d4dce1faef7788feabfb048
|
||||
yz7wBu/translation: 2c2e22f8424a1031de89063bd0022e16
|
||||
TcXcmk/message: a2da6c2339301e7c3ddf068c4ff9f7e8
|
||||
TcXcmk/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
@@ -652,8 +629,9 @@ checksums:
|
||||
dtQIWT/origin/0/0: c0b59968b2b234167c488a05cc38710b
|
||||
dtQIWT/translation: 9d2308d578ec21bd22374e880fe3bd3c
|
||||
AUYALh/message: ee2b0671e00972773210c5be5a9ccb89
|
||||
AUYALh/origin/0/0: d77eb1f90f2fa86a55181629d88d237a
|
||||
AUYALh/origin/1/0: dbad0c8d7863cb41f5495264f82e7081
|
||||
AUYALh/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
|
||||
AUYALh/origin/1/0: d77eb1f90f2fa86a55181629d88d237a
|
||||
AUYALh/origin/2/0: dbad0c8d7863cb41f5495264f82e7081
|
||||
AUYALh/translation: ee2b0671e00972773210c5be5a9ccb89
|
||||
VbyRUy/message: 502237cd1044491c48494aa60967a985
|
||||
VbyRUy/origin/0/0: d77eb1f90f2fa86a55181629d88d237a
|
||||
@@ -723,10 +701,10 @@ checksums:
|
||||
AX4ktp/message: 4e6be22ade0188f408471325fe363575
|
||||
AX4ktp/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
AX4ktp/translation: 4e6be22ade0188f408471325fe363575
|
||||
xGVfLh/translation: 3cfba90b4600131e82fc4260c568d044
|
||||
xGVfLh/message: 3cfba90b4600131e82fc4260c568d044
|
||||
xGVfLh/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
|
||||
xGVfLh/origin/1/0: 69d8634b177fdf937764b406fd477547
|
||||
xGVfLh/translation: 3cfba90b4600131e82fc4260c568d044
|
||||
J2Wbjt/message: 8ed03cf7c5e60a6edf3470a4558ff058
|
||||
J2Wbjt/origin/0/0: d96af8a06c2e38a72238bcd16e426859
|
||||
J2Wbjt/translation: 8ed03cf7c5e60a6edf3470a4558ff058
|
||||
@@ -758,9 +736,6 @@ checksums:
|
||||
uvH2xS/message: ecc2617544a6d385d18678577fa112fd
|
||||
uvH2xS/origin/0/0: 97bd206a05414e2a571020d64e166093
|
||||
uvH2xS/translation: ecc2617544a6d385d18678577fa112fd
|
||||
7mrkyO/message: d3853b58e9b736eefb0f9bf400daf4d7
|
||||
7mrkyO/origin/0/0: 1b05beba6bdd908015fa8e798717f024
|
||||
7mrkyO/translation: d3853b58e9b736eefb0f9bf400daf4d7
|
||||
9PaIxv/message: da95932e7a1465a5d21aa3b855a46dc2
|
||||
9PaIxv/origin/0/0: c0b59968b2b234167c488a05cc38710b
|
||||
9PaIxv/translation: da95932e7a1465a5d21aa3b855a46dc2
|
||||
@@ -857,11 +832,12 @@ checksums:
|
||||
7RpHg%2F/origin/0/0: c0b59968b2b234167c488a05cc38710b
|
||||
7RpHg%2F/origin/1/0: c0b59968b2b234167c488a05cc38710b
|
||||
7RpHg%2F/translation: 66f702512197d25cfdb117f2415ecd88
|
||||
ZDPJBc/translation: f9154d0c02a7db907f0a33fb5edd4be1
|
||||
ZDPJBc/message: f9154d0c02a7db907f0a33fb5edd4be1
|
||||
ZDPJBc/origin/0/0: 69d8634b177fdf937764b406fd477547
|
||||
ZDPJBc/translation: f9154d0c02a7db907f0a33fb5edd4be1
|
||||
j9IZZ0/message: 7ba841d0946eb04fa3d17d74365be37b
|
||||
j9IZZ0/origin/0/0: dbad0c8d7863cb41f5495264f82e7081
|
||||
j9IZZ0/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
|
||||
j9IZZ0/origin/1/0: dbad0c8d7863cb41f5495264f82e7081
|
||||
j9IZZ0/translation: 7ba841d0946eb04fa3d17d74365be37b
|
||||
OwneeH/message: e97476d280d4ebfd196d6c5336f3511f
|
||||
OwneeH/origin/0/0: fadd5a5b6963ec5f261072f7e8d2140b
|
||||
@@ -939,9 +915,6 @@ checksums:
|
||||
f8fH8W/message: 991b75727b6784c1a063a7462b76186d
|
||||
f8fH8W/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
f8fH8W/translation: 991b75727b6784c1a063a7462b76186d
|
||||
Uf%2B1DF/message: e5b4d2df65d6676318682404de4e38d3
|
||||
Uf%2B1DF/origin/0/0: f8e87f3fc3dc63bc7961601feb860fa8
|
||||
Uf%2B1DF/translation: e5b4d2df65d6676318682404de4e38d3
|
||||
Odv3J6/message: f3cc49ba2dc3f9c33917f8a749d68bf5
|
||||
Odv3J6/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
|
||||
Odv3J6/translation: f3cc49ba2dc3f9c33917f8a749d68bf5
|
||||
@@ -1023,7 +996,6 @@ checksums:
|
||||
BTDPLo/message: f6a33afcf5ff1489551c94867e083dca
|
||||
BTDPLo/origin/0/0: 97bd206a05414e2a571020d64e166093
|
||||
BTDPLo/origin/1/0: 0eeeb7e68164ff118040918f1ef00a7a
|
||||
BTDPLo/origin/2/0: 1b05beba6bdd908015fa8e798717f024
|
||||
BTDPLo/translation: f6a33afcf5ff1489551c94867e083dca
|
||||
06EQqT/message: e9f56489fb395a890ae2557200c29a36
|
||||
06EQqT/origin/0/0: 97bd206a05414e2a571020d64e166093
|
||||
@@ -1077,9 +1049,9 @@ checksums:
|
||||
bFREhu/message: b2cc9a311fc28d1594252f697c7f5342
|
||||
bFREhu/origin/0/0: 97bd206a05414e2a571020d64e166093
|
||||
bFREhu/translation: b2cc9a311fc28d1594252f697c7f5342
|
||||
SUXBxp/translation: 76e841ae8ec3340d7d0a5c2191abbea4
|
||||
SUXBxp/message: 76e841ae8ec3340d7d0a5c2191abbea4
|
||||
SUXBxp/origin/0/0: 69d8634b177fdf937764b406fd477547
|
||||
SUXBxp/translation: 76e841ae8ec3340d7d0a5c2191abbea4
|
||||
0%2BewPp/message: 785fe23c0eef0a5b60b5b2a88151de31
|
||||
0%2BewPp/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
0%2BewPp/origin/1/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
@@ -1149,9 +1121,6 @@ checksums:
|
||||
rarRW%2F/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
rarRW%2F/origin/1/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
rarRW%2F/translation: 91553b606a6ebed3534cd2e5635ce004
|
||||
sZixXc/message: db4e3e372abb1ac510c62b14d544f0f8
|
||||
sZixXc/origin/0/0: 0627e0040ca4939c9a57349e98c51797
|
||||
sZixXc/translation: db4e3e372abb1ac510c62b14d544f0f8
|
||||
sVBn4K/message: 0d7979a25d290123ed8b8906bf468f1a
|
||||
sVBn4K/origin/0/0: d465da67c0a870d198eb7f313569c137
|
||||
sVBn4K/translation: 0d7979a25d290123ed8b8906bf468f1a
|
||||
@@ -1165,7 +1134,8 @@ checksums:
|
||||
x0kMO%2F/origin/0/0: 30791e26a172d47d3cb0693e82679246
|
||||
x0kMO%2F/translation: 9da6c012e1b512d8c7fbd812626c4675
|
||||
YaZDYI/message: 57cc47581c7faaffb3b1721b6352b576
|
||||
YaZDYI/origin/0/0: dbad0c8d7863cb41f5495264f82e7081
|
||||
YaZDYI/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
YaZDYI/origin/1/0: dbad0c8d7863cb41f5495264f82e7081
|
||||
YaZDYI/translation: 57cc47581c7faaffb3b1721b6352b576
|
||||
lsgnzd/message: 934c6867d9bdbe6099b2c0f5a3107f78
|
||||
lsgnzd/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
|
||||
@@ -1274,9 +1244,6 @@ checksums:
|
||||
2POOFK/origin/4/0: 9737a9cef0646ff4bbd942b1924b9a2c
|
||||
2POOFK/origin/5/0: fadd5a5b6963ec5f261072f7e8d2140b
|
||||
2POOFK/translation: 0326365539c004f6088656f692602078
|
||||
8LLt89/message: 9a78178dc1874543d35153ae325e2811
|
||||
8LLt89/origin/0/0: 619b4be43b9080346366c4bf6ba2253d
|
||||
8LLt89/translation: 9a78178dc1874543d35153ae325e2811
|
||||
uP4V6I/message: 2d403501f0488ec7d4eac979faf051a2
|
||||
uP4V6I/origin/0/0: fadd5a5b6963ec5f261072f7e8d2140b
|
||||
uP4V6I/translation: 2d403501f0488ec7d4eac979faf051a2
|
||||
@@ -1365,9 +1332,9 @@ checksums:
|
||||
csFbe%2B/message: e4e58e33d637282d141df466d729bc7c
|
||||
csFbe%2B/origin/0/0: 94d3e20c29bb517dd394711d7524e478
|
||||
csFbe%2B/translation: e4e58e33d637282d141df466d729bc7c
|
||||
SUvm1Y/translation: 6c0f26526f77e9abb92fb2b1106a66db
|
||||
SUvm1Y/message: 6c0f26526f77e9abb92fb2b1106a66db
|
||||
SUvm1Y/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
|
||||
SUvm1Y/translation: 6c0f26526f77e9abb92fb2b1106a66db
|
||||
cdyS7J/message: 0f10050aeed2555a34fe3c27ce616607
|
||||
cdyS7J/origin/0/0: c0b59968b2b234167c488a05cc38710b
|
||||
cdyS7J/origin/1/0: c0b59968b2b234167c488a05cc38710b
|
||||
@@ -1413,9 +1380,6 @@ checksums:
|
||||
2Py5S%2F/message: fe1183a7439299210d8ab7379b8f4d01
|
||||
2Py5S%2F/origin/0/0: 83911e3eacbad4583e2b1647a784d154
|
||||
2Py5S%2F/translation: fe1183a7439299210d8ab7379b8f4d01
|
||||
VOwhhz/message: 613f43526ea128b2749f70878f10008c
|
||||
VOwhhz/origin/0/0: 1b05beba6bdd908015fa8e798717f024
|
||||
VOwhhz/translation: 613f43526ea128b2749f70878f10008c
|
||||
iwr7AP/message: 27c24b56895defecd10b0171b39bc93f
|
||||
iwr7AP/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
iwr7AP/translation: 27c24b56895defecd10b0171b39bc93f
|
||||
@@ -1500,9 +1464,6 @@ checksums:
|
||||
"%2BdjOzj/origin/0/0": faa852a0c0e3c81fe948e394029c1ba5
|
||||
"%2BdjOzj/origin/1/0": 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
"%2BdjOzj/translation": ade922db1be6b26bc979565ce5de2bc7
|
||||
M2zwdn/message: 4c9c0d43434ad63a70e8f956d1900602
|
||||
M2zwdn/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
M2zwdn/translation: 4c9c0d43434ad63a70e8f956d1900602
|
||||
qPP4lD/message: 03eeed2d715e770259f722ba48a61ab3
|
||||
qPP4lD/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
qPP4lD/translation: 03eeed2d715e770259f722ba48a61ab3
|
||||
@@ -1548,8 +1509,9 @@ checksums:
|
||||
k7rCa%2F/origin/0/0: 3de48fb04f7df9bdbe399911cdb3fe86
|
||||
k7rCa%2F/translation: 890b7851af05f97ac53644e4154c13b4
|
||||
8Is1ih/message: 624592ba2ecf647a3fe60d493c1ee71a
|
||||
8Is1ih/origin/0/0: fadd5a5b6963ec5f261072f7e8d2140b
|
||||
8Is1ih/origin/1/0: dbad0c8d7863cb41f5495264f82e7081
|
||||
8Is1ih/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
|
||||
8Is1ih/origin/1/0: fadd5a5b6963ec5f261072f7e8d2140b
|
||||
8Is1ih/origin/2/0: dbad0c8d7863cb41f5495264f82e7081
|
||||
8Is1ih/translation: 624592ba2ecf647a3fe60d493c1ee71a
|
||||
EEHmPC/message: 8c1be80f5b2fcc112922282afbe30287
|
||||
EEHmPC/origin/0/0: 7493592314bc3b96ad96127a949ba1ba
|
||||
@@ -1569,12 +1531,6 @@ checksums:
|
||||
iQmbPb/message: ac2bcac75151e2fcd9eb768cea275a77
|
||||
iQmbPb/origin/0/0: 98e2302d0d8d12ce65c4140b87450673
|
||||
iQmbPb/translation: ac2bcac75151e2fcd9eb768cea275a77
|
||||
aDzbuM/message: b6cb6ee4c4dd2cbe0d5d6a0088dcd731
|
||||
aDzbuM/origin/0/0: 278a40eb45b1219f424703d0abc1d273
|
||||
aDzbuM/translation: b6cb6ee4c4dd2cbe0d5d6a0088dcd731
|
||||
K3LUJr/message: 6dd9358a22160ebed51ab3f36e2bb0b1
|
||||
K3LUJr/origin/0/0: 278a40eb45b1219f424703d0abc1d273
|
||||
K3LUJr/translation: 6dd9358a22160ebed51ab3f36e2bb0b1
|
||||
1njn7W/message: bb78ea2c6edd8662676c81a8a13ecb93
|
||||
1njn7W/origin/0/0: 358ea1b5babb2e9ccbf1a4c9f89dd3a5
|
||||
1njn7W/translation: bb78ea2c6edd8662676c81a8a13ecb93
|
||||
@@ -1639,26 +1595,20 @@ checksums:
|
||||
jl3aEJ/placeholders/0/0: 06f23520ebabfffed4999b5bcf61c9cd
|
||||
jl3aEJ/origin/0/0: 1d6b5f88cdad160a67842f6b65387448
|
||||
jl3aEJ/translation: 4c38799ff25321ea25017bf4cd8e2e4f
|
||||
vo2a%2Ba/translation: fcf0f06f8b64b458c7ca6d95541a3cc8
|
||||
vo2a%2Ba/message: fcf0f06f8b64b458c7ca6d95541a3cc8
|
||||
vo2a%2Ba/origin/0/0: 69d8634b177fdf937764b406fd477547
|
||||
vo2a%2Ba/translation: fcf0f06f8b64b458c7ca6d95541a3cc8
|
||||
agPptk/message: 43983f2178fb28aacaef6c5f41282c4a
|
||||
agPptk/origin/0/0: 3de48fb04f7df9bdbe399911cdb3fe86
|
||||
agPptk/translation: 43983f2178fb28aacaef6c5f41282c4a
|
||||
W%2FElkg/message: 1f527cd6d1ed602930bcaa303f503b51
|
||||
W%2FElkg/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
W%2FElkg/translation: 1f527cd6d1ed602930bcaa303f503b51
|
||||
NwiNTb/message: 9840dd63c40b860a9de1488a07b86828
|
||||
NwiNTb/origin/0/0: 619b4be43b9080346366c4bf6ba2253d
|
||||
NwiNTb/translation: 9840dd63c40b860a9de1488a07b86828
|
||||
OvoEq7/message: 1606dc30b369856b9dba1fe9aec425d2
|
||||
OvoEq7/origin/0/0: 1d6b5f88cdad160a67842f6b65387448
|
||||
OvoEq7/origin/1/0: 1d6b5f88cdad160a67842f6b65387448
|
||||
OvoEq7/origin/2/0: 7493592314bc3b96ad96127a949ba1ba
|
||||
OvoEq7/translation: 1606dc30b369856b9dba1fe9aec425d2
|
||||
GnG6Oy/message: c7910e9020804dbe5c7022044c669e68
|
||||
GnG6Oy/origin/0/0: 619b4be43b9080346366c4bf6ba2253d
|
||||
GnG6Oy/translation: c7910e9020804dbe5c7022044c669e68
|
||||
wlQNTg/message: 0932e80cba1e3e0a7f52bb67ff31da32
|
||||
wlQNTg/origin/0/0: 94d3e20c29bb517dd394711d7524e478
|
||||
wlQNTg/origin/1/0: db1e755dc7d73e037f425111bf7c30a6
|
||||
@@ -1685,21 +1635,12 @@ checksums:
|
||||
vneRvS/message: e228107df80015377112d41f4f155cb3
|
||||
vneRvS/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
vneRvS/translation: e228107df80015377112d41f4f155cb3
|
||||
sZ%2FWDz/message: 4ae9f1cf0c53ebe8852574353d83d252
|
||||
sZ%2FWDz/origin/0/0: f8e87f3fc3dc63bc7961601feb860fa8
|
||||
sZ%2FWDz/translation: 4ae9f1cf0c53ebe8852574353d83d252
|
||||
VvCMyU/message: a70262481fb1e9e3ff2506b5c3efa03d
|
||||
VvCMyU/origin/0/0: f8e87f3fc3dc63bc7961601feb860fa8
|
||||
VvCMyU/translation: a70262481fb1e9e3ff2506b5c3efa03d
|
||||
51UCsN/message: ce14fde5e972c2b5276c25ddded817c4
|
||||
51UCsN/origin/0/0: 0eeeb7e68164ff118040918f1ef00a7a
|
||||
51UCsN/translation: ce14fde5e972c2b5276c25ddded817c4
|
||||
J4%2BOTA/message: 6ad27e803483208b5313660b7b5791d3
|
||||
J4%2BOTA/origin/0/0: fd0e66cef8d46e96bcf3143a12f9b6db
|
||||
J4%2BOTA/translation: 6ad27e803483208b5313660b7b5791d3
|
||||
S3wq2O/message: 9268b9b2e31373e5ac2e29c19d08e1ef
|
||||
S3wq2O/origin/0/0: 0ad2af981579a62b084b3d41462c5546
|
||||
S3wq2O/translation: 9268b9b2e31373e5ac2e29c19d08e1ef
|
||||
BOqTi5/message: 11edf0427766c26db541d46379dc3c16
|
||||
BOqTi5/placeholders/0/0: 9db68a4b386b9fa1fd1d5797c32be100
|
||||
BOqTi5/placeholders/1/0: b837a9e8bd06b58be95699b3ee663f43
|
||||
@@ -1800,9 +1741,6 @@ checksums:
|
||||
TXO5TM/message: 7f1ed5d777cade7d62303e9e591bbf63
|
||||
TXO5TM/origin/0/0: feacdaf2791aaddec128f463ffa2e807
|
||||
TXO5TM/translation: 7f1ed5d777cade7d62303e9e591bbf63
|
||||
HOMO91/message: 8a7b468d8a18e30de7743e4d5841c87f
|
||||
HOMO91/origin/0/0: 278a40eb45b1219f424703d0abc1d273
|
||||
HOMO91/translation: 8a7b468d8a18e30de7743e4d5841c87f
|
||||
hXMFoN/message: cedf633d99c77ff4356e089f2d98c0a6
|
||||
hXMFoN/origin/0/0: fc0c18a095a68277a0f90a115942683b
|
||||
hXMFoN/translation: cedf633d99c77ff4356e089f2d98c0a6
|
||||
@@ -1877,9 +1815,6 @@ checksums:
|
||||
Uworke/message: 213d63da450f35dabb3ab0e35e29feed
|
||||
Uworke/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
Uworke/translation: 213d63da450f35dabb3ab0e35e29feed
|
||||
nG7mus/message: 03326832c645f4ddb873a6e7eab8a639
|
||||
nG7mus/origin/0/0: 278a40eb45b1219f424703d0abc1d273
|
||||
nG7mus/translation: 03326832c645f4ddb873a6e7eab8a639
|
||||
IrZaAn/message: 1fcebe9ddb46f722a57f195efddc695d
|
||||
IrZaAn/origin/0/0: 0627e0040ca4939c9a57349e98c51797
|
||||
IrZaAn/translation: 1fcebe9ddb46f722a57f195efddc695d
|
||||
@@ -1892,9 +1827,6 @@ checksums:
|
||||
vwGkYB/message: 4c30501d085eaccea47af34212bb26a7
|
||||
vwGkYB/origin/0/0: 0627e0040ca4939c9a57349e98c51797
|
||||
vwGkYB/translation: 4c30501d085eaccea47af34212bb26a7
|
||||
Xlkxmi/message: 3251e7239a9b349a86d5785a113cd6b9
|
||||
Xlkxmi/origin/0/0: 0627e0040ca4939c9a57349e98c51797
|
||||
Xlkxmi/translation: 3251e7239a9b349a86d5785a113cd6b9
|
||||
lpIMne/message: 37ca1f4e0afc9a0b8e9617f767103c92
|
||||
lpIMne/origin/0/0: 0627e0040ca4939c9a57349e98c51797
|
||||
lpIMne/translation: 37ca1f4e0afc9a0b8e9617f767103c92
|
||||
@@ -1936,12 +1868,9 @@ checksums:
|
||||
8RdLDU/message: d7820b1bf4efecc61ed89234567aaa9c
|
||||
8RdLDU/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
8RdLDU/translation: d7820b1bf4efecc61ed89234567aaa9c
|
||||
Oi%2BJ%2BN/translation: 313804fe615d51e0f0cbd599161d1ade
|
||||
Oi%2BJ%2BN/message: 313804fe615d51e0f0cbd599161d1ade
|
||||
Oi%2BJ%2BN/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
|
||||
Oi%2BJ%2BN/translation: 313804fe615d51e0f0cbd599161d1ade
|
||||
GdgCoi/message: c6a08c771f97d86b02cf10d8f5f2d788
|
||||
GdgCoi/origin/0/0: 619b4be43b9080346366c4bf6ba2253d
|
||||
GdgCoi/translation: c6a08c771f97d86b02cf10d8f5f2d788
|
||||
Iqh0Uv/message: 0cc9dcd6ac624383e9977f66d4b3b777
|
||||
Iqh0Uv/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
Iqh0Uv/origin/1/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
@@ -2012,18 +1941,19 @@ checksums:
|
||||
tyHiOa/origin/34/0: afee530613110e248ffde6b56872ebed
|
||||
tyHiOa/origin/35/0: afee530613110e248ffde6b56872ebed
|
||||
tyHiOa/origin/36/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
tyHiOa/origin/37/0: 7493592314bc3b96ad96127a949ba1ba
|
||||
tyHiOa/origin/38/0: 69d8634b177fdf937764b406fd477547
|
||||
tyHiOa/origin/37/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
tyHiOa/origin/38/0: 7493592314bc3b96ad96127a949ba1ba
|
||||
tyHiOa/origin/39/0: 69d8634b177fdf937764b406fd477547
|
||||
tyHiOa/origin/40/0: 03c00909b41be01231cd3e843c8ee44e
|
||||
tyHiOa/origin/41/0: 5c60161e173f5eae9276d8e4c70b4ebb
|
||||
tyHiOa/origin/42/0: 83911e3eacbad4583e2b1647a784d154
|
||||
tyHiOa/origin/43/0: d465da67c0a870d198eb7f313569c137
|
||||
tyHiOa/origin/44/0: 33bf266228f835e12c0bfb0e5efb3be9
|
||||
tyHiOa/origin/45/0: ff48991e314b0b3e989a4dac59860fd5
|
||||
tyHiOa/origin/46/0: 30791e26a172d47d3cb0693e82679246
|
||||
tyHiOa/origin/47/0: dbad0c8d7863cb41f5495264f82e7081
|
||||
tyHiOa/origin/48/0: 0dccc39d0debbf3e089cb6d292de762b
|
||||
tyHiOa/origin/40/0: 69d8634b177fdf937764b406fd477547
|
||||
tyHiOa/origin/41/0: 03c00909b41be01231cd3e843c8ee44e
|
||||
tyHiOa/origin/42/0: 5c60161e173f5eae9276d8e4c70b4ebb
|
||||
tyHiOa/origin/43/0: 83911e3eacbad4583e2b1647a784d154
|
||||
tyHiOa/origin/44/0: d465da67c0a870d198eb7f313569c137
|
||||
tyHiOa/origin/45/0: 33bf266228f835e12c0bfb0e5efb3be9
|
||||
tyHiOa/origin/46/0: ff48991e314b0b3e989a4dac59860fd5
|
||||
tyHiOa/origin/47/0: 30791e26a172d47d3cb0693e82679246
|
||||
tyHiOa/origin/48/0: dbad0c8d7863cb41f5495264f82e7081
|
||||
tyHiOa/origin/49/0: 0dccc39d0debbf3e089cb6d292de762b
|
||||
tyHiOa/translation: 21ffcf0b00e7cd7b64f7454a95762e1d
|
||||
PZCqeW/message: 325dea6dd0348a27a6818db2c1340c98
|
||||
PZCqeW/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
@@ -2034,10 +1964,8 @@ checksums:
|
||||
fuwKpE/origin/1/0: fd0e66cef8d46e96bcf3143a12f9b6db
|
||||
fuwKpE/origin/2/0: fc0c18a095a68277a0f90a115942683b
|
||||
fuwKpE/origin/3/0: 1b05beba6bdd908015fa8e798717f024
|
||||
fuwKpE/origin/4/0: 1b05beba6bdd908015fa8e798717f024
|
||||
fuwKpE/origin/5/0: 1b05beba6bdd908015fa8e798717f024
|
||||
fuwKpE/origin/6/0: 9c8b544f5db130cd7c5eda9064ba3957
|
||||
fuwKpE/origin/7/0: faa8a2576972b4eb31565d8ea6098f43
|
||||
fuwKpE/origin/4/0: 9c8b544f5db130cd7c5eda9064ba3957
|
||||
fuwKpE/origin/5/0: faa8a2576972b4eb31565d8ea6098f43
|
||||
fuwKpE/translation: 346a18a54564881c888b95873d03df44
|
||||
nYNMZZ/message: d5a8a1b38eaf02d02cd6ac2211b42a92
|
||||
nYNMZZ/origin/0/0: 97bd206a05414e2a571020d64e166093
|
||||
@@ -2068,8 +1996,7 @@ checksums:
|
||||
3fPjUY/origin/2/0: fadd5a5b6963ec5f261072f7e8d2140b
|
||||
3fPjUY/translation: 682b3c9feab30112b4454cb5bb7974b1
|
||||
oyRjD%2F/message: 76a09fd843b496610f8cf1dced417b62
|
||||
oyRjD%2F/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
oyRjD%2F/origin/1/0: 7493592314bc3b96ad96127a949ba1ba
|
||||
oyRjD%2F/origin/0/0: 7493592314bc3b96ad96127a949ba1ba
|
||||
oyRjD%2F/translation: 76a09fd843b496610f8cf1dced417b62
|
||||
Qtzfdk/message: a898be9e4610706b9b8146a209e8ba16
|
||||
Qtzfdk/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
@@ -2213,13 +2140,6 @@ checksums:
|
||||
SkCNhl/message: 137017b2b3e885c4894ca8c899af5bc2
|
||||
SkCNhl/origin/0/0: 89a76a14edc1c0b7876d79d7571bb8fb
|
||||
SkCNhl/translation: 137017b2b3e885c4894ca8c899af5bc2
|
||||
66Jmqh/message: 5f56ef5447d5d3ae81d26936ca8e6758
|
||||
66Jmqh/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
66Jmqh/translation: 5f56ef5447d5d3ae81d26936ca8e6758
|
||||
MpFIca/message: 6e3f470ef5468c5195ae09f518b4dea4
|
||||
MpFIca/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
MpFIca/origin/1/0: 7493592314bc3b96ad96127a949ba1ba
|
||||
MpFIca/translation: 6e3f470ef5468c5195ae09f518b4dea4
|
||||
e1v%2BJ3/message: 9aeca4f286e14966ef1c32d2abc74584
|
||||
e1v%2BJ3/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
e1v%2BJ3/translation: 9aeca4f286e14966ef1c32d2abc74584
|
||||
@@ -2235,9 +2155,6 @@ checksums:
|
||||
zYRVNp/message: 8b36455dd5b43e56d2ab06e522c3bacc
|
||||
zYRVNp/origin/0/0: 97bd206a05414e2a571020d64e166093
|
||||
zYRVNp/translation: 8b36455dd5b43e56d2ab06e522c3bacc
|
||||
NM2hyD/message: dfd9e9abdca731ba9ca3fcff645b575e
|
||||
NM2hyD/origin/0/0: f8e87f3fc3dc63bc7961601feb860fa8
|
||||
NM2hyD/translation: dfd9e9abdca731ba9ca3fcff645b575e
|
||||
wgNoIs/message: eedc7cdb02de467c15dc418a066a77f2
|
||||
wgNoIs/origin/0/0: 3c57a918258af4b44c187f58d203345f
|
||||
wgNoIs/origin/1/0: 3c57a918258af4b44c187f58d203345f
|
||||
@@ -2266,26 +2183,17 @@ checksums:
|
||||
v3YoMA/message: 3fff865dc00435f82896fc302ea45630
|
||||
v3YoMA/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
v3YoMA/translation: 3fff865dc00435f82896fc302ea45630
|
||||
GXGbXi/message: 8686cb61f9bd3cd7ed100cc3af819458
|
||||
GXGbXi/origin/0/0: 627b72cd19a7154ae1c28cbd7ca0c730
|
||||
GXGbXi/translation: 8686cb61f9bd3cd7ed100cc3af819458
|
||||
8AbRER/message: 3cb81c4829857556f5d117c9a23f7bcc
|
||||
8AbRER/origin/0/0: 81b173297e9c022d9ebde5f7018c95f9
|
||||
8AbRER/origin/1/0: 0eeeb7e68164ff118040918f1ef00a7a
|
||||
8AbRER/origin/2/0: 2201bf679c663a732d7f1b3c3ce3897f
|
||||
8AbRER/translation: 3cb81c4829857556f5d117c9a23f7bcc
|
||||
2gHjVM/message: 873c4eb14a7e32e7fd7793047b6028a1
|
||||
2gHjVM/origin/0/0: 627b72cd19a7154ae1c28cbd7ca0c730
|
||||
2gHjVM/origin/1/0: 627b72cd19a7154ae1c28cbd7ca0c730
|
||||
2gHjVM/origin/2/0: 0627e0040ca4939c9a57349e98c51797
|
||||
2gHjVM/origin/3/0: 0627e0040ca4939c9a57349e98c51797
|
||||
2gHjVM/translation: 873c4eb14a7e32e7fd7793047b6028a1
|
||||
Eit43O/message: b5d9a1edd988f455041aee9fa7fb3d63
|
||||
Eit43O/origin/0/0: 1d6b5f88cdad160a67842f6b65387448
|
||||
Eit43O/translation: b5d9a1edd988f455041aee9fa7fb3d63
|
||||
JjEJSy/translation: 0154ff378b3f4329871378229e269fc7
|
||||
JjEJSy/message: 0154ff378b3f4329871378229e269fc7
|
||||
JjEJSy/origin/0/0: 69d8634b177fdf937764b406fd477547
|
||||
JjEJSy/translation: 0154ff378b3f4329871378229e269fc7
|
||||
Tz0i8g/message: 8df6777277469c1fd88cc18dde2f1cc3
|
||||
Tz0i8g/origin/0/0: 77c9f6139024b2069e4fdc5120f69cb5
|
||||
Tz0i8g/origin/1/0: 94d3e20c29bb517dd394711d7524e478
|
||||
@@ -2320,17 +2228,10 @@ checksums:
|
||||
n1ekoW/message: ec7b8f314fe9bc6591006707484ede61
|
||||
n1ekoW/origin/0/0: 6cda8b872e4902cbc0191d9375adf4ef
|
||||
n1ekoW/translation: ec7b8f314fe9bc6591006707484ede61
|
||||
jbGGzf/message: 7f18a6418cc7eb05158cff67247bc966
|
||||
jbGGzf/placeholders/partnerName/0: f182b91b0442585d06f8bf9545912628
|
||||
jbGGzf/origin/0/0: 055278d1067f7350adbbc1eaf446e43c
|
||||
jbGGzf/translation: 7f18a6418cc7eb05158cff67247bc966
|
||||
4dGN6E/message: dd086c229fd7cc0247d4c1b5c6f096a8
|
||||
4dGN6E/origin/0/0: 055278d1067f7350adbbc1eaf446e43c
|
||||
4dGN6E/translation: dd086c229fd7cc0247d4c1b5c6f096a8
|
||||
fcWrnU/translation: 6d842546a84e919c519bf8cbffdf25a0
|
||||
fcWrnU/message: 6d842546a84e919c519bf8cbffdf25a0
|
||||
fcWrnU/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
|
||||
fcWrnU/origin/1/0: 69d8634b177fdf937764b406fd477547
|
||||
fcWrnU/translation: 6d842546a84e919c519bf8cbffdf25a0
|
||||
mErq7F/message: 0dd2ae69be4618c1f9e615774a4509ca
|
||||
mErq7F/origin/0/0: 6cda8b872e4902cbc0191d9375adf4ef
|
||||
mErq7F/translation: 0dd2ae69be4618c1f9e615774a4509ca
|
||||
@@ -2366,15 +2267,9 @@ checksums:
|
||||
wIb7Ng/message: 67659097ef6bcb194839858b2c4f3a3c
|
||||
wIb7Ng/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
wIb7Ng/translation: 67659097ef6bcb194839858b2c4f3a3c
|
||||
6sKjjx/translation: 50861c0881fe87a77412417e5ffb3fbb
|
||||
6sKjjx/message: 50861c0881fe87a77412417e5ffb3fbb
|
||||
6sKjjx/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
|
||||
6sKjjx/translation: 50861c0881fe87a77412417e5ffb3fbb
|
||||
J9%2BzIR/message: d719cefeec51f2a6dd01064715a1fccc
|
||||
J9%2BzIR/origin/0/0: 055278d1067f7350adbbc1eaf446e43c
|
||||
J9%2BzIR/translation: d719cefeec51f2a6dd01064715a1fccc
|
||||
r%2BdxEH/message: c3d9e1523d1401870fbf56fa8bce8f8b
|
||||
r%2BdxEH/origin/0/0: 278a40eb45b1219f424703d0abc1d273
|
||||
r%2BdxEH/translation: c3d9e1523d1401870fbf56fa8bce8f8b
|
||||
vnS6Rf/message: 003f7f93d44b0f5c2e40bf24e7af3bd7
|
||||
vnS6Rf/origin/0/0: c0b59968b2b234167c488a05cc38710b
|
||||
vnS6Rf/translation: 003f7f93d44b0f5c2e40bf24e7af3bd7
|
||||
@@ -2382,22 +2277,15 @@ checksums:
|
||||
R6pB8R/origin/0/0: 09a6a32f9a6b415e81b4d2f8181a0e6b
|
||||
R6pB8R/translation: e5cb0428f1ac6485688f120d78742875
|
||||
veIDCY/message: 4be2811a8f3455618032ccb59e40658b
|
||||
veIDCY/origin/0/0: dbad0c8d7863cb41f5495264f82e7081
|
||||
veIDCY/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
veIDCY/origin/1/0: dbad0c8d7863cb41f5495264f82e7081
|
||||
veIDCY/translation: 4be2811a8f3455618032ccb59e40658b
|
||||
4%2BJaWo/message: e346e4ed7d138dcc873db187922369da
|
||||
4%2BJaWo/origin/0/0: 94d3e20c29bb517dd394711d7524e478
|
||||
4%2BJaWo/origin/1/0: 94d3e20c29bb517dd394711d7524e478
|
||||
4%2BJaWo/origin/2/0: 94d3e20c29bb517dd394711d7524e478
|
||||
4%2BJaWo/translation: e346e4ed7d138dcc873db187922369da
|
||||
Y8yzGg/message: 396a94648584067cc5795f7b32d3d663
|
||||
Y8yzGg/origin/0/0: 82a5cf8d69f098641eb357bf102cd504
|
||||
Y8yzGg/translation: 396a94648584067cc5795f7b32d3d663
|
||||
uAQUqI/message: 4e1fcce15854d824919b4a582c697c90
|
||||
uAQUqI/origin/0/0: dc1a240d2d0cccb102c67ca311d4e457
|
||||
uAQUqI/translation: 4e1fcce15854d824919b4a582c697c90
|
||||
hQRttt/message: 7c91ef5f747eea9f77a9c4f23e19fb2e
|
||||
hQRttt/origin/0/0: b1f4eb6768222ea825703105f6014486
|
||||
hQRttt/translation: 7c91ef5f747eea9f77a9c4f23e19fb2e
|
||||
WYDptz/message: 05f2b4abfc36def17756a6969983cf86
|
||||
WYDptz/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
WYDptz/translation: 05f2b4abfc36def17756a6969983cf86
|
||||
@@ -2421,13 +2309,12 @@ checksums:
|
||||
D%2BNlUC/message: 803281c327a02a2feba182f4a2b38311
|
||||
D%2BNlUC/origin/0/0: 358ea1b5babb2e9ccbf1a4c9f89dd3a5
|
||||
D%2BNlUC/translation: 803281c327a02a2feba182f4a2b38311
|
||||
KM6m8p/translation: c621ea9404a37af289def443b309bf1b
|
||||
KM6m8p/message: c621ea9404a37af289def443b309bf1b
|
||||
KM6m8p/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
|
||||
KM6m8p/translation: c621ea9404a37af289def443b309bf1b
|
||||
bff61F/message: 3f0ae9c914f16ceb9a0d7db868f4aa20
|
||||
bff61F/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
bff61F/origin/1/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
bff61F/origin/2/0: 7493592314bc3b96ad96127a949ba1ba
|
||||
bff61F/origin/1/0: 7493592314bc3b96ad96127a949ba1ba
|
||||
bff61F/translation: 3f0ae9c914f16ceb9a0d7db868f4aa20
|
||||
CAL6E9/message: b63448c05270497973ac4407047dae02
|
||||
CAL6E9/origin/0/0: c0b59968b2b234167c488a05cc38710b
|
||||
@@ -2460,14 +2347,6 @@ checksums:
|
||||
NnH3pK/message: 82a46f197779af4dde92e7e5f9336ccf
|
||||
NnH3pK/origin/0/0: dc1a240d2d0cccb102c67ca311d4e457
|
||||
NnH3pK/translation: 82a46f197779af4dde92e7e5f9336ccf
|
||||
InJcdo/message: 1b2364c95fd318d99cc502745beac94a
|
||||
InJcdo/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
InJcdo/origin/1/0: dc1a240d2d0cccb102c67ca311d4e457
|
||||
InJcdo/translation: 1b2364c95fd318d99cc502745beac94a
|
||||
zDNL2V/message: 6e053a87e63372e903c16a06de25c9a3
|
||||
zDNL2V/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
zDNL2V/origin/1/0: dc1a240d2d0cccb102c67ca311d4e457
|
||||
zDNL2V/translation: 6e053a87e63372e903c16a06de25c9a3
|
||||
itgYbC/message: efe14181bc517316e3ab3258d72d2ff9
|
||||
itgYbC/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
itgYbC/origin/1/0: dc1a240d2d0cccb102c67ca311d4e457
|
||||
@@ -2478,16 +2357,9 @@ checksums:
|
||||
nhMhRr/message: 07edd8c50685a52c0969d711df26d768
|
||||
nhMhRr/origin/0/0: 1fd8ccf586705354bcae6e1186108fc7
|
||||
nhMhRr/translation: 07edd8c50685a52c0969d711df26d768
|
||||
ssK7k%2B/message: 4321c1f033c350419ad198aaafe3b6fd
|
||||
ssK7k%2B/origin/0/0: 278a40eb45b1219f424703d0abc1d273
|
||||
ssK7k%2B/translation: 4321c1f033c350419ad198aaafe3b6fd
|
||||
NcFrgC/message: bd15a7b3d8b4bce18cd42682ea1ec164
|
||||
NcFrgC/origin/0/0: 0ad2af981579a62b084b3d41462c5546
|
||||
NcFrgC/translation: bd15a7b3d8b4bce18cd42682ea1ec164
|
||||
ZWk38w/message: 0a6b07963240d329f206e280282e36da
|
||||
ZWk38w/placeholders/0/0: 9622afa3aca4038b79417d808a505c87
|
||||
ZWk38w/origin/0/0: f8e87f3fc3dc63bc7961601feb860fa8
|
||||
ZWk38w/translation: 0a6b07963240d329f206e280282e36da
|
||||
C6gv54/message: 1573c77059bc92df17e661befda8d9b7
|
||||
C6gv54/origin/0/0: 0ad2af981579a62b084b3d41462c5546
|
||||
C6gv54/translation: 1573c77059bc92df17e661befda8d9b7
|
||||
@@ -2545,12 +2417,12 @@ checksums:
|
||||
vlxQFL/message: 730f33b8f1fc002c4f393ccd262b6664
|
||||
vlxQFL/origin/0/0: afee530613110e248ffde6b56872ebed
|
||||
vlxQFL/translation: 730f33b8f1fc002c4f393ccd262b6664
|
||||
HQVm6e/translation: ec66b543a593e9dabf47de5979892d8d
|
||||
HQVm6e/message: ec66b543a593e9dabf47de5979892d8d
|
||||
HQVm6e/origin/0/0: 69d8634b177fdf937764b406fd477547
|
||||
HQVm6e/translation: ec66b543a593e9dabf47de5979892d8d
|
||||
gKmoow/translation: 83997c77cb0dc8c8c90f7ab706aa817f
|
||||
gKmoow/message: 83997c77cb0dc8c8c90f7ab706aa817f
|
||||
gKmoow/origin/0/0: 69d8634b177fdf937764b406fd477547
|
||||
gKmoow/translation: 83997c77cb0dc8c8c90f7ab706aa817f
|
||||
OAYToL/message: 1c989752736fa41ecdd68ea890f16a15
|
||||
OAYToL/origin/0/0: 15792234f408027822ee71ccddde6f9b
|
||||
OAYToL/translation: 1c989752736fa41ecdd68ea890f16a15
|
||||
@@ -2560,9 +2432,6 @@ checksums:
|
||||
mwBdZ2/message: b49224632bd6c3b7f5e462912aeb1081
|
||||
mwBdZ2/origin/0/0: 5c60161e173f5eae9276d8e4c70b4ebb
|
||||
mwBdZ2/translation: b49224632bd6c3b7f5e462912aeb1081
|
||||
PV7XRK/message: 408b8862ad5ec9027eeec59b129ad18f
|
||||
PV7XRK/origin/0/0: 6cda8b872e4902cbc0191d9375adf4ef
|
||||
PV7XRK/translation: 408b8862ad5ec9027eeec59b129ad18f
|
||||
duf3Nr/message: b455329e2a71da677acab91d3a00bad6
|
||||
duf3Nr/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
|
||||
duf3Nr/translation: b455329e2a71da677acab91d3a00bad6
|
||||
@@ -2572,9 +2441,6 @@ checksums:
|
||||
kp6L3T/message: b7eadb89c615874f416d9658d0428c4c
|
||||
kp6L3T/origin/0/0: 30791e26a172d47d3cb0693e82679246
|
||||
kp6L3T/translation: b7eadb89c615874f416d9658d0428c4c
|
||||
pEb95p/message: 1d4c2a0ec7e4c707a4470fa4262b495d
|
||||
pEb95p/origin/0/0: 1b05beba6bdd908015fa8e798717f024
|
||||
pEb95p/translation: 1d4c2a0ec7e4c707a4470fa4262b495d
|
||||
MHrjPM/message: 344e64395eaff6822a57d18623853e1a
|
||||
MHrjPM/origin/0/0: a50c4fbde8da6e0a77c4e0aef0b44dea
|
||||
MHrjPM/translation: 344e64395eaff6822a57d18623853e1a
|
||||
@@ -2622,9 +2488,6 @@ checksums:
|
||||
LeM5RY/message: dfeac280858332082ad34d2623a59863
|
||||
LeM5RY/origin/0/0: 0dccc39d0debbf3e089cb6d292de762b
|
||||
LeM5RY/translation: dfeac280858332082ad34d2623a59863
|
||||
5MPY04/message: 661f2e21a85fbe1281b141a8041c8778
|
||||
5MPY04/origin/0/0: 1b05beba6bdd908015fa8e798717f024
|
||||
5MPY04/translation: 661f2e21a85fbe1281b141a8041c8778
|
||||
W1ewR0/message: ddcac5d14d48358b043a9487b42eec16
|
||||
W1ewR0/origin/0/0: fc0c18a095a68277a0f90a115942683b
|
||||
W1ewR0/origin/1/0: 1b05beba6bdd908015fa8e798717f024
|
||||
@@ -2644,9 +2507,6 @@ checksums:
|
||||
YcyP2z/origin/0/0: 3161dd30737e7d849d4ccc30a28bada9
|
||||
YcyP2z/origin/1/0: 3161dd30737e7d849d4ccc30a28bada9
|
||||
YcyP2z/translation: 7495f1434c9188849bffd015811d5337
|
||||
c1vOQR/message: 640ab5162eed616a7235d68376a016ed
|
||||
c1vOQR/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
c1vOQR/translation: 640ab5162eed616a7235d68376a016ed
|
||||
MxQXhL/message: c1c949477e121d73ed6683eddf9cd874
|
||||
MxQXhL/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
|
||||
MxQXhL/origin/1/0: 69d8634b177fdf937764b406fd477547
|
||||
@@ -2666,16 +2526,9 @@ checksums:
|
||||
2QGEbi/message: 550198b2c87f06726a843c79c1026ed9
|
||||
2QGEbi/origin/0/0: 4b4f55c033add8db69178563832227c6
|
||||
2QGEbi/translation: 550198b2c87f06726a843c79c1026ed9
|
||||
23nvb2/message: b9dedce59b5594ee2a88bf5d55d6dfac
|
||||
23nvb2/origin/0/0: a1fde3fcce608870830f168f027727ff
|
||||
23nvb2/translation: b9dedce59b5594ee2a88bf5d55d6dfac
|
||||
Oo4E6p/message: af985cf72c47df76e2f2139a427d9948
|
||||
Oo4E6p/origin/0/0: 97bd206a05414e2a571020d64e166093
|
||||
Oo4E6p/origin/1/0: 1b05beba6bdd908015fa8e798717f024
|
||||
Oo4E6p/translation: af985cf72c47df76e2f2139a427d9948
|
||||
d196%2F6/message: 64c37452b5ff985b44f1347ce0ef659f
|
||||
d196%2F6/origin/0/0: f8e87f3fc3dc63bc7961601feb860fa8
|
||||
d196%2F6/translation: 64c37452b5ff985b44f1347ce0ef659f
|
||||
K7k9u3/message: 1cf9d227042f6a65adba38cb7296830d
|
||||
K7k9u3/origin/0/0: fd0e66cef8d46e96bcf3143a12f9b6db
|
||||
K7k9u3/translation: 1cf9d227042f6a65adba38cb7296830d
|
||||
@@ -2691,13 +2544,9 @@ checksums:
|
||||
A8jqN7/message: 656c93265d7e2bef1245b17d85f85ae5
|
||||
A8jqN7/origin/0/0: 1fd8ccf586705354bcae6e1186108fc7
|
||||
A8jqN7/translation: 656c93265d7e2bef1245b17d85f85ae5
|
||||
Q60WUZ/translation: 4284c33aed2f2c5e004ed0a7d7d9bf87
|
||||
Q60WUZ/message: 4284c33aed2f2c5e004ed0a7d7d9bf87
|
||||
Q60WUZ/origin/0/0: 69d8634b177fdf937764b406fd477547
|
||||
Q60WUZ/translation: 4284c33aed2f2c5e004ed0a7d7d9bf87
|
||||
KNK33q/message: a606d59661b59b5e2e9ab58e8cbc143b
|
||||
KNK33q/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
KNK33q/origin/1/0: dc1a240d2d0cccb102c67ca311d4e457
|
||||
KNK33q/translation: a606d59661b59b5e2e9ab58e8cbc143b
|
||||
T7Mywq/message: 62899897d7bffcb1df3af77d460f0881
|
||||
T7Mywq/origin/0/0: 0ad2af981579a62b084b3d41462c5546
|
||||
T7Mywq/translation: 62899897d7bffcb1df3af77d460f0881
|
||||
@@ -2740,9 +2589,6 @@ checksums:
|
||||
TFv5tM/message: 4f2240aeff6f7275feb33ca3f608e48c
|
||||
TFv5tM/origin/0/0: 7493592314bc3b96ad96127a949ba1ba
|
||||
TFv5tM/translation: 4f2240aeff6f7275feb33ca3f608e48c
|
||||
V1o9Jo/message: 7b212f1c2fdc370974f859b08349b0c6
|
||||
V1o9Jo/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
V1o9Jo/translation: 7b212f1c2fdc370974f859b08349b0c6
|
||||
It4%2FFS/message: ae1527c2765592f13980acb66997e570
|
||||
It4%2FFS/origin/0/0: 0ad2af981579a62b084b3d41462c5546
|
||||
It4%2FFS/translation: ae1527c2765592f13980acb66997e570
|
||||
@@ -2786,21 +2632,16 @@ checksums:
|
||||
7Ufoyg/message: 5c418701b27b9acbc5dd199719007362
|
||||
7Ufoyg/origin/0/0: 9737a9cef0646ff4bbd942b1924b9a2c
|
||||
7Ufoyg/translation: 5c418701b27b9acbc5dd199719007362
|
||||
uXKtPH/message: 8a95d730b360db6041001450aa751ab4
|
||||
uXKtPH/origin/0/0: 619b4be43b9080346366c4bf6ba2253d
|
||||
uXKtPH/translation: 8a95d730b360db6041001450aa751ab4
|
||||
mx8%2B1u/message: f2949ca2dc18b0063af92e60779abb65
|
||||
mx8%2B1u/origin/0/0: c0b59968b2b234167c488a05cc38710b
|
||||
mx8%2B1u/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
|
||||
mx8%2B1u/origin/1/0: c0b59968b2b234167c488a05cc38710b
|
||||
mx8%2B1u/origin/2/0: fadd5a5b6963ec5f261072f7e8d2140b
|
||||
mx8%2B1u/origin/3/0: dbad0c8d7863cb41f5495264f82e7081
|
||||
mx8%2B1u/origin/2/0: c0b59968b2b234167c488a05cc38710b
|
||||
mx8%2B1u/origin/3/0: fadd5a5b6963ec5f261072f7e8d2140b
|
||||
mx8%2B1u/origin/4/0: dbad0c8d7863cb41f5495264f82e7081
|
||||
mx8%2B1u/translation: f2949ca2dc18b0063af92e60779abb65
|
||||
8AwlaR/translation: acb1fdc14ed875815ae9f8a5254bc634
|
||||
8AwlaR/message: acb1fdc14ed875815ae9f8a5254bc634
|
||||
8AwlaR/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
|
||||
8AwlaR/translation: acb1fdc14ed875815ae9f8a5254bc634
|
||||
i5yNAO/message: 478092018a27a4e588cb5c70968ef11a
|
||||
i5yNAO/origin/0/0: 7493592314bc3b96ad96127a949ba1ba
|
||||
i5yNAO/translation: 478092018a27a4e588cb5c70968ef11a
|
||||
Ws%2B3X%2B/message: 905b10eeb09fdc5ac7fc757e935ee757
|
||||
Ws%2B3X%2B/origin/0/0: fadd5a5b6963ec5f261072f7e8d2140b
|
||||
Ws%2B3X%2B/translation: 905b10eeb09fdc5ac7fc757e935ee757
|
||||
@@ -2834,15 +2675,14 @@ checksums:
|
||||
zERArS/placeholders/0/0: 06f23520ebabfffed4999b5bcf61c9cd
|
||||
zERArS/origin/0/0: 1d6b5f88cdad160a67842f6b65387448
|
||||
zERArS/translation: aca196d729401d8adc5c8ca9d558840e
|
||||
kwkhPe/message: 63c3b52882e0d779859307d672c178c2
|
||||
kwkhPe/origin/0/0: 7493592314bc3b96ad96127a949ba1ba
|
||||
kwkhPe/origin/1/0: 15ea2dfd5eae4e05b226b1320103476e
|
||||
kwkhPe/translation: 63c3b52882e0d779859307d672c178c2
|
||||
IelE1h/message: 972773025e763ddf53273df6d37a729a
|
||||
IelE1h/origin/0/0: dbad0c8d7863cb41f5495264f82e7081
|
||||
IelE1h/origin/0/0: 94d3e20c29bb517dd394711d7524e478
|
||||
IelE1h/origin/1/0: dbad0c8d7863cb41f5495264f82e7081
|
||||
IelE1h/origin/2/0: 5f6c4a0c9be5098099167871ae1564af
|
||||
IelE1h/translation: 972773025e763ddf53273df6d37a729a
|
||||
cWtfn1/message: 9a91ea70f5eb55aac96640bf0fd7feff
|
||||
cWtfn1/origin/0/0: 69d8634b177fdf937764b406fd477547
|
||||
cWtfn1/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
|
||||
cWtfn1/origin/1/0: 69d8634b177fdf937764b406fd477547
|
||||
cWtfn1/translation: 9a91ea70f5eb55aac96640bf0fd7feff
|
||||
b3Thhd/message: 5b7b755f05330164dbfb00096c500ee3
|
||||
b3Thhd/origin/0/0: 12c1fab543c8848bcb10107d47336736
|
||||
@@ -2912,22 +2752,12 @@ checksums:
|
||||
"%2BEkBs0/message": 69d1d2575f2e73b08ac622bde0a45b4d
|
||||
"%2BEkBs0/origin/0/0": d1e1d7ccce60e05570bedfbb93fc9416
|
||||
"%2BEkBs0/translation": 69d1d2575f2e73b08ac622bde0a45b4d
|
||||
7sdSkl/message: 6132de0281f288a30690bb2218089951
|
||||
7sdSkl/placeholders/magicLinkRecipient/0: 63ab7807b81db57469a3b7fa40e6826e
|
||||
7sdSkl/origin/0/0: 055278d1067f7350adbbc1eaf446e43c
|
||||
7sdSkl/translation: 6132de0281f288a30690bb2218089951
|
||||
9y1RcT/message: 2dfa121e0ff1914e08068308a81973c0
|
||||
9y1RcT/origin/0/0: d77eb1f90f2fa86a55181629d88d237a
|
||||
9y1RcT/translation: 2dfa121e0ff1914e08068308a81973c0
|
||||
an6ayw/message: 2d39f6440ad379e417193dbfaa265b5f
|
||||
an6ayw/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
an6ayw/translation: 2d39f6440ad379e417193dbfaa265b5f
|
||||
GdWB%2BV/message: 6a15a9b926ecd2c91454e9da19c6049e
|
||||
GdWB%2BV/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
GdWB%2BV/translation: 6a15a9b926ecd2c91454e9da19c6049e
|
||||
LnaC5y/message: 229d2a9159e17f243cf317b9bd7d471c
|
||||
LnaC5y/origin/0/0: a1fde3fcce608870830f168f027727ff
|
||||
LnaC5y/translation: 229d2a9159e17f243cf317b9bd7d471c
|
||||
2X4ecw/message: fcefd247ec76a372002d2cffac3c5b0f
|
||||
2X4ecw/origin/0/0: a1fde3fcce608870830f168f027727ff
|
||||
2X4ecw/translation: fcefd247ec76a372002d2cffac3c5b0f
|
||||
@@ -2941,9 +2771,6 @@ checksums:
|
||||
8iP9oQ/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
8iP9oQ/origin/1/0: dc1a240d2d0cccb102c67ca311d4e457
|
||||
8iP9oQ/translation: 5c07446c838d07572c6b40621507e951
|
||||
kxZ7LS/message: 41ebcdff41ce85329915754faf7140c0
|
||||
kxZ7LS/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
kxZ7LS/translation: 41ebcdff41ce85329915754faf7140c0
|
||||
3d54Wj/message: d67dc42810694fd1344a9a9545aca1e7
|
||||
3d54Wj/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
3d54Wj/translation: d67dc42810694fd1344a9a9545aca1e7
|
||||
@@ -3050,9 +2877,9 @@ checksums:
|
||||
RtlIYB/message: df4783d593505766d78c5bc08d3d48ac
|
||||
RtlIYB/origin/0/0: 627b72cd19a7154ae1c28cbd7ca0c730
|
||||
RtlIYB/translation: df4783d593505766d78c5bc08d3d48ac
|
||||
3WXdoZ/translation: 4cbeab40f77975492a23eec428732440
|
||||
3WXdoZ/message: 4cbeab40f77975492a23eec428732440
|
||||
3WXdoZ/origin/0/0: 69d8634b177fdf937764b406fd477547
|
||||
3WXdoZ/translation: 4cbeab40f77975492a23eec428732440
|
||||
aelko%2B/message: 41dd145ef56f539e12dc064a9807c660
|
||||
aelko%2B/origin/0/0: 4980d8e27a628e7cd9a70b839d844083
|
||||
aelko%2B/translation: 41dd145ef56f539e12dc064a9807c660
|
||||
@@ -3062,9 +2889,6 @@ checksums:
|
||||
vAj6xG/message: a6152a41b2d8f64d5a657e5b8bc808a9
|
||||
vAj6xG/origin/0/0: 4980d8e27a628e7cd9a70b839d844083
|
||||
vAj6xG/translation: a6152a41b2d8f64d5a657e5b8bc808a9
|
||||
tUL16u/message: 9064ca54e8a3ad28c03a173bc7b01184
|
||||
tUL16u/origin/0/0: f8e87f3fc3dc63bc7961601feb860fa8
|
||||
tUL16u/translation: 9064ca54e8a3ad28c03a173bc7b01184
|
||||
h2FKMV/message: 11d928b1993d95d54a95f85f8ae5016d
|
||||
h2FKMV/origin/0/0: 6812b79a8fdc3527586b523d4c137340
|
||||
h2FKMV/origin/1/0: 85d0a3237fa9329fba3f0278a2cd7284
|
||||
@@ -3090,28 +2914,18 @@ checksums:
|
||||
MdN5zD/message: e2e816f2b7a13b1056d79e7f25088664
|
||||
MdN5zD/origin/0/0: 0dccc39d0debbf3e089cb6d292de762b
|
||||
MdN5zD/translation: e2e816f2b7a13b1056d79e7f25088664
|
||||
ZhQoZn/message: b05a1191bf048d76e7d6c1c3e76ce1fd
|
||||
ZhQoZn/origin/0/0: 0627e0040ca4939c9a57349e98c51797
|
||||
ZhQoZn/translation: b05a1191bf048d76e7d6c1c3e76ce1fd
|
||||
2SePRT/message: 257b840726f972f384243a72767f880f
|
||||
2SePRT/origin/0/0: 6cda8b872e4902cbc0191d9375adf4ef
|
||||
2SePRT/translation: 257b840726f972f384243a72767f880f
|
||||
uOqaMC/message: 24fc6cdc8740f37a83df85f582f03293
|
||||
uOqaMC/origin/0/0: 6cda8b872e4902cbc0191d9375adf4ef
|
||||
uOqaMC/translation: 24fc6cdc8740f37a83df85f582f03293
|
||||
QriiAI/message: 37a6b38e70fdfef1e2208ede67aeab98
|
||||
QriiAI/placeholders/0/0: 4ab04dd216aebb6485be0c4b94d5ca6b
|
||||
QriiAI/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
QriiAI/translation: 37a6b38e70fdfef1e2208ede67aeab98
|
||||
8Zjc7q/message: 65609a209e74f230563a7c8d7258a83c
|
||||
8Zjc7q/origin/0/0: 278a40eb45b1219f424703d0abc1d273
|
||||
8Zjc7q/translation: 65609a209e74f230563a7c8d7258a83c
|
||||
GoDLB9/message: 8c8d944e07388c5877effdb2c2803dcf
|
||||
GoDLB9/origin/0/0: 5c60161e173f5eae9276d8e4c70b4ebb
|
||||
GoDLB9/translation: 8c8d944e07388c5877effdb2c2803dcf
|
||||
c4nMcR/translation: bf4f697079eef51c3ebe46ce79063895
|
||||
c4nMcR/message: bf4f697079eef51c3ebe46ce79063895
|
||||
c4nMcR/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
|
||||
c4nMcR/translation: bf4f697079eef51c3ebe46ce79063895
|
||||
evg7%2BA/message: 403972e7a25afc2415762c1c2b1ec868
|
||||
evg7%2BA/origin/0/0: 3c57a918258af4b44c187f58d203345f
|
||||
evg7%2BA/translation: 403972e7a25afc2415762c1c2b1ec868
|
||||
@@ -3130,18 +2944,9 @@ checksums:
|
||||
PELbXB/message: 6984aed03bbcd94041c5dd20f62e56ed
|
||||
PELbXB/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
|
||||
PELbXB/translation: 6984aed03bbcd94041c5dd20f62e56ed
|
||||
ygpeZ8/message: 59444b15ae58f987931a4746f4c77a0b
|
||||
ygpeZ8/origin/0/0: 278a40eb45b1219f424703d0abc1d273
|
||||
ygpeZ8/translation: 59444b15ae58f987931a4746f4c77a0b
|
||||
"%2BA6Wuq/message": 36d33d3622921dbb1475da2acb98f754
|
||||
"%2BA6Wuq/origin/0/0": 278a40eb45b1219f424703d0abc1d273
|
||||
"%2BA6Wuq/translation": 36d33d3622921dbb1475da2acb98f754
|
||||
Ozt2vv/message: 8f730f68d52b7efa727aa0784282ebcb
|
||||
Ozt2vv/origin/0/0: 0627e0040ca4939c9a57349e98c51797
|
||||
Ozt2vv/translation: 8f730f68d52b7efa727aa0784282ebcb
|
||||
EuK5ez/message: d262a1ba9ebeea549d982f9cf85a6c18
|
||||
EuK5ez/origin/0/0: 0627e0040ca4939c9a57349e98c51797
|
||||
EuK5ez/translation: d262a1ba9ebeea549d982f9cf85a6c18
|
||||
"%2FtOdd2/message": 099e1780348cd7bf617721344371a430
|
||||
"%2FtOdd2/origin/0/0": 03c00909b41be01231cd3e843c8ee44e
|
||||
"%2FtOdd2/translation": 099e1780348cd7bf617721344371a430
|
||||
@@ -3157,12 +2962,12 @@ checksums:
|
||||
25fAUC/message: 3e4cce96bc1197a1caa2b7fb4d3c7e53
|
||||
25fAUC/origin/0/0: d1e1d7ccce60e05570bedfbb93fc9416
|
||||
25fAUC/translation: 3e4cce96bc1197a1caa2b7fb4d3c7e53
|
||||
GRAGsB/translation: 751a534fd6e62a92ff64c46894c7db86
|
||||
GRAGsB/message: 751a534fd6e62a92ff64c46894c7db86
|
||||
GRAGsB/origin/0/0: 69d8634b177fdf937764b406fd477547
|
||||
GRAGsB/translation: 751a534fd6e62a92ff64c46894c7db86
|
||||
j0o6ml/translation: 1e036ffca9d9cb9e2941b83fbbf375c0
|
||||
j0o6ml/message: 1e036ffca9d9cb9e2941b83fbbf375c0
|
||||
j0o6ml/origin/0/0: 69d8634b177fdf937764b406fd477547
|
||||
j0o6ml/translation: 1e036ffca9d9cb9e2941b83fbbf375c0
|
||||
GnSSGm/message: 98de6f65c567c6654cc0adce3c9d5656
|
||||
GnSSGm/origin/0/0: 33bf266228f835e12c0bfb0e5efb3be9
|
||||
GnSSGm/translation: 98de6f65c567c6654cc0adce3c9d5656
|
||||
@@ -3175,10 +2980,10 @@ checksums:
|
||||
Hg1qE9/message: c808949b9b2b4a9aba2472f5d1050167
|
||||
Hg1qE9/origin/0/0: 30791e26a172d47d3cb0693e82679246
|
||||
Hg1qE9/translation: c808949b9b2b4a9aba2472f5d1050167
|
||||
OTxjpR/translation: 370a32c49489db61978ac91b55699c24
|
||||
OTxjpR/message: 370a32c49489db61978ac91b55699c24
|
||||
OTxjpR/origin/0/0: 69d8634b177fdf937764b406fd477547
|
||||
OTxjpR/origin/1/0: 69d8634b177fdf937764b406fd477547
|
||||
OTxjpR/translation: 370a32c49489db61978ac91b55699c24
|
||||
4DOCMI/message: 0b48896061a1124501fdaba026804148
|
||||
4DOCMI/origin/0/0: a50c4fbde8da6e0a77c4e0aef0b44dea
|
||||
4DOCMI/translation: 0b48896061a1124501fdaba026804148
|
||||
|
||||
@@ -27,13 +27,13 @@
|
||||
"@kan/api": "workspace:*",
|
||||
"@kan/auth": "workspace:*",
|
||||
"@kan/db": "workspace:^",
|
||||
"@kan/email": "workspace:^",
|
||||
"@kan/logger": "workspace:^",
|
||||
"@kan/shared": "workspace:^",
|
||||
"@lingui/babel-preset-react": "^2.9.2",
|
||||
"@lingui/conf": "^5.3.2",
|
||||
"@lingui/macro": "^5.3.2",
|
||||
"@lingui/react": "^5.3.2",
|
||||
"@novu/api": "^3.11.0",
|
||||
"@t3-oss/env-nextjs": "^0.11.1",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tanstack/react-query": "catalog:",
|
||||
|
||||
@@ -46,7 +46,6 @@ interface FormValues {
|
||||
interface AuthProps {
|
||||
setIsMagicLinkSent: (value: boolean, recipient: string) => void;
|
||||
isSignUp?: boolean;
|
||||
callbackURL?: string;
|
||||
}
|
||||
|
||||
const EmailSchema = z.object({
|
||||
@@ -153,11 +152,7 @@ const availableSocialProviders = {
|
||||
},
|
||||
};
|
||||
|
||||
export function Auth({
|
||||
setIsMagicLinkSent,
|
||||
isSignUp,
|
||||
callbackURL: callbackURLProp,
|
||||
}: AuthProps) {
|
||||
export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
const [isCloudEnv, setIsCloudEnv] = useState(false);
|
||||
const [isLoginWithProviderPending, setIsLoginWithProviderPending] =
|
||||
useState<null | AuthProvider>(null);
|
||||
@@ -170,7 +165,7 @@ export function Auth({
|
||||
const passwordRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const redirect = useSearchParams().get("next");
|
||||
const callbackURL = callbackURLProp ?? redirect ?? "/boards";
|
||||
const callbackURL = redirect ?? "/boards";
|
||||
|
||||
// Safely get environment variables on client side to avoid hydration mismatch
|
||||
useEffect(() => {
|
||||
|
||||
@@ -33,7 +33,6 @@ type SearchResult =
|
||||
boardPublicId: string;
|
||||
boardName: string;
|
||||
listName: string;
|
||||
cardNumber: number | null;
|
||||
updatedAt: Date | null;
|
||||
createdAt: Date;
|
||||
type: "card";
|
||||
@@ -160,17 +159,8 @@ export default function CommandPallette({
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="truncate text-sm font-bold text-light-900 dark:text-dark-900">
|
||||
{result.title}
|
||||
</div>
|
||||
{result.type === "card" &&
|
||||
result.cardNumber != null &&
|
||||
workspace.cardPrefix && (
|
||||
<span className="flex-shrink-0 text-xs text-light-600 dark:text-dark-600">
|
||||
{workspace.cardPrefix}-{result.cardNumber}
|
||||
</span>
|
||||
)}
|
||||
<div className="truncate text-sm font-bold text-light-900 dark:text-dark-900">
|
||||
{result.title}
|
||||
</div>
|
||||
{result.type === "card" && (
|
||||
<div className="truncate text-xs text-light-700 dark:text-dark-700">
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
TbLayoutSidebarLeftCollapse,
|
||||
@@ -14,12 +13,8 @@ import { authClient } from "@kan/auth/client";
|
||||
|
||||
import { useClickOutside } from "~/hooks/useClickOutside";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { useWorkspace, WorkspaceProvider } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
import { ChangePasswordFormConfirmation } from "~/views/settings/components/ChangePasswordConfirmation";
|
||||
import Button from "./Button";
|
||||
import Modal from "./modal";
|
||||
import SideNavigation from "./SideNavigation";
|
||||
|
||||
interface DashboardProps {
|
||||
@@ -48,11 +43,9 @@ export default function Dashboard({
|
||||
hasRightPanel = false,
|
||||
}: DashboardProps) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const { openModal, closeModal, modalContentType } = useModal();
|
||||
const { openModal } = useModal();
|
||||
const { availableWorkspaces, hasLoaded } = useWorkspace();
|
||||
const { showPopup } = usePopup();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const { data: session, isPending: sessionLoading } = authClient.useSession();
|
||||
const { data: user, isLoading: userLoading } = api.user.getUser.useQuery(
|
||||
@@ -106,67 +99,16 @@ export default function Dashboard({
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const partnerActivated = searchParams.get("partner_activated");
|
||||
const partnerError = searchParams.get("partner_error");
|
||||
|
||||
if (partnerActivated) {
|
||||
showPopup({
|
||||
header: t`License activated`,
|
||||
message: t`Your license has been activated successfully.`,
|
||||
icon: "success",
|
||||
});
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete("partner_activated");
|
||||
router.replace(`?${params.toString()}`);
|
||||
} else if (partnerError) {
|
||||
const messages: Record<string, string> = {
|
||||
invalid_license: t`That license key could not be found. Please contact support.`,
|
||||
license_inactive: t`Your license is not active. Please check your account.`,
|
||||
missing_license: t`No license key was provided. Please try activating again.`,
|
||||
};
|
||||
showPopup({
|
||||
header: t`License activation failed`,
|
||||
message:
|
||||
messages[partnerError] ??
|
||||
t`Something went wrong during license activation.`,
|
||||
icon: "error",
|
||||
});
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete("partner_error");
|
||||
router.replace(`?${params.toString()}`);
|
||||
}
|
||||
}, [searchParams, showPopup, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasLoaded && availableWorkspaces.length === 0) {
|
||||
if (env("NEXT_PUBLIC_KAN_ENV") === "cloud") {
|
||||
router.push(
|
||||
`/onboarding/select-plan?returnUrl=${encodeURIComponent(window.location.pathname)}`,
|
||||
);
|
||||
router.push(`/onboarding/select-plan?returnUrl=${encodeURIComponent(window.location.pathname)}`);
|
||||
} else {
|
||||
openModal("NEW_WORKSPACE", undefined, undefined, false);
|
||||
}
|
||||
}
|
||||
}, [hasLoaded, availableWorkspaces.length, openModal, router]);
|
||||
|
||||
useEffect(() => {
|
||||
const isCredentialsEnabled =
|
||||
env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true";
|
||||
|
||||
if (
|
||||
!userLoading &&
|
||||
user &&
|
||||
isCredentialsEnabled &&
|
||||
user.hasMagicLinkAccount &&
|
||||
!user.hasPassword &&
|
||||
!sessionStorage.getItem("set_password_prompted")
|
||||
) {
|
||||
sessionStorage.setItem("set_password_prompted", "1");
|
||||
openModal("SET_PASSWORD_PROMPT");
|
||||
}
|
||||
}, [user, userLoading, openModal]);
|
||||
|
||||
const isDarkMode = resolvedTheme === "dark";
|
||||
|
||||
return (
|
||||
@@ -261,30 +203,6 @@ export default function Dashboard({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={modalContentType === "SET_PASSWORD_PROMPT"}
|
||||
>
|
||||
{user?.hasPassword ? (
|
||||
<div className="p-5">
|
||||
<h2 className="pb-4 text-base font-medium dark:text-white">{t`Password already set`}</h2>
|
||||
<p className="mb-6 text-sm text-light-900">
|
||||
{t`Your account already has a password. You can change it from your account settings.`}
|
||||
</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={closeModal}
|
||||
fullWidth
|
||||
size="lg"
|
||||
>
|
||||
{t`Close`}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<ChangePasswordFormConfirmation hasPassword={false} />
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import { t } from "@lingui/core/macro";
|
||||
import Link from "@tiptap/extension-link";
|
||||
import Mention from "@tiptap/extension-mention";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import Typography from "@tiptap/extension-typography";
|
||||
import {
|
||||
BubbleMenu,
|
||||
EditorContent,
|
||||
@@ -18,6 +17,7 @@ import {
|
||||
ReactRenderer,
|
||||
useEditor,
|
||||
} from "@tiptap/react";
|
||||
import Typography from "@tiptap/extension-typography";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Suggestion from "@tiptap/suggestion";
|
||||
import {
|
||||
@@ -85,7 +85,7 @@ export interface RenderSuggestionsProps {
|
||||
export interface WorkspaceMember {
|
||||
publicId: string;
|
||||
user: {
|
||||
id: string | null;
|
||||
id: string;
|
||||
name: string | null;
|
||||
image: string | null;
|
||||
} | null;
|
||||
@@ -180,13 +180,13 @@ const RenderSuggestions = () => {
|
||||
|
||||
if (!props.clientRect) return;
|
||||
|
||||
popup?.[0]?.setProps({
|
||||
popup[0]?.setProps({
|
||||
getReferenceClientRect: props.clientRect,
|
||||
});
|
||||
},
|
||||
onKeyDown(props: SuggestionKeyDownProps): boolean {
|
||||
if (props.event.key === "Escape") {
|
||||
popup?.[0]?.hide();
|
||||
popup[0]?.hide();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -199,7 +199,7 @@ const RenderSuggestions = () => {
|
||||
);
|
||||
},
|
||||
onExit() {
|
||||
popup?.[0]?.destroy();
|
||||
popup[0]?.destroy();
|
||||
reactRenderer.destroy();
|
||||
},
|
||||
};
|
||||
@@ -308,11 +308,11 @@ const renderMentionSuggestions = () => {
|
||||
onUpdate(props: any) {
|
||||
reactRenderer.updateProps(props);
|
||||
if (!props.clientRect) return;
|
||||
popup?.[0]?.setProps({ getReferenceClientRect: props.clientRect });
|
||||
popup[0]?.setProps({ getReferenceClientRect: props.clientRect });
|
||||
},
|
||||
onKeyDown(props: SuggestionKeyDownProps) {
|
||||
if (props.event.key === "Escape") {
|
||||
popup?.[0]?.hide();
|
||||
popup[0]?.hide();
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
@@ -324,7 +324,7 @@ const renderMentionSuggestions = () => {
|
||||
);
|
||||
},
|
||||
onExit() {
|
||||
popup?.[0]?.destroy();
|
||||
popup[0]?.destroy();
|
||||
reactRenderer.destroy();
|
||||
},
|
||||
};
|
||||
@@ -440,7 +440,6 @@ export default function Editor({
|
||||
content,
|
||||
onChange,
|
||||
onBlur,
|
||||
onSubmit,
|
||||
readOnly = false,
|
||||
workspaceMembers,
|
||||
enableYouTubeEmbed = true,
|
||||
@@ -450,7 +449,6 @@ export default function Editor({
|
||||
content: string | null;
|
||||
onChange?: (value: string) => void;
|
||||
onBlur?: () => void;
|
||||
onSubmit?: () => void;
|
||||
readOnly?: boolean;
|
||||
workspaceMembers: WorkspaceMember[];
|
||||
enableYouTubeEmbed?: boolean;
|
||||
@@ -459,21 +457,6 @@ export default function Editor({
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// useEditor is created once (empty deps below), so keep the latest callbacks
|
||||
// in refs to avoid the editor capturing stale closures on re-render.
|
||||
const onChangeRef = useRef(onChange);
|
||||
const onBlurRef = useRef(onBlur);
|
||||
const onSubmitRef = useRef(onSubmit);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
}, [onChange]);
|
||||
useEffect(() => {
|
||||
onBlurRef.current = onBlur;
|
||||
}, [onBlur]);
|
||||
useEffect(() => {
|
||||
onSubmitRef.current = onSubmit;
|
||||
}, [onSubmit]);
|
||||
|
||||
const editor = useEditor(
|
||||
{
|
||||
extensions: [
|
||||
@@ -491,12 +474,12 @@ export default function Editor({
|
||||
autolink: true,
|
||||
linkOnPaste: true,
|
||||
}),
|
||||
Markdown.configure({ transformPastedText: true }),
|
||||
Markdown,
|
||||
Placeholder.configure({
|
||||
placeholder: readOnly
|
||||
? ""
|
||||
: (placeholder ??
|
||||
t`Add description... (type '/' to open commands or '@' to mention)`),
|
||||
: placeholder ??
|
||||
t`Add description... (type '/' to open commands or '@' to mention)`,
|
||||
}),
|
||||
SlashCommands.configure({
|
||||
commandItems: getCommandItems(disableHeadings),
|
||||
@@ -514,26 +497,24 @@ export default function Editor({
|
||||
suggestion: {
|
||||
char: "@",
|
||||
items: ({ query }: { query: string }) => {
|
||||
const withEmail = workspaceMembers.filter(
|
||||
(member) => member.email,
|
||||
);
|
||||
|
||||
const withEmail = workspaceMembers.filter((member) => member.email);
|
||||
|
||||
const mapped = withEmail.map((member: WorkspaceMember) => ({
|
||||
id: member.publicId,
|
||||
label: member?.user?.name?.trim() || member.email || "",
|
||||
image: member?.user?.image ?? null,
|
||||
}));
|
||||
|
||||
|
||||
const all: MentionItem[] = mapped.filter(
|
||||
(item) => item.label && item.label.length > 0,
|
||||
);
|
||||
|
||||
|
||||
const q = query.toLowerCase().trim();
|
||||
|
||||
|
||||
if (q === "") {
|
||||
return all;
|
||||
}
|
||||
|
||||
|
||||
const filtered = all.filter((u) =>
|
||||
u.label.toLowerCase().includes(q),
|
||||
);
|
||||
@@ -559,20 +540,20 @@ export default function Editor({
|
||||
},
|
||||
}),
|
||||
Typography.configure({
|
||||
openDoubleQuote: false,
|
||||
closeDoubleQuote: false,
|
||||
openSingleQuote: false,
|
||||
closeSingleQuote: false,
|
||||
oneHalf: false,
|
||||
oneQuarter: false,
|
||||
threeQuarters: false,
|
||||
superscriptTwo: false,
|
||||
superscriptThree: false,
|
||||
openDoubleQuote: false,
|
||||
closeDoubleQuote: false,
|
||||
openSingleQuote: false,
|
||||
closeSingleQuote: false,
|
||||
oneHalf: false,
|
||||
oneQuarter: false,
|
||||
threeQuarters: false,
|
||||
superscriptTwo: false,
|
||||
superscriptThree: false,
|
||||
}),
|
||||
...(enableYouTubeEmbed ? [YouTubeNode] : []),
|
||||
],
|
||||
content,
|
||||
onUpdate: ({ editor }) => onChangeRef.current?.(editor.getHTML()),
|
||||
onUpdate: ({ editor }) => onChange?.(editor.getHTML()),
|
||||
onBlur: ({ event }) => {
|
||||
if (
|
||||
document
|
||||
@@ -582,20 +563,13 @@ export default function Editor({
|
||||
return;
|
||||
// Only trigger onBlur if the click was outside both the editor and menu
|
||||
if (!containerRef.current?.contains(event.relatedTarget as Node)) {
|
||||
onBlurRef.current?.();
|
||||
onBlur?.();
|
||||
}
|
||||
},
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: "outline-none focus:outline-none focus-visible:ring-0",
|
||||
},
|
||||
handleKeyDown: (_view, event) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
|
||||
onSubmitRef.current?.();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
editable: !readOnly,
|
||||
injectCSS: false,
|
||||
@@ -613,16 +587,6 @@ export default function Editor({
|
||||
}
|
||||
}, [content, editor]);
|
||||
|
||||
// useEditor captures `readOnly` once at creation time (empty deps above), so
|
||||
// explicitly sync `editable` when the prop changes. Without this the editor
|
||||
// gets stuck read-only when `readOnly` flips from true to false after mount
|
||||
// (e.g. card permissions resolving slower than the card data on first load).
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
if (editor.isEditable === !readOnly) return;
|
||||
editor.setEditable(!readOnly);
|
||||
}, [editor, readOnly]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
<style jsx global>{`
|
||||
|
||||
@@ -96,7 +96,6 @@ export function NewWorkspaceForm() {
|
||||
description: values.description,
|
||||
slug: values.slug,
|
||||
plan: values.plan,
|
||||
cardPrefix: values.cardPrefix,
|
||||
role: "admin",
|
||||
weekStartDay: 1,
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Button } from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { HiBolt } from "react-icons/hi2";
|
||||
import {
|
||||
TbLayoutSidebarLeftCollapse,
|
||||
@@ -28,6 +28,7 @@ import ButtonComponent from "~/components/Button";
|
||||
import ReactiveButton from "~/components/ReactiveButton";
|
||||
import UserMenu from "~/components/UserMenu";
|
||||
import WorkspaceMenu from "~/components/WorkspaceMenu";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
@@ -52,6 +53,7 @@ export default function SideNavigation({
|
||||
const { workspace } = useWorkspace();
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const [isInitialised, setIsInitialised] = useState(false);
|
||||
const { openModal } = useModal();
|
||||
|
||||
const { data: workspaceData } = api.workspace.byId.useQuery(
|
||||
{ workspacePublicId: workspace.publicId },
|
||||
@@ -92,59 +94,56 @@ export default function SideNavigation({
|
||||
href: string;
|
||||
icon: object;
|
||||
keyboardShortcut: KeyboardShortcut;
|
||||
}[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
name: t`Boards`,
|
||||
href: "/boards",
|
||||
icon: isDarkMode ? boardsIconDark : boardsIconLight,
|
||||
keyboardShortcut: {
|
||||
type: "SEQUENCE",
|
||||
strokes: [{ key: "G" }, { key: "B" }],
|
||||
action: () => router.push("/boards"),
|
||||
group: "NAVIGATION",
|
||||
description: t`Go to boards`,
|
||||
},
|
||||
}[] = [
|
||||
{
|
||||
name: t`Boards`,
|
||||
href: "/boards",
|
||||
icon: isDarkMode ? boardsIconDark : boardsIconLight,
|
||||
keyboardShortcut: {
|
||||
type: "SEQUENCE",
|
||||
strokes: [{ key: "G" }, { key: "B" }],
|
||||
action: () => router.push("/boards"),
|
||||
group: "NAVIGATION",
|
||||
description: t`Go to boards`,
|
||||
},
|
||||
{
|
||||
name: t`Templates`,
|
||||
href: "/templates",
|
||||
icon: isDarkMode ? templatesIconDark : templatesIconLight,
|
||||
keyboardShortcut: {
|
||||
type: "SEQUENCE",
|
||||
strokes: [{ key: "G" }, { key: "T" }],
|
||||
action: () => router.push("/templates"),
|
||||
group: "NAVIGATION",
|
||||
description: t`Go to templates`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: t`Templates`,
|
||||
href: "/templates",
|
||||
icon: isDarkMode ? templatesIconDark : templatesIconLight,
|
||||
keyboardShortcut: {
|
||||
type: "SEQUENCE",
|
||||
strokes: [{ key: "G" }, { key: "T" }],
|
||||
action: () => router.push("/templates"),
|
||||
group: "NAVIGATION",
|
||||
description: t`Go to templates`,
|
||||
},
|
||||
{
|
||||
name: t`Members`,
|
||||
href: "/members",
|
||||
icon: isDarkMode ? membersIconDark : membersIconLight,
|
||||
keyboardShortcut: {
|
||||
type: "SEQUENCE",
|
||||
strokes: [{ key: "G" }, { key: "M" }],
|
||||
action: () => router.push("/members"),
|
||||
group: "NAVIGATION",
|
||||
description: t`Go to members`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: t`Members`,
|
||||
href: "/members",
|
||||
icon: isDarkMode ? membersIconDark : membersIconLight,
|
||||
keyboardShortcut: {
|
||||
type: "SEQUENCE",
|
||||
strokes: [{ key: "G" }, { key: "M" }],
|
||||
action: () => router.push("/members"),
|
||||
group: "NAVIGATION",
|
||||
description: t`Go to members`,
|
||||
},
|
||||
{
|
||||
name: t`Settings`,
|
||||
href: "/settings",
|
||||
icon: isDarkMode ? settingsIconDark : settingsIconLight,
|
||||
keyboardShortcut: {
|
||||
type: "SEQUENCE",
|
||||
strokes: [{ key: "G" }, { key: "S" }],
|
||||
action: () => router.push("/settings"),
|
||||
group: "NAVIGATION",
|
||||
description: t`Go to settings`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: t`Settings`,
|
||||
href: "/settings",
|
||||
icon: isDarkMode ? settingsIconDark : settingsIconLight,
|
||||
keyboardShortcut: {
|
||||
type: "SEQUENCE",
|
||||
strokes: [{ key: "G" }, { key: "S" }],
|
||||
action: () => router.push("/settings"),
|
||||
group: "NAVIGATION",
|
||||
description: t`Go to settings`,
|
||||
},
|
||||
],
|
||||
[isDarkMode],
|
||||
);
|
||||
},
|
||||
];
|
||||
|
||||
const toggleCollapse = () => {
|
||||
setIsCollapsed(!isCollapsed);
|
||||
@@ -224,19 +223,21 @@ export default function SideNavigation({
|
||||
<ButtonComponent
|
||||
iconLeft={<HiBolt />}
|
||||
variant="secondary"
|
||||
href={`/upgrade/select-plan?plan=pro&workspacePublicId=${workspace.publicId}&returnUrl=${encodeURIComponent("/settings/billing")}`}
|
||||
aria-label={t`Start free trial`}
|
||||
title={t`Start free trial`}
|
||||
href="/settings/workspace?upgrade=pro"
|
||||
aria-label="Upgrade to Pro"
|
||||
title="Upgrade to Pro"
|
||||
iconOnly
|
||||
onClick={() => openModal("UPGRADE_TO_PRO")}
|
||||
/>
|
||||
) : (
|
||||
<ButtonComponent
|
||||
iconLeft={<HiBolt />}
|
||||
fullWidth
|
||||
variant="secondary"
|
||||
href={`/upgrade/select-plan?plan=pro&workspacePublicId=${workspace.publicId}&returnUrl=${encodeURIComponent("/settings/billing")}`}
|
||||
href="/settings/workspace?upgrade=pro"
|
||||
onClick={() => openModal("UPGRADE_TO_PRO")}
|
||||
>
|
||||
{t`Start free trial`}
|
||||
{t`Upgrade to Pro`}
|
||||
</ButtonComponent>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { Root } from "react-dom/client";
|
||||
import type { Placement, Instance as TippyInstance } from "tippy.js";
|
||||
import type { Placement } from "tippy.js";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import tippy from "tippy.js";
|
||||
@@ -20,16 +20,16 @@ export function Tooltip({
|
||||
}: TooltipProps) {
|
||||
const triggerRef = useRef<HTMLDivElement>(null);
|
||||
const rootRef = useRef<Root | null>(null);
|
||||
const tippyRef = useRef<TippyInstance | null>(null);
|
||||
const contentRef = useRef(content);
|
||||
contentRef.current = content;
|
||||
|
||||
useEffect(() => {
|
||||
if (!triggerRef.current) return;
|
||||
|
||||
if (!content) return;
|
||||
|
||||
const container = document.createElement("div");
|
||||
const root = createRoot(container);
|
||||
rootRef.current = root;
|
||||
root.render(content);
|
||||
|
||||
const instance = tippy(triggerRef.current, {
|
||||
content: container,
|
||||
@@ -39,33 +39,12 @@ export function Tooltip({
|
||||
theme: "tooltip",
|
||||
touch: false,
|
||||
});
|
||||
tippyRef.current = instance;
|
||||
|
||||
if (contentRef.current) {
|
||||
root.render(contentRef.current);
|
||||
} else {
|
||||
instance.disable();
|
||||
}
|
||||
|
||||
return () => {
|
||||
instance.destroy();
|
||||
tippyRef.current = null;
|
||||
root.unmount();
|
||||
rootRef.current = null;
|
||||
rootRef.current?.unmount();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [placement, delay]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!content) {
|
||||
tippyRef.current?.disable();
|
||||
return;
|
||||
}
|
||||
if (tippyRef.current) {
|
||||
tippyRef.current.enable();
|
||||
rootRef.current?.render(content);
|
||||
}
|
||||
}, [content]);
|
||||
}, [content, placement, delay]);
|
||||
|
||||
return (
|
||||
<div ref={triggerRef} className="inline-flex">
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button, Menu, Transition } from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { env } from "next-runtime-env";
|
||||
import { Fragment, useMemo, useState } from "react";
|
||||
import { Fragment, useState } from "react";
|
||||
import { HiCheck, HiMagnifyingGlass } from "react-icons/hi2";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
import CommandPallette from "./CommandPallette";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
|
||||
@@ -21,27 +20,20 @@ export default function WorkspaceMenu({
|
||||
const { workspace, isLoading, availableWorkspaces, switchWorkspace } =
|
||||
useWorkspace();
|
||||
const { openModal } = useModal();
|
||||
const { data: hasPartnerSlot } =
|
||||
api.workspace.hasAvailablePartnerSlot.useQuery();
|
||||
const router = useRouter();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const commandPaletteShortcut = useMemo(
|
||||
() => ({
|
||||
type: "PRESS" as const,
|
||||
const { tooltipContent: commandPaletteShortcutTooltipContent } =
|
||||
useKeyboardShortcut({
|
||||
type: "PRESS",
|
||||
stroke: {
|
||||
key: "k",
|
||||
modifiers: ["META"] as ("META" | "CONTROL" | "ALT" | "SHIFT")[],
|
||||
modifiers: ["META"],
|
||||
},
|
||||
action: () => setIsOpen(true),
|
||||
description: t`Open command menu`,
|
||||
group: "GENERAL" as const,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const { tooltipContent: commandPaletteShortcutTooltipContent } =
|
||||
useKeyboardShortcut(commandPaletteShortcut);
|
||||
group: "GENERAL",
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -158,19 +150,11 @@ export default function WorkspaceMenu({
|
||||
<div className="border-t-[1px] border-light-600 p-1 dark:border-dark-500">
|
||||
<Menu.Item>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (env("NEXT_PUBLIC_KAN_ENV") !== "cloud") {
|
||||
openModal("NEW_WORKSPACE");
|
||||
} else if (hasPartnerSlot) {
|
||||
router.push(
|
||||
`/onboarding/workspace?partner=1&returnUrl=${encodeURIComponent(window.location.pathname)}`,
|
||||
);
|
||||
} else {
|
||||
router.push(
|
||||
`/onboarding/select-plan?returnUrl=${encodeURIComponent(window.location.pathname)}`,
|
||||
);
|
||||
}
|
||||
}}
|
||||
onClick={() =>
|
||||
env("NEXT_PUBLIC_KAN_ENV") === "cloud"
|
||||
? router.push(`/onboarding/select-plan?returnUrl=${encodeURIComponent(window.location.pathname)}`)
|
||||
: openModal("NEW_WORKSPACE")
|
||||
}
|
||||
className="flex w-full items-center justify-between rounded-[5px] px-3 py-2 text-left text-xs text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
|
||||
>
|
||||
{t`Create workspace`}
|
||||
|
||||
@@ -52,9 +52,8 @@ export const env = createEnv({
|
||||
VK_CLIENT_SECRET: z.string().optional(),
|
||||
LINKEDIN_CLIENT_ID: z.string().optional(),
|
||||
LINKEDIN_CLIENT_SECRET: z.string().optional(),
|
||||
SUBSCRIBER_API_URL: z.string().url().optional(),
|
||||
SUBSCRIBER_API_KEY: z.string().optional(),
|
||||
SUBSCRIBER_ENVIRONMENT_ID: z.string().optional(),
|
||||
NOVU_API_KEY: z.string().optional(),
|
||||
EMAIL_UNSUBSCRIBE_SECRET: z.string().optional(),
|
||||
// Generic OIDC Provider
|
||||
OIDC_CLIENT_ID: z.string().optional(),
|
||||
OIDC_CLIENT_SECRET: z.string().optional(),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Locale as DateFnsLocale } from "date-fns";
|
||||
import { useLingui } from "@lingui/react";
|
||||
import { de, enGB, es, fr, it, nl, pl, ptBR, ru } from "date-fns/locale";
|
||||
import { de, enGB, es, fr, it, nl, pl, ru, ptBR} from "date-fns/locale";
|
||||
|
||||
import type { Locale } from "~/locales";
|
||||
import { useLinguiContext } from "~/providers/lingui";
|
||||
@@ -19,7 +19,7 @@ export function useLocalisation() {
|
||||
nl,
|
||||
ru,
|
||||
pl,
|
||||
ptbr: ptBR,
|
||||
"pt-BR": ptBR,
|
||||
};
|
||||
|
||||
const currentDateLocale = dateLocaleMap[locale] ?? enGB;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useModal } from "~/providers/modal";
|
||||
|
||||
interface UseModalFormStateOptions<T> {
|
||||
@@ -13,43 +12,25 @@ export function useModalFormState<T extends Record<string, any>>({
|
||||
initialValues,
|
||||
resetOnClose = false,
|
||||
}: UseModalFormStateOptions<T>) {
|
||||
const {
|
||||
modalContentType,
|
||||
isOpen,
|
||||
getModalState,
|
||||
setModalState,
|
||||
clearModalState,
|
||||
} = useModal();
|
||||
|
||||
const { modalContentType, isOpen, getModalState, setModalState, clearModalState } = useModal();
|
||||
|
||||
const isCurrentModal = modalContentType === modalType;
|
||||
const savedState = getModalState(modalType) as T | undefined;
|
||||
|
||||
|
||||
// get current form state (using the saved values if available, otherwise the initial values)
|
||||
const formState = savedState || initialValues;
|
||||
|
||||
// Keep refs so the callbacks below stay stable across re-renders.
|
||||
const modalTypeRef = useRef(modalType);
|
||||
const initialValuesRef = useRef(initialValues);
|
||||
const getModalStateRef = useRef(getModalState);
|
||||
modalTypeRef.current = modalType;
|
||||
initialValuesRef.current = initialValues;
|
||||
getModalStateRef.current = getModalState;
|
||||
const saveFormState = (state: Partial<T>) => {
|
||||
if (!isCurrentModal) return;
|
||||
|
||||
const currentState = getModalState(modalType) || initialValues;
|
||||
const newState = { ...currentState, ...state };
|
||||
setModalState(modalType, newState);
|
||||
};
|
||||
|
||||
const saveFormState = useCallback(
|
||||
(state: Partial<T>) => {
|
||||
const type = modalTypeRef.current;
|
||||
const currentState =
|
||||
getModalStateRef.current(type) ?? initialValuesRef.current;
|
||||
const newState = { ...currentState, ...state };
|
||||
setModalState(type, newState);
|
||||
},
|
||||
// setModalState is stable (useCallback with [] deps in ModalProvider)
|
||||
[setModalState],
|
||||
);
|
||||
|
||||
const clearFormState = useCallback(() => {
|
||||
clearModalState(modalTypeRef.current);
|
||||
}, [clearModalState]);
|
||||
const clearFormState = () => {
|
||||
clearModalState(modalType);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (resetOnClose && !isOpen && savedState) {
|
||||
@@ -64,4 +45,4 @@ export function useModalFormState<T extends Record<string, any>>({
|
||||
isCurrentModal,
|
||||
hasSavedState: !!savedState,
|
||||
};
|
||||
}
|
||||
}
|
||||
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
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
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -1,20 +0,0 @@
|
||||
export type WorkspacePlan = "free" | "team" | "pro" | "enterprise";
|
||||
|
||||
export interface TierConfig {
|
||||
plan: WorkspacePlan;
|
||||
seats: number | null;
|
||||
unlimitedSeats: boolean;
|
||||
workspaceSlots: number;
|
||||
}
|
||||
|
||||
const TIER_MAP: Record<number, TierConfig> = {
|
||||
1: { plan: "team", seats: 5, unlimitedSeats: false, workspaceSlots: 1 },
|
||||
2: { plan: "pro", seats: 15, unlimitedSeats: false, workspaceSlots: 2 },
|
||||
3: { plan: "pro", seats: null, unlimitedSeats: true, workspaceSlots: 4 },
|
||||
};
|
||||
|
||||
export function tierConfig(tier: number): TierConfig {
|
||||
const config = TIER_MAP[tier];
|
||||
if (!config) throw new Error(`Unknown partner tier: ${tier}`);
|
||||
return config;
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
||||
import { createLogger } from "@kan/logger";
|
||||
|
||||
import { tierConfig } from "./_utils";
|
||||
|
||||
const log = createLogger("api");
|
||||
|
||||
interface TokenResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
}
|
||||
|
||||
interface OAuthLicenseResponse {
|
||||
license_key: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface LicenseDetailResponse {
|
||||
license_key: string;
|
||||
status: string;
|
||||
tier: number;
|
||||
}
|
||||
|
||||
async function exchangeCodeForToken(code: string): Promise<TokenResponse> {
|
||||
const tokenUrl = process.env.PARTNER_TOKEN_URL;
|
||||
const clientId = process.env.PARTNER_CLIENT_ID;
|
||||
const clientSecret = process.env.PARTNER_CLIENT_SECRET;
|
||||
const redirectUrl = process.env.PARTNER_REDIRECT_URL;
|
||||
if (!tokenUrl) throw new Error("PARTNER_TOKEN_URL not configured");
|
||||
if (!clientId) throw new Error("PARTNER_CLIENT_ID not configured");
|
||||
if (!clientSecret) throw new Error("PARTNER_CLIENT_SECRET not configured");
|
||||
if (!redirectUrl) throw new Error("PARTNER_REDIRECT_URL not configured");
|
||||
|
||||
const res = await fetch(tokenUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
redirect_uri: redirectUrl,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`Token exchange failed: ${res.status}`);
|
||||
return res.json() as Promise<TokenResponse>;
|
||||
}
|
||||
|
||||
async function fetchOAuthLicense(
|
||||
accessToken: string,
|
||||
): Promise<OAuthLicenseResponse> {
|
||||
const oauthLicenseUrl = process.env.PARTNER_OAUTH_LICENSE_URL;
|
||||
if (!oauthLicenseUrl)
|
||||
throw new Error("PARTNER_OAUTH_LICENSE_URL not configured");
|
||||
|
||||
const res = await fetch(`${oauthLicenseUrl}?access_token=${accessToken}`);
|
||||
if (!res.ok) throw new Error(`OAuth license fetch failed: ${res.status}`);
|
||||
return res.json() as Promise<OAuthLicenseResponse>;
|
||||
}
|
||||
|
||||
async function fetchLicenseDetail(
|
||||
licenseKey: string,
|
||||
): Promise<LicenseDetailResponse> {
|
||||
const licenseApiUrl = process.env.PARTNER_LICENSE_API_URL;
|
||||
const apiKey = process.env.PARTNER_API_KEY;
|
||||
if (!licenseApiUrl) throw new Error("PARTNER_LICENSE_API_URL not configured");
|
||||
if (!apiKey) throw new Error("PARTNER_API_KEY not configured");
|
||||
|
||||
const apiKeyHeader = process.env.PARTNER_API_KEY_HEADER;
|
||||
if (!apiKeyHeader) throw new Error("PARTNER_API_KEY_HEADER not configured");
|
||||
|
||||
const res = await fetch(`${licenseApiUrl}/${licenseKey}`, {
|
||||
headers: { [apiKeyHeader]: apiKey },
|
||||
});
|
||||
if (!res.ok) throw new Error(`License detail fetch failed: ${res.status}`);
|
||||
return res.json() as Promise<LicenseDetailResponse>;
|
||||
}
|
||||
|
||||
export default withRateLimit(
|
||||
{ points: 20, duration: 60 },
|
||||
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method !== "GET") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
|
||||
const { code } = req.query;
|
||||
|
||||
if (!code || typeof code !== "string") {
|
||||
return res.status(200).json({ message: "OK" });
|
||||
}
|
||||
|
||||
let license: LicenseDetailResponse;
|
||||
|
||||
try {
|
||||
const tokenData = await exchangeCodeForToken(code);
|
||||
const oauthLicense = await fetchOAuthLicense(tokenData.access_token);
|
||||
license = await fetchLicenseDetail(oauthLicense.license_key);
|
||||
} catch (err) {
|
||||
log.error({ err }, "Partner OAuth flow failed");
|
||||
return res.redirect(`/partner/activate?error=oauth_failed`);
|
||||
}
|
||||
|
||||
const { db, user } = await createNextApiContext(req);
|
||||
|
||||
const cfg = tierConfig(license.tier);
|
||||
const status = license.status === "active" ? "active" : "inactive";
|
||||
|
||||
// Ensure subscription slots exist — webhook may have already created them
|
||||
const existing = await subscriptionRepo.getAllByPartnerLicenseKey(
|
||||
db,
|
||||
license.license_key,
|
||||
);
|
||||
if (existing.length === 0) {
|
||||
await subscriptionRepo.createPartnerLicenseSlots(
|
||||
db,
|
||||
license.license_key,
|
||||
{
|
||||
plan: cfg.plan,
|
||||
status,
|
||||
partnerTier: license.tier,
|
||||
seats: cfg.seats,
|
||||
unlimitedSeats: cfg.unlimitedSeats,
|
||||
},
|
||||
cfg.workspaceSlots,
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return res.redirect(
|
||||
`/partner/activate?license_key=${encodeURIComponent(license.license_key)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return res.redirect(
|
||||
`/api/partner/link?license_key=${encodeURIComponent(license.license_key)}`,
|
||||
);
|
||||
}),
|
||||
);
|
||||
@@ -1,98 +0,0 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
|
||||
export default withRateLimit(
|
||||
{ points: 20, duration: 60 },
|
||||
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method !== "GET") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
|
||||
const { license_key } = req.query;
|
||||
|
||||
if (!license_key || typeof license_key !== "string") {
|
||||
return res.redirect("/boards?partner_error=missing_license");
|
||||
}
|
||||
|
||||
const { db, user } = await createNextApiContext(req);
|
||||
|
||||
if (!user) {
|
||||
return res.redirect(
|
||||
`/login?next=${encodeURIComponent(`/api/partner/link?license_key=${license_key}`)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const allSlots = await subscriptionRepo.getAllByPartnerLicenseKey(
|
||||
db,
|
||||
license_key,
|
||||
);
|
||||
|
||||
if (!allSlots.length) {
|
||||
return res.redirect("/boards?partner_error=invalid_license");
|
||||
}
|
||||
|
||||
const activeSlots = allSlots.filter((s) =>
|
||||
["active", "trialing"].includes(s.status),
|
||||
);
|
||||
|
||||
if (!activeSlots.length) {
|
||||
return res.redirect("/boards?partner_error=license_inactive");
|
||||
}
|
||||
|
||||
const unlinkedSlot = activeSlots.find((s) => !s.referenceId);
|
||||
|
||||
if (!unlinkedSlot) {
|
||||
return res.redirect("/boards?partner_activated=1");
|
||||
}
|
||||
|
||||
const linkedIds = new Set(
|
||||
activeSlots.filter((s) => s.referenceId).map((s) => s.referenceId!),
|
||||
);
|
||||
|
||||
const memberships = await workspaceRepo.getAllByUserId(db, user.id);
|
||||
const availableWorkspace = memberships
|
||||
.map((m) => m.workspace)
|
||||
.find((w) => w && !w.deletedAt && !linkedIds.has(w.publicId));
|
||||
|
||||
if (!availableWorkspace) {
|
||||
return res.redirect(
|
||||
`/onboarding/workspace?license_key=${encodeURIComponent(license_key)}`,
|
||||
);
|
||||
}
|
||||
|
||||
await subscriptionRepo.updateById(db, unlinkedSlot.id, {
|
||||
referenceId: availableWorkspace.publicId,
|
||||
});
|
||||
|
||||
await workspaceRepo.update(db, availableWorkspace.publicId, {
|
||||
plan: unlinkedSlot.plan as "free" | "team" | "pro" | "enterprise",
|
||||
});
|
||||
|
||||
const remainingUnlinked = activeSlots.filter(
|
||||
(s) => !s.referenceId && s.id !== unlinkedSlot.id,
|
||||
);
|
||||
|
||||
if (remainingUnlinked.length > 0) {
|
||||
const updatedLinkedIds = new Set([
|
||||
...linkedIds,
|
||||
availableWorkspace.publicId,
|
||||
]);
|
||||
const hasMoreAvailableWorkspace = memberships
|
||||
.map((m) => m.workspace)
|
||||
.some((w) => w && !w.deletedAt && !updatedLinkedIds.has(w.publicId));
|
||||
|
||||
if (hasMoreAvailableWorkspace) {
|
||||
return res.redirect(
|
||||
`/api/partner/link?license_key=${encodeURIComponent(license_key)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return res.redirect("/boards?partner_activated=1");
|
||||
}),
|
||||
);
|
||||
@@ -1,418 +0,0 @@
|
||||
import { createHmac, timingSafeEqual } from "crypto";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import type { Readable } from "node:stream";
|
||||
|
||||
import type { dbClient } from "@kan/db/client";
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||
import { cancelWorkspaceAccess } from "@kan/api/utils/workspace";
|
||||
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import { createLogger } from "@kan/logger";
|
||||
import { getActiveSubscriptions } from "@kan/shared/utils";
|
||||
|
||||
import type { TierConfig } from "./_utils";
|
||||
import { tierConfig } from "./_utils";
|
||||
|
||||
const log = createLogger("api");
|
||||
|
||||
async function createAndLinkSlots(
|
||||
db: dbClient,
|
||||
licenseKey: string,
|
||||
cfg: TierConfig,
|
||||
tier: number,
|
||||
alreadyLinkedReferenceIds: Set<string>,
|
||||
count: number,
|
||||
) {
|
||||
const linkedId = [...alreadyLinkedReferenceIds][0];
|
||||
let autoLinked = 0;
|
||||
|
||||
if (linkedId) {
|
||||
const linkedWorkspace = await workspaceRepo.getByPublicId(db, linkedId);
|
||||
|
||||
if (linkedWorkspace?.createdBy) {
|
||||
const owned = await workspaceRepo.getAllOwnedByUserId(
|
||||
db,
|
||||
linkedWorkspace.createdBy,
|
||||
);
|
||||
|
||||
const candidates = owned.filter(
|
||||
(w) => !alreadyLinkedReferenceIds.has(w.publicId),
|
||||
);
|
||||
|
||||
if (candidates.length > 0) {
|
||||
const existingSubs =
|
||||
await subscriptionRepo.getAllActivePartnerSubsByWorkspaceIds(
|
||||
db,
|
||||
candidates.map((w) => w.publicId),
|
||||
);
|
||||
const alreadySubscribed = new Set(
|
||||
existingSubs
|
||||
.map((s) => s.referenceId)
|
||||
.filter((id): id is string => !!id),
|
||||
);
|
||||
|
||||
const available = candidates
|
||||
.filter((w) => !alreadySubscribed.has(w.publicId))
|
||||
.slice(0, count);
|
||||
|
||||
if (available.length > 0) {
|
||||
const newSlots = await subscriptionRepo.createPartnerLicenseSlots(
|
||||
db,
|
||||
licenseKey,
|
||||
{
|
||||
plan: cfg.plan,
|
||||
status: "active",
|
||||
partnerTier: tier,
|
||||
seats: cfg.seats,
|
||||
unlimitedSeats: cfg.unlimitedSeats,
|
||||
},
|
||||
available.length,
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
available.map((workspace, i) => {
|
||||
const slot = newSlots[i];
|
||||
if (!slot) return;
|
||||
return Promise.all([
|
||||
subscriptionRepo.updateById(db, slot.id, {
|
||||
referenceId: workspace.publicId,
|
||||
}),
|
||||
workspaceRepo.update(db, workspace.publicId, {
|
||||
plan: cfg.plan,
|
||||
}),
|
||||
]);
|
||||
}),
|
||||
);
|
||||
|
||||
autoLinked = available.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const remaining = count - autoLinked;
|
||||
if (remaining > 0) {
|
||||
await subscriptionRepo.createPartnerLicenseSlots(
|
||||
db,
|
||||
licenseKey,
|
||||
{
|
||||
plan: cfg.plan,
|
||||
status: "active",
|
||||
partnerTier: tier,
|
||||
seats: cfg.seats,
|
||||
unlimitedSeats: cfg.unlimitedSeats,
|
||||
},
|
||||
remaining,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function buffer(readable: Readable) {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of readable) {
|
||||
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
function verifySignature(
|
||||
rawBody: string,
|
||||
signature: string,
|
||||
timestamp: string,
|
||||
): boolean {
|
||||
const secret = process.env.PARTNER_API_KEY;
|
||||
if (!secret) return false;
|
||||
|
||||
const ts = Number(timestamp);
|
||||
if (isNaN(ts) || Date.now() - ts > 300_000) return false;
|
||||
|
||||
const payload = `${timestamp}${rawBody}`;
|
||||
const expected = createHmac("sha256", secret).update(payload).digest("hex");
|
||||
|
||||
try {
|
||||
return timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function hasReferenceId<T extends { referenceId: string | null | undefined }>(
|
||||
s: T,
|
||||
): s is T & { referenceId: string } {
|
||||
return !!s.referenceId;
|
||||
}
|
||||
|
||||
interface WebhookPayload {
|
||||
event:
|
||||
| "purchase"
|
||||
| "activate"
|
||||
| "deactivate"
|
||||
| "upgrade"
|
||||
| "downgrade"
|
||||
| "migrate";
|
||||
license_key: string;
|
||||
license_status: string;
|
||||
tier: number;
|
||||
prev_license_key?: string;
|
||||
}
|
||||
|
||||
export default withApiLogging(
|
||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
|
||||
const signatureHeader = process.env.PARTNER_SIGNATURE_HEADER;
|
||||
const timestampHeader = process.env.PARTNER_TIMESTAMP_HEADER;
|
||||
|
||||
if (!signatureHeader || !timestampHeader) {
|
||||
log.error("Webhook signature headers not configured");
|
||||
return res.status(500).json({ message: "Server misconfiguration" });
|
||||
}
|
||||
|
||||
const signature = req.headers[signatureHeader] as string | undefined;
|
||||
const timestamp = req.headers[timestampHeader] as string | undefined;
|
||||
|
||||
if (!signature || !timestamp) {
|
||||
return res.status(400).json({ message: "Missing signature headers" });
|
||||
}
|
||||
|
||||
const buf = await buffer(req as unknown as Readable);
|
||||
const rawBody = buf.toString("utf8");
|
||||
|
||||
if (!verifySignature(rawBody, signature, timestamp)) {
|
||||
return res.status(401).json({ message: "Invalid signature" });
|
||||
}
|
||||
|
||||
const payload = JSON.parse(rawBody) as WebhookPayload;
|
||||
const { event, license_key, license_status, tier, prev_license_key } =
|
||||
payload;
|
||||
|
||||
log.info({ headers: req.headers, payload }, "partner webhook received");
|
||||
|
||||
const { db } = await createNextApiContext(req);
|
||||
|
||||
switch (event) {
|
||||
case "purchase":
|
||||
case "activate": {
|
||||
const cfg = tierConfig(tier);
|
||||
const existing = await subscriptionRepo.getAllByPartnerLicenseKey(
|
||||
db,
|
||||
license_key,
|
||||
);
|
||||
const status = event === "activate" ? "active" : license_status;
|
||||
|
||||
if (existing.length === 0) {
|
||||
await subscriptionRepo.createPartnerLicenseSlots(
|
||||
db,
|
||||
license_key,
|
||||
{
|
||||
plan: cfg.plan,
|
||||
status,
|
||||
partnerTier: tier,
|
||||
seats: cfg.seats,
|
||||
unlimitedSeats: cfg.unlimitedSeats,
|
||||
},
|
||||
cfg.workspaceSlots,
|
||||
);
|
||||
} else {
|
||||
await subscriptionRepo.updateAllByPartnerLicenseKey(db, license_key, {
|
||||
plan: cfg.plan,
|
||||
status,
|
||||
partnerTier: tier,
|
||||
seats: cfg.seats,
|
||||
unlimitedSeats: cfg.unlimitedSeats,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "deactivate": {
|
||||
const allSlots = await subscriptionRepo.getAllByPartnerLicenseKey(
|
||||
db,
|
||||
license_key,
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
allSlots.filter(hasReferenceId).map(async (slot) => {
|
||||
const siblingSubs = await subscriptionRepo.getByReferenceId(
|
||||
db,
|
||||
slot.referenceId,
|
||||
);
|
||||
const hasOtherActiveSub = getActiveSubscriptions(siblingSubs).some(
|
||||
(s) => s.id !== slot.id,
|
||||
);
|
||||
if (!hasOtherActiveSub) {
|
||||
await cancelWorkspaceAccess(db, slot.referenceId);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
await subscriptionRepo.updateAllByPartnerLicenseKey(db, license_key, {
|
||||
plan: "free",
|
||||
status: "canceled",
|
||||
unlimitedSeats: false,
|
||||
seats: null,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "upgrade":
|
||||
case "downgrade": {
|
||||
const lookupKey = prev_license_key ?? license_key;
|
||||
const existing = await subscriptionRepo.getAllByPartnerLicenseKey(
|
||||
db,
|
||||
lookupKey,
|
||||
);
|
||||
|
||||
if (existing.length === 0) break;
|
||||
|
||||
const cfg = tierConfig(tier);
|
||||
const newCount = cfg.workspaceSlots;
|
||||
|
||||
// Prefer keeping linked slots; among linked, keep in insertion order (LIFO removal)
|
||||
const preferKeep = [
|
||||
...existing.filter((s) => s.referenceId),
|
||||
...existing.filter((s) => !s.referenceId),
|
||||
];
|
||||
|
||||
const slotsToKeep = preferKeep.slice(0, newCount);
|
||||
const slotsToRemove = preferKeep.slice(newCount);
|
||||
|
||||
await Promise.all([
|
||||
...slotsToKeep.map((slot) =>
|
||||
subscriptionRepo.updateById(db, slot.id, {
|
||||
plan: cfg.plan,
|
||||
seats: cfg.seats,
|
||||
unlimitedSeats: cfg.unlimitedSeats,
|
||||
partnerTier: tier,
|
||||
status: "active",
|
||||
...(prev_license_key ? { partnerLicenseKey: license_key } : {}),
|
||||
}),
|
||||
),
|
||||
...slotsToKeep
|
||||
.filter(hasReferenceId)
|
||||
.map((s) =>
|
||||
workspaceRepo.update(db, s.referenceId, { plan: cfg.plan }),
|
||||
),
|
||||
]);
|
||||
|
||||
if (newCount > existing.length) {
|
||||
const linkedIds = new Set(
|
||||
slotsToKeep.filter(hasReferenceId).map((s) => s.referenceId),
|
||||
);
|
||||
await createAndLinkSlots(
|
||||
db,
|
||||
license_key,
|
||||
cfg,
|
||||
tier,
|
||||
linkedIds,
|
||||
newCount - existing.length,
|
||||
);
|
||||
}
|
||||
|
||||
if (slotsToRemove.length > 0) {
|
||||
await Promise.all([
|
||||
...slotsToRemove
|
||||
.filter(hasReferenceId)
|
||||
.map((s) => cancelWorkspaceAccess(db, s.referenceId)),
|
||||
...slotsToRemove.map((s) =>
|
||||
subscriptionRepo.updateById(db, s.id, {
|
||||
plan: "free",
|
||||
status: "inactive",
|
||||
unlimitedSeats: false,
|
||||
seats: null,
|
||||
}),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "migrate": {
|
||||
if (!prev_license_key) break;
|
||||
|
||||
const existing = await subscriptionRepo.getAllByPartnerLicenseKey(
|
||||
db,
|
||||
prev_license_key,
|
||||
);
|
||||
if (existing.length === 0) break;
|
||||
|
||||
const cfg = tierConfig(tier);
|
||||
const newCount = cfg.workspaceSlots;
|
||||
|
||||
const preferKeep = [
|
||||
...existing.filter((s) => s.referenceId),
|
||||
...existing.filter((s) => !s.referenceId),
|
||||
];
|
||||
|
||||
const slotsToKeep = preferKeep.slice(0, newCount);
|
||||
const slotsToRemove = preferKeep.slice(newCount);
|
||||
|
||||
await Promise.all([
|
||||
...slotsToKeep.map((slot) =>
|
||||
subscriptionRepo.updateById(db, slot.id, {
|
||||
partnerLicenseKey: license_key,
|
||||
plan: cfg.plan,
|
||||
status: "active",
|
||||
partnerTier: tier,
|
||||
seats: cfg.seats,
|
||||
unlimitedSeats: cfg.unlimitedSeats,
|
||||
}),
|
||||
),
|
||||
...slotsToKeep
|
||||
.filter(hasReferenceId)
|
||||
.map((s) =>
|
||||
workspaceRepo.update(db, s.referenceId, { plan: cfg.plan }),
|
||||
),
|
||||
]);
|
||||
|
||||
if (newCount > existing.length) {
|
||||
const linkedIds = new Set(
|
||||
slotsToKeep.filter(hasReferenceId).map((s) => s.referenceId),
|
||||
);
|
||||
await createAndLinkSlots(
|
||||
db,
|
||||
license_key,
|
||||
cfg,
|
||||
tier,
|
||||
linkedIds,
|
||||
newCount - existing.length,
|
||||
);
|
||||
}
|
||||
|
||||
if (slotsToRemove.length > 0) {
|
||||
await Promise.all([
|
||||
...slotsToRemove
|
||||
.filter(hasReferenceId)
|
||||
.map((s) => cancelWorkspaceAccess(db, s.referenceId)),
|
||||
...slotsToRemove.map((s) =>
|
||||
subscriptionRepo.updateById(db, s.id, {
|
||||
plan: "free",
|
||||
status: "inactive",
|
||||
unlimitedSeats: false,
|
||||
seats: null,
|
||||
}),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
log.warn({ event }, "Unhandled partner webhook event");
|
||||
}
|
||||
|
||||
return res.status(200).json({ success: true, event });
|
||||
},
|
||||
);
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
@@ -64,10 +64,7 @@ export default async function handler(
|
||||
meta.userId &&
|
||||
meta.userEmail
|
||||
) {
|
||||
const existing = await workspaceRepo.getByPublicId(
|
||||
db,
|
||||
meta.workspacePublicId,
|
||||
);
|
||||
const existing = await workspaceRepo.getByPublicId(db, meta.workspacePublicId);
|
||||
|
||||
if (!existing) {
|
||||
const slug = meta.workspaceSlug ?? meta.workspacePublicId;
|
||||
@@ -93,10 +90,10 @@ export default async function handler(
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Existing workspace upgrade — update plan (and slug for pro)
|
||||
await workspaceRepo.update(db, meta.workspacePublicId, {
|
||||
plan,
|
||||
...(plan === "pro" &&
|
||||
meta.workspaceSlug && { slug: meta.workspaceSlug }),
|
||||
...(plan === "pro" && meta.workspaceSlug && { slug: meta.workspaceSlug }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
108
apps/web/src/pages/api/unsubscribe.ts
Normal file
108
apps/web/src/pages/api/unsubscribe.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { Novu } from "@novu/api";
|
||||
import { jwtVerify } from "jose";
|
||||
import { z } from "zod";
|
||||
|
||||
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
|
||||
import { env } from "~/env";
|
||||
|
||||
const requestSchema = z.object({
|
||||
token: z.string().min(1),
|
||||
});
|
||||
|
||||
const tokenPayloadSchema = z.object({
|
||||
subscriberId: z.string(),
|
||||
});
|
||||
|
||||
type ResponseData =
|
||||
| { success: true }
|
||||
| { success: false; error: string; code?: string };
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
withApiLogging(
|
||||
async (req: NextApiRequest, res: NextApiResponse<ResponseData>) => {
|
||||
if (process.env.NEXT_PUBLIC_KAN_ENV !== "cloud") {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: "Unsubscribe endpoint is not available.",
|
||||
code: "UNAVAILABLE",
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method !== "POST") {
|
||||
res.setHeader("Allow", "POST");
|
||||
return res.status(405).json({
|
||||
success: false,
|
||||
error: "Method not allowed.",
|
||||
code: "METHOD_NOT_ALLOWED",
|
||||
});
|
||||
}
|
||||
|
||||
const parsedBody = requestSchema.safeParse(req.body);
|
||||
|
||||
if (!parsedBody.success) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "Invalid request payload.",
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
}
|
||||
|
||||
if (!env.EMAIL_UNSUBSCRIBE_SECRET || !env.NOVU_API_KEY) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: "Unsubscribe service is not configured.",
|
||||
code: "NOT_CONFIGURED",
|
||||
});
|
||||
}
|
||||
|
||||
let payload: z.infer<typeof tokenPayloadSchema>;
|
||||
|
||||
try {
|
||||
const verified = await jwtVerify(
|
||||
parsedBody.data.token,
|
||||
textEncoder.encode(env.EMAIL_UNSUBSCRIBE_SECRET),
|
||||
{
|
||||
// We intentionally do not use exp/iat claims –
|
||||
// tokens are long-lived and validated only by signature + payload.
|
||||
clockTolerance: "0s",
|
||||
},
|
||||
);
|
||||
payload = tokenPayloadSchema.parse(verified.payload);
|
||||
} catch {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: "Your unsubscribe link is invalid or has expired.",
|
||||
code: "INVALID_TOKEN",
|
||||
});
|
||||
}
|
||||
|
||||
const novu = new Novu({ secretKey: env.NOVU_API_KEY });
|
||||
|
||||
try {
|
||||
await novu.subscribers.preferences.update(
|
||||
{
|
||||
channels: {
|
||||
email: false,
|
||||
},
|
||||
},
|
||||
payload.subscriberId,
|
||||
);
|
||||
} catch (error) {
|
||||
return res.status(502).json({
|
||||
success: false,
|
||||
error:
|
||||
"We could not update your email preferences right now. Please try again later.",
|
||||
code: "NOVU_ERROR",
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json({ success: true });
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -67,15 +67,8 @@ export default withRateLimit(
|
||||
return res.status(400).json({ error: "File too large" });
|
||||
}
|
||||
|
||||
const rawFilenameHeader =
|
||||
const originalFilenameHeader =
|
||||
(req.headers["x-original-filename"] as string | undefined) ?? "file";
|
||||
const originalFilenameHeader = (() => {
|
||||
try {
|
||||
return decodeURIComponent(rawFilenameHeader);
|
||||
} catch {
|
||||
return rawFilenameHeader;
|
||||
}
|
||||
})();
|
||||
|
||||
const sanitizedFilename = originalFilenameHeader
|
||||
.replace(/[^a-zA-Z0-9._-]/g, "_")
|
||||
|
||||
@@ -64,15 +64,8 @@ export default withRateLimit(
|
||||
return res.status(400).json({ error: "File too large" });
|
||||
}
|
||||
|
||||
const rawFilenameHeader =
|
||||
const originalFilenameHeader =
|
||||
(req.headers["x-original-filename"] as string | undefined) ?? "file";
|
||||
const originalFilenameHeader = (() => {
|
||||
try {
|
||||
return decodeURIComponent(rawFilenameHeader);
|
||||
} catch {
|
||||
return rawFilenameHeader;
|
||||
}
|
||||
})();
|
||||
|
||||
const sanitizedFilename = originalFilenameHeader
|
||||
.replace(/[^a-zA-Z0-9._-]/g, "_")
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import { Auth } from "~/components/AuthForm";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import PatternedBackground from "~/components/PatternedBackground";
|
||||
|
||||
export default function PartnerActivatePage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const licenseKey = searchParams.get("license_key");
|
||||
const error = searchParams.get("error");
|
||||
|
||||
const partnerName = env("NEXT_PUBLIC_PARTNER_NAME");
|
||||
|
||||
const { data: session, isPending } = authClient.useSession();
|
||||
const [isMagicLinkSent, setIsMagicLinkSent] = useState(false);
|
||||
const [magicLinkRecipient, setMagicLinkRecipient] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (licenseKey) {
|
||||
localStorage.setItem("partnerLicenseKey", licenseKey);
|
||||
}
|
||||
}, [licenseKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPending && session?.user && licenseKey) {
|
||||
router.push(
|
||||
`/api/partner/link?license_key=${encodeURIComponent(licenseKey)}`,
|
||||
);
|
||||
} else if (!isPending && session?.user && !licenseKey) {
|
||||
router.push("/boards");
|
||||
}
|
||||
}, [session, isPending, licenseKey, router]);
|
||||
|
||||
if (isPending || (session?.user && licenseKey)) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={t`Activate | kan.bn`} />
|
||||
<main className="h-screen bg-light-100 pt-20 dark:bg-dark-50 sm:pt-0">
|
||||
<div className="justify-top flex h-full flex-col items-center px-4 sm:justify-center">
|
||||
<div className="z-10 flex w-full flex-col items-center">
|
||||
<Link href="/">
|
||||
<h1 className="mb-6 text-lg font-bold tracking-tight text-light-1000 dark:text-dark-1000">
|
||||
kan.bn
|
||||
</h1>
|
||||
</Link>
|
||||
<p className="mb-2 text-3xl font-bold tracking-tight text-light-1000 dark:text-dark-1000">
|
||||
{isMagicLinkSent ? t`Check your inbox` : t`Activate your account`}
|
||||
</p>
|
||||
<p className="mb-10 text-sm text-light-800 dark:text-dark-800">
|
||||
{isMagicLinkSent ? (
|
||||
<Trans>We sent a link to {magicLinkRecipient}</Trans>
|
||||
) : partnerName ? (
|
||||
<Trans>
|
||||
Sign in or create an account to activate your {partnerName}{" "}
|
||||
license
|
||||
</Trans>
|
||||
) : (
|
||||
t`Sign in or create an account to activate your license`
|
||||
)}
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 w-full rounded-md bg-red-50 px-4 py-3 text-sm text-red-700 dark:bg-red-900/20 dark:text-red-400 sm:max-w-md">
|
||||
{t`Something went wrong during activation. Please try again.`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isMagicLinkSent && (
|
||||
<div className="w-full rounded-lg border border-light-500 bg-light-300 px-4 py-10 dark:border-dark-400 dark:bg-dark-200 sm:max-w-md lg:px-10">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
|
||||
<Auth
|
||||
setIsMagicLinkSent={(val, recipient) => {
|
||||
setIsMagicLinkSent(val);
|
||||
setMagicLinkRecipient(recipient);
|
||||
}}
|
||||
callbackURL={
|
||||
licenseKey
|
||||
? `/api/partner/link?license_key=${encodeURIComponent(licenseKey)}`
|
||||
: "/boards"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-4 text-sm text-light-1000 dark:text-dark-1000">
|
||||
<Trans>
|
||||
Already have an account?{" "}
|
||||
<span className="underline">
|
||||
<Link
|
||||
href={
|
||||
licenseKey
|
||||
? `/login?next=${encodeURIComponent(`/api/partner/link?license_key=${licenseKey}`)}`
|
||||
: "/login"
|
||||
}
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</span>
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<PatternedBackground />
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
110
apps/web/src/pages/unsubscribe/index.tsx
Normal file
110
apps/web/src/pages/unsubscribe/index.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import PatternedBackground from "~/components/PatternedBackground";
|
||||
|
||||
type UnsubscribeStatus = "idle" | "processing" | "success" | "error";
|
||||
|
||||
export default function UnsubscribePage() {
|
||||
const router = useRouter();
|
||||
const [token, setToken] = useState("");
|
||||
const [status, setStatus] = useState<UnsubscribeStatus>("idle");
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!router.isReady) return;
|
||||
const value = router.query.token;
|
||||
if (typeof value === "string") {
|
||||
setToken(value);
|
||||
} else if (Array.isArray(value)) {
|
||||
setToken(value[0] ?? "");
|
||||
} else {
|
||||
setToken("");
|
||||
}
|
||||
}, [router.isReady, router.query.token]);
|
||||
|
||||
const handleUnsubscribe = async () => {
|
||||
if (!token) {
|
||||
setStatus("error");
|
||||
setErrorMessage(
|
||||
t`Your unsubscribe link is missing a token. Please open the latest email and try again.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("processing");
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/unsubscribe", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = (await response.json().catch(() => null)) as {
|
||||
error?: string;
|
||||
} | null;
|
||||
|
||||
throw new Error(
|
||||
payload?.error ??
|
||||
"We couldn't update your preferences. Please try again.",
|
||||
);
|
||||
}
|
||||
|
||||
setStatus("success");
|
||||
} catch (error) {
|
||||
setStatus("error");
|
||||
setErrorMessage(
|
||||
t`We couldn't update your preferences. Please try again.`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const title = t`Unsubscribe`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={`${title} | kan.bn`} />
|
||||
<div className="relative flex min-h-screen items-center justify-center px-4 py-12 sm:px-6 lg:px-8">
|
||||
<PatternedBackground />
|
||||
<div className="z-10 w-full max-w-md space-y-6">
|
||||
<div>
|
||||
<h1 className="mt-6 text-center text-3xl font-bold tracking-tight text-light-1000 dark:text-dark-1000">
|
||||
{t`Do you want to unsubscribe?`}
|
||||
</h1>
|
||||
<p className="mt-4 text-center text-sm text-light-900 dark:text-dark-800">
|
||||
{t`Confirm your email preferences:`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
onClick={handleUnsubscribe}
|
||||
disabled={status === "success"}
|
||||
isLoading={status === "processing"}
|
||||
variant="primary"
|
||||
size="md"
|
||||
>
|
||||
{t`Unsubscribe`}
|
||||
</Button>
|
||||
</div>
|
||||
{status === "success" && (
|
||||
<p className="text-center text-sm text-light-900 dark:text-dark-800">
|
||||
{t`You have been unsubscribed!`}
|
||||
</p>
|
||||
)}
|
||||
{status === "error" && (
|
||||
<p className="mx-auto max-w-[300px] text-center text-sm font-medium text-red-600 dark:text-red-400">
|
||||
{errorMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import SelectPlanView from "~/views/onboarding/select-plan";
|
||||
|
||||
export default function UpgradeSelectPlanPage() {
|
||||
const router = useRouter();
|
||||
const { data: session, isPending } = authClient.useSession();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPending && !session?.user) {
|
||||
router.push("/login");
|
||||
}
|
||||
if (!isPending && env("NEXT_PUBLIC_KAN_ENV") !== "cloud") {
|
||||
router.push("/boards");
|
||||
}
|
||||
}, [session, isPending, router]);
|
||||
|
||||
if (isPending || !session?.user) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title="Upgrade | kan.bn" />
|
||||
<SelectPlanView />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createContext, useCallback, useContext, useState } from "react";
|
||||
import { createContext, useContext, useState } from "react";
|
||||
|
||||
interface PopupContextType {
|
||||
isOpen: boolean;
|
||||
@@ -25,27 +25,24 @@ export const PopupProvider: React.FC<Props> = ({ children }) => {
|
||||
const [popupMessage, setPopupMessage] = useState("");
|
||||
const [popupIcon, setPopupIcon] = useState("");
|
||||
|
||||
const showPopup = useCallback(
|
||||
({
|
||||
header,
|
||||
message,
|
||||
icon,
|
||||
}: {
|
||||
header: string;
|
||||
message: string;
|
||||
icon: string;
|
||||
}) => {
|
||||
setIsOpen(true);
|
||||
setPopupHeader(header);
|
||||
setPopupMessage(message);
|
||||
setPopupIcon(icon);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const showPopup = ({
|
||||
header,
|
||||
message,
|
||||
icon,
|
||||
}: {
|
||||
header: string;
|
||||
message: string;
|
||||
icon: string;
|
||||
}) => {
|
||||
setIsOpen(true);
|
||||
setPopupHeader(header);
|
||||
setPopupMessage(message);
|
||||
setPopupIcon(icon);
|
||||
};
|
||||
|
||||
const hidePopup = useCallback(() => {
|
||||
const hidePopup = () => {
|
||||
setIsOpen(false);
|
||||
}, []);
|
||||
};
|
||||
|
||||
return (
|
||||
<PopupContext.Provider
|
||||
|
||||
@@ -20,7 +20,6 @@ interface Workspace {
|
||||
plan: "free" | "team" | "pro" | "enterprise" | undefined;
|
||||
role: "admin" | "member" | "guest";
|
||||
weekStartDay: 0 | 1 | 6;
|
||||
cardPrefix: string;
|
||||
}
|
||||
|
||||
const initialWorkspace: Workspace = {
|
||||
@@ -31,7 +30,6 @@ const initialWorkspace: Workspace = {
|
||||
plan: "free" as const,
|
||||
role: "member",
|
||||
weekStartDay: 1,
|
||||
cardPrefix: "",
|
||||
};
|
||||
|
||||
const initialAvailableWorkspaces: Workspace[] = [];
|
||||
@@ -58,12 +56,9 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
|
||||
const pollAttemptsRef = React.useRef(0);
|
||||
const MAX_POLL_ATTEMPTS = 5;
|
||||
|
||||
const { data, isLoading, isFetching } = api.workspace.all.useQuery(
|
||||
undefined,
|
||||
{
|
||||
refetchInterval: pendingWorkspaceId ? 2000 : false,
|
||||
},
|
||||
);
|
||||
const { data, isLoading } = api.workspace.all.useQuery(undefined, {
|
||||
refetchInterval: pendingWorkspaceId ? 2000 : false,
|
||||
});
|
||||
const utils = api.useUtils();
|
||||
|
||||
const switchWorkspace = (_workspace: Workspace) => {
|
||||
@@ -79,7 +74,7 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (!data?.length) {
|
||||
if (!isLoading && !isFetching) setHasLoaded(true);
|
||||
if (!isLoading) setHasLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -95,7 +90,6 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
|
||||
description: workspace.description,
|
||||
plan: workspace.plan,
|
||||
weekStartDay: workspace.weekStartDay,
|
||||
cardPrefix: workspace.cardPrefix,
|
||||
hasLoaded: true,
|
||||
})) as Workspace[];
|
||||
|
||||
@@ -103,72 +97,53 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
|
||||
}
|
||||
|
||||
if (storedWorkspaceId !== null) {
|
||||
const selectedWorkspace = data.find(
|
||||
const newData = data;
|
||||
const selectedWorkspace = newData.find(
|
||||
({ workspace }) => workspace.publicId === storedWorkspaceId,
|
||||
);
|
||||
|
||||
if (!selectedWorkspace?.workspace) {
|
||||
if (pendingWorkspaceId) {
|
||||
pollAttemptsRef.current += 1;
|
||||
if (pollAttemptsRef.current >= MAX_POLL_ATTEMPTS) {
|
||||
setPendingWorkspaceId(null);
|
||||
localStorage.removeItem("workspacePublicId");
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Clear stale workspacePublicId from localStorage
|
||||
localStorage.removeItem("workspacePublicId");
|
||||
pollAttemptsRef.current += 1;
|
||||
if (pollAttemptsRef.current >= MAX_POLL_ATTEMPTS) {
|
||||
setPendingWorkspaceId(null);
|
||||
}
|
||||
} else {
|
||||
pollAttemptsRef.current = 0;
|
||||
setPendingWorkspaceId(null);
|
||||
|
||||
setWorkspace({
|
||||
publicId: selectedWorkspace.workspace.publicId,
|
||||
name: selectedWorkspace.workspace.name,
|
||||
slug: selectedWorkspace.workspace.slug,
|
||||
plan: selectedWorkspace.workspace.plan,
|
||||
description: selectedWorkspace.workspace.description,
|
||||
role: selectedWorkspace.role as "admin" | "member" | "guest",
|
||||
weekStartDay: selectedWorkspace.workspace.weekStartDay as 0 | 1 | 6,
|
||||
cardPrefix: selectedWorkspace.workspace.cardPrefix,
|
||||
});
|
||||
|
||||
if (workspacePublicId) {
|
||||
router.push(`/boards`);
|
||||
localStorage.setItem("workspacePublicId", workspacePublicId);
|
||||
}
|
||||
|
||||
setHasLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
pollAttemptsRef.current = 0;
|
||||
setPendingWorkspaceId(null);
|
||||
|
||||
setWorkspace({
|
||||
publicId: selectedWorkspace.workspace.publicId,
|
||||
name: selectedWorkspace.workspace.name,
|
||||
slug: selectedWorkspace.workspace.slug,
|
||||
plan: selectedWorkspace.workspace.plan,
|
||||
description: selectedWorkspace.workspace.description,
|
||||
role: selectedWorkspace.role,
|
||||
weekStartDay: selectedWorkspace.workspace.weekStartDay as 0 | 1 | 6,
|
||||
});
|
||||
|
||||
if (workspacePublicId) {
|
||||
router.push(`/boards`);
|
||||
localStorage.setItem("workspacePublicId", workspacePublicId);
|
||||
}
|
||||
} else {
|
||||
const primaryWorkspace = data[0]?.workspace;
|
||||
const primaryWorkspaceRole = data[0]?.role;
|
||||
|
||||
if (!primaryWorkspace || !primaryWorkspaceRole) return;
|
||||
localStorage.setItem("workspacePublicId", primaryWorkspace.publicId);
|
||||
setWorkspace({
|
||||
publicId: primaryWorkspace.publicId,
|
||||
name: primaryWorkspace.name,
|
||||
slug: primaryWorkspace.slug,
|
||||
plan: primaryWorkspace.plan,
|
||||
description: primaryWorkspace.description,
|
||||
role: primaryWorkspaceRole,
|
||||
weekStartDay: primaryWorkspace.weekStartDay as 0 | 1 | 6,
|
||||
});
|
||||
}
|
||||
|
||||
const primaryWorkspace = data[0]?.workspace;
|
||||
const primaryWorkspaceRole = data[0]?.role;
|
||||
|
||||
if (!primaryWorkspace || !primaryWorkspaceRole) return;
|
||||
localStorage.setItem("workspacePublicId", primaryWorkspace.publicId);
|
||||
setWorkspace({
|
||||
publicId: primaryWorkspace.publicId,
|
||||
name: primaryWorkspace.name,
|
||||
slug: primaryWorkspace.slug,
|
||||
plan: primaryWorkspace.plan,
|
||||
description: primaryWorkspace.description,
|
||||
role: primaryWorkspaceRole as "admin" | "member" | "guest",
|
||||
weekStartDay: primaryWorkspace.weekStartDay as 0 | 1 | 6,
|
||||
cardPrefix: primaryWorkspace.cardPrefix,
|
||||
});
|
||||
setHasLoaded(true);
|
||||
}, [
|
||||
data,
|
||||
isLoading,
|
||||
isFetching,
|
||||
workspacePublicId,
|
||||
pendingWorkspaceId,
|
||||
router,
|
||||
]);
|
||||
}, [data, isLoading, workspacePublicId, router]);
|
||||
|
||||
return (
|
||||
<WorkspaceContext.Provider
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function LoginPage() {
|
||||
|
||||
const { data } = authClient.useSession();
|
||||
|
||||
if (data?.user.id) router.push(redirect ?? "/boards");
|
||||
if (data?.user.id) router.push("/boards");
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function SignUpPage() {
|
||||
|
||||
const { data } = authClient.useSession();
|
||||
|
||||
if (data?.user.id) router.push(redirect ?? "/boards");
|
||||
if (data?.user.id) router.push("/boards");
|
||||
|
||||
const handleMagicLinkSent = (value: boolean, recipient: string) => {
|
||||
setIsMagicLinkSent(value);
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import {
|
||||
HiArrowRightOnRectangle,
|
||||
HiEllipsisHorizontal,
|
||||
HiLink,
|
||||
HiOutlineDocumentDuplicate,
|
||||
@@ -31,7 +29,6 @@ export default function BoardDropdown({
|
||||
isFavorite?: boolean;
|
||||
boardName?: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { openModal } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
const { canEditBoard, canDeleteBoard, canCreateBoard, canArchiveBoard } =
|
||||
@@ -50,7 +47,6 @@ export default function BoardDropdown({
|
||||
: t`The board has been unarchived.`,
|
||||
icon: "success",
|
||||
});
|
||||
void router.push(`/boards`);
|
||||
} else if (variables.favorite !== undefined) {
|
||||
showPopup({
|
||||
header: variables.favorite
|
||||
@@ -89,7 +85,7 @@ export default function BoardDropdown({
|
||||
const isArchiveActionPending = updateBoard.isPending;
|
||||
|
||||
const items = [
|
||||
...(!isTemplate && canCreateBoard
|
||||
...(isTemplate && canCreateBoard
|
||||
? [
|
||||
{
|
||||
label: t`Make template`,
|
||||
@@ -120,17 +116,6 @@ export default function BoardDropdown({
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(!isTemplate && canEditBoard
|
||||
? [
|
||||
{
|
||||
label: t`Move to workspace`,
|
||||
action: () => openModal("MOVE_BOARD"),
|
||||
icon: (
|
||||
<HiArrowRightOnRectangle className="h-[16px] w-[16px] text-dark-900" />
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: isFavorite
|
||||
? t`Remove from favorites`
|
||||
|
||||
@@ -16,7 +16,6 @@ import { getAvatarUrl } from "~/utils/helpers";
|
||||
|
||||
const Card = ({
|
||||
title,
|
||||
ticketNumber,
|
||||
labels,
|
||||
members,
|
||||
checklists,
|
||||
@@ -26,7 +25,6 @@ const Card = ({
|
||||
dueDate,
|
||||
}: {
|
||||
title: string;
|
||||
ticketNumber?: string | null;
|
||||
labels: { name: string; colourCode: string | null }[];
|
||||
members: {
|
||||
publicId: string;
|
||||
@@ -69,11 +67,6 @@ const Card = ({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col overflow-hidden rounded-md border border-light-200 bg-light-50 px-3 py-2 text-sm text-neutral-900 dark:border-dark-200 dark:bg-dark-200 dark:text-dark-1000 dark:hover:bg-dark-300">
|
||||
{ticketNumber && (
|
||||
<span className="mb-1 text-xs text-light-700 dark:text-dark-800">
|
||||
{ticketNumber}
|
||||
</span>
|
||||
)}
|
||||
<span className="break-words">{title}</span>
|
||||
{labels.length ||
|
||||
members.length ||
|
||||
|
||||
@@ -95,7 +95,7 @@ export default function List({
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
className="dark-text-dark-1000 mr-5 h-fit min-w-[18rem] max-w-[18rem] snap-start rounded-md border border-light-400 bg-light-300 py-2 pl-2 pr-1 text-neutral-900 dark:border-dark-300 dark:bg-dark-100 md:snap-align-none"
|
||||
className="dark-text-dark-1000 mr-5 h-fit min-w-[18rem] max-w-[18rem] rounded-md border border-light-400 bg-light-300 py-2 pl-2 pr-1 text-neutral-900 dark:border-dark-300 dark:bg-dark-100"
|
||||
>
|
||||
<div className="mb-2 flex justify-between">
|
||||
<form
|
||||
@@ -118,7 +118,7 @@ export default function List({
|
||||
}
|
||||
>
|
||||
<button
|
||||
className="mx-1 inline-flex h-fit items-center rounded-md p-1 px-1 text-sm font-semibold text-dark-50 hover:bg-light-400 disabled:cursor-not-allowed disabled:opacity-60 dark:hover:bg-dark-200"
|
||||
className="mx-1 inline-flex h-fit items-center rounded-md p-1 px-1 text-sm font-semibold text-dark-50 hover:bg-light-400 disabled:opacity-60 disabled:cursor-not-allowed dark:hover:bg-dark-200"
|
||||
onClick={() => openNewCardForm(list.publicId)}
|
||||
disabled={!canCreateCard}
|
||||
>
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useState } from "react";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
export function MoveBoardForm({
|
||||
boardPublicId,
|
||||
}: {
|
||||
boardPublicId: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { closeModal } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
const { workspace, availableWorkspaces, switchWorkspace } = useWorkspace();
|
||||
const [targetWorkspacePublicId, setTargetWorkspacePublicId] = useState("");
|
||||
|
||||
const otherWorkspaces = availableWorkspaces.filter(
|
||||
(ws) => ws.publicId !== workspace.publicId && ws.role !== "guest",
|
||||
);
|
||||
|
||||
const moveBoard = api.board.move.useMutation({
|
||||
onSuccess: () => {
|
||||
const targetWorkspace = availableWorkspaces.find(
|
||||
(ws) => ws.publicId === targetWorkspacePublicId,
|
||||
);
|
||||
closeModal();
|
||||
showPopup({
|
||||
header: t`Board moved`,
|
||||
message: t`The board has been moved to ${targetWorkspace?.name ?? "the workspace"}.`,
|
||||
icon: "success",
|
||||
});
|
||||
if (targetWorkspace) {
|
||||
switchWorkspace(targetWorkspace);
|
||||
} else {
|
||||
router.push("/boards");
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
showPopup({
|
||||
header: t`Unable to move board`,
|
||||
message: error.message,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleMoveBoard = () => {
|
||||
if (!targetWorkspacePublicId) return;
|
||||
moveBoard.mutate({
|
||||
boardPublicId,
|
||||
targetWorkspacePublicId,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-5">
|
||||
<div className="flex w-full flex-col justify-between pb-4">
|
||||
<h2 className="text-md pb-4 font-medium text-neutral-900 dark:text-dark-1000">
|
||||
{t`Move board to another workspace`}
|
||||
</h2>
|
||||
{otherWorkspaces.length === 0 ? (
|
||||
<p className="text-sm font-medium text-light-900 dark:text-dark-900">
|
||||
{t`You don't have any other workspaces to move this board to.`}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<label
|
||||
htmlFor="target-workspace"
|
||||
className="mb-2 text-sm font-medium text-light-900 dark:text-dark-900"
|
||||
>
|
||||
{t`Destination workspace`}
|
||||
</label>
|
||||
<select
|
||||
id="target-workspace"
|
||||
value={targetWorkspacePublicId}
|
||||
onChange={(e) => setTargetWorkspacePublicId(e.target.value)}
|
||||
className="block w-full rounded-md border-0 bg-dark-300 bg-white/5 py-1.5 text-sm shadow-sm ring-1 ring-inset ring-light-600 placeholder:text-dark-800 focus:ring-2 focus:ring-inset focus:ring-light-700 dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:leading-6"
|
||||
>
|
||||
<option value="">{t`Select a workspace`}</option>
|
||||
{otherWorkspaces.map((ws) => (
|
||||
<option key={ws.publicId} value={ws.publicId}>
|
||||
{ws.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-3 text-sm text-light-800 dark:text-dark-800">
|
||||
{t`Card member assignments will be cleared when moving to a different workspace.`}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
|
||||
<Button onClick={() => closeModal()} variant="secondary">
|
||||
{t`Cancel`}
|
||||
</Button>
|
||||
{otherWorkspaces.length > 0 && (
|
||||
<Button
|
||||
onClick={handleMoveBoard}
|
||||
isLoading={moveBoard.isPending}
|
||||
disabled={!targetWorkspacePublicId}
|
||||
>
|
||||
{t`Move board`}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -146,10 +146,6 @@ export function NewCardForm({
|
||||
listId: 2,
|
||||
description: "",
|
||||
dueDate: args.dueDate ?? null,
|
||||
cardNumber: null,
|
||||
comments: [],
|
||||
checklists: [],
|
||||
attachments: [],
|
||||
labels: oldBoard.labels.filter((label) =>
|
||||
args.labelPublicIds.includes(label.publicId),
|
||||
),
|
||||
@@ -162,9 +158,6 @@ export function NewCardForm({
|
||||
...member,
|
||||
deletedAt: null,
|
||||
})) ?? [],
|
||||
comments: [],
|
||||
checklists: [],
|
||||
attachments: [],
|
||||
_filteredLabels: labelPublicIds.map((id) => ({ publicId: id })),
|
||||
_filteredMembers: memberPublicIds.map((id) => ({ publicId: id })),
|
||||
index: position === "start" ? 0 : list.cards.length,
|
||||
|
||||
@@ -2,10 +2,10 @@ import type { DropResult } from "react-beautiful-dnd";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useRouter } from "next/router";
|
||||
import { env } from "next-runtime-env";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { keepPreviousData } from "@tanstack/react-query";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { DragDropContext, Draggable } from "react-beautiful-dnd";
|
||||
import { useForm } from "react-hook-form";
|
||||
import {
|
||||
@@ -49,7 +49,6 @@ import { DeleteBoardConfirmation } from "./components/DeleteBoardConfirmation";
|
||||
import { DeleteListConfirmation } from "./components/DeleteListConfirmation";
|
||||
import Filters from "./components/Filters";
|
||||
import List from "./components/List";
|
||||
import { MoveBoardForm } from "./components/MoveBoardForm";
|
||||
import { NewCardForm } from "./components/NewCardForm";
|
||||
import { NewListForm } from "./components/NewListForm";
|
||||
import { NewTemplateForm } from "./components/NewTemplateForm";
|
||||
@@ -85,26 +84,21 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
const { canCreateList, canEditList, canEditCard, canEditBoard } =
|
||||
usePermissions();
|
||||
|
||||
const { tooltipContent: createListShortcutTooltipContent } =
|
||||
useKeyboardShortcut({
|
||||
type: "PRESS",
|
||||
stroke: { key: "C" },
|
||||
action: () => boardId && canCreateList && openNewListForm(boardId),
|
||||
description: t`Create new list`,
|
||||
group: "ACTIONS",
|
||||
});
|
||||
|
||||
const boardId = params?.boardId
|
||||
? Array.isArray(params.boardId)
|
||||
? params.boardId[0]
|
||||
: params.boardId
|
||||
: null;
|
||||
|
||||
const createListShortcut = useMemo(
|
||||
() => ({
|
||||
type: "PRESS" as const,
|
||||
stroke: { key: "C" },
|
||||
action: () => boardId && canCreateList && openNewListForm(boardId),
|
||||
description: t`Create new list`,
|
||||
group: "ACTIONS" as const,
|
||||
}),
|
||||
[boardId, canCreateList],
|
||||
);
|
||||
|
||||
const { tooltipContent: createListShortcutTooltipContent } =
|
||||
useKeyboardShortcut(createListShortcut);
|
||||
|
||||
const updateBoard = api.board.update.useMutation();
|
||||
|
||||
const { register, handleSubmit, setValue } = useForm<UpdateBoardInput>({
|
||||
@@ -466,13 +460,6 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "MOVE_BOARD"}
|
||||
>
|
||||
<MoveBoardForm boardPublicId={boardId ?? ""} />
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "CREATE_TEMPLATE"}
|
||||
@@ -647,7 +634,7 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onMouseDown={onMouseDown}
|
||||
className={`scrollbar-w-none scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-h-[8px] z-0 flex-1 snap-x snap-mandatory scroll-pl-[10px] 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 md:snap-none`}
|
||||
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">
|
||||
@@ -694,11 +681,11 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
>
|
||||
{(provided) => (
|
||||
<div
|
||||
className="flex w-max"
|
||||
className="flex"
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
>
|
||||
<div className="min-w-[10px] md:min-w-[2rem]" />
|
||||
<div className="min-w-[2rem]" />
|
||||
{boardData.lists.map((list, index) => (
|
||||
<List
|
||||
index={index}
|
||||
@@ -770,11 +757,6 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
>
|
||||
<Card
|
||||
title={card.title}
|
||||
ticketNumber={
|
||||
card.cardNumber != null
|
||||
? `${boardData.workspace.cardPrefix}-${card.cardNumber}`
|
||||
: null
|
||||
}
|
||||
labels={card.labels}
|
||||
members={card.members}
|
||||
checklists={card.checklists ?? []}
|
||||
@@ -795,7 +777,7 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
</Droppable>
|
||||
</List>
|
||||
))}
|
||||
<div className="min-w-[calc(100vw-18rem)] md:min-w-[0.75rem]" />
|
||||
<div className="min-w-[0.75rem]" />
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -5,12 +5,8 @@ import {
|
||||
ListboxOptions,
|
||||
} from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
HiArrowDownTray,
|
||||
HiChevronDown,
|
||||
HiOutlinePlusSmall,
|
||||
} from "react-icons/hi2";
|
||||
import { HiArrowDownTray, HiChevronDown, HiOutlinePlusSmall } from "react-icons/hi2";
|
||||
import { useState } from "react";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import FeedbackModal from "~/components/FeedbackModal";
|
||||
@@ -37,19 +33,14 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
const [activeTab, setActiveTab] = useState<"boards" | "archived">("boards");
|
||||
const { canCreateBoard } = usePermissions();
|
||||
|
||||
const createBoardShortcut = useMemo(
|
||||
() => ({
|
||||
type: "PRESS" as const,
|
||||
const { tooltipContent: createModalShortcutTooltipContent } =
|
||||
useKeyboardShortcut({
|
||||
type: "PRESS",
|
||||
stroke: { key: "C" },
|
||||
action: () => canCreateBoard && openModal("NEW_BOARD"),
|
||||
description: t`Create new ${isTemplate ? "template" : "board"}`,
|
||||
group: "ACTIONS" as const,
|
||||
}),
|
||||
[canCreateBoard, isTemplate, openModal],
|
||||
);
|
||||
|
||||
const { tooltipContent: createModalShortcutTooltipContent } =
|
||||
useKeyboardShortcut(createBoardShortcut);
|
||||
group: "ACTIONS",
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -146,7 +137,7 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
onChange={(tab) => setActiveTab(tab)}
|
||||
>
|
||||
<div className="relative mb-4">
|
||||
<ListboxButton className="w-full appearance-none rounded-md border-0 bg-light-50 py-3 pl-3 pr-10 text-left text-sm font-semibold text-light-1000 shadow-sm ring-1 ring-inset ring-light-300 dark:bg-dark-50 dark:text-dark-1000 dark:ring-dark-300 dark:focus:ring-dark-500">
|
||||
<ListboxButton className="w-full appearance-none rounded-md border-0 bg-light-50 py-3 pl-3 pr-10 text-left text-sm font-semibold text-light-1000 shadow-sm ring-1 ring-inset ring-light-300 dark:bg-dark-50 dark:text-dark-1000 dark:ring-dark-300 dark:focus:ring-dark-500">
|
||||
{boardsTabs.find((tab) => tab.key === activeTab)?.label ??
|
||||
"Select a tab"}
|
||||
<HiChevronDown
|
||||
@@ -160,10 +151,9 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
key={tab.key}
|
||||
value={tab.key}
|
||||
className={({ selected }) =>
|
||||
`relative cursor-pointer select-none py-2 pl-3 pr-9 ${
|
||||
selected
|
||||
? "font-bold text-light-1000 dark:text-dark-1000"
|
||||
: "font-normal text-light-1000 dark:text-dark-1000"
|
||||
`relative cursor-pointer select-none py-2 pl-3 pr-9 ${selected
|
||||
? "font-bold text-light-1000 dark:text-dark-1000"
|
||||
: "font-normal text-light-1000 dark:text-dark-1000"
|
||||
}`
|
||||
}
|
||||
>
|
||||
@@ -185,11 +175,10 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
key={tab.key}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`mb-8 mt-2 whitespace-nowrap px-1 py-0 text-sm font-semibold transition-colors focus:outline-none ${
|
||||
activeTab === tab.key
|
||||
? "border-light-1000 text-light-1000 dark:border-dark-1000 dark:text-dark-1000"
|
||||
: "border-transparent text-light-900 hover:border-light-950 hover:text-light-950 dark:text-dark-900 dark:hover:border-white/20 dark:hover:text-dark-950"
|
||||
}`}
|
||||
className={`whitespace-nowrap px-1 py-0 mt-2 mb-8 text-sm font-semibold transition-colors focus:outline-none ${activeTab === tab.key
|
||||
? "border-light-1000 text-light-1000 dark:border-dark-1000 dark:text-dark-1000"
|
||||
: "border-transparent text-light-900 hover:border-light-950 hover:text-light-950 dark:text-dark-900 dark:hover:border-white/20 dark:hover:text-dark-950"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
|
||||
@@ -525,6 +525,7 @@ const ActivityList = ({
|
||||
comment={activity.comment?.comment}
|
||||
isEdited={!!activity.comment?.updatedAt}
|
||||
isAuthor={activity.comment?.createdBy === sessionData?.user.id}
|
||||
isAdmin={isAdmin ?? false}
|
||||
isViewOnly={!!isViewOnly}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useRef, useState } from "react";
|
||||
import { HiOutlinePaperClip } from "react-icons/hi";
|
||||
import { HiCheckBadge } from "react-icons/hi2";
|
||||
@@ -8,6 +7,7 @@ import { twMerge } from "tailwind-merge";
|
||||
import Button from "~/components/Button";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { env } from "next-runtime-env";
|
||||
import { api } from "~/utils/api";
|
||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
||||
|
||||
@@ -30,7 +30,7 @@ export function AttachmentUpload({ cardPublicId }: { cardPublicId: string }) {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": file.type,
|
||||
"x-original-filename": encodeURIComponent(file.name),
|
||||
"x-original-filename": file.name,
|
||||
},
|
||||
body: file,
|
||||
},
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import {
|
||||
HiEllipsisHorizontal,
|
||||
HiHashtag,
|
||||
HiLink,
|
||||
HiOutlineCheckCircle,
|
||||
HiOutlineDocumentDuplicate,
|
||||
HiOutlineTrash,
|
||||
} from "react-icons/hi2";
|
||||
|
||||
@@ -14,52 +12,24 @@ import Dropdown from "~/components/Dropdown";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
export default function CardDropdown({
|
||||
cardPublicId,
|
||||
isTemplate,
|
||||
boardPublicId,
|
||||
cardCreatedBy,
|
||||
ticketNumber,
|
||||
listPublicId,
|
||||
cardIndex,
|
||||
}: {
|
||||
cardPublicId: string;
|
||||
isTemplate?: boolean;
|
||||
boardPublicId?: string;
|
||||
cardCreatedBy?: string | null;
|
||||
ticketNumber?: string | null;
|
||||
listPublicId?: string;
|
||||
cardIndex?: number;
|
||||
}) {
|
||||
const { openModal } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
const { canEditCard, canDeleteCard } = usePermissions();
|
||||
const { data: session } = authClient.useSession();
|
||||
const utils = api.useUtils();
|
||||
const isCreator = cardCreatedBy && session?.user.id === cardCreatedBy;
|
||||
|
||||
const duplicateCard = api.card.duplicate.useMutation({
|
||||
onSuccess: () => {
|
||||
showPopup({
|
||||
header: t`Card duplicated`,
|
||||
icon: "success",
|
||||
message: t`Card duplicated successfully.`,
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: t`Unable to duplicate card`,
|
||||
icon: "error",
|
||||
message: t`Please try again.`,
|
||||
});
|
||||
},
|
||||
onSettled: async () => {
|
||||
await utils.board.byId.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const handleCopyCardLink = async () => {
|
||||
const path =
|
||||
isTemplate && boardPublicId
|
||||
@@ -83,40 +53,12 @@ export default function CardDropdown({
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyTicketId = async () => {
|
||||
if (!ticketNumber) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(ticketNumber);
|
||||
showPopup({
|
||||
header: t`ID copied`,
|
||||
icon: "success",
|
||||
message: t`Ticket ID copied to clipboard`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showPopup({
|
||||
header: t`Unable to copy ID`,
|
||||
icon: "error",
|
||||
message: t`Please try again.`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const items = [
|
||||
{
|
||||
label: t`Copy card link`,
|
||||
action: handleCopyCardLink,
|
||||
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
|
||||
},
|
||||
...(ticketNumber
|
||||
? [
|
||||
{
|
||||
label: t`Copy ticket ID`,
|
||||
action: handleCopyTicketId,
|
||||
icon: <HiHashtag className="h-[16px] w-[16px] text-dark-900" />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(canEditCard
|
||||
? [
|
||||
{
|
||||
@@ -126,24 +68,6 @@ export default function CardDropdown({
|
||||
<HiOutlineCheckCircle className="h-[16px] w-[16px] text-dark-900" />
|
||||
),
|
||||
},
|
||||
{
|
||||
label: t`Duplicate card`,
|
||||
action: () => {
|
||||
if (!listPublicId || cardIndex === undefined) return;
|
||||
duplicateCard.mutate({
|
||||
cardPublicId,
|
||||
listPublicId,
|
||||
index: cardIndex + 1,
|
||||
copyLabels: true,
|
||||
copyMembers: true,
|
||||
copyChecklists: true,
|
||||
});
|
||||
},
|
||||
icon: (
|
||||
<HiOutlineDocumentDuplicate className="h-[16px] w-[16px] text-dark-900" />
|
||||
),
|
||||
disabled: duplicateCard.isPending || !listPublicId,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(canDeleteCard || isCreator
|
||||
|
||||
@@ -2,10 +2,9 @@ import { t } from "@lingui/core/macro";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { HiOutlineArrowUp } from "react-icons/hi2";
|
||||
|
||||
import type { WorkspaceMember } from "~/components/Editor";
|
||||
import Editor from "~/components/Editor";
|
||||
import type { WorkspaceMember } from "~/components/Editor";
|
||||
import LoadingSpinner from "~/components/LoadingSpinner";
|
||||
import { Tooltip } from "~/components/Tooltip";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
@@ -52,21 +51,6 @@ const NewCommentForm = ({
|
||||
});
|
||||
};
|
||||
|
||||
const isMac =
|
||||
typeof navigator !== "undefined" && navigator.userAgent.includes("Mac");
|
||||
|
||||
const submitTooltip = (
|
||||
<div className="flex flex-row items-center gap-2 text-[11px]">
|
||||
{t`Submit`}
|
||||
<span className="inline-flex items-center justify-center rounded border border-light-400 bg-light-200 px-1.5 py-0.5 font-mono text-[8px] font-semibold text-neutral-900 dark:border-dark-400 dark:bg-dark-200 dark:text-dark-950">
|
||||
{isMac ? "⌘" : "Ctrl"}
|
||||
</span>
|
||||
<span className="inline-flex items-center justify-center rounded border border-light-400 bg-light-200 px-1.5 py-0.5 font-mono text-[8px] font-semibold text-neutral-900 dark:border-dark-400 dark:bg-dark-200 dark:text-dark-950">
|
||||
Enter
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!canCreateComment) {
|
||||
return null;
|
||||
}
|
||||
@@ -79,26 +63,23 @@ const NewCommentForm = ({
|
||||
<Editor
|
||||
content={watch("comment")}
|
||||
onChange={(value) => setValue("comment", value)}
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
workspaceMembers={workspaceMembers}
|
||||
enableYouTubeEmbed={false}
|
||||
placeholder={t`Add comment... (type '/' to open commands or '@' to mention)`}
|
||||
disableHeadings={true}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Tooltip content={submitTooltip} placement="top">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={addCommentMutation.isPending}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full border border-light-600 bg-light-300 hover:bg-light-400 disabled:opacity-50 dark:border-dark-400 dark:bg-dark-200 dark:hover:bg-dark-400"
|
||||
>
|
||||
{addCommentMutation.isPending ? (
|
||||
<LoadingSpinner size="sm" />
|
||||
) : (
|
||||
<HiOutlineArrowUp />
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={addCommentMutation.isPending}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full border border-light-600 bg-light-300 hover:bg-light-400 disabled:opacity-50 dark:border-dark-400 dark:bg-dark-200 dark:hover:bg-dark-400"
|
||||
>
|
||||
{addCommentMutation.isPending ? (
|
||||
<LoadingSpinner size="sm" />
|
||||
) : (
|
||||
<HiOutlineArrowUp />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -185,11 +185,7 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
? router.query.cardId[0]
|
||||
: router.query.cardId;
|
||||
|
||||
const {
|
||||
data: card,
|
||||
isLoading,
|
||||
error,
|
||||
} = api.card.byId.useQuery(
|
||||
const { data: card, isLoading, error } = api.card.byId.useQuery(
|
||||
{ cardPublicId: cardId ?? "" },
|
||||
{ enabled: !!cardId && cardId.length >= 12 },
|
||||
);
|
||||
@@ -344,15 +340,6 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
>
|
||||
{board?.name}
|
||||
</Link>
|
||||
{card.cardNumber != null &&
|
||||
card.list.board.workspace.cardPrefix && (
|
||||
<>
|
||||
<IoChevronForwardSharp className="h-[10px] w-[10px] text-light-900 dark:text-dark-900" />
|
||||
<span className="whitespace-nowrap text-sm font-bold leading-[1.5rem] text-light-700 dark:text-dark-800">
|
||||
{card.list.board.workspace.cardPrefix}-{card.cardNumber}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Dropdown
|
||||
@@ -360,14 +347,6 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
isTemplate={isTemplate}
|
||||
boardPublicId={boardId}
|
||||
cardCreatedBy={card?.createdBy}
|
||||
ticketNumber={
|
||||
card.cardNumber != null &&
|
||||
card.list.board.workspace.cardPrefix
|
||||
? `${card.list.board.workspace.cardPrefix}-${card.cardNumber}`
|
||||
: null
|
||||
}
|
||||
listPublicId={card?.list.publicId}
|
||||
cardIndex={card?.index}
|
||||
/>
|
||||
<Link
|
||||
href={`/${isTemplate ? "templates" : "boards"}/${boardId}`}
|
||||
|
||||
@@ -36,17 +36,6 @@ export default function InvitePage() {
|
||||
return router.push(`/boards`);
|
||||
}
|
||||
|
||||
if (
|
||||
error.data?.code === "FORBIDDEN" &&
|
||||
error.message === "SEAT_LIMIT_REACHED"
|
||||
) {
|
||||
setError(
|
||||
t`This workspace has reached its member limit. The workspace owner will need to upgrade their plan.`,
|
||||
);
|
||||
setIsProcessing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setError(
|
||||
error.message ||
|
||||
t`Failed to accept invitation. Please try again later, or contact customer support.`,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { z } from "zod";
|
||||
|
||||
import type { InviteMemberInput } from "@kan/api/types";
|
||||
import type { Subscription } from "@kan/shared/utils";
|
||||
import { authClient } from "@kan/auth/client";
|
||||
import { getSubscriptionByPlan } from "@kan/shared/utils";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
@@ -24,15 +25,15 @@ import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
export function InviteMemberForm({
|
||||
numberOfMembers,
|
||||
subscriptions,
|
||||
unlimitedSeats,
|
||||
memberCount,
|
||||
seatLimit,
|
||||
userId,
|
||||
}: {
|
||||
numberOfMembers: number;
|
||||
subscriptions: Subscription[] | undefined;
|
||||
unlimitedSeats: boolean;
|
||||
memberCount: number;
|
||||
seatLimit: number | null;
|
||||
userId: string | undefined;
|
||||
}) {
|
||||
const utils = api.useUtils();
|
||||
const [isShareInviteLinkEnabled, setIsShareInviteLinkEnabled] =
|
||||
@@ -97,15 +98,6 @@ export function InviteMemberForm({
|
||||
message: t`User is already a member of this workspace`,
|
||||
icon: "error",
|
||||
});
|
||||
} else if (
|
||||
error.data?.code === "FORBIDDEN" &&
|
||||
error.message === "SEAT_LIMIT_REACHED"
|
||||
) {
|
||||
showPopup({
|
||||
header: t`Seat limit reached`,
|
||||
message: t`You've reached your ${seatLimit ?? 0}-seat limit. Please upgrade your plan to add more members.`,
|
||||
icon: "error",
|
||||
});
|
||||
} else {
|
||||
showPopup({
|
||||
header: t`Error inviting member`,
|
||||
@@ -161,9 +153,6 @@ export function InviteMemberForm({
|
||||
|
||||
const hasTeamSubscription = !!teamSubscription;
|
||||
const hasProSubscription = !!proSubscription;
|
||||
const isPartnerTier = !!(
|
||||
teamSubscription?.partnerTier ?? proSubscription?.partnerTier
|
||||
);
|
||||
|
||||
let isYearly = false;
|
||||
let price = t`$10/month`;
|
||||
@@ -186,13 +175,13 @@ export function InviteMemberForm({
|
||||
inviteMember.mutate(member);
|
||||
};
|
||||
|
||||
const isFreePlan =
|
||||
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||
!hasTeamSubscription &&
|
||||
!hasProSubscription;
|
||||
|
||||
const handleInviteLinkToggle = async () => {
|
||||
if (isFreePlan) return;
|
||||
if (
|
||||
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||
!hasTeamSubscription &&
|
||||
!hasProSubscription
|
||||
)
|
||||
return handleUpgrade();
|
||||
|
||||
setIsLoadingInviteLink(true);
|
||||
|
||||
@@ -230,6 +219,31 @@ export function InviteMemberForm({
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpgrade = async () => {
|
||||
const { data, error } = await authClient.subscription.upgrade({
|
||||
plan: "team",
|
||||
referenceId: workspace.publicId,
|
||||
metadata: { userId },
|
||||
seats: numberOfMembers,
|
||||
successUrl: "/members",
|
||||
cancelUrl: "/members",
|
||||
returnUrl: "/members",
|
||||
disableRedirect: true,
|
||||
});
|
||||
|
||||
if (data?.url) {
|
||||
window.location.href = data.url;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
showPopup({
|
||||
header: t`Error upgrading subscription`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const emailElement: HTMLElement | null =
|
||||
document.querySelector<HTMLElement>("#email");
|
||||
@@ -256,7 +270,11 @@ export function InviteMemberForm({
|
||||
<Input
|
||||
id="email"
|
||||
placeholder={t`Email`}
|
||||
disabled={isFreePlan}
|
||||
disabled={
|
||||
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||
!hasTeamSubscription &&
|
||||
!hasProSubscription
|
||||
}
|
||||
{...register("email", { required: true })}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
@@ -267,92 +285,78 @@ export function InviteMemberForm({
|
||||
errorMessage={errors.email?.message}
|
||||
/>
|
||||
)}
|
||||
{!isFreePlan &&
|
||||
(!isEmailEnabled || (isShareInviteLinkEnabled && inviteLink)) && (
|
||||
<div className="my-4">
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={inviteLink}
|
||||
className="pr-10 text-sm text-light-900 dark:text-dark-900"
|
||||
readOnly
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-light-900 hover:text-light-950 dark:text-dark-900 dark:hover:text-dark-950"
|
||||
onClick={copyToClipboard}
|
||||
>
|
||||
{copied ? (
|
||||
<HiMiniCheck className="h-5 w-5 text-green-600" />
|
||||
) : (
|
||||
<HiOutlineDocumentDuplicate className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 flex items-start gap-1">
|
||||
<HiInformationCircle className="mt-0.5 h-4 w-4 text-dark-900" />
|
||||
<p className="text-xs text-gray-500 dark:text-dark-900">
|
||||
{t`Anyone with this link can join your workspace`}
|
||||
{(!isEmailEnabled || (isShareInviteLinkEnabled && inviteLink)) && (
|
||||
<div className="my-4">
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={inviteLink}
|
||||
className="pr-10 text-sm text-light-900 dark:text-dark-900"
|
||||
readOnly
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-light-900 hover:text-light-950 dark:text-dark-900 dark:hover:text-dark-950"
|
||||
onClick={copyToClipboard}
|
||||
>
|
||||
{copied ? (
|
||||
<HiMiniCheck className="h-5 w-5 text-green-600" />
|
||||
) : (
|
||||
<HiOutlineDocumentDuplicate className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 flex items-start gap-1">
|
||||
<HiInformationCircle className="mt-0.5 h-4 w-4 text-dark-900" />
|
||||
<p className="text-xs text-gray-500 dark:text-dark-900">
|
||||
{t`Anyone with this link can join your workspace`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" && (
|
||||
<div className="mt-3 rounded-md bg-light-100 p-3 text-xs text-light-900 dark:bg-dark-200 dark:text-dark-900">
|
||||
{hasTeamSubscription || hasProSubscription ? (
|
||||
<div>
|
||||
<span className="font-medium text-emerald-500 dark:text-emerald-400">
|
||||
{hasTeamSubscription ? t`Team Plan` : t`Pro Plan ∞`}
|
||||
</span>
|
||||
<p className="mt-1">
|
||||
{unlimitedSeats
|
||||
? t`You have unlimited seats with your Pro Plan. There is no additional charge for new members!`
|
||||
: t`Adding a new member will cost an additional ${price} (${billingType}) per seat.`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||
(isPartnerTier && seatLimit !== null ? (
|
||||
<div className="mt-3 rounded-md bg-light-100 p-3 text-xs text-light-900 dark:bg-dark-200 dark:text-dark-900">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-emerald-500 dark:text-emerald-400">
|
||||
{hasTeamSubscription ? t`Team Plan` : t`Pro Plan`}
|
||||
</span>
|
||||
<span className="text-light-900 dark:text-dark-900">
|
||||
{memberCount} / {seatLimit} {t`seats`}
|
||||
) : (
|
||||
<div>
|
||||
<span className="font-medium text-light-950 dark:text-dark-950">
|
||||
{t`Free Plan`}
|
||||
</span>
|
||||
<p className="mt-1">
|
||||
{t`Inviting members requires a Team Plan. You'll be redirected to upgrade your workspace.`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : !unlimitedSeats ? (
|
||||
<div className="mt-3 rounded-md bg-light-100 p-3 text-xs text-light-900 dark:bg-dark-200 dark:text-dark-900">
|
||||
{hasTeamSubscription || hasProSubscription ? (
|
||||
<div>
|
||||
<span className="font-medium text-emerald-500 dark:text-emerald-400">
|
||||
{hasTeamSubscription ? t`Team Plan` : t`Pro Plan ∞`}
|
||||
</span>
|
||||
<p className="mt-1">
|
||||
{t`Adding a new member will cost an additional ${price} (${billingType}) per seat.`}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<span className="font-medium text-light-950 dark:text-dark-950">
|
||||
{t`Free Plan`}
|
||||
</span>
|
||||
<p className="mt-1">
|
||||
{t`Inviting members requires a Team or Pro plan. You'll be redirected to upgrade your workspace.`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null)}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-12 flex items-center justify-end space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
{!isFreePlan && (
|
||||
<Toggle
|
||||
label={
|
||||
isShareInviteLinkEnabled
|
||||
? t`Deactivate invite link`
|
||||
: t`Create invite link`
|
||||
}
|
||||
isChecked={isShareInviteLinkEnabled}
|
||||
onChange={handleInviteLinkToggle}
|
||||
/>
|
||||
)}
|
||||
<Toggle
|
||||
label={
|
||||
isShareInviteLinkEnabled
|
||||
? t`Deactivate invite link`
|
||||
: t`Create invite link`
|
||||
}
|
||||
isChecked={isShareInviteLinkEnabled}
|
||||
onChange={handleInviteLinkToggle}
|
||||
/>
|
||||
<div>
|
||||
{isFreePlan ? (
|
||||
<Button
|
||||
type="button"
|
||||
href={`/upgrade/select-plan?plan=pro&workspacePublicId=${workspace.publicId}&returnUrl=${encodeURIComponent("/members")}`}
|
||||
>
|
||||
{t`Choose plan`}
|
||||
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||
!hasTeamSubscription &&
|
||||
!hasProSubscription ? (
|
||||
<Button type="button" onClick={handleUpgrade}>
|
||||
{t`Start 14 day free trial`}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
|
||||
@@ -11,11 +11,7 @@ import { twMerge } from "tailwind-merge";
|
||||
|
||||
import type { Subscription } from "@kan/shared/utils";
|
||||
import { authClient } from "@kan/auth/client";
|
||||
import {
|
||||
getSeatLimit,
|
||||
getSubscriptionByPlan,
|
||||
hasUnlimitedSeats,
|
||||
} from "@kan/shared/utils";
|
||||
import { getSubscriptionByPlan, hasUnlimitedSeats } from "@kan/shared/utils";
|
||||
|
||||
import Avatar from "~/components/Avatar";
|
||||
import Button from "~/components/Button";
|
||||
@@ -31,8 +27,8 @@ import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
import { getAvatarUrl } from "~/utils/helpers";
|
||||
import { DeleteMemberConfirmation } from "./components/DeleteMemberConfirmation";
|
||||
import { EditMemberPermissionsModal } from "./components/EditMemberPermissionsModal";
|
||||
import { InviteMemberForm } from "./components/InviteMemberForm";
|
||||
import { EditMemberPermissionsModal } from "./components/EditMemberPermissionsModal";
|
||||
|
||||
export default function MembersPage() {
|
||||
const { modalContentType, openModal, isOpen } = useModal();
|
||||
@@ -80,23 +76,6 @@ export default function MembersPage() {
|
||||
|
||||
const unlimitedSeats = hasUnlimitedSeats(subscriptions);
|
||||
|
||||
const isProPlan =
|
||||
!!proSubscription ||
|
||||
workspace.plan === "pro" ||
|
||||
workspace.plan === "enterprise";
|
||||
const isTeamPlan = !!teamSubscription || workspace.plan === "team";
|
||||
const isPaidPlan = isProPlan || isTeamPlan;
|
||||
|
||||
const activeMembers = data?.members.length ?? 0;
|
||||
const seatLimit = getSeatLimit(subscriptions);
|
||||
const memberCount =
|
||||
data?.members.filter((m) => m.status === "active" || m.status === "invited")
|
||||
.length ?? 0;
|
||||
const totalSeats =
|
||||
teamSubscription?.seats ??
|
||||
proSubscription?.seats ??
|
||||
(isPaidPlan ? null : 1);
|
||||
|
||||
const TableRow = ({
|
||||
memberPublicId,
|
||||
memberId,
|
||||
@@ -165,8 +144,8 @@ export default function MembersPage() {
|
||||
{memberName}
|
||||
</p>
|
||||
</div>
|
||||
{(workspace.role === "admin" ||
|
||||
data?.showEmailsToMembers === true ||
|
||||
{((workspace.role === "admin" ||
|
||||
data?.showEmailsToMembers === true) ||
|
||||
showSkeleton) && (
|
||||
<p
|
||||
className={twMerge(
|
||||
@@ -201,7 +180,8 @@ export default function MembersPage() {
|
||||
<div className="relative inline-flex items-center">
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-400 ring-1 ring-inset ring-emerald-500/20 sm:text-[11px]">
|
||||
{memberRole &&
|
||||
memberRole.charAt(0).toUpperCase() + memberRole.slice(1)}
|
||||
memberRole.charAt(0).toUpperCase() +
|
||||
memberRole.slice(1)}
|
||||
{canEditMember && session?.user.id !== memberId && (
|
||||
<HiChevronDown className="h-3 w-3" />
|
||||
)}
|
||||
@@ -284,42 +264,38 @@ export default function MembersPage() {
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" && !!data && (
|
||||
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" && (
|
||||
<>
|
||||
{!isPaidPlan && (
|
||||
{!proSubscription && !teamSubscription && (
|
||||
<Link
|
||||
href={`/upgrade/select-plan?plan=pro&workspacePublicId=${workspace.publicId}&returnUrl=${encodeURIComponent("/members")}`}
|
||||
href="/settings/workspace?upgrade=pro"
|
||||
className="hidden items-center rounded-full border border-emerald-300 bg-emerald-50 px-3 py-1 text-center text-xs text-emerald-400 dark:border-emerald-700 dark:bg-emerald-950 dark:text-emerald-400 lg:flex"
|
||||
>
|
||||
<HiBolt />
|
||||
<span className="ml-1 font-medium">{t`Upgrade`}</span>
|
||||
<span className="ml-1 font-medium">
|
||||
{t`Launch offer: Get unlimited members with Pro`}
|
||||
</span>
|
||||
</Link>
|
||||
)}
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex items-center rounded-full border px-3 py-1 text-center text-xs",
|
||||
isPaidPlan
|
||||
teamSubscription || proSubscription
|
||||
? "border-emerald-300 bg-emerald-50 text-emerald-400 dark:border-emerald-700 dark:bg-emerald-950 dark:text-emerald-400"
|
||||
: "border-light-300 bg-light-50 text-light-1000 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900",
|
||||
)}
|
||||
>
|
||||
<span className="font-medium">
|
||||
{isProPlan
|
||||
{proSubscription
|
||||
? t`Pro Plan`
|
||||
: isTeamPlan
|
||||
: teamSubscription
|
||||
? t`Team Plan`
|
||||
: t`Free Plan`}
|
||||
{proSubscription && unlimitedSeats && (
|
||||
<span className="ml-1 text-xs">∞</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{isPaidPlan && (unlimitedSeats || totalSeats !== null) && (
|
||||
<div className="flex items-center rounded-full border border-light-300 bg-light-50 px-3 py-1 text-center text-xs text-light-1000 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900">
|
||||
<span className="font-medium">
|
||||
{unlimitedSeats
|
||||
? t`Unlimited seats`
|
||||
: `${activeMembers}/${totalSeats} ${t`seats`}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
@@ -408,10 +384,10 @@ export default function MembersPage() {
|
||||
isVisible={isOpen && modalContentType === "INVITE_MEMBER"}
|
||||
>
|
||||
<InviteMemberForm
|
||||
userId={session?.user.id}
|
||||
numberOfMembers={data?.members.length ?? 1}
|
||||
subscriptions={subscriptions}
|
||||
unlimitedSeats={unlimitedSeats}
|
||||
memberCount={memberCount}
|
||||
seatLimit={seatLimit}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Radio, RadioGroup } from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Radio, RadioGroup } from "@headlessui/react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useState } from "react";
|
||||
import { HiUser } from "react-icons/hi2";
|
||||
@@ -48,11 +48,6 @@ export default function SelectPlanView() {
|
||||
(searchParams.get("billing") as Billing | null) ?? "annual",
|
||||
);
|
||||
const returnUrl = searchParams.get("returnUrl") ?? "/boards";
|
||||
const workspacePublicId =
|
||||
searchParams.get("workspacePublicId") ??
|
||||
(typeof window !== "undefined"
|
||||
? localStorage.getItem("workspacePublicId")
|
||||
: null);
|
||||
const { data: workspaces } = api.workspace.all.useQuery();
|
||||
const { data: session } = authClient.useSession();
|
||||
const { data: user } = api.user.getUser.useQuery(undefined, {
|
||||
@@ -101,44 +96,18 @@ export default function SelectPlanView() {
|
||||
},
|
||||
];
|
||||
|
||||
const buildSelectPlanUrl = (plan: PlanId, b: Billing) => {
|
||||
const base = `/onboarding/select-plan?plan=${plan}&billing=${b}&returnUrl=${encodeURIComponent(returnUrl)}`;
|
||||
return workspacePublicId
|
||||
? `${base}&workspacePublicId=${workspacePublicId}`
|
||||
: base;
|
||||
};
|
||||
|
||||
const handleSelectPlan = (plan: PlanId) => {
|
||||
setSelected(plan);
|
||||
router.replace(buildSelectPlanUrl(plan, billing));
|
||||
router.replace(`/onboarding/select-plan?plan=${plan}&billing=${billing}&returnUrl=${encodeURIComponent(returnUrl)}`);
|
||||
};
|
||||
|
||||
const handleSetBilling = (b: Billing) => {
|
||||
setBilling(b);
|
||||
router.replace(buildSelectPlanUrl(selected, b));
|
||||
router.replace(`/onboarding/select-plan?plan=${selected}&billing=${b}&returnUrl=${encodeURIComponent(returnUrl)}`);
|
||||
};
|
||||
|
||||
const handleContinue = async () => {
|
||||
if (workspacePublicId && selected !== "solo") {
|
||||
const response = await fetch("/api/stripe/create_checkout_session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
plan: selected,
|
||||
billing,
|
||||
workspacePublicId,
|
||||
successUrl: returnUrl,
|
||||
cancelUrl: returnUrl,
|
||||
}),
|
||||
});
|
||||
const { url } = (await response.json()) as { url: string };
|
||||
if (url) window.location.href = url;
|
||||
return;
|
||||
}
|
||||
router.push(
|
||||
`/onboarding/workspace?plan=${selected}&billing=${billing}&returnUrl=${encodeURIComponent(returnUrl)}`,
|
||||
);
|
||||
};
|
||||
const handleContinue = () =>
|
||||
router.push(`/onboarding/workspace?plan=${selected}&billing=${billing}&returnUrl=${encodeURIComponent(returnUrl)}`);
|
||||
|
||||
const handleCancel = () => router.push(returnUrl);
|
||||
|
||||
@@ -240,11 +209,7 @@ export default function SelectPlanView() {
|
||||
{t`Cancel`}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={() => void handleContinue()}>
|
||||
{workspacePublicId && selected !== "solo"
|
||||
? t`Upgrade`
|
||||
: t`Continue`}
|
||||
</Button>
|
||||
<Button onClick={handleContinue}>{t`Continue`}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -39,17 +39,7 @@ export default function WorkspaceNameView() {
|
||||
const plan = searchParams.get("plan") ?? "solo";
|
||||
const billing = searchParams.get("billing") ?? "annual";
|
||||
const returnUrl = searchParams.get("returnUrl") ?? "/boards";
|
||||
const licenseKeyParam = searchParams.get("license_key");
|
||||
const isLicenseFlow =
|
||||
!!licenseKeyParam || searchParams.get("partner") === "1";
|
||||
const { showPopup } = usePopup();
|
||||
|
||||
useEffect(() => {
|
||||
if (licenseKeyParam) {
|
||||
localStorage.setItem("partnerLicenseKey", licenseKeyParam);
|
||||
}
|
||||
}, [licenseKeyParam]);
|
||||
|
||||
const [isProToggle, setIsProToggle] = useState(plan === "pro");
|
||||
const effectivePlan = isProToggle ? "pro" : plan;
|
||||
|
||||
@@ -104,16 +94,7 @@ export default function WorkspaceNameView() {
|
||||
if (!workspace.publicId) return;
|
||||
localStorage.setItem("workspacePublicId", workspace.publicId);
|
||||
void utils.workspace.all.invalidate();
|
||||
void utils.workspace.hasAvailablePartnerSlot.invalidate();
|
||||
const storedLicenseKey = localStorage.getItem("partnerLicenseKey");
|
||||
if (storedLicenseKey) {
|
||||
localStorage.removeItem("partnerLicenseKey");
|
||||
router.push(
|
||||
`/api/partner/link?license_key=${encodeURIComponent(storedLicenseKey)}`,
|
||||
);
|
||||
} else {
|
||||
router.push("/boards");
|
||||
}
|
||||
router.push("/boards");
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
@@ -129,7 +110,7 @@ export default function WorkspaceNameView() {
|
||||
const handleContinue = async () => {
|
||||
if (!name.trim()) return;
|
||||
|
||||
if (effectivePlan === "solo" || isLicenseFlow) {
|
||||
if (effectivePlan === "solo") {
|
||||
createWorkspace.mutate({
|
||||
name: name.trim(),
|
||||
...(description.trim() && { description: description.trim() }),
|
||||
@@ -212,52 +193,50 @@ export default function WorkspaceNameView() {
|
||||
maxLength={64}
|
||||
/>
|
||||
|
||||
{!isLicenseFlow && (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
placeholder={t`your-workspace`}
|
||||
value={isProToggle ? slug : t`your-workspace`}
|
||||
onChange={(e) => {
|
||||
setSlugManuallyEdited(true);
|
||||
setSlug(
|
||||
e.target.value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.slice(0, 60),
|
||||
);
|
||||
}}
|
||||
disabled={!isProToggle}
|
||||
prefix="kan.bn/"
|
||||
className={
|
||||
!isProToggle ? "cursor-not-allowed opacity-50" : ""
|
||||
}
|
||||
errorMessage={slugError}
|
||||
iconRight={
|
||||
!isProToggle ? (
|
||||
<Tooltip
|
||||
content={
|
||||
<span className="text-xs">{t`Custom usernames require upgrading to a Pro plan`}</span>
|
||||
}
|
||||
placement="top"
|
||||
delay={0}
|
||||
>
|
||||
<HiInformationCircle className="h-4 w-4 leading-[0] text-dark-700 dark:text-dark-700" />
|
||||
</Tooltip>
|
||||
) : isProToggle && slug.length >= 3 ? (
|
||||
isTyping || slugAvailability.isPending ? (
|
||||
<LoadingSpinner />
|
||||
) : isSlugAvailable ? (
|
||||
<HiCheck className="h-4 w-4 text-white" />
|
||||
) : null
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
placeholder={t`your-workspace`}
|
||||
value={isProToggle ? slug : t`your-workspace`}
|
||||
onChange={(e) => {
|
||||
setSlugManuallyEdited(true);
|
||||
setSlug(
|
||||
e.target.value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.slice(0, 60),
|
||||
);
|
||||
}}
|
||||
disabled={!isProToggle}
|
||||
prefix="kan.bn/"
|
||||
className={
|
||||
!isProToggle ? "cursor-not-allowed opacity-50" : ""
|
||||
}
|
||||
errorMessage={slugError}
|
||||
iconRight={
|
||||
!isProToggle ? (
|
||||
<Tooltip
|
||||
content={
|
||||
<span className="text-xs">{t`Custom usernames require upgrading to a Pro plan`}</span>
|
||||
}
|
||||
placement="top"
|
||||
delay={0}
|
||||
>
|
||||
<HiInformationCircle className="h-4 w-4 leading-[0] text-dark-700 dark:text-dark-700" />
|
||||
</Tooltip>
|
||||
) : isProToggle && slug.length >= 3 ? (
|
||||
isTyping || slugAvailability.isPending ? (
|
||||
<LoadingSpinner />
|
||||
) : isSlugAvailable ? (
|
||||
<HiCheck className="h-4 w-4 text-white" />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isLicenseFlow && plan !== "pro" && (
|
||||
{plan !== "pro" && (
|
||||
<div className="pb-2">
|
||||
<Toggle
|
||||
isChecked={isProToggle}
|
||||
@@ -286,18 +265,16 @@ export default function WorkspaceNameView() {
|
||||
|
||||
<div className="mt-6 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="ml-auto flex gap-2">
|
||||
{!isLicenseFlow && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
router.replace(
|
||||
`/onboarding/select-plan?plan=${effectivePlan}&billing=${billing}&returnUrl=${encodeURIComponent(returnUrl)}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
{t`Back`}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
router.replace(
|
||||
`/onboarding/select-plan?plan=${effectivePlan}&billing=${billing}&returnUrl=${encodeURIComponent(returnUrl)}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
{t`Back`}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void handleContinue()}
|
||||
disabled={
|
||||
|
||||
@@ -95,28 +95,8 @@ export function CardModal({
|
||||
<div className="flex h-full w-full flex-col overflow-hidden">
|
||||
<div className="h-full p-8">
|
||||
<div className="mb-6">
|
||||
<div className="flex w-full items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
{isLoading ? (
|
||||
<div className="flex space-x-2">
|
||||
<div className="h-[2.3rem] w-[300px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{data?.cardNumber != null &&
|
||||
data.list.board.workspace.cardPrefix && (
|
||||
<span className="mb-1 block text-xs font-medium text-light-700 dark:text-dark-800">
|
||||
{data.list.board.workspace.cardPrefix}-
|
||||
{data.cardNumber}
|
||||
</span>
|
||||
)}
|
||||
<h1 className="font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
|
||||
{data?.title}
|
||||
</h1>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 items-center gap-1">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="absolute right-[2rem] top-[2rem] flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyCardLink}
|
||||
@@ -154,6 +134,17 @@ export function CardModal({
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="flex space-x-2">
|
||||
<div className="h-[2.3rem] w-[300px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h1 className="pr-8 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
|
||||
{data?.title}
|
||||
</h1>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{labels.length > 0 && (
|
||||
<div className="mt-2">
|
||||
|
||||
@@ -165,7 +165,7 @@ export default function PublicBoardView() {
|
||||
<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 snap-x snap-mandatory scroll-pl-[10px] 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 md:snap-none"
|
||||
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">
|
||||
@@ -188,11 +188,11 @@ export default function PublicBoardView() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex">
|
||||
<div className="min-w-[10px] md:min-w-[2rem]" />
|
||||
<div className="min-w-[2rem]" />
|
||||
{data?.lists.map((list) => (
|
||||
<div
|
||||
key={list.publicId}
|
||||
className="dark-text-dark-1000 mr-5 h-fit min-w-[18rem] max-w-[18rem] snap-start rounded-md border border-light-400 bg-light-300 py-2 pl-2 pr-1 text-neutral-900 dark:border-dark-300 dark:bg-dark-100 md:snap-align-none"
|
||||
className="dark-text-dark-1000 mr-5 h-fit min-w-[18rem] max-w-[18rem] rounded-md border border-light-400 bg-light-300 py-2 pl-2 pr-1 text-neutral-900 dark:border-dark-300 dark:bg-dark-100"
|
||||
>
|
||||
<div className="flex justify-between">
|
||||
<span className="mb-4 block px-4 pt-1 text-sm font-medium text-neutral-900 dark:text-dark-1000">
|
||||
@@ -234,7 +234,7 @@ export default function PublicBoardView() {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="min-w-[calc(100vw-18rem)] md:min-w-[0.75rem]" />
|
||||
<div className="min-w-[0.75rem]" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -85,19 +85,17 @@ export default function AccountSettings() {
|
||||
{isCredentialsEnabled && (
|
||||
<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">
|
||||
{data?.hasPassword ? t`Change Password` : t`Set Password`}
|
||||
{t`Change Password`}
|
||||
</h2>
|
||||
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
|
||||
{data?.hasPassword
|
||||
? t`You are about to change your password.`
|
||||
: t`Set a password to enable password-based login.`}
|
||||
{t`You are about to change your password.`}
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => openModal("CHANGE_PASSWORD")}
|
||||
>
|
||||
{data?.hasPassword ? t`Change Password` : t`Set Password`}
|
||||
{t`Change Password`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -115,7 +113,7 @@ export default function AccountSettings() {
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "CHANGE_PASSWORD"}
|
||||
>
|
||||
<ChangePasswordFormConfirmation hasPassword={data?.hasPassword ?? false} />
|
||||
<ChangePasswordFormConfirmation />
|
||||
</Modal>
|
||||
|
||||
{/* Global modals */}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { env } from "next-runtime-env";
|
||||
import { HiMiniArrowTopRightOnSquare } from "react-icons/hi2";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
@@ -9,49 +7,24 @@ import Modal from "~/components/modal";
|
||||
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
export default function BillingSettings() {
|
||||
const { modalContentType, isOpen } = useModal();
|
||||
const router = useRouter();
|
||||
const isCloud = env("NEXT_PUBLIC_KAN_ENV") === "cloud";
|
||||
const { workspace } = useWorkspace();
|
||||
|
||||
const { data: workspaceData } = api.workspace.byId.useQuery(
|
||||
{ workspacePublicId: workspace.publicId },
|
||||
{ enabled: !!workspace.publicId && workspace.publicId.length >= 12 },
|
||||
);
|
||||
|
||||
const subscription = workspaceData?.subscriptions.find((s) =>
|
||||
["active", "trialing", "past_due"].includes(s.status),
|
||||
);
|
||||
|
||||
const planLabel = (() => {
|
||||
if (!subscription) return t`Free (1 member)`;
|
||||
if (subscription.unlimitedSeats) {
|
||||
const name =
|
||||
subscription.plan.charAt(0).toUpperCase() + subscription.plan.slice(1);
|
||||
return `${name} (${t`unlimited members`})`;
|
||||
}
|
||||
if (subscription.seats != null) {
|
||||
const name =
|
||||
subscription.plan.charAt(0).toUpperCase() + subscription.plan.slice(1);
|
||||
return `${name} (${subscription.seats} ${subscription.seats === 1 ? t`member` : t`members`})`;
|
||||
}
|
||||
return (
|
||||
subscription.plan.charAt(0).toUpperCase() + subscription.plan.slice(1)
|
||||
);
|
||||
})();
|
||||
|
||||
const handleOpenBillingPortal = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/stripe/create_billing_session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const { url } = (await response.json()) as { url: string };
|
||||
if (url) window.location.href = url;
|
||||
|
||||
if (url) {
|
||||
window.location.href = url;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error creating billing session:", error);
|
||||
}
|
||||
@@ -61,28 +34,6 @@ export default function BillingSettings() {
|
||||
<>
|
||||
<PageHead title={t`Settings | Billing`} />
|
||||
|
||||
<div className="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`Plan`}
|
||||
</h2>
|
||||
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
|
||||
{planLabel}
|
||||
</p>
|
||||
{!subscription && isCloud && (
|
||||
<div className="mb-8">
|
||||
<Button
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/upgrade/select-plan?plan=pro&workspacePublicId=${workspace.publicId}&returnUrl=${encodeURIComponent("/settings/billing")}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
{t`Choose plan`}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</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`Billing`}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useEffect, useState } from "react";
|
||||
import { HiBolt } from "react-icons/hi2";
|
||||
|
||||
import type { Subscription } from "@kan/shared/utils";
|
||||
import { hasActiveSubscription } from "@kan/shared/utils";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import FeedbackModal from "~/components/FeedbackModal";
|
||||
@@ -12,21 +17,43 @@ import { useModal } from "~/providers/modal";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
import { DeleteWorkspaceConfirmation } from "./components/DeleteWorkspaceConfirmation";
|
||||
import UpdateWeekStartDayForm from "./components/UpdateWeekStartDayForm";
|
||||
import UpdateWorkspaceDescriptionForm from "./components/UpdateWorkspaceDescriptionForm";
|
||||
import UpdateWorkspaceEmailVisibilityForm from "./components/UpdateWorkspaceEmailVisibilityForm";
|
||||
import UpdateWorkspaceNameForm from "./components/UpdateWorkspaceNameForm";
|
||||
import UpdateWorkspaceUrlForm from "./components/UpdateWorkspaceUrlForm";
|
||||
import UpdateWeekStartDayForm from "./components/UpdateWeekStartDayForm";
|
||||
import { UpgradeToProConfirmation } from "./components/UpgradeToProConfirmation";
|
||||
|
||||
export default function WorkspaceSettings() {
|
||||
const { modalContentType, openModal, isOpen } = useModal();
|
||||
const { workspace } = useWorkspace();
|
||||
const { canEditWorkspace } = usePermissions();
|
||||
const router = useRouter();
|
||||
const { data } = api.user.getUser.useQuery();
|
||||
const [hasOpenedUpgradeModal, setHasOpenedUpgradeModal] = useState(false);
|
||||
|
||||
const { data: workspaceData } = api.workspace.byId.useQuery(
|
||||
{ workspacePublicId: workspace.publicId },
|
||||
{ enabled: !!workspace.publicId && workspace.publicId.length >= 12 },
|
||||
);
|
||||
|
||||
const subscriptions = workspaceData?.subscriptions as
|
||||
| Subscription[]
|
||||
| undefined;
|
||||
|
||||
// Open upgrade modal if upgrade=pro is in URL params
|
||||
useEffect(() => {
|
||||
if (
|
||||
router.query.upgrade === "pro" &&
|
||||
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||
!hasActiveSubscription(subscriptions, "pro") &&
|
||||
!hasOpenedUpgradeModal
|
||||
) {
|
||||
openModal("UPGRADE_TO_PRO");
|
||||
setHasOpenedUpgradeModal(true);
|
||||
}
|
||||
}, [router.query.upgrade, subscriptions, openModal, hasOpenedUpgradeModal]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={t`Settings | Workspace`} />
|
||||
@@ -80,6 +107,19 @@ export default function WorkspaceSettings() {
|
||||
disabled={!canEditWorkspace}
|
||||
/>
|
||||
|
||||
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||
!hasActiveSubscription(subscriptions, "pro") &&
|
||||
!hasActiveSubscription(subscriptions, "team") && (
|
||||
<div className="my-8">
|
||||
<Button
|
||||
onClick={() => openModal("UPGRADE_TO_PRO")}
|
||||
iconRight={<HiBolt />}
|
||||
>
|
||||
{t`Upgrade to Pro`}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="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`Delete workspace`}
|
||||
@@ -106,6 +146,16 @@ export default function WorkspaceSettings() {
|
||||
>
|
||||
<DeleteWorkspaceConfirmation />
|
||||
</Modal>
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "UPGRADE_TO_PRO"}
|
||||
>
|
||||
<UpgradeToProConfirmation
|
||||
userId={data?.id ?? ""}
|
||||
workspacePublicId={workspace.publicId}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
{/* Global modals */}
|
||||
<Modal
|
||||
modalSize="md"
|
||||
|
||||
@@ -172,7 +172,7 @@ export default function Avatar({
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": blob.type,
|
||||
"x-original-filename": encodeURIComponent(fileName),
|
||||
"x-original-filename": fileName,
|
||||
},
|
||||
body: blob,
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useRouter } from "next/navigation";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -13,43 +14,27 @@ import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
const buildSchema = (hasPassword: boolean) => {
|
||||
const base = z.object({
|
||||
currentPassword: hasPassword
|
||||
? z.string().min(1, t`Current password is required`)
|
||||
: z.string().optional(),
|
||||
const FormSchema = z
|
||||
.object({
|
||||
currentPassword: z.string().min(1, t`Current password is required`),
|
||||
newPassword: z
|
||||
.string()
|
||||
.min(8, t`Password must be at least 8 characters`)
|
||||
.min(1, t`New password is required`),
|
||||
confirmPassword: z.string().min(1, t`Please confirm your new password`),
|
||||
})
|
||||
.refine((data) => data.newPassword === data.confirmPassword, {
|
||||
message: t`Passwords do not match`,
|
||||
path: ["confirmPassword"],
|
||||
})
|
||||
.refine((data) => data.currentPassword !== data.newPassword, {
|
||||
message: t`New password must be different from current password`,
|
||||
path: ["newPassword"],
|
||||
});
|
||||
|
||||
return base
|
||||
.refine((data) => data.newPassword === data.confirmPassword, {
|
||||
message: t`Passwords do not match`,
|
||||
path: ["confirmPassword"],
|
||||
})
|
||||
.refine(
|
||||
(data) => !hasPassword || data.currentPassword !== data.newPassword,
|
||||
{
|
||||
message: t`New password must be different from current password`,
|
||||
path: ["newPassword"],
|
||||
},
|
||||
);
|
||||
};
|
||||
type FormValues = z.infer<typeof FormSchema>;
|
||||
|
||||
interface FormValues {
|
||||
currentPassword?: string;
|
||||
newPassword: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
hasPassword: boolean;
|
||||
}
|
||||
|
||||
export function ChangePasswordFormConfirmation({ hasPassword }: Props) {
|
||||
export function ChangePasswordFormConfirmation() {
|
||||
const { closeModal } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
const router = useRouter();
|
||||
@@ -62,24 +47,15 @@ export function ChangePasswordFormConfirmation({ hasPassword }: Props) {
|
||||
reset,
|
||||
setError,
|
||||
} = useForm<FormValues>({
|
||||
resolver: zodResolver(buildSchema(hasPassword)),
|
||||
resolver: zodResolver(FormSchema),
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const setPasswordMutation = api.user.setPassword.useMutation();
|
||||
|
||||
const changePasswordMutation = useMutation({
|
||||
mutationFn: async (data: FormValues) => {
|
||||
if (!hasPassword) {
|
||||
await setPasswordMutation.mutateAsync({
|
||||
newPassword: data.newPassword,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await authClient.changePassword({
|
||||
newPassword: data.newPassword,
|
||||
currentPassword: data.currentPassword ?? "",
|
||||
currentPassword: data.currentPassword,
|
||||
revokeOtherSessions: true,
|
||||
});
|
||||
|
||||
@@ -90,18 +66,12 @@ export function ChangePasswordFormConfirmation({ hasPassword }: Props) {
|
||||
onSuccess: async () => {
|
||||
closeModal();
|
||||
showPopup({
|
||||
header: hasPassword ? t`Password Changed` : t`Password Set`,
|
||||
message: hasPassword
|
||||
? t`Your password has been changed.`
|
||||
: t`Your password has been set.`,
|
||||
header: t`Password Changed`,
|
||||
message: t`Your password has been changed.`,
|
||||
icon: "success",
|
||||
});
|
||||
|
||||
if (!hasPassword) {
|
||||
sessionStorage.removeItem("set_password_prompted");
|
||||
}
|
||||
|
||||
utils.user.getUser.invalidate();
|
||||
utils.invalidate();
|
||||
reset();
|
||||
router.push("/");
|
||||
},
|
||||
@@ -116,9 +86,7 @@ export function ChangePasswordFormConfirmation({ hasPassword }: Props) {
|
||||
} else {
|
||||
closeModal();
|
||||
showPopup({
|
||||
header: hasPassword
|
||||
? t`Error Changing Password`
|
||||
: t`Error Setting Password`,
|
||||
header: t`Error Changing Password`,
|
||||
message: t`An unexpected error occurred. Please try again later.`,
|
||||
icon: "error",
|
||||
});
|
||||
@@ -138,33 +106,27 @@ export function ChangePasswordFormConfirmation({ hasPassword }: Props) {
|
||||
return (
|
||||
<div className="p-5">
|
||||
<div className="flex w-full flex-col justify-between pb-4">
|
||||
<h2 className="pb-4 text-base font-medium dark:text-white">
|
||||
{hasPassword ? t`Change Password` : t`Set Password`}
|
||||
</h2>
|
||||
<h2 className="text-md pb-4 font-medium dark:text-white">{t`Change Password`}</h2>
|
||||
<p className="mb-4 text-sm text-light-900">
|
||||
{hasPassword
|
||||
? t`Enter your current password and choose a new secure password.`
|
||||
: t`You signed in without a password. Set a password to enable password-based login.`}
|
||||
{t`Enter your current password and choose a new secure password.`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="space-y-2">
|
||||
{hasPassword && (
|
||||
<div>
|
||||
<Input
|
||||
id="currentPassword"
|
||||
type="password"
|
||||
{...register("currentPassword")}
|
||||
placeholder={t`Enter your current password`}
|
||||
/>
|
||||
{errors.currentPassword && (
|
||||
<p className="mt-2 text-xs text-red-400">
|
||||
{errors.currentPassword.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Input
|
||||
id="currentPassword"
|
||||
type="password"
|
||||
{...register("currentPassword")}
|
||||
placeholder={t`Enter your current password`}
|
||||
/>
|
||||
{errors.currentPassword && (
|
||||
<p className="mt-2 text-xs text-red-400">
|
||||
{errors.currentPassword.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Input
|
||||
@@ -213,7 +175,7 @@ export function ChangePasswordFormConfirmation({ hasPassword }: Props) {
|
||||
fullWidth
|
||||
size="lg"
|
||||
>
|
||||
{hasPassword ? t`Change Password` : t`Set Password`}
|
||||
{t`Change Password`}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -13,29 +13,20 @@ interface DeleteWebhookConfirmationProps {
|
||||
export function DeleteWebhookConfirmation({
|
||||
workspacePublicId,
|
||||
}: DeleteWebhookConfirmationProps) {
|
||||
const {
|
||||
closeModal,
|
||||
entityId: webhookPublicId,
|
||||
entityLabel: webhookName,
|
||||
} = useModal();
|
||||
const { closeModal, entityId: webhookPublicId, entityLabel: webhookName } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const deleteWebhookMutation = api.webhook.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.webhook.list.invalidate({ workspacePublicId });
|
||||
showPopup({
|
||||
header: t`Webhook deleted`,
|
||||
message: t`Webhook deleted successfully`,
|
||||
icon: "success",
|
||||
});
|
||||
showPopup({ message: t`Webhook deleted successfully`, type: "success" });
|
||||
closeModal();
|
||||
},
|
||||
onError: (error) => {
|
||||
showPopup({
|
||||
header: t`Unable to delete webhook`,
|
||||
message: error.message || t`Failed to delete webhook`,
|
||||
icon: "error",
|
||||
type: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -44,7 +35,7 @@ export function DeleteWebhookConfirmation({
|
||||
if (!webhookPublicId) return;
|
||||
deleteWebhookMutation.mutate({
|
||||
workspacePublicId,
|
||||
webhookPublicId: webhookPublicId,
|
||||
webhookPublicId: webhookPublicId as string,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -44,12 +44,7 @@ export function NewWebhookModal({
|
||||
workspacePublicId,
|
||||
isEdit = false,
|
||||
}: NewWebhookModalProps) {
|
||||
const {
|
||||
closeModal,
|
||||
entityId: webhookPublicId,
|
||||
getModalState,
|
||||
clearModalState,
|
||||
} = useModal();
|
||||
const { closeModal, entityId: webhookPublicId, getModalState, clearModalState } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
const [isTestingWebhook, setIsTestingWebhook] = useState(false);
|
||||
|
||||
@@ -106,18 +101,13 @@ export function NewWebhookModal({
|
||||
const createWebhookMutation = api.webhook.create.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.webhook.list.invalidate({ workspacePublicId });
|
||||
showPopup({
|
||||
header: t`Webhook created`,
|
||||
message: t`Webhook created successfully`,
|
||||
icon: "success",
|
||||
});
|
||||
showPopup({ message: t`Webhook created successfully`, type: "success" });
|
||||
closeModal();
|
||||
},
|
||||
onError: (error) => {
|
||||
showPopup({
|
||||
header: t`Unable to create webhook`,
|
||||
message: error.message || t`Failed to create webhook`,
|
||||
icon: "error",
|
||||
type: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -125,18 +115,13 @@ export function NewWebhookModal({
|
||||
const updateWebhookMutation = api.webhook.update.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.webhook.list.invalidate({ workspacePublicId });
|
||||
showPopup({
|
||||
header: t`Webhook updated`,
|
||||
message: t`Webhook updated successfully`,
|
||||
icon: "success",
|
||||
});
|
||||
showPopup({ message: t`Webhook updated successfully`, type: "success" });
|
||||
closeModal();
|
||||
},
|
||||
onError: (error) => {
|
||||
showPopup({
|
||||
header: t`Unable to update webhook`,
|
||||
message: error.message || t`Failed to update webhook`,
|
||||
icon: "error",
|
||||
type: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -144,25 +129,19 @@ export function NewWebhookModal({
|
||||
const testWebhookMutation = api.webhook.test.useMutation({
|
||||
onSuccess: (result) => {
|
||||
if (result.success) {
|
||||
showPopup({
|
||||
header: t`Test sent`,
|
||||
message: t`Test webhook sent successfully!`,
|
||||
icon: "success",
|
||||
});
|
||||
showPopup({ message: t`Test webhook sent successfully!`, type: "success" });
|
||||
} else {
|
||||
showPopup({
|
||||
header: t`Test failed`,
|
||||
message: result.error || t`Webhook test failed`,
|
||||
icon: "error",
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
setIsTestingWebhook(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
showPopup({
|
||||
header: t`Unable to test webhook`,
|
||||
message: error.message || t`Failed to test webhook`,
|
||||
icon: "error",
|
||||
type: "error",
|
||||
});
|
||||
setIsTestingWebhook(false);
|
||||
},
|
||||
@@ -172,7 +151,7 @@ export function NewWebhookModal({
|
||||
if (isEdit && webhookPublicId) {
|
||||
updateWebhookMutation.mutate({
|
||||
workspacePublicId,
|
||||
webhookPublicId: webhookPublicId,
|
||||
webhookPublicId: webhookPublicId as string,
|
||||
name: data.name,
|
||||
url: data.url,
|
||||
secret: data.secret || undefined,
|
||||
@@ -195,12 +174,11 @@ export function NewWebhookModal({
|
||||
setIsTestingWebhook(true);
|
||||
testWebhookMutation.mutate({
|
||||
workspacePublicId,
|
||||
webhookPublicId: webhookPublicId,
|
||||
webhookPublicId: webhookPublicId as string,
|
||||
});
|
||||
};
|
||||
|
||||
const isPending =
|
||||
createWebhookMutation.isPending || updateWebhookMutation.isPending;
|
||||
const isPending = createWebhookMutation.isPending || updateWebhookMutation.isPending;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
@@ -253,11 +231,7 @@ export function NewWebhookModal({
|
||||
<Input
|
||||
id="secret"
|
||||
type="password"
|
||||
placeholder={
|
||||
isEdit
|
||||
? t`Enter new secret to update`
|
||||
: t`HMAC secret for signature verification`
|
||||
}
|
||||
placeholder={isEdit ? t`Enter new secret to update` : t`HMAC secret for signature verification`}
|
||||
{...register("secret")}
|
||||
errorMessage={errors.secret?.message}
|
||||
/>
|
||||
@@ -278,7 +252,7 @@ export function NewWebhookModal({
|
||||
{webhookEvents.map((event) => (
|
||||
<label
|
||||
key={event}
|
||||
className="flex cursor-pointer items-center space-x-2"
|
||||
className="flex items-center space-x-2 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -288,11 +262,11 @@ export function NewWebhookModal({
|
||||
field.onChange([...field.value, event]);
|
||||
} else {
|
||||
field.onChange(
|
||||
field.value.filter((v) => v !== event),
|
||||
field.value.filter((v) => v !== event)
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="text-primary-600 focus:ring-primary-500 h-4 w-4 rounded border-light-400 dark:border-dark-400"
|
||||
className="h-4 w-4 rounded border-light-400 text-primary-600 focus:ring-primary-500 dark:border-dark-400"
|
||||
/>
|
||||
<span className="text-sm text-light-900 dark:text-dark-900">
|
||||
{event}
|
||||
@@ -303,19 +277,17 @@ export function NewWebhookModal({
|
||||
)}
|
||||
/>
|
||||
{errors.events && (
|
||||
<p className="mt-1 text-xs text-red-500">
|
||||
{errors.events.message}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-red-500">{errors.events.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEdit && (
|
||||
<div>
|
||||
<label className="flex cursor-pointer items-center space-x-2">
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
{...register("active")}
|
||||
className="text-primary-600 focus:ring-primary-500 h-4 w-4 rounded border-light-400 dark:border-dark-400"
|
||||
className="h-4 w-4 rounded border-light-400 text-primary-600 focus:ring-primary-500 dark:border-dark-400"
|
||||
/>
|
||||
<span className="text-sm text-light-900 dark:text-dark-900">
|
||||
{t`Active`}
|
||||
|
||||
@@ -36,7 +36,7 @@ export default function UpdateWeekStartDayForm({
|
||||
setValue(newValue);
|
||||
updateWorkspace.mutate({
|
||||
workspacePublicId,
|
||||
weekStartDay: newValue as 0 | 1 | 6,
|
||||
weekStartDay: newValue,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ const UpdateWorkspaceUrlForm = ({
|
||||
}: {
|
||||
workspacePublicId: string;
|
||||
workspaceUrl: string;
|
||||
workspacePlan: "free" | "team" | "pro" | "enterprise";
|
||||
workspacePlan: "free" | "pro" | "enterprise";
|
||||
disabled?: boolean;
|
||||
}) => {
|
||||
const utils = api.useUtils();
|
||||
|
||||
@@ -171,20 +171,18 @@ export default function WebhookList({ workspacePublicId }: WebhookListProps) {
|
||||
const testWebhookMutation = api.webhook.test.useMutation({
|
||||
onSuccess: (result) => {
|
||||
if (result.success) {
|
||||
showPopup({ header: t`Test sent`, message: t`Test webhook sent successfully!`, icon: "success" });
|
||||
showPopup({ message: t`Test webhook sent successfully!`, type: "success" });
|
||||
} else {
|
||||
showPopup({
|
||||
header: t`Test failed`,
|
||||
message: result.error || t`Webhook test failed`,
|
||||
icon: "error",
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
showPopup({
|
||||
header: t`Unable to test webhook`,
|
||||
message: error.message || t`Failed to test webhook`,
|
||||
icon: "error",
|
||||
type: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -61,9 +61,9 @@ services:
|
||||
- SMTP_REJECT_UNAUTHORIZED=${SMTP_REJECT_UNAUTHORIZED}
|
||||
|
||||
# Notifications
|
||||
- SUBSCRIBER_API_URL=${SUBSCRIBER_API_URL}
|
||||
- SUBSCRIBER_API_KEY=${SUBSCRIBER_API_KEY}
|
||||
- SUBSCRIBER_ENVIRONMENT_ID=${SUBSCRIBER_ENVIRONMENT_ID}
|
||||
- NOVU_API_KEY=${NOVU_API_KEY}
|
||||
- DISCORD_WEBHOOK_URL=${DISCORD_WEBHOOK_URL}
|
||||
- EMAIL_UNSUBSCRIBE_SECRET=${EMAIL_UNSUBSCRIBE_SECRET}
|
||||
|
||||
# S3 storage
|
||||
- S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID}
|
||||
@@ -99,19 +99,6 @@ services:
|
||||
- NEXT_PUBLIC_UMAMI_ID=${NEXT_PUBLIC_UMAMI_ID}
|
||||
- NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY}
|
||||
- NEXT_PUBLIC_POSTHOG_HOST=${NEXT_PUBLIC_POSTHOG_HOST}
|
||||
|
||||
# Partner config (optional)
|
||||
- NEXT_PUBLIC_PARTNER_NAME=${NEXT_PUBLIC_PARTNER_NAME}
|
||||
- PARTNER_CLIENT_ID=${PARTNER_CLIENT_ID}
|
||||
- PARTNER_CLIENT_SECRET=${PARTNER_CLIENT_SECRET}
|
||||
- PARTNER_REDIRECT_URL=${PARTNER_REDIRECT_URL}
|
||||
- PARTNER_TOKEN_URL=${PARTNER_TOKEN_URL}
|
||||
- PARTNER_OAUTH_LICENSE_URL=${PARTNER_OAUTH_LICENSE_URL}
|
||||
- PARTNER_LICENSE_API_URL=${PARTNER_LICENSE_API_URL}
|
||||
- PARTNER_API_KEY=${PARTNER_API_KEY}
|
||||
- PARTNER_API_KEY_HEADER=${PARTNER_API_KEY_HEADER}
|
||||
- PARTNER_SIGNATURE_HEADER=${PARTNER_SIGNATURE_HEADER}
|
||||
- PARTNER_TIMESTAMP_HEADER=${PARTNER_TIMESTAMP_HEADER}
|
||||
depends_on:
|
||||
migrator:
|
||||
condition: service_completed_successfully
|
||||
|
||||
@@ -61,7 +61,7 @@ services:
|
||||
- S3_ENDPOINT=${S3_ENDPOINT}
|
||||
- S3_FORCE_PATH_STYLE=${S3_FORCE_PATH_STYLE}
|
||||
- S3_AVATAR_UPLOAD_LIMIT=${S3_AVATAR_UPLOAD_LIMIT}
|
||||
- NEXT_PUBLIC_STORAGE_URL
|
||||
- NEXT_PUBLIC_STORAGE_URL=${NEXT_PUBLIC_STORAGE_URL}
|
||||
- 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}
|
||||
|
||||
@@ -5,40 +5,36 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./src/index.ts"
|
||||
},
|
||||
"./root": {
|
||||
"types": "./src/root.ts",
|
||||
"types": "./dist/root.d.ts",
|
||||
"default": "./src/root.ts"
|
||||
},
|
||||
"./trpc": {
|
||||
"types": "./src/trpc.ts",
|
||||
"types": "./dist/trpc.d.ts",
|
||||
"default": "./src/trpc.ts"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./src/types/router.types.ts",
|
||||
"types": "./dist/types.d.ts",
|
||||
"default": "./src/types/router.types.ts"
|
||||
},
|
||||
"./openapi": {
|
||||
"types": "./src/openapi.ts",
|
||||
"types": "./dist/openapi.d.ts",
|
||||
"default": "./src/openapi.ts"
|
||||
},
|
||||
"./utils/rateLimit": {
|
||||
"types": "./src/utils/rateLimit.ts",
|
||||
"types": "./dist/utils/rateLimit.d.ts",
|
||||
"default": "./src/utils/rateLimit.ts"
|
||||
},
|
||||
"./utils/apiLogging": {
|
||||
"types": "./src/utils/apiLogging.ts",
|
||||
"types": "./dist/utils/apiLogging.d.ts",
|
||||
"default": "./src/utils/apiLogging.ts"
|
||||
},
|
||||
"./utils/permissions": {
|
||||
"types": "./src/utils/permissions.ts",
|
||||
"types": "./dist/utils/permissions.d.ts",
|
||||
"default": "./src/utils/permissions.ts"
|
||||
},
|
||||
"./utils/workspace": {
|
||||
"types": "./src/utils/workspace.ts",
|
||||
"default": "./src/utils/workspace.ts"
|
||||
}
|
||||
},
|
||||
"license": "GPL-3.0",
|
||||
|
||||
@@ -8,7 +8,6 @@ import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import { generateUID } from "@kan/shared/utils";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import { attachmentConfirmResponseSchema } from "../schemas";
|
||||
import { assertPermission } from "../utils/permissions";
|
||||
import { deleteObject, generateUploadUrl } from "@kan/shared/utils";
|
||||
|
||||
@@ -111,7 +110,7 @@ export const attachmentRouter = createTRPCRouter({
|
||||
size: z.number().positive(),
|
||||
}),
|
||||
)
|
||||
.output(attachmentConfirmResponseSchema)
|
||||
.output(z.custom<Awaited<ReturnType<typeof cardAttachmentRepo.create>>>())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
|
||||
@@ -1,268 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
// Mock all imports used by board.ts before importing the router
|
||||
vi.mock("@kan/db/repository/board.repo", () => ({
|
||||
getBoardForMove: vi.fn(),
|
||||
isBoardSlugAvailable: vi.fn(),
|
||||
moveToWorkspace: vi.fn(),
|
||||
getIdByPublicId: vi.fn(),
|
||||
getByPublicId: vi.fn(),
|
||||
getWithListIdsByPublicId: vi.fn(),
|
||||
getWithLatestListIndexByPublicId: vi.fn(),
|
||||
getWorkspaceAndBoardIdByBoardPublicId: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updatePositions: vi.fn(),
|
||||
archive: vi.fn(),
|
||||
deleteBoard: vi.fn(),
|
||||
getAllByWorkspaceId: vi.fn(),
|
||||
createFavorite: vi.fn(),
|
||||
deleteFavorite: vi.fn(),
|
||||
getFavorite: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@kan/db/repository/workspace.repo", () => ({
|
||||
getByPublicId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@kan/db/repository/card.repo", () => ({
|
||||
getByPublicId: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@kan/db/repository/cardActivity.repo", () => ({
|
||||
create: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@kan/db/repository/label.repo", () => ({
|
||||
create: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
getByPublicId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@kan/db/repository/list.repo", () => ({
|
||||
create: vi.fn(),
|
||||
getByPublicId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../utils/permissions", () => ({
|
||||
assertCanEdit: vi.fn(),
|
||||
assertCanDelete: vi.fn(),
|
||||
assertPermission: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@kan/shared/utils", () => ({
|
||||
generateSlug: vi.fn((name: string) => name.toLowerCase().replace(/\s+/g, "-")),
|
||||
generateUID: vi.fn(() => "abc123"),
|
||||
generateAvatarUrl: vi.fn(),
|
||||
convertDueDateFiltersToRanges: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@kan/shared/constants", () => ({
|
||||
colours: [],
|
||||
}));
|
||||
|
||||
import * as boardRepo from "@kan/db/repository/board.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import { assertCanEdit, assertPermission } from "../utils/permissions";
|
||||
|
||||
const mockGetBoardForMove = boardRepo.getBoardForMove as ReturnType<typeof vi.fn>;
|
||||
const mockIsBoardSlugAvailable = boardRepo.isBoardSlugAvailable as ReturnType<typeof vi.fn>;
|
||||
const mockMoveToWorkspace = boardRepo.moveToWorkspace as ReturnType<typeof vi.fn>;
|
||||
const mockWorkspaceGetByPublicId = workspaceRepo.getByPublicId as ReturnType<typeof vi.fn>;
|
||||
const mockAssertCanEdit = assertCanEdit as ReturnType<typeof vi.fn>;
|
||||
const mockAssertPermission = assertPermission as ReturnType<typeof vi.fn>;
|
||||
|
||||
describe("board.move", () => {
|
||||
const mockDb = {} as never;
|
||||
const mockUser = { id: "user-123", name: "Test User", email: "test@example.com" };
|
||||
const mockInput = {
|
||||
boardPublicId: "brd-123456789",
|
||||
targetWorkspacePublicId: "ws-target-789",
|
||||
};
|
||||
const mockBoard = {
|
||||
id: 1,
|
||||
name: "My Board",
|
||||
slug: "my-board",
|
||||
type: "board" as const,
|
||||
isArchived: false,
|
||||
workspaceId: 10,
|
||||
createdBy: "user-123",
|
||||
};
|
||||
const mockTargetWorkspace = { id: 20, publicId: "ws-target-789" };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockAssertCanEdit.mockResolvedValue(undefined);
|
||||
mockAssertPermission.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("throws UNAUTHORIZED when user is not authenticated", async () => {
|
||||
const { boardRouter } = await import("./board");
|
||||
const ctx = { user: null, db: mockDb } as never;
|
||||
|
||||
await expect(
|
||||
boardRouter.createCaller(ctx).move(mockInput),
|
||||
).rejects.toThrow(TRPCError);
|
||||
});
|
||||
|
||||
it("throws NOT_FOUND when board does not exist", async () => {
|
||||
const { boardRouter } = await import("./board");
|
||||
mockGetBoardForMove.mockResolvedValueOnce(null);
|
||||
|
||||
const ctx = { user: mockUser, db: mockDb } as never;
|
||||
|
||||
await expect(
|
||||
boardRouter.createCaller(ctx).move(mockInput),
|
||||
).rejects.toThrow(TRPCError);
|
||||
});
|
||||
|
||||
it("throws BAD_REQUEST for template boards", async () => {
|
||||
const { boardRouter } = await import("./board");
|
||||
mockGetBoardForMove.mockResolvedValueOnce({ ...mockBoard, type: "template" });
|
||||
|
||||
const ctx = { user: mockUser, db: mockDb } as never;
|
||||
|
||||
await expect(
|
||||
boardRouter.createCaller(ctx).move(mockInput),
|
||||
).rejects.toThrow(TRPCError);
|
||||
});
|
||||
|
||||
it("throws BAD_REQUEST for archived boards", async () => {
|
||||
const { boardRouter } = await import("./board");
|
||||
mockGetBoardForMove.mockResolvedValueOnce({ ...mockBoard, isArchived: true });
|
||||
|
||||
const ctx = { user: mockUser, db: mockDb } as never;
|
||||
|
||||
await expect(
|
||||
boardRouter.createCaller(ctx).move(mockInput),
|
||||
).rejects.toThrow(TRPCError);
|
||||
});
|
||||
|
||||
it("checks board:edit permission on source workspace", async () => {
|
||||
const { boardRouter } = await import("./board");
|
||||
mockGetBoardForMove.mockResolvedValueOnce(mockBoard);
|
||||
mockAssertCanEdit.mockRejectedValueOnce(
|
||||
new TRPCError({ code: "FORBIDDEN", message: "No permission" }),
|
||||
);
|
||||
|
||||
const ctx = { user: mockUser, db: mockDb } as never;
|
||||
|
||||
await expect(
|
||||
boardRouter.createCaller(ctx).move(mockInput),
|
||||
).rejects.toThrow(TRPCError);
|
||||
|
||||
expect(mockAssertCanEdit).toHaveBeenCalledWith(
|
||||
mockDb,
|
||||
mockUser.id,
|
||||
mockBoard.workspaceId,
|
||||
"board:edit",
|
||||
mockBoard.createdBy,
|
||||
);
|
||||
});
|
||||
|
||||
it("throws NOT_FOUND when target workspace does not exist", async () => {
|
||||
const { boardRouter } = await import("./board");
|
||||
mockGetBoardForMove.mockResolvedValueOnce(mockBoard);
|
||||
mockWorkspaceGetByPublicId.mockResolvedValueOnce(null);
|
||||
|
||||
const ctx = { user: mockUser, db: mockDb } as never;
|
||||
|
||||
await expect(
|
||||
boardRouter.createCaller(ctx).move(mockInput),
|
||||
).rejects.toThrow(TRPCError);
|
||||
});
|
||||
|
||||
it("throws NOT_FOUND when target workspace is soft-deleted", async () => {
|
||||
const { boardRouter } = await import("./board");
|
||||
mockGetBoardForMove.mockResolvedValueOnce(mockBoard);
|
||||
mockWorkspaceGetByPublicId.mockResolvedValueOnce({
|
||||
...mockTargetWorkspace,
|
||||
deletedAt: new Date(),
|
||||
});
|
||||
|
||||
const ctx = { user: mockUser, db: mockDb } as never;
|
||||
|
||||
await expect(
|
||||
boardRouter.createCaller(ctx).move(mockInput),
|
||||
).rejects.toThrow(TRPCError);
|
||||
});
|
||||
|
||||
it("throws BAD_REQUEST when target is the same workspace", async () => {
|
||||
const { boardRouter } = await import("./board");
|
||||
mockGetBoardForMove.mockResolvedValueOnce(mockBoard);
|
||||
mockWorkspaceGetByPublicId.mockResolvedValueOnce({
|
||||
id: mockBoard.workspaceId,
|
||||
publicId: "ws-target-789",
|
||||
});
|
||||
|
||||
const ctx = { user: mockUser, db: mockDb } as never;
|
||||
|
||||
await expect(
|
||||
boardRouter.createCaller(ctx).move(mockInput),
|
||||
).rejects.toThrow(TRPCError);
|
||||
});
|
||||
|
||||
it("checks board:create permission on target workspace", async () => {
|
||||
const { boardRouter } = await import("./board");
|
||||
mockGetBoardForMove.mockResolvedValueOnce(mockBoard);
|
||||
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockTargetWorkspace);
|
||||
mockAssertPermission.mockRejectedValueOnce(
|
||||
new TRPCError({ code: "FORBIDDEN", message: "No permission" }),
|
||||
);
|
||||
|
||||
const ctx = { user: mockUser, db: mockDb } as never;
|
||||
|
||||
await expect(
|
||||
boardRouter.createCaller(ctx).move(mockInput),
|
||||
).rejects.toThrow(TRPCError);
|
||||
|
||||
expect(mockAssertPermission).toHaveBeenCalledWith(
|
||||
mockDb,
|
||||
mockUser.id,
|
||||
mockTargetWorkspace.id,
|
||||
"board:create",
|
||||
);
|
||||
});
|
||||
|
||||
it("appends UID suffix when slug conflicts in target workspace", async () => {
|
||||
const { boardRouter } = await import("./board");
|
||||
mockGetBoardForMove.mockResolvedValueOnce(mockBoard);
|
||||
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockTargetWorkspace);
|
||||
mockIsBoardSlugAvailable.mockResolvedValueOnce(false);
|
||||
mockMoveToWorkspace.mockResolvedValueOnce(undefined);
|
||||
|
||||
const ctx = { user: mockUser, db: mockDb } as never;
|
||||
|
||||
await boardRouter.createCaller(ctx).move(mockInput);
|
||||
|
||||
expect(mockMoveToWorkspace).toHaveBeenCalledWith(
|
||||
mockDb,
|
||||
mockBoard.id,
|
||||
mockTargetWorkspace.id,
|
||||
"my-board-abc123",
|
||||
);
|
||||
});
|
||||
|
||||
it("moves board successfully with available slug", async () => {
|
||||
const { boardRouter } = await import("./board");
|
||||
mockGetBoardForMove.mockResolvedValueOnce(mockBoard);
|
||||
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockTargetWorkspace);
|
||||
mockIsBoardSlugAvailable.mockResolvedValueOnce(true);
|
||||
mockMoveToWorkspace.mockResolvedValueOnce(undefined);
|
||||
|
||||
const ctx = { user: mockUser, db: mockDb } as never;
|
||||
|
||||
const result = await boardRouter.createCaller(ctx).move(mockInput);
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(mockMoveToWorkspace).toHaveBeenCalledWith(
|
||||
mockDb,
|
||||
mockBoard.id,
|
||||
mockTargetWorkspace.id,
|
||||
"my-board",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -16,13 +16,6 @@ import {
|
||||
} from "@kan/shared/utils";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import {
|
||||
boardListItemSchema,
|
||||
boardDetailSchema,
|
||||
boardBySlugSchema,
|
||||
boardCreateResponseSchema,
|
||||
boardUpdateResponseSchema,
|
||||
} from "../schemas";
|
||||
import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions";
|
||||
|
||||
export const boardRouter = createTRPCRouter({
|
||||
@@ -44,7 +37,9 @@ export const boardRouter = createTRPCRouter({
|
||||
archived: z.boolean().optional(),
|
||||
}),
|
||||
)
|
||||
.output(z.array(boardListItemSchema))
|
||||
.output(
|
||||
z.custom<Awaited<ReturnType<typeof boardRepo.getAllByWorkspaceId>>>(),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
@@ -111,7 +106,7 @@ export const boardRouter = createTRPCRouter({
|
||||
type: z.enum(["regular", "template"]).optional(),
|
||||
}),
|
||||
)
|
||||
.output(boardDetailSchema)
|
||||
.output(z.custom<Awaited<ReturnType<typeof boardRepo.getByPublicId>>>())
|
||||
.query(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
@@ -251,7 +246,7 @@ export const boardRouter = createTRPCRouter({
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.output(boardBySlugSchema.nullable())
|
||||
.output(z.custom<Awaited<ReturnType<typeof boardRepo.getBySlug>>>())
|
||||
.query(async ({ ctx, input }) => {
|
||||
const workspace = await workspaceRepo.getBySlugWithBoards(
|
||||
ctx.db,
|
||||
@@ -304,7 +299,7 @@ export const boardRouter = createTRPCRouter({
|
||||
sourceBoardPublicId: z.string().min(12).optional(),
|
||||
}),
|
||||
)
|
||||
.output(boardCreateResponseSchema)
|
||||
.output(z.custom<Awaited<ReturnType<typeof boardRepo.create>>>())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
@@ -474,7 +469,7 @@ export const boardRouter = createTRPCRouter({
|
||||
isArchived: z.boolean().optional(),
|
||||
}),
|
||||
)
|
||||
.output(boardUpdateResponseSchema)
|
||||
.output(z.object({ success: z.boolean() }).or(z.custom<Awaited<ReturnType<typeof boardRepo.update>>>()))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
@@ -644,119 +639,6 @@ export const boardRouter = createTRPCRouter({
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
move: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: "POST",
|
||||
path: "/boards/{boardPublicId}/move",
|
||||
summary: "Move board to another workspace",
|
||||
description:
|
||||
"Moves a board and all its contents to a different workspace",
|
||||
tags: ["Boards"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
boardPublicId: z.string().min(12),
|
||||
targetWorkspacePublicId: z.string().min(12),
|
||||
}),
|
||||
)
|
||||
.output(z.object({ success: z.boolean() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: `User not authenticated`,
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
// Get source board
|
||||
const board = await boardRepo.getBoardForMove(
|
||||
ctx.db,
|
||||
input.boardPublicId,
|
||||
);
|
||||
|
||||
if (!board)
|
||||
throw new TRPCError({
|
||||
message: `Board with public ID ${input.boardPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
if (board.type === "template")
|
||||
throw new TRPCError({
|
||||
message: `Templates cannot be moved between workspaces`,
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
|
||||
if (board.isArchived)
|
||||
throw new TRPCError({
|
||||
message: `Archived boards cannot be moved. Unarchive the board first.`,
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
|
||||
// Check permission to edit board in source workspace
|
||||
await assertCanEdit(
|
||||
ctx.db,
|
||||
userId,
|
||||
board.workspaceId,
|
||||
"board:edit",
|
||||
board.createdBy ?? null,
|
||||
);
|
||||
|
||||
// Get target workspace. workspaceRepo.getByPublicId does not yet
|
||||
// filter soft-deleted workspaces (legacy: same is true for several
|
||||
// peer callers); guard at this call site so we never move a board
|
||||
// into a tombstoned workspace. A wider fix to make the repo treat
|
||||
// deleted-as-not-found is a separate concern.
|
||||
const targetWorkspace = await workspaceRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.targetWorkspacePublicId,
|
||||
);
|
||||
|
||||
if (!targetWorkspace || targetWorkspace.deletedAt)
|
||||
throw new TRPCError({
|
||||
message: `Target workspace not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
if (targetWorkspace.id === board.workspaceId)
|
||||
throw new TRPCError({
|
||||
message: `Board is already in this workspace`,
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
|
||||
// Check permission to create boards in target workspace
|
||||
await assertPermission(
|
||||
ctx.db,
|
||||
userId,
|
||||
targetWorkspace.id,
|
||||
"board:create",
|
||||
);
|
||||
|
||||
let slug = board.slug ?? generateSlug(board.name);
|
||||
|
||||
const isSlugAvailable = await boardRepo.isBoardSlugAvailable(
|
||||
ctx.db,
|
||||
slug,
|
||||
targetWorkspace.id,
|
||||
);
|
||||
|
||||
if (!isSlugAvailable) {
|
||||
slug = `${slug}-${generateUID()}`;
|
||||
}
|
||||
|
||||
// Move the board
|
||||
await boardRepo.moveToWorkspace(
|
||||
ctx.db,
|
||||
board.id,
|
||||
targetWorkspace.id,
|
||||
slug,
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
checkSlugAvailability: publicProcedure
|
||||
|
||||
@@ -8,24 +8,12 @@ import * as checklistRepo from "@kan/db/repository/checklist.repo";
|
||||
import * as labelRepo from "@kan/db/repository/label.repo";
|
||||
import * as listRepo from "@kan/db/repository/list.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import { generateAttachmentUrl, generateAvatarUrl } from "@kan/shared/utils";
|
||||
|
||||
import {
|
||||
activityItemSchema,
|
||||
cardCreateResponseSchema,
|
||||
cardDetailSchema,
|
||||
cardUpdateResponseSchema,
|
||||
commentDeleteResponseSchema,
|
||||
commentResponseSchema,
|
||||
} from "../schemas";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import { mergeActivities } from "../utils/activities";
|
||||
import { sendMentionEmails } from "../utils/notifications";
|
||||
import {
|
||||
assertCanDelete,
|
||||
assertCanEdit,
|
||||
assertPermission,
|
||||
} from "../utils/permissions";
|
||||
import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions";
|
||||
import { generateAttachmentUrl, generateAvatarUrl } from "@kan/shared/utils";
|
||||
import {
|
||||
createCardWebhookPayload,
|
||||
sendWebhooksForWorkspace,
|
||||
@@ -54,7 +42,7 @@ export const cardRouter = createTRPCRouter({
|
||||
dueDate: z.date().nullable().optional(),
|
||||
}),
|
||||
)
|
||||
.output(cardCreateResponseSchema)
|
||||
.output(z.custom<Awaited<ReturnType<typeof cardRepo.create>>>())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
@@ -82,7 +70,6 @@ export const cardRouter = createTRPCRouter({
|
||||
description: input.description,
|
||||
createdBy: userId,
|
||||
listId: list.id,
|
||||
workspaceId: list.workspaceId,
|
||||
position: input.position,
|
||||
dueDate: input.dueDate ?? null,
|
||||
});
|
||||
@@ -191,11 +178,10 @@ export const cardRouter = createTRPCRouter({
|
||||
"card.created",
|
||||
{
|
||||
id: String(newCard.id),
|
||||
publicId: newCard.publicId,
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
dueDate: input.dueDate ?? null,
|
||||
listId: list.publicId,
|
||||
listId: String(newCard.listId),
|
||||
},
|
||||
{
|
||||
boardId: list.boardPublicId,
|
||||
@@ -229,7 +215,7 @@ export const cardRouter = createTRPCRouter({
|
||||
comment: z.string().min(1),
|
||||
}),
|
||||
)
|
||||
.output(commentResponseSchema)
|
||||
.output(z.custom<Awaited<ReturnType<typeof cardCommentRepo.create>>>())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
@@ -250,12 +236,7 @@ export const cardRouter = createTRPCRouter({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertPermission(
|
||||
ctx.db,
|
||||
userId,
|
||||
card.workspaceId,
|
||||
"comment:create",
|
||||
);
|
||||
await assertPermission(ctx.db, userId, card.workspaceId, "comment:create");
|
||||
|
||||
const newComment = await cardCommentRepo.create(ctx.db, {
|
||||
comment: input.comment,
|
||||
@@ -307,7 +288,7 @@ export const cardRouter = createTRPCRouter({
|
||||
comment: z.string().min(1),
|
||||
}),
|
||||
)
|
||||
.output(commentResponseSchema)
|
||||
.output(z.custom<Awaited<ReturnType<typeof cardCommentRepo.update>>>())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
@@ -333,7 +314,7 @@ export const cardRouter = createTRPCRouter({
|
||||
input.commentPublicId,
|
||||
);
|
||||
|
||||
if (!existingComment || existingComment.cardId !== card.id)
|
||||
if (!existingComment)
|
||||
throw new TRPCError({
|
||||
message: `Comment with public ID ${input.commentPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
@@ -395,7 +376,7 @@ export const cardRouter = createTRPCRouter({
|
||||
commentPublicId: z.string().min(12),
|
||||
}),
|
||||
)
|
||||
.output(commentDeleteResponseSchema)
|
||||
.output(z.custom<Awaited<ReturnType<typeof cardCommentRepo.softDelete>>>())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
@@ -421,7 +402,7 @@ export const cardRouter = createTRPCRouter({
|
||||
input.commentPublicId,
|
||||
);
|
||||
|
||||
if (!existingComment || existingComment.cardId !== card.id)
|
||||
if (!existingComment)
|
||||
throw new TRPCError({
|
||||
message: `Comment with public ID ${input.commentPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
@@ -454,7 +435,7 @@ export const cardRouter = createTRPCRouter({
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
return { publicId: input.commentPublicId };
|
||||
return deletedComment;
|
||||
}),
|
||||
addOrRemoveLabel: protectedProcedure
|
||||
.meta({
|
||||
@@ -658,7 +639,25 @@ export const cardRouter = createTRPCRouter({
|
||||
},
|
||||
})
|
||||
.input(z.object({ cardPublicId: z.string().min(12) }))
|
||||
.output(cardDetailSchema)
|
||||
.output(
|
||||
z.custom<
|
||||
Omit<
|
||||
NonNullable<
|
||||
Awaited<ReturnType<typeof cardRepo.getWithListAndMembersByPublicId>>
|
||||
>,
|
||||
"attachments"
|
||||
> & {
|
||||
attachments: {
|
||||
publicId: string;
|
||||
contentType: string;
|
||||
s3Key: string;
|
||||
originalFilename: string | null;
|
||||
size?: number | null;
|
||||
url: string | null;
|
||||
}[];
|
||||
}
|
||||
>(),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const card = await cardRepo.getWorkspaceAndCardIdByCardPublicId(
|
||||
ctx.db,
|
||||
@@ -764,7 +763,15 @@ export const cardRouter = createTRPCRouter({
|
||||
)
|
||||
.output(
|
||||
z.object({
|
||||
activities: z.array(activityItemSchema),
|
||||
activities: z.array(
|
||||
z.custom<
|
||||
NonNullable<
|
||||
Awaited<
|
||||
ReturnType<typeof cardActivityRepo.getPaginatedActivities>
|
||||
>
|
||||
>["activities"][number]
|
||||
>(),
|
||||
),
|
||||
hasMore: z.boolean(),
|
||||
nextCursor: z.string().datetime().nullable(),
|
||||
}),
|
||||
@@ -864,7 +871,7 @@ export const cardRouter = createTRPCRouter({
|
||||
dueDate: z.date().nullable().optional(),
|
||||
}),
|
||||
)
|
||||
.output(cardUpdateResponseSchema)
|
||||
.output(z.custom<Awaited<ReturnType<typeof cardRepo.update>>>())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
@@ -899,18 +906,12 @@ export const cardRouter = createTRPCRouter({
|
||||
);
|
||||
|
||||
let newListId: number | undefined;
|
||||
let newList:
|
||||
| {
|
||||
id: number;
|
||||
publicId: string;
|
||||
name: string;
|
||||
boardId: number;
|
||||
index: number;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (input.listPublicId) {
|
||||
newList = await listRepo.getByPublicId(ctx.db, input.listPublicId);
|
||||
const newList = await listRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.listPublicId,
|
||||
);
|
||||
|
||||
if (!newList)
|
||||
throw new TRPCError({
|
||||
@@ -952,7 +953,7 @@ export const cardRouter = createTRPCRouter({
|
||||
);
|
||||
}
|
||||
|
||||
if (input.index !== undefined || newListId !== undefined) {
|
||||
if (input.index !== undefined) {
|
||||
result = await cardRepo.reorder(ctx.db, {
|
||||
cardId: existingCard.id,
|
||||
newIndex: input.index,
|
||||
@@ -1054,21 +1055,8 @@ export const cardRouter = createTRPCRouter({
|
||||
) {
|
||||
webhookChanges.dueDate = { from: previousDueDate, to: input.dueDate };
|
||||
}
|
||||
const movedToNewList = Boolean(
|
||||
newListId && existingCard.listId !== newListId,
|
||||
);
|
||||
const currentWebhookListPublicId = movedToNewList
|
||||
? input.listPublicId!
|
||||
: existingCard.list.publicId;
|
||||
const currentWebhookListName = movedToNewList
|
||||
? (newList?.name ?? card.listName)
|
||||
: existingCard.list.name;
|
||||
|
||||
if (movedToNewList) {
|
||||
webhookChanges.listId = {
|
||||
from: existingCard.list.publicId,
|
||||
to: input.listPublicId!,
|
||||
};
|
||||
if (newListId && existingCard.listId !== newListId) {
|
||||
webhookChanges.listId = { from: existingCard.listId, to: newListId };
|
||||
}
|
||||
|
||||
// Fire webhooks (non-blocking)
|
||||
@@ -1076,19 +1064,20 @@ export const cardRouter = createTRPCRouter({
|
||||
ctx.db,
|
||||
card.workspaceId,
|
||||
createCardWebhookPayload(
|
||||
movedToNewList ? "card.moved" : "card.updated",
|
||||
newListId && existingCard.listId !== newListId
|
||||
? "card.moved"
|
||||
: "card.updated",
|
||||
{
|
||||
id: String(result.id),
|
||||
publicId: result.publicId,
|
||||
title: result.title,
|
||||
description: result.description,
|
||||
dueDate: result.dueDate,
|
||||
listId: currentWebhookListPublicId,
|
||||
listId: String(newListId ?? existingCard.listId),
|
||||
},
|
||||
{
|
||||
boardId: card.boardPublicId,
|
||||
boardName: card.boardName,
|
||||
listName: currentWebhookListName,
|
||||
listName: card.listName,
|
||||
user: ctx.user
|
||||
? { id: ctx.user.id, name: ctx.user.name }
|
||||
: undefined,
|
||||
@@ -1175,16 +1164,15 @@ export const cardRouter = createTRPCRouter({
|
||||
"card.deleted",
|
||||
{
|
||||
id: String(fullCard.id),
|
||||
publicId: fullCard.publicId,
|
||||
title: fullCard.title,
|
||||
description: fullCard.description,
|
||||
dueDate: fullCard.dueDate,
|
||||
listId: fullCard.list.publicId,
|
||||
listId: String(fullCard.listId),
|
||||
},
|
||||
{
|
||||
boardId: card.boardPublicId,
|
||||
boardName: card.boardName,
|
||||
listName: fullCard.list.name,
|
||||
listName: card.listName,
|
||||
user: ctx.user
|
||||
? { id: ctx.user.id, name: ctx.user.name }
|
||||
: undefined,
|
||||
@@ -1284,7 +1272,6 @@ export const cardRouter = createTRPCRouter({
|
||||
description: sourceCard.description ?? "",
|
||||
createdBy: userId,
|
||||
listId: targetList.id,
|
||||
workspaceId: targetList.workspaceId,
|
||||
position: "end",
|
||||
dueDate: sourceCard.dueDate ?? null,
|
||||
});
|
||||
@@ -1299,10 +1286,7 @@ export const cardRouter = createTRPCRouter({
|
||||
|
||||
if (input.copyLabels && sourceCard.labels?.length) {
|
||||
const labelPublicIds = sourceCard.labels.map((l) => l.publicId);
|
||||
const labels = await labelRepo.getAllByPublicIds(
|
||||
ctx.db,
|
||||
labelPublicIds,
|
||||
);
|
||||
const labels = await labelRepo.getAllByPublicIds(ctx.db, labelPublicIds);
|
||||
if (labels.length) {
|
||||
const labelsInsert = labels.map((label) => ({
|
||||
cardId: newCard.id,
|
||||
@@ -1358,7 +1342,7 @@ export const cardRouter = createTRPCRouter({
|
||||
checklistId: newChecklist.id,
|
||||
title: item.title,
|
||||
createdBy: userId,
|
||||
completed: false,
|
||||
completed: item.completed ?? false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,7 +340,6 @@ export const importRouter = createTRPCRouter({
|
||||
description: card.description,
|
||||
createdBy: userId,
|
||||
listId: newListId,
|
||||
workspaceId: workspace.id,
|
||||
index,
|
||||
importId: newImportId,
|
||||
}));
|
||||
@@ -889,7 +888,6 @@ export const importRouter = createTRPCRouter({
|
||||
description: data.description,
|
||||
createdBy: userId,
|
||||
listId: data.listId,
|
||||
workspaceId: workspace.id,
|
||||
index: index,
|
||||
importId: newImportId,
|
||||
}));
|
||||
|
||||
@@ -7,7 +7,6 @@ import * as activityRepo from "@kan/db/repository/cardActivity.repo";
|
||||
import * as listRepo from "@kan/db/repository/list.repo";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import { listCreateResponseSchema, listUpdateResponseSchema } from "../schemas";
|
||||
import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions";
|
||||
|
||||
export const listRouter = createTRPCRouter({
|
||||
@@ -28,7 +27,7 @@ export const listRouter = createTRPCRouter({
|
||||
boardPublicId: z.string().min(12),
|
||||
}),
|
||||
)
|
||||
.output(listCreateResponseSchema)
|
||||
.output(z.custom<Awaited<ReturnType<typeof listRepo.create>>>())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
@@ -164,7 +163,12 @@ export const listRouter = createTRPCRouter({
|
||||
index: z.number().optional(),
|
||||
}),
|
||||
)
|
||||
.output(listUpdateResponseSchema)
|
||||
.output(
|
||||
z.custom<
|
||||
| Awaited<ReturnType<typeof listRepo.update>>
|
||||
| Awaited<ReturnType<typeof listRepo.reorder>>
|
||||
>(),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
|
||||
@@ -10,13 +10,11 @@ import * as userRepo from "@kan/db/repository/user.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import {
|
||||
generateUID,
|
||||
getSeatLimit,
|
||||
getSubscriptionByPlan,
|
||||
hasUnlimitedSeats,
|
||||
} from "@kan/shared";
|
||||
import { updateSubscriptionSeats } from "@kan/stripe";
|
||||
|
||||
import { memberInviteResponseSchema } from "../schemas";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import {
|
||||
assertCanManageMember,
|
||||
@@ -42,7 +40,7 @@ export const memberRouter = createTRPCRouter({
|
||||
workspacePublicId: z.string().min(12),
|
||||
}),
|
||||
)
|
||||
.output(memberInviteResponseSchema)
|
||||
.output(z.custom<Awaited<ReturnType<typeof memberRepo.create>>>())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
@@ -115,20 +113,6 @@ export const memberRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const seatLimit = getSeatLimit(subscriptions);
|
||||
if (seatLimit !== null) {
|
||||
const memberCount = await memberRepo.getCountByWorkspaceId(
|
||||
ctx.db,
|
||||
workspace.id,
|
||||
);
|
||||
if (memberCount >= seatLimit) {
|
||||
throw new TRPCError({
|
||||
message: `SEAT_LIMIT_REACHED`,
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const existingUser = await userRepo.getByEmail(ctx.db, input.email);
|
||||
@@ -659,20 +643,6 @@ export const memberRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const seatLimit = getSeatLimit(subscriptions);
|
||||
if (seatLimit !== null) {
|
||||
const memberCount = await memberRepo.getCountByWorkspaceId(
|
||||
ctx.db,
|
||||
workspace.id,
|
||||
);
|
||||
if (memberCount >= seatLimit) {
|
||||
throw new TRPCError({
|
||||
message: `SEAT_LIMIT_REACHED`,
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get the workspace role to set roleId
|
||||
|
||||
@@ -2,9 +2,9 @@ import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import * as userRepo from "@kan/db/repository/user.repo";
|
||||
import { generateAvatarUrl } from "@kan/shared/utils";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import { generateAvatarUrl } from "@kan/shared/utils";
|
||||
|
||||
export const userRouter = createTRPCRouter({
|
||||
getUser: protectedProcedure
|
||||
@@ -27,8 +27,6 @@ export const userRouter = createTRPCRouter({
|
||||
name: z.string().nullable(),
|
||||
image: z.string().nullable(),
|
||||
stripeCustomerId: z.string().nullable(),
|
||||
hasPassword: z.boolean(),
|
||||
hasMagicLinkAccount: z.boolean(),
|
||||
apiKey: z
|
||||
.object({
|
||||
id: z.number(),
|
||||
@@ -63,8 +61,6 @@ export const userRouter = createTRPCRouter({
|
||||
return {
|
||||
...result,
|
||||
image: imageUrl,
|
||||
hasPassword: result.hasPassword,
|
||||
hasMagicLinkAccount: result.hasMagicLinkAccount,
|
||||
apiKey: apiKey ? { id: apiKey.id, prefix: apiKey.prefix } : null,
|
||||
};
|
||||
}),
|
||||
@@ -118,60 +114,4 @@ export const userRouter = createTRPCRouter({
|
||||
image: imageUrl,
|
||||
};
|
||||
}),
|
||||
setPassword: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: "POST",
|
||||
path: "/users/me/password",
|
||||
summary: "Set password",
|
||||
description:
|
||||
"Sets a password for a user who signed up via magic link and has no password yet",
|
||||
tags: ["Users"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
newPassword: z
|
||||
.string()
|
||||
.min(8, "Password must be at least 8 characters"),
|
||||
}),
|
||||
)
|
||||
.output(z.object({ success: z.boolean() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: `User not authenticated`,
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const existing = await userRepo.getById(ctx.db, userId);
|
||||
|
||||
if (!existing) {
|
||||
throw new TRPCError({
|
||||
message: `User not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
}
|
||||
|
||||
if (existing.hasPassword) {
|
||||
throw new TRPCError({
|
||||
message: `Password already set; use change password instead`,
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await ctx.auth.api.setPassword({ newPassword: input.newPassword });
|
||||
} catch {
|
||||
throw new TRPCError({
|
||||
message: "Failed to set password",
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -334,7 +334,6 @@ export const webhookRouter = createTRPCRouter({
|
||||
|
||||
const testPayload = createCardWebhookPayload("card.created", {
|
||||
id: "test-card-id",
|
||||
publicId: "test-card-public-id",
|
||||
title: "Test Card",
|
||||
description: "This is a test webhook payload",
|
||||
dueDate: null,
|
||||
|
||||
@@ -2,20 +2,10 @@ import { TRPCError } from "@trpc/server";
|
||||
import { env } from "next-runtime-env";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { WorkspacePlan } from "@kan/db/schema";
|
||||
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import * as workspaceSlugRepo from "@kan/db/repository/workspaceSlug.repo";
|
||||
import { generateAvatarUrl, generateUID } from "@kan/shared/utils";
|
||||
|
||||
import {
|
||||
workspaceCreateResponseSchema,
|
||||
workspaceDeleteResponseSchema,
|
||||
workspaceDetailSchema,
|
||||
workspaceListItemSchema,
|
||||
workspaceUpdateResponseSchema,
|
||||
workspaceWithBoardsSchema,
|
||||
} from "../schemas";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import { assertPermission } from "../utils/permissions";
|
||||
|
||||
@@ -32,7 +22,9 @@ export const workspaceRouter = createTRPCRouter({
|
||||
},
|
||||
})
|
||||
.input(z.void())
|
||||
.output(z.array(workspaceListItemSchema))
|
||||
.output(
|
||||
z.custom<Awaited<ReturnType<typeof workspaceRepo.getAllByUserId>>>(),
|
||||
)
|
||||
.query(async ({ ctx }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
@@ -58,7 +50,11 @@ export const workspaceRouter = createTRPCRouter({
|
||||
},
|
||||
})
|
||||
.input(z.object({ workspacePublicId: z.string().min(12) }))
|
||||
.output(workspaceDetailSchema)
|
||||
.output(
|
||||
z.custom<
|
||||
Awaited<ReturnType<typeof workspaceRepo.getByPublicIdWithMembers>>
|
||||
>(),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
@@ -138,7 +134,7 @@ export const workspaceRouter = createTRPCRouter({
|
||||
return {
|
||||
...result,
|
||||
members: sanitizedMembers,
|
||||
};
|
||||
} as Awaited<ReturnType<typeof workspaceRepo.getByPublicIdWithMembers>>;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -166,7 +162,9 @@ export const workspaceRouter = createTRPCRouter({
|
||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/),
|
||||
}),
|
||||
)
|
||||
.output(workspaceWithBoardsSchema)
|
||||
.output(
|
||||
z.custom<Awaited<ReturnType<typeof workspaceRepo.getBySlugWithBoards>>>(),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
@@ -213,7 +211,7 @@ export const workspaceRouter = createTRPCRouter({
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.output(workspaceCreateResponseSchema)
|
||||
.output(z.custom<Awaited<ReturnType<typeof workspaceRepo.create>>>())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
const userEmail = ctx.user?.email;
|
||||
@@ -272,49 +270,7 @@ export const workspaceRouter = createTRPCRouter({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
let unlinkedSlot: Awaited<
|
||||
ReturnType<typeof subscriptionRepo.getFirstUnlinkedSlotByLicenseKey>
|
||||
>;
|
||||
|
||||
if (env("NEXT_PUBLIC_KAN_ENV") === "cloud") {
|
||||
const memberships = await workspaceRepo.getAllByUserId(ctx.db, userId);
|
||||
const otherWorkspaceIds = memberships
|
||||
.map((m) => m.workspace?.publicId)
|
||||
.filter((id): id is string => !!id && id !== workspacePublicId);
|
||||
|
||||
const partnerSub = otherWorkspaceIds.length
|
||||
? await subscriptionRepo.getFirstActivePartnerSubByWorkspaceIds(
|
||||
ctx.db,
|
||||
otherWorkspaceIds,
|
||||
)
|
||||
: undefined;
|
||||
unlinkedSlot = partnerSub?.partnerLicenseKey
|
||||
? await subscriptionRepo.getFirstUnlinkedSlotByLicenseKey(
|
||||
ctx.db,
|
||||
partnerSub.partnerLicenseKey,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
if (unlinkedSlot) {
|
||||
await Promise.all([
|
||||
subscriptionRepo.updateById(ctx.db, unlinkedSlot.id, {
|
||||
referenceId: workspacePublicId,
|
||||
}),
|
||||
workspaceRepo.update(ctx.db, workspacePublicId, {
|
||||
plan: unlinkedSlot.plan as WorkspacePlan,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
publicId: result.publicId,
|
||||
name: result.name!,
|
||||
slug: result.slug!,
|
||||
description: result.description ?? null,
|
||||
plan: (unlinkedSlot?.plan ?? result.plan!) as WorkspacePlan,
|
||||
cardPrefix: result.cardPrefix!,
|
||||
};
|
||||
return result;
|
||||
}),
|
||||
update: protectedProcedure
|
||||
.meta({
|
||||
@@ -344,7 +300,7 @@ export const workspaceRouter = createTRPCRouter({
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.output(workspaceUpdateResponseSchema)
|
||||
.output(z.custom<Awaited<ReturnType<typeof workspaceRepo.update>>>())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
@@ -427,7 +383,7 @@ export const workspaceRouter = createTRPCRouter({
|
||||
},
|
||||
})
|
||||
.input(z.object({ workspacePublicId: z.string().min(12) }))
|
||||
.output(workspaceDeleteResponseSchema)
|
||||
.output(z.custom<Awaited<ReturnType<typeof workspaceRepo.hardDelete>>>())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
@@ -449,23 +405,12 @@ export const workspaceRouter = createTRPCRouter({
|
||||
});
|
||||
await assertPermission(ctx.db, userId, workspace.id, "workspace:delete");
|
||||
|
||||
if (env("NEXT_PUBLIC_KAN_ENV") === "cloud") {
|
||||
const subs = await subscriptionRepo.getByReferenceId(
|
||||
ctx.db,
|
||||
input.workspacePublicId,
|
||||
);
|
||||
await Promise.all(
|
||||
subs
|
||||
.filter((s) => !!s.partnerLicenseKey)
|
||||
.map((s) =>
|
||||
subscriptionRepo.updateById(ctx.db, s.id, { referenceId: null }),
|
||||
),
|
||||
);
|
||||
}
|
||||
const result = await workspaceRepo.hardDelete(
|
||||
ctx.db,
|
||||
input.workspacePublicId,
|
||||
);
|
||||
|
||||
await workspaceRepo.hardDelete(ctx.db, input.workspacePublicId);
|
||||
|
||||
return { success: true };
|
||||
return result;
|
||||
}),
|
||||
checkSlugAvailability: publicProcedure
|
||||
.meta({
|
||||
@@ -570,7 +515,6 @@ export const workspaceRouter = createTRPCRouter({
|
||||
boardPublicId: z.string(),
|
||||
boardName: z.string(),
|
||||
listName: z.string(),
|
||||
cardNumber: z.number().nullable(),
|
||||
updatedAt: z.date().nullable(),
|
||||
createdAt: z.date(),
|
||||
type: z.literal("card"),
|
||||
@@ -608,38 +552,4 @@ export const workspaceRouter = createTRPCRouter({
|
||||
|
||||
return result;
|
||||
}),
|
||||
hasAvailablePartnerSlot: protectedProcedure
|
||||
.input(z.void())
|
||||
.output(z.boolean())
|
||||
.query(async ({ ctx }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: `User not authenticated`,
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const memberships = await workspaceRepo.getAllByUserId(ctx.db, userId);
|
||||
const workspaceIds = memberships
|
||||
.map((m) => m.workspace?.publicId)
|
||||
.filter((id): id is string => !!id);
|
||||
|
||||
if (!workspaceIds.length) return false;
|
||||
|
||||
const partnerSub =
|
||||
await subscriptionRepo.getFirstActivePartnerSubByWorkspaceIds(
|
||||
ctx.db,
|
||||
workspaceIds,
|
||||
);
|
||||
if (!partnerSub?.partnerLicenseKey) return false;
|
||||
|
||||
const unlinkedSlot =
|
||||
await subscriptionRepo.getFirstUnlinkedSlotByLicenseKey(
|
||||
ctx.db,
|
||||
partnerSub.partnerLicenseKey,
|
||||
);
|
||||
|
||||
return !!unlinkedSlot;
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// ─── attachment.confirm ──────────────────────────────────────
|
||||
export const attachmentConfirmResponseSchema = z.object({
|
||||
publicId: z.string(),
|
||||
filename: z.string(),
|
||||
originalFilename: z.string(),
|
||||
contentType: z.string(),
|
||||
size: z.number(),
|
||||
s3Key: z.string(),
|
||||
createdAt: z.date(),
|
||||
});
|
||||
@@ -1,132 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
checklistResponseSchema,
|
||||
labelSchema,
|
||||
workspaceMemberSchema,
|
||||
} from "./common";
|
||||
|
||||
// ─── board.all ───────────────────────────────────────────────
|
||||
export const boardListItemSchema = z.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
favorite: z.boolean(),
|
||||
lists: z.array(
|
||||
z.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
index: z.number(),
|
||||
}),
|
||||
),
|
||||
labels: z.array(labelSchema),
|
||||
});
|
||||
|
||||
// ─── Card sub-object inside board detail (byId) ─────────────
|
||||
const boardCardMemberSchema = z.object({
|
||||
publicId: z.string(),
|
||||
email: z.string(),
|
||||
user: z
|
||||
.object({
|
||||
name: z.string().nullable(),
|
||||
email: z.string(),
|
||||
image: z.string().nullable(),
|
||||
})
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
const boardDetailCardSchema = z.object({
|
||||
publicId: z.string(),
|
||||
title: z.string(),
|
||||
description: z.string().nullable(),
|
||||
index: z.number(),
|
||||
cardNumber: z.number().nullable(),
|
||||
dueDate: z.date().nullable(),
|
||||
labels: z.array(labelSchema),
|
||||
members: z.array(boardCardMemberSchema),
|
||||
attachments: z.array(z.object({ publicId: z.string() })),
|
||||
checklists: z.array(checklistResponseSchema),
|
||||
comments: z.array(z.object({ publicId: z.string() })),
|
||||
});
|
||||
|
||||
// ─── board.byId ──────────────────────────────────────────────
|
||||
export const boardDetailSchema = z.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
slug: z.string(),
|
||||
visibility: z.string(),
|
||||
isArchived: z.boolean(),
|
||||
favorite: z.boolean(),
|
||||
workspace: z.object({
|
||||
publicId: z.string(),
|
||||
cardPrefix: z.string(),
|
||||
members: z.array(workspaceMemberSchema),
|
||||
}),
|
||||
labels: z.array(labelSchema),
|
||||
lists: z.array(
|
||||
z.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
index: z.number(),
|
||||
cards: z.array(boardDetailCardSchema),
|
||||
}),
|
||||
),
|
||||
allLists: z.array(
|
||||
z.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
// ─── Card sub-object inside board detail (bySlug — no members) ─
|
||||
const boardSlugCardSchema = z.object({
|
||||
publicId: z.string(),
|
||||
title: z.string(),
|
||||
description: z.string().nullable(),
|
||||
index: z.number(),
|
||||
dueDate: z.date().nullable(),
|
||||
labels: z.array(labelSchema),
|
||||
attachments: z.array(z.object({ publicId: z.string() })),
|
||||
checklists: z.array(checklistResponseSchema),
|
||||
comments: z.array(z.object({ publicId: z.string() })),
|
||||
});
|
||||
|
||||
// ─── board.bySlug ────────────────────────────────────────────
|
||||
export const boardBySlugSchema = z.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
slug: z.string(),
|
||||
visibility: z.string(),
|
||||
workspace: z.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
slug: z.string(),
|
||||
}),
|
||||
labels: z.array(labelSchema),
|
||||
lists: z.array(
|
||||
z.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
index: z.number(),
|
||||
cards: z.array(boardSlugCardSchema),
|
||||
}),
|
||||
),
|
||||
allLists: z.array(
|
||||
z.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
// ─── board.create / board.createFromSnapshot ─────────────────
|
||||
export const boardCreateResponseSchema = z.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
// ─── board.update ────────────────────────────────────────────
|
||||
export const boardUpdateResponseSchema = z.union([
|
||||
z.object({ success: z.boolean() }),
|
||||
z.object({ publicId: z.string(), name: z.string() }),
|
||||
]);
|
||||
@@ -1,219 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
checklistResponseSchema,
|
||||
labelSchema,
|
||||
workspaceMemberSchema,
|
||||
} from "./common";
|
||||
|
||||
// ─── card.create ─────────────────────────────────────────────
|
||||
export const cardCreateResponseSchema = z.object({
|
||||
publicId: z.string(),
|
||||
});
|
||||
|
||||
// ─── card.update ─────────────────────────────────────────────
|
||||
export const cardUpdateResponseSchema = z.object({
|
||||
publicId: z.string(),
|
||||
title: z.string(),
|
||||
description: z.string().nullable(),
|
||||
dueDate: z.date().nullable(),
|
||||
});
|
||||
|
||||
// ─── Comment responses ───────────────────────────────────────
|
||||
export const commentResponseSchema = z.object({
|
||||
publicId: z.string(),
|
||||
comment: z.string(),
|
||||
});
|
||||
|
||||
export const commentDeleteResponseSchema = z.object({
|
||||
publicId: z.string(),
|
||||
});
|
||||
|
||||
// ─── card.byId ───────────────────────────────────────────────
|
||||
|
||||
const cardMemberSchema = z.object({
|
||||
publicId: z.string(),
|
||||
email: z.string(),
|
||||
user: z
|
||||
.object({
|
||||
id: z.string().nullable(),
|
||||
name: z.string().nullable(),
|
||||
})
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
export const cardDetailSchema = z.object({
|
||||
publicId: z.string(),
|
||||
title: z.string(),
|
||||
description: z.string().nullable(),
|
||||
cardNumber: z.number().nullable(),
|
||||
index: z.number(),
|
||||
dueDate: z.date().nullable(),
|
||||
createdBy: z.string().nullable(),
|
||||
labels: z.array(labelSchema),
|
||||
attachments: z.array(
|
||||
z.object({
|
||||
publicId: z.string(),
|
||||
contentType: z.string(),
|
||||
s3Key: z.string(),
|
||||
originalFilename: z.string().nullable(),
|
||||
size: z.number().nullable(),
|
||||
url: z.string().nullable(),
|
||||
}),
|
||||
),
|
||||
checklists: z.array(checklistResponseSchema),
|
||||
list: z.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
board: z.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
labels: z.array(labelSchema),
|
||||
lists: z.array(
|
||||
z.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
}),
|
||||
),
|
||||
workspace: z.object({
|
||||
publicId: z.string(),
|
||||
cardPrefix: z.string(),
|
||||
members: z.array(workspaceMemberSchema),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
members: z.array(cardMemberSchema),
|
||||
activities: z.array(
|
||||
z.object({
|
||||
publicId: z.string(),
|
||||
type: z.string(),
|
||||
createdAt: z.date(),
|
||||
fromIndex: z.number().nullable(),
|
||||
toIndex: z.number().nullable(),
|
||||
fromTitle: z.string().nullable(),
|
||||
toTitle: z.string().nullable(),
|
||||
fromDescription: z.string().nullable(),
|
||||
toDescription: z.string().nullable(),
|
||||
fromDueDate: z.date().nullable(),
|
||||
toDueDate: z.date().nullable(),
|
||||
fromList: z
|
||||
.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
index: z.number(),
|
||||
})
|
||||
.nullable(),
|
||||
toList: z
|
||||
.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
index: z.number(),
|
||||
})
|
||||
.nullable(),
|
||||
label: z
|
||||
.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
})
|
||||
.nullable(),
|
||||
member: z
|
||||
.object({
|
||||
publicId: z.string(),
|
||||
user: z
|
||||
.object({
|
||||
name: z.string().nullable(),
|
||||
email: z.string(),
|
||||
})
|
||||
.nullable(),
|
||||
})
|
||||
.nullable(),
|
||||
user: z
|
||||
.object({
|
||||
name: z.string().nullable(),
|
||||
email: z.string(),
|
||||
})
|
||||
.nullable(),
|
||||
comment: z
|
||||
.object({
|
||||
publicId: z.string(),
|
||||
comment: z.string(),
|
||||
createdBy: z.string().nullable(),
|
||||
updatedAt: z.date().nullable(),
|
||||
deletedAt: z.date().nullable(),
|
||||
})
|
||||
.nullable(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
// ─── card.getActivities ──────────────────────────────────────
|
||||
export const activityItemSchema = z.object({
|
||||
publicId: z.string(),
|
||||
type: z.string(),
|
||||
createdAt: z.date(),
|
||||
fromIndex: z.number().nullable(),
|
||||
toIndex: z.number().nullable(),
|
||||
fromTitle: z.string().nullable(),
|
||||
toTitle: z.string().nullable(),
|
||||
fromDescription: z.string().nullable(),
|
||||
toDescription: z.string().nullable(),
|
||||
fromDueDate: z.date().nullable(),
|
||||
toDueDate: z.date().nullable(),
|
||||
fromList: z
|
||||
.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
index: z.number(),
|
||||
})
|
||||
.nullable(),
|
||||
toList: z
|
||||
.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
index: z.number(),
|
||||
})
|
||||
.nullable(),
|
||||
label: z
|
||||
.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
})
|
||||
.nullable(),
|
||||
member: z
|
||||
.object({
|
||||
publicId: z.string(),
|
||||
user: z
|
||||
.object({
|
||||
id: z.string().nullable(),
|
||||
name: z.string().nullable(),
|
||||
email: z.string(),
|
||||
image: z.string().nullable(),
|
||||
})
|
||||
.nullable(),
|
||||
})
|
||||
.nullable(),
|
||||
user: z
|
||||
.object({
|
||||
id: z.string().nullable(),
|
||||
name: z.string().nullable(),
|
||||
email: z.string(),
|
||||
image: z.string().nullable(),
|
||||
})
|
||||
.nullable(),
|
||||
comment: z
|
||||
.object({
|
||||
publicId: z.string(),
|
||||
comment: z.string(),
|
||||
createdBy: z.string().nullable(),
|
||||
updatedAt: z.date().nullable(),
|
||||
deletedAt: z.date().nullable(),
|
||||
})
|
||||
.nullable(),
|
||||
attachment: z
|
||||
.object({
|
||||
publicId: z.string(),
|
||||
filename: z.string(),
|
||||
originalFilename: z.string().nullable(),
|
||||
})
|
||||
.nullable(),
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// Shared label schema used across board and card responses
|
||||
export const labelSchema = z.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
colourCode: z.string().nullable(),
|
||||
});
|
||||
|
||||
// Shared checklist item schema
|
||||
export const checklistItemResponseSchema = z.object({
|
||||
publicId: z.string(),
|
||||
title: z.string(),
|
||||
completed: z.boolean(),
|
||||
index: z.number(),
|
||||
});
|
||||
|
||||
// Shared checklist schema with items
|
||||
export const checklistResponseSchema = z.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
index: z.number(),
|
||||
items: z.array(checklistItemResponseSchema),
|
||||
});
|
||||
|
||||
// Shared user sub-object (used inside member/workspace responses)
|
||||
export const userSchema = z.object({
|
||||
id: z.string().nullable(),
|
||||
name: z.string().nullable(),
|
||||
email: z.string(),
|
||||
image: z.string().nullable(),
|
||||
});
|
||||
|
||||
// Workspace member sub-object
|
||||
export const workspaceMemberSchema = z.object({
|
||||
publicId: z.string(),
|
||||
email: z.string(),
|
||||
status: z.string(),
|
||||
user: userSchema.nullable(),
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user