Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ NEXT_PUBLIC_WS_URL=ws://localhost:8000
ZITADEL_ISSUER=http://localhost:8080
ZITADEL_CLIENT_ID=
ZITADEL_CLIENT_SECRET=
# Login-client PAT for custom login pages (extracted by setup-zitadel.sh)
ZITADEL_LOGIN_PAT=

# NextAuth.js
AUTH_URL=http://localhost:3000
Expand Down
7 changes: 7 additions & 0 deletions app/auth/login/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export default function LoginLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen items-center justify-center bg-slate-50 px-4 py-12 dark:bg-slate-900">
{children}
</div>
);
}
112 changes: 112 additions & 0 deletions app/auth/login/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
"use client";

import { useState, useRef, useEffect } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { Suspense } from "react";
import { AuthCard } from "@/components/auth/AuthCard";
import { AuthInput } from "@/components/auth/AuthInput";
import { Button } from "@/components/ui/button";
import { createSession } from "@/lib/auth/zitadel-session";
import { setLoginSession } from "@/lib/auth/login-session";

function LoginContent() {
const searchParams = useSearchParams();
const router = useRouter();
const authRequestId = searchParams.get("authRequest") || "";

const [loginName, setLoginName] = useState("");
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);

useEffect(() => {
inputRef.current?.focus();
}, []);

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();

if (!loginName.trim()) {
setError("Please enter your username or email.");
return;
}

if (!authRequestId) {
setError("Missing auth request. Please start the sign-in flow again.");
return;
}

setIsLoading(true);
setError(null);

try {
const result = await createSession(loginName.trim());

await setLoginSession({
sessionId: result.sessionId,
sessionToken: result.sessionToken,
authRequestId,
loginName: loginName.trim(),
userId: result.userId,
});

router.push("/auth/login/password");
} catch (err) {
setError(
err instanceof Error
? err.message.includes("not found")
? "User not found. Please check your username or email."
: "Something went wrong. Please try again."
: "Something went wrong. Please try again."
);
} finally {
setIsLoading(false);
}
};

return (
<AuthCard>
<h2 className="mb-6 text-center text-xl font-semibold text-slate-900 dark:text-white">
Sign in
</h2>

<form onSubmit={handleSubmit} className="space-y-4">
<AuthInput
ref={inputRef}
label="Username or email"
type="text"
value={loginName}
onChange={(e) => setLoginName(e.target.value)}
placeholder="you@example.com"
autoComplete="username"
error={error || undefined}
disabled={isLoading}
/>

<Button
type="submit"
className="w-full"
disabled={isLoading}
>
{isLoading ? "Signing in..." : "Continue"}
</Button>
</form>
</AuthCard>
);
}

export default function LoginPage() {
return (
<Suspense
fallback={
<AuthCard>
<div className="flex items-center justify-center py-8">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary-200 border-t-primary-600" />
</div>
</AuthCard>
}
>
<LoginContent />
</Suspense>
);
}
161 changes: 161 additions & 0 deletions app/auth/login/password/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
"use client";

import { useState, useRef, useEffect } from "react";
import { useRouter } from "next/navigation";
import { AuthCard } from "@/components/auth/AuthCard";
import { AuthInput } from "@/components/auth/AuthInput";
import { BackLink } from "@/components/auth/BackLink";
import { Button } from "@/components/ui/button";
import { verifyPassword, listAuthMethods, finalizeAuthRequest } from "@/lib/auth/zitadel-session";
import { getLoginSession, setLoginSession, clearLoginSession } from "@/lib/auth/login-session";

export default function PasswordPage() {
const router = useRouter();

const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [loginName, setLoginName] = useState<string | null>(null);
const [sessionReady, setSessionReady] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);

useEffect(() => {
async function loadSession() {
const session = await getLoginSession();
if (!session) {
// No session cookie — redirect back to login
router.replace("/auth/signin");
return;
}
setLoginName(session.loginName || null);
setSessionReady(true);
}
loadSession();
}, [router]);

useEffect(() => {
if (sessionReady) {
inputRef.current?.focus();
}
}, [sessionReady]);

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();

if (!password) {
setError("Please enter your password.");
return;
}

setIsLoading(true);
setError(null);

