Compare commits

...

18 Commits

Author SHA1 Message Date
Henry
fc692cb411 feat: basic workspace search (#203)
* feat: set up command pallette and search endpoint

* feat: use placeholder data in search results

* feat: add keyboard navigation

* feat: add icons for search results

* feat: add fuzzy search

* feat: add search button to collapsed menu

* feat: add keyboard shortcuts

* fix: prevent no results flickering

* feat: consistent font size for search placeholder

* chore: build translations
2025-10-02 22:25:48 +01:00
Henry
adbb25edd7 fix(cloud): enable magic link signup for cloud (#204) 2025-10-02 22:25:33 +01:00
Henry
6059fa35a2 fix: show invite link toggle on self hosted environments (#198) 2025-09-27 20:48:16 +01:00
Morfixx
60355c762f feat: disable email functions if email not enabled (#195)
* fix: disabled email functionality unless email sending enabled

* feat: missing locale only russian complete

* chore: add ENABLE_EMAIL to docker compose

* chore: add translations

* fix: removed new env variable, and used SMTP_HOST instead for checking

* Revert "fix: removed new env variable, and used SMTP_HOST instead for checking"

This reverts commit 5f75237e06ac07bcbb073c25cdc9d6a9071e4011.

* feat: allow inviting members via link

* refactor: update env to disable email

* chore: update translations

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
2025-09-26 23:20:57 +01:00
Henry
b159b48c35 feat: invite workspace members via link (#196)
* feat: add workspace invite links schema

* feat: update workspace invite schema

* feat: add repo funcs and share link toggle

* feat: redirect to next param on authentication

* feat: add invite page

* feat: switch to workspace on invite success

* refactor: tweak light mode styles

* feat(cloud): update subscription for cloud

* feat: only allow admin users to create links

* chore: add translations
2025-09-26 22:25:42 +01:00
Morfixx
7ab30acfc4 feat(db): added similar checks for duplicate indices for cards (#191) 2025-09-24 21:40:25 +01:00
Morfixx
fed24d1960 fix: bulkCreate to stop imports with duplicate indices (#190)
* fix(db): normalize/compact indices to prevent import duplicates

- bulkCreate now assigns sequential indices per board (preserves order; ignores incoming indices).
- Auto-compact indices after create/insert if duplicates exist, keeping active lists unique and gap-free.
- Prevents reorder errors caused by duplicate indices from Trello imports.

* feat(db): normalize/compact indices with fail-safe duplicate recheck
2025-09-20 22:17:41 +01:00
Morfixx
2a0220ce6f feat: added docs to website (#186)
* feat(docs): self-hosting s3 minio guide

* feat(docs): self-hosting introduction guide

* fix: build error

* refactor: use kan over kan.bn

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
2025-09-19 21:08:59 +01:00
Morfixx
7ccd2ac1e0 feat(web): added image cropping to avatar uploads (#185)
* feat(web): added image cropping to avatar uploads

* chore: add translations

* refactor: move react crop css import to avatar component

* refactor: remove duplicate interface

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
2025-09-19 20:44:20 +01:00
Henry
cea5cf84c8 feat(cloud): pause/reactivate members (#184)
* feat: add paused member status

* feat: add paused badge to member table

* chore: add translations

* feat: unpause all members on pro activation
2025-09-18 22:09:42 +01:00
Henry
6738fddc5f feat(localisation): add russian language support (#183) 2025-09-18 20:50:01 +01:00
Henry
dc1f78df55 fix: set correct grid size for boards on desktop viewports (#180) 2025-09-14 21:12:35 +01:00
Henry
87e02fdcb0 chore: add missing translations 2025-09-14 20:46:11 +01:00
Henry
7073cd5931 feat: only render table if api keys exist 2025-09-14 20:28:38 +01:00
LovelessCodes
3e21b23f0a refactor: reorganize settings page with tabbed interface (#57)
* refactor: reorganize settings page with tabbed interface

* feat: revamp API key management with new list view and confirmation modals

* refactor: update tab styling

* refactor: tweak UI/UX for managing API keys

* refactor: only show update button when change has been made

* refactor: only show update button when content of display name has been updated

* feat: store tab state in params

* refactor: remove focus state from tabs

* refactor: tweak styling on mobile select

* refactor: simplify settings pages

* feat: open upgrade modal if upgrade=pro is in params

* feat: add scroll to api key list on mobile

* chore: add translations

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
2025-09-14 15:48:55 +01:00
Henry
793baa8325 fix: update modal z-index to ensure proper layering (#177) 2025-09-12 20:14:41 +01:00
Henry
63e639e337 fix: show correct active workspace and truncate name (#175) 2025-09-11 23:02:48 +01:00
Henry
c2ccb6b13b fix: prevent card side menu selectors from closing unexpectedly (#174) 2025-09-11 22:48:53 +01:00
92 changed files with 16445 additions and 1601 deletions

View File

@@ -1,6 +1,6 @@
# https://github.com/kanbn/kan?tab=readme-ov-file#environment-variables-)
# Required environment variables
# Required environment variables
NEXT_PUBLIC_BASE_URL= # e.g. https://kan.bn
BETTER_AUTH_SECRET= # Random 32+ char string (can gen with: openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 32)
@@ -18,6 +18,9 @@ SMTP_PASSWORD=
EMAIL_FROM= # e.g. "Kan <hello@mail.kan.bn>"
SMTP_SECURE= # set to "false" to use port 587
# Switch email features off entirely (optional)
NEXT_PUBLIC_DISABLE_EMAIL=
# S3 storage (optional)
S3_REGION=
S3_ENDPOINT=

View File

@@ -147,6 +147,7 @@ pnpm dev
| `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` |
| `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` |
| `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | No | `http://localhost:3000,http://localhost:3001` |

View File

@@ -0,0 +1,140 @@
---
title: "Introduction"
description: "Overview and quick start to run Kan on your own infrastructure using Docker Compose."
mode: "wide"
tag: "NEW"
---
This guide introduces how to self-host Kan. It starts with the minimal Docker Compose setup (web + PostgreSQL) and points you to optional features like email and S3-based file storage.
## What youll set up
<CardGroup cols={2}>
<Card title="Kan" icon="globe" color="#0284c7" horizontal>
Next.js application served on port 3000.
</Card>
<Card title="PostgreSQL 15" icon="database" color="#65a30d" horizontal>
Primary database for Kan data.
</Card>
</CardGroup>
<Note>
For file uploads (avatars), OAuth, and other advanced options, see the
Environment Variables section in the README and the dedicated [S3
guide](/guides/self-hosting/s3). The [full
compose](https://github.com/kanbn/kan/blob/main/docker-compose.yml) in the
repo includes a richer configuration via <code>.env</code>.
</Note>
## Prerequisites
- Docker and Docker Compose
- A long random string for <code>BETTER_AUTH_SECRET</code> (32+ chars)
## Quick start
<Steps>
<Step title="Create a docker-compose.yml">
Paste the following minimal configuration into a new <code>docker-compose.yml</code> file:
```yaml
services:
web:
image: ghcr.io/kanbn/kan:latest
container_name: kan-web
ports:
- "3000:3000"
networks:
- kan-network
environment:
NEXT_PUBLIC_BASE_URL: http://localhost:3000
BETTER_AUTH_SECRET: your_auth_secret
POSTGRES_URL: postgresql://kan:your_postgres_password@postgres:5432/kan_db
NEXT_PUBLIC_ALLOW_CREDENTIALS: true
depends_on:
- postgres
restart: unless-stopped
postgres:
image: postgres:15
container_name: kan-db
environment:
POSTGRES_DB: kan_db
POSTGRES_USER: kan
POSTGRES_PASSWORD: your_postgres_password
ports:
- 5432:5432
volumes:
- kan_postgres_data:/var/lib/postgresql/data
restart: unless-stopped
networks:
- kan-network
networks:
kan-network:
volumes:
kan_postgres_data:
```
<Tip>
The example above is intentionally minimal. The repository provides a more feature-complete compose file at [docker-compose.yml](https://github.com/kanbn/kan/blob/main/docker-compose.yml) if you want environment-based configuration, OAuth, S3, and more.
</Tip>
</Step>
<Step title="Start the stack">
Bring everything up in detached mode:
```bash
docker compose up -d
```
Once started, open [http://localhost:3000](http://localhost:3000).
</Step>
<Step title="Manage the containers">
Useful commands while developing or testing:
- Stop the containers: <code>docker compose down</code>
- View logs: <code>docker compose logs -f</code>
- Restart: <code>docker compose restart</code>
</Step>
<Step title="Configure environment (optional)">
For a production-like setup and more features (email, OAuth, file uploads, etc.), create a <code>.env</code> file and set the relevant variables shown in the READMEs Environment Variables section.
<Accordion title="Common variables">
```bash
# Required
NEXT_PUBLIC_BASE_URL=http://localhost:3000
BETTER_AUTH_SECRET=replace_with_long_random_string
POSTGRES_URL=postgresql://kan:your_postgres_password@postgres:5432/kan_db
# Optional: Email
EMAIL_FROM="Kan <hello@mail.kan.bn>"
SMTP_HOST=smtp.resend.com
SMTP_PORT=465
SMTP_USER=resend
SMTP_PASSWORD=re_xxxx
SMTP_SECURE=true
# Optional: Auth toggles
NEXT_PUBLIC_ALLOW_CREDENTIALS=true
NEXT_PUBLIC_DISABLE_SIGN_UP=false
```
<Note type="warning">
If you plan to enable file uploads (avatars, etc.), youll also need S3 variables (<code>S3_ENDPOINT</code>, <code>S3_ACCESS_KEY_ID</code>, <code>S3_SECRET_ACCESS_KEY</code>, <code>NEXT_PUBLIC_STORAGE_URL</code>, <code>NEXT_PUBLIC_STORAGE_DOMAIN</code>, …). See the S3 guide linked at the top.
</Note>
</Accordion>
</Step>
</Steps>
## Reference
- [GitHub README](https://github.com/kanbn/kan/blob/main/README.md#self-hosting-)
- [GitHub docker-compose.yml](https://github.com/kanbn/kan/blob/main/docker-compose.yml)

View File

@@ -0,0 +1,397 @@
---
title: "Kan + MinIO (S3)"
mode: "wide"
tag: "NEW"
---
Deploy Kan with PostgreSQL and MinIO (S3-compatible storage) using Docker Compose, with clear steps and production notes.
## What youll set up
<CardGroup cols={2}>
<Card title="Kan" icon="globe" color="#0284c7" horizontal>
Kan web app (Next.js), on port 3000.
</Card>
<Card title="PostgreSQL 15" icon="database" color="#65a30d" horizontal>
PostgreSQL database for Kan.
</Card>
</CardGroup>
<Card title="MinIO (S3-compatible)" icon="cloud" color="#ca8a04" horizontal>
MinIO object storage (console port 9001, S3 API port 9000).
</Card>
## How it works
- Kan stores data in PostgreSQL.
- Kan uploads files (e.g., avatars) to MinIO over the S3 API.
- The browser fetches public files directly from MinIOs public URL.
- The Next.js image optimizer in Kan must be explicitly allowed to fetch from your storage host.
Key domain settings:
- <code>NEXT_PUBLIC_BASE_URL</code> → the Kan site
- <code>NEXT_PUBLIC_STORAGE_URL</code> → the public S3 base URL
- <code>NEXT_PUBLIC_STORAGE_DOMAIN</code> → the exact S3 hostname (no scheme)
## Prerequisites
- Docker and Docker Compose
- Open local ports: 3000 (Kan), 5432 (Postgres), 9000/9001 (MinIO)
- A long random string for <code>BETTER_AUTH_SECRET</code> (32+ chars)
<Note type="warning">
For production youll want a reverse proxy (Traefik/Nginx/Caddy), valid TLS
certificates, and DNS for your domains (e.g., <code>kan.example.com</code>,{" "}
<code>s3.example.com</code>).
</Note>
## Quick start
<Tip type="info">
Why <code>localtest.me</code>? It resolves to <code>127.0.0.1</code>{" "}
automatically, so you can test domain-based configs locally without editing
hosts.
</Tip>
<Steps>
<Step title="Set environment variables">
Provide the minimum required configuration (local example):
```bash
NEXT_PUBLIC_BASE_URL=http://kan.localtest.me:3000
BETTER_AUTH_SECRET=<long random string>
POSTGRES_URL=postgresql://kan:<password>@postgres:5432/kan_db
# MinIO/S3
S3_ENDPOINT=http://s3.localtest.me:9000
S3_ACCESS_KEY_ID=<minio-access-key>
S3_SECRET_ACCESS_KEY=<minio-secret-key>
S3_REGION=none
S3_FORCE_PATH_STYLE=true
# Public storage access
NEXT_PUBLIC_STORAGE_URL=http://s3.localtest.me:9000
NEXT_PUBLIC_STORAGE_DOMAIN=s3.localtest.me
NEXT_PUBLIC_AVATAR_BUCKET_NAME=kan
```
<Note type="info">
Issue #109 fix: make sure <code>NEXT_PUBLIC_STORAGE_DOMAIN</code> exactly
equals the hostname that serves your images (no scheme, no port).
</Note>
Optional (see README for full list): Email (`EMAIL_FROM`, `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_SECURE`), OAuth/OIDC (`GOOGLE_*`, `GITHUB_*`, `OIDC_*`), auth toggles, Trello import, etc.
</Step>
<Step title="Create or review Docker Compose files">
You can start from the minimal compose at the repository root (<code>docker-compose.yml</code>) and review the production-oriented settings in <code>cloud/docker-compose.yml</code>.
Start with the minimal setup (web + postgres + minio) and ensure environment variables are passed to the web service.
<Accordion title="Docker Compose example">
```yaml
services:
postgres:
image: postgres:15
container_name: kan-db
ports:
- "5432:5432"
environment:
POSTGRES_USER: kan
POSTGRES_PASSWORD: changeme
POSTGRES_DB: kan_db
volumes:
- pg_data:/var/lib/postgresql/data
restart: unless-stopped
minio:
image: minio/minio:latest
container_name: kan-minio
command: server /data --console-address ":9001"
ports:
- "9000:9000" # S3 API
- "9001:9001" # Console
environment:
# Use the same credentials in your .env
# as S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEY
MINIO_ROOT_USER: minio
MINIO_ROOT_PASSWORD: minio123456789
volumes:
- minio_data:/data
restart: unless-stopped
web:
image: ghcr.io/kanbn/kan:latest
container_name: kan-web
depends_on:
- postgres
- minio
ports:
- "3000:3000"
# Load variables from .env
# (see the "Set environment variables" step)
env_file:
- .env
restart: unless-stopped
volumes:
pg_data:
minio_data:
```
<Note type="info">
Ensure your <code>.env</code> contains values that match this compose file.
For example:
<ul>
<li>
<code>POSTGRES_URL=postgresql://kan:changeme@postgres:5432/kan_db</code>
</li>
<li>
<code>S3_ENDPOINT=http://s3.localtest.me:9000</code>
</li>
<li>
<code>S3_ACCESS_KEY_ID=minio</code> and{" "}
<code>S3_SECRET_ACCESS_KEY=minio123456789</code>
</li>
<li>
<code>S3_FORCE_PATH_STYLE=true</code>
</li>
<li>
<code>NEXT_PUBLIC_STORAGE_URL=http://s3.localtest.me:9000</code>
</li>
<li>
<code>NEXT_PUBLIC_STORAGE_DOMAIN=s3.localtest.me</code>
</li>
<li>
<code>NEXT_PUBLIC_AVATAR_BUCKET_NAME=kan</code>
</li>
</ul>
</Note>
</Accordion>
</Step>
<Step title="Start services">
```bash
docker compose up -d
```
Then open:
<ul>
<li>
Kan: <a href="http://kan.localtest.me:3000">http://kan.localtest.me:3000</a>
</li>
<li>
MinIO Console:{" "}
<a href="http://minio.localtest.me:9001">http://minio.localtest.me:9001</a>
</li>
<li>
MinIO S3 API:{" "}
<a href="http://s3.localtest.me:9000">http://s3.localtest.me:9000</a>
</li>
</ul>
</Step>
<Step title="Initialize MinIO">
1) Log into the MinIO Console (http://minio.localtest.me:9001).
2. Create a bucket (e.g., <code>kan</code>).
3. For simple public avatars, apply a read-only policy so GET requests are allowed for objects:
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": { "AWS": ["*"] },
"Action": ["s3:GetBucketLocation", "s3:ListBucket"],
"Resource": ["arn:aws:s3:::kan"]
},
{
"Effect": "Allow",
"Principal": { "AWS": ["*"] },
"Action": ["s3:GetObject"],
"Resource": ["arn:aws:s3:::kan/*"]
}
]
}
```
<Note type="warning">
Alternatively, keep the bucket private and use presigned URLs. In that case,
ensure your server and browser access paths are correctly configured.
</Note>
</Step>
<Step title="Verify the setup">
<ul>
<li>Sign in to Kan and upload an avatar (Settings).</li>
<li>Confirm the object is created in your MinIO bucket.</li>
<li>The avatar should render without errors.</li>
</ul>
If you see a 400 from <code>/\_next/image</code> with “url parameter is not allowed”:
<ul>
<li>
<code>NEXT_PUBLIC_STORAGE_DOMAIN</code> must exactly match the S3 hostname
that serves images.
</li>
<li>
<code>NEXT_PUBLIC_STORAGE_URL</code> should use the same host (with
scheme/port).
</li>
<li>Ensure youre using the latest Kan image.</li>
</ul>
</Step>
</Steps>
## Production setup
<Tabs>
<Tab title="Local">
<ul>
<li><code>NEXT_PUBLIC_BASE_URL=http://kan.localtest.me:3000</code></li>
<li><code>S3_ENDPOINT=http://s3.localtest.me:9000</code></li>
<li><code>NEXT_PUBLIC_STORAGE_URL=http://s3.localtest.me:9000</code></li>
<li><code>NEXT_PUBLIC_STORAGE_DOMAIN=s3.localtest.me</code></li>
<li>Keep <code>S3_FORCE_PATH_STYLE=true</code> for MinIO.</li>
</ul>
</Tab>
<Tab title="Production">
<ul>
<li><code>NEXT_PUBLIC_BASE_URL=https://kan.example.com</code></li>
<li><code>S3_ENDPOINT=https://s3.example.com</code></li>
<li><code>NEXT_PUBLIC_STORAGE_URL=https://s3.example.com</code></li>
<li><code>NEXT_PUBLIC_STORAGE_DOMAIN=s3.example.com</code></li>
<li>Keep <code>S3_FORCE_PATH_STYLE=true</code> for MinIO.</li>
<li>Put Kan and MinIO behind HTTPS with a reverse proxy (Traefik/Nginx) and valid TLS.</li>
</ul>
</Tab>
</Tabs>
## Troubleshooting
<AccordionGroup>
<Accordion title="Files upload but dont display">
<ul>
<li>If public: confirm GET is allowed on objects (bucket policy).</li>
<li>If private: ensure presigned URLs are generated and valid.</li>
<li>403 AccessDenied indicates permissions, not CORS. CORS is not required for simple <code>&lt;img&gt;</code> GETs.</li>
</ul>
</Accordion>
<Accordion title="Make the bucket public (read-only) with mc" defaultOpen>
Use your MinIO root credentials to allow anonymous reads:
```bash
# Replace with your MINIO_ROOT_PASSWORD
MINIO_PASS='<your-minio-password>'
# Point mc at MinIO via the container network (no ports required)
docker run --rm --network container:kan-minio minio/mc \
mc alias set local http://127.0.0.1:9000 minio "$MINIO_PASS"
# Allow public downloads from the bucket
docker run --rm --network container:kan-minio minio/mc \
mc anonymous set download local/kan
# Optional: verify anonymous status
docker run --rm --network container:kan-minio minio/mc \
mc anonymous get local/kan
```
</Accordion>
<Accordion title="Alternative: S3 bucket policy (AWS CLI)">
If you prefer a bucket policy, apply a public-read policy for objects:
```json policy.json
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "PublicReadGetObject",
"Effect": "Allow",
"Principal": "*",
"Action": ["s3:GetObject"],
"Resource": ["arn:aws:s3:::kan/*"]
}
]
}
```
```bash
# Use your MinIO root credentials
MINIO_PASS='<your-minio-password>'
docker run --rm --network container:kan-minio \
-e AWS_ACCESS_KEY_ID=minio \
-e AWS_SECRET_ACCESS_KEY="$MINIO_PASS" \
-e AWS_DEFAULT_REGION=us-east-1 -e AWS_S3_FORCE_PATH_STYLE=true \
-v "$PWD:/work" amazon/aws-cli \
s3api put-bucket-policy --bucket kan --policy file:///work/policy.json \
--endpoint-url http://127.0.0.1:9000
```
</Accordion>
<Accordion title="Next.js optimizer 400 — url parameter is not allowed">
<ul>
<li>
Exact match on <code>NEXT_PUBLIC_STORAGE_DOMAIN</code> with your storage
host.
</li>
<li>
Same host in <code>NEXT_PUBLIC_STORAGE_URL</code>.
</li>
<li>Update to the latest Kan image.</li>
</ul>
</Accordion>
<Accordion title="Next/Image: “url parameter is valid but upstream response is invalid”">
<ul>
<li>This means Next.js accepted the URL, but the upstream returned a non-image (e.g., 403 HTML/XML).</li>
<li>Fix: make the bucket/object publicly readable (see above), or use presigned URLs.</li>
<li>Sanity test from the web network (replace with your image URL):</li>
</ul>
```bash
IMG_URL="https://s3.example.com/kan/path/to/avatar.jpg"
# Headers/content-type as seen from the app network
docker run --rm --network container:kan-web curlimages/curl:8.9.1 \
-I -L --max-redirs 5 "$IMG_URL"
# Quick status + content-type summary
docker run --rm --network container:kan-web curlimages/curl:8.9.1 \
-s -o /dev/null -w "HTTP:%{http_code} CT:%{content_type} URL:%{url_effective}\n" -L "$IMG_URL"
```
</Accordion>
<Accordion title="Connectivity checks">
<ul>
<li>The Kan container must reach <code>S3_ENDPOINT</code>.</li>
<li>Verify DNS/ports inside the container (e.g., <code>docker exec -it &lt;kan-container&gt; sh</code>).</li>
</ul>
</Accordion>
</AccordionGroup>
## References
- [Kan README](https://github.com/kanbn/kan/blob/main/README.md)
- [Cloud compose reference](https://github.com/kanbn/kan/blob/main/cloud/docker-compose.yml)
- [Kan #109 Issue](https://github.com/kanbn/kan/issues/109)

View File

@@ -44,6 +44,18 @@
"group": "Get Started",
"pages": ["introduction"]
},
{
"group": "Guides",
"pages": [
{
"group": "Self-Hosting",
"pages": [
"guides/self-hosting/introduction",
"guides/self-hosting/s3"
]
}
]
},
{
"group": "Import",
"pages": ["imports/trello"]

View File

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

View File

@@ -10,6 +10,7 @@ checksums:
"%248%2Fmonth/singular": 4667034934bb2bc3d569c70b94205b51
1%20user/singular: 3b547431ab12f0fba84307e6a81109d8
A%20powerful%2C%20flexible%20kanban%20app%20that%20helps%20you%20organise%20work%2C%20track%20progress%2C%20and%20deliver%20results%E2%80%94all%20in%20one%20place./singular: d405b83b0d631cb72f4347c10bcbb643
Account/singular: 01215c12fb1cdb93bd0c84c1382bef56
Account%20deleted/singular: a25da96a1579c4491be0a95669ef18a4
Activity/singular: 1948763de8e531483a798b68195e297e
Activity%20logs/singular: 8b1f0bb96a905646ecfad1cfdfa42168
@@ -29,11 +30,18 @@ checksums:
added%20checklist%20item%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: bdd202da20b1fffbec21792c5453f90c
added%20label%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: b32be052b3d57de0c9120fa7f9fc86ee
Adding%20a%20new%20member%20will%20cost%20an%20additional%20%7Bprice%7D%20(%7BbillingType%7D)%20per%20seat./singular: 12e88573028306110fbc15ef1e714892
Adjust%20the%20square%20crop%20to%20fit%20your%20avatar./singular: a4df26bbce6f14c6962fac1324db00a8
Admin%20roles/singular: 32a5d78073b9bb9a246773afba8831df
All%20systems%20operational/singular: ee943a4046b09e6334cceeea9fda2bfc
Already%20have%20an%20account%3F%20%3C0%3E%3C1%3ESign%20in%3C%2F1%3E%3C%2F0%3E/singular: 2959fd276248208b65cb27ed46b20135
An%20error%20occurred%20while%20disconnecting%20your%20Trello%20account./singular: 0aa3973b860c1faf8d9123aebf567e40
An%20unexpected%20error%20occurred.%20Please%20try%20again%20later./singular: 1b5749b0cca6a62d75a577bee6804074
Anyone%20with%20this%20link%20can%20join%20your%20workspace/singular: 2366ed295eb2c03c425559c24cb31606
API/singular: 01d9819514e27056dcc69463194b63d2
API%20key%20created/singular: 8dbb2b60a719b0d120e774d6666c8c45
API%20key%20name/singular: 2d8aeb08b2cce3b750a584bbc5ce6d1d
API%20key%20name%20cannot%20exceed%2030%20characters/singular: 3ea1c7d68e1074a75128017b097b727a
API%20key%20name%20is%20required/singular: 870970743b14cb56e7fb7b410af0e933
API%20keys/singular: 07f3620a30e08136f0b072c9af8d1eef
API%20Reference/singular: 7dcc877064bfdf889ec55dbc9e06a242
Applicants/singular: 4babd8331a1441e91087f855f43e57f8
@@ -74,11 +82,12 @@ checksums:
Card%20not%20found/singular: 91509e2f92b0b3b11330b6983139fdbf
Card%20title/singular: 7c34f59f4005e6cb3a6ff546ea0b96e3
Change%20Password/singular: a552fc5c4189ebc3e2e6018edda7d18f
Change%20the%20language%20of%20the%20app./singular: fb20db145e28ed44aba89c146ded7bfc
Change%20your%20language%20preferences./singular: 293d49fc3c75e9c425b64bd7126e6b46
Check%20your%20inbox/singular: e9a430fcd298def74212238df0f680d6
Checklist%20name/singular: 5eb5de823f7ca5a4d97bb41e6a3f675a
Clear%20filters/singular: 8f40ab5af527e4b190da94e7b6221379
Click%20on%20the%20link%20we've%20sent%20to%20%7BmagicLinkRecipient%7D%20to%20sign%20in./singular: 210b6ff8727f976182ec3f29ea3c7667
Close/singular: 2c2e22f8424a1031de89063bd0022e16
Code%20Review/singular: a2da6c2339301e7c3ddf068c4ff9f7e8
Collaborate%20seamlessly%20with%20your%20team./singular: c5f10e431aaf8b51519bc009a4a4080c
Coming%20soon/singular: ee2b0671e00972773210c5be5a9ccb89
@@ -99,9 +108,11 @@ checksums:
Continue%20with%20%7B0%7D/singular: 2eaf6e1da91e208f7c5fb6bf862fe8a6
Control%20who%20can%20view%20and%20edit%20your%20boards./singular: 2a7e0bec29bac26280de707e2fe8bce5
Create%20another/singular: 2de8a82a416eb78c0462aa36278edc9a
Create%20API%20key/singular: 70ed8431c6ed5f7fdef122cc34f75b41
Create%20board/singular: 155b62818bfab0e34f0089e1b34a32f3
Create%20card/singular: 32792935dd5837a9433909b04021c22b
Create%20checklist/singular: 5cbca15a7004558c4e6d381f83a34da2
Create%20invite%20link/singular: bef23fe786978f7abf78dece49a35a26
Create%20label/singular: c64e8180fa956f005edc1fd9b8e65abb
Create%20list/singular: 858c3d514aa95765ec82114393199144
Create%20new%20board/singular: cbb87b1f4cc46b336ad9510c9f19407c
@@ -111,12 +122,14 @@ checksums:
Create%20workspace/singular: 2e6718e79964ea5ce22d76c2189c77ca
created%20the%20card/singular: 605475f5aaeb4dbccbf7c4eb9107b43d
Critical/singular: eb327cd411b50aee954f8d1d215d003a
Crop%20your%20avatar/singular: eb25e2d5972ec36a0b40481c8136ab15
Current%20password%20is%20required/singular: 72536bca9598680027f2be8ce80ac280
Custom%20domain/singular: b09e7a9c187b7163b4a6cfc78042fe42
Custom%20URLs%20require%20upgrading%20to%20a%20Pro%20plan/singular: f7275e3b473b8f7b39dab6b37eb26fea
Custom%20workspace%20URL/singular: 7ba841d0946eb04fa3d17d74365be37b
Customer%20Support/singular: 50e3c77e22e41061ca85ea2f02625a2e
Dark/singular: 73e6e208ba628b26e90fcf6dce15e1b2
Deactivate%20invite%20link/singular: 8cf4bf82e153e4dc82d026448cb4dd10
Delete/singular: 8bcf303dd10a645b5baacb02b47d72c9
Delete%20account/singular: a9d11113f1a1d7e20582bdcc9633bcef
Delete%20board/singular: e915cd160651ad4b61a67e8da0737c23
@@ -155,7 +168,10 @@ checksums:
Enter%20your%20name/singular: cd95fbdd0533f2c2e8edf9d9bd9aa8df
Enter%20your%20new%20password/singular: c67251e3002b68bc20a7cf5de23e43ac
Enter%20your%20password/singular: ea4fdd034522dead21bae0c0abb52eae
Error/singular: 3c95bcb32c2104b99a46f5b3dd015248
Error%20Changing%20Password/singular: ebecb5c1b72ba4b063117241f5ba4f2d
Error%20creating%20invite%20link/singular: cbedc3f3213dfc4fdc8b7503ae1a5cd6
Error%20deactivating%20invite%20link/singular: ccf42cd5aa8481692003e87e836de66c
Error%20deleting%20account/singular: d42965a9bc9e5ec4ed57890268924643
Error%20deleting%20label/singular: 94387e3a45ec768ae7715701ae00136e
Error%20deleting%20workspace/singular: 0aec9bd8170bc84f5ea5c9a47c52ed26
@@ -172,6 +188,8 @@ checksums:
Everything%20in%20the%20free%20plan%2C%20plus%3A/singular: 62b44c4973b92b806c69a4b15e0256dc
Everything%20you%20need%2C%20free%20forever.%20Unlimited%20boards%2C%20unlimited%20lists%2C%20unlimited%20cards.%20Upgrade%20any%20time./singular: fa21632ab1468edf10acda2fe7b71323
Execution/singular: cbac4a3c721123cbc6a883560bf29800
Failed%20to%20accept%20invitation.%20Please%20try%20again%20later%2C%20or%20contact%20customer%20support./singular: e4505a9df3a81e93a8a8b103c6e3ebc4
Failed%20to%20copy%20invite%20link/singular: 635884d5ed8d6ee20b85a003939b4ae7
Failed%20to%20login%20with%20%7B0%7D.%20Please%20try%20again./singular: 669a4b4247a73f53fb9b8b16e42d166f
FAQs/singular: dc36d7992372ccd419b39ef51cf5b16c
Feature/singular: 58f5f3f37862b6312a2f20ec1a1fd0e8
@@ -196,6 +214,7 @@ checksums:
Get%20started%20on%20Cloud/singular: ed926f526266ad2063283c58e6f4284a
Getting%20started/singular: 8e5e7bd026b5bec46bbfdce02ab9e0b8
GitHub/singular: 6e1cf3c00fa6fbe24afcc78ea3b5f3e4
Go%20Home/singular: 6251589da1964d55afabdfd64c84c335
Go%20to%20app/singular: 896d0441384dcd2bfb3b23d61ff1944d
High%20Priority/singular: 5d231ff8254aabc875f194c4b4f49c97
Hired/singular: e5a9b1bd409b007141fe3d7890022f9a
@@ -216,15 +235,21 @@ checksums:
Important/singular: 4cf0e8fc8e4e7c5c9b9458059a172a2b
Importing%20from%20Trello/singular: 1e3a46fb9bd8ce8c7d632f9555b5bd7c
Importing%20your%20Trello%20boards%20into%20Kan%20is%20easy.%20You%20can%20follow%20our%20step-by-step%20guide%20%3C0%3Ehere%3C%2F0%3E./singular: ca88dc22801fef08fee1ecfb1bf6914e
in/singular: 1f06ad6763244d5e9927853e65cc6274
In%20Progress/singular: 3de9afebcb9d4ce8ac42e14995f79ffd
Individuals/singular: 83bfc81f20e60556c44ec80c7d0603ec
Integrations/singular: 0ccce343287704cd90150c32e2fcad36
Interviewing/singular: 4ccdcdc784547e925077c3297bddee95
Invalid%20email%20address/singular: b2d9f25626f2d15c7c63e0281bccc247
Invalid%20invitation/singular: 4b936a8811a2295a5f58b41473c608f3
Invite/singular: 181884cea804cbde665f160811ee7ad0
Invite%20another/singular: acb543563dab7edbcf46060a53ade3a3
Invite%20link%20copied/singular: 4046f23a78e1cd5166671c3fb8a7ea6e
Invite%20link%20copied%20to%20clipboard/singular: 6fc055a0ea0ed1aa58c5e0c502efe17f
Invite%20links%20require%20a%20Team%20or%20Pro%20subscription.%20Please%20upgrade%20your%20workspace./singular: 18db646fc55f99789572c8df0539ee69
Invite%20member/singular: ade922db1be6b26bc979565ce5de2bc7
Inviting%20members%20requires%20a%20Team%20Plan.%20You'll%20be%20redirected%20to%20upgrade%20your%20workspace./singular: 03eeed2d715e770259f722ba48a61ab3
Join%20workspace/singular: f5d035df672b05abd760bd022309c719
Join%20workspace%20%7C%20kan.bn/singular: 97855216a0e00214b2dcf917e93164f2
Junior/singular: ed1bd2c59a824fdcdd56fc8a0660fe9f
Kanban%20is%20better%20with%20a%20team.%20Perfect%20for%20small%20and%20growing%20teams%20looking%20to%20collaborate./singular: a77bee43046b260797c8936ad23e9223
Kanban%20reimagined/singular: 613ccfdd9f54c66cbf68cfa313498766
@@ -259,6 +284,7 @@ checksums:
Name/singular: 9368b5a047572b6051f334af5aa76819
Need%20help%3F/singular: 04e7322f2d3ffb2d73ff2f64b71637c8
New/singular: 126d036fae5fb6b629728ecb97e6195b
New%20API%20key/singular: db3088aedba6e4a99b46451c5b3d36ed
New%20board/singular: 63f4e979e29a7fc2f5c09ff91fa75966
New%20card/singular: a33f6219a756127f91c2523cfe845a19
New%20checklist/singular: 58252b71e9693ae0f4d2d0b72108a569
@@ -275,6 +301,7 @@ checksums:
No%20boards%20found/singular: e044088d2c51b6bdf660fafa86ee1e17
No%20credit%20card%20required/singular: 2090aa4171dc0b60735069f89b592883
No%20lists/singular: cedf633d99c77ff4356e089f2d98c0a6
No%20results%20found%20for%20%22%7BdebouncedQuery%7D%22./singular: 5db6294712528cd897b15ae36f4fd834
Offer/singular: 82b4e0c9a3f5b4bd93590847de7c32a1
Onboarding/singular: 52b23f9c62ff199d4c09920e7641829e
Once%20you%20delete%20your%20account%2C%20there%20is%20no%20going%20back.%20This%20action%20cannot%20be%20undone./singular: 9cf7aa6ef30890e5124e266c081bae1c
@@ -284,8 +311,11 @@ checksums:
Own%20your%20data/singular: cc2178dac4bdf6b07f030cfc2a7510e6
Part-time/singular: 213d63da450f35dabb3ab0e35e29feed
Password%20Changed/singular: 1fcebe9ddb46f722a57f195efddc695d
Password%20is%20required%20to%20login./singular: a09c76294ca7b27b38658b34618a7119
Password%20is%20required%20to%20sign%20up./singular: 3fa1abbf2e6ac8ac4d18c215191bdbcc
Password%20must%20be%20at%20least%208%20characters/singular: 4c30501d085eaccea47af34212bb26a7
Passwords%20do%20not%20match/singular: 37ca1f4e0afc9a0b8e9617f767103c92
Paused/singular: edb1f7b7219e1c9b7aa67159090d6991
Payment%20frequency/singular: 63ded0e4ffb462ca8bd33d38e4691d86
Pending/singular: 030a6f3395d5d4efddd3cc67d6009039
per%20user%2Fmonth/singular: 72af182c1ba6df6732640f4d8a78d360
@@ -297,6 +327,7 @@ checksums:
Please%20enter%20a%20valid%20password/singular: 4b32c17e19b79bcbf0bb092c06ba310f
Please%20select%20a%20file%20to%20upload./singular: de315bf594047f8ef9307a7fa9285844
Please%20try%20again%20later%2C%20or%20contact%20customer%20support./singular: 21ffcf0b00e7cd7b64f7454a95762e1d
Please%20try%20again%20later./singular: 325dea6dd0348a27a6818db2c1340c98
Pricing/singular: ce27f1aeacccc542a174c4b2bce022b0
Priority%20email%20support/singular: 678538c912a770b1e1416ecdb8e299b1
Privacy%20policy/singular: 462c6a536b52873e4498785c66dd48c8
@@ -324,13 +355,13 @@ checksums:
Resolution/singular: 6d8bd9e1bd7dae5ae38c93061d32990e
Resources/singular: ec7fb05ed963bb6781a35782b3475502
Review/singular: 299f75db25382980b2895622d7712927
Revoke/singular: be57685a85b6dfeaeb6eab4e9560b520
Roadmap/singular: c60f4a1acf30e566861bf130f13b9ae7
Role/singular: 53743bbb6ca938f5b893552e839d067f
Run%20on%20your%20own%20infrastructure/singular: eba804911562b8dbf9d69c3e27f1d708
Save/singular: f7a2929f33bc420195e59ac5a8bcd454
Save%20time%20with%20reusable%20board%20templates./singular: d0f2d7d0fd682ceaf4ca12c6353fd75a
Screening/singular: 0327bfb25db87bdacf00d4dc297f8b26
Search%20boards%20and%20cards.../singular: 137017b2b3e885c4894ca8c899af5bc2
Select%20all/singular: eedc7cdb02de467c15dc418a066a77f2
Select%20source/singular: c7b0ec447415ff62856803a4d7838bd6
Self%20Host/singular: 43fbce2027548b712002e489a20a621a
@@ -339,8 +370,14 @@ checksums:
Send%20feedback/singular: 9631cc08d49da04475b30a0d320ce97c
Senior/singular: 3fff865dc00435f82896fc302ea45630
Settings/singular: 8df6777277469c1fd88cc18dde2f1cc3
Settings%20%7C%20%7B0%7D/singular: b8fc73080bc9c8f4f1403b2a69bd1ac5
Settings%20%7C%20Account/singular: 050e18406849ec057edac877c297c3e1
Settings%20%7C%20API/singular: 85101e4b802a09ad9e3f01ff116f0894
Settings%20%7C%20Billing/singular: e44cba741d5414035a0b499c5766c203
Settings%20%7C%20Integrations/singular: d04992e28016452f6d3d7dcc0b592415
Settings%20%7C%20Workspace/singular: 5d0bacf7ff696da940f232df45edfd39
Sign%20in/singular: cb8757c7450e17de1e226e82fb0fa4a2
Sign%20In/singular: ec7b8f314fe9bc6591006707484ede61
Sign%20Up/singular: 0dd2ae69be4618c1f9e615774a4509ca
Sign%20up%20%7C%20kan.bn/singular: f3de2a110c90358e6eac07d0b2f663a6
Sign%20up%20disabled/singular: 9581b1f75b404ac0ecb7e603e0d4189c
Sign%20up%20is%20currently%20disabled.%20Please%20try%20again%20later./singular: c6cb7c455ec053b351a27029158ff166
@@ -350,6 +387,7 @@ checksums:
Social%20Media/singular: 2227508afb0d38a027e323207e47d4a2
Software%20Development/singular: 67659097ef6bcb194839858b2c4f3a3c
Star%20on%20Github/singular: e5cb0428f1ac6485688f120d78742875
Subscription%20Required/singular: 05f2b4abfc36def17756a6969983cf86
Success/singular: c43827becada6750f7a25890905f38b9
Supercharge%20your%20workspace%20for%20just%20%2429%2Fmonth.%20Here's%20what%20you'll%20get%3A/singular: 21af8119141afcce5f12da489f34db6a
Support/singular: 55aab5fd0f31a9cb055a2edeeedfaf63
@@ -367,8 +405,10 @@ checksums:
Theme/singular: 21fe00b7a518089576fb83c08631107a
They%20won't%20be%20able%20to%20access%20this%20workspace./singular: 93b740350fe3430319fbca85349e41d9
This%20action%20can't%20be%20undone./singular: cb222ff89715d8c971e8c25d121e1dbd
This%20API%20key%20will%20only%20be%20shown%20once.%20Please%20save%20it%20in%20a%20secure%20location./singular: 7df18d2978d317375f780822f8321c4d
This%20board%20is%20private%20or%20does%20not%20exist/singular: a217ff3f04463b4df8c86adb6f83c6bc
This%20board%20URL%20has%20already%20been%20taken/singular: 1d8b40332a031b5b77a3658e48dd51ca
This%20invitation%20link%20is%20invalid%20or%20has%20expired./singular: 11cc7ef8f1512e7e058e1fbbe5644001
This%20will%20result%20in%20the%20permanent%20deletion%20of%20all%20data%20associated%20with%20this%20workspace./singular: a31141558af793635c1ddd2fa0a33499
This%20will%20result%20in%20the%20permanent%20deletion%20of%20all%20data%20associated%20with%20your%20account./singular: b49224632bd6c3b7f5e462912aeb1081
This%20workspace%20URL%20has%20already%20been%20taken/singular: b455329e2a71da677acab91d3a00bad6
@@ -377,6 +417,7 @@ checksums:
To%20Do/singular: d60813ea824f373462471e092d136eed
Toggle%20menu/singular: 29dea3e0b6238874f8c7a27619df8e36
Track%20all%20card%20changes%20with%20detailed%20activity%20history./singular: 0d3bac559c71ec4b8734f9f212320de5
Trello/singular: b5131f6488b5a439d58db3e22d6de45b
Trello%20disconnected/singular: 54b24a3e6c9a7eedd8c8d1060ab1175d
Trello%20imports/singular: 6827eca403faa8f89891d17827e72af9
Triaging/singular: 1d40799fcae53a8a27688fdae2a48dee
@@ -468,6 +509,8 @@ checksums:
You%20have%20been%20logged%20in%20successfully./singular: ef8fad1dce13ae4112f17c5258655fea
You%20have%20been%20signed%20up%20successfully./singular: f614a6e3b45f5ffb9a3b0fb420fef84b
You%20have%20unlimited%20seats%20with%20your%20Pro%20Plan.%20There%20is%20no%20additional%20charge%20for%20new%20members!/singular: e3dc59a5ba7211cd3d8516b3a79d85ca
You've%20been%20invited%20to%20join%20a%20workspace%20on%20kan.bn./singular: 257b840726f972f384243a72767f880f
You've%20been%20invited%20to%20join%20a%20workspace./singular: 24fc6cdc8740f37a83df85f582f03293
Your%20account%20has%20been%20deleted./singular: 8c8d944e07388c5877effdb2c2803dcf
Your%20boards%20have%20been%20imported./singular: 403972e7a25afc2415762c1c2b1ec868
Your%20display%20name%20has%20been%20updated./singular: 15e5fff36c554c16ec5214427fae1bf4

View File

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

View File

@@ -52,6 +52,15 @@ const config = {
// instrumentationHook: true,
swcPlugins: [["@lingui/swc-plugin", {}]],
},
async rewrites() {
return [
{
source: "/settings",
destination: "/settings/account",
},
];
},
};
// Only allow external images when OIDC is configured (for OIDC provider avatars)

View File

@@ -59,6 +59,7 @@
"react-dom": "catalog:react18",
"react-hook-form": "^7.51.1",
"react-icons": "^5.5.0",
"react-image-crop": "^11.0.10",
"react-lottie-player": "^1.5.5",
"react-markdown": "^10.1.0",
"superjson": "2.2.1",

View File

@@ -1,10 +1,11 @@
import type { SocialProvider } from "better-auth/social-providers";
import { useSearchParams } from "next/navigation";
import { zodResolver } from "@hookform/resolvers/zod";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useQuery } from "@tanstack/react-query";
import { env } from "next-runtime-env";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import {
FaApple,
@@ -152,18 +153,29 @@ const availableSocialProviders = {
};
export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
const [isCloudEnv, setIsCloudEnv] = useState(false);
const [isLoginWithProviderPending, setIsLoginWithProviderPending] =
useState<null | AuthProvider>(null);
const [isCredentialsEnabled, setIsCredentialsEnabled] = useState(false);
const [isEmailSendingEnabled, setIsEmailSendingEnabled] = useState(false);
const [isLoginWithEmailPending, setIsLoginWithEmailPending] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
const { showPopup } = usePopup();
const oidcProviderName = "OIDC";
const passwordRef = useRef<HTMLInputElement | null>(null);
const redirect = useSearchParams().get("next");
const callbackURL = redirect ?? "/boards";
// Safely get environment variables on client side to avoid hydration mismatch
useEffect(() => {
const credentialsAllowed =
env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true";
const emailSendingEnabled =
env("NEXT_PUBLIC_DISABLE_EMAIL")?.toLowerCase() !== "true";
const isCloudEnv = env("NEXT_PUBLIC_KAN_ENV") === "cloud";
setIsCloudEnv(isCloudEnv);
setIsEmailSendingEnabled(emailSendingEnabled);
setIsCredentialsEnabled(credentialsAllowed);
}, []);
@@ -183,7 +195,7 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
const handleLoginWithEmail = async (
email: string,
password?: string,
password?: string | null,
name?: string,
) => {
setIsLoginWithEmailPending(true);
@@ -195,7 +207,7 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
name,
email,
password,
callbackURL: "/boards",
callbackURL,
},
{
onSuccess: () =>
@@ -212,7 +224,7 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
{
email,
password,
callbackURL: "/boards",
callbackURL,
},
{
onSuccess: () =>
@@ -226,16 +238,26 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
);
}
} else {
await authClient.signIn.magicLink(
{
email,
callbackURL: "/boards",
},
{
onSuccess: () => setIsMagicLinkSent(true, email),
onError: ({ error }) => setLoginError(error.message),
},
);
// Only allow magic link if email sending is enabled and not in sign up mode
if (isCloudEnv || (isEmailSendingEnabled && !isSignUp)) {
await authClient.signIn.magicLink(
{
email,
callbackURL,
},
{
onSuccess: () => setIsMagicLinkSent(true, email),
onError: ({ error }) => setLoginError(error.message),
},
);
} else {
// Provide a clear error feedback when password omitted but magic link unavailable
setLoginError(
isSignUp
? t`Password is required to sign up.`
: t`Password is required to login.`,
);
}
}
setIsLoginWithEmailPending(false);
@@ -250,14 +272,14 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
// Use oauth2 signin for OIDC provider
const result = await authClient.signIn.oauth2({
providerId: "oidc",
callbackURL: "/boards",
callbackURL,
});
error = result.error;
} else {
// Use social signin for traditional social providers
const result = await authClient.signIn.social({
provider,
callbackURL: "/boards",
callbackURL,
});
error = result.error;
}
@@ -272,11 +294,43 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
};
const onSubmit = async (values: FormValues) => {
await handleLoginWithEmail(values.email, values.password, values.name);
// Treat empty password string as undefined to trigger magic link path
const sanitizedPassword = values.password?.trim()
? values.password
: undefined;
await handleLoginWithEmail(values.email, sanitizedPassword, values.name);
};
const password = watch("password");
// Determine if we should operate in magic link mode for current form state (login only)
const isMagicLinkMode = useMemo(() => {
// Magic link only viable when email sending enabled AND not sign up.
if (!isEmailSendingEnabled || isSignUp) return false;
// If credentials disabled we always default to magic link.
if (!isCredentialsEnabled) return true;
// Credentials enabled: user chooses magic link by leaving password blank.
return !password;
}, [isEmailSendingEnabled, isSignUp, isCredentialsEnabled, password]);
// Auto-focus password field when an error indicates it's required
useEffect(() => {
if (!isCredentialsEnabled) return;
// Focus when: sign up and missing password; login error requiring password; validation error on password.
const pwdEmpty = (password ?? "").length === 0;
let needsPassword = false;
if (isSignUp && pwdEmpty) {
needsPassword = true;
} else if (loginError?.toLowerCase().includes("password")) {
needsPassword = true;
} else if (errors.password) {
needsPassword = true;
}
if (needsPassword && passwordRef.current) {
passwordRef.current.focus();
}
}, [isSignUp, password, loginError, errors.password, isCredentialsEnabled]);
return (
<div className="space-y-6">
{socialProviders?.length !== 0 && (
@@ -347,7 +401,7 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
/>
{errors.password && (
<p className="mt-2 text-xs text-red-400">
{t`Please enter a valid password`}
{errors.password.message ?? t`Please enter a valid password`}
</p>
)}
</div>
@@ -364,9 +418,7 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
variant="secondary"
>
{isSignUp ? t`Sign up with ` : t`Continue with `}
{!isCredentialsEnabled || (password && password.length !== 0)
? t`email`
: t`magic link`}
{isMagicLinkMode ? t`magic link` : t`email`}
</Button>
</div>
</form>

View File

@@ -0,0 +1,193 @@
import { useRouter } from "next/router";
import {
Combobox,
ComboboxInput,
ComboboxOption,
ComboboxOptions,
Dialog,
DialogBackdrop,
DialogPanel,
} from "@headlessui/react";
import { t } from "@lingui/macro";
import { useState } from "react";
import { HiDocumentText, HiFolder, HiMagnifyingGlass } from "react-icons/hi2";
import { useDebounce } from "~/hooks/useDebounce";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
type SearchResult =
| {
publicId: string;
title: string;
description: string | null;
slug: string;
updatedAt: Date | null;
createdAt: Date;
type: "board";
}
| {
publicId: string;
title: string;
description: string | null;
boardPublicId: string;
boardName: string;
listName: string;
updatedAt: Date | null;
createdAt: Date;
type: "card";
};
export default function CommandPallette({
isOpen,
onClose,
}: {
isOpen: boolean;
onClose: () => void;
}) {
const [query, setQuery] = useState("");
const { workspace } = useWorkspace();
const router = useRouter();
// Debounce to avoid too many reqs
const [debouncedQuery] = useDebounce(query, 300);
const {
data: searchResults,
isLoading,
isFetched,
isPlaceholderData,
} = api.workspace.search.useQuery(
{
workspacePublicId: workspace.publicId,
query: debouncedQuery,
},
{
enabled: Boolean(workspace.publicId && debouncedQuery.trim().length > 0),
placeholderData: (previousData) => previousData,
},
);
// Clear results when query is empty, otherwise show search results
const results =
debouncedQuery.trim().length === 0
? []
: ((searchResults ?? []) as SearchResult[]);
const hasSearched = Boolean(debouncedQuery.trim().length > 0);
return (
<Dialog
className="relative z-50"
open={isOpen}
onClose={() => {
onClose();
setQuery("");
}}
>
<DialogBackdrop
transition
className="data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in fixed inset-0 bg-light-50 bg-opacity-40 transition-opacity dark:bg-dark-50 dark:bg-opacity-40"
/>
<div className="fixed inset-0 z-50 w-screen overflow-y-auto">
<div className="flex min-h-full items-start justify-center p-4 text-center sm:items-start sm:p-0">
<DialogPanel
transition
className="data-closed:opacity-0 data-closed:translate-y-4 data-closed:sm:translate-y-0 data-closed:sm:scale-95 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in relative mt-[25vh] w-full max-w-[550px] transform divide-y divide-gray-100 overflow-hidden rounded-lg border border-light-600 bg-white/90 shadow-3xl-light backdrop-blur-[6px] transition-all dark:divide-white/10 dark:border-dark-600 dark:bg-dark-100/90 dark:shadow-3xl-dark"
>
<Combobox>
<div className="grid grid-cols-1">
<ComboboxInput
autoFocus
className="col-start-1 row-start-1 h-12 w-full border-0 bg-transparent pl-11 pr-4 text-sm text-light-900 placeholder:text-light-700 focus:outline-none focus:ring-0 dark:text-dark-900 dark:placeholder:text-dark-700"
placeholder={t`Search boards and cards...`}
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && results.length > 0) {
event.preventDefault();
// Find the active option or fallback to first option
const targetOption =
document.querySelector(
'[data-headlessui-state*="active"][role="option"]',
) ?? document.querySelector('[role="option"]');
if (targetOption) {
(targetOption as HTMLElement).click();
}
}
}}
/>
<HiMagnifyingGlass
className="pointer-events-none col-start-1 row-start-1 ml-4 size-5 self-center text-light-700 dark:text-dark-700"
aria-hidden="true"
/>
</div>
{results.length > 0 && (
<ComboboxOptions
static
className={`max-h-72 scroll-py-2 overflow-y-auto py-2 ${
isPlaceholderData ? "opacity-75" : ""
}`}
>
{results.map((result) => {
const url =
result.type === "board"
? `/boards/${result.publicId}`
: `/cards/${result.publicId}`;
return (
<ComboboxOption
key={`${result.type}-${result.publicId}`}
value={result}
className="cursor-pointer select-none px-4 py-3 data-[focus]:bg-light-200 hover:bg-light-200 focus:outline-none dark:data-[focus]:bg-dark-200 dark:hover:bg-dark-200"
onClick={() => {
console.log("clicked", url);
void router.push(url);
onClose();
setQuery("");
}}
>
<div className="flex items-start gap-3">
<div className="mt-0.5 flex-shrink-0">
{result.type === "board" ? (
<HiFolder className="h-4 w-4 text-light-600 dark:text-dark-600" />
) : (
<HiDocumentText className="h-4 w-4 text-light-600 dark:text-dark-600" />
)}
</div>
<div className="min-w-0 flex-1 text-left">
<div className="truncate text-sm font-bold text-light-900 dark:text-dark-900">
{result.title}
</div>
{result.type === "card" && (
<div className="truncate text-xs text-light-700 dark:text-dark-700">
{`${t`in`} ${result.boardName}${result.listName}`}
</div>
)}
</div>
</div>
</ComboboxOption>
);
})}
</ComboboxOptions>
)}
{hasSearched &&
!isLoading &&
searchResults !== undefined &&
results.length === 0 && (
<div className="p-4 text-sm text-light-950 dark:text-dark-950">
{t`No results found for "${debouncedQuery}".`}
</div>
)}
</Combobox>
</DialogPanel>
</div>
</div>
</Dialog>
);
}

View File

@@ -15,7 +15,7 @@ export default function Dropdown({
<div>
<Menu.Button
disabled={disabled}
className="flex h-7 w-7 items-center justify-center rounded-[5px] hover:bg-light-200 dark:hover:bg-dark-200"
className="flex h-7 w-7 items-center justify-center rounded-[5px] hover:bg-light-200 focus:outline-none dark:hover:bg-dark-200"
>
{children}
</Menu.Button>
@@ -30,7 +30,7 @@ export default function Dropdown({
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="absolute right-0 z-30 mt-2 w-56 origin-top-right rounded-md border border-light-200 bg-light-50 p-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-400 dark:bg-dark-300">
<Menu.Items className="absolute right-0 z-50 mt-2 w-56 origin-top-right rounded-md border border-light-200 bg-light-50 p-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-400 dark:bg-dark-300">
<div className="flex flex-col">
{items.map((item) => (
<Menu.Item key={item.label}>

View File

@@ -13,7 +13,7 @@ export function LanguageSelector() {
id="language-select"
value={locale}
onChange={(e) => setLocale(e.target.value as any)}
className="mt-8 block w-full max-w-[180px] rounded-lg border-0 bg-light-50 pl-10 shadow-sm ring-1 ring-inset ring-light-300 focus:ring-2 focus:ring-inset focus:ring-light-400 dark:bg-dark-50 dark:text-dark-1000 dark:ring-dark-300 dark:focus:ring-dark-500 sm:text-sm"
className="mt-8 block w-full max-w-[180px] rounded-lg border-0 bg-light-50 pl-10 text-sm shadow-sm ring-1 ring-inset ring-light-300 focus:ring-2 focus:ring-inset focus:ring-light-400 dark:bg-dark-50 dark:text-dark-1000 dark:ring-dark-300 dark:focus:ring-dark-500"
>
{availableLocales.map((loc) => (
<option key={loc} value={loc}>

View File

@@ -123,7 +123,7 @@ export function NewWorkspaceForm() {
body: JSON.stringify({
slug: slug || undefined,
workspacePublicId: values.publicId,
cancelUrl: "/settings?upgrade=pro",
cancelUrl: "/settings/workspace?upgrade=pro",
successUrl: "/boards",
}),
},

View File

@@ -0,0 +1,148 @@
import Link from "next/link";
import { useRouter } from "next/router";
import {
Listbox,
ListboxButton,
ListboxOption,
ListboxOptions,
} from "@headlessui/react";
import { t } from "@lingui/core/macro";
import { env } from "next-runtime-env";
import { useEffect, useState } from "react";
import {
HiChevronDown,
HiOutlineBanknotes,
HiOutlineCodeBracketSquare,
HiOutlineRectangleGroup,
HiOutlineUser,
} from "react-icons/hi2";
interface SettingsLayoutProps {
children: React.ReactNode;
currentTab: string;
}
export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
const router = useRouter();
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
const settingsTabs = [
{
key: "account",
icon: <HiOutlineUser />,
label: t`Account`,
condition: true,
},
{
key: "workspace",
icon: <HiOutlineRectangleGroup />,
label: t`Workspace`,
condition: true,
},
{
key: "billing",
label: t`Billing`,
icon: <HiOutlineBanknotes />,
condition: env("NEXT_PUBLIC_KAN_ENV") === "cloud",
},
{
key: "api",
icon: <HiOutlineCodeBracketSquare />,
label: t`API`,
condition: true,
},
{
key: "integrations",
icon: <HiOutlineCodeBracketSquare />,
label: t`Integrations`,
condition: true,
},
];
const availableTabs = settingsTabs.filter((tab) => tab.condition);
// Update selected tab when currentTab prop changes
useEffect(() => {
const tabIndex = availableTabs.findIndex((tab) => tab.key === currentTab);
if (tabIndex !== -1) {
setSelectedTabIndex(tabIndex);
}
}, [currentTab, availableTabs]);
const isTabActive = (tabKey: string) => {
return currentTab === tabKey;
};
return (
<div className="flex h-full w-full flex-col overflow-hidden">
<div className="h-full max-h-[calc(100vdh-3rem)] overflow-y-auto md:max-h-[calc(100vdh-4rem)]">
<div className="m-auto max-w-[1100px] px-5 py-6 md:px-28 md:py-12">
<div className="mb-8 flex w-full justify-between">
<h1 className="font-bold tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
{t`Settings`}
</h1>
</div>
<div className="focus:outline-none">
<div className="sm:hidden">
{/* Mobile dropdown */}
<Listbox
value={selectedTabIndex}
onChange={(index) => {
const tabKey = availableTabs[index]?.key;
if (tabKey) {
void router.push(`/settings/${tabKey}`);
}
}}
>
<div className="relative mb-4">
<ListboxButton className="w-full appearance-none rounded-lg border-0 bg-light-50 py-2 pl-3 pr-10 text-left text-sm text-light-1000 shadow-sm ring-1 ring-inset ring-light-300 focus:ring-2 focus:ring-inset focus:ring-light-400 dark:bg-dark-50 dark:text-dark-1000 dark:ring-dark-300 dark:focus:ring-dark-500">
{availableTabs[selectedTabIndex]?.label || "Select a tab"}
<HiChevronDown
aria-hidden="true"
className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-light-900 dark:text-dark-900"
/>
</ListboxButton>
<ListboxOptions className="absolute z-10 mt-1 w-full rounded-lg bg-light-50 py-1 text-sm shadow-lg ring-1 ring-inset ring-light-300 dark:bg-dark-50 dark:ring-dark-300">
{availableTabs.map((tab) => (
<ListboxOption
key={tab.key}
value={availableTabs.indexOf(tab)}
className="relative cursor-pointer select-none py-2 pl-3 pr-9 text-light-1000 dark:text-dark-1000"
>
{tab.label}
</ListboxOption>
))}
</ListboxOptions>
</div>
</Listbox>
</div>
<div className="hidden sm:block">
<div className="border-b border-gray-200 dark:border-white/10">
<nav
aria-label="Tabs"
className="-mb-px flex space-x-8 focus:outline-none"
>
{availableTabs.map((tab) => (
<Link
key={tab.key}
href={`/settings/${tab.key}`}
className={`whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium transition-colors focus:outline-none ${
isTabActive(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}
</Link>
))}
</nav>
</div>
</div>
<div className="focus:outline-none">{children}</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -5,10 +5,12 @@ const Toggle = ({
isChecked,
onChange,
label,
disabled,
}: {
isChecked: boolean;
onChange: () => void;
label: string;
disabled?: boolean;
}) => (
<div className="mr-4 flex items-center justify-end">
<span className="mr-2 text-xs text-light-900 dark:text-dark-900">
@@ -17,6 +19,7 @@ const Toggle = ({
<Switch
checked={isChecked}
onChange={onChange}
disabled={disabled}
className={twMerge(
"relative inline-flex h-4 w-6 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent bg-light-800 transition-colors duration-200 ease-in-out focus:outline-none dark:bg-dark-800",
isChecked && "bg-indigo-600 dark:bg-indigo-600",

View File

@@ -1,11 +1,12 @@
import { Menu, Transition } from "@headlessui/react";
import { Button, Menu, Transition } from "@headlessui/react";
import { t } from "@lingui/core/macro";
import { Fragment } from "react";
import { HiCheck } from "react-icons/hi2";
import { Fragment, useEffect, useState } from "react";
import { HiCheck, HiMagnifyingGlass } from "react-icons/hi2";
import { twMerge } from "tailwind-merge";
import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
import CommandPallette from "./CommandPallette";
export default function WorkspaceMenu({
isCollapsed = false,
@@ -15,110 +16,143 @@ export default function WorkspaceMenu({
const { workspace, isLoading, availableWorkspaces, switchWorkspace } =
useWorkspace();
const { openModal } = useModal();
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key === "k") {
event.preventDefault();
setIsOpen(true);
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, []);
return (
<Menu as="div" className="relative inline-block w-full pb-3 text-left">
<div>
{isLoading ? (
<div className={twMerge("mb-1 flex", isCollapsed && "md:p-1.5")}>
<div className="h-6 w-6 animate-pulse rounded-md bg-light-200 dark:bg-dark-200" />
<div
className={twMerge(
"ml-2 h-6 w-[150px] animate-pulse rounded-md bg-light-200 dark:bg-dark-200",
isCollapsed && "md:hidden",
)}
/>
</div>
) : (
<Menu.Button
className={twMerge(
"mb-1 flex h-[34px] w-full items-center rounded-md p-1.5 hover:bg-light-200 dark:hover:bg-dark-200",
isCollapsed && "md:mb-1.5 md:h-9 md:p-1",
)}
title={isCollapsed ? workspace.name : undefined}
>
<span className="inline-flex h-6 w-6 items-center justify-center rounded-md bg-indigo-700">
<span className="text-xs font-bold leading-none text-white">
{workspace.name.charAt(0).toUpperCase()}
</span>
</span>
<span
className={twMerge(
"ml-2 text-sm font-bold text-neutral-900 dark:text-dark-1000",
isCollapsed && "md:hidden",
)}
>
{workspace.name}
</span>
{workspace.plan === "pro" && (
<span
<>
<CommandPallette isOpen={isOpen} onClose={() => setIsOpen(false)} />
<Menu as="div" className="relative inline-block w-full pb-3 text-left">
<div>
{isLoading ? (
<div className={twMerge("mb-1 flex", isCollapsed && "md:p-1.5")}>
<div className="h-6 w-6 animate-pulse rounded-md bg-light-200 dark:bg-dark-200" />
<div
className={twMerge(
"ml-2 inline-flex items-center rounded-md bg-indigo-100 px-2 py-1 text-[10px] font-medium text-indigo-700",
"ml-2 h-6 w-[150px] animate-pulse rounded-md bg-light-200 dark:bg-dark-200",
isCollapsed && "md:hidden",
)}
/>
</div>
) : (
<div
className={twMerge(
"flex items-center gap-1",
isCollapsed && "md:flex-col-reverse md:items-stretch",
)}
>
<Menu.Button
className={twMerge(
"mb-1 flex h-[34px] flex-1 items-center rounded-md p-1.5 hover:bg-light-200 dark:hover:bg-dark-200",
isCollapsed &&
"md:mb-1.5 md:h-9 md:w-9 md:flex-none md:justify-center md:p-0",
)}
title={isCollapsed ? workspace.name : undefined}
>
Pro
</span>
)}
</Menu.Button>
)}
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items
className={twMerge(
"absolute left-0 z-10 origin-top-left rounded-md border border-light-600 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-600 dark:bg-dark-300",
isCollapsed ? "w-48" : "w-full",
)}
>
<div className="p-1">
{availableWorkspaces.map((availableWorkspace) => (
<div key={availableWorkspace.publicId} className="flex">
<Menu.Item>
<button
onClick={() => switchWorkspace(availableWorkspace)}
className="flex w-full items-center justify-between rounded-[5px] px-3 py-2 text-left text-sm text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
>
<div>
<span className="inline-flex h-5 w-5 items-center justify-center rounded-[5px] bg-indigo-700">
<span className="text-xs font-medium leading-none text-white">
{availableWorkspace.name.charAt(0).toUpperCase()}
</span>
</span>
<span className="ml-2 text-xs font-medium">
{availableWorkspace.name}
</span>
</div>
{workspace.name === availableWorkspace.name && (
<span>
<HiCheck className="h-4 w-4" aria-hidden="true" />
</span>
<span className="inline-flex h-6 w-6 items-center justify-center rounded-md bg-indigo-700">
<span className="text-xs font-bold leading-none text-white">
{workspace.name.charAt(0).toUpperCase()}
</span>
</span>
<span
className={twMerge(
"ml-2 truncate text-sm font-bold text-neutral-900 dark:text-dark-1000",
isCollapsed && "md:hidden",
)}
>
{workspace.name}
</span>
{workspace.plan === "pro" && (
<span
className={twMerge(
"ml-2 inline-flex items-center rounded-md bg-indigo-100 px-2 py-1 text-[10px] font-medium text-indigo-700",
isCollapsed && "md:hidden",
)}
</button>
</Menu.Item>
</div>
))}
</div>
<div className="border-t-[1px] border-light-600 p-1 dark:border-dark-500">
<Menu.Item>
<button
onClick={() => 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"
>
Pro
</span>
)}
</Menu.Button>
<Button
className={twMerge(
"mb-1 h-[34px] w-[34px] flex-shrink-0 rounded-lg bg-light-200 p-2 hover:bg-light-300 focus:outline-none dark:bg-dark-200 dark:hover:bg-dark-300",
isCollapsed && "md:mb-2 md:h-9 md:w-9 md:self-center",
)}
onClick={() => setIsOpen(true)}
>
{t`Create workspace`}
</button>
</Menu.Item>
</div>
</Menu.Items>
</Transition>
</Menu>
<HiMagnifyingGlass className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
)}
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items
className={twMerge(
"absolute left-0 z-10 origin-top-left rounded-md border border-light-600 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-600 dark:bg-dark-300",
isCollapsed ? "w-48" : "w-full",
)}
>
<div className="p-1">
{availableWorkspaces.map((availableWorkspace) => (
<div key={availableWorkspace.publicId} className="flex">
<Menu.Item>
<button
onClick={() => switchWorkspace(availableWorkspace)}
className="flex w-full items-center justify-between rounded-[5px] px-3 py-2 text-left text-sm text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
>
<div className="flex min-w-0 flex-1 items-center">
<span className="inline-flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-[5px] bg-indigo-700">
<span className="text-xs font-medium leading-none text-white">
{availableWorkspace.name.charAt(0).toUpperCase()}
</span>
</span>
<span className="ml-2 truncate text-xs font-medium">
{availableWorkspace.name}
</span>
</div>
{workspace.publicId === availableWorkspace.publicId && (
<span>
<HiCheck className="h-4 w-4" aria-hidden="true" />
</span>
)}
</button>
</Menu.Item>
</div>
))}
</div>
<div className="border-t-[1px] border-light-600 p-1 dark:border-dark-500">
<Menu.Item>
<button
onClick={() => 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`}
</button>
</Menu.Item>
</div>
</Menu.Items>
</Transition>
</Menu>
</>
);
}

View File

@@ -34,7 +34,7 @@ const Modal: React.FC<Props> = ({
return (
<Transition.Root show={shouldShow} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={closeModal}>
<Dialog as="div" className="relative z-50" onClose={closeModal}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
@@ -47,7 +47,7 @@ const Modal: React.FC<Props> = ({
<div className="fixed inset-0 bg-light-50 bg-opacity-40 transition-opacity dark:bg-dark-50 dark:bg-opacity-40" />
</Transition.Child>
<div className="fixed inset-0 z-10 w-screen overflow-y-auto">
<div className="fixed inset-0 z-50 w-screen overflow-y-auto">
<div className="flex min-h-full items-start justify-center p-4 text-center sm:items-start sm:p-0">
<Transition.Child
as={Fragment}

View File

@@ -0,0 +1,36 @@
import { useState } from "react";
export function useClipboard({ timeout = 500 } = {}) {
const [error, setError] = useState<string | Error | null | undefined>(null);
const [copied, setCopied] = useState<boolean>(false);
const [copyTimeout, setCopyTimeout] = useState<number | undefined>(undefined);
const handleCopyResult = (hasError: boolean) => {
clearTimeout(copyTimeout);
setCopyTimeout(
setTimeout(() => setCopied(false), timeout) as unknown as number,
);
setCopied(hasError);
};
const copy = (value: string) => {
if ("clipboard" in navigator) {
navigator.clipboard
.writeText(value)
.then(() => handleCopyResult(true))
.catch((err) => setError(err));
} else {
setError(new Error("Error: navigator.clipboard is not supported"));
}
};
const reset = () => {
setError(null);
setCopied(false);
clearTimeout(copyTimeout);
};
return { copy, reset, error, copied };
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +1,4 @@
export const locales = ["en", "fr", "de", "es", "it", "nl"] as const;
export const locales = ["en", "fr", "de", "es", "it", "nl", "ru"] as const;
export type Locale = (typeof locales)[number];
@@ -11,4 +11,5 @@ export const localeNames: Record<Locale, string> = {
es: "Español",
it: "Italiano",
nl: "Nederlands",
ru: "Русский",
};

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -7,6 +7,7 @@ import type { ReactElement, ReactNode } from "react";
import { Plus_Jakarta_Sans } from "next/font/google";
import Script from "next/script";
import { env } from "next-runtime-env";
import { ThemeProvider } from "next-themes";
import posthog from "posthog-js";
import { PostHogProvider } from "posthog-js/react";
import { useEffect } from "react";
@@ -14,7 +15,6 @@ import { useEffect } from "react";
import { LinguiProviderWrapper } from "~/providers/lingui";
import { ModalProvider } from "~/providers/modal";
import { PopupProvider } from "~/providers/popup";
import { ThemeProvider } from "next-themes";
import { api } from "~/utils/api";
const jakarta = Plus_Jakarta_Sans({
@@ -82,8 +82,8 @@ const MyApp: AppType = ({ Component, pageProps }: AppPropsWithLayout) => {
<main className="font-sans">
<LinguiProviderWrapper>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<ModalProvider>
<PopupProvider>
<ModalProvider>
<PopupProvider>
{posthogKey ? (
<PostHogProvider client={posthog}>
{getLayout(<Component {...pageProps} />)}
@@ -91,8 +91,8 @@ const MyApp: AppType = ({ Component, pageProps }: AppPropsWithLayout) => {
) : (
getLayout(<Component {...pageProps} />)
)}
</PopupProvider>
</ModalProvider>
</PopupProvider>
</ModalProvider>
</ThemeProvider>
</LinguiProviderWrapper>
</main>

View File

@@ -0,0 +1,5 @@
import InviteView from "~/views/invite";
export default function InvitePage() {
return <InviteView />;
}

View File

@@ -0,0 +1,16 @@
import type { NextPageWithLayout } from "~/pages/_app";
import { getDashboardLayout } from "~/components/Dashboard";
import { SettingsLayout } from "~/components/SettingsLayout";
import AccountSettings from "~/views/settings/AccountSettings";
const AccountSettingsPage: NextPageWithLayout = () => {
return (
<SettingsLayout currentTab="account">
<AccountSettings />
</SettingsLayout>
);
};
AccountSettingsPage.getLayout = (page) => getDashboardLayout(page);
export default AccountSettingsPage;

View File

@@ -0,0 +1,16 @@
import type { NextPageWithLayout } from "~/pages/_app";
import { getDashboardLayout } from "~/components/Dashboard";
import { SettingsLayout } from "~/components/SettingsLayout";
import ApiSettings from "~/views/settings/ApiSettings";
const ApiSettingsPage: NextPageWithLayout = () => {
return (
<SettingsLayout currentTab="api">
<ApiSettings />
</SettingsLayout>
);
};
ApiSettingsPage.getLayout = (page) => getDashboardLayout(page);
export default ApiSettingsPage;

View File

@@ -0,0 +1,16 @@
import type { NextPageWithLayout } from "~/pages/_app";
import { getDashboardLayout } from "~/components/Dashboard";
import { SettingsLayout } from "~/components/SettingsLayout";
import BillingSettings from "~/views/settings/BillingSettings";
const BillingSettingsPage: NextPageWithLayout = () => {
return (
<SettingsLayout currentTab="billing">
<BillingSettings />
</SettingsLayout>
);
};
BillingSettingsPage.getLayout = (page) => getDashboardLayout(page);
export default BillingSettingsPage;

View File

@@ -1,17 +0,0 @@
import type { NextPageWithLayout } from "~/pages/_app";
import { getDashboardLayout } from "~/components/Dashboard";
import Popup from "~/components/Popup";
import SettingsView from "~/views/settings";
const SettingsPage: NextPageWithLayout = () => {
return (
<>
<SettingsView />
<Popup />
</>
);
};
SettingsPage.getLayout = (page) => getDashboardLayout(page);
export default SettingsPage;

View File

@@ -0,0 +1,16 @@
import type { NextPageWithLayout } from "~/pages/_app";
import { getDashboardLayout } from "~/components/Dashboard";
import { SettingsLayout } from "~/components/SettingsLayout";
import IntegrationsSettings from "~/views/settings/IntegrationsSettings";
const IntegrationsSettingsPage: NextPageWithLayout = () => {
return (
<SettingsLayout currentTab="integrations">
<IntegrationsSettings />
</SettingsLayout>
);
};
IntegrationsSettingsPage.getLayout = (page) => getDashboardLayout(page);
export default IntegrationsSettingsPage;

View File

@@ -0,0 +1,16 @@
import type { NextPageWithLayout } from "~/pages/_app";
import { getDashboardLayout } from "~/components/Dashboard";
import { SettingsLayout } from "~/components/SettingsLayout";
import WorkspaceSettings from "~/views/settings/WorkspaceSettings";
const WorkspaceSettingsPage: NextPageWithLayout = () => {
return (
<SettingsLayout currentTab="workspace">
<WorkspaceSettings />
</SettingsLayout>
);
};
WorkspaceSettingsPage.getLayout = (page) => getDashboardLayout(page);
export default WorkspaceSettingsPage;

View File

@@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import React, { createContext, useContext, useEffect, useState } from "react";
import { api } from "~/utils/api";
@@ -46,6 +46,8 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
);
const [hasLoaded, setHasLoaded] = useState(false);
const workspacePublicId = useSearchParams().get("workspacePublicId");
const { data, isLoading } = api.workspace.all.useQuery();
const utils = api.useUtils();
@@ -67,7 +69,7 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
}
const storedWorkspaceId: string | null =
localStorage.getItem("workspacePublicId");
workspacePublicId ?? localStorage.getItem("workspacePublicId");
if (data.length) {
const workspaces = data.map(({ workspace, role }) => ({
@@ -99,6 +101,11 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
description: selectedWorkspace.workspace.description,
role: selectedWorkspace.role,
});
if (workspacePublicId) {
router.push(`/boards`);
localStorage.setItem("workspacePublicId", workspacePublicId);
}
} else {
const primaryWorkspace = data[0]?.workspace;
const primaryWorkspaceRole = data[0]?.role;
@@ -114,7 +121,7 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
role: primaryWorkspaceRole,
});
}
}, [data, isLoading]);
}, [data, isLoading, workspacePublicId, router]);
return (
<WorkspaceContext.Provider

View File

@@ -18,6 +18,8 @@ const loadMessages = async (locale: Locale) => {
return (await import("~/locales/it/messages")).messages;
case "nl":
return (await import("~/locales/nl/messages")).messages;
case "ru":
return (await import("~/locales/ru/messages")).messages;
default:
return enMessages;
}

View File

@@ -1,5 +1,5 @@
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { env } from "next-runtime-env";
@@ -17,6 +17,8 @@ export default function LoginPage() {
const [isMagicLinkSent, setIsMagicLinkSent] = useState<boolean>(false);
const [magicLinkRecipient, setMagicLinkRecipient] = useState<string>("");
const redirect = useSearchParams().get("next");
const handleMagicLinkSent = (value: boolean, recipient: string) => {
setIsMagicLinkSent(value);
setMagicLinkRecipient(recipient);
@@ -61,7 +63,11 @@ export default function LoginPage() {
<Trans>
Don't have an account?{" "}
<span className="underline">
<Link href="/signup">Sign up</Link>
<Link
href={redirect ? `/signup?next=${redirect}` : "/signup"}
>
Sign up
</Link>
</span>
</Trans>
</p>

View File

@@ -1,5 +1,5 @@
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { env } from "next-runtime-env";
@@ -17,6 +17,8 @@ export default function SignUpPage() {
const [isMagicLinkSent, setIsMagicLinkSent] = useState<boolean>(false);
const [magicLinkRecipient, setMagicLinkRecipient] = useState<string>("");
const redirect = useSearchParams().get("next");
const { data } = authClient.useSession();
if (data?.user.id) router.push("/boards");
@@ -86,7 +88,9 @@ export default function SignUpPage() {
<Trans>
Already have an account?{" "}
<span className="underline">
<Link href="/login">Sign in</Link>
<Link href={redirect ? `/login?next=${redirect}` : "/login"}>
Sign in
</Link>
</span>
</Trans>
</p>

View File

@@ -155,7 +155,7 @@ export function UpdateBoardSlugForm({
<div className="flex items-center gap-2">
<Button
variant="secondary"
href="/settings?edit=workspace_url"
href="/settings?tab=workspace"
onClick={closeModal}
>
{t`Edit workspace URL`}

View File

@@ -19,7 +19,7 @@ export function BoardsList() {
if (isLoading)
return (
<div className="grid h-fit w-full grid-cols-1 gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-7">
<div className="3xl:grid-cols-4 grid h-fit w-full grid-cols-1 gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-3">
<div className="mr-5 flex h-[150px] w-full animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
<div className="mr-5 flex h-[150px] w-full animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
<div className="mr-5 flex h-[150px] w-full animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
@@ -45,7 +45,7 @@ export function BoardsList() {
);
return (
<div className="grid h-fit w-full grid-cols-1 gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-7">
<div className="3xl:grid-cols-4 grid h-fit w-full grid-cols-1 gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-3">
{data?.map((board) => (
<Link key={board.publicId} href={`boards/${board.publicId}`}>
<div className="align-center relative mr-5 flex h-[150px] w-full items-center justify-center rounded-md border border-dashed border-light-400 bg-light-50 shadow-sm hover:bg-light-200 dark:border-dark-600 dark:bg-dark-50 dark:hover:bg-dark-100">

View File

@@ -1,4 +1,3 @@
import { Menu } from "@headlessui/react";
import { t } from "@lingui/core/macro";
import { HiMiniPlus } from "react-icons/hi2";
@@ -101,22 +100,19 @@ export default function LabelSelector({
{selectedLabels.length ? (
<div className="flex flex-wrap gap-x-0.5">
{selectedLabels.map((label) => (
<Menu.Button key={label.key}>
<Badge value={label.value} iconLeft={label.leftIcon} />
</Menu.Button>
))}
<Menu.Button>
<Badge
value={t`Add label`}
iconLeft={<HiMiniPlus size={14} />}
key={label.key}
value={label.value}
iconLeft={label.leftIcon}
/>
</Menu.Button>
))}
<Badge value={t`Add label`} iconLeft={<HiMiniPlus size={14} />} />
</div>
) : (
<Menu.Button className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 pl-2 text-left text-sm text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100">
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 pl-2 text-left text-sm text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100">
<HiMiniPlus size={22} className="pr-2" />
{t`Add label`}
</Menu.Button>
</div>
)}
</CheckboxDropdown>
)}

View File

@@ -1,4 +1,3 @@
import { Menu } from "@headlessui/react";
import { t } from "@lingui/core/macro";
import CheckboxDropdown from "~/components/CheckboxDropdown";
@@ -79,9 +78,9 @@ export default function ListSelector({
}}
asChild
>
<Menu.Button className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-sm text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100">
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-sm text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100">
{selectedList?.value}
</Menu.Button>
</div>
</CheckboxDropdown>
)}
</>

View File

@@ -1,5 +1,4 @@
import { useRouter } from "next/router";
import { Menu } from "@headlessui/react";
import { t } from "@lingui/core/macro";
import { HiMiniPlus } from "react-icons/hi2";
@@ -112,11 +111,12 @@ export default function MemberSelector({
createNewItemLabel={t`Invite member`}
asChild
>
<Menu.Button className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-sm text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100">
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-sm text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100">
{selectedMembers.length ? (
<div className="isolate flex justify-end -space-x-1 overflow-hidden">
{selectedMembers.map(({ value, imageUrl }) => (
<Avatar
key={value}
size="sm"
name={value}
imageUrl={imageUrl}
@@ -130,7 +130,7 @@ export default function MemberSelector({
{t`Add member`}
</>
)}
</Menu.Button>
</div>
</CheckboxDropdown>
)}
</>

View File

@@ -0,0 +1,171 @@
import { useRouter } from "next/router";
import { t } from "@lingui/core/macro";
import { env } from "next-runtime-env";
import { useEffect, useState } from "react";
import { authClient } from "@kan/auth/client";
import Button from "~/components/Button";
import LoadingSpinner from "~/components/LoadingSpinner";
import { PageHead } from "~/components/PageHead";
import PatternedBackground from "~/components/PatternedBackground";
import { api } from "~/utils/api";
export default function InvitePage() {
const router = useRouter();
const { code } = router.query;
const [isProcessing, setIsProcessing] = useState(false);
const [error, setError] = useState<string | null>(null);
const { data: session, isPending: isSessionLoading } =
authClient.useSession();
const isCloudEnv = env("NEXT_PUBLIC_KAN_ENV") === "cloud";
const inviteCode = Array.isArray(code) ? code[0] : code;
const acceptInviteMutation = api.member.acceptInviteLink.useMutation({
onSuccess: (result) => {
if (result.success) {
return router.push(
`/boards?workspacePublicId=${result.workspacePublicId}`,
);
}
},
onError: (error) => {
if (error.data?.code === "CONFLICT") {
return router.push(`/boards`);
}
setError(
error.message ||
t`Failed to accept invitation. Please try again later, or contact customer support.`,
);
setIsProcessing(false);
},
});
const {
data: inviteInfo,
isLoading: isInviteInfoLoading,
isError: isInviteInfoError,
} = api.member.getInviteByCode.useQuery(
{ inviteCode: inviteCode ?? "" },
{
enabled: !!inviteCode,
retry: false,
},
);
// Auto accept invite if user is logged in
useEffect(() => {
if (session?.user.id && inviteCode && inviteInfo && !error) {
setIsProcessing(true);
setError(null);
acceptInviteMutation.mutate({
inviteCode,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [session?.user.id, inviteCode, inviteInfo, error]);
if (
!isInviteInfoError &&
!error &&
(session?.user.id || isInviteInfoLoading || isSessionLoading)
) {
return (
<>
<PageHead title={t`Join workspace`} />
<PatternedBackground />
<div className="flex min-h-screen items-center justify-center">
<LoadingSpinner size="lg" />
</div>
</>
);
}
const PageWrapper = ({ children }: { children: React.ReactNode }) => {
return (
<>
<PageHead title={t`Join workspace | kan.bn`} />
{children}
</>
);
};
if (isInviteInfoError || (!isInviteInfoLoading && !inviteInfo)) {
return (
<PageWrapper>
<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-8">
<div>
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-light-1000 dark:text-dark-1000">
{t`Invalid invitation`}
</h2>
<p className="mt-4 text-center text-sm text-light-900 dark:text-dark-800">
{t`This invitation link is invalid or has expired.`}
</p>
</div>
<div className="text-center">
<Button href="/" variant="primary">
{t`Go Home`}
</Button>
</div>
</div>
</div>
</PageWrapper>
);
}
return (
<PageWrapper>
<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-[400px] space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-light-1000 dark:text-dark-1000">
{t`Join workspace`}
</h2>
{!error ? (
<p className="mt-4 text-center text-sm text-light-900 dark:text-dark-800">
{isCloudEnv
? t`You've been invited to join a workspace on kan.bn.`
: t`You've been invited to join a workspace.`}
</p>
) : (
<p className="mt-4 text-center text-sm text-red-500">{error}</p>
)}
</div>
<div className="flex justify-center gap-2">
{session?.user.id ? (
<Button href={`/boards`} variant="primary" size="md">
{t`Go to app`}
</Button>
) : (
<>
<Button
href={`/login?next=/invite/${inviteCode}`}
disabled={isProcessing}
variant="primary"
size="md"
>
{t`Sign In`}
</Button>
<Button
href={`/signup?next=/invite/${inviteCode}`}
disabled={isProcessing}
variant="primary"
size="md"
>
{t`Sign Up`}
</Button>
</>
)}
</div>
</div>
</div>
</PageWrapper>
);
}

View File

@@ -3,7 +3,12 @@ import { t } from "@lingui/core/macro";
import { env } from "next-runtime-env";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { HiXMark } from "react-icons/hi2";
import {
HiInformationCircle,
HiMiniCheck,
HiOutlineDocumentDuplicate,
HiXMark,
} from "react-icons/hi2";
import { z } from "zod";
import type { InviteMemberInput } from "@kan/api/types";
@@ -31,11 +36,17 @@ export function InviteMemberForm({
userId: string | undefined;
}) {
const utils = api.useUtils();
const [isCreateAnotherEnabled, setIsCreateAnotherEnabled] = useState(false);
const [isShareInviteLinkEnabled, setIsShareInviteLinkEnabled] =
useState(false);
const [inviteLink, setInviteLink] = useState<string>("");
const [_isLoadingInviteLink, setIsLoadingInviteLink] = useState(false);
const [copied, setCopied] = useState(false);
const { closeModal } = useModal();
const { workspace } = useWorkspace();
const { showPopup } = usePopup();
const isEmailEnabled = env("NEXT_PUBLIC_DISABLE_EMAIL") !== "true";
const InviteMemberSchema = z.object({
email: z.string().email({ message: t`Invalid email address` }),
workspacePublicId: z.string(),
@@ -56,6 +67,21 @@ export function InviteMemberForm({
const refetchBoards = () => utils.board.all.refetch();
// Fetch active invite link on component mount
const { data: activeInviteLink, refetch: _refetchInviteLink } =
api.member.getActiveInviteLink.useQuery(
{ workspacePublicId: workspace.publicId || "" },
{ enabled: !!workspace.publicId },
);
// Set initial state based on active invite link
useEffect(() => {
if (activeInviteLink) {
setIsShareInviteLinkEnabled(activeInviteLink.isActive);
setInviteLink(activeInviteLink.inviteLink ?? "");
}
}, [activeInviteLink]);
const inviteMember = api.member.invite.useMutation({
onSuccess: async () => {
closeModal();
@@ -64,7 +90,7 @@ export function InviteMemberForm({
},
onError: (error) => {
reset();
if (!isCreateAnotherEnabled) closeModal();
if (!isShareInviteLinkEnabled) closeModal();
if (error.data?.code === "CONFLICT") {
showPopup({
@@ -82,6 +108,46 @@ export function InviteMemberForm({
},
});
const createInviteLink = api.member.createInviteLink.useMutation({
onSuccess: (data) => {
setInviteLink(data.inviteLink);
setIsLoadingInviteLink(false);
},
onError: (error) => {
setIsLoadingInviteLink(false);
setIsShareInviteLinkEnabled(false);
if (error.data?.code === "FORBIDDEN") {
showPopup({
header: t`Subscription Required`,
message: t`Invite links require a Team or Pro subscription. Please upgrade your workspace.`,
icon: "error",
});
} else {
showPopup({
header: t`Error creating invite link`,
message: t`Please try again later.`,
icon: "error",
});
}
},
});
const deactivateInviteLink = api.member.deactivateInviteLink.useMutation({
onSuccess: () => {
setInviteLink("");
setIsLoadingInviteLink(false);
},
onError: () => {
setIsLoadingInviteLink(false);
showPopup({
header: t`Error deactivating invite link`,
message: t`Please try again later.`,
icon: "error",
});
},
});
const teamSubscription = getSubscriptionByPlan(subscriptions, "team");
const proSubscription = getSubscriptionByPlan(subscriptions, "pro");
@@ -92,7 +158,7 @@ export function InviteMemberForm({
let price = t`$10/month`;
let billingType = t`monthly billing`;
if (teamSubscription?.periodStart && teamSubscription?.periodEnd) {
if (teamSubscription?.periodStart && teamSubscription.periodEnd) {
const periodStartDate = new Date(teamSubscription.periodStart);
const periodEndDate = new Date(teamSubscription.periodEnd);
const diffInDays = Math.round(
@@ -109,6 +175,43 @@ export function InviteMemberForm({
inviteMember.mutate(member);
};
const handleInviteLinkToggle = async () => {
setIsLoadingInviteLink(true);
if (isShareInviteLinkEnabled && workspace.publicId) {
// Deactivate invite link
await deactivateInviteLink.mutateAsync({
workspacePublicId: workspace.publicId,
});
setIsShareInviteLinkEnabled(false);
} else {
// Create new invite link
await createInviteLink.mutateAsync({
workspacePublicId: workspace.publicId,
});
setIsShareInviteLinkEnabled(true);
}
};
const copyToClipboard = async () => {
try {
await navigator.clipboard.writeText(inviteLink);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
showPopup({
header: t`Invite link copied`,
message: t`Invite link copied to clipboard`,
icon: "success",
});
} catch {
showPopup({
header: t`Error`,
message: t`Failed to copy invite link`,
icon: "error",
});
}
};
const handleUpgrade = async () => {
const { data, error } = await authClient.subscription.upgrade({
plan: "team",
@@ -156,23 +259,53 @@ export function InviteMemberForm({
<HiXMark size={18} className="dark:text-dark-9000 text-light-900" />
</button>
</div>
<Input
id="email"
placeholder={t`Email`}
disabled={
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
!hasTeamSubscription &&
!hasProSubscription
}
{...register("email", { required: true })}
onKeyDown={async (e) => {
if (e.key === "Enter") {
e.preventDefault();
await handleSubmit(onSubmit)();
{isEmailEnabled && (
<Input
id="email"
placeholder={t`Email`}
disabled={
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
!hasTeamSubscription &&
!hasProSubscription
}
}}
errorMessage={errors.email?.message}
/>
{...register("email", { required: true })}
onKeyDown={async (e) => {
if (e.key === "Enter") {
e.preventDefault();
await handleSubmit(onSubmit)();
}
}}
errorMessage={errors.email?.message}
/>
)}
{(!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">
@@ -202,33 +335,32 @@ export function InviteMemberForm({
</div>
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
{(hasTeamSubscription || hasProSubscription) &&
env("NEXT_PUBLIC_KAN_ENV") === "cloud" && (
<Toggle
label={t`Invite another`}
isChecked={isCreateAnotherEnabled}
onChange={() =>
setIsCreateAnotherEnabled(!isCreateAnotherEnabled)
}
/>
)}
<Toggle
label={
isShareInviteLinkEnabled
? t`Deactivate invite link`
: t`Create invite link`
}
isChecked={isShareInviteLinkEnabled}
disabled={
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
!hasTeamSubscription &&
!hasProSubscription
}
onChange={handleInviteLinkToggle}
/>
<div>
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
!hasTeamSubscription &&
!hasProSubscription ? (
<Button
type="button"
onClick={handleUpgrade}
className="inline-flex w-full justify-center rounded-md bg-light-1000 px-3 py-2 text-sm font-semibold text-light-50 shadow-sm focus-visible:outline-none dark:bg-dark-1000 dark:text-dark-50"
>
<Button type="button" onClick={handleUpgrade}>
{t`Upgrade to Team Plan`}
</Button>
) : (
<Button
type="submit"
disabled={inviteMember.isPending}
disabled={inviteMember.isPending || !isEmailEnabled}
isLoading={inviteMember.isPending}
className="inline-flex w-full justify-center rounded-md bg-light-1000 px-3 py-2 text-sm font-semibold text-light-50 shadow-sm focus-visible:outline-none dark:bg-dark-1000 dark:text-dark-50"
>
{t`Invite member`}
</Button>

View File

@@ -1,6 +1,7 @@
import Link from "next/link";
import { t } from "@lingui/core/macro";
import { env } from "next-runtime-env";
import { useEffect, useState } from "react";
import {
HiBolt,
HiEllipsisHorizontal,
@@ -129,9 +130,9 @@ export default function MembersPage() {
{memberRole &&
memberRole.charAt(0).toUpperCase() + memberRole.slice(1)}
</span>
{memberStatus === "invited" && (
{(memberStatus === "invited" || memberStatus === "paused") && (
<span className="mt-1 inline-flex items-center rounded-md bg-gray-500/10 px-1.5 py-0.5 text-[10px] font-medium text-gray-400 ring-1 ring-inset ring-gray-500/20 sm:ml-2 sm:mt-0 sm:text-[11px]">
{t`Pending`}
{memberStatus === "invited" ? t`Pending` : t`Paused`}
</span>
)}
</div>
@@ -183,7 +184,7 @@ export default function MembersPage() {
<>
{!proSubscription && (
<Link
href="/settings?upgrade=pro"
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 />

View File

@@ -0,0 +1,116 @@
import { t } from "@lingui/core/macro";
import { env } from "next-runtime-env";
import Button from "~/components/Button";
import FeedbackModal from "~/components/FeedbackModal";
import { LanguageSelector } from "~/components/LanguageSelector";
import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import { PageHead } from "~/components/PageHead";
import { useModal } from "~/providers/modal";
import { api } from "~/utils/api";
import Avatar from "./components/Avatar";
import { ChangePasswordFormConfirmation } from "./components/ChangePasswordConfirmation";
import { DeleteAccountConfirmation } from "./components/DeleteAccountConfirmation";
import UpdateDisplayNameForm from "./components/UpdateDisplayNameForm";
export default function AccountSettings() {
const { modalContentType, openModal, isOpen } = useModal();
const isCredentialsEnabled =
env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true";
const { data } = api.user.getUser.useQuery();
return (
<>
<PageHead title={t`Settings | Account`} />
<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`Profile picture`}
</h2>
<Avatar userId={data?.id} userImage={data?.image} />
<div className="mb-4">
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
{t`Display name`}
</h2>
<UpdateDisplayNameForm displayName={data?.name ?? ""} />
</div>
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
{t`Language`}
</h2>
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
{t`Change your language preferences.`}
</p>
<LanguageSelector />
</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`Delete account`}
</h2>
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
{t`Once you delete your account, there is no going back. This action cannot be undone.`}
</p>
<div className="mt-4">
<Button
variant="secondary"
onClick={() => openModal("DELETE_ACCOUNT")}
>
{t`Delete account`}
</Button>
</div>
</div>
{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">
{t`Change Password`}
</h2>
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
{t`You are about to change your password.`}
</p>
<div className="mt-4">
<Button
variant="secondary"
onClick={() => openModal("CHANGE_PASSWORD")}
>
{t`Change Password`}
</Button>
</div>
</div>
)}
</div>
{/* Account-specific modals */}
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "DELETE_ACCOUNT"}
>
<DeleteAccountConfirmation />
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "CHANGE_PASSWORD"}
>
<ChangePasswordFormConfirmation />
</Modal>
{/* Global modals */}
<Modal
modalSize="md"
isVisible={isOpen && modalContentType === "NEW_FEEDBACK"}
>
<FeedbackModal />
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "NEW_WORKSPACE"}
>
<NewWorkspaceForm />
</Modal>
</>
);
}

View File

@@ -0,0 +1,66 @@
import { t } from "@lingui/core/macro";
import Button from "~/components/Button";
import FeedbackModal from "~/components/FeedbackModal";
import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import { PageHead } from "~/components/PageHead";
import { useModal } from "~/providers/modal";
import ApiKeyList from "./components/ApiKeyList";
import NewApiKeyModal from "./components/NewApiKeyModal";
import { RevokeApiKeyConfirmation } from "./components/RevokeApiKeyConfirmation";
export default function ApiSettings() {
const { modalContentType, openModal, isOpen } = useModal();
return (
<>
<PageHead title={t`Settings | API`} />
<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`API keys`}
</h2>
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
{t`View and manage your API keys.`}
</p>
<div className="mb-4 flex items-center justify-between">
<Button variant="primary" onClick={() => openModal("NEW_API_KEY")}>
{t`Create new key`}
</Button>
</div>
<ApiKeyList />
</div>
{/* API-specific modals */}
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "NEW_API_KEY"}
>
<NewApiKeyModal />
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "REVOKE_API_KEY"}
>
<RevokeApiKeyConfirmation />
</Modal>
{/* Global modals */}
<Modal
modalSize="md"
isVisible={isOpen && modalContentType === "NEW_FEEDBACK"}
>
<FeedbackModal />
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "NEW_WORKSPACE"}
>
<NewWorkspaceForm />
</Modal>
</>
);
}

View File

@@ -0,0 +1,68 @@
import { t } from "@lingui/core/macro";
import { HiMiniArrowTopRightOnSquare } from "react-icons/hi2";
import Button from "~/components/Button";
import FeedbackModal from "~/components/FeedbackModal";
import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import { PageHead } from "~/components/PageHead";
import { useModal } from "~/providers/modal";
export default function BillingSettings() {
const { modalContentType, isOpen } = useModal();
const handleOpenBillingPortal = async () => {
try {
const response = await fetch("/api/stripe/create_billing_session", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
const { url } = (await response.json()) as { url: string };
if (url) {
window.location.href = url;
}
} catch (error) {
console.error("Error creating billing session:", error);
}
};
return (
<>
<PageHead title={t`Settings | Billing`} />
<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`}
</h2>
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
{t`View and manage your billing and subscription.`}
</p>
<Button
variant="primary"
iconRight={<HiMiniArrowTopRightOnSquare />}
onClick={handleOpenBillingPortal}
>
{t`Billing portal`}
</Button>
</div>
{/* Global modals */}
<Modal
modalSize="md"
isVisible={isOpen && modalContentType === "NEW_FEEDBACK"}
>
<FeedbackModal />
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "NEW_WORKSPACE"}
>
<NewWorkspaceForm />
</Modal>
</>
);
}

View File

@@ -0,0 +1,130 @@
import { t } from "@lingui/core/macro";
import { useEffect } from "react";
import { HiMiniArrowTopRightOnSquare } from "react-icons/hi2";
import Button from "~/components/Button";
import FeedbackModal from "~/components/FeedbackModal";
import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import { PageHead } from "~/components/PageHead";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
export default function IntegrationsSettings() {
const { modalContentType, isOpen } = useModal();
const { showPopup } = usePopup();
const {
data: integrations,
refetch: refetchIntegrations,
isLoading: integrationsLoading,
} = api.integration.providers.useQuery();
const { data: trelloUrl, refetch: refetchTrelloUrl } =
api.integration.getAuthorizationUrl.useQuery(
{ provider: "trello" },
{
enabled:
!integrationsLoading &&
!integrations?.some(
(integration) => integration.provider === "trello",
),
refetchOnWindowFocus: true,
},
);
useEffect(() => {
const handleFocus = () => {
refetchIntegrations();
};
window.addEventListener("focus", handleFocus);
return () => {
window.removeEventListener("focus", handleFocus);
};
}, [refetchIntegrations]);
const { mutateAsync: disconnectTrello } =
api.integration.disconnect.useMutation({
onSuccess: () => {
refetchIntegrations();
refetchTrelloUrl();
showPopup({
header: t`Trello disconnected`,
message: t`Your Trello account has been disconnected.`,
icon: "success",
});
},
onError: () => {
showPopup({
header: t`Error disconnecting Trello`,
message: t`An error occurred while disconnecting your Trello account.`,
icon: "error",
});
},
});
return (
<>
<PageHead title={t`Settings | Integrations`} />
<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`Trello`}
</h2>
{!integrations?.some(
(integration) => integration.provider === "trello",
) && trelloUrl ? (
<>
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
{t`Connect your Trello account to import boards.`}
</p>
<Button
variant="primary"
iconRight={<HiMiniArrowTopRightOnSquare />}
onClick={() =>
window.open(
trelloUrl.url,
"trello_auth",
"height=800,width=600",
)
}
>
{t`Connect Trello`}
</Button>
</>
) : (
integrations?.some(
(integration) => integration.provider === "trello",
) && (
<>
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
{t`Your Trello account is connected.`}
</p>
<Button
variant="secondary"
onClick={() => disconnectTrello({ provider: "trello" })}
>
{t`Disconnect Trello`}
</Button>
</>
)
)}
</div>
{/* Global modals */}
<Modal
modalSize="md"
isVisible={isOpen && modalContentType === "NEW_FEEDBACK"}
>
<FeedbackModal />
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "NEW_WORKSPACE"}
>
<NewWorkspaceForm />
</Modal>
</>
);
}

View File

@@ -0,0 +1,145 @@
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";
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";
import { DeleteWorkspaceConfirmation } from "./components/DeleteWorkspaceConfirmation";
import UpdateWorkspaceDescriptionForm from "./components/UpdateWorkspaceDescriptionForm";
import UpdateWorkspaceNameForm from "./components/UpdateWorkspaceNameForm";
import UpdateWorkspaceUrlForm from "./components/UpdateWorkspaceUrlForm";
import { UpgradeToProConfirmation } from "./components/UpgradeToProConfirmation";
export default function WorkspaceSettings() {
const { modalContentType, openModal, isOpen } = useModal();
const { workspace } = useWorkspace();
const router = useRouter();
const { data } = api.user.getUser.useQuery();
const [hasOpenedUpgradeModal, setHasOpenedUpgradeModal] = useState(false);
const { data: workspaceData } = api.workspace.byId.useQuery({
workspacePublicId: workspace.publicId,
});
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`} />
<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`Workspace name`}
</h2>
<UpdateWorkspaceNameForm
workspacePublicId={workspace.publicId}
workspaceName={workspace.name}
/>
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
{t`Workspace URL`}
</h2>
<UpdateWorkspaceUrlForm
workspacePublicId={workspace.publicId}
workspaceUrl={workspace.slug ?? ""}
workspacePlan={workspace.plan ?? "free"}
/>
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
{t`Workspace description`}
</h2>
<UpdateWorkspaceDescriptionForm
workspacePublicId={workspace.publicId}
workspaceDescription={workspace.description ?? ""}
/>
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
!hasActiveSubscription(subscriptions, "pro") && (
<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`}
</h2>
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
{t`Once you delete your workspace, there is no going back. This action cannot be undone.`}
</p>
<div className="mt-4">
<Button
variant="secondary"
onClick={() => openModal("DELETE_WORKSPACE")}
disabled={workspace.role !== "admin"}
>
{t`Delete workspace`}
</Button>
</div>
</div>
</div>
{/* Workspace-specific modals */}
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "DELETE_WORKSPACE"}
>
<DeleteWorkspaceConfirmation />
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "UPGRADE_TO_PRO"}
>
<UpgradeToProConfirmation
userId={data?.id ?? ""}
workspacePublicId={workspace.publicId}
/>
</Modal>
{/* Global modals */}
<Modal
modalSize="md"
isVisible={isOpen && modalContentType === "NEW_FEEDBACK"}
>
<FeedbackModal />
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "NEW_WORKSPACE"}
>
<NewWorkspaceForm />
</Modal>
</>
);
}

View File

@@ -0,0 +1,202 @@
import { useQuery } from "@tanstack/react-query";
import { HiEllipsisHorizontal } from "react-icons/hi2";
import { twMerge } from "tailwind-merge";
import { authClient } from "@kan/auth/client";
import Dropdown from "~/components/Dropdown";
import { useModal } from "~/providers/modal";
export default function ApiKeyList() {
const { openModal } = useModal();
const { data, isLoading } = useQuery({
queryKey: ["apiKeys"],
queryFn: () => authClient.apiKey.list(),
});
const TableRow = ({
keyId,
keyName,
keyStart,
createdAt,
lastRequest,
isLastRow,
showSkeleton,
}: {
keyId?: string;
keyName?: string | null | undefined;
keyStart?: string | null | undefined;
createdAt?: Date | null;
lastRequest?: Date | null;
isLastRow?: boolean | undefined;
showSkeleton?: boolean | undefined;
}) => {
const formatDate = (date?: Date | string | null) => {
if (!date) return "Never";
const dateObj = date instanceof Date ? date : new Date(date);
return dateObj.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
};
return (
<tr className="rounded-b-lg">
<td className={twMerge("w-[30%]", isLastRow ? "rounded-bl-lg" : "")}>
<div className="flex items-center p-4">
<div className="ml-2 min-w-0 flex-1">
<div>
<div className="flex items-center">
<p
className={twMerge(
"mr-2 text-sm font-medium text-light-900 dark:text-dark-900",
showSkeleton &&
"md mb-2 h-3 w-[125px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
)}
>
{keyName}
</p>
</div>
</div>
</div>
</div>
</td>
<td className="w-[20%] px-3 py-4">
<p
className={twMerge(
"text-sm text-light-900 dark:text-dark-900",
showSkeleton &&
"h-3 w-[80px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
)}
>
{formatDate(createdAt)}
</p>
</td>
<td className="w-[20%] px-3 py-4">
<p
className={twMerge(
"text-sm text-light-900 dark:text-dark-900",
showSkeleton &&
"h-3 w-[80px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
)}
>
{formatDate(lastRequest)}
</p>
</td>
<td className="w-[25%] px-3 py-4">
<div>
<span
className={twMerge(
"inline-flex items-center rounded-md bg-emerald-500/10 px-1.5 py-0.5 text-[11px] font-medium text-emerald-400 ring-1 ring-inset ring-emerald-500/20",
showSkeleton &&
"h-5 w-[50px] animate-pulse bg-light-200 ring-0 dark:bg-dark-200",
)}
>
{keyStart}...
</span>
</div>
</td>
<td
className={twMerge(
"w-[5%] min-w-[50px]",
isLastRow && "rounded-br-lg",
)}
>
<div className="flex w-full items-center justify-center px-3">
<div className="relative z-50">
<Dropdown
items={[
{
label: "Revoke",
action: () =>
openModal("REVOKE_API_KEY", keyId, keyName ?? ""),
},
]}
>
<HiEllipsisHorizontal
size={25}
className="text-light-900 dark:text-dark-900"
/>
</Dropdown>
</div>
</div>
</td>
</tr>
);
};
if (!isLoading && (!data?.data || data.data.length === 0)) {
return null;
}
return (
<div className="mt-8 flow-root">
<div className="overflow-x-auto overflow-y-visible">
<div className="inline-block min-w-full py-2 pb-12 align-middle">
<div className="relative h-full shadow ring-1 ring-black ring-opacity-5 sm:rounded-lg">
<table className="min-w-[600px] divide-y divide-light-600 dark:divide-dark-600">
<thead className="rounded-t-lg bg-light-300 dark:bg-dark-200">
<tr>
<th
scope="col"
className="w-[30%] rounded-tl-lg py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-light-900 dark:text-dark-900 sm:pl-6"
>
Name
</th>
<th
scope="col"
className="w-[20%] px-3 py-3.5 text-left text-sm font-semibold text-light-900 dark:text-dark-900"
>
Created
</th>
<th
scope="col"
className="w-[20%] px-3 py-3.5 text-left text-sm font-semibold text-light-900 dark:text-dark-900"
>
Last Used
</th>
<th
scope="col"
className="w-[25%] px-3 py-3.5 text-left text-sm font-semibold text-light-900 dark:text-dark-900"
>
Key
</th>
<th
scope="col"
className="w-[5%] rounded-tr-lg px-3 py-3.5 text-center text-sm font-semibold text-light-900 dark:text-dark-900"
>
{/* Actions column */}
</th>
</tr>
</thead>
<tbody className="divide-y divide-light-600 bg-light-50 dark:divide-dark-600 dark:bg-dark-100">
{!isLoading &&
data?.data?.map((apiKey, index) => (
<TableRow
key={apiKey.id}
keyId={apiKey.id}
keyName={apiKey.name}
keyStart={apiKey.start}
createdAt={apiKey.createdAt}
lastRequest={apiKey.lastRequest}
isLastRow={index === data.data.length - 1}
/>
))}
{isLoading && (
<>
<TableRow showSkeleton />
<TableRow showSkeleton />
<TableRow showSkeleton isLastRow />
</>
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,14 +1,45 @@
import Image from "next/image";
import { t } from "@lingui/core/macro";
import { env } from "next-runtime-env";
import { useState } from "react";
import { useCallback, useRef, useState } from "react";
import ReactCrop from "react-image-crop";
import "react-image-crop/dist/ReactCrop.css";
import { generateUID } from "@kan/shared/utils";
import Button from "~/components/Button";
import Modal from "~/components/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
import { getAvatarUrl } from "~/utils/helpers";
interface PercentCrop {
unit: "%";
x: number;
y: number;
width: number;
height: number;
}
interface LocalPixelCrop {
x: number;
y: number;
width: number;
height: number;
}
interface ReactCropProps {
crop: PercentCrop | undefined;
onChange: (crop: LocalPixelCrop, percentCrop: PercentCrop) => void;
aspect?: number;
className?: string;
circularCrop?: boolean;
children: React.ReactNode;
}
const AnyReactCrop = ReactCrop as unknown as React.FC<ReactCropProps>;
export default function Avatar({
userId,
userImage,
@@ -19,6 +50,13 @@ export default function Avatar({
const utils = api.useUtils();
const { showPopup } = usePopup();
const [uploading, setUploading] = useState(false);
const [cropDialogOpen, setCropDialogOpen] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [selectedPreviewUrl, setSelectedPreviewUrl] = useState<string | null>(
null,
);
const [crop, setCrop] = useState<PercentCrop>();
const imgRef = useRef<HTMLImageElement | null>(null);
const updateUser = api.user.update.useMutation({
onSuccess: async () => {
@@ -45,24 +83,109 @@ export default function Avatar({
const avatarUrl = userImage ? getAvatarUrl(userImage) : undefined;
const uploadAvatar = async (event: React.ChangeEvent<HTMLInputElement>) => {
try {
event.preventDefault();
const onFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
event.preventDefault();
const file = event.target.files?.[0] ?? null;
if (!file || !userId) {
return showPopup({
header: t`Error uploading profile image`,
message: t`Please select a file to upload.`,
icon: "error",
});
}
// Open crop dialog with preview
setSelectedFile(file);
const objUrl = URL.createObjectURL(file);
setSelectedPreviewUrl(objUrl);
setCropDialogOpen(true);
};
const file = event.target.files?.[0];
if (!file || !userId) {
return showPopup({
header: t`Error uploading profile image`,
message: t`Please select a file to upload.`,
icon: "error",
});
const onImageLoad = useCallback(
(e: React.SyntheticEvent<HTMLImageElement>) => {
const { naturalWidth, naturalHeight } = e.currentTarget;
// Create a centered square crop at ~90% of the smaller dimension
// Compute width% so that the square fits within the image
let widthPercent: number;
let heightPercent: number;
if (naturalWidth >= naturalHeight) {
// landscape: height is limiting
heightPercent = 90;
widthPercent = (naturalHeight / naturalWidth) * heightPercent;
} else {
// portrait: width is limiting
widthPercent = 90;
heightPercent = (naturalWidth / naturalHeight) * widthPercent;
}
const x = (100 - widthPercent) / 2;
const y = (100 - heightPercent) / 2;
setCrop({ unit: "%", x, y, width: widthPercent, height: heightPercent });
},
[],
);
const fileExt = file.name.split(".").pop();
const fileName = `${userId}/avatar-${generateUID()}.${fileExt}`;
const getCroppedBlob = useCallback(async (): Promise<Blob> => {
if (!imgRef.current || !crop) throw new Error("No crop to save");
const image = imgRef.current;
const canvas = document.createElement("canvas");
const cropXpx = (crop.x / 100) * image.naturalWidth;
const cropYpx = (crop.y / 100) * image.naturalHeight;
const cropWpx = (crop.width / 100) * image.naturalWidth;
const cropHpx = (crop.height / 100) * image.naturalHeight;
canvas.width = Math.max(1, Math.floor(cropWpx));
canvas.height = Math.max(1, Math.floor(cropHpx));
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Canvas not supported");
// For better quality on HiDPI screens
const pixelRatio = window.devicePixelRatio || 1;
canvas.width = canvas.width * pixelRatio;
canvas.height = canvas.height * pixelRatio;
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
ctx.imageSmoothingQuality = "high";
ctx.drawImage(
image,
cropXpx,
cropYpx,
cropWpx,
cropHpx,
0,
0,
canvas.width / pixelRatio,
canvas.height / pixelRatio,
);
const mime = selectedFile?.type ?? "image/jpeg";
const blob: Blob = await new Promise((resolve, reject) => {
canvas.toBlob(
(b) => (b ? resolve(b) : reject(new Error("toBlob failed"))),
mime,
);
});
return blob;
}, [crop, selectedFile]);
const resetCropState = useCallback(() => {
setCrop(undefined);
setSelectedFile(null);
if (selectedPreviewUrl) URL.revokeObjectURL(selectedPreviewUrl);
setSelectedPreviewUrl(null);
}, [selectedPreviewUrl]);
const handleCancelCrop = useCallback(() => {
setCropDialogOpen(false);
resetCropState();
}, [resetCropState]);
const handleSaveCrop = useCallback(async () => {
try {
if (!userId || !selectedFile) return;
setUploading(true);
const blob = await getCroppedBlob();
const originalExt = selectedFile.name.split(".").pop() ?? "jpg";
const fileName = `${userId}/avatar-${generateUID()}.${originalExt}`;
const response = await fetch(
env("NEXT_PUBLIC_BASE_URL") + "/api/upload/image",
@@ -71,26 +194,24 @@ export default function Avatar({
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ filename: fileName, contentType: file.type }),
body: JSON.stringify({ filename: fileName, contentType: blob.type }),
},
);
if (!response.ok) throw new Error("Failed to get pre-signed URL");
const { url } = (await response.json()) as {
url: string;
};
const { url } = (await response.json()) as { url: string };
const uploadResponse = await fetch(url, {
method: "PUT",
body: file,
body: blob,
});
if (!uploadResponse.ok) throw new Error("Failed to upload profile image");
updateUser.mutate({
image: fileName,
});
updateUser.mutate({ image: fileName });
setCropDialogOpen(false);
resetCropState();
} catch (error) {
console.error(error);
showPopup({
@@ -101,7 +222,14 @@ export default function Avatar({
} finally {
setUploading(false);
}
};
}, [
getCroppedBlob,
resetCropState,
selectedFile,
showPopup,
updateUser,
userId,
]);
return (
<div>
@@ -111,7 +239,7 @@ export default function Avatar({
type="file"
id="single"
accept="image/*"
onChange={uploadAvatar}
onChange={onFileChange}
disabled={uploading}
/>
{avatarUrl ? (
@@ -134,6 +262,56 @@ export default function Avatar({
</span>
)}
</div>
{/* Crop Dialog */}
{cropDialogOpen && (
<Modal modalSize="md" positionFromTop="sm" isVisible>
<div className="p-4 sm:p-6">
<div className="mb-4">
<h3 className="text-base font-semibold text-light-1000 dark:text-dark-1000">
{t`Crop your avatar`}
</h3>
<p className="mt-1 text-sm text-light-800 dark:text-dark-800">
{t`Adjust the square crop to fit your avatar.`}
</p>
</div>
<div className="max-h-[80vh]">
<div className="rounded-md border border-light-600 p-2 dark:border-dark-600">
<AnyReactCrop
crop={crop}
onChange={(_crop: LocalPixelCrop, percentCrop: PercentCrop) =>
setCrop(percentCrop)
}
aspect={1}
circularCrop
className="w-full"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
ref={imgRef}
src={selectedPreviewUrl ?? undefined}
alt="Avatar to crop"
onLoad={onImageLoad}
className="h-auto max-h-[50vh] w-full object-contain"
/>
</AnyReactCrop>
</div>
</div>
<div className="mt-6 flex items-center justify-end gap-2">
<Button
variant="ghost"
onClick={handleCancelCrop}
disabled={uploading}
>
{t`Cancel`}
</Button>
<Button onClick={handleSaveCrop} isLoading={uploading}>
{t`Save`}
</Button>
</div>
</div>
</Modal>
)}
</div>
);
}

View File

@@ -1,60 +0,0 @@
import { t } from "@lingui/core/macro";
import { authClient } from "@kan/auth/client";
import Button from "~/components/Button";
import Input from "~/components/Input";
const CreateAPIKeyForm = ({
apiKey,
refetchUser,
}: {
apiKey:
| {
id: number;
prefix: string | null;
key: string;
}
| null
| undefined;
refetchUser: () => void;
}) => {
const handleCreateAPIKey = async () => {
await authClient.apiKey.create({
name: "Kan API Key",
prefix: "kan_",
});
refetchUser();
};
const handleRevokeAPIKey = async () => {
if (!apiKey) return;
await authClient.apiKey.delete({
keyId: apiKey.id.toString(),
});
refetchUser();
};
return (
<div>
{apiKey ? (
<div className="flex gap-2">
<div className="mb-4 flex w-full max-w-[325px] items-center gap-2">
<Input value={apiKey.key} readOnly type="password" />
</div>
<div>
<Button variant="danger" onClick={handleRevokeAPIKey}>
{t`Revoke`}
</Button>
</div>
</div>
) : (
<Button onClick={handleCreateAPIKey}>{t`Create new key`}</Button>
)}
</div>
);
};
export default CreateAPIKeyForm;

View File

@@ -0,0 +1,69 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { authClient } from "@kan/auth/client";
import Button from "~/components/Button";
import Input from "~/components/Input";
import { useModal } from "~/providers/modal";
const newApiKeySchema = z.object({
name: z.string().min(1),
});
export default function NewApiKeyForm() {
const { openModal } = useModal();
const form = useForm({
resolver: zodResolver(newApiKeySchema),
defaultValues: {
name: "",
},
});
const qc = useQueryClient();
const createApiKeyMutation = useMutation({
mutationFn: ({ name }: { name: string }) =>
authClient.apiKey.create({ name, prefix: "kan_" }),
onSuccess: ({ data: apiKey }) => {
qc.invalidateQueries({
queryKey: ["apiKeys"],
});
openModal("API_KEY_CREATED", apiKey?.key, apiKey?.name ?? "");
},
onError: () => {
form.setError("name", {
type: "manual",
message: "Failed to create API key",
});
},
});
const handleSubmit = (data: z.infer<typeof newApiKeySchema>) => {
createApiKeyMutation.mutate({ name: data.name });
};
return (
<div className="px-2 py-2">
<form
onSubmit={form.handleSubmit(handleSubmit)}
className="flex flex-col gap-2"
>
<h2 className="text-sm font-bold text-neutral-900 dark:text-dark-1000">
New API key
</h2>
<Input
{...form.register("name")}
placeholder="Name"
className="w-full"
errorMessage={form.formState.errors.name?.message}
/>
<Button type="submit" isLoading={createApiKeyMutation.isPending}>
Create
</Button>
</form>
</div>
);
}

View File

@@ -0,0 +1,181 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { t } from "@lingui/core/macro";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import {
HiInformationCircle,
HiMiniCheck,
HiOutlineDocumentDuplicate,
HiXMark,
} from "react-icons/hi2";
import { z } from "zod";
import { authClient } from "@kan/auth/client";
import Button from "~/components/Button";
import Input from "~/components/Input";
import { useClipboard } from "~/hooks/useClipboard";
import { useModal } from "~/providers/modal";
const newApiKeySchema = z.object({
name: z
.string()
.min(1, { message: t`API key name is required` })
.max(30, { message: t`API key name cannot exceed 30 characters` }),
});
export default function NewApiKeyModal() {
const { closeModal } = useModal();
const { copied, copy } = useClipboard({ timeout: 2000 });
const [createdApiKey, setCreatedApiKey] = useState<{
key: string;
name: string;
} | null>(null);
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<z.infer<typeof newApiKeySchema>>({
resolver: zodResolver(newApiKeySchema),
defaultValues: {
name: "",
},
});
const qc = useQueryClient();
const createApiKeyMutation = useMutation({
mutationFn: ({ name }: { name: string }) =>
authClient.apiKey.create({ name, prefix: "kan_" }),
onSuccess: ({ data: apiKey }) => {
void qc.invalidateQueries({
queryKey: ["apiKeys"],
});
if (apiKey && apiKey.key && apiKey.name) {
setCreatedApiKey({
key: apiKey.key,
name: apiKey.name,
});
}
},
onError: () => {
// Handle error if needed
},
});
const onSubmit = (data: z.infer<typeof newApiKeySchema>) => {
createApiKeyMutation.mutate({ name: data.name });
};
useEffect(() => {
// Reset state and form when modal opens
setCreatedApiKey(null);
reset();
}, [reset]);
useEffect(() => {
if (!createdApiKey) {
const nameElement = document.querySelector<HTMLElement>("#name");
if (nameElement) nameElement.focus();
}
}, [createdApiKey]);
if (createdApiKey) {
return (
<div>
<div className="px-5 pt-5">
<div className="flex w-full items-center justify-between pb-4 text-neutral-900 dark:text-dark-1000">
<h2 className="text-sm font-bold">{t`API key created`}</h2>
<button
type="button"
className="rounded p-1 hover:bg-light-300 focus:outline-none dark:hover:bg-dark-300"
onClick={(e) => {
e.preventDefault();
closeModal();
}}
>
<HiXMark
size={18}
className="text-light-900 dark:text-dark-900"
/>
</button>
</div>
<div className="mb-4">
<div className="relative">
<Input
value={createdApiKey.key}
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={() => copy(createdApiKey.key)}
>
{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`This API key will only be shown once. Please save it in a secure location.`}
</p>
</div>
</div>
</div>
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<div>
<Button onClick={() => closeModal()}>{t`Close`}</Button>
</div>
</div>
</div>
);
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="px-5 pt-5">
<div className="flex w-full items-center justify-between pb-4 text-neutral-900 dark:text-dark-1000">
<h2 className="text-sm font-bold">{t`New API key`}</h2>
<button
type="button"
className="rounded p-1 hover:bg-light-300 focus:outline-none dark:hover:bg-dark-300"
onClick={(e) => {
e.preventDefault();
closeModal();
}}
>
<HiXMark size={18} className="text-light-900 dark:text-dark-900" />
</button>
</div>
<Input
id="name"
placeholder={t`API key name`}
{...register("name", { required: true })}
errorMessage={errors.name?.message}
onKeyDown={async (e) => {
if (e.key === "Enter") {
e.preventDefault();
await handleSubmit(onSubmit)();
}
}}
/>
</div>
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<div>
<Button type="submit" isLoading={createApiKeyMutation.isPending}>
{t`Create API key`}
</Button>
</div>
</div>
</form>
);
}

View File

@@ -0,0 +1,96 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { authClient } from "@kan/auth/client";
import Button from "~/components/Button";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
export function RevokeApiKeyConfirmation() {
const { closeModal, entityId, entityLabel } = useModal();
const { showPopup } = usePopup();
const qc = useQueryClient();
const [isAcknowledgmentChecked, setIsAcknowledgmentChecked] = useState(false);
const deleteApiKeyMutation = useMutation({
mutationFn: () => authClient.apiKey.delete({ keyId: entityId }),
onSuccess: async () => {
closeModal();
showPopup({
header: "API key revoked",
message: `Your API key: ${entityLabel} has been revoked.`,
icon: "success",
});
qc.invalidateQueries({
queryKey: ["apiKeys"],
});
},
onError: () => {
closeModal();
showPopup({
header: "Error revoking API key",
message: "Please try again later, or contact customer support.",
icon: "error",
});
},
});
const handleRevokeApiKey = () => {
deleteApiKeyMutation.mutate();
};
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">
{`Are you sure you want to revoke this API key: ${entityLabel}?`}
</h2>
<p className="mb-4 text-sm text-light-900 dark:text-dark-900">
Keep in mind that this action is irreversible.
</p>
<p className="text-sm text-light-900 dark:text-dark-900">
This will result in the permanent revocation of this API key.
</p>
</div>
<div className="relative flex items-start">
<div className="flex h-6 items-center">
<input
id="acknowledgment"
name="acknowledgment"
type="checkbox"
aria-describedby="acknowledgment-description"
className="mt-2 h-[14px] w-[14px] rounded border-gray-300 bg-transparent text-indigo-600 focus:shadow-none focus:ring-0 focus:ring-offset-0"
checked={isAcknowledgmentChecked}
onChange={() =>
setIsAcknowledgmentChecked(!isAcknowledgmentChecked)
}
/>
</div>
<div className="ml-3 text-sm leading-6">
<p
id="comments-description"
className="text-light-900 dark:text-dark-1000"
>
I acknowledge that this API key will be permanently revoked and want
to proceed.
</p>
</div>
</div>
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
<Button variant="secondary" onClick={() => closeModal()}>
Cancel
</Button>
<Button
variant="danger"
onClick={handleRevokeApiKey}
disabled={!isAcknowledgmentChecked}
isLoading={deleteApiKeyMutation.isPending}
>
Revoke API key
</Button>
</div>
</div>
);
}

View File

@@ -69,16 +69,18 @@ const UpdateDisplayNameForm = ({ displayName }: { displayName: string }) => {
<div className="mb-4 flex w-full max-w-[325px] items-center gap-2">
<Input {...register("name")} errorMessage={errors.name?.message} />
</div>
<div>
<Button
onClick={handleSubmit(onSubmit)}
variant="primary"
disabled={!isDirty || updateDisplayName.isPending}
isLoading={updateDisplayName.isPending}
>
{t`Update`}
</Button>
</div>
{isDirty && (
<div>
<Button
onClick={handleSubmit(onSubmit)}
variant="primary"
disabled={updateDisplayName.isPending}
isLoading={updateDisplayName.isPending}
>
{t`Update`}
</Button>
</div>
)}
</div>
);
};

View File

@@ -80,16 +80,18 @@ const UpdateWorkspaceDescriptionForm = ({
errorMessage={errors.description?.message}
/>
</div>
<div>
<Button
onClick={handleSubmit(onSubmit)}
variant="primary"
disabled={!isDirty || updateWorkspaceDescription.isPending}
isLoading={updateWorkspaceDescription.isPending}
>
{t`Update`}
</Button>
</div>
{isDirty && (
<div>
<Button
onClick={handleSubmit(onSubmit)}
variant="primary"
disabled={updateWorkspaceDescription.isPending}
isLoading={updateWorkspaceDescription.isPending}
>
{t`Update`}
</Button>
</div>
)}
</div>
);
};

View File

@@ -72,16 +72,18 @@ const UpdateWorkspaceNameForm = ({
<div className="mb-4 flex w-full max-w-[325px] items-center gap-2">
<Input {...register("name")} errorMessage={errors.name?.message} />
</div>
<div>
<Button
onClick={handleSubmit(onSubmit)}
variant="primary"
disabled={!isDirty || updateWorkspaceName.isPending}
isLoading={updateWorkspaceName.isPending}
>
{t`Update`}
</Button>
</div>
{isDirty && (
<div>
<Button
onClick={handleSubmit(onSubmit)}
variant="primary"
disabled={updateWorkspaceName.isPending}
isLoading={updateWorkspaceName.isPending}
>
{t`Update`}
</Button>
</div>
)}
</div>
);
};

View File

@@ -138,22 +138,23 @@ const UpdateWorkspaceUrlForm = ({
}
/>
</div>
<div>
<Button
onClick={handleSubmit(onSubmit)}
variant="primary"
disabled={
!isDirty ||
updateWorkspaceSlug.isPending ||
checkWorkspaceSlugAvailability.isPending ||
isWorkspaceSlugAvailable?.isAvailable === false ||
isTyping
}
isLoading={updateWorkspaceSlug.isPending}
>
{t`Update`}
</Button>
</div>
{isDirty && (
<div>
<Button
onClick={handleSubmit(onSubmit)}
variant="primary"
disabled={
updateWorkspaceSlug.isPending ||
checkWorkspaceSlugAvailability.isPending ||
isWorkspaceSlugAvailable?.isAvailable === false ||
isTyping
}
isLoading={updateWorkspaceSlug.isPending}
>
{t`Update`}
</Button>
</div>
)}
</div>
);
};

View File

@@ -1,412 +0,0 @@
import { useRouter } from "next/router";
import { t } from "@lingui/core/macro";
import { env } from "next-runtime-env";
import { useEffect, useRef, useState } from "react";
import { HiBolt, HiMiniArrowTopRightOnSquare } 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";
import { LanguageSelector } from "~/components/LanguageSelector";
import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import { PageHead } from "~/components/PageHead";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
import Avatar from "./components/Avatar";
import { ChangePasswordFormConfirmation } from "./components/ChangePasswordConfirmation";
import CreateAPIKeyForm from "./components/CreateAPIKeyForm";
import { DeleteAccountConfirmation } from "./components/DeleteAccountConfirmation";
import { DeleteWorkspaceConfirmation } from "./components/DeleteWorkspaceConfirmation";
import UpdateDisplayNameForm from "./components/UpdateDisplayNameForm";
import UpdateWorkspaceDescriptionForm from "./components/UpdateWorkspaceDescriptionForm";
import UpdateWorkspaceNameForm from "./components/UpdateWorkspaceNameForm";
import UpdateWorkspaceUrlForm from "./components/UpdateWorkspaceUrlForm";
import { UpgradeToProConfirmation } from "./components/UpgradeToProConfirmation";
export default function SettingsPage() {
const { modalContentType, openModal, isOpen } = useModal();
const { workspace } = useWorkspace();
const utils = api.useUtils();
const { showPopup } = usePopup();
const router = useRouter();
const workspaceUrlSectionRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [hasOpenedUpgradeModal, setHasOpenedUpgradeModal] = useState(false);
const isCredentialsEnabled =
env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true";
const { data } = api.user.getUser.useQuery();
const { data: workspaceData } = api.workspace.byId.useQuery({
workspacePublicId: workspace.publicId,
});
const subscriptions = workspaceData?.subscriptions as
| Subscription[]
| undefined;
const {
data: integrations,
refetch: refetchIntegrations,
isLoading: integrationsLoading,
} = api.integration.providers.useQuery();
const { data: trelloUrl, refetch: refetchTrelloUrl } =
api.integration.getAuthorizationUrl.useQuery(
{ provider: "trello" },
{
enabled:
!integrationsLoading &&
!integrations?.some(
(integration) => integration.provider === "trello",
),
refetchOnWindowFocus: true,
},
);
useEffect(() => {
const handleFocus = () => {
refetchIntegrations();
};
window.addEventListener("focus", handleFocus);
return () => {
window.removeEventListener("focus", handleFocus);
};
}, [refetchIntegrations]);
useEffect(() => {
if (
router.query.edit === "workspace_url" &&
workspaceUrlSectionRef.current &&
scrollContainerRef.current
) {
const element = workspaceUrlSectionRef.current;
const container = scrollContainerRef.current;
container.scrollTop = element.offsetTop - 40;
const input = element.querySelector('input[type="text"]');
if (input instanceof HTMLInputElement) {
input.focus();
}
}
}, [router.query.edit]);
// 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]);
const { mutateAsync: disconnectTrello } =
api.integration.disconnect.useMutation({
onSuccess: () => {
refetchUser();
refetchIntegrations();
refetchTrelloUrl();
showPopup({
header: t`Trello disconnected`,
message: t`Your Trello account has been disconnected.`,
icon: "success",
});
},
onError: () => {
showPopup({
header: t`Error disconnecting Trello`,
message: t`An error occurred while disconnecting your Trello account.`,
icon: "error",
});
},
});
const refetchUser = () => utils.user.getUser.refetch();
const handleOpenBillingPortal = async () => {
try {
const response = await fetch("/api/stripe/create_billing_session", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
const { url } = (await response.json()) as { url: string };
if (url) {
window.location.href = url;
}
} catch (error) {
console.error("Error creating billing session:", error);
}
};
return (
<>
<div className="flex h-full w-full flex-col overflow-hidden">
<div
ref={scrollContainerRef}
className="h-full max-h-[calc(100vdh-3rem)] overflow-y-auto md:max-h-[calc(100vdh-4rem)]"
>
<PageHead title={t`Settings | ${workspace.name ?? "Workspace"}`} />
<div className="m-auto max-w-[1100px] px-5 py-6 md:px-28 md:py-12">
<div className="mb-8 flex w-full justify-between">
<h1 className="font-bold tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
{t`Settings`}
</h1>
</div>
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
{t`Profile picture`}
</h2>
<Avatar userId={data?.id} userImage={data?.image} />
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
{t`Display name`}
</h2>
<UpdateDisplayNameForm displayName={data?.name ?? ""} />
</div>
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
{t`Workspace name`}
</h2>
<UpdateWorkspaceNameForm
workspacePublicId={workspace.publicId}
workspaceName={workspace.name}
/>
<div ref={workspaceUrlSectionRef}>
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
{t`Workspace URL`}
</h2>
<UpdateWorkspaceUrlForm
workspacePublicId={workspace.publicId}
workspaceUrl={workspace.slug ?? ""}
workspacePlan={workspace.plan ?? "free"}
/>
</div>
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
{t`Workspace description`}
</h2>
<UpdateWorkspaceDescriptionForm
workspacePublicId={workspace.publicId}
workspaceDescription={workspace.description ?? ""}
/>
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
!hasActiveSubscription(subscriptions, "pro") && (
<div className="mt-8">
<Button
onClick={() => openModal("UPGRADE_TO_PRO")}
iconRight={<HiBolt />}
>
{t`Upgrade to Pro`}
</Button>
</div>
)}
</div>
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
{t`Language`}
</h2>
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
{t`Change the language of the app.`}
</p>
<LanguageSelector />
</div>
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" && (
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
{t`Billing`}
</h2>
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
{t`View and manage your billing and subscription.`}
</p>
<Button
variant="primary"
iconRight={<HiMiniArrowTopRightOnSquare />}
onClick={handleOpenBillingPortal}
>
{t`Billing portal`}
</Button>
</div>
)}
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
Trello
</h2>
{!integrations?.some(
(integration) => integration.provider === "trello",
) && trelloUrl ? (
<>
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
{t`Connect your Trello account to import boards.`}
</p>
<Button
variant="primary"
iconRight={<HiMiniArrowTopRightOnSquare />}
onClick={() =>
window.open(
trelloUrl.url,
"trello_auth",
"height=800,width=600",
)
}
>
{t`Connect Trello`}
</Button>
</>
) : (
integrations?.some(
(integration) => integration.provider === "trello",
) && (
<>
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
{t`Your Trello account is connected.`}
</p>
<Button
variant="secondary"
onClick={() => disconnectTrello({ provider: "trello" })}
>
{t`Disconnect Trello`}
</Button>
</>
)
)}
</div>
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
{t`API keys`}
</h2>
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
{t`View and manage your API keys.`}
</p>
<CreateAPIKeyForm
apiKey={data?.apiKey}
refetchUser={refetchUser}
/>
</div>
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
{t`Delete workspace`}
</h2>
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
{t`Once you delete your workspace, there is no going back. This action cannot be undone.`}
</p>
<div className="mt-4">
<Button
variant="secondary"
onClick={() => openModal("DELETE_WORKSPACE")}
disabled={workspace.role !== "admin"}
>
{t`Delete workspace`}
</Button>
</div>
</div>
{isCredentialsEnabled && (
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
{t`Change Password`}
</h2>
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
{t`You are about to change your password.`}
</p>
<div className="mt-4">
<Button
variant="secondary"
onClick={() => openModal("CHANGE_PASSWORD")}
>
{t`Change Password`}
</Button>
</div>
</div>
)}
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
{t`Delete account`}
</h2>
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
{t`Once you delete your account, there is no going back. This action cannot be undone.`}
</p>
<div className="mt-4">
<Button
variant="secondary"
onClick={() => openModal("DELETE_ACCOUNT")}
>
{t`Delete account`}
</Button>
</div>
</div>
</div>
<>
<Modal
modalSize="md"
isVisible={isOpen && modalContentType === "NEW_FEEDBACK"}
>
<FeedbackModal />
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "NEW_WORKSPACE"}
>
<NewWorkspaceForm />
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "DELETE_WORKSPACE"}
>
<DeleteWorkspaceConfirmation />
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "UPGRADE_TO_PRO"}
>
<UpgradeToProConfirmation
userId={data?.id ?? ""}
workspacePublicId={workspace.publicId}
/>
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "DELETE_ACCOUNT"}
>
<DeleteAccountConfirmation />
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "CHANGE_PASSWORD"}
>
<ChangePasswordFormConfirmation />
</Modal>
</>
</div>
</div>
</>
);
}

View File

@@ -25,6 +25,9 @@ services:
- SMTP_PASSWORD=${SMTP_PASSWORD}
- EMAIL_FROM=${EMAIL_FROM}
# Disable email features entirely (optional)
- NEXT_PUBLIC_DISABLE_EMAIL=${NEXT_PUBLIC_DISABLE_EMAIL}
# S3 storage (optional)
- S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID}
- S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY}

View File

@@ -1,14 +1,19 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import * as inviteLinkRepo from "@kan/db/repository/inviteLink.repo";
import * as memberRepo from "@kan/db/repository/member.repo";
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
import * as userRepo from "@kan/db/repository/user.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { getSubscriptionByPlan, hasUnlimitedSeats } from "@kan/shared/utils";
import {
generateUID,
getSubscriptionByPlan,
hasUnlimitedSeats,
} from "@kan/shared/utils";
import { updateSubscriptionSeats } from "@kan/stripe";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import { assertUserInWorkspace } from "../utils/auth";
export const memberRouter = createTRPCRouter({
@@ -241,4 +246,403 @@ export const memberRouter = createTRPCRouter({
return { success: true };
}),
getActiveInviteLink: protectedProcedure
.meta({
openapi: {
summary: "Get active invite link for workspace",
method: "GET",
path: "/workspaces/{workspacePublicId}/invite",
description: "Gets the active invite link for a workspace",
tags: ["Invites"],
protect: true,
},
})
.input(
z.object({
workspacePublicId: z.string().min(12),
}),
)
.output(
z.object({
id: z.number().optional(),
inviteCode: z.string().optional(),
inviteLink: z.string().optional(),
isActive: z.boolean(),
expiresAt: z.date().optional(),
}),
)
.query(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId)
throw new TRPCError({
message: `User not authenticated`,
code: "UNAUTHORIZED",
});
const workspace = await workspaceRepo.getByPublicId(
ctx.db,
input.workspacePublicId,
);
if (!workspace)
throw new TRPCError({
message: `Workspace not found`,
code: "NOT_FOUND",
});
// Check if user is in workspace
await assertUserInWorkspace(ctx.db, userId, workspace.id);
// Get active invite link for this workspace
const activeInviteLink = await inviteLinkRepo.getActiveForWorkspace(
ctx.db,
workspace.id,
);
if (
activeInviteLink &&
(!activeInviteLink.expiresAt || new Date() < activeInviteLink.expiresAt)
) {
return {
id: activeInviteLink.id,
inviteCode: activeInviteLink.code,
inviteLink: `${process.env.NEXT_PUBLIC_BASE_URL}/invite/${activeInviteLink.code}`,
isActive: true,
expiresAt: activeInviteLink.expiresAt ?? undefined,
};
}
return { isActive: false };
}),
createInviteLink: protectedProcedure
.meta({
openapi: {
summary: "Create invite link for workspace",
method: "POST",
path: "/workspaces/{workspacePublicId}/invites",
description: "Create invite link for a workspace",
tags: ["Invites"],
protect: true,
},
})
.input(
z.object({
workspacePublicId: z.string().min(12),
}),
)
.output(
z.object({
publicId: z.string().min(12),
inviteCode: z.string(),
inviteLink: z.string(),
expiresAt: z.date().nullable(),
}),
)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId)
throw new TRPCError({
message: `User not authenticated`,
code: "UNAUTHORIZED",
});
const workspace = await workspaceRepo.getByPublicId(
ctx.db,
input.workspacePublicId,
);
if (!workspace)
throw new TRPCError({
message: `Workspace not found`,
code: "NOT_FOUND",
});
// Check if user is in workspace
await assertUserInWorkspace(ctx.db, userId, workspace.id, "admin");
// Check subscription for cloud environment
if (process.env.NEXT_PUBLIC_KAN_ENV === "cloud") {
const subscriptions = await subscriptionRepo.getByReferenceId(
ctx.db,
workspace.publicId,
);
const activeTeamSubscription = getSubscriptionByPlan(
subscriptions,
"team",
);
const activeProSubscription = getSubscriptionByPlan(
subscriptions,
"pro",
);
if (!activeTeamSubscription && !activeProSubscription) {
throw new TRPCError({
message: `Invite links require a Team or Pro subscription`,
code: "FORBIDDEN",
});
}
}
// Deactivate any existing active invite links
await inviteLinkRepo.deactivateAllActiveForWorkspace(ctx.db, {
workspaceId: workspace.id,
updatedBy: userId,
});
// Generate new invite code
const inviteCode = generateUID();
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 7);
// Create new invite link
const inviteLink = await inviteLinkRepo.createInviteLink(ctx.db, {
workspaceId: workspace.id,
code: inviteCode,
expiresAt,
createdBy: userId,
});
if (!inviteLink) {
throw new TRPCError({
message: `Failed to create invite link`,
code: "INTERNAL_SERVER_ERROR",
});
}
return {
publicId: inviteLink.publicId,
inviteCode: inviteLink.code,
inviteLink: `${process.env.NEXT_PUBLIC_BASE_URL}/invite/${inviteLink.code}`,
expiresAt: inviteLink.expiresAt,
};
}),
deactivateInviteLink: protectedProcedure
.meta({
openapi: {
summary: "Deactivate invite link for workspace",
method: "DELETE",
path: "/workspaces/{workspacePublicId}/invites",
description: "Deactivates the invite link for a workspace",
tags: ["Invites"],
protect: true,
},
})
.input(
z.object({
workspacePublicId: 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",
});
const workspace = await workspaceRepo.getByPublicId(
ctx.db,
input.workspacePublicId,
);
if (!workspace)
throw new TRPCError({
message: `Workspace not found`,
code: "NOT_FOUND",
});
// Check if user is in workspace
await assertUserInWorkspace(ctx.db, userId, workspace.id, "admin");
// Deactivate all active invite links
await inviteLinkRepo.deactivateAllActiveForWorkspace(ctx.db, {
workspaceId: workspace.id,
updatedBy: userId,
});
return { success: true };
}),
getInviteByCode: publicProcedure
.meta({
openapi: {
summary: "Get invite information by code",
method: "GET",
path: "/workspaces/{workspacePublicId}/invites/{inviteCode}",
description: "Get invite information by invite code",
tags: ["Invites"],
protect: false,
},
})
.input(
z.object({
inviteCode: z.string().min(12),
}),
)
.output(
z
.object({
publicId: z.string().min(12),
status: z.string(),
expiresAt: z.date().nullable(),
})
.optional(),
)
.query(async ({ ctx, input }) => {
const invite = await inviteLinkRepo.getByCode(ctx.db, input.inviteCode);
if (
!invite ||
invite.status !== "active" ||
(invite.expiresAt && new Date() > invite.expiresAt)
) {
throw new TRPCError({
message: `Invalid or expired invite link`,
code: "BAD_REQUEST",
});
}
return {
publicId: invite.publicId,
status: invite.status,
expiresAt: invite.expiresAt ?? null,
};
}),
acceptInviteLink: publicProcedure
.meta({
openapi: {
summary: "Accept an invite link",
method: "POST",
path: "/workspaces/{workspacePublicId}/invites/accept",
description: "Accepts an invitation via invite link",
tags: ["Invites"],
protect: false,
},
})
.input(
z.object({
inviteCode: z.string().min(12),
}),
)
.output(
z.object({
success: z.boolean(),
workspacePublicId: z.string().optional(),
workspaceSlug: z.string().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId)
throw new TRPCError({
message: `User not authenticated`,
code: "UNAUTHORIZED",
});
const invite = await inviteLinkRepo.getByCode(ctx.db, input.inviteCode);
if (
!invite ||
invite.status !== "active" ||
(invite.expiresAt && new Date() > invite.expiresAt)
)
throw new TRPCError({
message: `Invalid or expired invite link`,
code: "BAD_REQUEST",
});
const workspace = await workspaceRepo.getById(ctx.db, invite.workspaceId);
if (!workspace)
throw new TRPCError({
message: `Workspace not found`,
code: "NOT_FOUND",
});
const isMember = await workspaceRepo.isUserInWorkspace(
ctx.db,
userId,
invite.workspaceId,
);
if (isMember) {
throw new TRPCError({
message: `User is already a member of this workspace`,
code: "CONFLICT",
});
}
const user = await userRepo.getById(ctx.db, userId);
if (!user)
throw new TRPCError({
message: `User not found`,
code: "NOT_FOUND",
});
if (process.env.NEXT_PUBLIC_KAN_ENV === "cloud") {
const subscriptions = await subscriptionRepo.getByReferenceId(
ctx.db,
workspace.publicId,
);
// get the active subscriptions
const activeTeamSubscription = getSubscriptionByPlan(
subscriptions,
"team",
);
const activeProSubscription = getSubscriptionByPlan(
subscriptions,
"pro",
);
const unlimitedSeats = hasUnlimitedSeats(subscriptions);
if (!activeTeamSubscription && !activeProSubscription) {
throw new TRPCError({
message: `Workspace with public ID ${workspace.publicId} does not have an active subscription`,
code: "NOT_FOUND",
});
}
// Update the Stripe subscription
if (activeTeamSubscription?.stripeSubscriptionId && !unlimitedSeats) {
try {
await updateSubscriptionSeats(
activeTeamSubscription.stripeSubscriptionId,
1,
);
} catch (error) {
console.error("Failed to update Stripe subscription seats:", error);
throw new TRPCError({
message: `Failed to update subscription for the new member.`,
code: "INTERNAL_SERVER_ERROR",
});
}
}
}
await memberRepo.create(ctx.db, {
workspaceId: invite.workspaceId,
email: user.email,
userId: user.id,
createdBy: user.id,
role: "member",
status: "active",
});
return {
success: true,
workspacePublicId: workspace.publicId,
workspaceSlug: workspace.slug,
};
}),
});

View File

@@ -409,4 +409,80 @@ export const workspaceRouter = createTRPCRouter({
isReserved: workspaceSlug?.type === "reserved",
};
}),
search: protectedProcedure
.meta({
openapi: {
summary: "Search boards and cards in a workspace",
method: "GET",
path: "/workspaces/{workspacePublicId}/search",
description:
"Searches for boards and cards by title within a workspace",
tags: ["Workspaces"],
protect: true,
},
})
.input(
z.object({
workspacePublicId: z.string().min(12),
query: z.string().min(1).max(100),
limit: z.number().min(1).max(50).optional().default(20),
}),
)
.output(
z.array(
z.discriminatedUnion("type", [
z.object({
publicId: z.string(),
title: z.string(),
description: z.string().nullable(),
slug: z.string(),
updatedAt: z.date().nullable(),
createdAt: z.date(),
type: z.literal("board"),
}),
z.object({
publicId: z.string(),
title: z.string(),
description: z.string().nullable(),
boardPublicId: z.string(),
boardName: z.string(),
listName: z.string(),
updatedAt: z.date().nullable(),
createdAt: z.date(),
type: z.literal("card"),
}),
]),
),
)
.query(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId)
throw new TRPCError({
message: `User not authenticated`,
code: "UNAUTHORIZED",
});
const workspace = await workspaceRepo.getByPublicId(
ctx.db,
input.workspacePublicId,
);
if (!workspace)
throw new TRPCError({
message: `Workspace not found`,
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, workspace.id);
const result = await workspaceRepo.searchBoardsAndCards(
ctx.db,
workspace.id,
input.query,
input.limit,
);
return result;
}),
});

View File

@@ -220,6 +220,19 @@ export const initAuth = (db: dbClient) => {
console.log(
`Pro subscription ${stripeSubscription.id} activated with unlimited seats`,
);
const workspace = await workspaceRepo.getByPublicId(
db,
subscription.referenceId,
);
if (workspace?.id) {
await memberRepo.unpauseAllMembers(db, workspace.id);
console.log(
`Unpausing all members for workspace ${workspace.id}`,
);
}
}
},
},

View File

@@ -0,0 +1 @@
ALTER TYPE "public"."member_status" ADD VALUE 'paused';

View File

@@ -0,0 +1,34 @@
CREATE TYPE "public"."invite_link_status" AS ENUM('active', 'inactive');--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "workspace_invite_links" (
"id" bigserial PRIMARY KEY NOT NULL,
"publicId" varchar(12) NOT NULL,
"workspaceId" bigint NOT NULL,
"code" varchar(12) NOT NULL,
"status" "invite_link_status" DEFAULT 'active' NOT NULL,
"expiresAt" timestamp,
"createdAt" timestamp DEFAULT now() NOT NULL,
"createdBy" uuid,
"updatedAt" timestamp,
"updatedBy" uuid,
CONSTRAINT "workspace_invite_links_publicId_unique" UNIQUE("publicId"),
CONSTRAINT "workspace_invite_links_code_unique" UNIQUE("code")
);
--> statement-breakpoint
ALTER TABLE "workspace_invite_links" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "workspace_invite_links" ADD CONSTRAINT "workspace_invite_links_workspaceId_workspace_id_fk" FOREIGN KEY ("workspaceId") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "workspace_invite_links" ADD CONSTRAINT "workspace_invite_links_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "workspace_invite_links" ADD CONSTRAINT "workspace_invite_links_updatedBy_user_id_fk" FOREIGN KEY ("updatedBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

View File

@@ -0,0 +1,4 @@
CREATE EXTENSION IF NOT EXISTS pg_trgm; --> statement-breakpoint
CREATE INDEX IF NOT EXISTS boards_name_trgm_idx ON board USING gin (name gin_trgm_ops);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS cards_title_trgm_idx ON card USING gin (title gin_trgm_ops);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -99,6 +99,27 @@
"when": 1757535838766,
"tag": "20250910202358_AddCascadeSetNullToReferenceIdOnSubsriptions",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1758226671081,
"tag": "20250918201751_AddPausedMemberStatus",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1758662398166,
"tag": "20250923211958_AddWorkspaceInviteLinks",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1759356096392,
"tag": "20251001220136_AddFuzzySearchSupport",
"breakpoints": true
}
]
}

View File

@@ -95,9 +95,32 @@ export const create = async (
.having(gt(countExpr, 1));
if (duplicateIndices.length > 0) {
throw new Error(
`Duplicate indices found after creating card ${result[0].id}`,
);
// Compact indices for this list to sequential values (0..n-1) preserving order
await tx.execute(sql`
WITH ordered AS (
SELECT id, ROW_NUMBER() OVER (ORDER BY "index", id) - 1 AS new_index
FROM "card"
WHERE "listId" = ${result[0].listId} AND "deletedAt" IS NULL
)
UPDATE "card" c
SET "index" = o.new_index
FROM ordered o
WHERE c.id = o.id;
`);
// Last resort: verify fix; rollback if duplicates persist
const postFixDupes = await tx
.select({ index: cards.index, count: countExpr })
.from(cards)
.where(and(eq(cards.listId, result[0].listId), isNull(cards.deletedAt)))
.groupBy(cards.listId, cards.index)
.having(gt(countExpr, 1));
if (postFixDupes.length > 0) {
throw new Error(
`Invariant violation: duplicate card indices remain after compaction in list ${result[0].listId}`,
);
}
}
return result[0];
@@ -219,11 +242,95 @@ export const bulkCreate = async (
importId?: number;
}[],
) => {
const result = await db.insert(cards).values(cardInput).returning({
id: cards.id,
});
if (cardInput.length === 0) return [];
return result;
return db.transaction(async (tx) => {
// Group incoming cards by list to compute safe, sequential indices per list
const byList = new Map<number, typeof cardInput>();
for (const item of cardInput) {
const arr = byList.get(item.listId) ?? [];
arr.push(item);
byList.set(item.listId, arr);
}
const allValuesToInsert: {
publicId: string;
title: string;
description: string;
createdBy: string;
listId: number;
index: number;
importId?: number;
}[] = [];
// For each list, append incoming cards after current max index, preserving incoming order
for (const [listId, items] of byList.entries()) {
const last = await tx.query.cards.findFirst({
columns: { index: true },
where: and(eq(cards.listId, listId), isNull(cards.deletedAt)),
orderBy: [desc(cards.index)],
});
let nextIndex = last ? last.index + 1 : 0;
const sorted = [...items].sort((a, b) => a.index - b.index);
for (const it of sorted) {
allValuesToInsert.push({
publicId: it.publicId,
title: it.title,
description: it.description,
createdBy: it.createdBy,
listId: it.listId,
index: nextIndex++,
importId: it.importId,
});
}
}
const inserted = await tx
.insert(cards)
.values(allValuesToInsert)
.returning({ id: cards.id });
// Post-insert: compact per list if duplicates exist; then verify
const countExpr = sql<number>`COUNT(*)`.mapWith(Number);
for (const listId of byList.keys()) {
const duplicateIndices = await tx
.select({ index: cards.index, count: countExpr })
.from(cards)
.where(and(eq(cards.listId, listId), isNull(cards.deletedAt)))
.groupBy(cards.listId, cards.index)
.having(gt(countExpr, 1));
if (duplicateIndices.length > 0) {
await tx.execute(sql`
WITH ordered AS (
SELECT id, ROW_NUMBER() OVER (ORDER BY "index", id) - 1 AS new_index
FROM "card"
WHERE "listId" = ${listId} AND "deletedAt" IS NULL
)
UPDATE "card" c
SET "index" = o.new_index
FROM ordered o
WHERE c.id = o.id;
`);
const postFixDupes = await tx
.select({ index: cards.index, count: countExpr })
.from(cards)
.where(and(eq(cards.listId, listId), isNull(cards.deletedAt)))
.groupBy(cards.listId, cards.index)
.having(gt(countExpr, 1));
if (postFixDupes.length > 0) {
throw new Error(
`Invariant violation: duplicate card indices remain after compaction in list ${listId}`,
);
}
}
}
return inserted;
});
};
export const createCardLabelRelationship = async (
@@ -606,9 +713,53 @@ export const reorder = async (
.having(gt(countExpr, 1));
if (duplicateIndices.length > 0) {
throw new Error(
`Duplicate indices found after reordering card ${card.id}`,
// Auto-heal by compacting indices for the affected list(s)
const affectedListIds = [currentList.id, newList?.id].filter(
(id): id is number => id !== undefined,
);
if (affectedListIds.length === 1) {
await tx.execute(sql`
WITH ordered AS (
SELECT id, ROW_NUMBER() OVER (ORDER BY "index", id) - 1 AS new_index
FROM "card"
WHERE "listId" = ${affectedListIds[0]} AND "deletedAt" IS NULL
)
UPDATE "card" c
SET "index" = o.new_index
FROM ordered o
WHERE c.id = o.id;
`);
} else if (affectedListIds.length === 2) {
await tx.execute(sql`
WITH ordered AS (
SELECT id,
ROW_NUMBER() OVER (PARTITION BY "listId" ORDER BY "index", id) - 1 AS new_index
FROM "card"
WHERE "listId" IN (${sql.join(affectedListIds, sql`,`)}) AND "deletedAt" IS NULL
)
UPDATE "card" c
SET "index" = o.new_index
FROM ordered o
WHERE c.id = o.id;
`);
}
// Verify fix and rollback if necessary
const postFixDupes = await tx
.select({ index: cards.index, count: countExpr })
.from(cards)
.where(
and(inArray(cards.listId, affectedListIds), isNull(cards.deletedAt)),
)
.groupBy(cards.listId, cards.index)
.having(gt(countExpr, 1));
if (postFixDupes.length > 0) {
throw new Error(
`Invariant violation: duplicate card indices remain after compaction for card ${card.id}`,
);
}
}
const updatedCard = await tx.query.cards.findFirst({

View File

@@ -0,0 +1,71 @@
import { and, eq, gt } from "drizzle-orm";
import type { dbClient } from "@kan/db/client";
import { workspaceInviteLinks } from "@kan/db/schema";
import { generateUID } from "@kan/shared/utils";
export const createInviteLink = async (
db: dbClient,
args: {
workspaceId: number;
code: string;
expiresAt: Date | null;
createdBy: string;
},
) => {
const [result] = await db
.insert(workspaceInviteLinks)
.values({
publicId: generateUID(),
workspaceId: args.workspaceId,
code: args.code,
expiresAt: args.expiresAt ?? null,
status: "active",
createdBy: args.createdBy,
})
.returning({
publicId: workspaceInviteLinks.publicId,
code: workspaceInviteLinks.code,
status: workspaceInviteLinks.status,
expiresAt: workspaceInviteLinks.expiresAt,
});
return result;
};
export const deactivateAllActiveForWorkspace = async (
db: dbClient,
args: { workspaceId: number; updatedBy: string },
) => {
await db
.update(workspaceInviteLinks)
.set({
status: "inactive",
updatedBy: args.updatedBy,
updatedAt: new Date(),
})
.where(
and(
eq(workspaceInviteLinks.workspaceId, args.workspaceId),
eq(workspaceInviteLinks.status, "active"),
),
);
};
export const getActiveForWorkspace = async (
db: dbClient,
workspaceId: number,
) => {
return db.query.workspaceInviteLinks.findFirst({
where: and(
eq(workspaceInviteLinks.workspaceId, workspaceId),
eq(workspaceInviteLinks.status, "active"),
),
orderBy: (links, { desc }) => [desc(links.createdAt)],
});
};
export const getByCode = async (db: dbClient, code: string) => {
return db.query.workspaceInviteLinks.findFirst({
where: eq(workspaceInviteLinks.code, code),
});
};

View File

@@ -59,9 +59,32 @@ export const create = async (
.having(gt(countExpr, 1));
if (duplicateIndices.length > 0) {
throw new Error(
`Duplicate indices found after reordering in board ${result.boardId}`,
);
// Compact indices to sequential values (0..n-1) to resolve duplicates while preserving order
await tx.execute(sql`
WITH ordered AS (
SELECT id, ROW_NUMBER() OVER (ORDER BY "index", id) - 1 AS new_index
FROM "list"
WHERE "boardId" = ${result.boardId} AND "deletedAt" IS NULL
)
UPDATE "list" l
SET "index" = o.new_index
FROM ordered o
WHERE l.id = o.id;
`);
// Last resort: verify fix; if duplicates persist (e.g., due to race conditions), rollback
const postFixDupes = await tx
.select({ index: lists.index, count: countExpr })
.from(lists)
.where(and(eq(lists.boardId, result.boardId), isNull(lists.deletedAt)))
.groupBy(lists.index)
.having(gt(countExpr, 1));
if (postFixDupes.length > 0) {
throw new Error(
`Invariant violation: duplicate indices remain after compaction in board ${result.boardId}`,
);
}
}
return result;
@@ -79,7 +102,98 @@ export const bulkCreate = async (
importId?: number;
}[],
) => {
return db.insert(lists).values(listInput).returning();
if (listInput.length === 0) return [];
return db.transaction(async (tx) => {
// Group incoming rows by board to compute safe, sequential indices per board
const byBoard = new Map<number, typeof listInput>();
for (const item of listInput) {
const arr = byBoard.get(item.boardId) ?? [];
arr.push(item);
byBoard.set(item.boardId, arr);
}
const allValuesToInsert: {
publicId: string;
name: string;
createdBy: string;
boardId: number;
index: number;
importId?: number;
}[] = [];
// For each board, append incoming lists after the current max index, preserving their relative order
for (const [boardId, items] of byBoard.entries()) {
// Find current max index for non-deleted lists in this board
const last = await tx.query.lists.findFirst({
columns: { index: true },
where: and(eq(lists.boardId, boardId), isNull(lists.deletedAt)),
orderBy: [desc(lists.index)],
});
let nextIndex = last ? last.index + 1 : 0;
// Sort incoming by their provided index to preserve Trello order, then reassign sequential indices
const sorted = [...items].sort((a, b) => a.index - b.index);
for (const it of sorted) {
allValuesToInsert.push({
publicId: it.publicId,
name: it.name,
createdBy: it.createdBy,
boardId: it.boardId,
index: nextIndex++,
importId: it.importId,
});
}
}
// Insert all rows in one go
const inserted = await tx
.insert(lists)
.values(allValuesToInsert)
.returning();
// Post-insert check: if duplicates exist, compact indices per board instead of failing
const countExpr = sql<number>`COUNT(*)`.mapWith(Number);
for (const boardId of byBoard.keys()) {
const duplicateIndices = await tx
.select({ index: lists.index, count: countExpr })
.from(lists)
.where(and(eq(lists.boardId, boardId), isNull(lists.deletedAt)))
.groupBy(lists.index)
.having(gt(countExpr, 1));
if (duplicateIndices.length > 0) {
await tx.execute(sql`
WITH ordered AS (
SELECT id, ROW_NUMBER() OVER (ORDER BY "index", id) - 1 AS new_index
FROM "list"
WHERE "boardId" = ${boardId} AND "deletedAt" IS NULL
)
UPDATE "list" l
SET "index" = o.new_index
FROM ordered o
WHERE l.id = o.id;
`);
// Last resort: verify fix; if duplicates persist (e.g., due to race conditions), rollback
const postFixDupes = await tx
.select({ index: lists.index, count: countExpr })
.from(lists)
.where(and(eq(lists.boardId, boardId), isNull(lists.deletedAt)))
.groupBy(lists.index)
.having(gt(countExpr, 1));
if (postFixDupes.length > 0) {
throw new Error(
`Invariant violation: duplicate indices remain after compaction in board ${boardId}`,
);
}
}
}
return inserted;
});
};
export const getByPublicId = async (db: dbClient, listPublicId: string) => {
@@ -180,9 +294,32 @@ export const reorder = async (
.having(gt(countExpr, 1));
if (duplicateIndices.length > 0) {
throw new Error(
`Duplicate indices found after reordering in board ${list.boardId}`,
);
// Attempt to auto-heal by compacting indices to sequential values (0..n-1) while preserving order
await tx.execute(sql`
WITH ordered AS (
SELECT id, ROW_NUMBER() OVER (ORDER BY "index", id) - 1 AS new_index
FROM "list"
WHERE "boardId" = ${list.boardId} AND "deletedAt" IS NULL
)
UPDATE "list" l
SET "index" = o.new_index
FROM ordered o
WHERE l.id = o.id;
`);
// Last resort verification: if duplicates persist, rollback
const postFixDupes = await tx
.select({ index: lists.index, count: countExpr })
.from(lists)
.where(and(eq(lists.boardId, list.boardId), isNull(lists.deletedAt)))
.groupBy(lists.index)
.having(gt(countExpr, 1));
if (postFixDupes.length > 0) {
throw new Error(
`Invariant violation: duplicate indices remain after compaction in board ${list.boardId}`,
);
}
}
const updatedList = await tx.query.lists.findFirst({

View File

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

View File

@@ -1,7 +1,13 @@
import { and, eq, inArray, isNull } from "drizzle-orm";
import { and, desc, eq, ilike, inArray, isNull, or, sql } from "drizzle-orm";
import type { dbClient } from "@kan/db/client";
import { boards, workspaceMembers, workspaces } from "@kan/db/schema";
import {
boards,
cards,
lists,
workspaceMembers,
workspaces,
} from "@kan/db/schema";
import { generateUID } from "@kan/shared/utils";
export const create = async (
@@ -87,11 +93,25 @@ export const getByPublicId = (db: dbClient, workspacePublicId: string) => {
publicId: true,
name: true,
plan: true,
slug: true,
},
where: eq(workspaces.publicId, workspacePublicId),
});
};
export const getById = (db: dbClient, workspaceId: number) => {
return db.query.workspaces.findFirst({
columns: {
id: true,
publicId: true,
name: true,
plan: true,
slug: true,
},
where: eq(workspaces.id, workspaceId),
});
};
export const getByPublicIdWithMembers = (
db: dbClient,
workspacePublicId: string,
@@ -260,3 +280,84 @@ export const isUserInWorkspace = async (
return result?.id !== undefined;
};
export const searchBoardsAndCards = async (
db: dbClient,
workspaceId: number,
query: string,
limit = 20,
) => {
const searchQuery = `%${query}%`;
// Search for boards
const boardResults = await db
.select({
publicId: boards.publicId,
title: boards.name,
description: boards.description,
slug: boards.slug,
updatedAt: boards.updatedAt,
createdAt: boards.createdAt,
})
.from(boards)
.where(
and(
eq(boards.workspaceId, workspaceId),
// Combine exact and fuzzy matching
or(
ilike(boards.name, `%${query}%`), // Exact substring match
sql`similarity(${boards.name}, ${query}) > 0.2`, // Fuzzy match
),
isNull(boards.deletedAt),
),
)
.orderBy(
sql`CASE WHEN ${boards.name} ILIKE ${`%${query}%`} THEN 1 ELSE 0 END DESC`,
sql`similarity(${boards.name}, ${query}) DESC`,
desc(boards.updatedAt),
)
.limit(Math.ceil(limit * 0.4));
// Search for cards
const cardResults = await db
.select({
publicId: cards.publicId,
title: cards.title,
description: cards.description,
boardPublicId: boards.publicId,
boardName: boards.name,
listName: lists.name,
updatedAt: cards.updatedAt,
createdAt: cards.createdAt,
})
.from(cards)
.innerJoin(lists, eq(cards.listId, lists.id))
.innerJoin(boards, eq(lists.boardId, boards.id))
.where(
and(
eq(boards.workspaceId, workspaceId),
or(
ilike(cards.title, searchQuery),
sql`similarity(${cards.title}, ${query}) > 0.2`,
),
isNull(cards.deletedAt),
isNull(lists.deletedAt),
isNull(boards.deletedAt),
),
)
.orderBy(
sql`CASE WHEN ${cards.title} ILIKE ${searchQuery} THEN 1 ELSE 0 END DESC`,
sql`similarity(${cards.title}, ${query}) DESC`,
desc(cards.updatedAt),
)
.limit(Math.floor(limit * 0.6));
// Combine results
const allResults = [
...boardResults.map((board) => ({ ...board, type: "board" as const })),
...cardResults.map((card) => ({ ...card, type: "card" as const })),
];
// Ensure we don't exceed the total limit
return allResults.slice(0, limit);
};

View File

@@ -11,3 +11,4 @@ export * from "./users";
export * from "./integrations";
export * from "./workspaces";
export * from "./subscriptions";
export * from "./workspaceInviteLinks";

View File

@@ -0,0 +1,38 @@
import {
bigint,
bigserial,
pgEnum,
pgTable,
timestamp,
uuid,
varchar,
} from "drizzle-orm/pg-core";
import { users } from "./users";
import { workspaces } from "./workspaces";
export const inviteLinkStatuses = ["active", "inactive"] as const;
export type InviteLinkStatus = (typeof inviteLinkStatuses)[number];
export const inviteLinkStatusEnum = pgEnum(
"invite_link_status",
inviteLinkStatuses,
);
export const workspaceInviteLinks = pgTable("workspace_invite_links", {
id: bigserial("id", { mode: "number" }).primaryKey(),
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
workspaceId: bigint("workspaceId", { mode: "number" })
.notNull()
.references(() => workspaces.id, { onDelete: "cascade" }),
code: varchar("code", { length: 12 }).notNull().unique(),
status: inviteLinkStatusEnum("status").notNull().default("active"),
expiresAt: timestamp("expiresAt"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
createdBy: uuid("createdBy").references(() => users.id, {
onDelete: "set null",
}),
updatedAt: timestamp("updatedAt"),
updatedBy: uuid("updatedBy").references(() => users.id, {
onDelete: "set null",
}),
}).enableRLS();

View File

@@ -19,7 +19,12 @@ export const memberRoles = ["admin", "member", "guest"] as const;
export type MemberRole = (typeof memberRoles)[number];
export const memberRoleEnum = pgEnum("role", memberRoles);
export const memberStatuses = ["invited", "active", "removed"] as const;
export const memberStatuses = [
"invited",
"active",
"removed",
"paused",
] as const;
export type MemberStatus = (typeof memberStatuses)[number];
export const memberStatusEnum = pgEnum("member_status", memberStatuses);

12
pnpm-lock.yaml generated
View File

@@ -205,6 +205,9 @@ importers:
react-icons:
specifier: ^5.5.0
version: 5.5.0(react@18.3.1)
react-image-crop:
specifier: ^11.0.10
version: 11.0.10(react@18.3.1)
react-lottie-player:
specifier: ^1.5.5
version: 1.5.6(react@18.3.1)
@@ -6116,6 +6119,11 @@ packages:
peerDependencies:
react: '*'
react-image-crop@11.0.10:
resolution: {integrity: sha512-+5FfDXUgYLLqBh1Y/uQhIycpHCbXkI50a+nbfkB1C0xXXUTwkisHDo2QCB1SQJyHCqIuia4FeyReqXuMDKWQTQ==}
peerDependencies:
react: '>=16.13.1'
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@@ -13696,6 +13704,10 @@ snapshots:
dependencies:
react: 18.3.1
react-image-crop@11.0.10(react@18.3.1):
dependencies:
react: 18.3.1
react-is@16.13.1: {}
react-is@17.0.2: {}

View File

@@ -89,6 +89,7 @@
"APPLE_CLIENT_ID",
"APPLE_CLIENT_SECRET",
"APPLE_APP_BUNDLE_IDENTIFIER",
"NEXT_PUBLIC_DISABLE_EMAIL",
"EMAIL_FROM",
"SMTP_HOST",
"SMTP_PORT",