From 2f2c2b422242e8211e3d88247a845140346c3085 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 14 Jan 2026 19:15:19 -0800 Subject: [PATCH] improved org idp login flow --- messages/en-US.json | 44 ++- server/routers/auth/index.ts | 3 +- server/routers/auth/lookupUser.ts | 224 ++++++++++++++++ server/routers/external.ts | 15 ++ src/app/auth/layout.tsx | 4 +- src/app/auth/login/page.tsx | 130 +++++++-- src/app/auth/signup/page.tsx | 2 + src/components/DashboardLoginForm.tsx | 33 --- src/components/DeviceLoginForm.tsx | 192 ++++++++----- src/components/LoginCardHeader.tsx | 33 +++ src/components/LoginForm.tsx | 307 +++------------------ src/components/LoginOrgSelector.tsx | 155 +++++++++++ src/components/LoginPasswordForm.tsx | 326 +++++++++++++++++++++++ src/components/MfaInputForm.tsx | 169 ++++++++++++ src/components/OrgLoginPage.tsx | 8 + src/components/OrgSignInLink.tsx | 107 ++++++++ src/components/SecurityKeyAuthButton.tsx | 157 +++++++++++ src/components/SignupForm.tsx | 38 ++- src/components/SmartLoginForm.tsx | 232 ++++++++++++++++ src/components/UserProfileCard.tsx | 52 ++++ src/components/VerifyEmailForm.tsx | 2 +- src/hooks/useUserLookup.ts | 51 ++++ 22 files changed, 1872 insertions(+), 412 deletions(-) create mode 100644 server/routers/auth/lookupUser.ts create mode 100644 src/components/LoginCardHeader.tsx create mode 100644 src/components/LoginOrgSelector.tsx create mode 100644 src/components/LoginPasswordForm.tsx create mode 100644 src/components/MfaInputForm.tsx create mode 100644 src/components/OrgSignInLink.tsx create mode 100644 src/components/SecurityKeyAuthButton.tsx create mode 100644 src/components/SmartLoginForm.tsx create mode 100644 src/components/UserProfileCard.tsx create mode 100644 src/hooks/useUserLookup.ts diff --git a/messages/en-US.json b/messages/en-US.json index 2e09fac3..82b419bb 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -874,7 +874,7 @@ "inviteAlready": "Looks like you've been invited!", "inviteAlreadyDescription": "To accept the invite, you must log in or create an account.", "signupQuestion": "Already have an account?", - "login": "Log in", + "login": "Log In", "resourceNotFound": "Resource Not Found", "resourceNotFoundDescription": "The resource you're trying to access does not exist.", "pincodeRequirementsLength": "PIN must be exactly 6 digits", @@ -954,13 +954,13 @@ "passwordExpiryDescription": "This organization requires you to change your password every {maxDays} days.", "changePasswordNow": "Change Password Now", "pincodeAuth": "Authenticator Code", - "pincodeSubmit2": "Submit Code", + "pincodeSubmit2": "Submit code", "passwordResetSubmit": "Request Reset", "passwordResetAlreadyHaveCode": "Enter Code", "passwordResetSmtpRequired": "Please contact your administrator", "passwordResetSmtpRequiredDescription": "A password reset code is required to reset your password. Please contact your administrator for assistance.", "passwordBack": "Back to Password", - "loginBack": "Go back to log in", + "loginBack": "Go back to main login page", "signup": "Sign up", "loginStart": "Log in to get started", "idpOidcTokenValidating": "Validating OIDC token", @@ -1138,14 +1138,14 @@ "searchProgress": "Search...", "create": "Create", "orgs": "Organizations", - "loginError": "An error occurred while logging in", + "loginError": "An unexpected error occurred. Please try again.", "loginRequiredForDevice": "Login is required for your device.", "passwordForgot": "Forgot your password?", "otpAuth": "Two-Factor Authentication", "otpAuthDescription": "Enter the code from your authenticator app or one of your single-use backup codes.", "otpAuthSubmit": "Submit Code", "idpContinue": "Or continue with", - "otpAuthBack": "Back to Log In", + "otpAuthBack": "Back to Password", "navbar": "Navigation Menu", "navbarDescription": "Main navigation menu for the application", "navbarDocsLink": "Documentation", @@ -1424,7 +1424,7 @@ "securityKeyRemoveSuccess": "Security key removed successfully", "securityKeyRemoveError": "Failed to remove security key", "securityKeyLoadError": "Failed to load security keys", - "securityKeyLogin": "Continue with security key", + "securityKeyLogin": "Use Security Key", "securityKeyAuthError": "Failed to authenticate with security key", "securityKeyRecommendation": "Register a backup security key on another device to ensure you always have access to your account.", "registering": "Registering...", @@ -1880,7 +1880,7 @@ "orgAuthChooseIdpDescription": "Choose your identity provider to continue", "orgAuthNoIdpConfigured": "This organization doesn't have any identity providers configured. You can log in with your Pangolin identity instead.", "orgAuthSignInWithPangolin": "Sign in with Pangolin", - "orgAuthSignInToOrg": "Use organization's identity provider", + "orgAuthSignInToOrg": "Sign in to an organization", "orgAuthSelectOrgTitle": "Organization Sign In", "orgAuthSelectOrgDescription": "Enter your organization ID to continue", "orgAuthOrgIdPlaceholder": "your-organization", @@ -2236,6 +2236,8 @@ "deviceCodeInvalidFormat": "Code must be 9 characters (e.g., A1AJ-N5JD)", "deviceCodeInvalidOrExpired": "Invalid or expired code", "deviceCodeVerifyFailed": "Failed to verify device code", + "deviceCodeValidating": "Validating device code...", + "deviceCodeVerifying": "Verifying device authorization...", "signedInAs": "Signed in as", "deviceCodeEnterPrompt": "Enter the code displayed on the device", "continue": "Continue", @@ -2310,6 +2312,7 @@ "identifier": "Identifier", "deviceLoginUseDifferentAccount": "Not you? Use a different account.", "deviceLoginDeviceRequestingAccessToAccount": "A device is requesting access to this account.", + "loginSelectAuthenticationMethod": "Select an authentication method to continue.", "noData": "No Data", "machineClients": "Machine Clients", "install": "Install", @@ -2424,5 +2427,30 @@ "blockClientQuestion": "Are you sure you want to block this client?", "blockClientMessage": "The device will be forced to disconnect if currently connected. You can unblock the device later.", "blockClientConfirm": "Block Client", - "active": "Active" + "active": "Active", + "usernameOrEmail": "Username or Email", + "selectYourOrganization": "Select your organization", + "signInTo": "Log in in to", + "signInWithPassword": "Continue with Password", + "noAuthMethodsAvailable": "No authentication methods available for this organization.", + "enterPassword": "Enter your password", + "enterMfaCode": "Enter the code from your authenticator app", + "securityKeyRequired": "Please use your security key to sign in.", + "needToUseAnotherAccount": "Need to use a different account?", + "loginLegalDisclaimer": "By clicking the buttons below, you acknowledge you have read, understand, and agree to the Terms of Service and Privacy Policy.", + "termsOfService": "Terms of Service", + "privacyPolicy": "Privacy Policy", + "userNotFoundWithUsername": "No user found with that username.", + "verify": "Verify", + "signIn": "Sign In", + "forgotPassword": "Forgot password?", + "orgSignInTip": "If you've logged in before, you can enter your username or email above to authenticate with your organization's identity provider instead. It's easier!", + "continueAnyway": "Continue anyway", + "dontShowAgain": "Don't show again", + "orgSignInNotice": "Did you know?", + "signupOrgNotice": "Trying to sign in?", + "signupOrgTip": "Are you trying to sign in through your organization's identity provider?", + "signupOrgLink": "Sign in or sign up with your organization instead", + "verifyEmailLogInWithDifferentAccount": "Use a Different Account", + "logIn": "Log In" } diff --git a/server/routers/auth/index.ts b/server/routers/auth/index.ts index 4600a4cc..ee08d155 100644 --- a/server/routers/auth/index.ts +++ b/server/routers/auth/index.ts @@ -16,4 +16,5 @@ export * from "./checkResourceSession"; export * from "./securityKey"; export * from "./startDeviceWebAuth"; export * from "./verifyDeviceWebAuth"; -export * from "./pollDeviceWebAuth"; \ No newline at end of file +export * from "./pollDeviceWebAuth"; +export * from "./lookupUser"; \ No newline at end of file diff --git a/server/routers/auth/lookupUser.ts b/server/routers/auth/lookupUser.ts new file mode 100644 index 00000000..83894927 --- /dev/null +++ b/server/routers/auth/lookupUser.ts @@ -0,0 +1,224 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { + users, + userOrgs, + orgs, + idpOrg, + idp, + idpOidcConfig +} from "@server/db"; +import { eq, or, sql, and, isNotNull, inArray } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { UserType } from "@server/types/UserTypes"; + +const lookupBodySchema = z.strictObject({ + identifier: z.string().min(1).toLowerCase() +}); + +export type LookupUserResponse = { + found: boolean; + identifier: string; + accounts: Array<{ + userId: string; + email: string | null; + username: string; + hasInternalAuth: boolean; + orgs: Array<{ + orgId: string; + orgName: string; + idps: Array<{ + idpId: number; + name: string; + variant: string | null; + }>; + hasInternalAuth: boolean; + }>; + }>; +}; + +// registry.registerPath({ +// method: "post", +// path: "/auth/lookup-user", +// description: "Lookup user accounts by username or email and return available authentication methods.", +// tags: [OpenAPITags.Auth], +// request: { +// body: lookupBodySchema +// }, +// responses: {} +// }); + +export async function lookupUser( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = lookupBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { identifier } = parsedBody.data; + + // Query users matching identifier (case-insensitive) + // Match by username OR email + const matchingUsers = await db + .select({ + userId: users.userId, + email: users.email, + username: users.username, + type: users.type, + passwordHash: users.passwordHash, + idpId: users.idpId + }) + .from(users) + .where( + or( + sql`LOWER(${users.username}) = ${identifier}`, + sql`LOWER(${users.email}) = ${identifier}` + ) + ); + + if (!matchingUsers || matchingUsers.length === 0) { + return response(res, { + data: { + found: false, + identifier, + accounts: [] + }, + success: true, + error: false, + message: "No accounts found", + status: HttpCode.OK + }); + } + + // Get unique user IDs + const userIds = [...new Set(matchingUsers.map((u) => u.userId))]; + + // Get all org memberships for these users + const orgMemberships = await db + .select({ + userId: userOrgs.userId, + orgId: userOrgs.orgId, + orgName: orgs.name + }) + .from(userOrgs) + .innerJoin(orgs, eq(orgs.orgId, userOrgs.orgId)) + .where(inArray(userOrgs.userId, userIds)); + + // Get unique org IDs + const orgIds = [...new Set(orgMemberships.map((m) => m.orgId))]; + + // Get all IdPs for these orgs + const orgIdps = + orgIds.length > 0 + ? await db + .select({ + orgId: idpOrg.orgId, + idpId: idp.idpId, + idpName: idp.name, + variant: idpOidcConfig.variant + }) + .from(idpOrg) + .innerJoin(idp, eq(idp.idpId, idpOrg.idpId)) + .innerJoin( + idpOidcConfig, + eq(idpOidcConfig.idpId, idp.idpId) + ) + .where(inArray(idpOrg.orgId, orgIds)) + : []; + + // Build response structure + const accounts: LookupUserResponse["accounts"] = []; + + for (const user of matchingUsers) { + const hasInternalAuth = + user.type === UserType.Internal && user.passwordHash !== null; + + // Get orgs for this user + const userOrgMemberships = orgMemberships.filter( + (m) => m.userId === user.userId + ); + + // Deduplicate orgs (user might have multiple memberships in same org) + const uniqueOrgs = new Map(); + for (const membership of userOrgMemberships) { + if (!uniqueOrgs.has(membership.orgId)) { + uniqueOrgs.set(membership.orgId, membership); + } + } + + const orgsData = Array.from(uniqueOrgs.values()).map((membership) => { + // Get IdPs for this org where the user (with the exact identifier) is authenticated via that IdP + // Only show IdPs where the user's idpId matches + // Internal users don't have an idpId, so they won't see any IdPs + const orgIdpsList = orgIdps + .filter((idp) => { + if (idp.orgId !== membership.orgId) { + return false; + } + // Only show IdPs where the user (with exact identifier) is authenticated via that IdP + // This means user.idpId must match idp.idpId + if (user.idpId !== null && user.idpId === idp.idpId) { + return true; + } + return false; + }) + .map((idp) => ({ + idpId: idp.idpId, + name: idp.idpName, + variant: idp.variant + })); + + // Check if user has internal auth for this org + // User has internal auth if they have an internal account type + const orgHasInternalAuth = hasInternalAuth; + + return { + orgId: membership.orgId, + orgName: membership.orgName, + idps: orgIdpsList, + hasInternalAuth: orgHasInternalAuth + }; + }); + + accounts.push({ + userId: user.userId, + email: user.email, + username: user.username, + hasInternalAuth, + orgs: orgsData + }); + } + + return response(res, { + data: { + found: true, + identifier, + accounts + }, + success: true, + error: false, + message: "User lookup completed", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/external.ts b/server/routers/external.ts index 9b6490a5..89688bf6 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -1107,6 +1107,21 @@ authRouter.post( auth.login ); authRouter.post("/logout", auth.logout); +authRouter.post( + "/lookup-user", + rateLimit({ + windowMs: 15 * 60 * 1000, + max: 15, + keyGenerator: (req) => + `lookupUser:${req.body.identifier || ipKeyGenerator(req.ip || "")}`, + handler: (req, res, next) => { + const message = `You can only lookup users ${15} times every ${15} minutes. Please try again later.`; + return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); + }, + store: createStore() + }), + auth.lookupUser +); authRouter.post( "/newt/get-token", rateLimit({ diff --git a/src/app/auth/layout.tsx b/src/app/auth/layout.tsx index fae271f5..997ca3fb 100644 --- a/src/app/auth/layout.tsx +++ b/src/app/auth/layout.tsx @@ -113,7 +113,7 @@ export default async function AuthLayout({ children }: AuthLayoutProps) { aria-label="GitHub" className="flex items-center space-x-2 whitespace-nowrap" > - {t("terms")} + {t("termsOfService")} - {t("privacy")} + {t("privacyPolicy")} )} diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index 0c9faafc..071020cd 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -1,19 +1,22 @@ import { verifySession } from "@app/lib/auth/verifySession"; import Link from "next/link"; import { redirect } from "next/navigation"; +import OrgSignInLink from "@app/components/OrgSignInLink"; import { cache } from "react"; +import SmartLoginForm from "@app/components/SmartLoginForm"; import DashboardLoginForm from "@app/components/DashboardLoginForm"; import { Mail } from "lucide-react"; import { pullEnv } from "@app/lib/pullEnv"; import { cleanRedirect } from "@app/lib/cleanRedirect"; -import { idp } from "@server/db"; -import { LoginFormIDP } from "@app/components/LoginForm"; -import { priv } from "@app/lib/api"; -import { AxiosResponse } from "axios"; -import { ListIdpsResponse } from "@server/routers/idp"; import { getTranslations } from "next-intl/server"; import { build } from "@server/build"; import { LoadLoginPageResponse } from "@server/routers/loginPage/types"; +import { Card, CardContent } from "@app/components/ui/card"; +import LoginCardHeader from "@app/components/LoginCardHeader"; +import { priv } from "@app/lib/api"; +import { AxiosResponse } from "axios"; +import { LoginFormIDP } from "@app/components/LoginForm"; +import { ListIdpsResponse } from "@server/routers/idp"; export const dynamic = "force-dynamic"; @@ -69,22 +72,57 @@ export default async function Page(props: { searchParams.redirect = redirectUrl; } + // Only use SmartLoginForm if NOT (OSS build OR org-only IdP enabled) + const useSmartLogin = + build === "saas" || (build === "enterprise" && env.flags.useOrgOnlyIdp); + let loginIdps: LoginFormIDP[] = []; - if (build === "oss" || !env.flags.useOrgOnlyIdp) { - const idpsRes = await cache( - async () => await priv.get>("/idp") - )(); - loginIdps = idpsRes.data.data.idps.map((idp) => ({ - idpId: idp.idpId, - name: idp.name, - variant: idp.type - })) as LoginFormIDP[]; + if (!useSmartLogin) { + // Load IdPs for DashboardLoginForm (OSS or org-only IdP mode) + if (build === "oss" || !env.flags.useOrgOnlyIdp) { + const idpsRes = await cache( + async () => + await priv.get>("/idp") + )(); + loginIdps = idpsRes.data.data.idps.map((idp) => ({ + idpId: idp.idpId, + name: idp.name, + variant: idp.type + })) as LoginFormIDP[]; + } } const t = await getTranslations(); return ( <> + {build === "saas" && ( +

