diff --git a/README.md b/README.md index 1fc5144c..148d2303 100644 --- a/README.md +++ b/README.md @@ -138,37 +138,40 @@ pnpm dev ## Environment Variables 🔐 -| Variable | Description | Required | Example | -| ----------------------------------------- | -------------------------------------------------------- | ------------------------ | --------------------------------------------- | -| `POSTGRES_URL` | PostgreSQL connection URL | To use external database | `postgres://user:pass@localhost:5432/db` | -| `EMAIL_FROM` | Sender email address | For Email | `"Kan "` | -| `SMTP_HOST` | SMTP server hostname | For Email | `smtp.resend.com` | -| `SMTP_PORT` | SMTP server port | For Email | `465` | -| `SMTP_USER` | SMTP username/email | No | `resend` | -| `SMTP_PASSWORD` | SMTP password/token | No | `re_xxxx` | -| `SMTP_SECURE` | Use secure SMTP connection (defaults to true if not set) | For Email | `true` | -| `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_TRUSTED_ORIGINS` | Allowed callback origins | No | `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` | -| `TRELLO_APP_API_KEY` | Trello app API key | For Trello import | `xxx` | -| `TRELLO_APP_API_SECRET` | Trello app API secret | For Trello import | `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` | -| `S3_FORCE_PATH_STYLE` | Use path-style URLs for S3 | For file uploads | `true` | -| `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` | -| `NEXT_PUBLIC_ALLOW_CREDENTIALS` | Allow email & password login | For authentication | `true` | -| `NEXT_PUBLIC_DISABLE_SIGN_UP` | Disable sign up | For authentication | `false` | -| `NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY` | Hide “Powered by kan.bn” on public boards (self-host) | For white labelling | `true` | +| Variable | Description | Required | Example | +| ----------------------------------------- | -------------------------------------------------------- | ------------------------ | ----------------------------------------------------------- | +| `POSTGRES_URL` | PostgreSQL connection URL | To use external database | `postgres://user:pass@localhost:5432/db` | +| `EMAIL_FROM` | Sender email address | For Email | `"Kan "` | +| `SMTP_HOST` | SMTP server hostname | For Email | `smtp.resend.com` | +| `SMTP_PORT` | SMTP server port | For Email | `465` | +| `SMTP_USER` | SMTP username/email | No | `resend` | +| `SMTP_PASSWORD` | SMTP password/token | No | `re_xxxx` | +| `SMTP_SECURE` | Use secure SMTP connection (defaults to true if not set) | For Email | `true` | +| `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_TRUSTED_ORIGINS` | Allowed callback origins | No | `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` | +| `OIDC_CLIENT_ID` | Generic OIDC client ID | For OIDC login | `xxx` | +| `OIDC_CLIENT_SECRET` | Generic OIDC client secret | For OIDC login | `xxx` | +| `OIDC_DISCOVERY_URL` | OIDC discovery URL | For OIDC login | `https://auth.example.com/.well-known/openid-configuration` | +| `TRELLO_APP_API_KEY` | Trello app API key | For Trello import | `xxx` | +| `TRELLO_APP_API_SECRET` | Trello app API secret | For Trello import | `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` | +| `S3_FORCE_PATH_STYLE` | Use path-style URLs for S3 | For file uploads | `true` | +| `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` | +| `NEXT_PUBLIC_ALLOW_CREDENTIALS` | Allow email & password login | For authentication | `true` | +| `NEXT_PUBLIC_DISABLE_SIGN_UP` | Disable sign up | For authentication | `false` | +| `NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY` | Hide “Powered by kan.bn” on public boards (self-host) | For white labelling | `true` | See `.env.example` for a complete list of supported environment variables. diff --git a/apps/web/next.config.js b/apps/web/next.config.js index a174c534..cb0323de 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -31,12 +31,12 @@ const config = { images: { remotePatterns: [ - { protocol: "https", - hostname: process.env.S3_FORCE_PATH_STYLE === "true" - ? `${env("NEXT_PUBLIC_STORAGE_DOMAIN")}` - : `*.${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`, + hostname: + env("S3_FORCE_PATH_STYLE") === "true" + ? `${env("NEXT_PUBLIC_STORAGE_DOMAIN")}` + : `*.${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`, }, { protocol: "http", @@ -54,4 +54,16 @@ const config = { }, }; +// Only allow external images when OIDC is configured (for OIDC provider avatars) +if ( + env("OIDC_CLIENT_ID") && + env("OIDC_CLIENT_SECRET") && + env("OIDC_DISCOVERY_URL") +) { + config.images?.remotePatterns?.push({ + protocol: "https", + hostname: "**", + }); +} + export default config; diff --git a/apps/web/src/components/AuthForm.tsx b/apps/web/src/components/AuthForm.tsx index 6207d0d0..ca426b55 100644 --- a/apps/web/src/components/AuthForm.tsx +++ b/apps/web/src/components/AuthForm.tsx @@ -4,7 +4,7 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { useQuery } from "@tanstack/react-query"; import { env } from "next-runtime-env"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { FaApple, @@ -16,6 +16,7 @@ import { FaGoogle, FaLinkedin, FaMicrosoft, + FaOpenid, FaReddit, FaSpotify, FaTiktok, @@ -33,6 +34,8 @@ import Button from "~/components/Button"; import Input from "~/components/Input"; import { usePopup } from "~/providers/popup"; +type AuthProvider = SocialProvider | "oidc"; + interface FormValues { name?: string; email: string; @@ -141,16 +144,28 @@ const availableSocialProviders = { name: "Zoom", icon: SiZoom, }, + oidc: { + id: "oidc", + name: "OIDC", + icon: FaOpenid, + }, }; export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) { const [isLoginWithProviderPending, setIsLoginWithProviderPending] = - useState(null); - const isCredentialsEnabled = - env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true"; + useState(null); + const [isCredentialsEnabled, setIsCredentialsEnabled] = useState(false); const [isLoginWithEmailPending, setIsLoginWithEmailPending] = useState(false); const [loginError, setLoginError] = useState(null); const { showPopup } = usePopup(); + const oidcProviderName = "OIDC"; + + // Safely get environment variables on client side to avoid hydration mismatch + useEffect(() => { + const credentialsAllowed = + env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true"; + setIsCredentialsEnabled(credentialsAllowed); + }, []); const { register, @@ -226,13 +241,26 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) { setIsLoginWithEmailPending(false); }; - const handleLoginWithProvider = async (provider: SocialProvider) => { + const handleLoginWithProvider = async (provider: AuthProvider) => { setIsLoginWithProviderPending(provider); setLoginError(null); - const { error } = await authClient.signIn.social({ - provider, - callbackURL: "/boards", - }); + + let error; + if (provider === "oidc") { + // Use oauth2 signin for OIDC provider + const result = await authClient.signIn.oauth2({ + providerId: "oidc", + callbackURL: "/boards", + }); + error = result.error; + } else { + // Use social signin for traditional social providers + const result = await authClient.signIn.social({ + provider, + callbackURL: "/boards", + }); + error = result.error; + } setIsLoginWithProviderPending(null); @@ -260,13 +288,16 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) { return ( ); })} diff --git a/apps/web/src/components/NewWorkspaceForm.tsx b/apps/web/src/components/NewWorkspaceForm.tsx index 720bd66d..23ce462a 100644 --- a/apps/web/src/components/NewWorkspaceForm.tsx +++ b/apps/web/src/components/NewWorkspaceForm.tsx @@ -31,6 +31,7 @@ export function NewWorkspaceForm() { description: values.description, slug: values.slug, plan: values.plan, + role: "admin", }); closeModal(); } diff --git a/apps/web/src/env.ts b/apps/web/src/env.ts index 4aebcf59..45678b7d 100644 --- a/apps/web/src/env.ts +++ b/apps/web/src/env.ts @@ -51,6 +51,10 @@ export const env = createEnv({ VK_CLIENT_SECRET: z.string().optional(), LINKEDIN_CLIENT_ID: z.string().optional(), LINKEDIN_CLIENT_SECRET: z.string().optional(), + // Generic OIDC Provider + OIDC_CLIENT_ID: z.string().optional(), + OIDC_CLIENT_SECRET: z.string().optional(), + OIDC_DISCOVERY_URL: z.string().optional(), REDDIT_CLIENT_ID: z.string().optional(), REDDIT_CLIENT_SECRET: z.string().optional(), ROBLOX_CLIENT_ID: z.string().optional(), diff --git a/docker-compose.yml b/docker-compose.yml index 3eddea0f..d26a8bea 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -46,6 +46,11 @@ services: - TRELLO_APP_API_KEY=${TRELLO_APP_API_KEY} - TRELLO_APP_SECRET=${TRELLO_APP_SECRET} + # Generic OIDC provider (optional) + - OIDC_CLIENT_ID=${OIDC_CLIENT_ID} + - OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET} + - OIDC_DISCOVERY_URL=${OIDC_DISCOVERY_URL} + # OAuth providers (optional) - BETTER_AUTH_TRUSTED_ORIGINS=${BETTER_AUTH_TRUSTED_ORIGINS} - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID} diff --git a/packages/auth/src/auth.ts b/packages/auth/src/auth.ts index d6498b0d..1cc86c78 100644 --- a/packages/auth/src/auth.ts +++ b/packages/auth/src/auth.ts @@ -2,7 +2,7 @@ import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { createAuthEndpoint, createAuthMiddleware } from "better-auth/api"; -import { apiKey } from "better-auth/plugins"; +import { apiKey, genericOAuth } from "better-auth/plugins"; import { magicLink } from "better-auth/plugins/magic-link"; import { socialProviderList } from "better-auth/social-providers"; import { env } from "next-runtime-env"; @@ -83,8 +83,20 @@ export const socialProvidersPlugin = () => ({ { method: "GET", }, - async (ctx) => - ctx.json(ctx.context.socialProviders.map((p) => p.id.toLowerCase())), + async (ctx) => { + const providers = ctx.context.socialProviders.map((p) => + p.id.toLowerCase(), + ); + // Add OIDC provider if configured + if ( + process.env.OIDC_CLIENT_ID && + process.env.OIDC_CLIENT_SECRET && + process.env.OIDC_DISCOVERY_URL + ) { + providers.push("oidc"); + } + return ctx.json(providers); + }, ), }, }); @@ -162,6 +174,25 @@ export const initAuth = (db: dbClient) => { } }, }), + // Generic OIDC provider + ...(process.env.OIDC_CLIENT_ID && + process.env.OIDC_CLIENT_SECRET && + process.env.OIDC_DISCOVERY_URL + ? [ + genericOAuth({ + config: [ + { + providerId: "oidc", + clientId: process.env.OIDC_CLIENT_ID, + clientSecret: process.env.OIDC_CLIENT_SECRET, + discoveryUrl: process.env.OIDC_DISCOVERY_URL, + scopes: ["openid", "email", "profile"], + pkce: true, + }, + ], + }), + ] + : []), ], databaseHooks: { user: { diff --git a/packages/auth/src/client.ts b/packages/auth/src/client.ts index 765f1142..fa84a611 100644 --- a/packages/auth/src/client.ts +++ b/packages/auth/src/client.ts @@ -1,8 +1,13 @@ -import { BetterAuthClientPlugin } from "better-auth"; -import { apiKeyClient, magicLinkClient } from "better-auth/client/plugins"; -import { BetterFetchOption, createAuthClient } from "better-auth/react"; +import type { BetterAuthClientPlugin } from "better-auth"; +import type { BetterFetchOption } from "better-auth/react"; +import { + apiKeyClient, + genericOAuthClient, + magicLinkClient, +} from "better-auth/client/plugins"; +import { createAuthClient } from "better-auth/react"; -import { socialProvidersPlugin } from "./auth"; +import type { socialProvidersPlugin } from "./auth"; const socialProvidersPluginClient = { id: "social-providers-plugin", @@ -21,5 +26,10 @@ const socialProvidersPluginClient = { } satisfies BetterAuthClientPlugin; export const authClient = createAuthClient({ - plugins: [magicLinkClient(), apiKeyClient(), socialProvidersPluginClient], + plugins: [ + magicLinkClient(), + apiKeyClient(), + genericOAuthClient(), + socialProvidersPluginClient, + ], }); diff --git a/turbo.json b/turbo.json index 5e4c36ac..7427220b 100644 --- a/turbo.json +++ b/turbo.json @@ -72,6 +72,9 @@ "VK_CLIENT_SECRET", "LINKEDIN_CLIENT_ID", "LINKEDIN_CLIENT_SECRET", + "OIDC_CLIENT_ID", + "OIDC_CLIENT_SECRET", + "OIDC_DISCOVERY_URL", "REDDIT_CLIENT_ID", "REDDIT_CLIENT_SECRET", "ROBLOX_CLIENT_ID",