Compare commits

..

26 Commits

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

* fix: prevent list buttons from stacking

---------

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

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

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

* feat: allow board slug reuse across different workspaces

* chore: update environment settings and remove unused imports

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

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

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

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

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

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

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

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

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

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

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

* fix: add google to the remote patterns

---------

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

* chore(format): formatted edited files

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

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

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

* feat: return configured social provider names from auth endpoint

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

* refactor: remove unused EMAIL_URL environment variable

* chore: remove unused EMAIL_URL environment variable from docker compose

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

View File

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

View File

@@ -66,6 +66,13 @@ jobs:
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
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

View File

@@ -45,6 +45,37 @@ See our [roadmap](https://kan.bn/kan/roadmap) for upcoming features.
- [Drizzle ORM](https://orm.drizzle.team/?ref=kan.bn)
- [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).

View File

@@ -25,7 +25,11 @@ Get started with Kan by choosing your preferred deployment option below.
Get started instantly with our hosted solution - we'll handle all the
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>

View File

@@ -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"]

View File

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

View File

@@ -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",

View File

@@ -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"

View File

@@ -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(),
},
/**

View File

@@ -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,
}),
);

View File

@@ -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 (
<>

View File

@@ -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)}

View File

@@ -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>

View File

@@ -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

View File

@@ -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" && (

View File

@@ -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}
/>
);

View File

@@ -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>

View File

@@ -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";

View File

@@ -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>

View File

@@ -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,
);

View File

@@ -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}

View File

@@ -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,
};
}),
});

View File

@@ -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")) {

View File

@@ -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],
});

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,4 +1,4 @@
import { and, asc, desc, eq, inArray, isNull, or } from "drizzle-orm";
import { and, asc, desc, eq, exists, inArray, isNull, or, sql } from "drizzle-orm";
import type { dbClient } from "@kan/db/client";
import type { 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;
};

View File

@@ -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
View File

@@ -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

View File

@@ -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"
]
}
}