feat: use local gotrue for studio auth in self-hosted

This commit is contained in:
Andrey A. committed 2026-01-21 18:50:48 +01:00
1 parent 2b6aa930d3
commit d9e3cdb5d2
17 files changed
+157 -36

No files matched your search

+3
View File
@@ -51,6 +51,9 @@ CMD ["pnpm", "dev:studio"]
# Compile Next.js
FROM dev AS builder
ARG NEXT_PUBLIC_STUDIO_AUTH_MODE
ENV NEXT_PUBLIC_STUDIO_AUTH_MODE=$NEXT_PUBLIC_STUDIO_AUTH_MODE
RUN pnpm --filter studio exec next build
# Copy only compiled code and dependencies
@@ -13,6 +13,7 @@ import { useAddLoginEvent } from 'data/misc/audit-login-mutation'
import { getMfaAuthenticatorAssuranceLevel } from 'data/profile/mfa-authenticator-assurance-level-query'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useLastSignIn } from 'hooks/misc/useLastSignIn'
import { IS_PLATFORM } from 'lib/constants'
import { captureCriticalError } from 'lib/error-reporting'
import { auth, buildPathWithParams, getReturnToPath } from 'lib/gotrue'
import { Button, Form_Shadcn_, FormControl_Shadcn_, FormField_Shadcn_, Input_Shadcn_ } from 'ui'
@@ -86,15 +87,18 @@ export const SignInForm = () => {
}
toast.success(`Signed in successfully!`, { id: toastId })
sendEvent({
action: 'sign_in',
properties: { category: 'account', method: 'email' },
})
addLoginEvent({})
if (IS_PLATFORM) {
sendEvent({
action: 'sign_in',
properties: { category: 'account', method: 'email' },
})
addLoginEvent({})
}
await queryClient.resetQueries()
// since we're already on the /sign-in page, prevent redirect loops
let redirectPath = '/organizations'
// Use /project/default for self-hosted, /organizations for platform
let redirectPath = IS_PLATFORM ? '/organizations' : '/project/default'
if (returnTo && returnTo !== '/sign-in') {
redirectPath = returnTo
}
@@ -175,12 +179,15 @@ export const SignInForm = () => {
/>
{/* positioned using absolute instead of labelOptional prop so tabbing between inputs works smoothly */}
<Link
href={forgotPasswordUrl}
className="absolute top-0 right-0 text-sm text-foreground-lighter"
>
Forgot password?
</Link>
{/* Hide forgot password link for self-hosted - users must be managed manually */}
{IS_PLATFORM && (
<Link
href={forgotPasswordUrl}
className="absolute top-0 right-0 text-sm text-foreground-lighter"
>
Forgot password?
</Link>
)}
</div>
<div className="self-center">
+3 -3
View File
@@ -7,7 +7,7 @@ import { SessionTimeoutModal } from 'components/interfaces/SignIn/SessionTimeout
import { usePermissionsQuery } from 'data/permissions/permissions-query'
import { useAuthenticatorAssuranceLevelQuery } from 'data/profile/mfa-authenticator-assurance-level-query'
import { useSignOut } from 'lib/auth'
import { BASE_PATH, IS_PLATFORM } from 'lib/constants'
import { BASE_PATH, IS_PLATFORM, STUDIO_AUTH_ENABLED } from 'lib/constants'
import { isNextPageWithLayout, type NextPageWithLayout } from 'types'
const MAX_TIMEOUT = 10000 // 10 seconds
@@ -26,8 +26,8 @@ export function withAuth<T>(
useHighestAAL: boolean
} = { useHighestAAL: true }
) {
// ignore auth in self-hosted
if (!IS_PLATFORM) {
// ignore auth in self-hosted unless studio auth mode is enabled
if (!IS_PLATFORM && !STUDIO_AUTH_ENABLED) {
return WrappedComponent
}
+5 -2
View File
@@ -10,7 +10,7 @@ import {
useAuthError,
} from 'common'
import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state'
import { GOTRUE_ERRORS, IS_PLATFORM } from './constants'
import { GOTRUE_ERRORS, IS_PLATFORM, STUDIO_AUTH_ENABLED } from './constants'
const AuthErrorToaster = ({ children }: PropsWithChildren) => {
const error = useAuthError()
@@ -33,8 +33,11 @@ const AuthErrorToaster = ({ children }: PropsWithChildren) => {
}
export const AuthProvider = ({ children }: PropsWithChildren) => {
// Use real auth if platform OR if studio auth mode is enabled for self-hosted
const useRealAuth = IS_PLATFORM || STUDIO_AUTH_ENABLED
return (
<AuthProviderInternal alwaysLoggedIn={!IS_PLATFORM}>
<AuthProviderInternal alwaysLoggedIn={!useRealAuth}>
<AuthErrorToaster>{children}</AuthErrorToaster>
</AuthProviderInternal>
)
+1
View File
@@ -3,6 +3,7 @@
export * from './infrastructure'
export const IS_PLATFORM = process.env.NEXT_PUBLIC_IS_PLATFORM === 'true'
export const STUDIO_AUTH_ENABLED = process.env.NEXT_PUBLIC_STUDIO_AUTH_MODE === 'supabase'
export const API_URL = (() => {
if (process.env.NODE_ENV === 'test') return 'http://localhost:3000/api'
+3 -1
View File
@@ -1,10 +1,12 @@
import type { JwtPayload } from '@supabase/supabase-js'
import { type User } from 'common/auth'
import { gotrueClient } from 'common/gotrue'
import { IS_PLATFORM } from './constants'
export const auth = gotrueClient
export const DEFAULT_FALLBACK_PATH = '/organizations'
// Use /project/default for self-hosted, /organizations for platform
export const DEFAULT_FALLBACK_PATH = IS_PLATFORM ? '/organizations' : '/project/default'
export const validateReturnTo = (
returnTo: string,
@@ -7,6 +7,7 @@ import { toast } from 'sonner'
import { getAccessToken } from 'common'
import { SignInMfaForm } from 'components/interfaces/SignIn/SignInMfaForm'
import ForgotPasswordLayout from 'components/layouts/SignInLayout/ForgotPasswordLayout'
import { IS_PLATFORM } from 'lib/constants'
import { auth, buildPathWithParams, getReturnToPath } from 'lib/gotrue'
import type { NextPageWithLayout } from 'types'
import { LogoLoader } from 'ui'
@@ -17,6 +18,13 @@ const ForgotPasswordMfa: NextPageWithLayout = () => {
const [loading, setLoading] = useState(true)
useEffect(() => {
// Forgot password flow is disabled for self-hosted
if (!IS_PLATFORM) {
router.replace('/project/default')
}
}, [router])
// This useEffect redirects the user to MFA if they're already halfway signed in
useEffect(() => {
auth
+12
View File
@@ -1,10 +1,22 @@
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useEffect } from 'react'
import { ForgotPasswordWizard } from 'components/interfaces/SignIn/ForgotPasswordWizard'
import ForgotPasswordLayout from 'components/layouts/SignInLayout/ForgotPasswordLayout'
import { IS_PLATFORM } from 'lib/constants'
import type { NextPageWithLayout } from 'types'
const ForgotPasswordPage: NextPageWithLayout = () => {
const router = useRouter()
useEffect(() => {
// No self-service password reset for self-hosted - users must be managed manually
if (!IS_PLATFORM) {
router.replace('/project/default')
}
}, [router])
return (
<>
<div className="flex flex-col gap-4">
+4 -1
View File
@@ -3,6 +3,7 @@ import { useEffect } from 'react'
import { toast } from 'sonner'
import { useSignOut } from 'lib/auth'
import { IS_PLATFORM, STUDIO_AUTH_ENABLED } from 'lib/constants'
import type { NextPageWithLayout } from 'types'
import { LogoLoader } from 'ui'
@@ -14,7 +15,9 @@ const LogoutPage: NextPageWithLayout = () => {
const logout = async () => {
await signOut()
toast('Successfully logged out')
await router.push('/sign-in')
// Redirect to sign-in if using real auth, otherwise to project page
const redirectPath = IS_PLATFORM || STUDIO_AUTH_ENABLED ? '/sign-in' : '/project/default'
await router.push(redirectPath)
}
logout()
}, [])
+8 -1
View File
@@ -7,7 +7,7 @@ import { useEffect, useState } from 'react'
import { useIsLoggedIn } from 'common'
import { useOrganizationByFlyOrgIdMutation } from 'data/organizations/organization-by-fly-organization-id-mutation'
import { useProjectByFlyExtensionIdMutation } from 'data/projects/project-by-fly-extension-id-mutation'
import { API_URL, BASE_PATH } from 'lib/constants'
import { API_URL, BASE_PATH, IS_PLATFORM } from 'lib/constants'
import { Button } from 'ui'
const SignInFlyTos = () => {
@@ -18,6 +18,13 @@ const SignInFlyTos = () => {
isReady,
query: { fly_extension_id, fly_organization_id },
} = router
useEffect(() => {
// Fly.io integration is platform-only feature
if (!IS_PLATFORM) {
router.replace('/project/default')
}
}, [router])
const { resolvedTheme } = useTheme()
const { mutateAsync: getProjectByFlyExtensionId } = useProjectByFlyExtensionIdMutation({
onSuccess: (res) => {
+11 -8
View File
@@ -10,6 +10,7 @@ import SignInLayout from 'components/layouts/SignInLayout/SignInLayout'
import { useAddLoginEvent } from 'data/misc/audit-login-mutation'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import useLatest from 'hooks/misc/useLatest'
import { IS_PLATFORM } from 'lib/constants'
import { auth, buildPathWithParams, getReturnToPath } from 'lib/gotrue'
import type { NextPageWithLayout } from 'types'
import { LogoLoader } from 'ui'
@@ -54,14 +55,16 @@ const SignInMfaPage: NextPageWithLayout = () => {
}
if (data.currentLevel === data.nextLevel) {
sendEvent({
action: 'sign_in',
properties: {
category: 'account',
method: signInMethodRef.current,
},
})
addLoginEvent({})
if (IS_PLATFORM) {
sendEvent({
action: 'sign_in',
properties: {
category: 'account',
method: signInMethodRef.current,
},
})
addLoginEvent({})
}
await queryClient.resetQueries()
router.push(getReturnToPath())
+13
View File
@@ -1,9 +1,22 @@
import { useRouter } from 'next/router'
import { useEffect } from 'react'
import { SignInPartner } from 'components/interfaces/SignIn/SignInPartner'
import ForgotPasswordLayout from 'components/layouts/SignInLayout/ForgotPasswordLayout'
import { IS_PLATFORM } from 'lib/constants'
import type { NextPageWithLayout } from 'types'
import { cn } from 'ui'
const SignInPartnerPage: NextPageWithLayout = () => {
const router = useRouter()
useEffect(() => {
// Partner sign-in is platform-only feature
if (!IS_PLATFORM) {
router.replace('/project/default')
}
}, [router])
return <SignInPartner />
}
+12
View File
@@ -1,12 +1,24 @@
import { useRouter } from 'next/router'
import { useEffect } from 'react'
import { SignInSSOForm } from 'components/interfaces/SignIn/SignInSSOForm'
import SignInLayout from 'components/layouts/SignInLayout/SignInLayout'
import { UnknownInterface } from 'components/ui/UnknownInterface'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { IS_PLATFORM } from 'lib/constants'
import type { NextPageWithLayout } from 'types'
const SignInSSOPage: NextPageWithLayout = () => {
const router = useRouter()
const signInWithSSOEnabled = useIsFeatureEnabled('dashboard_auth:sign_in_with_sso')
useEffect(() => {
// SSO is platform-only feature
if (!IS_PLATFORM) {
router.replace('/project/default')
}
}, [router])
if (!signInWithSSOEnabled) {
return <UnknownInterface fullHeight={false} urlBack="/sign-in" />
}
+6 -5
View File
@@ -11,7 +11,7 @@ import { AuthenticationLayout } from 'components/layouts/AuthenticationLayout'
import SignInLayout from 'components/layouts/SignInLayout/SignInLayout'
import { useCustomContent } from 'hooks/custom-content/useCustomContent'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { IS_PLATFORM } from 'lib/constants'
import { IS_PLATFORM, STUDIO_AUTH_ENABLED } from 'lib/constants'
import type { NextPageWithLayout } from 'types'
import { Button } from 'ui'
@@ -38,8 +38,8 @@ const SignInPage: NextPageWithLayout = () => {
(signInWithGithubEnabled || signInWithSsoEnabled || customProvider) && signInWithEmailEnabled
useEffect(() => {
if (!IS_PLATFORM) {
// on selfhosted instance just redirect to projects page
// Redirect to projects page on self-hosted unless studio auth mode is enabled
if (!IS_PLATFORM && !STUDIO_AUTH_ENABLED) {
router.replace('/project/default')
}
}, [router])
@@ -83,10 +83,11 @@ const SignInPage: NextPageWithLayout = () => {
{signInWithEmailEnabled && <SignInForm />}
</div>
{signUpEnabled && (
{/* Hide signup link for self-hosted - users must be added manually */}
{IS_PLATFORM && signUpEnabled && (
<div className="self-center my-8 text-sm">
<div>
<span className="text-foreground-light">Don’t have an account?</span>{' '}
<span className="text-foreground-light">Don't have an account?</span>{' '}
<Link
href={{
pathname: '/sign-up',
+12
View File
@@ -1,18 +1,30 @@
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useEffect } from 'react'
import { SignInWithGitHub } from 'components/interfaces/SignIn/SignInWithGitHub'
import { SignUpForm } from 'components/interfaces/SignIn/SignUpForm'
import SignInLayout from 'components/layouts/SignInLayout/SignInLayout'
import { UnknownInterface } from 'components/ui/UnknownInterface'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { IS_PLATFORM } from 'lib/constants'
import type { NextPageWithLayout } from 'types'
const SignUpPage: NextPageWithLayout = () => {
const router = useRouter()
const {
dashboardAuthSignUp: signUpEnabled,
dashboardAuthSignInWithGithub: signInWithGithubEnabled,
} = useIsFeatureEnabled(['dashboard_auth:sign_up', 'dashboard_auth:sign_in_with_github'])
useEffect(() => {
// No self-service signup for self-hosted - users must be added manually
if (!IS_PLATFORM) {
router.replace('/project/default')
}
}, [router])
if (!signUpEnabled) {
return <UnknownInterface fullHeight={false} urlBack="/sign-in" />
}
+6
View File
@@ -104,6 +104,12 @@ IMGPROXY_ENABLE_WEBP_DETECTION=true
# Add your OpenAI API key to enable SQL Editor Assistant
OPENAI_API_KEY=
# Studio Authentication Mode
# Set to 'supabase' to use Supabase Auth (GoTrue) for Studio login instead of Kong basic-auth
# When enabled, you must create users manually via CLI: docker compose exec auth ./auth admin createuser --confirm <email> <password>
# Leave empty for default Kong basic-auth (DASHBOARD_USERNAME/DASHBOARD_PASSWORD)
NEXT_PUBLIC_STUDIO_AUTH_MODE=
############
# Functions - Configuration for Functions
+31 -3
View File
@@ -62,6 +62,33 @@ services:
- /auth/v1/authorize
plugins:
- name: cors
- name: auth-v1-open-token
url: http://auth:9999/token
routes:
- name: auth-v1-open-token
strip_path: true
paths:
- /auth/v1/token
plugins:
- name: cors
- name: auth-v1-open-user
url: http://auth:9999/user
routes:
- name: auth-v1-open-user
strip_path: true
paths:
- /auth/v1/user
plugins:
- name: cors
- name: auth-v1-open-logout
url: http://auth:9999/logout
routes:
- name: auth-v1-open-logout
strip_path: true
paths:
- /auth/v1/logout
plugins:
- name: cors
## Secure Auth routes
- name: auth-v1
@@ -278,6 +305,7 @@ services:
- /
plugins:
- name: cors
- name: basic-auth
config:
hide_credentials: true
# Uncomment to use Kong basic-auth instead of Supabase Auth (GoTrue)
# - name: basic-auth
# config:
# hide_credentials: true