diff --git a/internal/site/src/components/login/auth-form.tsx b/internal/site/src/components/login/auth-form.tsx index eab875764..fdf5c79a2 100644 --- a/internal/site/src/components/login/auth-form.tsx +++ b/internal/site/src/components/login/auth-form.tsx @@ -19,395 +19,201 @@ import { OtpInputForm } from "./otp-forms" const honeypot = v.literal("") const emailSchema = v.pipe(v.string(), v.rfcEmail(t`Invalid email address.`)) const passwordSchema = v.pipe( - v.string(), - v.minLength(8, t`Password must be at least 8 characters.`), - v.maxBytes(72, t`Password must be less than 72 bytes.`) + v.string(), + v.minLength(8, t`Password must be at least 8 characters.`), + v.maxBytes(72, t`Password must be less than 72 bytes.`) ) const LoginSchema = v.looseObject({ - website: honeypot, - email: emailSchema, - password: passwordSchema, + website: honeypot, + email: emailSchema, + password: passwordSchema, }) const RegisterSchema = v.looseObject({ - website: honeypot, - email: emailSchema, - password: passwordSchema, - passwordConfirm: passwordSchema, + website: honeypot, + email: emailSchema, + password: passwordSchema, + passwordConfirm: passwordSchema, }) export const showLoginFaliedToast = (description = t`Please check your credentials and try again`) => { - toast({ - title: t`Login attempt failed`, - description, - variant: "destructive", - }) + toast({ + title: t`Login attempt failed`, + description, + variant: "destructive", + }) } const getAuthProviderIcon = (provider: AuthProviderInfo) => { - let { name } = provider - if (name.startsWith("oidc")) { - name = "oidc" - } - return prependBasePath(`/_/images/oauth2/${name}.svg`) + let { name } = provider + if (name.startsWith("oidc")) { + name = "oidc" + } + return prependBasePath(`/_/images/oauth2/${name}.svg`) } export function UserAuthForm({ - className, - isFirstRun, - authMethods, - ...props + className, + isFirstRun, + authMethods, + ...props }: { - className?: string - isFirstRun: boolean - authMethods: AuthMethodsList + className?: string + isFirstRun: boolean + authMethods: AuthMethodsList }) { - const [isLoading, setIsLoading] = useState(false) - const [isOauthLoading, setIsOauthLoading] = useState(false) - const [errors, setErrors] = useState>({}) - const [mfaId, setMfaId] = useState() - const [otpId, setOtpId] = useState() - - const handleSubmit = useCallback( - async (e: React.FormEvent) => { - e.preventDefault() - setIsLoading(true) - // store email for later use if mfa is enabled - let email = "" - try { - const formData = new FormData(e.target as HTMLFormElement) - const data = Object.fromEntries(formData) as Record - const Schema = isFirstRun ? RegisterSchema : LoginSchema - const result = v.safeParse(Schema, data) - if (!result.success) { - console.log(result) - const errors = {} - for (const issue of result.issues) { - // @ts-expect-error - errors[issue.path[0].key] = issue.message - } - setErrors(errors) - return - } - const { password, passwordConfirm } = result.output - email = result.output.email - if (isFirstRun) { - // check that passwords match - if (password !== passwordConfirm) { - const msg = "Passwords do not match" - setErrors({ passwordConfirm: msg }) - return - } - await pb.send("/api/beszel/create-user", { - method: "POST", - body: JSON.stringify({ email, password }), - }) - await pb.collection("users").authWithPassword(email, password) - } else { - await pb.collection("users").authWithPassword(email, password) - } - $authenticated.set(true) - } catch (err: any) { - const mfaId = err?.response?.mfaId - if (!mfaId) { - showLoginFaliedToast() - throw err - } - setMfaId(mfaId) - try { - const { otpId } = await pb.collection("users").requestOTP(email) - setOtpId(otpId) - } catch (err) { - console.log({ err }) - showLoginFaliedToast() - } - } finally { - setIsLoading(false) - } - }, - [isFirstRun] - ) - - const authProviders = authMethods.oauth2.providers ?? [] - const oauthEnabled = authMethods.oauth2.enabled && authProviders.length > 0 - const passwordEnabled = authMethods.password.enabled - const otpEnabled = authMethods.otp.enabled - const mfaEnabled = authMethods.mfa.enabled - - function loginWithOauth(provider: AuthProviderInfo, forcePopup = false) { - setIsOauthLoading(true) - - if (globalThis.BESZEL.OAUTH_DISABLE_POPUP) { - redirectToOauthProvider(provider) - return - } - - const oAuthOpts: OAuth2AuthConfig = { - provider: provider.name, - } - // https://github.com/pocketbase/pocketbase/discussions/2429#discussioncomment-5943061 - if (forcePopup || navigator.userAgent.match(/iPhone|iPad|iPod/i)) { - const authWindow = window.open() - if (!authWindow) { - setIsOauthLoading(false) - showLoginFaliedToast(t`Please enable pop-ups for this site`) - return - } - oAuthOpts.urlCallback = (url) => { - authWindow.location.href = url - } - } - pb.collection("users") - .authWithOAuth2(oAuthOpts) - .then(() => { - $authenticated.set(pb.authStore.isValid) - }) - .catch(showLoginFaliedToast) - .finally(() => { - setIsOauthLoading(false) - }) - } - - /** - * Redirects the user to the OAuth provider's authentication page in the same window. - * Requires the app's base URL to be registered as a redirect URI with the OAuth provider. - */ - function redirectToOauthProvider(provider: AuthProviderInfo) { - const url = new URL(provider.authURL) - // url.searchParams.set("redirect_uri", `${window.location.origin}${basePath}`) - sessionStorage.setItem("provider", JSON.stringify(provider)) - window.location.href = url.toString() - } - - useEffect(() => { - // handle redirect-based OAuth callback if we have a code - const params = new URLSearchParams(window.location.search) - const code = params.get("code") - if (code) { - const state = params.get("state") - const provider: AuthProviderInfo = JSON.parse(sessionStorage.getItem("provider") ?? "{}") - if (!state || provider.state !== state) { - showLoginFaliedToast() - } else { - setIsOauthLoading(true) - window.history.replaceState({}, "", window.location.pathname) - pb.collection("users") - .authWithOAuth2Code(provider.name, code, provider.codeVerifier, `${window.location.origin}${basePath}`) - .then(() => $authenticated.set(pb.authStore.isValid)) - .catch((e: unknown) => showLoginFaliedToast((e as Error).message)) - .finally(() => setIsOauthLoading(false)) - } - } - - // auto login if password disabled and only one auth provider - if (!code && !passwordEnabled && authProviders.length === 1 && !sessionStorage.getItem("lo")) { - // Add a small timeout to ensure browser is ready to handle popups - setTimeout(() => loginWithOauth(authProviders[0], false), 300) - return - } - - // refresh auth if not in above states (required for trusted auth header) - pb.collection("users") - .authRefresh() - .then((res) => { - pb.authStore.save(res.token, res.record) - $authenticated.set(!!pb.authStore.isValid) - }) - }, []) - - if (!authMethods) { - return null - } - - if (otpId && mfaId) { - return - } - - return ( -
- {passwordEnabled && ( - <> -
setErrors({})}> -
-
- - - - {errors?.email &&

{errors.email}

} -
-
- - - - {errors?.password &&

{errors.password}

} -
- {isFirstRun && ( -
- - - - {errors?.passwordConfirm &&

{errors.passwordConfirm}

} -
- )} -
- {/* honeypot */} - - -
- -
-
- {(isFirstRun || oauthEnabled || (otpEnabled && !mfaEnabled)) && ( - // only show 'continue with' during onboarding or if we have auth providers -
-
- -
-
- - Or continue with - -
-
- )} - - )} - {/* hide OTP button if MFA is enabled (it will be used as MFA) */} - {otpEnabled && !mfaEnabled && ( -
- - - One-time password - -
- )} - {oauthEnabled && ( -
- {authMethods.oauth2.providers.map((provider) => ( - - ))} -
- )} - {!oauthEnabled && isFirstRun && ( - // only show GitHub button / dialog during onboarding - - - - - - - - OAuth 2 / OIDC support - - -
-

- Beszel supports OpenID Connect and many OAuth2 authentication providers. -

-

- - Please see{" "} - - the documentation - {" "} - for instructions. - -

-
-
-
- )} - {passwordEnabled && !isFirstRun && ( - - Forgot password? - - )} -
- ) -} + const [isLoading, setIsLoading] = useState(false) + const [isOauthLoading, setIsOauthLoading] = useState(false) + const [errors, setErrors] = useState>({}) + const [mfaId, setMfaId] = useState() + const [otpId, setOtpId] = useState() + + const handleSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault() + // store email for later use if mfa is enabled + let email = "" + try { + const formData = new FormData(e.target as HTMLFormElement) + const data = Object.fromEntries(formData) as Record + const Schema = isFirstRun ? RegisterSchema : LoginSchema + const result = v.safeParse(Schema, data) + if (!result.success) { + const errors: Record = {} + for (const issue of result.issues) { + const key = issue.path?.[0]?.key as string | undefined + if (key && key !== "website") { + errors[key] = issue.message + } + } + if (Object.keys(errors).length === 0) { + errors["email"] = "Please check your details and try again." + } + setErrors(errors) + return + } + setIsLoading(true) + const { password, passwordConfirm } = result.output + email = result.output.email + if (isFirstRun) { + // check that passwords match + if (password !== passwordConfirm) { + const msg = "Passwords do not match" + setErrors({ passwordConfirm: msg }) + return + } + await pb.send("/api/beszel/create-user", { + method: "POST", + body: JSON.stringify({ email, password }), + }) + await pb.collection("users").authWithPassword(email, password) + } else { + await pb.collection("users").authWithPassword(email, password) + } + $authenticated.set(true) + } catch (err: any) { + const mfaId = err?.response?.mfaId + if (!mfaId) { + showLoginFaliedToast() + throw err + } + setMfaId(mfaId) + try { + const { otpId } = await pb.collection("users").requestOTP(email) + setOtpId(otpId) + } catch (err) { + console.log({ err }) + showLoginFaliedToast() + } + } finally { + setIsLoading(false) + } + }, + [isFirstRun] + ) + + const authProviders = authMethods.oauth2.providers ?? [] + const oauthEnabled = authMethods.oauth2.enabled && authProviders.length > 0 + const passwordEnabled = authMethods.password.enabled + const otpEnabled = authMethods.otp.enabled + const mfaEnabled = authMethods.mfa.enabled + + function loginWithOauth(provider: AuthProviderInfo, forcePopup = false) { + setIsOauthLoading(true) + + if (globalThis.BESZEL.OAUTH_DISABLE_POPUP) { + redirectToOauthProvider(provider) + return + } + + const oAuthOpts: OAuth2AuthConfig = { + provider: provider.name, + } + // https://github.com/pocketbase/pocketbase/discussions/2429#discussioncomment-5943061 + if (forcePopup || navigator.userAgent.match(/iPhone|iPad|iPod/i)) { + const authWindow = window.open() + if (!authWindow) { + setIsOauthLoading(false) + showLoginFaliedToast(t`Please enable pop-ups for this site`) + return + } + oAuthOpts.urlCallback = (url) => { + authWindow.location.href = url + } + } + pb.collection("users") + .authWithOAuth2(oAuthOpts) + .then(() => { + $authenticated.set(pb.authStore.isValid) + }) + .catch(showLoginFaliedToast) + .finally(() => { + setIsOauthLoading(false) + }) + } + + /** + * Redirects the user to the OAuth provider's authentication page in the same window. + * Requires the app's base URL to be registered as a redirect URI with the OAuth provider. + */ + function redirectToOauthProvider(provider: AuthProviderInfo) { + const url = new URL(provider.authURL) + // url.searchParams.set("redirect_uri", `${window.location.origin}${basePath}`) + sessionStorage.setItem("provider", JSON.stringify(provider)) + window.location.href = url.toString() + } + + useEffect(() => { + // handle redirect-based OAuth callback if we have a code + const params = new URLSearchParams(window.location.search) + const code = params.get("code") + if (code) { + const state = params.get("state") + const provider: AuthProviderInfo = JSON.parse(sessionStorage.getItem("provider") ?? "{}") + if (!state || provider.state !== state) { + showLoginFaliedToast() + } else { + setIsOauthLoading(true) + window.history.replaceState({}, "", window.location.pathname) + pb.collection("users") + .authWithOAuth2Code(provider.name, code, provider.codeVerifier, `${window.location.origin}${basePath}`) + .then(() => $authenticated.set(pb.authStore.isValid)) + .catch((e: unknown) => showLoginFaliedToast((e as Error).message)) + .finally(() => setIsOauthLoading(false)) + } + } + + // auto login if password disabled and only one auth provider + if (!code && !passwordEnabled && authProviders.length === 1 && !sessionStorage.getItem("lo")) { + // Add a small timeout to ensure browser is ready to handle popups + setTimeout(() => loginWithOauth(authProviders[0], false), 300) + return + } + + // refresh auth if not in above states (required for trusted auth header) + pb.collection("users") + .authRefresh() + .then((res) => { + pb.authStore.save(res.token, res.record) + $authenticated.set(!!pb.authStore.isValid)