+ {t.rich("loginLegalDisclaimer", { + termsOfService: (chunks) => ( + + {chunks} + + ), + privacyPolicy: (chunks) => ( + + {chunks} + + ) + })} +

+ )} + {isInvite && (
@@ -99,15 +137,36 @@ export default async function Page(props: {
)} - + {useSmartLogin ? ( + <> + + + + + + + + ) : ( + + )} {(!signUpDisabled || isInvite) && (

@@ -124,6 +183,31 @@ export default async function Page(props: {

)} + + {!isInvite && (build === "saas" || env.flags.useOrgOnlyIdp) ? ( + + ) : null} ); } + +function buildQueryString(searchParams: { + [key: string]: string | string[] | undefined; +}): string { + const params = new URLSearchParams(); + const redirect = searchParams.redirect; + const forceLogin = searchParams.forceLogin; + + if (redirect && typeof redirect === "string") { + params.set("redirect", redirect); + } + if (forceLogin && typeof forceLogin === "string") { + params.set("forceLogin", forceLogin); + } + const queryString = params.toString(); + return queryString ? `?${queryString}` : ""; +} diff --git a/src/app/auth/signup/page.tsx b/src/app/auth/signup/page.tsx index b4f4fddd..9ab7b7e6 100644 --- a/src/app/auth/signup/page.tsx +++ b/src/app/auth/signup/page.tsx @@ -14,6 +14,7 @@ export default async function Page(props: { searchParams: Promise<{ redirect: string | undefined; email: string | undefined; + fromSmartLogin: string | undefined; }>; }) { const searchParams = await props.searchParams; @@ -73,6 +74,7 @@ export default async function Page(props: { inviteToken={inviteToken} inviteId={inviteId} emailParam={searchParams.email} + fromSmartLogin={searchParams.fromSmartLogin === "true"} />

diff --git a/src/components/DashboardLoginForm.tsx b/src/components/DashboardLoginForm.tsx index 5082f00d..f57eb8b1 100644 --- a/src/components/DashboardLoginForm.tsx +++ b/src/components/DashboardLoginForm.tsx @@ -69,22 +69,6 @@ export default function DashboardLoginForm({

{getSubtitle()}

- {showOrgLogin && ( -
- - - -
- )} ); } - -function buildQueryString(searchParams: { - [key: string]: string | string[] | undefined; -}): string { - const params = new URLSearchParams(); - const redirect = searchParams.redirect; - const forceLogin = searchParams.forceLogin; - - if (redirect && typeof redirect === "string") { - params.set("redirect", redirect); - } - if (forceLogin && typeof forceLogin === "string") { - params.set("forceLogin", forceLogin); - } - const queryString = params.toString(); - return queryString ? `?${queryString}` : ""; -} diff --git a/src/components/DeviceLoginForm.tsx b/src/components/DeviceLoginForm.tsx index 777c1c03..c6972d56 100644 --- a/src/components/DeviceLoginForm.tsx +++ b/src/components/DeviceLoginForm.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect, useCallback } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import * as z from "zod"; @@ -13,7 +13,13 @@ import { FormLabel, FormMessage } from "@/components/ui/form"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Card, + CardContent, + CardHeader, + CardTitle, + CardDescription +} from "@/components/ui/card"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; @@ -25,12 +31,12 @@ import { InputOTPSlot } from "@/components/ui/input-otp"; import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp"; -import { AlertTriangle } from "lucide-react"; +import { AlertTriangle, Loader2 } from "lucide-react"; import { DeviceAuthConfirmation } from "@/components/DeviceAuthConfirmation"; import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; import BrandingLogo from "./BrandingLogo"; import { useTranslations } from "next-intl"; -import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import UserProfileCard from "@/components/UserProfileCard"; const createFormSchema = (t: (key: string) => string) => z.object({ @@ -61,6 +67,8 @@ export default function DeviceLoginForm({ const api = createApiClient({ env }); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); + const [validatingInitialCode, setValidatingInitialCode] = useState(false); + const [verifyingInitialCode, setVerifyingInitialCode] = useState(false); const [metadata, setMetadata] = useState(null); const [code, setCode] = useState(""); const { isUnlocked } = useLicenseStatusContext(); @@ -75,39 +83,88 @@ export default function DeviceLoginForm({ } }); - async function onSubmit(data: z.infer) { - setError(null); - setLoading(true); + const validateCode = useCallback( + async (codeToValidate: string, skipConfirmation = false) => { + setError(null); + setLoading(true); - try { - // split code and add dash if missing - if (!data.code.includes("-") && data.code.length === 8) { - data.code = data.code.slice(0, 4) + "-" + data.code.slice(4); - } - - // First check - get metadata - const res = await api.post( - "/device-web-auth/verify?forceLogin=true", - { - code: data.code.toUpperCase(), - verify: false + try { + // split code and add dash if missing + let formattedCode = codeToValidate; + if ( + !formattedCode.includes("-") && + formattedCode.length === 8 + ) { + formattedCode = + formattedCode.slice(0, 4) + + "-" + + formattedCode.slice(4); } - ); - if (res.data.success && res.data.data.metadata) { - setMetadata(res.data.data.metadata); - setCode(data.code.toUpperCase()); - } else { - setError(t("deviceCodeInvalidOrExpired")); + // First check - get metadata + const res = await api.post( + "/device-web-auth/verify?forceLogin=true", + { + code: formattedCode.toUpperCase(), + verify: false + } + ); + + if (res.data.success && res.data.data.metadata) { + setCode(formattedCode.toUpperCase()); + + // If skipping confirmation (initial code), go straight to verify + if (skipConfirmation) { + setVerifyingInitialCode(true); + try { + await api.post("/device-web-auth/verify", { + code: formattedCode.toUpperCase(), + verify: true + }); + router.push("/auth/login/device/success"); + } catch (e: any) { + const errorMessage = formatAxiosError(e); + setError( + errorMessage || t("deviceCodeVerifyFailed") + ); + setVerifyingInitialCode(false); + return false; + } + return true; + } else { + setMetadata(res.data.data.metadata); + return true; + } + } else { + setError(t("deviceCodeInvalidOrExpired")); + return false; + } + } catch (e: any) { + const errorMessage = formatAxiosError(e); + setError(errorMessage || t("deviceCodeInvalidOrExpired")); + return false; + } finally { + setLoading(false); } - } catch (e: any) { - const errorMessage = formatAxiosError(e); - setError(errorMessage || t("deviceCodeInvalidOrExpired")); - } finally { - setLoading(false); - } + }, + [api, t, router] + ); + + async function onSubmit(data: z.infer) { + await validateCode(data.code); } + // Auto-validate initial code if provided + useEffect(() => { + const cleanedInitialCode = initialCode.replace(/-/g, "").toUpperCase(); + if (cleanedInitialCode && cleanedInitialCode.length === 8) { + setValidatingInitialCode(true); + validateCode(cleanedInitialCode, true).finally(() => { + setValidatingInitialCode(false); + }); + } + }, [initialCode, validateCode]); + async function onConfirm() { if (!code || !metadata) return; @@ -149,9 +206,6 @@ export default function DeviceLoginForm({ } const profileLabel = (userName || userEmail || "").trim(); - const profileInitial = profileLabel - ? profileLabel.charAt(0).toUpperCase() - : "?"; async function handleUseDifferentAccount() { try { @@ -172,6 +226,39 @@ export default function DeviceLoginForm({ } } + // Show loading state while validating/verifying initial code + if (validatingInitialCode || verifyingInitialCode) { + return ( +
+ + + {t("deviceActivation")} + + {validatingInitialCode + ? t("deviceCodeValidating") + : t("deviceCodeVerifying")} + + + +
+ + + {validatingInitialCode + ? t("deviceCodeValidating") + : t("deviceCodeVerifying")} + +
+ {error && ( + + {error} + + )} +
+
+
+ ); + } + if (metadata) { return (
- -
- - {profileInitial} - -
-
-

- {profileLabel || userEmail} -

-

- {t( - "deviceLoginDeviceRequestingAccessToAccount" - )} -

-
- -
-
+ +
+
+ +
+
+

{subtitle}

+
+ + ); +} diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx index 49bcc69b..5497826c 100644 --- a/src/components/LoginForm.tsx +++ b/src/components/LoginForm.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState, useRef } from "react"; +import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import * as z from "zod"; @@ -23,32 +23,24 @@ import { } from "@app/components/ui/card"; import { Alert, AlertDescription } from "@app/components/ui/alert"; import { useParams, useRouter } from "next/navigation"; -import { LockIcon, FingerprintIcon } from "lucide-react"; +import { LockIcon } from "lucide-react"; +import SecurityKeyAuthButton from "@app/components/SecurityKeyAuthButton"; import { createApiClient } from "@app/lib/api"; -import { - InputOTP, - InputOTPGroup, - InputOTPSeparator, - InputOTPSlot -} from "./ui/input-otp"; import Link from "next/link"; -import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp"; import Image from "next/image"; import { GenerateOidcUrlResponse } from "@server/routers/idp"; import { Separator } from "./ui/separator"; import { useTranslations } from "next-intl"; -import { startAuthentication } from "@simplewebauthn/browser"; import { generateOidcUrlProxy, - loginProxy, - securityKeyStartProxy, - securityKeyVerifyProxy + loginProxy } from "@app/actions/server"; import { redirect as redirectTo } from "next/navigation"; import { useEnvContext } from "@app/hooks/useEnvContext"; // @ts-ignore import { loadReoScript } from "reodotdev"; import { build } from "@server/build"; +import MfaInputForm from "@app/components/MfaInputForm"; export type LoginFormIDP = { idpId: number; @@ -83,8 +75,6 @@ export default function LoginForm({ const hasIdp = idps && idps.length > 0; const [mfaRequested, setMfaRequested] = useState(false); - const [showSecurityKeyPrompt, setShowSecurityKeyPrompt] = useState(false); - const otpContainerRef = useRef(null); const t = useTranslations(); const currentHost = @@ -113,52 +103,6 @@ export default function LoginForm({ } }, []); - // Auto-focus MFA input when MFA is requested - useEffect(() => { - if (!mfaRequested) return; - - const focusInput = () => { - // Try using the ref first - if (otpContainerRef.current) { - const hiddenInput = otpContainerRef.current.querySelector( - "input" - ) as HTMLInputElement; - if (hiddenInput) { - hiddenInput.focus(); - return; - } - } - - // Fallback: query the DOM - const otpContainer = document.querySelector( - '[data-slot="input-otp"]' - ); - if (!otpContainer) return; - - const hiddenInput = otpContainer.querySelector( - "input" - ) as HTMLInputElement; - if (hiddenInput) { - hiddenInput.focus(); - return; - } - - // Last resort: click the first slot - const firstSlot = otpContainer.querySelector( - '[data-slot="input-otp-slot"]' - ) as HTMLElement; - if (firstSlot) { - firstSlot.click(); - } - }; - - // Use requestAnimationFrame to wait for the next paint - requestAnimationFrame(() => { - requestAnimationFrame(() => { - focusInput(); - }); - }); - }, [mfaRequested]); const formSchema = z.object({ email: z.string().email({ message: t("emailInvalid") }), @@ -184,97 +128,6 @@ export default function LoginForm({ } }); - async function initiateSecurityKeyAuth() { - setShowSecurityKeyPrompt(true); - setLoading(true); - setError(null); - - try { - // Start WebAuthn authentication without email - const startResponse = await securityKeyStartProxy({}, forceLogin); - - if (startResponse.error) { - setError(startResponse.message); - return; - } - - const { tempSessionId, ...options } = startResponse.data!; - - // Perform WebAuthn authentication - try { - const credential = await startAuthentication({ - optionsJSON: { - ...options, - userVerification: options.userVerification as - | "required" - | "preferred" - | "discouraged" - } - }); - - // Verify authentication - const verifyResponse = await securityKeyVerifyProxy( - { credential }, - tempSessionId, - forceLogin - ); - - if (verifyResponse.error) { - setError(verifyResponse.message); - return; - } - - if (verifyResponse.success) { - if (onLogin) { - await onLogin(redirect); - } - } - } catch (error: any) { - if (error.name === "NotAllowedError") { - if (error.message.includes("denied permission")) { - setError( - t("securityKeyPermissionDenied", { - defaultValue: - "Please allow access to your security key to continue signing in." - }) - ); - } else { - setError( - t("securityKeyRemovedTooQuickly", { - defaultValue: - "Please keep your security key connected until the sign-in process completes." - }) - ); - } - } else if (error.name === "NotSupportedError") { - setError( - t("securityKeyNotSupported", { - defaultValue: - "Your security key may not be compatible. Please try a different security key." - }) - ); - } else { - setError( - t("securityKeyUnknownError", { - defaultValue: - "There was a problem using your security key. Please try again." - }) - ); - } - } - } catch (e: any) { - console.error(e); - setError( - t("securityKeyAuthError", { - defaultValue: - "An unexpected error occurred. Please try again." - }) - ); - } finally { - setLoading(false); - setShowSecurityKeyPrompt(false); - } - } async function onSubmit(values: any) { const { email, password } = form.getValues(); @@ -282,7 +135,6 @@ export default function LoginForm({ setLoading(true); setError(null); - setShowSecurityKeyPrompt(false); try { const response = await loginProxy( @@ -323,7 +175,12 @@ export default function LoginForm({ } if (data.useSecurityKey) { - await initiateSecurityKeyAuth(); + setError( + t("securityKeyRequired", { + defaultValue: + "Please use your security key to sign in." + }) + ); return; } @@ -409,18 +266,6 @@ export default function LoginForm({ return (
- {showSecurityKeyPrompt && ( - - - - {t("securityKeyPrompt", { - defaultValue: - "Please verify your identity using your security key. Make sure your security key is connected and ready." - })} - - - )} - {!mfaRequested && ( <> @@ -488,115 +333,36 @@ export default function LoginForm({ )} {mfaRequested && ( - <> -
-

{t("otpAuth")}

-

- {t("otpAuthDescription")} -

-
- - - ( - - -
- { - field.onChange(value); - if ( - value.length === 6 - ) { - mfaForm.handleSubmit( - onSubmit - )(); - } - }} - > - - - - - - - - - -
-
- -
- )} - /> - - - + { + setMfaRequested(false); + mfaForm.reset(); + }} + error={error} + loading={loading} + formId="form" + /> )} - {error && ( + {!mfaRequested && error && ( {error} )}
- {mfaRequested && ( - - )} {!mfaRequested && ( <> - + {hasIdp && ( <> @@ -652,19 +418,6 @@ export default function LoginForm({ )} - {mfaRequested && ( - - )}
); diff --git a/src/components/LoginOrgSelector.tsx b/src/components/LoginOrgSelector.tsx new file mode 100644 index 00000000..a7b52414 --- /dev/null +++ b/src/components/LoginOrgSelector.tsx @@ -0,0 +1,155 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@app/components/ui/button"; +import { Alert, AlertDescription } from "@app/components/ui/alert"; +import { useTranslations } from "next-intl"; +import { Separator } from "./ui/separator"; +import LoginPasswordForm from "./LoginPasswordForm"; +import IdpLoginButtons from "./private/IdpLoginButtons"; +import { LookupUserResponse } from "@server/routers/auth/lookupUser"; +import UserProfileCard from "./UserProfileCard"; + +type LoginOrgSelectorProps = { + identifier: string; + lookupResult: LookupUserResponse; + redirect?: string; + forceLogin?: boolean; + onUseDifferentAccount?: () => void; +}; + +export default function LoginOrgSelector({ + identifier, + lookupResult, + redirect, + forceLogin, + onUseDifferentAccount +}: LoginOrgSelectorProps) { + const t = useTranslations(); + const [showPasswordForm, setShowPasswordForm] = useState(false); + + // Collect all unique orgs from all accounts + const orgMap = new Map< + string, + { + orgId: string; + orgName: string; + idps: Array<{ + idpId: number; + name: string; + variant: string | null; + }>; + hasInternalAuth: boolean; + } + >(); + + for (const account of lookupResult.accounts) { + for (const org of account.orgs) { + if (!orgMap.has(org.orgId)) { + orgMap.set(org.orgId, { + orgId: org.orgId, + orgName: org.orgName, + idps: org.idps, + hasInternalAuth: org.hasInternalAuth + }); + } else { + // Merge IdPs if org appears in multiple accounts + const existing = orgMap.get(org.orgId)!; + const existingIdpIds = new Set( + existing.idps.map((i) => i.idpId) + ); + for (const idp of org.idps) { + if (!existingIdpIds.has(idp.idpId)) { + existing.idps.push(idp); + } + } + if (org.hasInternalAuth) { + existing.hasInternalAuth = true; + } + } + } + } + + const orgs = Array.from(orgMap.values()); + + // Check if there's an internal account (can only be one) + const hasInternalAccount = lookupResult.accounts.some( + (acc) => acc.hasInternalAuth + ); + + // If user selected password auth, show password form + if (showPasswordForm) { + return ( +
+ + +
+ ); + } + + return ( +
+ + + {hasInternalAccount && ( +
+ +
+ )} + +
+ {orgs.map((org, index) => { + const hasIdps = org.idps.length > 0; + + if (!hasIdps) { + return null; + } + + // Convert org.idps to LoginFormIDP format + const idps = org.idps.map((idp) => ({ + idpId: idp.idpId, + name: idp.name, + variant: idp.variant || undefined + })); + + return ( +
+
+

+ {org.orgName} +

+ +
+
+ ); + })} +
+
+ ); +} diff --git a/src/components/LoginPasswordForm.tsx b/src/components/LoginPasswordForm.tsx new file mode 100644 index 00000000..b2cb3175 --- /dev/null +++ b/src/components/LoginPasswordForm.tsx @@ -0,0 +1,326 @@ +"use client"; + +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +import { Button } from "@app/components/ui/button"; +import { Input } from "@app/components/ui/input"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { Alert, AlertDescription } from "@app/components/ui/alert"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { loginProxy } from "@app/actions/server"; +import Link from "next/link"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { cleanRedirect } from "@app/lib/cleanRedirect"; +import MfaInputForm from "@app/components/MfaInputForm"; + +type LoginPasswordFormProps = { + identifier: string; + redirect?: string; + forceLogin?: boolean; +}; + +export default function LoginPasswordForm({ + identifier, + redirect, + forceLogin +}: LoginPasswordFormProps) { + const router = useRouter(); + const { env } = useEnvContext(); + const t = useTranslations(); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const [mfaRequested, setMfaRequested] = useState(false); + + // Check if identifier is a valid email + const isEmail = (() => { + try { + z.string().email().parse(identifier); + return true; + } catch { + return false; + } + })(); + + const currentHost = + typeof window !== "undefined" ? window.location.hostname : ""; + const expectedHost = new URL(env.app.dashboardUrl).host; + const isExpectedHost = currentHost === expectedHost; + + const formSchema = z.object({ + password: z.string().min(8, { message: t("passwordRequirementsChars") }) + }); + + const mfaSchema = z.object({ + code: z.string().length(6, { message: t("pincodeInvalid") }) + }); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + password: "" + } + }); + + const mfaForm = useForm({ + resolver: zodResolver(mfaSchema), + defaultValues: { + code: "" + } + }); + + async function onSubmit(values: z.infer) { + const { password } = values; + const { code } = mfaForm.getValues(); + + setLoading(true); + setError(null); + + try { + const response = await loginProxy( + { + email: identifier, + password, + code, + resourceGuid: undefined + }, + forceLogin + ); + + if (response.error) { + setError(response.message); + return; + } + + const data = response.data; + + if (!data) { + // Already logged in + if (redirect) { + const safe = cleanRedirect(redirect); + router.replace(safe); + } else { + router.replace("/"); + } + return; + } + + if (data.useSecurityKey) { + setError(t("securityKeyRequired")); + return; + } + + if (data.codeRequested) { + setMfaRequested(true); + setLoading(false); + mfaForm.reset(); + return; + } + + if (data.emailVerificationRequired) { + if (!isExpectedHost) { + setError( + t("emailVerificationRequired", { + dashboardUrl: env.app.dashboardUrl + }) + ); + return; + } + if (redirect) { + router.push(`/auth/verify-email?redirect=${redirect}`); + } else { + router.push("/auth/verify-email"); + } + return; + } + + if (data.twoFactorSetupRequired) { + if (!isExpectedHost) { + setError( + t("twoFactorSetupRequired", { + dashboardUrl: env.app.dashboardUrl + }) + ); + return; + } + const setupUrl = `/auth/2fa/setup?email=${encodeURIComponent(identifier)}${redirect ? `&redirect=${encodeURIComponent(redirect)}` : ""}`; + router.push(setupUrl); + return; + } + + // Success + if (redirect) { + const safe = cleanRedirect(redirect); + router.replace(safe); + } else { + router.replace("/"); + } + } catch (e: any) { + console.error(e); + setError(t("loginError")); + } finally { + setLoading(false); + } + } + + async function onMfaSubmit(values: z.infer) { + const { password } = form.getValues(); + const { code } = values; + + setLoading(true); + setError(null); + + try { + const response = await loginProxy( + { + email: identifier, + password, + code, + resourceGuid: undefined + }, + forceLogin + ); + + if (response.error) { + setError(response.message); + setLoading(false); + return; + } + + const data = response.data; + + if (!data) { + if (redirect) { + const safe = cleanRedirect(redirect); + router.replace(safe); + } else { + router.replace("/"); + } + return; + } + + if (data.emailVerificationRequired) { + if (!isExpectedHost) { + setError( + t("emailVerificationRequired", { + dashboardUrl: env.app.dashboardUrl + }) + ); + return; + } + if (redirect) { + router.push(`/auth/verify-email?redirect=${redirect}`); + } else { + router.push("/auth/verify-email"); + } + return; + } + + if (data.twoFactorSetupRequired) { + if (!isExpectedHost) { + setError( + t("twoFactorSetupRequired", { + dashboardUrl: env.app.dashboardUrl + }) + ); + return; + } + const setupUrl = `/auth/2fa/setup?email=${encodeURIComponent(identifier)}${redirect ? `&redirect=${encodeURIComponent(redirect)}` : ""}`; + router.push(setupUrl); + return; + } + + // Success + if (redirect) { + const safe = cleanRedirect(redirect); + router.replace(safe); + } else { + router.replace("/"); + } + } catch (e: any) { + console.error(e); + setError(t("loginError")); + } finally { + setLoading(false); + } + } + + if (mfaRequested) { + return ( + { + setMfaRequested(false); + mfaForm.reset(); + }} + error={error} + loading={loading} + /> + ); + } + + return ( +
+
+ + ( + + {t("password")} + + + + + + )} + /> + + {error && ( + + {error} + + )} + +
+ + {t("passwordForgot")} + +
+ + + + +
+ ); +} diff --git a/src/components/MfaInputForm.tsx b/src/components/MfaInputForm.tsx new file mode 100644 index 00000000..d52b3169 --- /dev/null +++ b/src/components/MfaInputForm.tsx @@ -0,0 +1,169 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { UseFormReturn } from "react-hook-form"; +import { Button } from "@app/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormMessage +} from "@app/components/ui/form"; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot +} from "./ui/input-otp"; +import { Alert, AlertDescription } from "@app/components/ui/alert"; +import { useTranslations } from "next-intl"; +import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp"; +import * as z from "zod"; + +type MfaInputFormProps = { + form: UseFormReturn<{ code: string }>; + onSubmit: (values: { code: string }) => void | Promise; + onBack: () => void; + error?: string | null; + loading?: boolean; + formId?: string; +}; + +export default function MfaInputForm({ + form, + onSubmit, + onBack, + error, + loading = false, + formId = "mfaForm" +}: MfaInputFormProps) { + const t = useTranslations(); + const otpContainerRef = useRef(null); + + // Auto-focus MFA input when component mounts + useEffect(() => { + const focusInput = () => { + // Try using the ref first + if (otpContainerRef.current) { + const hiddenInput = otpContainerRef.current.querySelector( + "input" + ) as HTMLInputElement; + if (hiddenInput) { + hiddenInput.focus(); + return; + } + } + + // Fallback: query the DOM + const otpContainer = document.querySelector( + '[data-slot="input-otp"]' + ); + if (!otpContainer) return; + + const hiddenInput = otpContainer.querySelector( + "input" + ) as HTMLInputElement; + if (hiddenInput) { + hiddenInput.focus(); + return; + } + + // Last resort: click the first slot + const firstSlot = otpContainer.querySelector( + '[data-slot="input-otp-slot"]' + ) as HTMLElement; + if (firstSlot) { + firstSlot.click(); + } + }; + + // Use requestAnimationFrame to wait for the next paint + requestAnimationFrame(() => { + requestAnimationFrame(() => { + focusInput(); + }); + }); + }, []); + + return ( +
+
+

{t("otpAuth")}

+

+ {t("otpAuthDescription")} +

+
+
+ + ( + + +
+ { + field.onChange(value); + if (value.length === 6) { + form.handleSubmit(onSubmit)(); + } + }} + > + + + + + + + + + +
+
+ +
+ )} + /> + + + + {error && ( + + {error} + + )} + +
+ + +
+
+ ); +} diff --git a/src/components/OrgLoginPage.tsx b/src/components/OrgLoginPage.tsx index 5efb2a04..f2a8ae2a 100644 --- a/src/components/OrgLoginPage.tsx +++ b/src/components/OrgLoginPage.tsx @@ -116,6 +116,14 @@ export default async function OrgLoginPage({ )}
+

