Compare commits

..

2 Commits

Author SHA1 Message Date
Henry
281fbc7b4c fix: submit forms on enter key submission 2025-06-03 14:56:01 +01:00
Krisztiaan
eb49bab675 fix: add type="button" to modal close buttons to fix Enter key form submission
Previously, pressing Enter in modal form inputs would dismiss the modal instead
of submitting the form because close buttons defaulted to type="submit". This
adds explicit type="button" to all modal close buttons to ensure Enter key
properly submits forms.

Fixes:
- New board creation via Enter key
- New workspace creation via Enter key
- New card creation via Enter key
- New list creation via Enter key
- Member invitation via Enter key
- Label creation/editing via Enter key
- Board URL updates via Enter key
- Import boards forms via Enter key
2025-06-03 03:14:37 +02:00
61 changed files with 296 additions and 3366 deletions

View File

@@ -1,7 +1,5 @@
.env .env
docker-compose.override.yml
Dockerfile Dockerfile
./**/*/Dockerfile ./**/*/Dockerfile

View File

@@ -1,10 +1,8 @@
POSTGRES_URL= POSTGRES_URL=
EMAIL_FROM= EMAIL_FROM=
SMTP_HOST= EMAIL_URL=
SMTP_PORT= EMAIL_TOKEN=
SMTP_USER=
SMTP_PASSWORD=
NEXT_PUBLIC_BASE_URL= NEXT_PUBLIC_BASE_URL=
NEXT_PUBLIC_STORAGE_URL= NEXT_PUBLIC_STORAGE_URL=
@@ -21,39 +19,3 @@ BETTER_AUTH_TRUSTED_ORIGINS=
GOOGLE_CLIENT_ID= GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET= GOOGLE_CLIENT_SECRET=
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GITLAB_CLIENT_ID=
GITLAB_CLIENT_SECRET=
GITLAB_ISSUER=
MICROSOFT_CLIENT_ID=
MICROSOFT_CLIENT_SECRET=
TWITTER_CLIENT_ID=
TWITTER_CLIENT_SECRET=
KICK_CLIENT_ID=
KICK_CLIENT_SECRET=
ZOOM_CLIENT_ID=
ZOOM_CLIENT_SECRET=
DROPBOX_CLIENT_ID=
DROPBOX_CLIENT_SECRET=
VK_CLIENT_ID=
VK_CLIENT_SECRET=
LINKEDIN_CLIENT_ID=
LINKEDIN_CLIENT_SECRET=
REDDIT_CLIENT_ID=
REDDIT_CLIENT_SECRET=
ROBLOX_CLIENT_ID=
ROBLOX_CLIENT_SECRET=
SPOTIFY_CLIENT_ID=
SPOTIFY_CLIENT_SECRET=
TIKTOK_CLIENT_ID=
TIKTOK_CLIENT_SECRET=
TIKTOK_CLIENT_KEY=
TWITCH_CLIENT_ID=
TWITCH_CLIENT_SECRET=
APPLE_CLIENT_ID=
APPLE_CLIENT_SECRET=
APPLE_APP_BUNDLE_IDENTIFIER=

View File

@@ -1,37 +0,0 @@
---
name: "\U0001F41B Bug Report"
about: Create a report to help us improve
title: ''
labels: bug
assignees: ''
---
## 🐛 Bug Report
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '...'
3. Scroll down to '...'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Environment (please complete the following information):**
- OS: [e.g. macOS, Windows, Linux]
- Browser [e.g. chrome, safari]
- Node version [e.g. 20]
- App version/commit [if applicable]
**Additional context**
Add any other context about the problem here.

View File

@@ -1,30 +0,0 @@
---
name: "✨ Feature Request"
about: Suggest an idea for this project
title: ''
labels: feature
assignees: ''
---
## ✨ Feature Request
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
**Implementation ideas (optional)**
If you have any ideas about how this feature could be implemented, please share them here.
**Would you like to work on this feature?**
- [ ] Yes, I'd like to help implement this feature
- [ ] No, I'm just suggesting the feature

View File

@@ -1,105 +0,0 @@
name: Docker
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
on:
schedule:
- cron: "21 21 * * *"
push:
branches: ["main"]
# Publish semver tags as releases.
tags: ["v*.*.*"]
pull_request:
branches: ["main"]
env:
# Use docker.io for Docker Hub if empty
REGISTRY: ghcr.io
# github.repository as <account>/<repo>
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
# This is used to complete the identity challenge
# with sigstore/fulcio when running outside of PRs.
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
# Install the cosign tool except on PR
# https://github.com/sigstore/cosign-installer
- name: Install cosign
if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 #v3.5.0
with:
cosign-release: "v2.2.4"
# Set up BuildKit Docker container builder to be able to build
# multi-platform images and export cache
# https://github.com/docker/setup-buildx-action
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0
# Login against a Docker registry except on PR
# https://github.com/docker/login-action
- name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request'
uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Extract metadata (tags, labels) for Docker
# https://github.com/docker/metadata-action
- name: Extract Docker metadata
id: meta
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
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0
with:
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
cache-to: type=gha,mode=max
# Sign the resulting Docker image digest except on PRs.
# This will only write to the public Rekor transparency log when the Docker
# repository is public to avoid leaking data. If you would like to publish
# transparency data even for private images, pass --force to cosign below.
# https://github.com/sigstore/cosign
- name: Sign the published Docker image
if: ${{ github.event_name != 'pull_request' }}
env:
# https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable
TAGS: ${{ steps.meta.outputs.tags }}
DIGEST: ${{ steps.build-and-push.outputs.digest }}
# This step uses the identity token to provision an ephemeral certificate
# against the sigstore community Fulcio instance.
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}

3
.gitignore vendored
View File

@@ -43,6 +43,3 @@ dist/
# turbo # turbo
.turbo .turbo
# docker
docker-compose.override.yml

View File

@@ -45,37 +45,6 @@ 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)
@@ -91,58 +60,16 @@ pnpm install
``` ```
3. Copy `.env.example` to `.env` and configure your environment variables 3. Copy `.env.example` to `.env` and configure your environment variables
4. Migrate database 4. Start the development server
```bash
pnpm db:migrate
```
5. Start the development server
```bash ```bash
pnpm dev 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 | 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.
## Contributing 🤝 ## Contributing 🤝
We welcome contributions! Please read our [contribution guidelines](CONTRIBUTING.md) before submitting a pull request. We welcome contributions! Please read our [contribution guidelines](CONTRIBUTING.md) before submitting a pull request.
## Contributors 👥
<a href="https://github.com/kanbn/kan/graphs/contributors">
<img src="https://contrib.rocks/image?repo=kanbn/kan" />
</a>
## License 📝 ## License 📝
Kan is licensed under the [AGPLv3 license](LICENSE). Kan is licensed under the [AGPLv3 license](LICENSE).

View File

@@ -25,11 +25,7 @@ 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 <Card title="Self-host" icon="code" href="https://github.com/kanbn/kan">
title="Self-host"
icon="code"
href="https://github.com/kanbn/kan?tab=readme-ov-file#self-hosting-"
>
For full data ownership and control, you can self-host Kan on your For full data ownership and control, you can self-host Kan on your
infrastructure. infrastructure.
</Card> </Card>

View File

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

View File

