mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-16 09:56:36 +00:00
improved org idp login flow
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user