mirror of
https://github.com/fosrl/pangolin.git
synced 2026-01-28 22:00:51 +00:00
improved org idp login flow
This commit is contained in:
@@ -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 <termsOfService>Terms of Service</termsOfService> and <privacyPolicy>Privacy Policy</privacyPolicy>.",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -16,4 +16,5 @@ export * from "./checkResourceSession";
|
||||
export * from "./securityKey";
|
||||
export * from "./startDeviceWebAuth";
|
||||
export * from "./verifyDeviceWebAuth";
|
||||
export * from "./pollDeviceWebAuth";
|
||||
export * from "./pollDeviceWebAuth";
|
||||
export * from "./lookupUser";
|
||||
224
server/routers/auth/lookupUser.ts
Normal file
224
server/routers/auth/lookupUser.ts
Normal file
@@ -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<any> {
|
||||
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<LookupUserResponse>(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<string, typeof userOrgMemberships[0]>();
|
||||
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<LookupUserResponse>(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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -113,7 +113,7 @@ export default async function AuthLayout({ children }: AuthLayoutProps) {
|
||||
aria-label="GitHub"
|
||||
className="flex items-center space-x-2 whitespace-nowrap"
|
||||
>
|
||||
<span>{t("terms")}</span>
|
||||
<span>{t("termsOfService")}</span>
|
||||
</a>
|
||||
<Separator orientation="vertical" />
|
||||
<a
|
||||
@@ -123,7 +123,7 @@ export default async function AuthLayout({ children }: AuthLayoutProps) {
|
||||
aria-label="GitHub"
|
||||
className="flex items-center space-x-2 whitespace-nowrap"
|
||||
>
|
||||
<span>{t("privacy")}</span>
|
||||
<span>{t("privacyPolicy")}</span>
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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<AxiosResponse<ListIdpsResponse>>("/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<AxiosResponse<ListIdpsResponse>>("/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" && (
|
||||
<p className="text-xs text-muted-foreground text-center mb-4">
|
||||
{t.rich("loginLegalDisclaimer", {
|
||||
termsOfService: (chunks) => (
|
||||
<Link
|
||||
href="https://pangolin.net/terms-of-service.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{chunks}
|
||||
</Link>
|
||||
),
|
||||
privacyPolicy: (chunks) => (
|
||||
<Link
|
||||
href="https://pangolin.net/privacy-policy.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{chunks}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isInvite && (
|
||||
<div className="border rounded-md p-3 mb-4 bg-card">
|
||||
<div className="flex flex-col items-center">
|
||||
@@ -99,15 +137,36 @@ export default async function Page(props: {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DashboardLoginForm
|
||||
redirect={redirectUrl}
|
||||
idps={loginIdps}
|
||||
forceLogin={forceLogin}
|
||||
showOrgLogin={
|
||||
!isInvite && (build === "saas" || env.flags.useOrgOnlyIdp)
|
||||
}
|
||||
searchParams={searchParams}
|
||||
/>
|
||||
{useSmartLogin ? (
|
||||
<>
|
||||
<Card className="w-full max-w-md">
|
||||
<LoginCardHeader
|
||||
subtitle={
|
||||
forceLogin
|
||||
? t("loginRequiredForDevice")
|
||||
: t("loginStart")
|
||||
}
|
||||
/>
|
||||
<CardContent className="pt-6">
|
||||
<SmartLoginForm
|
||||
redirect={redirectUrl}
|
||||
forceLogin={forceLogin}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
) : (
|
||||
<DashboardLoginForm
|
||||
redirect={redirectUrl}
|
||||
idps={loginIdps}
|
||||
forceLogin={forceLogin}
|
||||
showOrgLogin={
|
||||
!isInvite &&
|
||||
(build === "saas" || env.flags.useOrgOnlyIdp)
|
||||
}
|
||||
searchParams={searchParams}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(!signUpDisabled || isInvite) && (
|
||||
<p className="text-center text-muted-foreground mt-4">
|
||||
@@ -124,6 +183,31 @@ export default async function Page(props: {
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isInvite && (build === "saas" || env.flags.useOrgOnlyIdp) ? (
|
||||
<OrgSignInLink
|
||||
href={`/auth/org${buildQueryString(searchParams)}`}
|
||||
linkText={t("orgAuthSignInToOrg")}
|
||||
descriptionText={t("needToSignInToOrg")}
|
||||
/>
|
||||
) : 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}` : "";
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
/>
|
||||
|
||||
<p className="text-center text-muted-foreground mt-4">
|
||||
|
||||
@@ -69,22 +69,6 @@ export default function DashboardLoginForm({
|
||||
<div className="text-center space-y-1 pt-3">
|
||||
<p className="text-muted-foreground">{getSubtitle()}</p>
|
||||
</div>
|
||||
{showOrgLogin && (
|
||||
<div className="space-y-2 mt-4">
|
||||
<Link
|
||||
href={`/auth/org${buildQueryString(searchParams || {})}`}
|
||||
className="underline"
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full gap-2"
|
||||
>
|
||||
{t("orgAuthSignInToOrg")}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
<LoginForm
|
||||
@@ -104,20 +88,3 @@ export default function DashboardLoginForm({
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
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}` : "";
|
||||
}
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [validatingInitialCode, setValidatingInitialCode] = useState(false);
|
||||
const [verifyingInitialCode, setVerifyingInitialCode] = useState(false);
|
||||
const [metadata, setMetadata] = useState<DeviceAuthMetadata | null>(null);
|
||||
const [code, setCode] = useState<string>("");
|
||||
const { isUnlocked } = useLicenseStatusContext();
|
||||
@@ -75,39 +83,88 @@ export default function DeviceLoginForm({
|
||||
}
|
||||
});
|
||||
|
||||
async function onSubmit(data: z.infer<typeof formSchema>) {
|
||||
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<typeof formSchema>) {
|
||||
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 (
|
||||
<div className="flex items-center justify-center">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("deviceActivation")}</CardTitle>
|
||||
<CardDescription>
|
||||
{validatingInitialCode
|
||||
? t("deviceCodeValidating")
|
||||
: t("deviceCodeVerifying")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col items-center space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
<span>
|
||||
{validatingInitialCode
|
||||
? t("deviceCodeValidating")
|
||||
: t("deviceCodeVerifying")}
|
||||
</span>
|
||||
</div>
|
||||
{error && (
|
||||
<Alert variant="destructive" className="w-full">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (metadata) {
|
||||
return (
|
||||
<DeviceAuthConfirmation
|
||||
@@ -195,32 +282,17 @@ export default function DeviceLoginForm({
|
||||
</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3 p-3 mb-4 border rounded-md">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarFallback>{profileInitial}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 space-y-1">
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{profileLabel || userEmail}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground break-all">
|
||||
{t(
|
||||
"deviceLoginDeviceRequestingAccessToAccount"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
className="h-auto px-0 text-xs"
|
||||
onClick={handleUseDifferentAccount}
|
||||
>
|
||||
{t("deviceLoginUseDifferentAccount")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
<UserProfileCard
|
||||
identifier={profileLabel || userEmail}
|
||||
description={t(
|
||||
"deviceLoginDeviceRequestingAccessToAccount"
|
||||
)}
|
||||
onUseDifferentAccount={handleUseDifferentAccount}
|
||||
useDifferentAccountText={t(
|
||||
"deviceLoginUseDifferentAccount"
|
||||
)}
|
||||
/>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
|
||||
33
src/components/LoginCardHeader.tsx
Normal file
33
src/components/LoginCardHeader.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import BrandingLogo from "@app/components/BrandingLogo";
|
||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { CardHeader } from "./ui/card";
|
||||
|
||||
type LoginCardHeaderProps = {
|
||||
subtitle: string;
|
||||
};
|
||||
|
||||
export default function LoginCardHeader({ subtitle }: LoginCardHeaderProps) {
|
||||
const { env } = useEnvContext();
|
||||
const { isUnlocked } = useLicenseStatusContext();
|
||||
|
||||
const logoWidth = isUnlocked()
|
||||
? env.branding.logo?.authPage?.width || 175
|
||||
: 175;
|
||||
const logoHeight = isUnlocked()
|
||||
? env.branding.logo?.authPage?.height || 58
|
||||
: 58;
|
||||
|
||||
return (
|
||||
<CardHeader className="border-b">
|
||||
<div className="flex flex-row items-center justify-center">
|
||||
<BrandingLogo height={logoHeight} width={logoWidth} />
|
||||
</div>
|
||||
<div className="text-center space-y-1 pt-3">
|
||||
<p className="text-muted-foreground">{subtitle}</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLDivElement>(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 (
|
||||
<div className="space-y-4">
|
||||
{showSecurityKeyPrompt && (
|
||||
<Alert>
|
||||
<FingerprintIcon className="w-5 h-5 mr-2" />
|
||||
<AlertDescription>
|
||||
{t("securityKeyPrompt", {
|
||||
defaultValue:
|
||||
"Please verify your identity using your security key. Make sure your security key is connected and ready."
|
||||
})}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!mfaRequested && (
|
||||
<>
|
||||
<Form {...form}>
|
||||
@@ -488,115 +333,36 @@ export default function LoginForm({
|
||||
)}
|
||||
|
||||
{mfaRequested && (
|
||||
<>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-medium">{t("otpAuth")}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("otpAuthDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Form {...mfaForm}>
|
||||
<form
|
||||
onSubmit={mfaForm.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
id="form"
|
||||
>
|
||||
<FormField
|
||||
control={mfaForm.control}
|
||||
name="code"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div
|
||||
ref={otpContainerRef}
|
||||
className="flex justify-center"
|
||||
>
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
{...field}
|
||||
autoFocus
|
||||
pattern={
|
||||
REGEXP_ONLY_DIGITS_AND_CHARS
|
||||
}
|
||||
onChange={(
|
||||
value: string
|
||||
) => {
|
||||
field.onChange(value);
|
||||
if (
|
||||
value.length === 6
|
||||
) {
|
||||
mfaForm.handleSubmit(
|
||||
onSubmit
|
||||
)();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot
|
||||
index={0}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={1}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={2}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={3}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={4}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={5}
|
||||
/>
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</>
|
||||
<MfaInputForm
|
||||
form={mfaForm}
|
||||
onSubmit={onSubmit}
|
||||
onBack={() => {
|
||||
setMfaRequested(false);
|
||||
mfaForm.reset();
|
||||
}}
|
||||
error={error}
|
||||
loading={loading}
|
||||
formId="form"
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
{!mfaRequested && error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{mfaRequested && (
|
||||
<Button
|
||||
type="submit"
|
||||
form="form"
|
||||
className="w-full"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("otpAuthSubmit")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!mfaRequested && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={initiateSecurityKeyAuth}
|
||||
loading={loading}
|
||||
disabled={loading || showSecurityKeyPrompt}
|
||||
>
|
||||
<FingerprintIcon className="w-4 h-4 mr-2" />
|
||||
{t("securityKeyLogin", {
|
||||
defaultValue: "Sign in with security key"
|
||||
})}
|
||||
</Button>
|
||||
<SecurityKeyAuthButton
|
||||
redirect={redirect}
|
||||
forceLogin={forceLogin}
|
||||
onSuccess={onLogin}
|
||||
onError={setError}
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
{hasIdp && (
|
||||
<>
|
||||
@@ -652,19 +418,6 @@ export default function LoginForm({
|
||||
</>
|
||||
)}
|
||||
|
||||
{mfaRequested && (
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setMfaRequested(false);
|
||||
mfaForm.reset();
|
||||
}}
|
||||
>
|
||||
{t("otpAuthBack")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
155
src/components/LoginOrgSelector.tsx
Normal file
155
src/components/LoginOrgSelector.tsx
Normal file
@@ -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 (
|
||||
<div className="space-y-4">
|
||||
<UserProfileCard
|
||||
identifier={identifier}
|
||||
description={t("loginSelectAuthenticationMethod")}
|
||||
onUseDifferentAccount={onUseDifferentAccount}
|
||||
useDifferentAccountText={t(
|
||||
"deviceLoginUseDifferentAccount"
|
||||
)}
|
||||
/>
|
||||
<LoginPasswordForm
|
||||
identifier={identifier}
|
||||
redirect={redirect}
|
||||
forceLogin={forceLogin}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<UserProfileCard
|
||||
identifier={identifier}
|
||||
description={t("loginSelectAuthenticationMethod")}
|
||||
onUseDifferentAccount={onUseDifferentAccount}
|
||||
useDifferentAccountText={t("deviceLoginUseDifferentAccount")}
|
||||
/>
|
||||
|
||||
{hasInternalAccount && (
|
||||
<div className="mt-3">
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
onClick={() => setShowPasswordForm(true)}
|
||||
>
|
||||
{t("signInWithPassword")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-0 mt-3">
|
||||
{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 (
|
||||
<div key={org.orgId}>
|
||||
<div className="py-3">
|
||||
<h3 className="text-base font-semibold mb-3">
|
||||
{org.orgName}
|
||||
</h3>
|
||||
<IdpLoginButtons
|
||||
idps={idps}
|
||||
redirect={redirect}
|
||||
orgId={org.orgId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
326
src/components/LoginPasswordForm.tsx
Normal file
326
src/components/LoginPasswordForm.tsx
Normal file
@@ -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<string | null>(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<typeof formSchema>) {
|
||||
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<typeof mfaSchema>) {
|
||||
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 (
|
||||
<MfaInputForm
|
||||
form={mfaForm}
|
||||
onSubmit={onMfaSubmit}
|
||||
onBack={() => {
|
||||
setMfaRequested(false);
|
||||
mfaForm.reset();
|
||||
}}
|
||||
error={error}
|
||||
loading={loading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("password")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
autoFocus
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="text-center">
|
||||
<Link
|
||||
href={`${env.app.dashboardUrl}/auth/reset-password${isEmail ? `?email=${encodeURIComponent(identifier)}` : ""}${redirect ? `${isEmail ? "&" : "?"}redirect=${encodeURIComponent(redirect)}` : ""}`}
|
||||
className="text-sm text-muted-foreground"
|
||||
>
|
||||
{t("passwordForgot")}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={loading}
|
||||
loading={loading}
|
||||
>
|
||||
{t("logIn")}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
169
src/components/MfaInputForm.tsx
Normal file
169
src/components/MfaInputForm.tsx
Normal file
@@ -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<void>;
|
||||
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<HTMLDivElement>(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 (
|
||||
<div className="space-y-4">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-medium">{t("otpAuth")}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("otpAuthDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
id={formId}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="code"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div
|
||||
ref={otpContainerRef}
|
||||
className="flex justify-center"
|
||||
>
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
{...field}
|
||||
autoFocus
|
||||
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
|
||||
onChange={(value: string) => {
|
||||
field.onChange(value);
|
||||
if (value.length === 6) {
|
||||
form.handleSubmit(onSubmit)();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
type="submit"
|
||||
form={formId}
|
||||
className="w-full"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("otpAuthSubmit")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
onClick={onBack}
|
||||
>
|
||||
{t("otpAuthBack")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -116,6 +116,14 @@ export default async function OrgLoginPage({
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<p className="text-center text-muted-foreground mt-4">
|
||||
<Link
|
||||
href={`${env.app.dashboardUrl}/auth/login${buildQueryString(searchParams)}`}
|
||||
className="underline"
|
||||
>
|
||||
{t("loginBack")}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
107
src/components/OrgSignInLink.tsx
Normal file
107
src/components/OrgSignInLink.tsx
Normal file
@@ -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 && (
|
||||
<Alert className="mb-4 mt-8">
|
||||
<AlertTitle>{t("orgSignInNotice")}</AlertTitle>
|
||||
<AlertDescription className="space-y-3 mt-3">
|
||||
<p>{t("orgSignInTip")}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={handleDontShowAgain}
|
||||
>
|
||||
{t("dontShowAgain")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={handleContinueAnyway}
|
||||
>
|
||||
{t("continueAnyway")}
|
||||
</Button>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="text-sm text-center text-muted-foreground mt-8 flex flex-col items-center">
|
||||
<span>{descriptionText}</span>
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="underline text-inherit bg-transparent border-none p-0 cursor-pointer"
|
||||
>
|
||||
{linkText}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
157
src/components/SecurityKeyAuthButton.tsx
Normal file
157
src/components/SecurityKeyAuthButton.tsx
Normal file
@@ -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<void>;
|
||||
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<string | null>(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 (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className={className || "w-full"}
|
||||
onClick={initiateSecurityKeyAuth}
|
||||
disabled={externalDisabled || loading}
|
||||
loading={loading}
|
||||
>
|
||||
<FingerprintIcon className="w-4 h-4 mr-2" />
|
||||
{t("securityKeyLogin")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Card className="w-full max-w-md">
|
||||
<>
|
||||
{showOrgBanner && (
|
||||
<Alert className="mb-4 w-full max-w-md">
|
||||
<AlertTitle>{t("signupOrgNotice")}</AlertTitle>
|
||||
<AlertDescription className="space-y-2 mt-3">
|
||||
<p>{t("signupOrgTip")}</p>
|
||||
<Link
|
||||
href={orgBannerHref}
|
||||
className="text-sm font-medium underline"
|
||||
>
|
||||
{t("signupOrgLink")}
|
||||
</Link>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="border-b">
|
||||
<div className="flex flex-row items-center justify-center">
|
||||
<BrandingLogo height={logoHeight} width={logoWidth} />
|
||||
@@ -581,9 +604,10 @@ export default function SignupForm({
|
||||
<Button type="submit" className="w-full">
|
||||
{t("createAccount")}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
232
src/components/SmartLoginForm.tsx
Normal file
232
src/components/SmartLoginForm.tsx
Normal file
@@ -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<ViewState>({ type: "initial" });
|
||||
const [securityKeyError, setSecurityKeyError] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const form = useForm<z.infer<typeof identifierSchema>>({
|
||||
resolver: zodResolver(identifierSchema),
|
||||
defaultValues: {
|
||||
identifier: ""
|
||||
}
|
||||
});
|
||||
|
||||
const handleLookup = async (values: z.infer<typeof identifierSchema>) => {
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<UserProfileCard
|
||||
identifier={viewState.identifier}
|
||||
description={t("loginSelectAuthenticationMethod")}
|
||||
onUseDifferentAccount={handleBack}
|
||||
useDifferentAccountText={t(
|
||||
"deviceLoginUseDifferentAccount"
|
||||
)}
|
||||
/>
|
||||
<LoginPasswordForm
|
||||
identifier={viewState.identifier}
|
||||
redirect={redirect}
|
||||
forceLogin={forceLogin}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (viewState.type === "orgSelector") {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<LoginOrgSelector
|
||||
identifier={viewState.identifier}
|
||||
lookupResult={viewState.lookupResult}
|
||||
redirect={redirect}
|
||||
forceLogin={forceLogin}
|
||||
onUseDifferentAccount={handleBack}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Initial view
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleLookup)}
|
||||
className="space-y-4"
|
||||
id="form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="identifier"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("usernameOrEmail")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{(error || securityKeyError) && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
{error || securityKeyError}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
type="submit"
|
||||
form="form"
|
||||
className="w-full"
|
||||
disabled={loading}
|
||||
loading={loading}
|
||||
>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
|
||||
<SecurityKeyAuthButton
|
||||
redirect={redirect}
|
||||
forceLogin={forceLogin}
|
||||
onError={setSecurityKeyError}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
52
src/components/UserProfileCard.tsx
Normal file
52
src/components/UserProfileCard.tsx
Normal file
@@ -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 (
|
||||
<div className="flex items-center gap-3 p-3 border rounded-md">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarFallback>{profileInitial}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 space-y-1">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{profileLabel}</p>
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground break-all">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{onUseDifferentAccount && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
className="h-auto px-0 text-xs"
|
||||
onClick={onUseDifferentAccount}
|
||||
>
|
||||
{useDifferentAccountText || "Use a different account"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -245,7 +245,7 @@ export default function VerifyEmailForm({
|
||||
className="w-full"
|
||||
onClick={logout}
|
||||
>
|
||||
Log in with another account
|
||||
{t("verifyEmailLogInWithDifferentAccount")}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
51
src/hooks/useUserLookup.ts
Normal file
51
src/hooks/useUserLookup.ts
Normal file
@@ -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<LookupUserResponse | null>;
|
||||
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<string | null>(null);
|
||||
|
||||
const lookup = async (
|
||||
identifier: string
|
||||
): Promise<LookupUserResponse | null> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await api.post<
|
||||
AxiosResponse<LookupUserResponse>
|
||||
>("/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 };
|
||||
}
|
||||
Reference in New Issue
Block a user