@@ -1,13 +1,9 @@
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
import createJiti from "jiti"; import createJiti from "jiti";
import { env } from "next-runtime-env";
import { configureRuntimeEnv } from "next-runtime-env/build/configure.js";
// Import env files to validate at build time. Use jiti so we can load .ts files in here. // Import env files to validate at build time. Use jiti so we can load .ts files in here.
createJiti(fileURLToPath(import.meta.url))("./src/env"); createJiti(fileURLToPath(import.meta.url))("./src/env");
configureRuntimeEnv();
/** @type {import("next").NextConfig} */ /** @type {import("next").NextConfig} */
const config = { const config = {
reactStrictMode: true, reactStrictMode: true,
@@ -26,20 +22,7 @@ const config = {
typescript: { ignoreBuildErrors: true }, typescript: { ignoreBuildErrors: true },
images: { images: {
remotePatterns: [ domains: [process.env.NEXT_PUBLIC_STORAGE_DOMAIN ?? ""],
{
protocol: "https",
hostname: `*.${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`,
},
{
protocol: "http",
hostname: "localhost",
},
{
protocol: "https",
hostname: "*.googleusercontent.com",
},
],
}, },
experimental: { experimental: {
instrumentationHook: true, instrumentationHook: true,

View File

@@ -31,9 +31,10 @@
"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",
"nextjs-cors": "^2.2.0", "nextjs-cors": "^2.2.0",
"pino": "^9.6.0", "pino": "^9.6.0",
"react": "catalog:react18", "react": "catalog:react18",
@@ -53,6 +54,7 @@
"@kan/stripe": "workspace:*", "@kan/stripe": "workspace:*",
"@kan/tailwind-config": "workspace:*", "@kan/tailwind-config": "workspace:*",
"@kan/tsconfig": "workspace:*", "@kan/tsconfig": "workspace:*",
"@types/js-cookie": "^3.0.6",
"@types/node": "^20.17.7", "@types/node": "^20.17.7",
"@types/react": "catalog:react18", "@types/react": "catalog:react18",
"@types/react-beautiful-dnd": "^13.1.7", "@types/react-beautiful-dnd": "^13.1.7",

View File

@@ -1 +0,0 @@
window.__ENV = {"NEXT_PUBLIC_UMAMI_ID":"aaed1f55-25e2-4223-b918-e429c390be35","NEXT_PUBLIC_KAN_ENV":"cloud","NEXT_PUBLIC_BASE_URL":"http://localhost:3000","NEXT_PUBLIC_AVATAR_BUCKET_NAME":"avatars","NEXT_PUBLIC_STORAGE_DOMAIN":"kanbn.com","NEXT_PUBLIC_STORAGE_URL":"https://storage.kanbn.com"};

View File

@@ -1,14 +1,10 @@
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 { FaDiscord, FaGithub, FaGoogle, FaApple, FaMicrosoft, FaFacebook, FaSpotify, FaTwitch, FaTwitter, FaDropbox, FaLinkedin, FaGitlab, FaTiktok, FaReddit, FaVk } from "react-icons/fa"; import { FaGoogle } 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";
import Button from "~/components/Button"; import Button from "~/components/Button";
import Input from "~/components/Input"; import Input from "~/components/Input";
@@ -23,103 +19,9 @@ 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 [isLoginWithProviderPending, setIsLoginWithProviderPending] = useState< const [isLoginWithGooglePending, setIsLoginWithGooglePending] =
null | SocialProvider useState(false);
>(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);
@@ -131,11 +33,6 @@ 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);
@@ -155,21 +52,18 @@ export function Auth({ setIsMagicLinkSent }: AuthProps) {
} }
}; };
const handleLoginWithProvider = async ( const handleLoginWithGoogle = async () => {
provider: SocialProvider) => { setIsLoginWithGooglePending(true);
setIsLoginWithProviderPending(provider);
setLoginError(null); setLoginError(null);
const { error } = await authClient.signIn.social({ const { error } = await authClient.signIn.social({
provider, provider: "google",
callbackURL: "/boards", callbackURL: "/boards",
}); });
setIsLoginWithProviderPending(null); setIsLoginWithGooglePending(false);
if (error) { if (error) {
setLoginError( setLoginError("Failed to login with Google. Please try again.");
`Failed to login with ${provider.at(0)?.toUpperCase() + provider.slice(1)}. Please try again.`,
);
} }
}; };
@@ -179,35 +73,23 @@ export function Auth({ setIsMagicLinkSent }: AuthProps) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{socialProviders?.length !== 0 && ( <div>
<div className="space-y-2">
{Object.entries(availableSocialProviders).map(([key, provider]) => {
if (!socialProviders?.includes(key)) {
return null;
}
return (
<Button <Button
onClick={() => handleLoginWithProvider(key as SocialProvider)} onClick={handleLoginWithGoogle}
isLoading={isLoginWithProviderPending === key} isLoading={isLoginWithGooglePending}
iconLeft={<provider.icon />} iconLeft={<FaGoogle />}
fullWidth fullWidth
size="lg" size="lg"
> >
Continue with {provider.name} Continue with Google
</Button> </Button>
)})}
</div> </div>
)}
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
{socialProviders?.length !== 0 && (
<div className="mb-[1.5rem] flex w-full items-center gap-4"> <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" /> <div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
<span className="text-sm text-light-900 dark:text-dark-900"> <span className="text-sm text-light-900 dark:text-dark-900">or</span>
or
</span>
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" /> <div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
</div> </div>
)}
<Input <Input
{...register("email", { required: true })} {...register("email", { required: true })}
placeholder="Enter your email address" placeholder="Enter your email address"

View File

@@ -3,7 +3,7 @@ import { useRouter } from "next/navigation";
import { Menu, Transition } from "@headlessui/react"; import { Menu, Transition } from "@headlessui/react";
import { Fragment } from "react"; import { Fragment } from "react";
import { authClient } from "@kan/auth/client"; import { authClient } from "@kan/auth";
import { useTheme } from "~/providers/theme"; import { useTheme } from "~/providers/theme";
import { getAvatarUrl } from "~/utils/helpers"; import { getAvatarUrl } from "~/utils/helpers";

View File

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

View File

@@ -2,8 +2,8 @@ import "~/styles/globals.css";
import type { AppType } from "next/app"; import type { AppType } from "next/app";
import { Plus_Jakarta_Sans } from "next/font/google"; import { Plus_Jakarta_Sans } from "next/font/google";
import { env } from "next-runtime-env";
import { env } from "~/env";
import { ModalProvider } from "~/providers/modal"; import { ModalProvider } from "~/providers/modal";
import { PopupProvider } from "~/providers/popup"; import { PopupProvider } from "~/providers/popup";
import { ThemeProvider } from "~/providers/theme"; import { ThemeProvider } from "~/providers/theme";
@@ -31,14 +31,13 @@ const MyApp: AppType = ({ Component, pageProps }) => {
position: relative; position: relative;
} }
`}</style> `}</style>
{env("NEXT_PUBLIC_UMAMI_ID") && ( {env.NEXT_PUBLIC_UMAMI_ID && (
<script <script
defer defer
src="https://cloud.umami.is/script.js" src="https://cloud.umami.is/script.js"
data-website-id={env("NEXT_PUBLIC_UMAMI_ID")} data-website-id={env.NEXT_PUBLIC_UMAMI_ID}
/> />
)} )}
<script src="/__ENV.js" />
<main className="font-sans"> <main className="font-sans">
<ThemeProvider> <ThemeProvider>
<ModalProvider> <ModalProvider>