+ + {t("loginBack")} + +

); } diff --git a/src/components/OrgSignInLink.tsx b/src/components/OrgSignInLink.tsx new file mode 100644 index 00000000..819a1dc7 --- /dev/null +++ b/src/components/OrgSignInLink.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; +import { Button } from "@app/components/ui/button"; + +type OrgSignInLinkProps = { + href: string; + linkText: string; + descriptionText: string; +}; + +const STORAGE_KEY_CLICKED = "orgSignInLinkClicked"; +const STORAGE_KEY_ACKNOWLEDGED = "orgSignInTipAcknowledged"; + +export default function OrgSignInLink({ + href, + linkText, + descriptionText +}: OrgSignInLinkProps) { + const router = useRouter(); + const t = useTranslations(); + const [showTip, setShowTip] = useState(false); + + useEffect(() => { + // Check if tip was previously acknowledged + const acknowledged = + localStorage.getItem(STORAGE_KEY_ACKNOWLEDGED) === "true"; + if (acknowledged) { + // Clear the clicked flag if tip was acknowledged + localStorage.removeItem(STORAGE_KEY_CLICKED); + } + }, []); + + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + + const hasClickedBefore = + localStorage.getItem(STORAGE_KEY_CLICKED) === "true"; + const isAcknowledged = + localStorage.getItem(STORAGE_KEY_ACKNOWLEDGED) === "true"; + + if (hasClickedBefore && !isAcknowledged) { + // Second click (or later) - show tip + setShowTip(true); + } else { + // First click - store flag and navigate + localStorage.setItem(STORAGE_KEY_CLICKED, "true"); + router.push(href); + } + }; + + const handleContinueAnyway = () => { + setShowTip(false); + router.push(href); + }; + + const handleDontShowAgain = () => { + setShowTip(false); + localStorage.setItem(STORAGE_KEY_ACKNOWLEDGED, "true"); + localStorage.removeItem(STORAGE_KEY_CLICKED); + }; + + return ( + <> + {showTip && ( + + {t("orgSignInNotice")} + +

{t("orgSignInTip")}

+
+ + +
+
+
+ )} +
+ {descriptionText} + +
+ + ); +} diff --git a/src/components/SecurityKeyAuthButton.tsx b/src/components/SecurityKeyAuthButton.tsx new file mode 100644 index 00000000..184587fd --- /dev/null +++ b/src/components/SecurityKeyAuthButton.tsx @@ -0,0 +1,157 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@app/components/ui/button"; +import { FingerprintIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { startAuthentication } from "@simplewebauthn/browser"; +import { + securityKeyStartProxy, + securityKeyVerifyProxy +} from "@app/actions/server"; +import { useRouter } from "next/navigation"; +import { cleanRedirect } from "@app/lib/cleanRedirect"; + +type SecurityKeyAuthButtonProps = { + redirect?: string; + forceLogin?: boolean; + onSuccess?: (redirectUrl?: string) => void | Promise; + onError?: (error: string) => void; + disabled?: boolean; + className?: string; +}; + +export default function SecurityKeyAuthButton({ + redirect, + forceLogin, + onSuccess, + onError, + disabled: externalDisabled, + className +}: SecurityKeyAuthButtonProps) { + const router = useRouter(); + const t = useTranslations(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + async function initiateSecurityKeyAuth() { + setLoading(true); + setError(null); + + try { + // Start WebAuthn authentication without email + const startResponse = await securityKeyStartProxy({}, forceLogin); + + if (startResponse.error) { + const errorMessage = startResponse.message; + setError(errorMessage); + if (onError) { + onError(errorMessage); + } + setLoading(false); + return; + } + + const { tempSessionId, ...options } = startResponse.data!; + + // Perform WebAuthn authentication + try { + const credential = await startAuthentication({ + optionsJSON: { + ...options, + userVerification: options.userVerification as + | "required" + | "preferred" + | "discouraged" + } + }); + + // Verify authentication + const verifyResponse = await securityKeyVerifyProxy( + { credential }, + tempSessionId, + forceLogin + ); + + if (verifyResponse.error) { + const errorMessage = verifyResponse.message; + setError(errorMessage); + if (onError) { + onError(errorMessage); + } + setLoading(false); + return; + } + + if (verifyResponse.success) { + if (onSuccess) { + await onSuccess(redirect); + } else { + // Default behavior: redirect + if (redirect) { + const safe = cleanRedirect(redirect); + router.replace(safe); + } else { + router.replace("/"); + } + } + } + } catch (error: any) { + let errorMessage: string; + if (error.name === "NotAllowedError") { + if (error.message.includes("denied permission")) { + errorMessage = t("securityKeyPermissionDenied", { + defaultValue: + "Please allow access to your security key to continue signing in." + }); + } else { + errorMessage = t("securityKeyRemovedTooQuickly", { + defaultValue: + "Please keep your security key connected until the sign-in process completes." + }); + } + } else if (error.name === "NotSupportedError") { + errorMessage = t("securityKeyNotSupported", { + defaultValue: + "Your security key may not be compatible. Please try a different security key." + }); + } else { + errorMessage = t("securityKeyUnknownError", { + defaultValue: + "There was a problem using your security key. Please try again." + }); + } + setError(errorMessage); + if (onError) { + onError(errorMessage); + } + setLoading(false); + } + } catch (e: any) { + console.error(e); + const errorMessage = t("securityKeyAuthError", { + defaultValue: + "An unexpected error occurred. Please try again." + }); + setError(errorMessage); + if (onError) { + onError(errorMessage); + } + setLoading(false); + } + } + + return ( + + ); +} diff --git a/src/components/SignupForm.tsx b/src/components/SignupForm.tsx index 42364b06..f20856b4 100644 --- a/src/components/SignupForm.tsx +++ b/src/components/SignupForm.tsx @@ -16,7 +16,8 @@ import { FormMessage } from "@/components/ui/form"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; -import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import Link from "next/link"; import { Progress } from "@/components/ui/progress"; import { SignUpResponse } from "@server/routers/auth"; import { useRouter } from "next/navigation"; @@ -70,6 +71,7 @@ type SignupFormProps = { inviteId?: string; inviteToken?: string; emailParam?: string; + fromSmartLogin?: boolean; }; const formSchema = z @@ -100,7 +102,8 @@ export default function SignupForm({ redirect, inviteId, inviteToken, - emailParam + emailParam, + fromSmartLogin = false }: SignupFormProps) { const router = useRouter(); const { env } = useEnvContext(); @@ -201,8 +204,28 @@ export default function SignupForm({ ? env.branding.logo?.authPage?.height || 58 : 58; + const showOrgBanner = fromSmartLogin && (build === "saas" || env.flags.useOrgOnlyIdp); + const orgBannerHref = redirect + ? `/auth/org?redirect=${encodeURIComponent(redirect)}` + : "/auth/org"; + return ( - + <> + {showOrgBanner && ( + + {t("signupOrgNotice")} + +

