Compare commits

..

20 Commits

Author SHA1 Message Date
Henry
808d23bd15 fix: add google to the remote patterns 2025-06-05 12:58:01 +01:00
Lonneke Vuur
1b2389d618 fix: issue 23, handle external URLs in avatar rendering 2025-06-05 10:28:26 +02:00
LovelessCodes
c9a9779e79 feat: include column name in board repository query response to use in board PageHead (#20) 2025-06-05 06:08:20 +01:00
LovelessCodes
356e35964b feat: allow public card access without authentication (#19) 2025-06-05 06:05:03 +01:00
Henry
1698b744e4 chore: fix labels on issue templates 2025-06-05 05:41:21 +01:00
Henry
27c0c0a5e8 chore: update issue templates 2025-06-05 05:37:31 +01:00
Henry
2129a60d22 chore: setup github issue templates 2025-06-05 05:22:22 +01:00
Henry
84dd9a9b6d feat: support SMTP via nodemailer (#17) 2025-06-04 12:33:24 +01:00
Henry
3625d702e3 fix: bypass custom URL checkout for self hosted (#16)
* fix: bypass custom URL checkout for self hosted

* Update error code
2025-06-04 07:34:19 +01:00
Sergio Guillen
2047f5a9ea Add step to run db:migrate as it is a required step to have a functional local setup (#14) 2025-06-04 06:04:18 +01:00
Henry
fcb97908e0 fix: revert to GH token 2025-06-03 23:12:04 +01:00
Henry
348d81a705 fix: use PAT instead of github token 2025-06-03 22:44:53 +01:00
Henry
cef670dc11 fix: update build context to root 2025-06-03 21:48:17 +01:00
Henry
23ae7a375b feat: add docker-publish.yml 2025-06-03 21:35:51 +01:00
Henry
0b06f80181 feat: remove NEXT_PUBLIC env vars from docker build args (#13)
* feat: add next public envs to runtime

* Commit env vars
2025-06-03 21:25:57 +01:00
Krisztiaan
37d2ebccf7 fix: modal forms not submitting on Enter key (#9)
* 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

* fix: submit forms on enter key submission

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
2025-06-03 16:07:12 +01:00
Henry
1027b45cdb feat: update image upload API with user authentication and validation 2025-06-03 13:45:26 +01:00
Henry
f590937ddb chore: add env variable table to readme 2025-06-03 13:07:13 +01:00
Tiago Manuel
a29a2f6e16 Update .env.example (#4)
remove double S3_ENDPOINT entry
2025-06-02 16:20:10 +01:00
Henry
7e7ba2c0da chore: add screenshot to README 2025-06-02 15:22:59 +01:00
53 changed files with 506 additions and 96 deletions

View File

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

View File

@@ -1,8 +1,10 @@
POSTGRES_URL=
EMAIL_FROM=
EMAIL_URL=
EMAIL_TOKEN=
SMTP_HOST=
SMTP_PORT=
SMTP_USER=
SMTP_PASSWORD=
NEXT_PUBLIC_BASE_URL=
NEXT_PUBLIC_STORAGE_URL=
@@ -10,7 +12,6 @@ NEXT_PUBLIC_AVATAR_BUCKET_NAME=
S3_REGION=
S3_ENDPOINT=
S3_ENDPOINT=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=

37
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,37 @@
---
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

@@ -0,0 +1,30 @@
---
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

97
.github/workflows/docker-publish.yml vendored Normal file
View File

@@ -0,0 +1,97 @@
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 }}
# 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' }}
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,3 +43,6 @@ dist/
# turbo
.turbo
# docker
docker-compose.override.yml

View File

@@ -32,6 +32,10 @@
See our [roadmap](https://kan.bn/kan/roadmap) for upcoming features.
## Screenshot 👁️
<img width="1507" alt="hero-dark" src="https://github.com/user-attachments/assets/5f7b6ad3-f31d-4b45-93dc-0132b3f2afd4" />
## Made With 🛠️
- [Next.js](https://nextjs.org/?ref=kan.bn)
@@ -41,7 +45,7 @@ See our [roadmap](https://kan.bn/kan/roadmap) for upcoming features.
- [Drizzle ORM](https://orm.drizzle.team/?ref=kan.bn)
- [React Email](https://react.email/?ref=kan.bn)
## Local Development 🛠️
## Local Development 🧑‍💻
1. Clone the repository (or fork)
@@ -56,12 +60,44 @@ pnpm install
```
3. Copy `.env.example` to `.env` and configure your environment variables
4. Start the development server
4. Migrate database
```bash
pnpm db:migrate
```
5. Start the development server
```bash
pnpm dev
```
## Environment Variables 🔐
| Variable | Description | Required | Example |
| -------------------------------- | ----------------------------- | ---------------- | --------------------------------------------- |
| `POSTGRES_URL` | PostgreSQL connection URL | Yes | `postgres://user:pass@localhost:5432/db` |
| `EMAIL_FROM` | Sender email address | Yes | `"Kan <hello@mail.kan.bn>"` |
| `SMTP_HOST` | SMTP server hostname | Yes | `smtp.resend.com` |
| `SMTP_PORT` | SMTP server port | Yes | `465` |
| `SMTP_USER` | SMTP username/email | Yes | `resend` |
| `SMTP_PASSWORD` | SMTP password/token | Yes | `re_xxxx` |
| `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` |
| `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
| `BETTER_AUTH_URL` | Auth callback URL | Yes | Same as `NEXT_PUBLIC_BASE_URL` |
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | For Google login | `http://localhost:3000,http://localhost:3001` |
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` |
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` |
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads | `xxx` |
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads | `xxx` |
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` |
| `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` |
See `.env.example` for a complete list of supported environment variables.
## Contributing 🤝
We welcome contributions! Please read our [contribution guidelines](CONTRIBUTING.md) before submitting a pull request.

View File

@@ -42,13 +42,6 @@ ENV CI=true
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 --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
COPY --from=pruner /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml

View File

@@ -1,9 +1,13 @@
import { fileURLToPath } from "url";
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.
createJiti(fileURLToPath(import.meta.url))("./src/env");
configureRuntimeEnv();
/** @type {import("next").NextConfig} */
const config = {
reactStrictMode: true,
@@ -22,7 +26,16 @@ const config = {
typescript: { ignoreBuildErrors: true },
images: {
domains: [process.env.NEXT_PUBLIC_STORAGE_DOMAIN ?? ""],
remotePatterns: [
{
protocol: "https",
hostname: `*.${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`,
},
{
protocol: "https",
hostname: "*.googleusercontent.com",
},
],
},
experimental: {
instrumentationHook: true,

View File

@@ -35,6 +35,7 @@
"jwt-decode": "^4.0.0",
"next": "^14.2.15",
"next-logger": "^5.0.1",
"next-runtime-env": "^1.7.2",
"nextjs-cors": "^2.2.0",
"pino": "^9.6.0",
"react": "catalog:react18",

1
apps/web/public/__ENV.js Normal file
View File

@@ -0,0 +1 @@
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

@@ -4,7 +4,7 @@ import { useForm } from "react-hook-form";
import { FaGoogle } from "react-icons/fa";
import { z } from "zod";
import { authClient } from "@kan/auth";
import { authClient } from "@kan/auth/client";
import Button from "~/components/Button";
import Input from "~/components/Input";

View File

@@ -98,12 +98,16 @@ const FeedbackButton: React.FC = () => {
onChange={(e) => {
setValue("feedback", e.target.value);
}}
onKeyDown={(e) => {
e.stopPropagation();
}}
value={watch("feedback")}
contentEditable
className="max-h-[300px] min-h-[100px]"
onKeyDown={async (e) => {
e.stopPropagation();
if (e.key === "Enter" && e.shiftKey) {
e.preventDefault();
await handleSubmit(onSubmit)();
}
}}
/>
<div className="flex flex-row items-center justify-between pt-2">
<div>

View File

@@ -69,6 +69,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
prefix && "rounded-l-none",
className && className,
)}
onKeyDown={onKeyDown}
{...props}
/>
{type === "password" && (

View File

@@ -1,5 +1,5 @@
import { Listbox, Transition } from "@headlessui/react";
import { Fragment } from "react";
import { Fragment, useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { HiChevronUpDown, HiXMark } from "react-icons/hi2";
@@ -103,6 +103,12 @@ export function LabelForm({
}
};
useEffect(() => {
const nameElement: HTMLElement | null =
document.querySelector<HTMLElement>("#label-name");
if (nameElement) nameElement.focus();
}, []);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="px-5 pt-5">
@@ -111,6 +117,7 @@ export function LabelForm({
{isEdit ? "Edit label" : "New label"}
</h2>
<button
type="button"
className="rounded p-1 hover:bg-light-300 focus:outline-none dark:hover:bg-dark-300"
onClick={(e) => {
e.preventDefault();
@@ -121,7 +128,17 @@ export function LabelForm({
</button>
</div>
<Input id="label-name" placeholder="Name" {...register("name")} />
<Input
id="label-name"
placeholder="Name"
{...register("name")}
onKeyDown={async (e) => {
if (e.key === "Enter") {
e.preventDefault();
await handleSubmit(onSubmit)();
}
}}
/>
<Controller
name="colour"
control={control}

View File

@@ -61,6 +61,7 @@ export function NewWorkspaceForm() {
New workspace
</h2>
<button
type="button"
className="rounded p-1 hover:bg-light-200 focus:outline-none dark:hover:bg-dark-300"
onClick={(e) => {
e.preventDefault();
@@ -75,6 +76,12 @@ export function NewWorkspaceForm() {
id="workspace-name"
placeholder="Workspace name"
{...register("name")}
onKeyDown={async (e) => {
if (e.key === "Enter") {
e.preventDefault();
await handleSubmit(onSubmit)();
}
}}
/>
</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">

View File

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

View File

@@ -2,8 +2,8 @@ import "~/styles/globals.css";
import type { AppType } from "next/app";
import { Plus_Jakarta_Sans } from "next/font/google";
import { env } from "next-runtime-env";
import { env } from "~/env";
import { ModalProvider } from "~/providers/modal";
import { PopupProvider } from "~/providers/popup";
import { ThemeProvider } from "~/providers/theme";
@@ -31,13 +31,14 @@ const MyApp: AppType = ({ Component, pageProps }) => {
position: relative;
}
`}</style>
{env.NEXT_PUBLIC_UMAMI_ID && (
{env("NEXT_PUBLIC_UMAMI_ID") && (
<script
defer
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">
<ThemeProvider>
<ModalProvider>

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,11 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { env } from "next-runtime-env";
import { createNextApiContext } from "@kan/api/trpc";
import { env } from "~/env";
const allowedContentTypes = ["image/jpeg", "image/png"];
export default async function handler(
req: NextApiRequest,
@@ -13,14 +16,37 @@ export default async function handler(
}
try {
const { filename, contentType } = req.body;
const { user } = await createNextApiContext(req);
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({
region: env.S3_REGION,
endpoint: env.S3_ENDPOINT,
region: env.S3_REGION ?? "",
endpoint: env.S3_ENDPOINT ?? "",
credentials: {
accessKeyId: env.S3_ACCESS_KEY_ID,
secretAccessKey: env.S3_SECRET_ACCESS_KEY,
accessKeyId: env.S3_ACCESS_KEY_ID ?? "",
secretAccessKey: env.S3_SECRET_ACCESS_KEY ?? "",
},
});
@@ -28,7 +54,7 @@ export default async function handler(
// @ts-ignore
client,
new PutObjectCommand({
Bucket: process.env.NEXT_PUBLIC_AVATAR_BUCKET_NAME ?? "",
Bucket: env("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "",
Key: filename,
}),
);

View File

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

View File

@@ -226,6 +226,7 @@ export function NewCardForm({
New card
</h2>
<button
type="button"
className="rounded p-1 hover:bg-light-200 focus:outline-none dark:hover:bg-dark-300"
onClick={(e) => {
closeModal();
@@ -237,7 +238,17 @@ export function NewCardForm({
</div>
<div>
<Input id="title" placeholder="Card title" {...register("title")} />
<Input
id="title"
placeholder="Card title"
{...register("title")}
onKeyDown={async (e) => {
if (e.key === "Enter") {
e.preventDefault();
await handleSubmit(onSubmit)();
}
}}
/>
</div>
<div className="mt-2">
<Input
@@ -245,6 +256,12 @@ export function NewCardForm({
onChange={(e) => setValue("description", e.target.value)}
value={watch("description")}
contentEditable
onKeyDown={async (e) => {
if (e.key === "Enter" && e.shiftKey) {
e.preventDefault();
await handleSubmit(onSubmit)();
}
}}
/>
</div>
<div className="mt-2 flex space-x-1">

View File

@@ -111,6 +111,7 @@ export function NewListForm({
New list
</h2>
<button
type="button"
className="rounded p-1 hover:bg-light-200 focus:outline-none dark:hover:bg-dark-300"
onClick={(e) => {
e.preventDefault();
@@ -121,7 +122,17 @@ export function NewListForm({
</button>
</div>
<Input id="list-name" placeholder="List name" {...register("name")} />
<Input
id="list-name"
placeholder="List name"
{...register("name")}
onKeyDown={async (e) => {
if (e.key === "Enter") {
e.preventDefault();
await handleSubmit(onSubmit)();
}
}}
/>
</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">
<Toggle

View File

@@ -92,6 +92,7 @@ export function UpdateBoardSlugForm({
Edit board URL
</h2>
<button
type="button"
className="rounded p-1 hover:bg-light-200 focus:outline-none dark:hover:bg-dark-300"
onClick={(e) => {
e.preventDefault();
@@ -107,6 +108,12 @@ export function UpdateBoardSlugForm({
{...register("slug")}
errorMessage={errors.slug?.message}
prefix={`kan.bn/${workspaceSlug}/`}
onKeyDown={async (e) => {
if (e.key === "Enter") {
e.preventDefault();
await handleSubmit(onSubmit)();
}
}}
/>
</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">

View File

@@ -251,6 +251,7 @@ export function ImportBoardsForm() {
</div>
<button
type="button"
className="rounded p-1 hover:bg-light-200 dark:hover:bg-dark-300"
onClick={() => closeModal()}
>

View File

@@ -1,9 +1,11 @@
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { HiXMark } from "react-icons/hi2";
import type { NewBoardInput } from "@kan/api/types";
import Button from "~/components/Button";
import Input from "~/components/Input";
import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
@@ -33,12 +35,19 @@ export function NewBoardForm() {
createBoard.mutate(data);
};
useEffect(() => {
const titleElement: HTMLElement | null =
document.querySelector<HTMLElement>("#name");
if (titleElement) titleElement.focus();
}, []);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="px-5 pt-5">
<div className="text-neutral-9000 flex w-full items-center justify-between pb-4 dark:text-dark-1000">
<h2 className="text-sm font-bold">New board</h2>
<button
type="button"
className="hover:bg-li ght-300 rounded p-1 focus:outline-none dark:hover:bg-dark-300"
onClick={(e) => {
e.preventDefault();
@@ -48,11 +57,16 @@ export function NewBoardForm() {
<HiXMark size={18} className="dark:text-dark-9000 text-light-900" />
</button>
</div>
<input
<Input
id="name"
placeholder="Name"
{...register("name", { required: true })}
className="block w-full rounded-md border-0 bg-white/5 py-1.5 text-neutral-900 placeholder-dark-800 shadow-sm ring-1 ring-inset ring-light-600 focus:ring-2 focus:ring-inset focus:ring-light-600 dark:bg-dark-300 dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:text-sm sm:leading-6"
onKeyDown={async (e) => {
if (e.key === "Enter") {
e.preventDefault();
await handleSubmit(onSubmit)();
}
}}
/>
</div>

View File

@@ -58,6 +58,12 @@ const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
disabled={false}
onChange={(e) => setValue("comment", e.target.value)}
className="block w-full border-0 bg-transparent py-1.5 text-light-900 focus-visible:outline-none dark:text-dark-1000 sm:text-sm sm:leading-6"
onKeyDown={async (e) => {
if (e.key === "Enter" && e.shiftKey) {
e.preventDefault();
await handleSubmit(onSubmit)();
}
}}
/>
<div className="flex justify-end">
<button

View File

@@ -1,9 +1,11 @@
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { HiXMark } from "react-icons/hi2";
import type { InviteMemberInput } from "@kan/api/types";
import Button from "~/components/Button";
import Input from "~/components/Input";
import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
@@ -34,12 +36,19 @@ export function InviteMemberForm() {
createBoard.mutate(data);
};
useEffect(() => {
const emailElement: HTMLElement | null =
document.querySelector<HTMLElement>("#email");
if (emailElement) emailElement.focus();
}, []);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="px-5 pt-5">
<div className="text-neutral-9000 flex w-full items-center justify-between pb-4 dark:text-dark-1000">
<h2 className="text-sm font-bold">Add member</h2>
<button
type="button"
className="hover:bg-li ght-300 rounded p-1 focus:outline-none dark:hover:bg-dark-300"
onClick={(e) => {
e.preventDefault();
@@ -49,11 +58,16 @@ export function InviteMemberForm() {
<HiXMark size={18} className="dark:text-dark-9000 text-light-900" />
</button>
</div>
<input
<Input
id="email"
placeholder="Email"
{...register("email", { required: true })}
className="block w-full rounded-md border-0 bg-white/5 py-1.5 text-neutral-900 placeholder-dark-800 shadow-sm ring-1 ring-inset ring-light-600 focus:ring-2 focus:ring-inset focus:ring-light-600 dark:bg-dark-300 dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:text-sm sm:leading-6"
onKeyDown={async (e) => {
if (e.key === "Enter") {
e.preventDefault();
await handleSubmit(onSubmit)();
}
}}
/>
</div>

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
import { env } from "next-runtime-env";
import { HiMiniArrowTopRightOnSquare } from "react-icons/hi2";
import Button from "~/components/Button";
@@ -95,7 +96,7 @@ export default function SettingsPage() {
/>
</div>
{process.env.NEXT_PUBLIC_KAN_ENV === "cloud" && (
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" && (
<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">
Billing

View File

@@ -2,6 +2,7 @@ version: "3.7"
services:
web:
image: ghcr.io/kanbn/kan:latest
ports:
- "3001:3000"
networks:
@@ -9,12 +10,6 @@ services:
build:
context: .
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
command: ["pnpm", "start"]
@@ -40,7 +35,6 @@ services:
- NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME}
- NEXT_PUBLIC_STORAGE_DOMAIN=${NEXT_PUBLIC_STORAGE_DOMAIN}
- NEXT_PUBLIC_UMAMI_ID=${NEXT_PUBLIC_UMAMI_ID}
networks:
dokploy-network:
external: true

View File

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

View File

@@ -578,14 +578,6 @@ export const cardRouter = createTRPCRouter({
>(),
)
.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(
ctx.db,
input.cardPublicId,
@@ -597,7 +589,17 @@ export const cardRouter = createTRPCRouter({
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
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);
}
const result = await cardRepo.getWithListAndMembersByPublicId(
ctx.db,

View File

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

View File

@@ -1,4 +1,5 @@
import { TRPCError } from "@trpc/server";
import { env } from "next-runtime-env";
import { z } from "zod";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
@@ -224,10 +225,19 @@ export const workspaceRouter = createTRPCRouter({
const isWorkspaceSlugAvailable =
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 (
reservedOrPremiumWorkspaceSlug?.type === "reserved" ||
(workspace.plan !== "pro" &&
reservedOrPremiumWorkspaceSlug?.type === "premium") ||
!isWorkspaceSlugAvailable
) {
throw new TRPCError({

View File

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

View File

@@ -4,7 +4,15 @@
"version": "0.1.0",
"type": "module",
"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",
"scripts": {

View File

@@ -3,6 +3,7 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { createAuthMiddleware } from "better-auth/api";
import { apiKey } from "better-auth/plugins";
import { magicLink } from "better-auth/plugins/magic-link";
import { env } from "next-runtime-env";
import type { dbClient } from "@kan/db/client";
import * as memberRepo from "@kan/db/repository/member.repo";
@@ -70,7 +71,7 @@ export const initAuth = (db: dbClient) => {
const user = ctx.context.session?.user;
if (
process.env.NEXT_PUBLIC_KAN_ENV === "cloud" &&
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
user &&
!user.stripeCustomerId
) {

View File

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

View File

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

View File

@@ -239,6 +239,7 @@ export const getBySlug = async (
workspace: {
columns: {
publicId: true,
name: true,
slug: true,
},
},

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import { render } from "@react-email/render";
import nodemailer from "nodemailer";
import JoinWorkspaceTemplate from "./templates/join-workspace";
import MagicLinkTemplate from "./templates/magic-link";
@@ -10,6 +11,16 @@ const emailTemplates: Record<Templates, React.FC> = {
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 (
to: string,
subject: string,
@@ -20,22 +31,17 @@ export const sendEmail = async (
const html = await render(<EmailTemplate {...data} />, { pretty: true });
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,
to,
subject,
html,
}),
});
const options = {
from: process.env.EMAIL_FROM,
to,
subject,
html,
};
if (!response.ok) {
throw new Error(`Failed to send email: ${response.statusText}`);
const response = await transporter.sendMail(options);
if (!response.accepted.length) {
throw new Error(`Failed to send email: ${response.response}`);
}
return response;

View File

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

View File

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

29
pnpm-lock.yaml generated
View File

@@ -138,6 +138,9 @@ importers:
next-logger:
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)
next-runtime-env:
specifier: ^1.7.2
version: 1.8.0
nextjs-cors:
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))
@@ -367,6 +370,9 @@ importers:
'@react-email/components':
specifier: ^0.0.25
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:
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)
@@ -380,6 +386,9 @@ importers:
'@kan/tsconfig':
specifier: workspace:*
version: link:../../tooling/typescript
'@types/nodemailer':
specifier: ^6.4.17
version: 6.4.17
eslint:
specifier: 'catalog:'
version: 9.16.0(jiti@1.21.6)
@@ -2339,6 +2348,9 @@ packages:
'@types/node@20.17.9':
resolution: {integrity: sha512-0JOXkRyLanfGPE2QRCwgxhzlBAvaRdCNMcvbd7jFfpmD4eEXll7LRwy5ymJmyeZqk7Nh7eD2LeUyQ68BbndmXw==}
'@types/nodemailer@6.4.17':
resolution: {integrity: sha512-I9CCaIp6DTldEg7vyUTZi8+9Vo0hi1/T8gv3C89yk1rSAAzoKQ8H8ki/jBYJSFoH/BisgLP8tkZMlQ91CIquww==}
'@types/pg@8.11.6':
resolution: {integrity: sha512-/2WmmBXHLsfRqzfHW7BNZ8SbYzE8OSk7i3WjFYvfgRHj7S1xj+16Je5fUKv3lVdVzk/zn9TXOqf+avFCFIE0yQ==}
@@ -4461,6 +4473,9 @@ packages:
winston:
optional: true
next-runtime-env@1.8.0:
resolution: {integrity: sha512-QJVxzmr2gTao/vZKFgcrByFrifl0YOMTvuUVxcI/X7ratlW+9zMvMmA9AGU9cFsumXJtohWkCvwPdBNdkTCYfw==}
next@14.2.20:
resolution: {integrity: sha512-yPvIiWsiyVYqJlSQxwmzMIReXn5HxFNq4+tlVQ812N1FbvhmE+fDpIAD7bcS2mGYQwPJ5vAsQouyme2eKsxaug==}
engines: {node: '>=18.17.0'}
@@ -4531,6 +4546,10 @@ packages:
node-releases@2.0.19:
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:
resolution: {integrity: sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
@@ -8089,6 +8108,10 @@ snapshots:
dependencies:
undici-types: 6.19.8
'@types/nodemailer@6.4.17':
dependencies:
'@types/node': 20.17.9
'@types/pg@8.11.6':
dependencies:
'@types/node': 20.17.9
@@ -10665,6 +10688,10 @@ snapshots:
optionalDependencies:
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):
dependencies:
'@next/env': 14.2.20
@@ -10749,6 +10776,8 @@ snapshots:
node-releases@2.0.19: {}
nodemailer@7.0.3: {}
nopt@7.2.1:
dependencies:
abbrev: 2.0.0

View File

@@ -50,8 +50,10 @@
"GOOGLE_CLIENT_ID",
"GOOGLE_CLIENT_SECRET",
"EMAIL_FROM",
"EMAIL_URL",
"EMAIL_TOKEN",
"SMTP_HOST",
"SMTP_PORT",
"SMTP_USER",
"SMTP_PASSWORD",
"NEXT_PUBLIC_KAN_ENV",
"STRIPE_SECRET_KEY",
"STRIPE_WEBHOOK_SECRET",