View File

@@ -1,6 +1,6 @@
import { toNodeHandler } from "better-auth/node"; import { toNodeHandler } from "better-auth/node";
import { initAuth } from "@kan/auth/server"; import { initAuth } from "@kan/auth";
import { createDrizzleClient } from "@kan/db/client"; import { createDrizzleClient } from "@kan/db/client";
export const config = { api: { bodyParser: false } }; export const config = { api: { bodyParser: false } };

View File

@@ -1,5 +1,4 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { env } from "next-runtime-env";
import { createNextApiContext } from "@kan/api/trpc"; import { createNextApiContext } from "@kan/api/trpc";
import { createStripeClient } from "@kan/stripe"; import { createStripeClient } from "@kan/stripe";
@@ -23,7 +22,7 @@ export default async function handler(
const session = await stripe.billingPortal.sessions.create({ const session = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId, customer: user.stripeCustomerId,
return_url: `${env("NEXT_PUBLIC_BASE_URL")}/settings`, return_url: `${process.env.NEXT_PUBLIC_BASE_URL}/settings`,
}); });
return res.status(200).json({ url: session.url }); return res.status(200).json({ url: session.url });

View File

@@ -1,5 +1,4 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { env } from "next-runtime-env";
import { z } from "zod"; import { z } from "zod";
import { createNextApiContext } from "@kan/api/trpc"; import { createNextApiContext } from "@kan/api/trpc";
@@ -74,8 +73,8 @@ export default async function handler(
quantity: 1, quantity: 1,
}, },
], ],
success_url: `${env("NEXT_PUBLIC_BASE_URL")}${successUrl}`, success_url: `${process.env.NEXT_PUBLIC_BASE_URL}${successUrl}`,
cancel_url: `${env("NEXT_PUBLIC_BASE_URL")}${cancelUrl}`, cancel_url: `${process.env.NEXT_PUBLIC_BASE_URL}${cancelUrl}`,
customer: user.stripeCustomerId ?? undefined, customer: user.stripeCustomerId ?? undefined,
metadata: { metadata: {
workspaceSlug: slug, workspaceSlug: slug,

View File

@@ -1,14 +1,9 @@
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 as nextRuntimeEnv } from "next-runtime-env";
import { createNextApiContext } from "@kan/api/trpc";
import { env } from "~/env"; import { env } from "~/env";
const allowedContentTypes = ["image/jpeg", "image/png"];
export default async function handler( export default async function handler(
req: NextApiRequest, req: NextApiRequest,
res: NextApiResponse, res: NextApiResponse,
@@ -18,37 +13,14 @@ export default async function handler(
} }
try { try {
const { user } = await createNextApiContext(req); const { filename, contentType } = req.body;
if (!user) {
return res.status(401).json({ error: "Unauthorized" });
}
const { filename, contentType } = req.body as {
filename: string;
contentType: string;
};
// Specific to avatar uploads for now
const filenameRegex = /^[a-f0-9\-]+\/[a-zA-Z0-9_\-]+(\.jpg|\.jpeg|\.png)$/;
if (!filenameRegex.test(filename)) {
return res.status(400).json({ error: "Invalid filename" });
}
if (
typeof contentType !== "string" ||
!allowedContentTypes.includes(contentType)
) {
return res.status(400).json({ error: "Invalid content type" });
}
const client = new S3Client({ const client = new S3Client({
region: env.S3_REGION ?? "", region: env.S3_REGION,
endpoint: env.S3_ENDPOINT ?? "", endpoint: env.S3_ENDPOINT,
credentials: { credentials: {
accessKeyId: env.S3_ACCESS_KEY_ID ?? "", accessKeyId: env.S3_ACCESS_KEY_ID,
secretAccessKey: env.S3_SECRET_ACCESS_KEY ?? "", secretAccessKey: env.S3_SECRET_ACCESS_KEY,
}, },
}); });
@@ -56,7 +28,7 @@ export default async function handler(
// @ts-ignore // @ts-ignore
client, client,
new PutObjectCommand({ new PutObjectCommand({
Bucket: nextRuntimeEnv("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "", Bucket: process.env.NEXT_PUBLIC_AVATAR_BUCKET_NAME ?? "",
Key: filename, Key: filename,
}), }),
); );

View File

@@ -1,4 +1,4 @@
import { env } from "next-runtime-env"; import { env } from "~/env";
export const formatToArray = ( export const formatToArray = (
value: string | string[] | undefined, value: string | string[] | undefined,
@@ -45,10 +45,6 @@ export const formatMemberDisplayName = (
return localPart.replace(/[_-]/g, "."); return localPart.replace(/[_-]/g, ".");
}; };
export const getAvatarUrl = (imageOrKey: string) => { export const getAvatarUrl = (key: string) => {
if (imageOrKey.startsWith("http://") || imageOrKey.startsWith("https://")) { return `${env.NEXT_PUBLIC_STORAGE_URL}/${env.NEXT_PUBLIC_AVATAR_BUCKET_NAME}/${key}`;
return imageOrKey;
}
return `${env("NEXT_PUBLIC_STORAGE_URL")}/${env("NEXT_PUBLIC_AVATAR_BUCKET_NAME")}/${imageOrKey}`;
}; };

View File

@@ -1,14 +1,15 @@
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>("");
@@ -17,9 +18,15 @@ export default function LoginPage() {
setMagicLinkRecipient(recipient); setMagicLinkRecipient(recipient);
}; };
const { data } = authClient.useSession(); // const authCookieExists = document.cookie
// .split("; ")
// .some((cookie) => cookie.includes("auth-token"));
if (data?.user.id) router.push("/boards"); // const { data } = api.user.getUser.useQuery(undefined, {
// enabled: authCookieExists ? true : false,
// });
// if (data?.id) router.push("/boards");
return ( return (
<> <>

View File

@@ -1,16 +1,17 @@
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 {
HiEllipsisHorizontal, import { api } from "~/utils/api";
HiOutlinePlusSmall, import { useModal } from "~/providers/modal";
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;
@@ -79,20 +80,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="mb-2 flex justify-between"> <div className="flex justify-between">
<form <form
onSubmit={handleSubmit(onSubmit)} onSubmit={handleSubmit(onSubmit)}
className="w-full focus-visible:outline-none" className="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="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" 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"
/> />
</form> </form>
<div className="flex items-center"> <div>
<button <button
className="mx-1 inline-flex h-fit items-center rounded-md p-1 px-1 text-sm font-semibold text-dark-50 hover:bg-light-400 dark:hover:bg-dark-200" className="mx-1 inline-flex h-fit items-center rounded-md p-1 px-1 text-sm font-semibold text-dark-50 hover:bg-light-400 dark:hover:bg-dark-200"
onClick={() => openNewCardForm(list.publicId)} onClick={() => openNewCardForm(list.publicId)}

View File

@@ -63,7 +63,6 @@ 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,
@@ -111,29 +110,16 @@ 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: error.data?.zodError?.fieldErrors.title?.[0] ? message: "Please try again later, or contact customer support.",
`${error.data?.zodError?.fieldErrors.title?.[0].replace("String", "Title")}` :
"Please try again later, or contact customer support.",
icon: "error", icon: "error",
}); });
}, },
onSuccess: async () => { onSettled: 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,
});
}, },
}); });
@@ -178,6 +164,18 @@ 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,
@@ -389,7 +387,7 @@ export function NewCardForm({
/> />
<div> <div>
<Button type="submit" disabled={title.length === 0 || createCard.isPending}>Create card</Button> <Button type="submit">Create card</Button>
</div> </div>
</div> </div>
</form> </form>

View File

@@ -1,13 +1,11 @@
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 { HiCheck, HiXMark } from "react-icons/hi2"; import { 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";
@@ -51,7 +49,6 @@ 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: {
@@ -60,10 +57,6 @@ 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({
@@ -78,20 +71,6 @@ 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");
@@ -99,9 +78,6 @@ 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,
@@ -130,23 +106,14 @@ export function UpdateBoardSlugForm({
<Input <Input
id="board-slug" id="board-slug"
{...register("slug")} {...register("slug")}
errorMessage={errors.slug?.message || (isBoardSlugAvailable?.isReserved errorMessage={errors.slug?.message}
? "This board URL has already been taken" prefix={`kan.bn/${workspaceSlug}/`}
: 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">
@@ -157,8 +124,7 @@ export function UpdateBoardSlugForm({
disabled={ disabled={
!isDirty || !isDirty ||
updateBoardSlug.isPending || updateBoardSlug.isPending ||
errors.slug?.message !== undefined || errors.slug?.message !== undefined
isBoardSlugAvailable?.isReserved
} }
> >
Update Update

View File

@@ -240,12 +240,11 @@ 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 && !boardData && ( {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-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"
@@ -259,23 +258,20 @@ 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={!boardData} isLoading={isLoading}
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={!boardData} isLoading={isLoading}
/> />
<Button <Button
iconLeft={ iconLeft={
@@ -287,11 +283,11 @@ export default function BoardPage() {
onClick={() => { onClick={() => {
if (boardId) openNewListForm(boardId); if (boardId) openNewListForm(boardId);
}} }}
disabled={!boardData} disabled={isLoading}
> >
New list New list
</Button> </Button>
<BoardDropdown isLoading={!boardData} /> <BoardDropdown isLoading={isLoading} />
</div> </div>
</div> </div>
@@ -302,7 +298,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]">
@@ -374,7 +370,8 @@ 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 ${card.publicId.startsWith( className={`mb-2 flex !cursor-pointer flex-col ${
card.publicId.startsWith(
"PLACEHOLDER", "PLACEHOLDER",
) )
? "pointer-events-none" ? "pointer-events-none"
@@ -407,7 +404,7 @@ export default function BoardPage() {
</DragDropContext> </DragDropContext>
)} )}
</> </>
) : null} )}
</div> </div>
<Modal modalSize={modalContentType === "NEW_CARD" ? "md" : "sm"}> <Modal modalSize={modalContentType === "NEW_CARD" ? "md" : "sm"}>
{modalContentType === "DELETE_BOARD" && ( {modalContentType === "DELETE_BOARD" && (

View File

@@ -8,11 +8,11 @@ import {
HiOutlineUserMinus, HiOutlineUserMinus,
HiOutlineUserPlus, HiOutlineUserPlus,
} from "react-icons/hi2"; } from "react-icons/hi2";
import type { GetCardByIdOutput } from "@kan/api/types"; import type { GetCardByIdOutput } from "@kan/api/types";
import Avatar from "~/components/Avatar"; import Avatar from "~/components/Avatar";
import Comment from "./Comment"; import Comment from "./Comment";
import { authClient } from "@kan/auth/client";
type ActivityType = type ActivityType =
NonNullable<GetCardByIdOutput>["activities"][number]["type"]; NonNullable<GetCardByIdOutput>["activities"][number]["type"];
@@ -147,8 +147,6 @@ 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) => {
@@ -158,7 +156,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 === data?.user?.id, isSelf: activity.member?.user?.id === activity.user?.id,
label: activity.label?.name ?? null, label: activity.label?.name ?? null,
}); });
@@ -174,7 +172,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 === data?.user?.id} isAuthor={activity.comment?.createdBy === activity.user?.id}
isAdmin={isAdmin ?? false} isAdmin={isAdmin ?? false}
/> />
); );

View File

@@ -148,13 +148,12 @@ 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">
{!card && isLoading && ( {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]"
@@ -185,14 +184,7 @@ 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>
{card && (
<>
<div className="mb-10 flex w-full max-w-2xl justify-between"> <div className="mb-10 flex w-full max-w-2xl justify-between">
<form <form
onSubmit={handleSubmit(onSubmit)} onSubmit={handleSubmit(onSubmit)}
@@ -218,7 +210,7 @@ export default function CardPage() {
<ActivityList <ActivityList
cardPublicId={cardId} cardPublicId={cardId}
activities={activities ?? []} activities={activities ?? []}
isLoading={!card} isLoading={isLoading}
isAdmin={workspace.role === "admin"} isAdmin={workspace.role === "admin"}
/> />
</div> </div>
@@ -226,8 +218,6 @@ export default function CardPage() {
<NewCommentForm cardPublicId={cardId} /> <NewCommentForm cardPublicId={cardId} />
</div> </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">
@@ -236,7 +226,7 @@ export default function CardPage() {
<ListSelector <ListSelector
cardPublicId={cardId} cardPublicId={cardId}
lists={formattedLists} lists={formattedLists}
isLoading={!card} isLoading={isLoading}
/> />
</div> </div>
<div className="mb-4 flex w-full"> <div className="mb-4 flex w-full">
@@ -244,7 +234,7 @@ export default function CardPage() {
<LabelSelector <LabelSelector
cardPublicId={cardId} cardPublicId={cardId}
labels={formattedLabels} labels={formattedLabels}
isLoading={!card} isLoading={isLoading}
/> />
</div> </div>
<div className="flex w-full"> <div className="flex w-full">
@@ -252,7 +242,7 @@ export default function CardPage() {
<MemberSelector <MemberSelector
cardPublicId={cardId} cardPublicId={cardId}
members={formattedMembers} members={formattedMembers}
isLoading={!card} isLoading={isLoading}
/> />
</div> </div>
</div> </div>

View File

@@ -1,15 +1,22 @@
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 { data: session } = authClient.useSession(); const token =
typeof window !== "undefined" ? Cookies.get("kan.session_token") : null;
const isLoggedIn = !!session?.user; const { data } = api.user.getUser.useQuery(undefined, {
enabled: !!token,
});
const isLoggedIn = !!data;
const isDarkMode = theme.activeTheme === "dark"; const isDarkMode = theme.activeTheme === "dark";

View File

@@ -1,6 +1,6 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { IoLogoGithub, IoLogoHackernews } from "react-icons/io"; import { IoLogoGithub } 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,10 +21,8 @@ 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="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="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="gradient-border absolute inset-0 animate-border-spin" />
<div className="relative z-10 rounded-full bg-light-50 dark:bg-dark-50"> <div className="relative z-10 rounded-full bg-light-50 dark:bg-dark-50">
<Link <Link
href="https://github.com/kanbn/kan" href="https://github.com/kanbn/kan"
@@ -38,27 +36,6 @@ export default function HomeView() {
</div> </div>
</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>
<p className="mt-2 text-center text-4xl font-bold text-light-1000 dark:text-dark-1000 lg:text-5xl"> <p className="mt-2 text-center text-4xl font-bold text-light-1000 dark:text-dark-1000 lg:text-5xl">
The open source <br /> The open source <br />
alternative to Trello alternative to Trello

View File

@@ -1,7 +1,7 @@
import Image from "next/image"; import Image from "next/image";
import { env } from "next-runtime-env";
import { useState } from "react"; import { useState } from "react";
import { env } from "~/env";
import { usePopup } from "~/providers/popup"; import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
import { getAvatarUrl } from "~/utils/helpers"; import { getAvatarUrl } from "~/utils/helpers";
@@ -62,7 +62,7 @@ export default function Avatar({
setUploading(true); setUploading(true);
const response = await fetch( const response = await fetch(
env("NEXT_PUBLIC_BASE_URL") + "/api/upload/image", env.NEXT_PUBLIC_BASE_URL + "/api/upload/image",
{ {
method: "POST", method: "POST",
headers: { headers: {

View File

@@ -1,4 +1,4 @@
import { authClient } from "@kan/auth/client"; import { authClient } from "@kan/auth";
import Button from "~/components/Button"; import Button from "~/components/Button";
import Input from "~/components/Input"; import Input from "~/components/Input";

View File

@@ -15,19 +15,14 @@ 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: async () => { onSuccess: () => {
closeModal(); closeModal();
showPopup({ showPopup({
header: "Workspace deleted", header: "Workspace deleted",
message: "Your workspace has been deleted.", message: "Your workspace has been deleted.",
icon: "success", icon: "success",
}); });
await utils.workspace.all.refetch();
const filteredWorkspaces = availableWorkspaces.filter( const filteredWorkspaces = availableWorkspaces.filter(
(ws) => ws.publicId !== workspace.publicId, (ws) => ws.publicId !== workspace.publicId,
); );

View File

@@ -1,5 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { env } from "next-runtime-env";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { HiCheck, HiMiniStar } from "react-icons/hi2"; import { HiCheck, HiMiniStar } from "react-icons/hi2";
import { z } from "zod"; import { z } from "zod";
@@ -93,9 +92,7 @@ const UpdateWorkspaceUrlForm = ({
const isWorkspaceSlugAvailable = checkWorkspaceSlugAvailability.data; const isWorkspaceSlugAvailable = checkWorkspaceSlugAvailability.data;
const onSubmit = (data: FormValues) => { const onSubmit = (data: FormValues) => {
if (!isWorkspaceSlugAvailable?.isAvailable) return; if (isWorkspaceSlugAvailable?.isAvailable && workspacePlan !== "pro")
if (workspacePlan !== "pro" && env("NEXT_PUBLIC_KAN_ENV") === "cloud")
return openModal("UPDATE_WORKSPACE_URL", data.slug); return openModal("UPDATE_WORKSPACE_URL", data.slug);
updateWorkspaceSlug.mutate({ updateWorkspaceSlug.mutate({
@@ -121,11 +118,7 @@ const UpdateWorkspaceUrlForm = ({
? "This workspace username has already been taken" ? "This workspace username has already been taken"
: undefined) : undefined)
} }
prefix={ prefix="kan.bn/"
env("NEXT_PUBLIC_KAN_ENV") === "cloud"
? "kan.bn/"
: `${env("NEXT_PUBLIC_BASE_URL")}/`
}
iconRight={ iconRight={
isWorkspaceSlugAvailable?.isAvailable || isWorkspaceSlugAvailable?.isAvailable ||
(workspacePlan === "pro" && slug === workspaceUrl) ? ( (workspacePlan === "pro" && slug === workspaceUrl) ? (

View File

@@ -1,4 +1,3 @@
import { env } from "next-runtime-env";
import { HiMiniArrowTopRightOnSquare } from "react-icons/hi2"; import { HiMiniArrowTopRightOnSquare } from "react-icons/hi2";
import Button from "~/components/Button"; import Button from "~/components/Button";
@@ -96,7 +95,7 @@ export default function SettingsPage() {
/> />
</div> </div>
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" && ( {process.env.NEXT_PUBLIC_KAN_ENV === "cloud" && (
<div className="mb-8 border-t border-light-300 dark:border-dark-300"> <div className="mb-8 border-t border-light-300 dark:border-dark-300">
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000"> <h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
Billing Billing

View File

@@ -2,7 +2,6 @@ version: "3.7"
services: services:
web: web:
image: ghcr.io/kanbn/kan:latest
ports: ports:
- "3001:3000" - "3001:3000"
networks: networks:
@@ -10,15 +9,19 @@ services:
build: build:
context: . context: .
dockerfile: ./apps/web/Dockerfile dockerfile: ./apps/web/Dockerfile
args:
NEXT_PUBLIC_KAN_ENV: ${NEXT_PUBLIC_KAN_ENV}
NEXT_PUBLIC_UMAMI_ID: ${NEXT_PUBLIC_UMAMI_ID}
NEXT_PUBLIC_BASE_URL: ${NEXT_PUBLIC_BASE_URL}
NEXT_PUBLIC_STORAGE_URL: ${NEXT_PUBLIC_STORAGE_URL}
NEXT_PUBLIC_AVATAR_BUCKET_NAME: ${NEXT_PUBLIC_AVATAR_BUCKET_NAME}
env_file: env_file:
- .env - .env
command: ["pnpm", "start"] command: ["pnpm", "start"]
environment: environment:
- EMAIL_FROM=${EMAIL_FROM} - EMAIL_FROM=${EMAIL_FROM}
- SMTP_HOST=${SMTP_HOST} - EMAIL_URL=${EMAIL_URL}
- SMTP_PORT=${SMTP_PORT} - EMAIL_TOKEN=${EMAIL_TOKEN}
- 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}
@@ -27,41 +30,6 @@ 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}
@@ -72,6 +40,7 @@ services:
- NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME} - NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME}
- NEXT_PUBLIC_STORAGE_DOMAIN=${NEXT_PUBLIC_STORAGE_DOMAIN} - NEXT_PUBLIC_STORAGE_DOMAIN=${NEXT_PUBLIC_STORAGE_DOMAIN}
- NEXT_PUBLIC_UMAMI_ID=${NEXT_PUBLIC_UMAMI_ID} - NEXT_PUBLIC_UMAMI_ID=${NEXT_PUBLIC_UMAMI_ID}
networks: networks:
dokploy-network: dokploy-network:
external: true external: true

View File

@@ -1,4 +1,3 @@
import { env } from "next-runtime-env";
import { generateOpenApiDocument } from "trpc-to-openapi"; import { generateOpenApiDocument } from "trpc-to-openapi";
import { appRouter } from "./root"; import { appRouter } from "./root";
@@ -7,7 +6,7 @@ export const openApiDocument = generateOpenApiDocument(appRouter, {
title: "Kan API", title: "Kan API",
description: "OpenAPI compliant REST API", description: "OpenAPI compliant REST API",
version: "1.0.0", version: "1.0.0",
baseUrl: `${env("NEXT_PUBLIC_BASE_URL")}/api/v1`, baseUrl: `${process.env.NEXT_PUBLIC_BASE_URL}/api/v1`,
docsUrl: "docs.kan.bn", docsUrl: "docs.kan.bn",
tags: ["Auth", "Users", "Boards", "Lists", "Cards", "Labels", "Imports"], tags: ["Auth", "Users", "Boards", "Lists", "Cards", "Labels", "Imports"],
}); });

View File

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

View File

@@ -578,6 +578,14 @@ export const cardRouter = createTRPCRouter({
>(), >(),
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId)
throw new TRPCError({
message: `User not authenticated`,
code: "UNAUTHORIZED",
});
const card = await cardRepo.getWorkspaceAndCardIdByCardPublicId( const card = await cardRepo.getWorkspaceAndCardIdByCardPublicId(
ctx.db, ctx.db,
input.cardPublicId, input.cardPublicId,
@@ -589,17 +597,7 @@ export const cardRouter = createTRPCRouter({
code: "NOT_FOUND", code: "NOT_FOUND",
}); });
if (card.workspaceVisibility === "private") {
const userId = ctx.user?.id;
if (!userId)
throw new TRPCError({
message: `User not authenticated`,
code: "UNAUTHORIZED",
});
await assertUserInWorkspace(ctx.db, userId, card.workspaceId); await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
}
const result = await cardRepo.getWithListAndMembersByPublicId( const result = await cardRepo.getWithListAndMembersByPublicId(
ctx.db, ctx.db,

View File

@@ -1,7 +1,7 @@
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
import { authClient } from "@kan/auth/client"; import { authClient } from "@kan/auth";
import * as memberRepo from "@kan/db/repository/member.repo"; import * as memberRepo from "@kan/db/repository/member.repo";
import * as userRepo from "@kan/db/repository/user.repo"; import * as userRepo from "@kan/db/repository/user.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo"; import * as workspaceRepo from "@kan/db/repository/workspace.repo";

View File

@@ -1,5 +1,4 @@
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { env } from "next-runtime-env";
import { z } from "zod"; import { z } from "zod";
import * as workspaceRepo from "@kan/db/repository/workspace.repo"; import * as workspaceRepo from "@kan/db/repository/workspace.repo";
@@ -225,19 +224,10 @@ export const workspaceRouter = createTRPCRouter({
const isWorkspaceSlugAvailable = const isWorkspaceSlugAvailable =
await workspaceRepo.isWorkspaceSlugAvailable(ctx.db, input.slug); await workspaceRepo.isWorkspaceSlugAvailable(ctx.db, input.slug);
if (
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
workspace.plan !== "pro" &&
input.slug !== workspace.publicId
) {
throw new TRPCError({
message: `Workspace slug cannot be changed in cloud without upgrading to a paid plan`,
code: "FORBIDDEN",
});
}
if ( if (
reservedOrPremiumWorkspaceSlug?.type === "reserved" || reservedOrPremiumWorkspaceSlug?.type === "reserved" ||
(workspace.plan !== "pro" &&
reservedOrPremiumWorkspaceSlug?.type === "premium") ||
!isWorkspaceSlugAvailable !isWorkspaceSlugAvailable
) { ) {
throw new TRPCError({ throw new TRPCError({

View File

@@ -6,7 +6,7 @@ import superjson from "superjson";
import { ZodError } from "zod"; import { ZodError } from "zod";
import type { dbClient } from "@kan/db/client"; import type { dbClient } from "@kan/db/client";
import { initAuth } from "@kan/auth/server"; import { initAuth } from "@kan/auth";
import { createDrizzleClient } from "@kan/db/client"; import { createDrizzleClient } from "@kan/db/client";
export interface User { export interface User {

View File

@@ -4,15 +4,7 @@
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",
"exports": { "exports": {
".": "./src/index.ts", ".": "./src/index.ts"
"./client": {
"types": "./src/client.ts",
"default": "./src/client.ts"
},
"./server": {
"types": "./src/server.ts",
"default": "./src/server.ts"
}
}, },
"license": "GPL-3.0", "license": "GPL-3.0",
"scripts": { "scripts": {

View File

@@ -1,10 +1,8 @@
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 { createAuthEndpoint, createAuthMiddleware } from "better-auth/api"; import { 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 { 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";
@@ -12,89 +10,6 @@ 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({
@@ -110,7 +25,12 @@ export const initAuth = (db: dbClient) => {
user: schema.users, user: schema.users,
}, },
}), }),
socialProviders: configuredProviders, socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
},
},
user: { user: {
additionalFields: { additionalFields: {
stripeCustomerId: { stripeCustomerId: {
@@ -122,7 +42,6 @@ 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({
@@ -145,53 +64,13 @@ 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")) {
const user = ctx.context.session?.user; const user = ctx.context.session?.user;
if ( if (
env("NEXT_PUBLIC_KAN_ENV") === "cloud" && process.env.NEXT_PUBLIC_KAN_ENV === "cloud" &&
user && user &&
!user.stripeCustomerId !user.stripeCustomerId
) { ) {

View File

@@ -1,25 +0,0 @@
import { BetterAuthClientPlugin } from "better-auth";
import { apiKeyClient, magicLinkClient } from "better-auth/client/plugins";
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(), socialProvidersPluginClient],
});

View File

@@ -0,0 +1,6 @@
import { apiKeyClient, magicLinkClient } from "better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
plugins: [magicLinkClient(), apiKeyClient()],
});

View File

@@ -1 +1,6 @@
import { initAuth } from "./auth";
import { authClient } from "./clients";
export const name = "auth"; export const name = "auth";
export { initAuth, authClient };

View File

@@ -1 +0,0 @@
export { initAuth } from "./auth";

View File

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

View File

@@ -1,79 +0,0 @@
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,13 +22,6 @@
"when": 1748378293342, "when": 1748378293342,
"tag": "20250527203813_AddDeletedAtToLabel", "tag": "20250527203813_AddDeletedAtToLabel",
"breakpoints": true "breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1749118315675,
"tag": "20250605101155_AddCascadeDeleteToCardRelations",
"breakpoints": true
} }
] ]
} }

View File

@@ -1,4 +1,4 @@
import { and, asc, desc, eq, exists, inArray, isNull, or, sql } from "drizzle-orm"; import { and, asc, desc, eq, inArray, isNull, or } 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";
@@ -239,7 +239,6 @@ export const getBySlug = async (
workspace: { workspace: {
columns: { columns: {
publicId: true, publicId: true,
name: true,
slug: true, slug: true,
}, },
}, },
@@ -477,34 +476,3 @@ export const getWorkspaceAndBoardIdByBoardPublicId = async (
return result; return result;
}; };
export const isBoardSlugAvailable = async (
db: dbClient,
boardSlug: string,
boardPublicId: string,
) => {
const result = await db
.select({ id: boards.id })
.from(boards)
.where(
and(
eq(boards.publicId, boardPublicId),
exists(
db
.select({ id: boards.id })
.from(boards)
.where(
and(
eq(boards.slug, boardSlug),
eq(boards.workspaceId, sql`${boards.workspaceId}`), // Reference outer query's workspaceId
isNull(boards.deletedAt),
),
)
.limit(1),
),
),
)
.limit(1);
return result.length === 0;
};

View File

@@ -731,7 +731,6 @@ export const getWorkspaceAndCardIdByCardPublicId = async (
board: { board: {
columns: { columns: {
workspaceId: true, workspaceId: true,
visibility: true,
}, },
}, },
}, },
@@ -743,7 +742,6 @@ export const getWorkspaceAndCardIdByCardPublicId = async (
? { ? {
id: result.id, id: result.id,
workspaceId: result.list.board.workspaceId, workspaceId: result.list.board.workspaceId,
workspaceVisibility: result.list.board.visibility,
} }
: null; : null;
}; };

View File

@@ -90,23 +90,23 @@ export const cardActivities = pgTable("card_activity", {
fromIndex: integer("fromIndex"), fromIndex: integer("fromIndex"),
toIndex: integer("toIndex"), toIndex: integer("toIndex"),
fromListId: bigint("fromListId", { mode: "number" }).references( fromListId: bigint("fromListId", { mode: "number" }).references(
() => lists.id, { onDelete: "cascade" }, () => lists.id,
), ),
toListId: bigint("toListId", { mode: "number" }).references(() => lists.id, { onDelete: "cascade" }), toListId: bigint("toListId", { mode: "number" }).references(() => lists.id),
labelId: bigint("labelId", { mode: "number" }).references(() => labels.id, { onDelete: "cascade" }), labelId: bigint("labelId", { mode: "number" }).references(() => labels.id),
workspaceMemberId: bigint("workspaceMemberId", { workspaceMemberId: bigint("workspaceMemberId", {
mode: "number", mode: "number",
}).references(() => workspaceMembers.id, { onDelete: "cascade" }), }).references(() => workspaceMembers.id),
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, { onDelete: "cascade" }), .references(() => users.id),
createdAt: timestamp("createdAt").defaultNow().notNull(), createdAt: timestamp("createdAt").defaultNow().notNull(),
commentId: bigint("commentId", { mode: "number" }).references( commentId: bigint("commentId", { mode: "number" }).references(
() => comments.id, { onDelete: "cascade" }, () => comments.id,
), ),
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, { onDelete: "cascade" }), .references(() => cards.id),
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, { onDelete: "cascade" }), .references(() => cards.id),
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, { onDelete: "cascade" }), .references(() => users.id),
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, { onDelete: "cascade" }), deletedBy: uuid("deletedBy").references(() => users.id),
}).enableRLS(); }).enableRLS();
export const commentsRelations = relations(comments, ({ one }) => ({ export const commentsRelations = relations(comments, ({ one }) => ({

View File

@@ -24,14 +24,12 @@
}, },
"dependencies": { "dependencies": {
"@react-email/components": "^0.0.25", "@react-email/components": "^0.0.25",
"nodemailer": "^7.0.3",
"react-email": "^3.0.1" "react-email": "^3.0.1"
}, },
"devDependencies": { "devDependencies": {
"@kan/eslint-config": "workspace:*", "@kan/eslint-config": "workspace:*",
"@kan/prettier-config": "workspace:*", "@kan/prettier-config": "workspace:*",
"@kan/tsconfig": "workspace:*", "@kan/tsconfig": "workspace:*",
"@types/nodemailer": "^6.4.17",
"eslint": "catalog:", "eslint": "catalog:",
"prettier": "catalog:", "prettier": "catalog:",
"typescript": "catalog:" "typescript": "catalog:"

View File

@@ -1,5 +1,4 @@
import { render } from "@react-email/render"; import { render } from "@react-email/render";
import nodemailer from "nodemailer";
import JoinWorkspaceTemplate from "./templates/join-workspace"; import JoinWorkspaceTemplate from "./templates/join-workspace";
import MagicLinkTemplate from "./templates/magic-link"; import MagicLinkTemplate from "./templates/magic-link";
@@ -11,16 +10,6 @@ const emailTemplates: Record<Templates, React.FC> = {
JOIN_WORKSPACE: JoinWorkspaceTemplate, JOIN_WORKSPACE: JoinWorkspaceTemplate,
}; };
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT),
secure: true,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASSWORD,
},
});
export const sendEmail = async ( export const sendEmail = async (
to: string, to: string,
subject: string, subject: string,
@@ -31,17 +20,22 @@ export const sendEmail = async (
const html = await render(<EmailTemplate {...data} />, { pretty: true }); const html = await render(<EmailTemplate {...data} />, { pretty: true });
const options = { const response = await fetch(process.env.EMAIL_URL!, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.EMAIL_TOKEN}`,
},
body: JSON.stringify({
from: process.env.EMAIL_FROM, from: process.env.EMAIL_FROM,
to, to,
subject, subject,
html, html,
}; }),
});
const response = await transporter.sendMail(options); if (!response.ok) {
throw new Error(`Failed to send email: ${response.statusText}`);
if (!response.accepted.length) {
throw new Error(`Failed to send email: ${response.response}`);
} }
return response; return response;

View File

@@ -8,7 +8,6 @@ import { Html } from "@react-email/html";
import { Link } from "@react-email/link"; import { Link } from "@react-email/link";
import { Preview } from "@react-email/preview"; import { Preview } from "@react-email/preview";
import { Text } from "@react-email/text"; import { Text } from "@react-email/text";
import { env } from "next-runtime-env";
import * as React from "react"; import * as React from "react";
export const JoinWorkspaceTemplate = ({ export const JoinWorkspaceTemplate = ({
@@ -91,7 +90,7 @@ export const JoinWorkspaceTemplate = ({
/> />
<Text style={{ color: "#7e7e7e" }}> <Text style={{ color: "#7e7e7e" }}>
<Link <Link
href={env("NEXT_PUBLIC_BASE_URL")} href={process.env.NEXT_PUBLIC_BASE_URL}
target="_blank" target="_blank"
style={{ color: "#7e7e7e", textDecoration: "underline" }} style={{ color: "#7e7e7e", textDecoration: "underline" }}
> >

View File

@@ -8,7 +8,6 @@ import { Html } from "@react-email/html";
import { Link } from "@react-email/link"; import { Link } from "@react-email/link";
import { Preview } from "@react-email/preview"; import { Preview } from "@react-email/preview";
import { Text } from "@react-email/text"; import { Text } from "@react-email/text";
import { env } from "next-runtime-env";
import * as React from "react"; import * as React from "react";
export const MagicLinkTemplate = ({ export const MagicLinkTemplate = ({
@@ -91,7 +90,7 @@ export const MagicLinkTemplate = ({
/> />
<Text style={{ color: "#7e7e7e" }}> <Text style={{ color: "#7e7e7e" }}>
<Link <Link
href={env("NEXT_PUBLIC_BASE_URL")} href={process.env.NEXT_PUBLIC_BASE_URL}
target="_blank" target="_blank"
style={{ color: "#7e7e7e", textDecoration: "underline" }} style={{ color: "#7e7e7e", textDecoration: "underline" }}
> >

49
pnpm-lock.yaml generated
View File

@@ -126,15 +126,18 @@ 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)
next-logger: next-logger:
specifier: ^5.0.1 specifier: ^5.0.1
version: 5.0.1(next@14.2.20(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(pino@9.6.0) version: 5.0.1(next@14.2.20(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(pino@9.6.0)
next-runtime-env:
specifier: ^1.7.2
version: 1.8.0
nextjs-cors: nextjs-cors:
specifier: ^2.2.0 specifier: ^2.2.0
version: 2.2.0(next@14.2.20(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) version: 2.2.0(next@14.2.20(react-dom@18.3.1(react@18.3.1))(react@18.3.1))
@@ -187,6 +190,9 @@ 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
@@ -361,9 +367,6 @@ importers:
'@react-email/components': '@react-email/components':
specifier: ^0.0.25 specifier: ^0.0.25
version: 0.0.25(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 0.0.25(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
nodemailer:
specifier: ^7.0.3
version: 7.0.3
react-email: react-email:
specifier: ^3.0.1 specifier: ^3.0.1
version: 3.0.4(bufferutil@4.0.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(utf-8-validate@6.0.3) version: 3.0.4(bufferutil@4.0.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(utf-8-validate@6.0.3)
@@ -377,9 +380,6 @@ importers:
'@kan/tsconfig': '@kan/tsconfig':
specifier: workspace:* specifier: workspace:*
version: link:../../tooling/typescript version: link:../../tooling/typescript
'@types/nodemailer':
specifier: ^6.4.17
version: 6.4.17
eslint: eslint:
specifier: 'catalog:' specifier: 'catalog:'
version: 9.16.0(jiti@1.21.6) version: 9.16.0(jiti@1.21.6)
@@ -2315,6 +2315,9 @@ 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==}
@@ -2336,9 +2339,6 @@ packages:
'@types/node@20.17.9': '@types/node@20.17.9':
resolution: {integrity: sha512-0JOXkRyLanfGPE2QRCwgxhzlBAvaRdCNMcvbd7jFfpmD4eEXll7LRwy5ymJmyeZqk7Nh7eD2LeUyQ68BbndmXw==} resolution: {integrity: sha512-0JOXkRyLanfGPE2QRCwgxhzlBAvaRdCNMcvbd7jFfpmD4eEXll7LRwy5ymJmyeZqk7Nh7eD2LeUyQ68BbndmXw==}
'@types/nodemailer@6.4.17':
resolution: {integrity: sha512-I9CCaIp6DTldEg7vyUTZi8+9Vo0hi1/T8gv3C89yk1rSAAzoKQ8H8ki/jBYJSFoH/BisgLP8tkZMlQ91CIquww==}
'@types/pg@8.11.6': '@types/pg@8.11.6':
resolution: {integrity: sha512-/2WmmBXHLsfRqzfHW7BNZ8SbYzE8OSk7i3WjFYvfgRHj7S1xj+16Je5fUKv3lVdVzk/zn9TXOqf+avFCFIE0yQ==} resolution: {integrity: sha512-/2WmmBXHLsfRqzfHW7BNZ8SbYzE8OSk7i3WjFYvfgRHj7S1xj+16Je5fUKv3lVdVzk/zn9TXOqf+avFCFIE0yQ==}
@@ -4054,6 +4054,10 @@ 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
@@ -4457,9 +4461,6 @@ packages:
winston: winston:
optional: true optional: true
next-runtime-env@1.8.0:
resolution: {integrity: sha512-QJVxzmr2gTao/vZKFgcrByFrifl0YOMTvuUVxcI/X7ratlW+9zMvMmA9AGU9cFsumXJtohWkCvwPdBNdkTCYfw==}
next@14.2.20: next@14.2.20:
resolution: {integrity: sha512-yPvIiWsiyVYqJlSQxwmzMIReXn5HxFNq4+tlVQ812N1FbvhmE+fDpIAD7bcS2mGYQwPJ5vAsQouyme2eKsxaug==} resolution: {integrity: sha512-yPvIiWsiyVYqJlSQxwmzMIReXn5HxFNq4+tlVQ812N1FbvhmE+fDpIAD7bcS2mGYQwPJ5vAsQouyme2eKsxaug==}
engines: {node: '>=18.17.0'} engines: {node: '>=18.17.0'}
@@ -4530,10 +4531,6 @@ packages:
node-releases@2.0.19: node-releases@2.0.19:
resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==}
nodemailer@7.0.3:
resolution: {integrity: sha512-Ajq6Sz1x7cIK3pN6KesGTah+1gnwMnx5gKl3piQlQQE/PwyJ4Mbc8is2psWYxK3RJTVeqsDaCv8ZzXLCDHMTZw==}
engines: {node: '>=6.0.0'}
nopt@7.2.1: nopt@7.2.1:
resolution: {integrity: sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==} resolution: {integrity: sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
@@ -8072,6 +8069,8 @@ 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': {}
@@ -8090,10 +8089,6 @@ snapshots:
dependencies: dependencies:
undici-types: 6.19.8 undici-types: 6.19.8
'@types/nodemailer@6.4.17':
dependencies:
'@types/node': 20.17.9
'@types/pg@8.11.6': '@types/pg@8.11.6':
dependencies: dependencies:
'@types/node': 20.17.9 '@types/node': 20.17.9
@@ -10056,6 +10051,8 @@ 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
@@ -10668,10 +10665,6 @@ snapshots:
optionalDependencies: optionalDependencies:
pino: 9.6.0 pino: 9.6.0
next-runtime-env@1.8.0:
dependencies:
chalk: 4.1.2
next@14.2.20(react-dom@18.3.1(react@18.3.1))(react@18.3.1): next@14.2.20(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies: dependencies:
'@next/env': 14.2.20 '@next/env': 14.2.20
@@ -10756,8 +10749,6 @@ snapshots:
node-releases@2.0.19: {} node-releases@2.0.19: {}
nodemailer@7.0.3: {}
nopt@7.2.1: nopt@7.2.1:
dependencies: dependencies:
abbrev: 2.0.0 abbrev: 2.0.0

View File

@@ -3,49 +3,28 @@
"ui": "tui", "ui": "tui",
"tasks": { "tasks": {
"topo": { "topo": {
"dependsOn": [ "dependsOn": ["^topo"]
"^topo"
]
}, },
"build": { "build": {
"dependsOn": [ "dependsOn": ["^build"],
"^build" "outputs": [".cache/tsbuildinfo.json", "dist/**"]
],
"outputs": [
".cache/tsbuildinfo.json",
"dist/**"
]
}, },
"dev": { "dev": {
"dependsOn": [ "dependsOn": ["^dev"],
"^dev"
],
"cache": false, "cache": false,
"persistent": false "persistent": false
}, },
"format": { "format": {
"outputs": [ "outputs": [".cache/.prettiercache"],
".cache/.prettiercache"
],
"outputLogs": "new-only" "outputLogs": "new-only"
}, },
"lint": { "lint": {
"dependsOn": [ "dependsOn": ["^topo", "^build"],
"^topo", "outputs": [".cache/.eslintcache"]
"^build"
],
"outputs": [
".cache/.eslintcache"
]
}, },
"typecheck": { "typecheck": {
"dependsOn": [ "dependsOn": ["^topo", "^build"],
"^topo", "outputs": [".cache/tsbuildinfo.json"]
"^build"
],
"outputs": [
".cache/tsbuildinfo.json"
]
}, },
"clean": { "clean": {
"cache": false "cache": false
@@ -70,46 +49,9 @@
"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", "EMAIL_URL",
"SMTP_PORT", "EMAIL_TOKEN",
"SMTP_USER",
"SMTP_PASSWORD",
"NEXT_PUBLIC_KAN_ENV", "NEXT_PUBLIC_KAN_ENV",
"STRIPE_SECRET_KEY", "STRIPE_SECRET_KEY",
"STRIPE_WEBHOOK_SECRET", "STRIPE_WEBHOOK_SECRET",