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_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=
|
||||||
|
|
||||||
|
|||||||
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
|
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
|
||||||
|
|||||||
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)
|
- [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).
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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(),
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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" && (
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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")) {
|
||||||
|
|||||||
@@ -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],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
"when": 1748378293342,
|
||||||
"tag": "20250527203813_AddDeletedAtToLabel",
|
"tag": "20250527203813_AddDeletedAtToLabel",
|
||||||
"breakpoints": true
|
"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 { 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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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
20
pnpm-lock.yaml
generated
@@ -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
|
||||||
|
|||||||
76
turbo.json
76
turbo.json
@@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user