{t("signupOrgTip")}

+ + {t("signupOrgLink")} + +
+
+ )} +
@@ -581,9 +604,10 @@ export default function SignupForm({ - - - - + + + + + ); } diff --git a/src/components/SmartLoginForm.tsx b/src/components/SmartLoginForm.tsx new file mode 100644 index 00000000..5e1498ff --- /dev/null +++ b/src/components/SmartLoginForm.tsx @@ -0,0 +1,232 @@ +"use client"; + +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +import { Button } from "@app/components/ui/button"; +import { Input } from "@app/components/ui/input"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { Alert, AlertDescription } from "@app/components/ui/alert"; +import { useRouter } from "next/navigation"; +import { useUserLookup } from "@app/hooks/useUserLookup"; +import { LookupUserResponse } from "@server/routers/auth/lookupUser"; +import { useTranslations } from "next-intl"; +import LoginPasswordForm from "@app/components/LoginPasswordForm"; +import LoginOrgSelector from "@app/components/LoginOrgSelector"; +import UserProfileCard from "@app/components/UserProfileCard"; +import { ArrowLeft } from "lucide-react"; +import SecurityKeyAuthButton from "@app/components/SecurityKeyAuthButton"; + +const identifierSchema = z.object({ + identifier: z.string().min(1, "Username or email is required") +}); + +// Helper to check if string is a valid email +const isValidEmail = (str: string): boolean => { + try { + z.string().email().parse(str); + return true; + } catch { + return false; + } +}; + +type SmartLoginFormProps = { + redirect?: string; + forceLogin?: boolean; +}; + +type ViewState = + | { type: "initial" } + | { + type: "password"; + identifier: string; + account: LookupUserResponse["accounts"][0]; + } + | { + type: "orgSelector"; + identifier: string; + lookupResult: LookupUserResponse; + }; + +export default function SmartLoginForm({ + redirect, + forceLogin +}: SmartLoginFormProps) { + const router = useRouter(); + const { lookup, loading, error } = useUserLookup(); + const t = useTranslations(); + const [viewState, setViewState] = useState({ type: "initial" }); + const [securityKeyError, setSecurityKeyError] = useState( + null + ); + + const form = useForm>({ + resolver: zodResolver(identifierSchema), + defaultValues: { + identifier: "" + } + }); + + const handleLookup = async (values: z.infer) => { + const identifier = values.identifier.trim(); + const isEmail = isValidEmail(identifier); + const result = await lookup(identifier); + + if (!result) { + // Error already set by hook + return; + } + + if (!result.found || result.accounts.length === 0) { + // No accounts found + if (!isEmail || forceLogin) { + // Not a valid email or forceLogin is true - show error + form.setError("identifier", { + type: "manual", + message: t("userNotFoundWithUsername") + }); + return; + } + // Valid email but no accounts and not forceLogin - redirect to signup + const signupUrl = redirect + ? `/auth/signup?email=${encodeURIComponent(identifier)}&redirect=${encodeURIComponent(redirect)}&fromSmartLogin=true` + : `/auth/signup?email=${encodeURIComponent(identifier)}&fromSmartLogin=true`; + router.push(signupUrl); + return; + } + + // Determine which view to show + const account = result.accounts[0]; // Use first account for now + + // Check if all accounts are internal-only (no IdPs) + const allInternalOnly = result.accounts.every( + (acc) => + acc.hasInternalAuth && + acc.orgs.every((org) => org.idps.length === 0) + ); + + if (allInternalOnly) { + // Show password form + setViewState({ + type: "password", + identifier, + account + }); + return; + } + + // Show org selector for both single and multiple orgs + setViewState({ + type: "orgSelector", + identifier, + lookupResult: result + }); + }; + + const handleBack = () => { + setViewState({ type: "initial" }); + form.reset(); + }; + + if (viewState.type === "password") { + return ( +
+ + +
+ ); + } + + if (viewState.type === "orgSelector") { + return ( +
+ +
+ ); + } + + // Initial view + return ( +
+
+ + ( + + {t("usernameOrEmail")} + + + + + + )} + /> + + {(error || securityKeyError) && ( + + + {error || securityKeyError} + + + )} + + + +
+ + + +
+
+ ); +} diff --git a/src/components/UserProfileCard.tsx b/src/components/UserProfileCard.tsx new file mode 100644 index 00000000..869ae690 --- /dev/null +++ b/src/components/UserProfileCard.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { Button } from "@app/components/ui/button"; +import { Avatar, AvatarFallback } from "@app/components/ui/avatar"; + +type UserProfileCardProps = { + identifier: string; + description?: string; + onUseDifferentAccount?: () => void; + useDifferentAccountText?: string; +}; + +export default function UserProfileCard({ + identifier, + description, + onUseDifferentAccount, + useDifferentAccountText +}: UserProfileCardProps) { + // Create profile label and initial from identifier + const profileLabel = identifier.trim(); + const profileInitial = profileLabel + ? profileLabel.charAt(0).toUpperCase() + : ""; + + return ( +
+ + {profileInitial} + +
+
+

{profileLabel}

+ {description && ( +

+ {description} +

+ )} +
+ {onUseDifferentAccount && ( + + )} +
+
+ ); +} diff --git a/src/components/VerifyEmailForm.tsx b/src/components/VerifyEmailForm.tsx index 14a362df..31a60819 100644 --- a/src/components/VerifyEmailForm.tsx +++ b/src/components/VerifyEmailForm.tsx @@ -245,7 +245,7 @@ export default function VerifyEmailForm({ className="w-full" onClick={logout} > - Log in with another account + {t("verifyEmailLogInWithDifferentAccount")} diff --git a/src/hooks/useUserLookup.ts b/src/hooks/useUserLookup.ts new file mode 100644 index 00000000..879af8d8 --- /dev/null +++ b/src/hooks/useUserLookup.ts @@ -0,0 +1,51 @@ +import { useState } from "react"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { AxiosResponse } from "axios"; +import { LookupUserResponse } from "@server/routers/auth/lookupUser"; + +type UseUserLookupResult = { + lookup: (identifier: string) => Promise; + loading: boolean; + error: string | null; +}; + +export function useUserLookup(): UseUserLookupResult { + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const lookup = async ( + identifier: string + ): Promise => { + setLoading(true); + setError(null); + + try { + const response = await api.post< + AxiosResponse + >("/auth/lookup-user", { + identifier: identifier.toLowerCase().trim() + }); + + if (response.data.data) { + return response.data.data; + } + + setError("Failed to lookup user"); + return null; + } catch (err: any) { + const errorMessage = + err.response?.data?.message || + err.message || + "An error occurred during lookup"; + setError(errorMessage); + return null; + } finally { + setLoading(false); + } + }; + + return { lookup, loading, error }; +}