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
20 changed files with 37 additions and 110 deletions

View File

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

3
.gitignore vendored
View File

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

View File

@@ -66,30 +66,6 @@ pnpm install
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>"` |
| `EMAIL_URL` | Email service API URL | Yes | `https://api.resend.com/emails` |
| `EMAIL_TOKEN` | Email service API 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 | `storage.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.

View File

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

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,7 +22,7 @@ const config = {
typescript: { ignoreBuildErrors: true }, typescript: { ignoreBuildErrors: true },
images: { images: {
domains: [env("NEXT_PUBLIC_STORAGE_DOMAIN") ?? ""], domains: [process.env.NEXT_PUBLIC_STORAGE_DOMAIN ?? ""],
}, },
experimental: { experimental: {
instrumentationHook: true, instrumentationHook: true,

View File

@@ -35,7 +35,6 @@
"jwt-decode": "^4.0.0", "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",

View File

@@ -1,8 +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: "storage.kanbn.com",
NEXT_PUBLIC_STORAGE_URL: "https://storage.kanbn.com",
};

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,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,11 +1,8 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { env } from "next-runtime-env";
import { createNextApiContext } from "@kan/api/trpc";
import { env } from "~/env";
const allowedContentTypes = ["image/jpeg", "image/png"];
export default async function handler( export default async function handler(
req: NextApiRequest, req: NextApiRequest,
@@ -16,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,
}, },
}); });
@@ -54,7 +28,7 @@ export default async function handler(
// @ts-ignore // @ts-ignore
client, client,
new PutObjectCommand({ new PutObjectCommand({
Bucket: env("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,
@@ -46,5 +46,5 @@ export const formatMemberDisplayName = (
}; };
export const getAvatarUrl = (key: string) => { export const getAvatarUrl = (key: string) => {
return `${env("NEXT_PUBLIC_STORAGE_URL")}/${env("NEXT_PUBLIC_AVATAR_BUCKET_NAME")}/${key}`; return `${env.NEXT_PUBLIC_STORAGE_URL}/${env.NEXT_PUBLIC_AVATAR_BUCKET_NAME}/${key}`;
}; };

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,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,6 +9,12 @@ 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"]
@@ -35,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

@@ -3,7 +3,6 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { 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 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";
@@ -71,7 +70,7 @@ export const initAuth = (db: dbClient) => {
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

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

10
pnpm-lock.yaml generated
View File

@@ -138,9 +138,6 @@ importers:
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))
@@ -4464,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'}
@@ -10671,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