+
+
+
diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx
index 2f3aefb..0b9c985 100644
--- a/src/app/(auth)/login/page.tsx
+++ b/src/app/(auth)/login/page.tsx
@@ -1,20 +1,46 @@
"use client";
-import { useState, Suspense } from "react";
+import { useState, Suspense, useEffect, useCallback } from "react";
+import Image from "next/image";
import { useRouter, useSearchParams } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
-import { useMutation } from "@tanstack/react-query";
+import { useMutation, useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
-import { KeyRound, Loader2, ShieldCheck, ArrowLeft, Eye, EyeOff } from "lucide-react";
+import {
+ KeyRound,
+ Loader2,
+ Mail,
+ ShieldCheck,
+ ArrowLeft,
+ Eye,
+ EyeOff,
+ Sparkles,
+ CheckCircle2,
+ Smartphone,
+} from "lucide-react";
import { startAuthentication } from "@simplewebauthn/browser";
+import Link from "next/link";
+import QRCode from "qrcode";
import { apiClient } from "@/lib/api-client";
import { useAuthStore } from "@/stores/auth-store";
-import { AuthResponse, MFAChallengeResponse } from "@/lib/types";
+import {
+ AuthResponse,
+ MFAChallengeResponse,
+ MFAEnrollResponse,
+ MFAEnrollmentRequiredResponse,
+ PublicTenantAuthConfig,
+} from "@/lib/types";
import { getApiErrorMessage, isNotAllowedError } from "@/lib/errors";
-import { FaGithub, FaGoogle, FaMicrosoft } from "react-icons/fa";
+import {
+ PLATFORM_TENANT_ID,
+ PublicOAuthProvider,
+ SOCIAL_PROVIDER_META,
+} from "@/lib/social-login";
+import { isWebAuthnSupported } from "@/lib/webauthn-support";
+import { LoginFormSkeleton } from "@/components/auth/login-form-skeleton";
import { Button } from "@/components/ui/button";
import {
@@ -26,21 +52,147 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
-import {
- Card,
- CardContent,
- CardDescription,
- CardFooter,
- CardHeader,
- CardTitle,
-} from "@/components/ui/card";
-import Link from "next/link";
+import { Label } from "@/components/ui/label";
+import { Card, CardContent, CardFooter } from "@/components/ui/card";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { cn } from "@/lib/utils";
+
+const LOGIN_TAB_KEY = "authengine_login_tab";
+const MAGIC_LINK_COOLDOWN_SEC = 60;
const loginSchema = z.object({
email: z.string().email({ message: "Please enter a valid email address." }),
password: z.string().min(1, { message: "Password is required." }),
});
+const passwordOnlySchema = z.object({
+ password: z.string().min(1, { message: "Password is required." }),
+});
+
+const magicLinkSchema = z.object({
+ email: z.string().email({ message: "Please enter a valid email address." }),
+});
+
+function validateEmail(email: string): string | null {
+ const result = z.string().email().safeParse(email.trim());
+ return result.success ? null : "Please enter a valid email address.";
+}
+
+function AuthShell({
+ title,
+ description,
+ children,
+ footer,
+}: {
+ title: string;
+ description: string;
+ children: React.ReactNode;
+ footer?: React.ReactNode;
+}) {
+ return (
+
+
+
+
+
+
+
+
{title}
+
{description}
+
+
+ {children}
+
+ {footer ? (
+
+ {footer}
+
+ ) : null}
+
+
+
+ );
+}
+
+function SectionDivider({ label }: { label: string }) {
+ return (
+
+
+
+
+
+
+ {label}
+
+
+
+ );
+}
+
+function InlineError({ message }: { message: string }) {
+ return (
+
+ {message}
+
+ );
+}
+
+function MagicLinkSentPanel({
+ email,
+ resendCooldown,
+ isResending,
+ onResend,
+ onChangeEmail,
+}: {
+ email: string;
+ resendCooldown: number;
+ isResending: boolean;
+ onResend: () => void;
+ onChangeEmail: () => void;
+}) {
+ return (
+
+
+
+
+
Check your inbox
+
+ We sent a sign-in link to{" "}
+ {email} . It expires in
+ 15 minutes and works once.
+
+
+
+
+ 0 || isResending}
+ onClick={onResend}
+ >
+ {isResending ? (
+
+ ) : resendCooldown > 0 ? (
+ `Resend in ${resendCooldown}s`
+ ) : (
+ "Resend link"
+ )}
+
+
+ Use different email
+
+
+
+ );
+}
+
function LoginPageContent() {
const router = useRouter();
const searchParams = useSearchParams();
@@ -49,14 +201,96 @@ function LoginPageContent() {
const [mfaPendingToken, setMfaPendingToken] = useState
(null);
const [mfaCode, setMfaCode] = useState("");
+ const [mfaError, setMfaError] = useState(null);
+ const [mfaEnrollmentToken, setMfaEnrollmentToken] = useState(null);
+ const [enrollData, setEnrollData] = useState(null);
+ const [enrollmentQrCode, setEnrollmentQrCode] = useState("");
+ const [enrollmentCode, setEnrollmentCode] = useState("");
+ const [enrollmentError, setEnrollmentError] = useState(null);
const [isWebAuthnLoading, setIsWebAuthnLoading] = useState(false);
+ const [passkeyError, setPasskeyError] = useState(null);
const [showPassword, setShowPassword] = useState(false);
+ const [credentialTab, setCredentialTab] = useState<"password" | "magic_link">("password");
+ const [sharedEmail, setSharedEmail] = useState("");
+ const [sharedEmailError, setSharedEmailError] = useState(null);
+ const [magicLinkSentEmail, setMagicLinkSentEmail] = useState(null);
+ const [resendCooldown, setResendCooldown] = useState(0);
+ const [webAuthnSupported, setWebAuthnSupported] = useState(false);
+
+ useEffect(() => {
+ setWebAuthnSupported(isWebAuthnSupported());
+ const saved = localStorage.getItem(LOGIN_TAB_KEY);
+ if (saved === "password" || saved === "magic_link") {
+ setCredentialTab(saved);
+ }
+ }, []);
- const form = useForm>({
+ useEffect(() => {
+ if (resendCooldown <= 0) return;
+ const timer = window.setTimeout(() => setResendCooldown((c) => c - 1), 1000);
+ return () => window.clearTimeout(timer);
+ }, [resendCooldown]);
+
+ useEffect(() => {
+ if (!enrollData?.provisioning_uri) {
+ setEnrollmentQrCode("");
+ return;
+ }
+
+ QRCode.toDataURL(enrollData.provisioning_uri)
+ .then(setEnrollmentQrCode)
+ .catch(() => setEnrollmentQrCode(""));
+ }, [enrollData]);
+
+ const { data: authConfig, isLoading: isLoadingAuthConfig } = useQuery({
+ queryKey: ["publicAuthConfig", PLATFORM_TENANT_ID],
+ queryFn: async () => {
+ const { data } = await apiClient.get("/auth/auth-config", {
+ params: { tenant_id: PLATFORM_TENANT_ID },
+ });
+ return data;
+ },
+ enabled: !!PLATFORM_TENANT_ID,
+ });
+
+ const allowedMethods = new Set(authConfig?.allowed_methods ?? []);
+ const showEmailPassword = allowedMethods.has("email_password");
+ const showMagicLink = allowedMethods.has("magic_link");
+ const showPasskeyConfig = allowedMethods.has("passkey");
+ const showSocial = allowedMethods.has("social_provider");
+ const showCredentialTabs = showEmailPassword && showMagicLink;
+ const showEmailField = showEmailPassword || showMagicLink;
+ const showPasskeySection = showPasskeyConfig && webAuthnSupported;
+
+ const { data: socialProviders = [], isLoading: isLoadingSocialProviders } = useQuery<
+ PublicOAuthProvider[]
+ >({
+ queryKey: ["loginSocialProviders", PLATFORM_TENANT_ID],
+ queryFn: async () => {
+ const { data } = await apiClient.get(
+ "/auth/oauth/providers",
+ { params: { tenant_id: PLATFORM_TENANT_ID } }
+ );
+ return data;
+ },
+ enabled: !!PLATFORM_TENANT_ID && showSocial,
+ });
+
+ const passwordForm = useForm>({
resolver: zodResolver(loginSchema),
defaultValues: { email: "", password: "" },
});
+ const passwordOnlyForm = useForm>({
+ resolver: zodResolver(passwordOnlySchema),
+ defaultValues: { password: "" },
+ });
+
+ const magicForm = useForm>({
+ resolver: zodResolver(magicLinkSchema),
+ defaultValues: { email: "" },
+ });
+
const handleLoginSuccess = (data: AuthResponse) => {
setTokens(data.access_token, data.refresh_token);
if (data.user) setUser(data.user);
@@ -64,21 +298,69 @@ function LoginPageContent() {
router.push(returnTo);
};
+ const handleCredentialTabChange = (value: string) => {
+ const tab = value as "password" | "magic_link";
+ setCredentialTab(tab);
+ localStorage.setItem(LOGIN_TAB_KEY, tab);
+ setSharedEmailError(null);
+ passwordForm.clearErrors("root");
+ magicForm.clearErrors("root");
+ };
+
+ const getEmailForSubmit = useCallback(
+ (formEmail?: string) => (showCredentialTabs ? sharedEmail.trim() : (formEmail ?? "").trim()),
+ [showCredentialTabs, sharedEmail]
+ );
+
+ const sendMagicLink = async (email: string) => {
+ await apiClient.post("/auth/magic-link/request", {
+ email,
+ tenant_id: PLATFORM_TENANT_ID || undefined,
+ });
+ };
+
const loginMutation = useMutation({
- mutationFn: async (values: z.infer) => {
- const { data } = await apiClient.post("/auth/login", values);
+ mutationFn: async (values: { email: string; password: string }) => {
+ const { data } = await apiClient.post("/auth/login", {
+ ...values,
+ tenant_id: PLATFORM_TENANT_ID || undefined,
+ });
return data;
},
- onSuccess: (data: AuthResponse | MFAChallengeResponse) => {
+ onSuccess: (data: AuthResponse | MFAChallengeResponse | MFAEnrollmentRequiredResponse) => {
+ passwordForm.clearErrors("root");
if ("mfa_pending_token" in data && data.mfa_pending_token) {
setMfaPendingToken(data.mfa_pending_token);
- toast.info("Enter your MFA code to continue.");
+ setMfaError(null);
+ return;
+ }
+ if ("mfa_enrollment_token" in data && data.mfa_enrollment_token) {
+ setMfaEnrollmentToken(data.mfa_enrollment_token);
+ setEnrollData(null);
+ setEnrollmentCode("");
+ setEnrollmentError(null);
return;
}
handleLoginSuccess(data as AuthResponse);
},
onError: (error: unknown) => {
- toast.error(getApiErrorMessage(error, "Invalid email or password."));
+ passwordForm.setError("root", {
+ message: getApiErrorMessage(error, "Invalid email or password."),
+ });
+ },
+ });
+
+ const magicLinkMutation = useMutation({
+ mutationFn: sendMagicLink,
+ onSuccess: (_data, email) => {
+ magicForm.clearErrors("root");
+ setMagicLinkSentEmail(email);
+ setResendCooldown(MAGIC_LINK_COOLDOWN_SEC);
+ },
+ onError: (error: unknown) => {
+ magicForm.setError("root", {
+ message: getApiErrorMessage(error, "Failed to send magic link."),
+ });
},
});
@@ -91,266 +373,670 @@ function LoginPageContent() {
return data;
},
onSuccess: handleLoginSuccess,
- onError: () => toast.error("Invalid MFA code."),
+ onError: () => setMfaError("Invalid MFA code. Please try again."),
});
+ const mfaEnrollStartMutation = useMutation({
+ mutationFn: async (token: string) => {
+ const { data } = await apiClient.post("/auth/mfa/enroll", {
+ mfa_enrollment_token: token,
+ });
+ return data;
+ },
+ onSuccess: (data) => {
+ setEnrollData(data);
+ setEnrollmentError(null);
+ },
+ onError: (error: unknown) => {
+ setEnrollmentError(
+ getApiErrorMessage(error, "Failed to start MFA setup. Please sign in again.")
+ );
+ },
+ });
+
+ const mfaEnrollVerifyMutation = useMutation({
+ mutationFn: async ({ token, code }: { token: string; code: string }) => {
+ const { data } = await apiClient.post("/auth/mfa/enroll/verify", {
+ mfa_enrollment_token: token,
+ code,
+ });
+ return data;
+ },
+ onSuccess: handleLoginSuccess,
+ onError: (error: unknown) => {
+ setEnrollmentError(
+ getApiErrorMessage(error, "Invalid verification code. Please try again.")
+ );
+ },
+ });
+
+ const submitPasswordLogin = (password: string, formEmail?: string) => {
+ const email = getEmailForSubmit(formEmail);
+ const emailErr = validateEmail(email);
+ if (emailErr) {
+ if (showCredentialTabs) {
+ setSharedEmailError(emailErr);
+ } else {
+ passwordForm.setError("email", { message: emailErr });
+ }
+ return;
+ }
+ setSharedEmailError(null);
+ loginMutation.mutate({ email, password });
+ };
+
+ const submitMagicLink = (formEmail?: string) => {
+ const email = getEmailForSubmit(formEmail);
+ const emailErr = validateEmail(email);
+ if (emailErr) {
+ if (showCredentialTabs) {
+ setSharedEmailError(emailErr);
+ } else {
+ magicForm.setError("email", { message: emailErr });
+ }
+ return;
+ }
+ setSharedEmailError(null);
+ magicLinkMutation.mutate(email);
+ };
+
const handleWebAuthnLogin = async () => {
+ setPasskeyError(null);
setIsWebAuthnLoading(true);
try {
- const { data } = await apiClient.post(
- "/auth/webauthn/authenticate/begin",
- {}
- );
-
- const assertion = await startAuthentication({
- optionsJSON: data.options,
+ const { data } = await apiClient.post("/auth/webauthn/authenticate/begin", {
+ tenant_id: PLATFORM_TENANT_ID || undefined,
});
- const credentialPayload = {
- id: assertion.id,
- rawId: assertion.rawId,
- type: assertion.type,
- response: {
- clientDataJSON: assertion.response.clientDataJSON,
- authenticatorData: assertion.response.authenticatorData,
- signature: assertion.response.signature,
- userHandle: assertion.response.userHandle,
- },
- };
+ const assertion = await startAuthentication({ optionsJSON: data.options });
const { data: loginData } = await apiClient.post(
"/auth/webauthn/authenticate/complete",
- { credential: credentialPayload }
+ {
+ credential: {
+ id: assertion.id,
+ rawId: assertion.rawId,
+ type: assertion.type,
+ response: {
+ clientDataJSON: assertion.response.clientDataJSON,
+ authenticatorData: assertion.response.authenticatorData,
+ signature: assertion.response.signature,
+ userHandle: assertion.response.userHandle,
+ },
+ },
+ tenant_id: PLATFORM_TENANT_ID || undefined,
+ }
);
handleLoginSuccess(loginData);
} catch (error: unknown) {
if (isNotAllowedError(error)) {
- toast.error("Passkey authentication cancelled.");
- } else {
- toast.error(getApiErrorMessage(error, "Passkey login failed."));
+ // User dismissed the prompt or the browser timed out — no error banner.
+ return;
}
+ setPasskeyError(getApiErrorMessage(error, "Passkey login failed."));
} finally {
setIsWebAuthnLoading(false);
}
};
- const handleSocialLogin = (provider: string) => {
- const baseUrl =
- process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api/v1";
-
- window.location.href = `${baseUrl}/auth/oauth/${provider}/login`;
+ const handleSocialLogin = (provider: string, tenantId: string) => {
+ const baseUrl = process.env.NEXT_PUBLIC_API_URL;
+ const params = new URLSearchParams({ tenant_id: tenantId });
+ window.location.href = `${baseUrl}/auth/oauth/${provider}/login?${params.toString()}`;
};
- const onSubmit = (values: z.infer) =>
- loginMutation.mutate(values);
+ const sharedEmailField = showCredentialTabs ? (
+
+
Email
+
{
+ setSharedEmail(e.target.value);
+ setSharedEmailError(null);
+ setMagicLinkSentEmail(null);
+ }}
+ />
+ {sharedEmailError ? (
+
{sharedEmailError}
+ ) : null}
+
+ ) : null;
+
+ const passwordRootError = passwordForm.formState.errors.root?.message;
+ const magicRootError = magicForm.formState.errors.root?.message;
- if (mfaPendingToken) {
+ const hasSocialProviders = showSocial && socialProviders.length > 0;
+ const showSocialSection = showSocial && (hasSocialProviders || isLoadingSocialProviders);
+ const hasPrimaryAuth = showEmailField;
+
+ if (mfaEnrollmentToken) {
return (
-
-
-
-
- Two-Factor Verification
-
- Enter the 6-digit code from your authenticator app.
-
-
-
-
-
- setMfaCode(e.target.value.replace(/\D/g, ""))
- }
- className="text-center text-xl tracking-[0.4em]"
- />
-
-
- mfaCompleteMutation.mutate({
- token: mfaPendingToken,
- code: mfaCode,
- })
- }
- >
- Verify & Sign In
-
-
+
+
+ {!enrollData ? (
+ <>
+
+
+
+ {enrollmentError ?
: null}
+
mfaEnrollStartMutation.mutate(mfaEnrollmentToken)}
+ >
+ {mfaEnrollStartMutation.isPending && (
+
+ )}
+ Set up authenticator
+
+ >
+ ) : (
+ <>
+ {enrollmentQrCode ? (
+
+ ) : null}
+
+ Manual code: {enrollData.secret}
+
+
{
+ setEnrollmentCode(e.target.value.replace(/\D/g, ""));
+ setEnrollmentError(null);
+ }}
+ className="h-12 text-center text-xl tracking-[0.45em]"
+ />
+ {enrollmentError ?
: null}
+
+ mfaEnrollVerifyMutation.mutate({
+ token: mfaEnrollmentToken,
+ code: enrollmentCode,
+ })
+ }
+ >
+ {mfaEnrollVerifyMutation.isPending && (
+
+ )}
+ Verify & sign in
+
+ >
+ )}
+
{
+ setMfaEnrollmentToken(null);
+ setEnrollData(null);
+ setEnrollmentCode("");
+ setEnrollmentError(null);
+ }}
+ >
+
+ Back to sign in
+
+
+
+ );
+ }
-
- setMfaPendingToken(null)}
- >
-
- Back
-
-
-
-
+ if (mfaPendingToken) {
+ return (
+
+
+
+
+
+
{
+ setMfaCode(e.target.value.replace(/\D/g, ""));
+ setMfaError(null);
+ }}
+ className="h-12 text-center text-xl tracking-[0.45em]"
+ />
+ {mfaError ?
: null}
+
+ mfaCompleteMutation.mutate({
+ token: mfaPendingToken,
+ code: mfaCode,
+ })
+ }
+ >
+ {mfaCompleteMutation.isPending && (
+
+ )}
+ Verify & sign in
+
+
{
+ setMfaPendingToken(null);
+ setMfaError(null);
+ }}
+ >
+
+ Back to sign in
+
+
+
);
}
+ const signUpFooter = (
+ <>
+ Don't have an account?{" "}
+
+ Sign up
+
+ >
+ );
+
return (
-
-
-
- Sign In
-
- Access your account securely
-
-
-
-
-
- {/* EMAIL LOGIN */}
-
-
);
}
diff --git a/src/app/(dashboard)/platform/roles/page.tsx b/src/app/(dashboard)/platform/roles/page.tsx
index b894f72..ddc88c8 100644
--- a/src/app/(dashboard)/platform/roles/page.tsx
+++ b/src/app/(dashboard)/platform/roles/page.tsx
@@ -43,6 +43,8 @@ export default function PlatformRolesPage() {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingRole, setEditingRole] = useState