Compare commits

..

26 Commits

Author SHA1 Message Date
Henry
ec17d76ebf fix: close self hosting docs loop 2025-06-08 16:13:32 +01:00
Henry
75ad0d8f98 chore: set trusted origins to required in readme 2025-06-08 16:11:36 +01:00
Henry
4066e6bba7 chore: add postgres setup to docs 2025-06-08 10:58:32 +01:00
Henry
8aa4aa2a7c chore: update self hosting docs in readme 2025-06-08 00:17:58 +01:00
Henry
43164a0d8a chore: fix image tags 2025-06-08 00:05:28 +01:00
Henry
8474601b2e chore: prevent sig from taking the latest tag 2025-06-07 23:46:10 +01:00
Henry
d768b1e6ba chore: update dockerfile to run migration on start 2025-06-07 22:59:26 +01:00
Mathieu Andrade
6a7f2f148d fix: prevent list buttons from stacking (#49)
* Fix: Improve list header

* fix: prevent list buttons from stacking

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
2025-06-07 21:50:30 +01:00
Henry
cbc90c3002 feat: multi-arch docker builds with latest tag 2025-06-07 14:24:47 +01:00
LovelessCodes
efe83ec8a9 refactor: replace js-cookie auth with new auth client and remove unused packages from web package (#50) 2025-06-07 12:44:32 +01:00
Henry
d1d37840fc chore: tweak HN logo 2025-06-06 14:03:35 +01:00
Henry
4135e316f4 feat: add hacker news link to landing page 2025-06-06 13:43:34 +01:00
LovelessCodes
805ef8cf43 refactor: update board slug prefix to use dynamic base URL from env (#38)
* refactor: update board slug prefix to use dynamic base URL from env

* feat: add board slug availability check with real-time validation UI

* refactor: consolidate board slug lookup by removing boardSlug repo and updating environment variables

* feat: allow board slug reuse across different workspaces

* chore: update environment settings and remove unused imports

* refactor: update board slug availability check to use boardPublicId instead of workspaceSlug

* refactor: simplify board slug availability check with direct SQL query
2025-06-06 12:55:00 +01:00
LovelessCodes
e590de80c0 chore: update local development configuration (#45) 2025-06-06 12:52:13 +01:00
LovelessCodes
f64549f743 feat: add not found states for board and card views with conditional … (#41)
* feat: add not found states for board and card views with conditional rendering

* refactor: move out of nested ternary
2025-06-05 23:40:37 +01:00
LovelessCodes
ce2ef1b1d4 feat: add S3 avatar upload during user creation from social provider (#28)
* feat: add S3 avatar upload during user creation from social provider

* chore: remove console.log and update storage config for local development

* refactor: move avatar upload to after user creation and use user ID for filename

* feat: update avatar storage path
2025-06-05 23:29:56 +01:00
LovelessCodes
56b0161c6f refactor: move form reset logic to onSuccess and improve error handli… (#40)
* refactor: move form reset logic to onSuccess and improve error handling in NewCardForm

* feat: disable create card button when title is empty or form is submitting
2025-06-05 23:21:04 +01:00
LovelessCodes
314053fa95 feat: add support for 15 new OAuth providers including Apple, Microsoft, and TikTok (#34)
* feat: add support for 15 new OAuth providers including Apple, Microsoft, and TikTok

* chore: remove unused OAuth providers from docs
2025-06-05 23:15:00 +01:00
LovelessCodes
f41736c1aa feat: implement auth session check and redirect on login page (#39) 2025-06-05 23:06:11 +01:00
LovelessCodes
e53efe80c5 feat: add user authentication check for activity list self-identifica… (#36)
* feat: add user authentication check for activity list self-identification

* refactor: replace api.user with authClient for session management in ActivityList
2025-06-05 22:42:09 +01:00
LovelessCodes
2043a8f98c fix: add cascade delete for card-related foreign keys and invalidate … (#31)
* fix: add cascade delete for card-related foreign keys and invalidate workspace cache after deletion

* fix: use refetch instead of invalidate for workspace deletion to ensure immediate UI update

* feat: add cascade delete to card relations and activity tables
2025-06-05 21:20:31 +01:00
Henry
116fefcaf3 chore: add contributors to readme 2025-06-05 13:18:25 +01:00
Lonneke
f88bd82044 fix: handle external URLs in avatar rendering (#23) (#32)
* fix: issue 23, handle external URLs in avatar rendering

* fix: add google to the remote patterns

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
2025-06-05 13:10:38 +01:00
LovelessCodes
6eb17529f1 feat(auth): added discord & github authentication methods (#18)
* feat(auth): added discord & github authentication methods

* chore(format): formatted edited files

* docs: add Discord and GitHub OAuth environment variables to README

* feat: add Discord and GitHub OAuth client credentials to environment config

* refactor: migrate social provider types to better-auth package

* feat: return configured social provider names from auth endpoint

* refactor: simplify socialProvidersPlugin by removing nested function and condensing return
2025-06-05 12:00:07 +01:00
Henry
4b7f58aeeb fix: missing s3 env variables 2025-06-05 10:54:36 +01:00
LovelessCodes
86a01074b9 refactor: remove unused EMAIL_ environment variables (#24)
* refactor: remove unused EMAIL_TOKEN environment variable

* refactor: remove unused EMAIL_URL environment variable

* chore: remove unused EMAIL_URL environment variable from docker compose

* chore: add SMTP environment variables
2025-06-05 10:41:08 +01:00
32 changed files with 2995 additions and 233 deletions

View File

@@ -21,3 +21,39 @@ BETTER_AUTH_TRUSTED_ORIGINS=
GOOGLE_CLIENT_ID= GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET= GOOGLE_CLIENT_SECRET=
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GITLAB_CLIENT_ID=
GITLAB_CLIENT_SECRET=
GITLAB_ISSUER=
MICROSOFT_CLIENT_ID=
MICROSOFT_CLIENT_SECRET=
TWITTER_CLIENT_ID=
TWITTER_CLIENT_SECRET=
KICK_CLIENT_ID=
KICK_CLIENT_SECRET=
ZOOM_CLIENT_ID=
ZOOM_CLIENT_SECRET=
DROPBOX_CLIENT_ID=
DROPBOX_CLIENT_SECRET=
VK_CLIENT_ID=
VK_CLIENT_SECRET=
LINKEDIN_CLIENT_ID=
LINKEDIN_CLIENT_SECRET=
REDDIT_CLIENT_ID=
REDDIT_CLIENT_SECRET=
ROBLOX_CLIENT_ID=
ROBLOX_CLIENT_SECRET=
SPOTIFY_CLIENT_ID=
SPOTIFY_CLIENT_SECRET=
TIKTOK_CLIENT_ID=
TIKTOK_CLIENT_SECRET=
TIKTOK_CLIENT_KEY=
TWITCH_CLIENT_ID=
TWITCH_CLIENT_SECRET=
APPLE_CLIENT_ID=
APPLE_CLIENT_SECRET=
APPLE_APP_BUNDLE_IDENTIFIER=

View File

@@ -66,6 +66,13 @@ jobs:
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0 uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest,enable={{is_default_branch}}
# Build and push Docker image with Buildx (don't push on PR) # Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action # https://github.com/docker/build-push-action
@@ -76,6 +83,7 @@ jobs:
context: . context: .
file: apps/web/Dockerfile file: apps/web/Dockerfile
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha cache-from: type=gha

View File

@@ -45,6 +45,37 @@ See our [roadmap](https://kan.bn/kan/roadmap) for upcoming features.
- [Drizzle ORM](https://orm.drizzle.team/?ref=kan.bn) - [Drizzle ORM](https://orm.drizzle.team/?ref=kan.bn)
- [React Email](https://react.email/?ref=kan.bn) - [React Email](https://react.email/?ref=kan.bn)
## Self Hosting 🐳
### PostgreSQL Database Setup
Kan requires a PostgreSQL database. You can run one using the official PostgreSQL Docker image:
```bash
# Run PostgreSQL in a container using the official postgres image
docker run -d \
--name kan-db \
-e POSTGRES_DB=kan \
-e POSTGRES_USER=kan_user \
-e POSTGRES_PASSWORD=your_secure_password \
-p 5432:5432 \
-v kan_postgres_data:/var/lib/postgresql/data \
postgres:15
# Your POSTGRES_URL should be:
# postgres://kan_user:your_secure_password@your_host:5432/kan
```
### Kan Application Deployment
Deploy Kan with Docker using our pre-built image:
```bash
docker pull ghcr.io/kanbn/kan:latest && docker run -it -p 3000:3000 --env-file .env ghcr.io/kanbn/kan:latest
```
Make sure to create a `.env` file with the required environment variables (see the Environment Variables section below).
## Local Development 🧑‍💻 ## Local Development 🧑‍💻
1. Clone the repository (or fork) 1. Clone the repository (or fork)
@@ -74,27 +105,31 @@ pnpm dev
## Environment Variables 🔐 ## Environment Variables 🔐
| Variable | Description | Required | Example | | Variable | Description | Required | Example |
| -------------------------------- | ----------------------------- | ---------------- | --------------------------------------------- | | -------------------------------- | ----------------------------- | ----------------- | --------------------------------------------- |
| `POSTGRES_URL` | PostgreSQL connection URL | Yes | `postgres://user:pass@localhost:5432/db` | | `POSTGRES_URL` | PostgreSQL connection URL | Yes | `postgres://user:pass@localhost:5432/db` |
| `EMAIL_FROM` | Sender email address | Yes | `"Kan <hello@mail.kan.bn>"` | | `EMAIL_FROM` | Sender email address | Yes | `"Kan <hello@mail.kan.bn>"` |
| `SMTP_HOST` | SMTP server hostname | Yes | `smtp.resend.com` | | `SMTP_HOST` | SMTP server hostname | Yes | `smtp.resend.com` |
| `SMTP_PORT` | SMTP server port | Yes | `465` | | `SMTP_PORT` | SMTP server port | Yes | `465` |
| `SMTP_USER` | SMTP username/email | Yes | `resend` | | `SMTP_USER` | SMTP username/email | Yes | `resend` |
| `SMTP_PASSWORD` | SMTP password/token | Yes | `re_xxxx` | | `SMTP_PASSWORD` | SMTP password/token | Yes | `re_xxxx` |
| `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` | | `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_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
| `BETTER_AUTH_URL` | Auth callback URL | Yes | Same as `NEXT_PUBLIC_BASE_URL` | | `BETTER_AUTH_URL` | Auth callback URL | Yes | Same as `NEXT_PUBLIC_BASE_URL` |
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | For Google login | `http://localhost:3000,http://localhost:3001` | | `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | Yes | `http://localhost:3000,http://localhost:3001` |
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` | | `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` |
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` | | `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` |
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` | | `DISCORD_CLIENT_ID` | Discord OAuth client ID | For Discord login | `xxx` |
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` | | `DISCORD_CLIENT_SECRET` | Discord OAuth client secret | For Discord login | `xxx` |
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads | `xxx` | | `GITHUB_CLIENT_ID` | GitHub OAuth client ID | For GitHub login | `xxx` |
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads | `xxx` | | `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret | For GitHub login | `xxx` |
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` | | `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` | | `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
| `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` | | `S3_ACCESS_KEY_ID` | S3 access key | For file uploads | `xxx` |
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads | `xxx` |
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` |
| `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` |
See `.env.example` for a complete list of supported environment variables. See `.env.example` for a complete list of supported environment variables.
@@ -102,6 +137,12 @@ See `.env.example` for a complete list of supported environment variables.
We welcome contributions! Please read our [contribution guidelines](CONTRIBUTING.md) before submitting a pull request. We welcome contributions! Please read our [contribution guidelines](CONTRIBUTING.md) before submitting a pull request.
## Contributors 👥
<a href="https://github.com/kanbn/kan/graphs/contributors">
<img src="https://contrib.rocks/image?repo=kanbn/kan" />
</a>
## License 📝 ## License 📝
Kan is licensed under the [AGPLv3 license](LICENSE). Kan is licensed under the [AGPLv3 license](LICENSE).

View File

@@ -25,7 +25,11 @@ Get started with Kan by choosing your preferred deployment option below.
Get started instantly with our hosted solution - we'll handle all the Get started instantly with our hosted solution - we'll handle all the
hosting, scaling, and maintenance for you. hosting, scaling, and maintenance for you.
</Card> </Card>
<Card title="Self-host" icon="code" href="https://github.com/kanbn/kan"> <Card
title="Self-host"
icon="code"
href="https://github.com/kanbn/kan?tab=readme-ov-file#self-hosting-"
>
For full data ownership and control, you can self-host Kan on your For full data ownership and control, you can self-host Kan on your
infrastructure. infrastructure.
</Card> </Card>

View File

@@ -31,7 +31,7 @@ COPY . .
# Generate a partial monorepo with a pruned lockfile for a target workspace. # Generate a partial monorepo with a pruned lockfile for a target workspace.
# Assuming "@acme/nextjs" is the name entered in the project's package.json: { name: "@acme/nextjs" } # Assuming "@acme/nextjs" is the name entered in the project's package.json: { name: "@acme/nextjs" }
RUN turbo prune --scope=${PROJECT} --docker RUN turbo prune --scope=${PROJECT} --scope=@kan/db --docker
# 3. Build the project # 3. Build the project
FROM base AS builder FROM base AS builder
@@ -75,5 +75,6 @@ ARG PORT=3000
ENV PORT=${PORT} ENV PORT=${PORT}
EXPOSE ${PORT} EXPOSE ${PORT}
CMD ["pnpm", "start"] # Run migration on start for now (until we have a better way to handle this)
CMD ["sh", "-c", "cd /app && pnpm db:migrate && cd /app/apps/web && pnpm start"]

View File

@@ -31,6 +31,10 @@ const config = {
protocol: "https", protocol: "https",
hostname: `*.${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`, hostname: `*.${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`,
}, },
{
protocol: "http",
hostname: "localhost",
},
{ {
protocol: "https", protocol: "https",
hostname: "*.googleusercontent.com", hostname: "*.googleusercontent.com",

View File

@@ -31,8 +31,6 @@
"aws-sdk": "^2.1692.0", "aws-sdk": "^2.1692.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"geist": "^1.3.1", "geist": "^1.3.1",
"js-cookie": "^3.0.5",
"jwt-decode": "^4.0.0",
"next": "^14.2.15", "next": "^14.2.15",
"next-logger": "^5.0.1", "next-logger": "^5.0.1",
"next-runtime-env": "^1.7.2", "next-runtime-env": "^1.7.2",
@@ -55,7 +53,6 @@
"@kan/stripe": "workspace:*", "@kan/stripe": "workspace:*",
"@kan/tailwind-config": "workspace:*", "@kan/tailwind-config": "workspace:*",
"@kan/tsconfig": "workspace:*", "@kan/tsconfig": "workspace:*",
"@types/js-cookie": "^3.0.6",
"@types/node": "^20.17.7", "@types/node": "^20.17.7",
"@types/react": "catalog:react18", "@types/react": "catalog:react18",
"@types/react-beautiful-dnd": "^13.1.7", "@types/react-beautiful-dnd": "^13.1.7",

View File

@@ -1,8 +1,12 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react"; import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { FaGoogle } from "react-icons/fa"; import { FaDiscord, FaGithub, FaGoogle, FaApple, FaMicrosoft, FaFacebook, FaSpotify, FaTwitch, FaTwitter, FaDropbox, FaLinkedin, FaGitlab, FaTiktok, FaReddit, FaVk } from "react-icons/fa";
import { SiRoblox, SiZoom } from "react-icons/si";
import { TbBrandKick } from "react-icons/tb";
import { z } from "zod"; import { z } from "zod";
import type { SocialProvider } from "better-auth/social-providers";
import { authClient } from "@kan/auth/client"; import { authClient } from "@kan/auth/client";
@@ -19,9 +23,103 @@ interface AuthProps {
const EmailSchema = z.object({ email: z.string().email() }); const EmailSchema = z.object({ email: z.string().email() });
const availableSocialProviders = {
google: {
id: "google",
name: "Google",
icon: FaGoogle,
},
github: {
id: "github",
name: "GitHub",
icon: FaGithub,
},
discord: {
id: "discord",
name: "Discord",
icon: FaDiscord,
},
apple: {
id: "apple",
name: "Apple",
icon: FaApple,
},
microsoft: {
id: "microsoft",
name: "Microsoft",
icon: FaMicrosoft,
},
facebook: {
id: "facebook",
name: "Facebook",
icon: FaFacebook,
},
spotify: {
id: "spotify",
name: "Spotify",
icon: FaSpotify,
},
twitch: {
id: "twitch",
name: "Twitch",
icon: FaTwitch,
},
twitter: {
id: "twitter",
name: "Twitter",
icon: FaTwitter,
},
dropbox: {
id: "dropbox",
name: "Dropbox",
icon: FaDropbox,
},
linkedin: {
id: "linkedin",
name: "LinkedIn",
icon: FaLinkedin,
},
gitlab: {
id: "gitlab",
name: "GitLab",
icon: FaGitlab,
},
tiktok: {
id: "tiktok",
name: "TikTok",
icon: FaTiktok,
},
reddit: {
id: "reddit",
name: "Reddit",
icon: FaReddit,
},
roblox: {
id: "roblox",
name: "Roblox",
icon: SiRoblox,
},
vk: {
id: "vk",
name: "VK",
icon: FaVk,
},
kick: {
id: "kick",
name: "Kick",
icon: TbBrandKick,
},
zoom: {
id: "zoom",
name: "Zoom",
icon: SiZoom,
},
}
export function Auth({ setIsMagicLinkSent }: AuthProps) { export function Auth({ setIsMagicLinkSent }: AuthProps) {
const [isLoginWithGooglePending, setIsLoginWithGooglePending] = const [isLoginWithProviderPending, setIsLoginWithProviderPending] = useState<
useState(false); null | SocialProvider
>(null);
const [isLoginWithEmailPending, setIsLoginWithEmailPending] = useState(false); const [isLoginWithEmailPending, setIsLoginWithEmailPending] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null); const [loginError, setLoginError] = useState<string | null>(null);
@@ -33,6 +131,11 @@ export function Auth({ setIsMagicLinkSent }: AuthProps) {
resolver: zodResolver(EmailSchema), resolver: zodResolver(EmailSchema),
}); });
const { data: socialProviders } = useQuery({
queryKey: ["social_providers"],
queryFn: () => authClient.getSocialProviders(),
});
const handleLoginWithEmail = async (email: string) => { const handleLoginWithEmail = async (email: string) => {
setIsLoginWithEmailPending(true); setIsLoginWithEmailPending(true);
setLoginError(null); setLoginError(null);
@@ -52,18 +155,21 @@ export function Auth({ setIsMagicLinkSent }: AuthProps) {
} }
}; };
const handleLoginWithGoogle = async () => { const handleLoginWithProvider = async (
setIsLoginWithGooglePending(true); provider: SocialProvider) => {
setIsLoginWithProviderPending(provider);
setLoginError(null); setLoginError(null);
const { error } = await authClient.signIn.social({ const { error } = await authClient.signIn.social({
provider: "google", provider,
callbackURL: "/boards", callbackURL: "/boards",
}); });
setIsLoginWithGooglePending(false); setIsLoginWithProviderPending(null);
if (error) { if (error) {
setLoginError("Failed to login with Google. Please try again."); setLoginError(
`Failed to login with ${provider.at(0)?.toUpperCase() + provider.slice(1)}. Please try again.`,
);
} }
}; };
@@ -73,23 +179,35 @@ export function Auth({ setIsMagicLinkSent }: AuthProps) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> {socialProviders?.length !== 0 && (
<Button <div className="space-y-2">
onClick={handleLoginWithGoogle} {Object.entries(availableSocialProviders).map(([key, provider]) => {
isLoading={isLoginWithGooglePending} if (!socialProviders?.includes(key)) {
iconLeft={<FaGoogle />} return null;
fullWidth }
size="lg" return (
> <Button
Continue with Google onClick={() => handleLoginWithProvider(key as SocialProvider)}
</Button> isLoading={isLoginWithProviderPending === key}
</div> iconLeft={<provider.icon />}
<form onSubmit={handleSubmit(onSubmit)}> fullWidth
<div className="mb-[1.5rem] flex w-full items-center gap-4"> size="lg"
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" /> >
<span className="text-sm text-light-900 dark:text-dark-900">or</span> Continue with {provider.name}
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" /> </Button>
)})}
</div> </div>
)}
<form onSubmit={handleSubmit(onSubmit)}>
{socialProviders?.length !== 0 && (
<div className="mb-[1.5rem] flex w-full items-center gap-4">
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
<span className="text-sm text-light-900 dark:text-dark-900">
or
</span>
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
</div>
)}
<Input <Input
{...register("email", { required: true })} {...register("email", { required: true })}
placeholder="Enter your email address" placeholder="Enter your email address"

View File

@@ -21,13 +21,46 @@ export const env = createEnv({
STRIPE_SECRET_KEY: z.string().optional(), STRIPE_SECRET_KEY: z.string().optional(),
GOOGLE_CLIENT_ID: z.string().optional(), GOOGLE_CLIENT_ID: z.string().optional(),
GOOGLE_CLIENT_SECRET: z.string().optional(), GOOGLE_CLIENT_SECRET: z.string().optional(),
DISCORD_CLIENT_ID: z.string().optional(),
DISCORD_CLIENT_SECRET: z.string().optional(),
GITHUB_CLIENT_ID: z.string().optional(),
GITHUB_CLIENT_SECRET: z.string().optional(),
GITLAB_CLIENT_ID: z.string().optional(),
GITLAB_CLIENT_SECRET: z.string().optional(),
GITLAB_ISSUER: z.string().optional(),
MICROSOFT_CLIENT_ID: z.string().optional(),
MICROSOFT_CLIENT_SECRET: z.string().optional(),
TWITTER_CLIENT_ID: z.string().optional(),
TWITTER_CLIENT_SECRET: z.string().optional(),
KICK_CLIENT_ID: z.string().optional(),
KICK_CLIENT_SECRET: z.string().optional(),
ZOOM_CLIENT_ID: z.string().optional(),
ZOOM_CLIENT_SECRET: z.string().optional(),
DROPBOX_CLIENT_ID: z.string().optional(),
DROPBOX_CLIENT_SECRET: z.string().optional(),
VK_CLIENT_ID: z.string().optional(),
VK_CLIENT_SECRET: z.string().optional(),
LINKEDIN_CLIENT_ID: z.string().optional(),
LINKEDIN_CLIENT_SECRET: z.string().optional(),
REDDIT_CLIENT_ID: z.string().optional(),
REDDIT_CLIENT_SECRET: z.string().optional(),
ROBLOX_CLIENT_ID: z.string().optional(),
ROBLOX_CLIENT_SECRET: z.string().optional(),
SPOTIFY_CLIENT_ID: z.string().optional(),
SPOTIFY_CLIENT_SECRET: z.string().optional(),
TIKTOK_CLIENT_ID: z.string().optional(),
TIKTOK_CLIENT_SECRET: z.string().optional(),
TIKTOK_CLIENT_KEY: z.string().optional(),
TWITCH_CLIENT_ID: z.string().optional(),
TWITCH_CLIENT_SECRET: z.string().optional(),
APPLE_CLIENT_ID: z.string().optional(),
APPLE_CLIENT_SECRET: z.string().optional(),
APPLE_APP_BUNDLE_IDENTIFIER: z.string().optional(),
S3_ACCESS_KEY_ID: z.string().optional(), S3_ACCESS_KEY_ID: z.string().optional(),
S3_SECRET_ACCESS_KEY: z.string().optional(), S3_SECRET_ACCESS_KEY: z.string().optional(),
S3_REGION: z.string().optional(), S3_REGION: z.string().optional(),
S3_ENDPOINT: z.string().optional(), S3_ENDPOINT: z.string().optional(),
EMAIL_FROM: z.string(), EMAIL_FROM: z.string(),
EMAIL_URL: z.string(),
EMAIL_TOKEN: z.string(),
}, },
/** /**

View File

@@ -1,9 +1,11 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { env } from "next-runtime-env"; import { env as nextRuntimeEnv } from "next-runtime-env";
import { createNextApiContext } from "@kan/api/trpc"; import { createNextApiContext } from "@kan/api/trpc";
import { env } from "~/env";
const allowedContentTypes = ["image/jpeg", "image/png"]; const allowedContentTypes = ["image/jpeg", "image/png"];
@@ -54,7 +56,7 @@ export default async function handler(
// @ts-ignore // @ts-ignore
client, client,
new PutObjectCommand({ new PutObjectCommand({
Bucket: env("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "", Bucket: nextRuntimeEnv("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "",
Key: filename, Key: filename,
}), }),
); );

View File

@@ -1,15 +1,14 @@
import Link from "next/link"; import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
// import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Auth } from "~/components/AuthForm"; import { Auth } from "~/components/AuthForm";
import { PageHead } from "~/components/PageHead"; import { PageHead } from "~/components/PageHead";
import PatternedBackground from "~/components/PatternedBackground"; import PatternedBackground from "~/components/PatternedBackground";
import { authClient } from "@kan/auth/client";
// import { api } from "~/utils/api";
export default function LoginPage() { export default function LoginPage() {
// const router = useRouter(); const router = useRouter();
const [isMagicLinkSent, setIsMagicLinkSent] = useState<boolean>(false); const [isMagicLinkSent, setIsMagicLinkSent] = useState<boolean>(false);
const [magicLinkRecipient, setMagicLinkRecipient] = useState<string>(""); const [magicLinkRecipient, setMagicLinkRecipient] = useState<string>("");
@@ -18,15 +17,9 @@ export default function LoginPage() {
setMagicLinkRecipient(recipient); setMagicLinkRecipient(recipient);
}; };
// const authCookieExists = document.cookie const { data } = authClient.useSession();
// .split("; ")
// .some((cookie) => cookie.includes("auth-token"));
// const { data } = api.user.getUser.useQuery(undefined, { if (data?.user.id) router.push("/boards");
// enabled: authCookieExists ? true : false,
// });
// if (data?.id) router.push("/boards");
return ( return (
<> <>

View File

@@ -1,17 +1,16 @@
import { type ReactNode } from "react"; import type { ReactNode } from "react";
import {
HiOutlinePlusSmall,
HiEllipsisHorizontal,
HiOutlineTrash,
HiOutlineSquaresPlus,
} from "react-icons/hi2";
import { Draggable } from "react-beautiful-dnd"; import { Draggable } from "react-beautiful-dnd";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import {
import { api } from "~/utils/api"; HiEllipsisHorizontal,
import { useModal } from "~/providers/modal"; HiOutlinePlusSmall,
HiOutlineSquaresPlus,
HiOutlineTrash,
} from "react-icons/hi2";
import Dropdown from "~/components/Dropdown"; import Dropdown from "~/components/Dropdown";
import { useModal } from "~/providers/modal";
import { api } from "~/utils/api";
interface ListProps { interface ListProps {
children: ReactNode; children: ReactNode;
@@ -80,20 +79,20 @@ export default function List({
{...provided.dragHandleProps} {...provided.dragHandleProps}
className="dark-text-dark-1000 mr-5 h-fit min-w-[18rem] max-w-[18rem] rounded-md border border-light-400 bg-light-300 py-2 pl-2 pr-1 text-neutral-900 dark:border-dark-300 dark:bg-dark-100" className="dark-text-dark-1000 mr-5 h-fit min-w-[18rem] max-w-[18rem] rounded-md border border-light-400 bg-light-300 py-2 pl-2 pr-1 text-neutral-900 dark:border-dark-300 dark:bg-dark-100"
> >
<div className="flex justify-between"> <div className="mb-2 flex justify-between">
<form <form
onSubmit={handleSubmit(onSubmit)} onSubmit={handleSubmit(onSubmit)}
className="focus-visible:outline-none" className="w-full focus-visible:outline-none"
> >
<input <input
id="name" id="name"
type="text" type="text"
{...register("name")} {...register("name")}
onBlur={handleSubmit(onSubmit)} onBlur={handleSubmit(onSubmit)}
className="mb-4 block border-0 bg-transparent px-4 pt-1 text-sm font-medium text-neutral-900 focus:ring-0 focus-visible:outline-none dark:text-dark-1000" className="w-full border-0 bg-transparent px-4 pt-1 text-sm font-medium text-neutral-900 focus:ring-0 focus-visible:outline-none dark:text-dark-1000"
/> />
</form> </form>
<div> <div className="flex items-center">
<button <button
className="mx-1 inline-flex h-fit items-center rounded-md p-1 px-1 text-sm font-semibold text-dark-50 hover:bg-light-400 dark:hover:bg-dark-200" className="mx-1 inline-flex h-fit items-center rounded-md p-1 px-1 text-sm font-semibold text-dark-50 hover:bg-light-400 dark:hover:bg-dark-200"
onClick={() => openNewCardForm(list.publicId)} onClick={() => openNewCardForm(list.publicId)}

View File

@@ -63,6 +63,7 @@ export function NewCardForm({
const memberPublicIds = watch("memberPublicIds") || []; const memberPublicIds = watch("memberPublicIds") || [];
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled"); const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
const position = watch("position"); const position = watch("position");
const title = watch("title");
const { data: boardData } = api.board.byId.useQuery(queryParams, { const { data: boardData } = api.board.byId.useQuery(queryParams, {
enabled: !!boardPublicId, enabled: !!boardPublicId,
@@ -110,16 +111,29 @@ export function NewCardForm({
return { previousState: currentState }; return { previousState: currentState };
}, },
onError: (_error, _newList, context) => { onError: (error, _newList, context) => {
utils.board.byId.setData(queryParams, context?.previousState); utils.board.byId.setData(queryParams, context?.previousState);
showPopup({ showPopup({
header: "Unable to create card", header: "Unable to create card",
message: "Please try again later, or contact customer support.", message: error.data?.zodError?.fieldErrors.title?.[0] ?
`${error.data?.zodError?.fieldErrors.title?.[0].replace("String", "Title")}` :
"Please try again later, or contact customer support.",
icon: "error", icon: "error",
}); });
}, },
onSettled: async () => { onSuccess: async () => {
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
if (!isCreateAnotherEnabled) closeModal();
await utils.board.byId.invalidate(queryParams); await utils.board.byId.invalidate(queryParams);
reset({
title: "",
description: "",
listPublicId: watch("listPublicId"),
labelPublicIds: [],
memberPublicIds: [],
isCreateAnotherEnabled,
position,
});
}, },
}); });
@@ -164,18 +178,6 @@ export function NewCardForm({
})) ?? []; })) ?? [];
const onSubmit = (data: NewCardInput) => { const onSubmit = (data: NewCardInput) => {
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
if (!isCreateAnotherEnabled) closeModal();
reset({
title: "",
description: "",
listPublicId: watch("listPublicId"),
labelPublicIds: [],
memberPublicIds: [],
isCreateAnotherEnabled,
position,
});
createCard.mutate({ createCard.mutate({
title: data.title, title: data.title,
description: data.description, description: data.description,
@@ -387,7 +389,7 @@ export function NewCardForm({
/> />
<div> <div>
<Button type="submit">Create card</Button> <Button type="submit" disabled={title.length === 0 || createCard.isPending}>Create card</Button>
</div> </div>
</div> </div>
</form> </form>

View File

@@ -1,11 +1,13 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { env } from "next-runtime-env";
import { useEffect } from "react"; import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { HiXMark } from "react-icons/hi2"; import { HiCheck, HiXMark } from "react-icons/hi2";
import { z } from "zod"; import { z } from "zod";
import Button from "~/components/Button"; import Button from "~/components/Button";
import Input from "~/components/Input"; import Input from "~/components/Input";
import { useDebounce } from "~/hooks/useDebounce";
import { useModal } from "~/providers/modal"; import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup"; import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
@@ -49,6 +51,7 @@ export function UpdateBoardSlugForm({
register, register,
handleSubmit, handleSubmit,
formState: { isDirty, errors }, formState: { isDirty, errors },
watch,
} = useForm<FormValues>({ } = useForm<FormValues>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
values: { values: {
@@ -57,6 +60,10 @@ export function UpdateBoardSlugForm({
mode: "onChange", mode: "onChange",
}); });
const slug = watch("slug");
const [debouncedSlug] = useDebounce(slug, 500);
const updateBoardSlug = api.board.update.useMutation({ const updateBoardSlug = api.board.update.useMutation({
onError: () => { onError: () => {
showPopup({ showPopup({
@@ -71,6 +78,20 @@ export function UpdateBoardSlugForm({
}, },
}); });
const checkBoardSlugAvailability =
api.board.checkSlugAvailability.useQuery(
{
boardSlug: debouncedSlug,
boardPublicId,
},
{
enabled:
!!debouncedSlug && debouncedSlug !== boardSlug && !errors.slug,
},
);
const isBoardSlugAvailable = checkBoardSlugAvailability.data;
useEffect(() => { useEffect(() => {
const nameElement: HTMLElement | null = const nameElement: HTMLElement | null =
document.querySelector<HTMLElement>("#board-slug"); document.querySelector<HTMLElement>("#board-slug");
@@ -78,6 +99,9 @@ export function UpdateBoardSlugForm({
}, []); }, []);
const onSubmit = (data: FormValues) => { const onSubmit = (data: FormValues) => {
if (!isBoardSlugAvailable) return;
if (isBoardSlugAvailable?.isReserved) return;
updateBoardSlug.mutate({ updateBoardSlug.mutate({
slug: data.slug, slug: data.slug,
boardPublicId, boardPublicId,
@@ -106,14 +130,23 @@ export function UpdateBoardSlugForm({
<Input <Input
id="board-slug" id="board-slug"
{...register("slug")} {...register("slug")}
errorMessage={errors.slug?.message} errorMessage={errors.slug?.message || (isBoardSlugAvailable?.isReserved
prefix={`kan.bn/${workspaceSlug}/`} ? "This board URL has already been taken"
: undefined)}
prefix={`${env("NEXT_PUBLIC_BASE_URL")}/${workspaceSlug}/`}
onKeyDown={async (e) => { onKeyDown={async (e) => {
if (e.key === "Enter") { if (e.key === "Enter") {
e.preventDefault(); e.preventDefault();
await handleSubmit(onSubmit)(); await handleSubmit(onSubmit)();
} }
}} }}
iconRight={
!!errors.slug?.message || isBoardSlugAvailable?.isReserved ? (
<HiXMark className="h-4 w-4 text-red-500" />
) : (
<HiCheck className="h-4 w-4 dark:text-dark-1000" />
)
}
/> />
</div> </div>
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600"> <div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
@@ -124,7 +157,8 @@ export function UpdateBoardSlugForm({
disabled={ disabled={
!isDirty || !isDirty ||
updateBoardSlug.isPending || updateBoardSlug.isPending ||
errors.slug?.message !== undefined errors.slug?.message !== undefined ||
isBoardSlugAvailable?.isReserved
} }
> >
Update Update

View File

@@ -240,11 +240,12 @@ export default function BoardPage() {
<div className="relative flex h-full flex-col"> <div className="relative flex h-full flex-col">
<PatternedBackground /> <PatternedBackground />
<div className="z-10 flex w-full justify-between p-8"> <div className="z-10 flex w-full justify-between p-8">
{isLoading ? ( {isLoading && !boardData && (
<div className="flex space-x-2"> <div className="flex space-x-2">
<div className="h-[2.3rem] w-[150px] animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-100" /> <div className="h-[2.3rem] w-[150px] animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-100" />
</div> </div>
) : ( )}
{boardData && (
<form <form
onSubmit={handleSubmit(onSubmit)} onSubmit={handleSubmit(onSubmit)}
className="focus-visible:outline-none" className="focus-visible:outline-none"
@@ -258,20 +259,23 @@ export default function BoardPage() {
/> />
</form> </form>
)} )}
{!boardData && !isLoading && (
<p className="block p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">Board not found</p>
)}
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<VisibilityButton <VisibilityButton
visibility={boardData?.visibility ?? "private"} visibility={boardData?.visibility ?? "private"}
boardPublicId={boardId ?? ""} boardPublicId={boardId ?? ""}
queryParams={queryParams} queryParams={queryParams}
isLoading={isLoading} isLoading={!boardData}
isAdmin={workspace.role === "admin"} isAdmin={workspace.role === "admin"}
/> />
<Filters <Filters
labels={boardData?.labels ?? []} labels={boardData?.labels ?? []}
members={boardData?.workspace.members ?? []} members={boardData?.workspace.members ?? []}
position="left" position="left"
isLoading={isLoading} isLoading={!boardData}
/> />
<Button <Button
iconLeft={ iconLeft={
@@ -283,11 +287,11 @@ export default function BoardPage() {
onClick={() => { onClick={() => {
if (boardId) openNewListForm(boardId); if (boardId) openNewListForm(boardId);
}} }}
disabled={isLoading} disabled={!boardData}
> >
New list New list
</Button> </Button>
<BoardDropdown isLoading={isLoading} /> <BoardDropdown isLoading={!boardData} />
</div> </div>
</div> </div>
@@ -298,7 +302,7 @@ export default function BoardPage() {
<div className="0 mr-5 h-[275px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" /> <div className="0 mr-5 h-[275px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
<div className="0 mr-5 h-[375px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" /> <div className="0 mr-5 h-[375px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
</div> </div>
) : ( ) : boardData ? (
<> <>
{boardData?.lists.length === 0 ? ( {boardData?.lists.length === 0 ? (
<div className="z-10 flex h-full w-full flex-col items-center justify-center space-y-8 pb-[150px]"> <div className="z-10 flex h-full w-full flex-col items-center justify-center space-y-8 pb-[150px]">
@@ -370,13 +374,12 @@ export default function BoardPage() {
}} }}
key={card.publicId} key={card.publicId}
href={`/cards/${card.publicId}`} href={`/cards/${card.publicId}`}
className={`mb-2 flex !cursor-pointer flex-col ${ className={`mb-2 flex !cursor-pointer flex-col ${card.publicId.startsWith(
card.publicId.startsWith( "PLACEHOLDER",
"PLACEHOLDER", )
) ? "pointer-events-none"
? "pointer-events-none" : ""
: "" }`}
}`}
ref={provided.innerRef} ref={provided.innerRef}
{...provided.draggableProps} {...provided.draggableProps}
{...provided.dragHandleProps} {...provided.dragHandleProps}
@@ -404,7 +407,7 @@ export default function BoardPage() {
</DragDropContext> </DragDropContext>
)} )}
</> </>
)} ) : null}
</div> </div>
<Modal modalSize={modalContentType === "NEW_CARD" ? "md" : "sm"}> <Modal modalSize={modalContentType === "NEW_CARD" ? "md" : "sm"}>
{modalContentType === "DELETE_BOARD" && ( {modalContentType === "DELETE_BOARD" && (

View File

@@ -8,11 +8,11 @@ import {
HiOutlineUserMinus, HiOutlineUserMinus,
HiOutlineUserPlus, HiOutlineUserPlus,
} from "react-icons/hi2"; } from "react-icons/hi2";
import type { GetCardByIdOutput } from "@kan/api/types"; import type { GetCardByIdOutput } from "@kan/api/types";
import Avatar from "~/components/Avatar"; import Avatar from "~/components/Avatar";
import Comment from "./Comment"; import Comment from "./Comment";
import { authClient } from "@kan/auth/client";
type ActivityType = type ActivityType =
NonNullable<GetCardByIdOutput>["activities"][number]["type"]; NonNullable<GetCardByIdOutput>["activities"][number]["type"];
@@ -147,6 +147,8 @@ const ActivityList = ({
isLoading: boolean; isLoading: boolean;
isAdmin?: boolean; isAdmin?: boolean;
}) => { }) => {
const { data } = authClient.useSession();
return ( return (
<div className="flex flex-col space-y-4 pt-4"> <div className="flex flex-col space-y-4 pt-4">
{activities.map((activity, index) => { {activities.map((activity, index) => {
@@ -156,7 +158,7 @@ const ActivityList = ({
fromList: activity.fromList?.name ?? null, fromList: activity.fromList?.name ?? null,
toList: activity.toList?.name ?? null, toList: activity.toList?.name ?? null,
memberName: activity.member?.user?.name ?? null, memberName: activity.member?.user?.name ?? null,
isSelf: activity.member?.user?.id === activity.user?.id, isSelf: activity.member?.user?.id === data?.user?.id,
label: activity.label?.name ?? null, label: activity.label?.name ?? null,
}); });
@@ -172,7 +174,7 @@ const ActivityList = ({
createdAt={activity.createdAt} createdAt={activity.createdAt}
comment={activity.comment?.comment} comment={activity.comment?.comment}
isEdited={!!activity.comment?.updatedAt} isEdited={!!activity.comment?.updatedAt}
isAuthor={activity.comment?.createdBy === activity.user?.id} isAuthor={activity.comment?.createdBy === data?.user?.id}
isAdmin={isAdmin ?? false} isAdmin={isAdmin ?? false}
/> />
); );

View File

@@ -148,12 +148,13 @@ export default function CardPage() {
<div className="flex h-full w-full flex-col overflow-hidden"> <div className="flex h-full w-full flex-col overflow-hidden">
<div className="h-full max-h-[calc(100vh-4rem)] overflow-y-auto p-8"> <div className="h-full max-h-[calc(100vh-4rem)] overflow-y-auto p-8">
<div className="mb-8 flex w-full items-center justify-between"> <div className="mb-8 flex w-full items-center justify-between">
{isLoading ? ( {!card && isLoading && (
<div className="flex space-x-2"> <div className="flex space-x-2">
<div className="h-[2.3rem] w-[150px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" /> <div className="h-[2.3rem] w-[150px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
<div className="h-[2.3rem] w-[300px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" /> <div className="h-[2.3rem] w-[300px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
</div> </div>
) : ( )}
{card && (
<> <>
<Link <Link
className="whitespace-nowrap font-bold leading-[2.3rem] tracking-tight text-light-900 dark:text-dark-900 sm:text-[1.2rem]" className="whitespace-nowrap font-bold leading-[2.3rem] tracking-tight text-light-900 dark:text-dark-900 sm:text-[1.2rem]"
@@ -184,40 +185,49 @@ export default function CardPage() {
</div> </div>
</> </>
)} )}
{!card && !isLoading && (
<p className="block p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
Card not found
</p>
)}
</div> </div>
<div className="mb-10 flex w-full max-w-2xl justify-between"> {card && (
<form <>
onSubmit={handleSubmit(onSubmit)} <div className="mb-10 flex w-full max-w-2xl justify-between">
className="w-full space-y-6" <form
> onSubmit={handleSubmit(onSubmit)}
<div className="mt-2"> className="w-full space-y-6"
<ContentEditable >
placeholder="Add description..." <div className="mt-2">
html={watch("description")} <ContentEditable
disabled={false} placeholder="Add description..."
onChange={(e) => setValue("description", e.target.value)} html={watch("description")}
onBlur={handleSubmit(onSubmit)} disabled={false}
className="block w-full border-0 bg-transparent py-1.5 text-light-900 focus-visible:outline-none dark:text-dark-1000 sm:text-sm sm:leading-6" onChange={(e) => setValue("description", e.target.value)}
/> onBlur={handleSubmit(onSubmit)}
className="block w-full border-0 bg-transparent py-1.5 text-light-900 focus-visible:outline-none dark:text-dark-1000 sm:text-sm sm:leading-6"
/>
</div>
</form>
</div> </div>
</form> <div className="border-t-[1px] border-light-600 pt-12 dark:border-dark-400">
</div> <h2 className="text-md pb-4 font-medium text-light-900 dark:text-dark-1000">
<div className="border-t-[1px] border-light-600 pt-12 dark:border-dark-400"> Activity
<h2 className="text-md pb-4 font-medium text-light-900 dark:text-dark-1000"> </h2>
Activity <div>
</h2> <ActivityList
<div> cardPublicId={cardId}
<ActivityList activities={activities ?? []}
cardPublicId={cardId} isLoading={!card}
activities={activities ?? []} isAdmin={workspace.role === "admin"}
isLoading={isLoading} />
isAdmin={workspace.role === "admin"} </div>
/> <div className="mt-6">
</div> <NewCommentForm cardPublicId={cardId} />
<div className="mt-6"> </div>
<NewCommentForm cardPublicId={cardId} /> </div>
</div> </>
</div> )}
</div> </div>
</div> </div>
<div className="w-[475px] border-l-[1px] border-light-600 bg-light-200 p-8 text-light-900 dark:border-dark-400 dark:bg-dark-100 dark:text-dark-900"> <div className="w-[475px] border-l-[1px] border-light-600 bg-light-200 p-8 text-light-900 dark:border-dark-400 dark:bg-dark-100 dark:text-dark-900">
@@ -226,7 +236,7 @@ export default function CardPage() {
<ListSelector <ListSelector
cardPublicId={cardId} cardPublicId={cardId}
lists={formattedLists} lists={formattedLists}
isLoading={isLoading} isLoading={!card}
/> />
</div> </div>
<div className="mb-4 flex w-full"> <div className="mb-4 flex w-full">
@@ -234,7 +244,7 @@ export default function CardPage() {
<LabelSelector <LabelSelector
cardPublicId={cardId} cardPublicId={cardId}
labels={formattedLabels} labels={formattedLabels}
isLoading={isLoading} isLoading={!card}
/> />
</div> </div>
<div className="flex w-full"> <div className="flex w-full">
@@ -242,7 +252,7 @@ export default function CardPage() {
<MemberSelector <MemberSelector
cardPublicId={cardId} cardPublicId={cardId}
members={formattedMembers} members={formattedMembers}
isLoading={isLoading} isLoading={!card}
/> />
</div> </div>
</div> </div>

View File

@@ -1,22 +1,15 @@
import Cookies from "js-cookie";
import PatternedBackground from "~/components/PatternedBackground"; import PatternedBackground from "~/components/PatternedBackground";
import { useTheme } from "~/providers/theme"; import { useTheme } from "~/providers/theme";
import { api } from "~/utils/api";
import Footer from "./Footer"; import Footer from "./Footer";
import Header from "./Header"; import Header from "./Header";
import { authClient } from "@kan/auth/client";
export default function Layout({ children }: { children: React.ReactNode }) { export default function Layout({ children }: { children: React.ReactNode }) {
const theme = useTheme(); const theme = useTheme();
const token = const { data: session } = authClient.useSession();
typeof window !== "undefined" ? Cookies.get("kan.session_token") : null;
const { data } = api.user.getUser.useQuery(undefined, { const isLoggedIn = !!session?.user;
enabled: !!token,
});
const isLoggedIn = !!data;
const isDarkMode = theme.activeTheme === "dark"; const isDarkMode = theme.activeTheme === "dark";

View File

@@ -1,6 +1,6 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { IoLogoGithub } from "react-icons/io"; import { IoLogoGithub, IoLogoHackernews } from "react-icons/io";
import Button from "~/components/Button"; import Button from "~/components/Button";
import { PageHead } from "~/components/PageHead"; import { PageHead } from "~/components/PageHead";
@@ -21,18 +21,41 @@ export default function HomeView() {
<div className="flex h-full w-full flex-col lg:pt-[5rem]"> <div className="flex h-full w-full flex-col lg:pt-[5rem]">
<div className="w-full pb-10 pt-32 lg:py-32"> <div className="w-full pb-10 pt-32 lg:py-32">
<div className="my-10 flex h-full w-full animate-fade-down flex-col items-center justify-center px-4"> <div className="my-10 flex h-full w-full animate-fade-down flex-col items-center justify-center px-4">
<div className="relative animate-fade-in overflow-hidden rounded-full bg-gradient-to-b from-light-300 to-light-400 p-[2px] dark:from-dark-300 dark:to-dark-400"> <div className="flex items-center gap-2">
<div className="gradient-border absolute inset-0 animate-border-spin" /> <div className="relative animate-fade-in overflow-hidden rounded-full bg-gradient-to-b from-light-300 to-light-400 p-[2px] dark:from-dark-300 dark:to-dark-400">
<div className="relative z-10 rounded-full bg-light-50 dark:bg-dark-50"> <div className="gradient-border absolute inset-0 animate-border-spin" />
<Link
href="https://github.com/kanbn/kan" <div className="relative z-10 rounded-full bg-light-50 dark:bg-dark-50">
rel="noopener noreferrer" <Link
target="_blank" href="https://github.com/kanbn/kan"
className="flex items-center gap-2 px-4 py-1 text-center text-xs text-light-1000 dark:text-dark-1000 lg:text-sm" rel="noopener noreferrer"
> target="_blank"
Star on Github className="flex items-center gap-2 px-4 py-1 text-center text-xs text-light-1000 dark:text-dark-1000 lg:text-sm"
<IoLogoGithub size={20} /> >
</Link> Star on Github
<IoLogoGithub size={20} />
</Link>
</div>
</div>
<div className="relative overflow-hidden rounded-full bg-gradient-to-b from-light-300 to-light-400 p-[2px] dark:from-dark-300 dark:to-dark-400">
<div className="relative z-10 rounded-full bg-light-50 dark:bg-dark-50">
<Link
href="https://news.ycombinator.com/item?id=44157177"
rel="noopener noreferrer"
target="_blank"
className="flex items-center gap-2 px-4 py-1 text-center text-xs text-light-1000 dark:text-dark-1000 lg:text-sm"
>
#1 Hacker News
<div className="relative">
<div className="absolute inset-1 bg-white" />
<IoLogoHackernews
size={20}
className="relative text-orange-500"
/>
</div>
</Link>
</div>
</div> </div>
</div> </div>

View File

@@ -15,14 +15,19 @@ export function DeleteWorkspaceConfirmation() {
const [isAcknowledgmentChecked, setIsAcknowledgmentChecked] = useState(false); const [isAcknowledgmentChecked, setIsAcknowledgmentChecked] = useState(false);
const utils = api.useUtils();
const deleteWorkspaceMutation = api.workspace.delete.useMutation({ const deleteWorkspaceMutation = api.workspace.delete.useMutation({
onSuccess: () => { onSuccess: async () => {
closeModal(); closeModal();
showPopup({ showPopup({
header: "Workspace deleted", header: "Workspace deleted",
message: "Your workspace has been deleted.", message: "Your workspace has been deleted.",
icon: "success", icon: "success",
}); });
await utils.workspace.all.refetch();
const filteredWorkspaces = availableWorkspaces.filter( const filteredWorkspaces = availableWorkspaces.filter(
(ws) => ws.publicId !== workspace.publicId, (ws) => ws.publicId !== workspace.publicId,
); );

View File

@@ -15,8 +15,10 @@ services:
command: ["pnpm", "start"] command: ["pnpm", "start"]
environment: environment:
- EMAIL_FROM=${EMAIL_FROM} - EMAIL_FROM=${EMAIL_FROM}
- EMAIL_URL=${EMAIL_URL} - SMTP_HOST=${SMTP_HOST}
- EMAIL_TOKEN=${EMAIL_TOKEN} - SMTP_PORT=${SMTP_PORT}
- SMTP_USER=${SMTP_USER}
- SMTP_PASSWORD=${SMTP_PASSWORD}
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET} - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
- BETTER_AUTH_URL=${BETTER_AUTH_URL} - BETTER_AUTH_URL=${BETTER_AUTH_URL}
- BETTER_AUTH_TRUSTED_ORIGINS=${BETTER_AUTH_TRUSTED_ORIGINS} - BETTER_AUTH_TRUSTED_ORIGINS=${BETTER_AUTH_TRUSTED_ORIGINS}
@@ -25,6 +27,41 @@ services:
- POSTGRES_URL=${POSTGRES_URL} - POSTGRES_URL=${POSTGRES_URL}
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID} - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET} - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
- DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID}
- DISCORD_CLIENT_SECRET=${DISCORD_CLIENT_SECRET}
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID}
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET}
- GITLAB_CLIENT_ID=${GITLAB_CLIENT_ID}
- GITLAB_CLIENT_SECRET=${GITLAB_CLIENT_SECRET}
- GITLAB_ISSUER=${GITLAB_ISSUER}
- MICROSOFT_CLIENT_ID=${MICROSOFT_CLIENT_ID}
- MICROSOFT_CLIENT_SECRET=${MICROSOFT_CLIENT_SECRET}
- TWITTER_CLIENT_ID=${TWITTER_CLIENT_ID}
- TWITTER_CLIENT_SECRET=${TWITTER_CLIENT_SECRET}
- KICK_CLIENT_ID=${KICK_CLIENT_ID}
- KICK_CLIENT_SECRET=${KICK_CLIENT_SECRET}
- ZOOM_CLIENT_ID=${ZOOM_CLIENT_ID}
- ZOOM_CLIENT_SECRET=${ZOOM_CLIENT_SECRET}
- DROPBOX_CLIENT_ID=${DROPBOX_CLIENT_ID}
- DROPBOX_CLIENT_SECRET=${DROPBOX_CLIENT_SECRET}
- VK_CLIENT_ID=${VK_CLIENT_ID}
- VK_CLIENT_SECRET=${VK_CLIENT_SECRET}
- LINKEDIN_CLIENT_ID=${LINKEDIN_CLIENT_ID}
- LINKEDIN_CLIENT_SECRET=${LINKEDIN_CLIENT_SECRET}
- REDDIT_CLIENT_ID=${REDDIT_CLIENT_ID}
- REDDIT_CLIENT_SECRET=${REDDIT_CLIENT_SECRET}
- ROBLOX_CLIENT_ID=${ROBLOX_CLIENT_ID}
- ROBLOX_CLIENT_SECRET=${ROBLOX_CLIENT_SECRET}
- SPOTIFY_CLIENT_ID=${SPOTIFY_CLIENT_ID}
- SPOTIFY_CLIENT_SECRET=${SPOTIFY_CLIENT_SECRET}
- TIKTOK_CLIENT_ID=${TIKTOK_CLIENT_ID}
- TIKTOK_CLIENT_SECRET=${TIKTOK_CLIENT_SECRET}
- TIKTOK_CLIENT_KEY=${TIKTOK_CLIENT_KEY}
- TWITCH_CLIENT_ID=${TWITCH_CLIENT_ID}
- TWITCH_CLIENT_SECRET=${TWITCH_CLIENT_SECRET}
- APPLE_CLIENT_ID=${APPLE_CLIENT_ID}
- APPLE_CLIENT_SECRET=${APPLE_CLIENT_SECRET}
- APPLE_APP_BUNDLE_IDENTIFIER=${APPLE_APP_BUNDLE_IDENTIFIER}
- S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID} - S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID}
- S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY} - S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY}
- S3_REGION=${S3_REGION} - S3_REGION=${S3_REGION}

View File

@@ -352,4 +352,40 @@ export const boardRouter = createTRPCRouter({
return { success: true }; return { success: true };
}), }),
checkSlugAvailability: publicProcedure
.meta({
openapi: {
summary: "Check if a board slug is available",
method: "GET",
path: "/boards/{boardPublicId}/check-slug-availability",
description: "Checks if a board slug is available",
tags: ["Boards"],
protect: true,
},
})
.input(
z.object({
boardSlug: z
.string()
.min(3)
.max(24)
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/),
boardPublicId: z.string().min(12),
}),
)
.output(
z.object({
isReserved: z.boolean(),
}),
)
.query(async ({ ctx, input }) => {
const isBoardSlugAvailable = await boardRepo.isBoardSlugAvailable(
ctx.db,
input.boardSlug,
input.boardPublicId,
);
return {
isReserved: !isBoardSlugAvailable,
};
}),
}); });

View File

@@ -1,9 +1,10 @@
import { betterAuth } from "better-auth"; import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { createAuthMiddleware } from "better-auth/api"; import { createAuthEndpoint, createAuthMiddleware } from "better-auth/api";
import { apiKey } from "better-auth/plugins"; import { apiKey } from "better-auth/plugins";
import { magicLink } from "better-auth/plugins/magic-link"; import { magicLink } from "better-auth/plugins/magic-link";
import { env } from "next-runtime-env"; import { env } from "next-runtime-env";
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import type { dbClient } from "@kan/db/client"; import type { dbClient } from "@kan/db/client";
import * as memberRepo from "@kan/db/repository/member.repo"; import * as memberRepo from "@kan/db/repository/member.repo";
@@ -11,6 +12,89 @@ import * as userRepo from "@kan/db/repository/user.repo";
import * as schema from "@kan/db/schema"; import * as schema from "@kan/db/schema";
import { sendEmail } from "@kan/email"; import { sendEmail } from "@kan/email";
import { createStripeClient } from "@kan/stripe"; import { createStripeClient } from "@kan/stripe";
import { socialProviderList } from "better-auth/social-providers";
export const configuredProviders = socialProviderList.reduce<
Record<
string,
{
clientId: string;
clientSecret: string;
appBundleIdentifier?: string;
tenantId?: string;
requireSelectAccount?: boolean;
clientKey?: string;
issuer?: string;
}
>
>((acc, provider) => {
const id = process.env[`${provider.toUpperCase()}_CLIENT_ID`];
const secret = process.env[`${provider.toUpperCase()}_CLIENT_SECRET`];
if (id && id.length > 0 && secret && secret.length > 0) {
acc[provider] = { clientId: id, clientSecret: secret };
}
if (
provider === "apple" &&
Object.keys(acc).includes("apple") &&
acc[provider]
) {
const bundleId =
process.env[`${provider.toUpperCase()}_APP_BUNDLE_IDENTIFIER`];
if (bundleId && bundleId.length > 0) {
acc[provider].appBundleIdentifier = bundleId;
}
}
if (
provider === "gitlab" &&
Object.keys(acc).includes("gitlab") &&
acc[provider]
) {
const issuer = process.env[`${provider.toUpperCase()}_ISSUER`];
if (issuer && issuer.length > 0) {
acc[provider].issuer = issuer;
}
}
if (
provider === "microsoft" &&
Object.keys(acc).includes("microsoft") &&
acc[provider]
) {
acc[provider].tenantId = "common";
acc[provider].requireSelectAccount = true;
}
if (
provider === "tiktok" &&
Object.keys(acc).includes("tiktok") &&
acc[provider]
) {
const key = process.env[`${provider.toUpperCase()}_CLIENT_KEY`];
if (key && key.length > 0) {
acc[provider].clientKey = key;
}
}
return acc;
}, {});
export const socialProvidersPlugin = () => ({
id: "social-providers-plugin",
endpoints: {
getSocialProviders: createAuthEndpoint(
"/social-providers",
{
method: "GET",
},
async (ctx) => ctx.json(ctx.context.socialProviders.map(p => p.name.toLowerCase())),
),
},
});
async function downloadImage(url: string): Promise<Buffer> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to download image: ${response.statusText}`);
}
return Buffer.from(await response.arrayBuffer());
}
export const initAuth = (db: dbClient) => { export const initAuth = (db: dbClient) => {
return betterAuth({ return betterAuth({
@@ -26,12 +110,7 @@ export const initAuth = (db: dbClient) => {
user: schema.users, user: schema.users,
}, },
}), }),
socialProviders: { socialProviders: configuredProviders,
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
},
},
user: { user: {
additionalFields: { additionalFields: {
stripeCustomerId: { stripeCustomerId: {
@@ -43,6 +122,7 @@ export const initAuth = (db: dbClient) => {
}, },
}, },
plugins: [ plugins: [
socialProvidersPlugin(),
// @todo: hasing is disabled due to a bug in the api key plugin // @todo: hasing is disabled due to a bug in the api key plugin
apiKey({ disableKeyHashing: true }), apiKey({ disableKeyHashing: true }),
magicLink({ magicLink({
@@ -65,6 +145,46 @@ export const initAuth = (db: dbClient) => {
}, },
}), }),
], ],
databaseHooks: {
user: {
create: {
async after(user, _context) {
if (user.image && !user.image.includes(process.env.NEXT_PUBLIC_STORAGE_DOMAIN!)) {
try {
const client = new S3Client({
region: env("S3_REGION") ?? "",
endpoint: env("S3_ENDPOINT") ?? "",
credentials: {
accessKeyId: env("S3_ACCESS_KEY_ID") ?? "",
secretAccessKey: env("S3_SECRET_ACCESS_KEY") ?? "",
},
});
const allowedFileExtensions = ["jpg", "jpeg", "png", "webp"];
const fileExtension = user.image.split('.').pop()?.split('?')[0] || 'jpg';
const key = `${user.id}/avatar.${!allowedFileExtensions.includes(fileExtension) ? 'jpg' : fileExtension}`;
const imageBuffer = await downloadImage(user.image);
await client.send(new PutObjectCommand({
Bucket: env("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "",
Key: key,
Body: imageBuffer,
ContentType: `image/${!allowedFileExtensions.includes(fileExtension) ? 'jpeg' : fileExtension}`,
ACL: 'public-read',
}));
await userRepo.update(db, user.id, {
image: key,
});
} catch (error) {
console.error(error);
}
}
}
}
}
},
hooks: { hooks: {
after: createAuthMiddleware(async (ctx) => { after: createAuthMiddleware(async (ctx) => {
if (ctx.path.startsWith("/get-session")) { if (ctx.path.startsWith("/get-session")) {

View File

@@ -1,6 +1,25 @@
import { BetterAuthClientPlugin } from "better-auth";
import { apiKeyClient, magicLinkClient } from "better-auth/client/plugins"; import { apiKeyClient, magicLinkClient } from "better-auth/client/plugins";
import { createAuthClient } from "better-auth/react"; import { BetterFetchOption, createAuthClient } from "better-auth/react";
import { socialProvidersPlugin } from "./auth";
const socialProvidersPluginClient = {
id: "social-providers-plugin",
$InferServerPlugin: {} as ReturnType<typeof socialProvidersPlugin>,
getActions: ($fetch) => {
return {
getSocialProviders: async (fetchOptions?: BetterFetchOption) => {
const res = $fetch("/social-providers", {
method: "GET",
...fetchOptions,
});
return res.then((res) => res.data as string[]);
},
};
},
} satisfies BetterAuthClientPlugin;
export const authClient = createAuthClient({ export const authClient = createAuthClient({
plugins: [magicLinkClient(), apiKeyClient()], plugins: [magicLinkClient(), apiKeyClient(), socialProvidersPluginClient],
}); });

View File

@@ -1,6 +1,8 @@
{ {
"extends": "@kan/tsconfig/internal-package.json", "extends": "@kan/tsconfig/internal-package.json",
"compilerOptions": {}, "compilerOptions": {
"jsx": "react-jsx"
},
"include": ["*.ts", "src"], "include": ["*.ts", "src"],
"exclude": ["node_modules"] "exclude": ["node_modules"]
} }

View File

@@ -0,0 +1,79 @@
ALTER TABLE "card_activity" DROP CONSTRAINT "card_activity_fromListId_list_id_fk";
--> statement-breakpoint
ALTER TABLE "card_activity" DROP CONSTRAINT "card_activity_toListId_list_id_fk";
--> statement-breakpoint
ALTER TABLE "card_activity" DROP CONSTRAINT "card_activity_labelId_label_id_fk";
--> statement-breakpoint
ALTER TABLE "card_activity" DROP CONSTRAINT "card_activity_workspaceMemberId_workspace_members_id_fk";
--> statement-breakpoint
ALTER TABLE "card_activity" DROP CONSTRAINT "card_activity_createdBy_user_id_fk";
--> statement-breakpoint
ALTER TABLE "card_activity" DROP CONSTRAINT "card_activity_commentId_card_comments_id_fk";
--> statement-breakpoint
ALTER TABLE "_card_workspace_members" DROP CONSTRAINT "_card_workspace_members_cardId_card_id_fk";
--> statement-breakpoint
ALTER TABLE "_card_labels" DROP CONSTRAINT "_card_labels_cardId_card_id_fk";
--> statement-breakpoint
ALTER TABLE "card_comments" DROP CONSTRAINT "card_comments_createdBy_user_id_fk";
--> statement-breakpoint
ALTER TABLE "card_comments" DROP CONSTRAINT "card_comments_deletedBy_user_id_fk";
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "card_activity" ADD CONSTRAINT "card_activity_fromListId_list_id_fk" FOREIGN KEY ("fromListId") REFERENCES "public"."list"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "card_activity" ADD CONSTRAINT "card_activity_toListId_list_id_fk" FOREIGN KEY ("toListId") REFERENCES "public"."list"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "card_activity" ADD CONSTRAINT "card_activity_labelId_label_id_fk" FOREIGN KEY ("labelId") REFERENCES "public"."label"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "card_activity" ADD CONSTRAINT "card_activity_workspaceMemberId_workspace_members_id_fk" FOREIGN KEY ("workspaceMemberId") REFERENCES "public"."workspace_members"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "card_activity" ADD CONSTRAINT "card_activity_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "card_activity" ADD CONSTRAINT "card_activity_commentId_card_comments_id_fk" FOREIGN KEY ("commentId") REFERENCES "public"."card_comments"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "_card_workspace_members" ADD CONSTRAINT "_card_workspace_members_cardId_card_id_fk" FOREIGN KEY ("cardId") REFERENCES "public"."card"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "_card_labels" ADD CONSTRAINT "_card_labels_cardId_card_id_fk" FOREIGN KEY ("cardId") REFERENCES "public"."card"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "card_comments" ADD CONSTRAINT "card_comments_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "card_comments" ADD CONSTRAINT "card_comments_deletedBy_user_id_fk" FOREIGN KEY ("deletedBy") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,13 @@
"when": 1748378293342, "when": 1748378293342,
"tag": "20250527203813_AddDeletedAtToLabel", "tag": "20250527203813_AddDeletedAtToLabel",
"breakpoints": true "breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1749118315675,
"tag": "20250605101155_AddCascadeDeleteToCardRelations",
"breakpoints": true
} }
] ]
} }

View File

@@ -1,4 +1,4 @@
import { and, asc, desc, eq, inArray, isNull, or } from "drizzle-orm"; import { and, asc, desc, eq, exists, inArray, isNull, or, sql } from "drizzle-orm";
import type { dbClient } from "@kan/db/client"; import type { dbClient } from "@kan/db/client";
import type { BoardVisibilityStatus } from "@kan/db/schema"; import type { BoardVisibilityStatus } from "@kan/db/schema";
@@ -477,3 +477,34 @@ export const getWorkspaceAndBoardIdByBoardPublicId = async (
return result; return result;
}; };
export const isBoardSlugAvailable = async (
db: dbClient,
boardSlug: string,
boardPublicId: string,
) => {
const result = await db
.select({ id: boards.id })
.from(boards)
.where(
and(
eq(boards.publicId, boardPublicId),
exists(
db
.select({ id: boards.id })
.from(boards)
.where(
and(
eq(boards.slug, boardSlug),
eq(boards.workspaceId, sql`${boards.workspaceId}`), // Reference outer query's workspaceId
isNull(boards.deletedAt),
),
)
.limit(1),
),
),
)
.limit(1);
return result.length === 0;
};

View File

@@ -90,23 +90,23 @@ export const cardActivities = pgTable("card_activity", {
fromIndex: integer("fromIndex"), fromIndex: integer("fromIndex"),
toIndex: integer("toIndex"), toIndex: integer("toIndex"),
fromListId: bigint("fromListId", { mode: "number" }).references( fromListId: bigint("fromListId", { mode: "number" }).references(
() => lists.id, () => lists.id, { onDelete: "cascade" },
), ),
toListId: bigint("toListId", { mode: "number" }).references(() => lists.id), toListId: bigint("toListId", { mode: "number" }).references(() => lists.id, { onDelete: "cascade" }),
labelId: bigint("labelId", { mode: "number" }).references(() => labels.id), labelId: bigint("labelId", { mode: "number" }).references(() => labels.id, { onDelete: "cascade" }),
workspaceMemberId: bigint("workspaceMemberId", { workspaceMemberId: bigint("workspaceMemberId", {
mode: "number", mode: "number",
}).references(() => workspaceMembers.id), }).references(() => workspaceMembers.id, { onDelete: "cascade" }),
fromTitle: varchar("fromTitle", { length: 255 }), fromTitle: varchar("fromTitle", { length: 255 }),
toTitle: varchar("toTitle", { length: 255 }), toTitle: varchar("toTitle", { length: 255 }),
fromDescription: text("fromDescription"), fromDescription: text("fromDescription"),
toDescription: text("toDescription"), toDescription: text("toDescription"),
createdBy: uuid("createdBy") createdBy: uuid("createdBy")
.notNull() .notNull()
.references(() => users.id), .references(() => users.id, { onDelete: "cascade" }),
createdAt: timestamp("createdAt").defaultNow().notNull(), createdAt: timestamp("createdAt").defaultNow().notNull(),
commentId: bigint("commentId", { mode: "number" }).references( commentId: bigint("commentId", { mode: "number" }).references(
() => comments.id, () => comments.id, { onDelete: "cascade" },
), ),
fromComment: text("fromComment"), fromComment: text("fromComment"),
toComment: text("toComment"), toComment: text("toComment"),
@@ -152,7 +152,7 @@ export const cardsToLabels = pgTable(
{ {
cardId: bigint("cardId", { mode: "number" }) cardId: bigint("cardId", { mode: "number" })
.notNull() .notNull()
.references(() => cards.id), .references(() => cards.id, { onDelete: "cascade" }),
labelId: bigint("labelId", { mode: "number" }) labelId: bigint("labelId", { mode: "number" })
.notNull() .notNull()
.references(() => labels.id, { onDelete: "cascade" }), .references(() => labels.id, { onDelete: "cascade" }),
@@ -176,7 +176,7 @@ export const cardToWorkspaceMembers = pgTable(
{ {
cardId: bigint("cardId", { mode: "number" }) cardId: bigint("cardId", { mode: "number" })
.notNull() .notNull()
.references(() => cards.id), .references(() => cards.id, { onDelete: "cascade" }),
workspaceMemberId: bigint("workspaceMemberId", { mode: "number" }) workspaceMemberId: bigint("workspaceMemberId", { mode: "number" })
.notNull() .notNull()
.references(() => workspaceMembers.id, { onDelete: "cascade" }), .references(() => workspaceMembers.id, { onDelete: "cascade" }),
@@ -207,11 +207,11 @@ export const comments = pgTable("card_comments", {
.references(() => cards.id, { onDelete: "cascade" }), .references(() => cards.id, { onDelete: "cascade" }),
createdBy: uuid("createdBy") createdBy: uuid("createdBy")
.notNull() .notNull()
.references(() => users.id), .references(() => users.id, { onDelete: "cascade" }),
createdAt: timestamp("createdAt").defaultNow().notNull(), createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt"), updatedAt: timestamp("updatedAt"),
deletedAt: timestamp("deletedAt"), deletedAt: timestamp("deletedAt"),
deletedBy: uuid("deletedBy").references(() => users.id), deletedBy: uuid("deletedBy").references(() => users.id, { onDelete: "cascade" }),
}).enableRLS(); }).enableRLS();
export const commentsRelations = relations(comments, ({ one }) => ({ export const commentsRelations = relations(comments, ({ one }) => ({

20
pnpm-lock.yaml generated
View File

@@ -126,12 +126,6 @@ importers:
geist: geist:
specifier: ^1.3.1 specifier: ^1.3.1
version: 1.3.1(next@14.2.20(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) version: 1.3.1(next@14.2.20(react-dom@18.3.1(react@18.3.1))(react@18.3.1))
js-cookie:
specifier: ^3.0.5
version: 3.0.5
jwt-decode:
specifier: ^4.0.0
version: 4.0.0
next: next:
specifier: ^14.2.15 specifier: ^14.2.15
version: 14.2.20(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 14.2.20(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -193,9 +187,6 @@ importers:
'@kan/tsconfig': '@kan/tsconfig':
specifier: workspace:* specifier: workspace:*
version: link:../../tooling/typescript version: link:../../tooling/typescript
'@types/js-cookie':
specifier: ^3.0.6
version: 3.0.6
'@types/node': '@types/node':
specifier: ^20.17.7 specifier: ^20.17.7
version: 20.17.9 version: 20.17.9
@@ -2324,9 +2315,6 @@ packages:
'@types/inquirer@6.5.0': '@types/inquirer@6.5.0':
resolution: {integrity: sha512-rjaYQ9b9y/VFGOpqBEXRavc3jh0a+e6evAbI31tMda8VlPaSy0AZJfXsvmIe3wklc7W6C3zCSfleuMXR7NOyXw==} resolution: {integrity: sha512-rjaYQ9b9y/VFGOpqBEXRavc3jh0a+e6evAbI31tMda8VlPaSy0AZJfXsvmIe3wklc7W6C3zCSfleuMXR7NOyXw==}
'@types/js-cookie@3.0.6':
resolution: {integrity: sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==}
'@types/json-schema@7.0.15': '@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
@@ -4066,10 +4054,6 @@ packages:
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
engines: {node: '>=4.0'} engines: {node: '>=4.0'}
jwt-decode@4.0.0:
resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==}
engines: {node: '>=18'}
katex@0.16.19: katex@0.16.19:
resolution: {integrity: sha512-3IA6DYVhxhBabjSLTNO9S4+OliA3Qvb8pBQXMfC4WxXJgLwZgnfDl0BmB4z6nBMdznBsZ+CGM8DrGZ5hcguDZg==} resolution: {integrity: sha512-3IA6DYVhxhBabjSLTNO9S4+OliA3Qvb8pBQXMfC4WxXJgLwZgnfDl0BmB4z6nBMdznBsZ+CGM8DrGZ5hcguDZg==}
hasBin: true hasBin: true
@@ -8088,8 +8072,6 @@ snapshots:
'@types/through': 0.0.33 '@types/through': 0.0.33
rxjs: 6.6.7 rxjs: 6.6.7
'@types/js-cookie@3.0.6': {}
'@types/json-schema@7.0.15': {} '@types/json-schema@7.0.15': {}
'@types/json5@0.0.29': {} '@types/json5@0.0.29': {}
@@ -10074,8 +10056,6 @@ snapshots:
object.assign: 4.1.5 object.assign: 4.1.5
object.values: 1.2.0 object.values: 1.2.0
jwt-decode@4.0.0: {}
katex@0.16.19: katex@0.16.19:
dependencies: dependencies:
commander: 8.3.0 commander: 8.3.0

View File

@@ -3,28 +3,49 @@
"ui": "tui", "ui": "tui",
"tasks": { "tasks": {
"topo": { "topo": {
"dependsOn": ["^topo"] "dependsOn": [
"^topo"
]
}, },
"build": { "build": {
"dependsOn": ["^build"], "dependsOn": [
"outputs": [".cache/tsbuildinfo.json", "dist/**"] "^build"
],
"outputs": [
".cache/tsbuildinfo.json",
"dist/**"
]
}, },
"dev": { "dev": {
"dependsOn": ["^dev"], "dependsOn": [
"^dev"
],
"cache": false, "cache": false,
"persistent": false "persistent": false
}, },
"format": { "format": {
"outputs": [".cache/.prettiercache"], "outputs": [
".cache/.prettiercache"
],
"outputLogs": "new-only" "outputLogs": "new-only"
}, },
"lint": { "lint": {
"dependsOn": ["^topo", "^build"], "dependsOn": [
"outputs": [".cache/.eslintcache"] "^topo",
"^build"
],
"outputs": [
".cache/.eslintcache"
]
}, },
"typecheck": { "typecheck": {
"dependsOn": ["^topo", "^build"], "dependsOn": [
"outputs": [".cache/tsbuildinfo.json"] "^topo",
"^build"
],
"outputs": [
".cache/tsbuildinfo.json"
]
}, },
"clean": { "clean": {
"cache": false "cache": false
@@ -49,6 +70,41 @@
"POSTGRES_URL", "POSTGRES_URL",
"GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_ID",
"GOOGLE_CLIENT_SECRET", "GOOGLE_CLIENT_SECRET",
"DISCORD_CLIENT_ID",
"DISCORD_CLIENT_SECRET",
"GITHUB_CLIENT_ID",
"GITHUB_CLIENT_SECRET",
"GITLAB_CLIENT_ID",
"GITLAB_CLIENT_SECRET",
"GITLAB_ISSUER",
"MICROSOFT_CLIENT_ID",
"MICROSOFT_CLIENT_SECRET",
"TWITTER_CLIENT_ID",
"TWITTER_CLIENT_SECRET",
"KICK_CLIENT_ID",
"KICK_CLIENT_SECRET",
"ZOOM_CLIENT_ID",
"ZOOM_CLIENT_SECRET",
"DROPBOX_CLIENT_ID",
"DROPBOX_CLIENT_SECRET",
"VK_CLIENT_ID",
"VK_CLIENT_SECRET",
"LINKEDIN_CLIENT_ID",
"LINKEDIN_CLIENT_SECRET",
"REDDIT_CLIENT_ID",
"REDDIT_CLIENT_SECRET",
"ROBLOX_CLIENT_ID",
"ROBLOX_CLIENT_SECRET",
"SPOTIFY_CLIENT_ID",
"SPOTIFY_CLIENT_SECRET",
"TIKTOK_CLIENT_ID",
"TIKTOK_CLIENT_SECRET",
"TIKTOK_CLIENT_KEY",
"TWITCH_CLIENT_ID",
"TWITCH_CLIENT_SECRET",
"APPLE_CLIENT_ID",
"APPLE_CLIENT_SECRET",
"APPLE_APP_BUNDLE_IDENTIFIER",
"EMAIL_FROM", "EMAIL_FROM",
"SMTP_HOST", "SMTP_HOST",
"SMTP_PORT", "SMTP_PORT",
@@ -78,4 +134,4 @@
"VERCEL_URL", "VERCEL_URL",
"npm_lifecycle_event" "npm_lifecycle_event"
] ]
} }