Compare commits
26 Commits
fix-23
...
fix/8-self
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec17d76ebf | ||
|
|
75ad0d8f98 | ||
|
|
4066e6bba7 | ||
|
|
8aa4aa2a7c | ||
|
|
43164a0d8a | ||
|
|
8474601b2e | ||
|
|
d768b1e6ba | ||
|
|
6a7f2f148d | ||
|
|
cbc90c3002 | ||
|
|
efe83ec8a9 | ||
|
|
d1d37840fc | ||
|
|
4135e316f4 | ||
|
|
805ef8cf43 | ||
|
|
e590de80c0 | ||
|
|
f64549f743 | ||
|
|
ce2ef1b1d4 | ||
|
|
56b0161c6f | ||
|
|
314053fa95 | ||
|
|
f41736c1aa | ||
|
|
e53efe80c5 | ||
|
|
2043a8f98c | ||
|
|
116fefcaf3 | ||
|
|
f88bd82044 | ||
|
|
6eb17529f1 | ||
|
|
4b7f58aeeb | ||
|
|
86a01074b9 |
36
.env.example
36
.env.example
@@ -21,3 +21,39 @@ BETTER_AUTH_TRUSTED_ORIGINS=
|
||||
|
||||
GOOGLE_CLIENT_ID=
|
||||
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=
|
||||
|
||||
|
||||
8
.github/workflows/docker-publish.yml
vendored
8
.github/workflows/docker-publish.yml
vendored
@@ -66,6 +66,13 @@ jobs:
|
||||
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
|
||||
with:
|
||||
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)
|
||||
# https://github.com/docker/build-push-action
|
||||
@@ -76,6 +83,7 @@ jobs:
|
||||
context: .
|
||||
file: apps/web/Dockerfile
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
|
||||
83
README.md
83
README.md
@@ -45,6 +45,37 @@ See our [roadmap](https://kan.bn/kan/roadmap) for upcoming features.
|
||||
- [Drizzle ORM](https://orm.drizzle.team/?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 🧑💻
|
||||
|
||||
1. Clone the repository (or fork)
|
||||
@@ -74,27 +105,31 @@ pnpm dev
|
||||
|
||||
## Environment Variables 🔐
|
||||
|
||||
| Variable | Description | Required | Example |
|
||||
| -------------------------------- | ----------------------------- | ---------------- | --------------------------------------------- |
|
||||
| `POSTGRES_URL` | PostgreSQL connection URL | Yes | `postgres://user:pass@localhost:5432/db` |
|
||||
| `EMAIL_FROM` | Sender email address | Yes | `"Kan <hello@mail.kan.bn>"` |
|
||||
| `SMTP_HOST` | SMTP server hostname | Yes | `smtp.resend.com` |
|
||||
| `SMTP_PORT` | SMTP server port | Yes | `465` |
|
||||
| `SMTP_USER` | SMTP username/email | Yes | `resend` |
|
||||
| `SMTP_PASSWORD` | SMTP password/token | Yes | `re_xxxx` |
|
||||
| `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_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` |
|
||||
| `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` |
|
||||
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
|
||||
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
|
||||
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads | `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` |
|
||||
| Variable | Description | Required | Example |
|
||||
| -------------------------------- | ----------------------------- | ----------------- | --------------------------------------------- |
|
||||
| `POSTGRES_URL` | PostgreSQL connection URL | Yes | `postgres://user:pass@localhost:5432/db` |
|
||||
| `EMAIL_FROM` | Sender email address | Yes | `"Kan <hello@mail.kan.bn>"` |
|
||||
| `SMTP_HOST` | SMTP server hostname | Yes | `smtp.resend.com` |
|
||||
| `SMTP_PORT` | SMTP server port | Yes | `465` |
|
||||
| `SMTP_USER` | SMTP username/email | Yes | `resend` |
|
||||
| `SMTP_PASSWORD` | SMTP password/token | Yes | `re_xxxx` |
|
||||
| `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_URL` | Auth callback URL | Yes | Same as `NEXT_PUBLIC_BASE_URL` |
|
||||
| `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_SECRET` | Google OAuth client secret | For Google login | `xxx` |
|
||||
| `DISCORD_CLIENT_ID` | Discord OAuth client ID | For Discord login | `xxx` |
|
||||
| `DISCORD_CLIENT_SECRET` | Discord OAuth client secret | For Discord login | `xxx` |
|
||||
| `GITHUB_CLIENT_ID` | GitHub OAuth client ID | For GitHub login | `xxx` |
|
||||
| `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret | For GitHub login | `xxx` |
|
||||
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
|
||||
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
|
||||
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads | `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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
## Contributors 👥
|
||||
|
||||
<a href="https://github.com/kanbn/kan/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=kanbn/kan" />
|
||||
</a>
|
||||
|
||||
## License 📝
|
||||
|
||||
Kan is licensed under the [AGPLv3 license](LICENSE).
|
||||
|
||||
@@ -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
|
||||
hosting, scaling, and maintenance for you.
|
||||
</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
|
||||
infrastructure.
|
||||
</Card>
|
||||
|
||||
@@ -31,7 +31,7 @@ COPY . .
|
||||
|
||||
# Generate a partial monorepo with a pruned lockfile for a target workspace.
|
||||
# Assuming "@acme/nextjs" is the name entered in the project's package.json: { name: "@acme/nextjs" }
|
||||
RUN turbo prune --scope=${PROJECT} --docker
|
||||
RUN turbo prune --scope=${PROJECT} --scope=@kan/db --docker
|
||||
|
||||
# 3. Build the project
|
||||
FROM base AS builder
|
||||
@@ -75,5 +75,6 @@ ARG PORT=3000
|
||||
ENV PORT=${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"]
|
||||
|
||||
|
||||
@@ -31,6 +31,10 @@ const config = {
|
||||
protocol: "https",
|
||||
hostname: `*.${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`,
|
||||
},
|
||||
{
|
||||
protocol: "http",
|
||||
hostname: "localhost",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "*.googleusercontent.com",
|
||||
|
||||
@@ -31,8 +31,6 @@
|
||||
"aws-sdk": "^2.1692.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"geist": "^1.3.1",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"next": "^14.2.15",
|
||||
"next-logger": "^5.0.1",
|
||||
"next-runtime-env": "^1.7.2",
|
||||
@@ -55,7 +53,6 @@
|
||||
"@kan/stripe": "workspace:*",
|
||||
"@kan/tailwind-config": "workspace:*",
|
||||
"@kan/tsconfig": "workspace:*",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/node": "^20.17.7",
|
||||
"@types/react": "catalog:react18",
|
||||
"@types/react-beautiful-dnd": "^13.1.7",
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
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 type { SocialProvider } from "better-auth/social-providers";
|
||||
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
@@ -19,9 +23,103 @@ interface AuthProps {
|
||||
|
||||
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) {
|
||||
const [isLoginWithGooglePending, setIsLoginWithGooglePending] =
|
||||
useState(false);
|
||||
const [isLoginWithProviderPending, setIsLoginWithProviderPending] = useState<
|
||||
null | SocialProvider
|
||||
>(null);
|
||||
const [isLoginWithEmailPending, setIsLoginWithEmailPending] = useState(false);
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
|
||||
@@ -33,6 +131,11 @@ export function Auth({ setIsMagicLinkSent }: AuthProps) {
|
||||
resolver: zodResolver(EmailSchema),
|
||||
});
|
||||
|
||||
const { data: socialProviders } = useQuery({
|
||||
queryKey: ["social_providers"],
|
||||
queryFn: () => authClient.getSocialProviders(),
|
||||
});
|
||||
|
||||
const handleLoginWithEmail = async (email: string) => {
|
||||
setIsLoginWithEmailPending(true);
|
||||
setLoginError(null);
|
||||
@@ -52,18 +155,21 @@ export function Auth({ setIsMagicLinkSent }: AuthProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoginWithGoogle = async () => {
|
||||
setIsLoginWithGooglePending(true);
|
||||
const handleLoginWithProvider = async (
|
||||
provider: SocialProvider) => {
|
||||
setIsLoginWithProviderPending(provider);
|
||||
setLoginError(null);
|
||||
const { error } = await authClient.signIn.social({
|
||||
provider: "google",
|
||||
provider,
|
||||
callbackURL: "/boards",
|
||||
});
|
||||
|
||||
setIsLoginWithGooglePending(false);
|
||||
setIsLoginWithProviderPending(null);
|
||||
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Button
|
||||
onClick={handleLoginWithGoogle}
|
||||
isLoading={isLoginWithGooglePending}
|
||||
iconLeft={<FaGoogle />}
|
||||
fullWidth
|
||||
size="lg"
|
||||
>
|
||||
Continue with Google
|
||||
</Button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<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" />
|
||||
{socialProviders?.length !== 0 && (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(availableSocialProviders).map(([key, provider]) => {
|
||||
if (!socialProviders?.includes(key)) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
onClick={() => handleLoginWithProvider(key as SocialProvider)}
|
||||
isLoading={isLoginWithProviderPending === key}
|
||||
iconLeft={<provider.icon />}
|
||||
fullWidth
|
||||
size="lg"
|
||||
>
|
||||
Continue with {provider.name}
|
||||
</Button>
|
||||
)})}
|
||||
</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
|
||||
{...register("email", { required: true })}
|
||||
placeholder="Enter your email address"
|
||||
|
||||
@@ -21,13 +21,46 @@ export const env = createEnv({
|
||||
STRIPE_SECRET_KEY: z.string().optional(),
|
||||
GOOGLE_CLIENT_ID: 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_SECRET_ACCESS_KEY: z.string().optional(),
|
||||
S3_REGION: z.string().optional(),
|
||||
S3_ENDPOINT: z.string().optional(),
|
||||
EMAIL_FROM: z.string(),
|
||||
EMAIL_URL: z.string(),
|
||||
EMAIL_TOKEN: z.string(),
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
||||
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 { env } from "~/env";
|
||||
|
||||
const allowedContentTypes = ["image/jpeg", "image/png"];
|
||||
|
||||
@@ -54,7 +56,7 @@ export default async function handler(
|
||||
// @ts-ignore
|
||||
client,
|
||||
new PutObjectCommand({
|
||||
Bucket: env("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "",
|
||||
Bucket: nextRuntimeEnv("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "",
|
||||
Key: filename,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
// import { useRouter } from "next/navigation";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Auth } from "~/components/AuthForm";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import PatternedBackground from "~/components/PatternedBackground";
|
||||
|
||||
// import { api } from "~/utils/api";
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
export default function LoginPage() {
|
||||
// const router = useRouter();
|
||||
const router = useRouter();
|
||||
const [isMagicLinkSent, setIsMagicLinkSent] = useState<boolean>(false);
|
||||
const [magicLinkRecipient, setMagicLinkRecipient] = useState<string>("");
|
||||
|
||||
@@ -18,15 +17,9 @@ export default function LoginPage() {
|
||||
setMagicLinkRecipient(recipient);
|
||||
};
|
||||
|
||||
// const authCookieExists = document.cookie
|
||||
// .split("; ")
|
||||
// .some((cookie) => cookie.includes("auth-token"));
|
||||
const { data } = authClient.useSession();
|
||||
|
||||
// const { data } = api.user.getUser.useQuery(undefined, {
|
||||
// enabled: authCookieExists ? true : false,
|
||||
// });
|
||||
|
||||
// if (data?.id) router.push("/boards");
|
||||
if (data?.user.id) router.push("/boards");
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { type ReactNode } from "react";
|
||||
import {
|
||||
HiOutlinePlusSmall,
|
||||
HiEllipsisHorizontal,
|
||||
HiOutlineTrash,
|
||||
HiOutlineSquaresPlus,
|
||||
} from "react-icons/hi2";
|
||||
import type { ReactNode } from "react";
|
||||
import { Draggable } from "react-beautiful-dnd";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { api } from "~/utils/api";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import {
|
||||
HiEllipsisHorizontal,
|
||||
HiOutlinePlusSmall,
|
||||
HiOutlineSquaresPlus,
|
||||
HiOutlineTrash,
|
||||
} from "react-icons/hi2";
|
||||
|
||||
import Dropdown from "~/components/Dropdown";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
interface ListProps {
|
||||
children: ReactNode;
|
||||
@@ -80,20 +79,20 @@ export default function List({
|
||||
{...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"
|
||||
>
|
||||
<div className="flex justify-between">
|
||||
<div className="mb-2 flex justify-between">
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="focus-visible:outline-none"
|
||||
className="w-full focus-visible:outline-none"
|
||||
>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
{...register("name")}
|
||||
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>
|
||||
<div>
|
||||
<div className="flex items-center">
|
||||
<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"
|
||||
onClick={() => openNewCardForm(list.publicId)}
|
||||
|
||||
@@ -63,6 +63,7 @@ export function NewCardForm({
|
||||
const memberPublicIds = watch("memberPublicIds") || [];
|
||||
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
|
||||
const position = watch("position");
|
||||
const title = watch("title");
|
||||
|
||||
const { data: boardData } = api.board.byId.useQuery(queryParams, {
|
||||
enabled: !!boardPublicId,
|
||||
@@ -110,16 +111,29 @@ export function NewCardForm({
|
||||
|
||||
return { previousState: currentState };
|
||||
},
|
||||
onError: (_error, _newList, context) => {
|
||||
onError: (error, _newList, context) => {
|
||||
utils.board.byId.setData(queryParams, context?.previousState);
|
||||
showPopup({
|
||||
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",
|
||||
});
|
||||
},
|
||||
onSettled: async () => {
|
||||
onSuccess: async () => {
|
||||
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
|
||||
if (!isCreateAnotherEnabled) closeModal();
|
||||
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 isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
|
||||
if (!isCreateAnotherEnabled) closeModal();
|
||||
reset({
|
||||
title: "",
|
||||
description: "",
|
||||
listPublicId: watch("listPublicId"),
|
||||
labelPublicIds: [],
|
||||
memberPublicIds: [],
|
||||
isCreateAnotherEnabled,
|
||||
position,
|
||||
});
|
||||
|
||||
createCard.mutate({
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
@@ -387,7 +389,7 @@ export function NewCardForm({
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Button type="submit">Create card</Button>
|
||||
<Button type="submit" disabled={title.length === 0 || createCard.isPending}>Create card</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { HiXMark } from "react-icons/hi2";
|
||||
import { HiCheck, HiXMark } from "react-icons/hi2";
|
||||
import { z } from "zod";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import Input from "~/components/Input";
|
||||
import { useDebounce } from "~/hooks/useDebounce";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
@@ -49,6 +51,7 @@ export function UpdateBoardSlugForm({
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { isDirty, errors },
|
||||
watch,
|
||||
} = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
values: {
|
||||
@@ -57,6 +60,10 @@ export function UpdateBoardSlugForm({
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const slug = watch("slug");
|
||||
|
||||
const [debouncedSlug] = useDebounce(slug, 500);
|
||||
|
||||
const updateBoardSlug = api.board.update.useMutation({
|
||||
onError: () => {
|
||||
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(() => {
|
||||
const nameElement: HTMLElement | null =
|
||||
document.querySelector<HTMLElement>("#board-slug");
|
||||
@@ -78,6 +99,9 @@ export function UpdateBoardSlugForm({
|
||||
}, []);
|
||||
|
||||
const onSubmit = (data: FormValues) => {
|
||||
if (!isBoardSlugAvailable) return;
|
||||
if (isBoardSlugAvailable?.isReserved) return;
|
||||
|
||||
updateBoardSlug.mutate({
|
||||
slug: data.slug,
|
||||
boardPublicId,
|
||||
@@ -106,14 +130,23 @@ export function UpdateBoardSlugForm({
|
||||
<Input
|
||||
id="board-slug"
|
||||
{...register("slug")}
|
||||
errorMessage={errors.slug?.message}
|
||||
prefix={`kan.bn/${workspaceSlug}/`}
|
||||
errorMessage={errors.slug?.message || (isBoardSlugAvailable?.isReserved
|
||||
? "This board URL has already been taken"
|
||||
: undefined)}
|
||||
prefix={`${env("NEXT_PUBLIC_BASE_URL")}/${workspaceSlug}/`}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
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 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={
|
||||
!isDirty ||
|
||||
updateBoardSlug.isPending ||
|
||||
errors.slug?.message !== undefined
|
||||
errors.slug?.message !== undefined ||
|
||||
isBoardSlugAvailable?.isReserved
|
||||
}
|
||||
>
|
||||
Update
|
||||
|
||||
@@ -240,11 +240,12 @@ export default function BoardPage() {
|
||||
<div className="relative flex h-full flex-col">
|
||||
<PatternedBackground />
|
||||
<div className="z-10 flex w-full justify-between p-8">
|
||||
{isLoading ? (
|
||||
{isLoading && !boardData && (
|
||||
<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>
|
||||
) : (
|
||||
)}
|
||||
{boardData && (
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="focus-visible:outline-none"
|
||||
@@ -258,20 +259,23 @@ export default function BoardPage() {
|
||||
/>
|
||||
</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">
|
||||
<VisibilityButton
|
||||
visibility={boardData?.visibility ?? "private"}
|
||||
boardPublicId={boardId ?? ""}
|
||||
queryParams={queryParams}
|
||||
isLoading={isLoading}
|
||||
isLoading={!boardData}
|
||||
isAdmin={workspace.role === "admin"}
|
||||
/>
|
||||
<Filters
|
||||
labels={boardData?.labels ?? []}
|
||||
members={boardData?.workspace.members ?? []}
|
||||
position="left"
|
||||
isLoading={isLoading}
|
||||
isLoading={!boardData}
|
||||
/>
|
||||
<Button
|
||||
iconLeft={
|
||||
@@ -283,11 +287,11 @@ export default function BoardPage() {
|
||||
onClick={() => {
|
||||
if (boardId) openNewListForm(boardId);
|
||||
}}
|
||||
disabled={isLoading}
|
||||
disabled={!boardData}
|
||||
>
|
||||
New list
|
||||
</Button>
|
||||
<BoardDropdown isLoading={isLoading} />
|
||||
<BoardDropdown isLoading={!boardData} />
|
||||
</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-[375px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
|
||||
</div>
|
||||
) : (
|
||||
) : boardData ? (
|
||||
<>
|
||||
{boardData?.lists.length === 0 ? (
|
||||
<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}
|
||||
href={`/cards/${card.publicId}`}
|
||||
className={`mb-2 flex !cursor-pointer flex-col ${
|
||||
card.publicId.startsWith(
|
||||
"PLACEHOLDER",
|
||||
)
|
||||
? "pointer-events-none"
|
||||
: ""
|
||||
}`}
|
||||
className={`mb-2 flex !cursor-pointer flex-col ${card.publicId.startsWith(
|
||||
"PLACEHOLDER",
|
||||
)
|
||||
? "pointer-events-none"
|
||||
: ""
|
||||
}`}
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
@@ -404,7 +407,7 @@ export default function BoardPage() {
|
||||
</DragDropContext>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
<Modal modalSize={modalContentType === "NEW_CARD" ? "md" : "sm"}>
|
||||
{modalContentType === "DELETE_BOARD" && (
|
||||
|
||||
@@ -8,11 +8,11 @@ import {
|
||||
HiOutlineUserMinus,
|
||||
HiOutlineUserPlus,
|
||||
} from "react-icons/hi2";
|
||||
|
||||
import type { GetCardByIdOutput } from "@kan/api/types";
|
||||
|
||||
import Avatar from "~/components/Avatar";
|
||||
import Comment from "./Comment";
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
type ActivityType =
|
||||
NonNullable<GetCardByIdOutput>["activities"][number]["type"];
|
||||
@@ -147,6 +147,8 @@ const ActivityList = ({
|
||||
isLoading: boolean;
|
||||
isAdmin?: boolean;
|
||||
}) => {
|
||||
const { data } = authClient.useSession();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-4 pt-4">
|
||||
{activities.map((activity, index) => {
|
||||
@@ -156,7 +158,7 @@ const ActivityList = ({
|
||||
fromList: activity.fromList?.name ?? null,
|
||||
toList: activity.toList?.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,
|
||||
});
|
||||
|
||||
@@ -172,7 +174,7 @@ const ActivityList = ({
|
||||
createdAt={activity.createdAt}
|
||||
comment={activity.comment?.comment}
|
||||
isEdited={!!activity.comment?.updatedAt}
|
||||
isAuthor={activity.comment?.createdBy === activity.user?.id}
|
||||
isAuthor={activity.comment?.createdBy === data?.user?.id}
|
||||
isAdmin={isAdmin ?? false}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -148,12 +148,13 @@ export default function CardPage() {
|
||||
<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="mb-8 flex w-full items-center justify-between">
|
||||
{isLoading ? (
|
||||
{!card && isLoading && (
|
||||
<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-[300px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
|
||||
</div>
|
||||
) : (
|
||||
)}
|
||||
{card && (
|
||||
<>
|
||||
<Link
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
{!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 className="mb-10 flex w-full max-w-2xl justify-between">
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="w-full space-y-6"
|
||||
>
|
||||
<div className="mt-2">
|
||||
<ContentEditable
|
||||
placeholder="Add description..."
|
||||
html={watch("description")}
|
||||
disabled={false}
|
||||
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"
|
||||
/>
|
||||
{card && (
|
||||
<>
|
||||
<div className="mb-10 flex w-full max-w-2xl justify-between">
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="w-full space-y-6"
|
||||
>
|
||||
<div className="mt-2">
|
||||
<ContentEditable
|
||||
placeholder="Add description..."
|
||||
html={watch("description")}
|
||||
disabled={false}
|
||||
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>
|
||||
</form>
|
||||
</div>
|
||||
<div className="border-t-[1px] border-light-600 pt-12 dark:border-dark-400">
|
||||
<h2 className="text-md pb-4 font-medium text-light-900 dark:text-dark-1000">
|
||||
Activity
|
||||
</h2>
|
||||
<div>
|
||||
<ActivityList
|
||||
cardPublicId={cardId}
|
||||
activities={activities ?? []}
|
||||
isLoading={isLoading}
|
||||
isAdmin={workspace.role === "admin"}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<NewCommentForm cardPublicId={cardId} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t-[1px] border-light-600 pt-12 dark:border-dark-400">
|
||||
<h2 className="text-md pb-4 font-medium text-light-900 dark:text-dark-1000">
|
||||
Activity
|
||||
</h2>
|
||||
<div>
|
||||
<ActivityList
|
||||
cardPublicId={cardId}
|
||||
activities={activities ?? []}
|
||||
isLoading={!card}
|
||||
isAdmin={workspace.role === "admin"}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<NewCommentForm cardPublicId={cardId} />
|
||||
</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">
|
||||
@@ -226,7 +236,7 @@ export default function CardPage() {
|
||||
<ListSelector
|
||||
cardPublicId={cardId}
|
||||
lists={formattedLists}
|
||||
isLoading={isLoading}
|
||||
isLoading={!card}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4 flex w-full">
|
||||
@@ -234,7 +244,7 @@ export default function CardPage() {
|
||||
<LabelSelector
|
||||
cardPublicId={cardId}
|
||||
labels={formattedLabels}
|
||||
isLoading={isLoading}
|
||||
isLoading={!card}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full">
|
||||
@@ -242,7 +252,7 @@ export default function CardPage() {
|
||||
<MemberSelector
|
||||
cardPublicId={cardId}
|
||||
members={formattedMembers}
|
||||
isLoading={isLoading}
|
||||
isLoading={!card}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,22 +1,15 @@
|
||||
import Cookies from "js-cookie";
|
||||
|
||||
import PatternedBackground from "~/components/PatternedBackground";
|
||||
import { useTheme } from "~/providers/theme";
|
||||
import { api } from "~/utils/api";
|
||||
import Footer from "./Footer";
|
||||
import Header from "./Header";
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
const theme = useTheme();
|
||||
|
||||
const token =
|
||||
typeof window !== "undefined" ? Cookies.get("kan.session_token") : null;
|
||||
const { data: session } = authClient.useSession();
|
||||
|
||||
const { data } = api.user.getUser.useQuery(undefined, {
|
||||
enabled: !!token,
|
||||
});
|
||||
|
||||
const isLoggedIn = !!data;
|
||||
const isLoggedIn = !!session?.user;
|
||||
|
||||
const isDarkMode = theme.activeTheme === "dark";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { IoLogoGithub } from "react-icons/io";
|
||||
import { IoLogoGithub, IoLogoHackernews } from "react-icons/io";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
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="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="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="gradient-border absolute inset-0 animate-border-spin" />
|
||||
<div className="relative z-10 rounded-full bg-light-50 dark:bg-dark-50">
|
||||
<Link
|
||||
href="https://github.com/kanbn/kan"
|
||||
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"
|
||||
>
|
||||
Star on Github
|
||||
<IoLogoGithub size={20} />
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<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="gradient-border absolute inset-0 animate-border-spin" />
|
||||
|
||||
<div className="relative z-10 rounded-full bg-light-50 dark:bg-dark-50">
|
||||
<Link
|
||||
href="https://github.com/kanbn/kan"
|
||||
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"
|
||||
>
|
||||
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>
|
||||
|
||||
|
||||
@@ -15,14 +15,19 @@ export function DeleteWorkspaceConfirmation() {
|
||||
|
||||
const [isAcknowledgmentChecked, setIsAcknowledgmentChecked] = useState(false);
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const deleteWorkspaceMutation = api.workspace.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
onSuccess: async () => {
|
||||
closeModal();
|
||||
showPopup({
|
||||
header: "Workspace deleted",
|
||||
message: "Your workspace has been deleted.",
|
||||
icon: "success",
|
||||
});
|
||||
|
||||
await utils.workspace.all.refetch();
|
||||
|
||||
const filteredWorkspaces = availableWorkspaces.filter(
|
||||
(ws) => ws.publicId !== workspace.publicId,
|
||||
);
|
||||
|
||||
@@ -15,8 +15,10 @@ services:
|
||||
command: ["pnpm", "start"]
|
||||
environment:
|
||||
- EMAIL_FROM=${EMAIL_FROM}
|
||||
- EMAIL_URL=${EMAIL_URL}
|
||||
- EMAIL_TOKEN=${EMAIL_TOKEN}
|
||||
- SMTP_HOST=${SMTP_HOST}
|
||||
- SMTP_PORT=${SMTP_PORT}
|
||||
- SMTP_USER=${SMTP_USER}
|
||||
- SMTP_PASSWORD=${SMTP_PASSWORD}
|
||||
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
|
||||
- BETTER_AUTH_URL=${BETTER_AUTH_URL}
|
||||
- BETTER_AUTH_TRUSTED_ORIGINS=${BETTER_AUTH_TRUSTED_ORIGINS}
|
||||
@@ -25,6 +27,41 @@ services:
|
||||
- POSTGRES_URL=${POSTGRES_URL}
|
||||
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
|
||||
- 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_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY}
|
||||
- S3_REGION=${S3_REGION}
|
||||
|
||||
@@ -352,4 +352,40 @@ export const boardRouter = createTRPCRouter({
|
||||
|
||||
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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { betterAuth } from "better-auth";
|
||||
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 { magicLink } from "better-auth/plugins/magic-link";
|
||||
import { env } from "next-runtime-env";
|
||||
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
||||
|
||||
import type { dbClient } from "@kan/db/client";
|
||||
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 { sendEmail } from "@kan/email";
|
||||
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) => {
|
||||
return betterAuth({
|
||||
@@ -26,12 +110,7 @@ export const initAuth = (db: dbClient) => {
|
||||
user: schema.users,
|
||||
},
|
||||
}),
|
||||
socialProviders: {
|
||||
google: {
|
||||
clientId: process.env.GOOGLE_CLIENT_ID!,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
||||
},
|
||||
},
|
||||
socialProviders: configuredProviders,
|
||||
user: {
|
||||
additionalFields: {
|
||||
stripeCustomerId: {
|
||||
@@ -43,6 +122,7 @@ export const initAuth = (db: dbClient) => {
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
socialProvidersPlugin(),
|
||||
// @todo: hasing is disabled due to a bug in the api key plugin
|
||||
apiKey({ disableKeyHashing: true }),
|
||||
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: {
|
||||
after: createAuthMiddleware(async (ctx) => {
|
||||
if (ctx.path.startsWith("/get-session")) {
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
import { BetterAuthClientPlugin } from "better-auth";
|
||||
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({
|
||||
plugins: [magicLinkClient(), apiKeyClient()],
|
||||
plugins: [magicLinkClient(), apiKeyClient(), socialProvidersPluginClient],
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"extends": "@kan/tsconfig/internal-package.json",
|
||||
"compilerOptions": {},
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["*.ts", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
@@ -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 $$;
|
||||
2087
packages/db/migrations/meta/20250605101155_snapshot.json
Normal file
2087
packages/db/migrations/meta/20250605101155_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,13 @@
|
||||
"when": 1748378293342,
|
||||
"tag": "20250527203813_AddDeletedAtToLabel",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1749118315675,
|
||||
"tag": "20250605101155_AddCascadeDeleteToCardRelations",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 { BoardVisibilityStatus } from "@kan/db/schema";
|
||||
@@ -477,3 +477,34 @@ export const getWorkspaceAndBoardIdByBoardPublicId = async (
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -90,23 +90,23 @@ export const cardActivities = pgTable("card_activity", {
|
||||
fromIndex: integer("fromIndex"),
|
||||
toIndex: integer("toIndex"),
|
||||
fromListId: bigint("fromListId", { mode: "number" }).references(
|
||||
() => lists.id,
|
||||
() => lists.id, { onDelete: "cascade" },
|
||||
),
|
||||
toListId: bigint("toListId", { mode: "number" }).references(() => lists.id),
|
||||
labelId: bigint("labelId", { mode: "number" }).references(() => labels.id),
|
||||
toListId: bigint("toListId", { mode: "number" }).references(() => lists.id, { onDelete: "cascade" }),
|
||||
labelId: bigint("labelId", { mode: "number" }).references(() => labels.id, { onDelete: "cascade" }),
|
||||
workspaceMemberId: bigint("workspaceMemberId", {
|
||||
mode: "number",
|
||||
}).references(() => workspaceMembers.id),
|
||||
}).references(() => workspaceMembers.id, { onDelete: "cascade" }),
|
||||
fromTitle: varchar("fromTitle", { length: 255 }),
|
||||
toTitle: varchar("toTitle", { length: 255 }),
|
||||
fromDescription: text("fromDescription"),
|
||||
toDescription: text("toDescription"),
|
||||
createdBy: uuid("createdBy")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
commentId: bigint("commentId", { mode: "number" }).references(
|
||||
() => comments.id,
|
||||
() => comments.id, { onDelete: "cascade" },
|
||||
),
|
||||
fromComment: text("fromComment"),
|
||||
toComment: text("toComment"),
|
||||
@@ -152,7 +152,7 @@ export const cardsToLabels = pgTable(
|
||||
{
|
||||
cardId: bigint("cardId", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => cards.id),
|
||||
.references(() => cards.id, { onDelete: "cascade" }),
|
||||
labelId: bigint("labelId", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => labels.id, { onDelete: "cascade" }),
|
||||
@@ -176,7 +176,7 @@ export const cardToWorkspaceMembers = pgTable(
|
||||
{
|
||||
cardId: bigint("cardId", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => cards.id),
|
||||
.references(() => cards.id, { onDelete: "cascade" }),
|
||||
workspaceMemberId: bigint("workspaceMemberId", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => workspaceMembers.id, { onDelete: "cascade" }),
|
||||
@@ -207,11 +207,11 @@ export const comments = pgTable("card_comments", {
|
||||
.references(() => cards.id, { onDelete: "cascade" }),
|
||||
createdBy: uuid("createdBy")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt"),
|
||||
deletedAt: timestamp("deletedAt"),
|
||||
deletedBy: uuid("deletedBy").references(() => users.id),
|
||||
deletedBy: uuid("deletedBy").references(() => users.id, { onDelete: "cascade" }),
|
||||
}).enableRLS();
|
||||
|
||||
export const commentsRelations = relations(comments, ({ one }) => ({
|
||||
|
||||
20
pnpm-lock.yaml
generated
20
pnpm-lock.yaml
generated
@@ -126,12 +126,6 @@ importers:
|
||||
geist:
|
||||
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))
|
||||
js-cookie:
|
||||
specifier: ^3.0.5
|
||||
version: 3.0.5
|
||||
jwt-decode:
|
||||
specifier: ^4.0.0
|
||||
version: 4.0.0
|
||||
next:
|
||||
specifier: ^14.2.15
|
||||
version: 14.2.20(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
@@ -193,9 +187,6 @@ importers:
|
||||
'@kan/tsconfig':
|
||||
specifier: workspace:*
|
||||
version: link:../../tooling/typescript
|
||||
'@types/js-cookie':
|
||||
specifier: ^3.0.6
|
||||
version: 3.0.6
|
||||
'@types/node':
|
||||
specifier: ^20.17.7
|
||||
version: 20.17.9
|
||||
@@ -2324,9 +2315,6 @@ packages:
|
||||
'@types/inquirer@6.5.0':
|
||||
resolution: {integrity: sha512-rjaYQ9b9y/VFGOpqBEXRavc3jh0a+e6evAbI31tMda8VlPaSy0AZJfXsvmIe3wklc7W6C3zCSfleuMXR7NOyXw==}
|
||||
|
||||
'@types/js-cookie@3.0.6':
|
||||
resolution: {integrity: sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==}
|
||||
|
||||
'@types/json-schema@7.0.15':
|
||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||
|
||||
@@ -4066,10 +4054,6 @@ packages:
|
||||
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
|
||||
engines: {node: '>=4.0'}
|
||||
|
||||
jwt-decode@4.0.0:
|
||||
resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
katex@0.16.19:
|
||||
resolution: {integrity: sha512-3IA6DYVhxhBabjSLTNO9S4+OliA3Qvb8pBQXMfC4WxXJgLwZgnfDl0BmB4z6nBMdznBsZ+CGM8DrGZ5hcguDZg==}
|
||||
hasBin: true
|
||||
@@ -8088,8 +8072,6 @@ snapshots:
|
||||
'@types/through': 0.0.33
|
||||
rxjs: 6.6.7
|
||||
|
||||
'@types/js-cookie@3.0.6': {}
|
||||
|
||||
'@types/json-schema@7.0.15': {}
|
||||
|
||||
'@types/json5@0.0.29': {}
|
||||
@@ -10074,8 +10056,6 @@ snapshots:
|
||||
object.assign: 4.1.5
|
||||
object.values: 1.2.0
|
||||
|
||||
jwt-decode@4.0.0: {}
|
||||
|
||||
katex@0.16.19:
|
||||
dependencies:
|
||||
commander: 8.3.0
|
||||
|
||||
76
turbo.json
76
turbo.json
@@ -3,28 +3,49 @@
|
||||
"ui": "tui",
|
||||
"tasks": {
|
||||
"topo": {
|
||||
"dependsOn": ["^topo"]
|
||||
"dependsOn": [
|
||||
"^topo"
|
||||
]
|
||||
},
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": [".cache/tsbuildinfo.json", "dist/**"]
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
],
|
||||
"outputs": [
|
||||
".cache/tsbuildinfo.json",
|
||||
"dist/**"
|
||||
]
|
||||
},
|
||||
"dev": {
|
||||
"dependsOn": ["^dev"],
|
||||
"dependsOn": [
|
||||
"^dev"
|
||||
],
|
||||
"cache": false,
|
||||
"persistent": false
|
||||
},
|
||||
"format": {
|
||||
"outputs": [".cache/.prettiercache"],
|
||||
"outputs": [
|
||||
".cache/.prettiercache"
|
||||
],
|
||||
"outputLogs": "new-only"
|
||||
},
|
||||
"lint": {
|
||||
"dependsOn": ["^topo", "^build"],
|
||||
"outputs": [".cache/.eslintcache"]
|
||||
"dependsOn": [
|
||||
"^topo",
|
||||
"^build"
|
||||
],
|
||||
"outputs": [
|
||||
".cache/.eslintcache"
|
||||
]
|
||||
},
|
||||
"typecheck": {
|
||||
"dependsOn": ["^topo", "^build"],
|
||||
"outputs": [".cache/tsbuildinfo.json"]
|
||||
"dependsOn": [
|
||||
"^topo",
|
||||
"^build"
|
||||
],
|
||||
"outputs": [
|
||||
".cache/tsbuildinfo.json"
|
||||
]
|
||||
},
|
||||
"clean": {
|
||||
"cache": false
|
||||
@@ -49,6 +70,41 @@
|
||||
"POSTGRES_URL",
|
||||
"GOOGLE_CLIENT_ID",
|
||||
"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",
|
||||
"SMTP_HOST",
|
||||
"SMTP_PORT",
|
||||
@@ -78,4 +134,4 @@
|
||||
"VERCEL_URL",
|
||||
"npm_lifecycle_event"
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user