diff --git a/apps/web/app/(org)/login/form.tsx b/apps/web/app/(org)/login/form.tsx index 50ffff2733f..cb41d0787ab 100644 --- a/apps/web/app/(org)/login/form.tsx +++ b/apps/web/app/(org)/login/form.tsx @@ -6,6 +6,7 @@ import { faArrowLeft, faEnvelope, faExclamationCircle, + faRightToBracket, } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { AnimatePresence, motion } from "framer-motion"; @@ -27,6 +28,7 @@ const MotionLink = motion(Link); const MotionButton = motion(Button); export function LoginForm() { + const publicEnv = usePublicEnv(); const searchParams = useSearchParams(); const router = useRouter(); const next = searchParams?.get("next"); @@ -115,6 +117,17 @@ export function LoginForm() { }); }; + const handleAuthentikSignIn = () => { + trackEvent("auth_started", { + method: "authentik", + is_signup: false, + auth_surface: "login", + }); + signIn("authentik", { + ...(next && next.length > 0 ? { callbackUrl: next } : {}), + }); + }; + const handleOrganizationLookup = async (e: React.FormEvent) => { e.preventDefault(); if (!organizationId) { @@ -326,34 +339,37 @@ export function LoginForm() { loading={loading} oauthError={oauthError} handleGoogleSignIn={handleGoogleSignIn} + handleAuthentikSignIn={handleAuthentikSignIn} /> )} - - By typing your email and clicking continue, you acknowledge that - you have both read and agree to Cap's{" "} - - Terms of Service - {" "} - and{" "} - - Privacy Policy - - . - + By typing your email and clicking continue, you acknowledge that + you have both read and agree to Cap's{" "} + + Terms of Service + {" "} + and{" "} + + Privacy Policy + + . + + )} @@ -405,6 +421,7 @@ const NormalLogin = ({ loading, oauthError, handleGoogleSignIn, + handleAuthentikSignIn, }: { setShowOrgInput: (show: boolean) => void; email: string; @@ -413,34 +430,36 @@ const NormalLogin = ({ loading: boolean; oauthError: boolean; handleGoogleSignIn: () => void; + handleAuthentikSignIn: () => void; }) => { const publicEnv = usePublicEnv(); return ( - - { - setEmail(e.target.value.toLowerCase()); - }} - /> - } - > - Login with email - + {!publicEnv.disableEmailAuth && ( + + { + setEmail(e.target.value.toLowerCase()); + }} + /> + } + > + Login with email + {/* {NODE_ENV === "development" && (

@@ -451,31 +470,50 @@ const NormalLogin = ({

)} */} -
- - Don't have an account?{" "} - + )} + {!publicEnv.disableEmailAuth && ( + - Sign up here - - + Don't have an account?{" "} + + Sign up here + + + )} - {(publicEnv.googleAuthAvailable || publicEnv.workosAuthAvailable) && ( + {(publicEnv.googleAuthAvailable || + publicEnv.workosAuthAvailable || + publicEnv.authentikAuthAvailable) && ( <> -
- -

OR

- -
+ {!publicEnv.disableEmailAuth && ( +
+ +

OR

+ +
+ )} + {publicEnv.authentikAuthAvailable && !oauthError && ( + + + Login with Authentik + + )} {publicEnv.googleAuthAvailable && !oauthError && ( { + trackEvent("auth_started", { + method: "authentik", + is_signup: true, + auth_surface: "signup", + }); + signIn("authentik", { + ...(next && next.length > 0 ? { callbackUrl: next } : {}), + }); + }; + const handleOrganizationLookup = async (e: React.FormEvent) => { e.preventDefault(); if (!organizationId) { @@ -326,6 +339,7 @@ export function SignupForm() { loading={loading} oauthError={oauthError} handleGoogleSignIn={handleGoogleSignIn} + handleAuthentikSignIn={handleAuthentikSignIn} /> )} @@ -343,29 +357,31 @@ export function SignupForm() { Log in here - - By typing your email and clicking continue, you acknowledge that - you have both read and agree to Cap's{" "} - - Terms of Service - {" "} - and{" "} - - Privacy Policy - - . - + By typing your email and clicking continue, you acknowledge that + you have both read and agree to Cap's{" "} + + Terms of Service + {" "} + and{" "} + + Privacy Policy + + . + + )}
@@ -419,6 +435,7 @@ const NormalSignup = ({ loading, oauthError, handleGoogleSignIn, + handleAuthentikSignIn, }: { setShowOrgInput: (show: boolean) => void; email: string; @@ -427,47 +444,66 @@ const NormalSignup = ({ loading: boolean; oauthError: boolean; handleGoogleSignIn: () => void; + handleAuthentikSignIn: () => void; }) => { const publicEnv = usePublicEnv(); return ( - - { - setEmail(e.target.value.toLowerCase()); - }} - /> - } - > - Sign up with email - - - {(publicEnv.googleAuthAvailable || publicEnv.workosAuthAvailable) && ( + {!publicEnv.disableEmailAuth && ( + + { + setEmail(e.target.value.toLowerCase()); + }} + /> + } + > + Sign up with email + + + )} + {(publicEnv.googleAuthAvailable || + publicEnv.workosAuthAvailable || + publicEnv.authentikAuthAvailable) && ( <> -
- -

OR

- -
+ {!publicEnv.disableEmailAuth && ( +
+ +

OR

+ +
+ )} - {!oauthError && ( + {publicEnv.authentikAuthAvailable && !oauthError && ( + + + Sign up with Authentik + + )} + {publicEnv.googleAuthAvailable && !oauthError && ( webUrl: buildEnv.NEXT_PUBLIC_WEB_URL, workosAuthAvailable: !!serverEnv().WORKOS_CLIENT_ID, googleAuthAvailable: !!serverEnv().GOOGLE_CLIENT_ID, + authentikAuthAvailable: !!serverEnv().AUTHENTIK_ISSUER, + disableEmailAuth: serverEnv().CAP_DISABLE_EMAIL_AUTH, }} > diff --git a/apps/web/app/s/[videoId]/_components/AuthOverlay.tsx b/apps/web/app/s/[videoId]/_components/AuthOverlay.tsx index 75a3c834089..325d189f3fb 100644 --- a/apps/web/app/s/[videoId]/_components/AuthOverlay.tsx +++ b/apps/web/app/s/[videoId]/_components/AuthOverlay.tsx @@ -1,6 +1,10 @@ import { NODE_ENV } from "@cap/env"; import { Button, Dialog, DialogContent, Input, LogoBadge } from "@cap/ui"; -import { faArrowLeft, faEnvelope } from "@fortawesome/free-solid-svg-icons"; +import { + faArrowLeft, + faEnvelope, + faRightToBracket, +} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import Image from "next/image"; import Link from "next/link"; @@ -150,6 +154,19 @@ const StepOne = ({ callbackUrl: `${window.location.origin}/s/${videoId}`, }); }; + const handleAuthentikSignIn = () => { + trackEvent("auth_started", { + method: "authentik", + is_signup: false, + auth_surface: "share_overlay", + video_id: videoId, + }); + setLoading(true); + signIn("authentik", { + redirect: false, + callbackUrl: `${window.location.origin}/s/${videoId}`, + }); + }; const publicEnv = usePublicEnv(); return ( @@ -196,53 +213,74 @@ const StepOne = ({ }} className="flex flex-col gap-3" > -
- { - setEmail(e.target.value.toLowerCase()); - }} - /> -
- - {publicEnv.googleAuthAvailable && ( + {!publicEnv.disableEmailAuth && ( <> -
- -

OR

- +
+ { + setEmail(e.target.value.toLowerCase()); + }} + />
)} + {(publicEnv.googleAuthAvailable || + publicEnv.authentikAuthAvailable) && ( + <> + {!publicEnv.disableEmailAuth && ( +
+ +

OR

+ +
+ )} + {publicEnv.authentikAuthAvailable && ( + + )} + {publicEnv.googleAuthAvailable && ( + + )} + + )} ); }; diff --git a/apps/web/utils/public-env.tsx b/apps/web/utils/public-env.tsx index 94999923909..2d4a8e83cb4 100644 --- a/apps/web/utils/public-env.tsx +++ b/apps/web/utils/public-env.tsx @@ -6,6 +6,8 @@ type PublicEnvContext = { webUrl: string; googleAuthAvailable: boolean; workosAuthAvailable: boolean; + authentikAuthAvailable: boolean; + disableEmailAuth: boolean; }; const Context = createContext(null); diff --git a/packages/database/auth/auth-options.ts b/packages/database/auth/auth-options.ts index 1e3a886b2b1..70404c84182 100644 --- a/packages/database/auth/auth-options.ts +++ b/packages/database/auth/auth-options.ts @@ -6,6 +6,7 @@ import type { NextAuthOptions } from "next-auth"; import { getServerSession as _getServerSession } from "next-auth"; import type { Adapter } from "next-auth/adapters"; import { decode, type JWT, type JWTDecodeParams } from "next-auth/jwt"; +import AuthentikProvider from "next-auth/providers/authentik"; import EmailProvider from "next-auth/providers/email"; import GoogleProvider from "next-auth/providers/google"; import type { Provider } from "next-auth/providers/index"; @@ -68,7 +69,30 @@ export const authOptions = (): NextAuthOptions => { }, get providers() { if (_providers) return _providers; + const authentikIssuer = serverEnv().AUTHENTIK_ISSUER; + const authentikClientId = serverEnv().AUTHENTIK_CLIENT_ID; + const authentikClientSecret = serverEnv().AUTHENTIK_CLIENT_SECRET; + if ( + authentikIssuer && + (!authentikClientId || !authentikClientSecret) + ) { + throw new Error( + "AUTHENTIK_ISSUER is set but AUTHENTIK_CLIENT_ID and/or " + + "AUTHENTIK_CLIENT_SECRET is missing. All three must be " + + "provided together to enable the Authentik OIDC provider.", + ); + } + _providers = [ + ...(authentikIssuer && authentikClientId && authentikClientSecret + ? [ + AuthentikProvider({ + clientId: authentikClientId, + clientSecret: authentikClientSecret, + issuer: authentikIssuer, + }), + ] + : []), GoogleProvider({ clientId: serverEnv().GOOGLE_CLIENT_ID as string, clientSecret: serverEnv().GOOGLE_CLIENT_SECRET as string, diff --git a/packages/env/server.ts b/packages/env/server.ts index febc9e3e873..a36f9939885 100644 --- a/packages/env/server.ts +++ b/packages/env/server.ts @@ -72,6 +72,14 @@ function createServerEnv() { WORKOS_CLIENT_ID: z.string().optional(), WORKOS_API_KEY: z.string().optional(), + /// Authentik OIDC (self-hosted SSO) + // Provide these to enable a "Sign in with Authentik" provider. + // AUTHENTIK_ISSUER must include the application slug; no trailing slash: + // https://auth.example.com/application/o/ + AUTHENTIK_CLIENT_ID: z.string().optional(), + AUTHENTIK_CLIENT_SECRET: z.string().optional(), + AUTHENTIK_ISSUER: z.string().optional(), + /// Settings CAP_VIDEOS_DEFAULT_PUBLIC: boolString(true).describe( "Should videos be public or private by default", @@ -80,6 +88,10 @@ function createServerEnv() { .string() .optional() .describe("Comma-separated list of permitted signup domains"), + CAP_DISABLE_EMAIL_AUTH: boolString(false).describe( + "Hide the email magic-link UI on login/signup/share pages. " + + "The provider stays wired server-side as break-glass.", + ), /// AI providers DEEPGRAM_API_KEY: z.string().optional().describe("Audio transcription"),