try {
const session = await getLoginSession();
if (!session) {
router.replace("/auth/signin");
return;
}

const result = await verifyPassword(session.sessionId, session.sessionToken, password);

// Update the session token (it changes after each check)
await setLoginSession({
...session,
sessionToken: result.sessionToken,
});

// Check if MFA is required
const mfaMethods: string[] = [];
if (session.userId) {
try {
const methods = await listAuthMethods(session.userId);
// Filter for second-factor methods (TOTP, passkey, etc.)
const secondFactors = methods.filter(
(m) => m !== "AUTHENTICATION_METHOD_TYPE_PASSWORD" && m !== "AUTHENTICATION_METHOD_TYPE_UNSPECIFIED"
);
mfaMethods.push(...secondFactors);
} catch {
// If we can't check auth methods, try to finalize directly
}
}

if (mfaMethods.length > 0) {
// MFA required — redirect to MFA page (Phase 3)
// For now, try to finalize anyway; Zitadel will enforce MFA if required
router.push("/auth/login/mfa");
return;
}

// No MFA required — finalize the auth request
const callbackUrl = await finalizeAuthRequest(
session.authRequestId,
session.sessionId,
result.sessionToken
);

// Clean up the login session cookie
await clearLoginSession();

// Redirect to the NextAuth callback to complete OIDC flow
router.push(callbackUrl);
} catch (err) {
if (err instanceof Error && err.message.includes("Invalid password")) {
setError("Incorrect password. Please try again.");
} else {
setError("Something went wrong. Please try again.");
}
} finally {
setIsLoading(false);
}
};

if (!sessionReady) {
return (
<AuthCard>
<div className="flex items-center justify-center py-8">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary-200 border-t-primary-600" />
</div>
</AuthCard>
);
}

return (
<AuthCard>
<div className="mb-4">
<BackLink href="/auth/signin" label="Use a different account" />
</div>

<h2 className="mb-2 text-center text-xl font-semibold text-slate-900 dark:text-white">
Enter your password
</h2>
{loginName && (
<p className="mb-6 text-center text-sm text-slate-500 dark:text-slate-400">
Signing in as <span className="font-medium text-slate-700 dark:text-slate-300">{loginName}</span>
</p>
)}

<form onSubmit={handleSubmit} className="space-y-4">
<AuthInput
ref={inputRef}
label="Password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password"
autoComplete="current-password"
error={error || undefined}
disabled={isLoading}
/>

<Button
type="submit"
className="w-full"
disabled={isLoading}
>
{isLoading ? "Verifying..." : "Sign in"}
</Button>
</form>
</AuthCard>
);
}
26 changes: 26 additions & 0 deletions components/auth/AuthCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { cn } from "@/lib/utils";

interface AuthCardProps {
children: React.ReactNode;
className?: string;
}

export function AuthCard({ children, className }: AuthCardProps) {
return (
<div
className={cn(
"w-full max-w-md rounded-xl border border-slate-200 bg-white p-8 shadow-lg",
"dark:border-slate-700 dark:bg-slate-800",
className
)}
>
<div className="mb-6 text-center">
<h1 className="text-3xl font-bold text-slate-900 dark:text-white">OntoKit</h1>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
Collaborative ontology curation
</p>
</div>
{children}
</div>
);
}
49 changes: 49 additions & 0 deletions components/auth/AuthInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"use client";

import { forwardRef, type InputHTMLAttributes } from "react";
import { cn } from "@/lib/utils";

interface AuthInputProps extends InputHTMLAttributes<HTMLInputElement> {
label: string;
error?: string;
}

export const AuthInput = forwardRef<HTMLInputElement, AuthInputProps>(
({ label, error, className, id, ...props }, ref) => {
const inputId = id || label.toLowerCase().replace(/\s+/g, "-");

return (
<div className="space-y-1">
<label
htmlFor={inputId}
className="block text-sm font-medium text-slate-700 dark:text-slate-300"
>
{label}
</label>
<input
ref={ref}
id={inputId}
className={cn(
"w-full rounded-md border px-3 py-2 text-sm shadow-sm transition-colors",
"border-slate-300 bg-white text-slate-900 placeholder:text-slate-400",
"focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20",
"dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100 dark:placeholder:text-slate-500",
"dark:focus:border-primary-400 dark:focus:ring-primary-400/20",
error && "border-red-500 focus:border-red-500 focus:ring-red-500/20",
className
)}
aria-invalid={!!error}
aria-describedby={error ? `${inputId}-error` : undefined}
{...props}
/>
{error && (
<p id={`${inputId}-error`} className="text-sm text-red-600 dark:text-red-400" role="alert">
{error}
</p>
)}
</div>
);
}
);

AuthInput.displayName = "AuthInput";
21 changes: 21 additions & 0 deletions components/auth/BackLink.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"use client";

import Link from "next/link";
import { ArrowLeft } from "lucide-react";

interface BackLinkProps {
href: string;
label?: string;
}

export function BackLink({ href, label = "Back" }: BackLinkProps) {
return (
<Link
href={href}
className="inline-flex items-center gap-1.5 text-sm text-slate-500 transition-colors hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200"
>
<ArrowLeft className="h-4 w-4" />
{label}
</Link>
);
}
Loading
Loading