diff --git a/app/(auth)/invite/page.tsx b/app/(auth)/invite/page.tsx index d807ccd..762eaef 100644 --- a/app/(auth)/invite/page.tsx +++ b/app/(auth)/invite/page.tsx @@ -1,211 +1,29 @@ "use client"; -import { useEffect, useState, Suspense } from "react"; -import { useSearchParams, useRouter } from "next/navigation"; -import { useAuth } from "@/app/lib/context/AuthContext"; -import { InviteVerifyResponse } from "@/app/lib/types/auth"; -import { - CheckCircleIcon, - WarningIcon, - SpinnerIcon, -} from "@/app/components/icons"; -import { Button } from "@/app/components"; - -type Status = "verifying" | "success" | "error"; +import { Suspense } from "react"; +import { useSearchParams } from "next/navigation"; +import { SpinnerIcon } from "@/app/components/icons"; +import TokenVerifyPage from "@/app/components/auth/TokenVerifyPage"; function InviteContent() { const searchParams = useSearchParams(); - const router = useRouter(); - const { loginWithToken } = useAuth(); - const [status, setStatus] = useState("verifying"); - const [error, setError] = useState(""); - const [progress, setProgress] = useState(0); - - useEffect(() => { - const token = searchParams.get("token"); - - if (!token) { - setStatus("error"); - setError("Invalid invitation link. No token found."); - return; - } - - let cancelled = false; - - (async () => { - try { - const res = await fetch( - `/api/auth/invite?token=${encodeURIComponent(token)}`, - { credentials: "include" }, - ); - - const data: InviteVerifyResponse = await res.json(); - - if (cancelled) return; - - if (!res.ok || !data.success || !data.data) { - setStatus("error"); - setError(data.error || "Invitation link is invalid or has expired."); - return; - } - - loginWithToken(data.data.access_token, data.data.user); - setStatus("success"); - } catch { - if (!cancelled) { - setStatus("error"); - setError("Failed to verify invitation. Please try again."); - } - } - })(); - - return () => { - cancelled = true; - }; - }, [searchParams, loginWithToken]); - - useEffect(() => { - if (status !== "success") return; - - const duration = 2000; - const interval = 30; - let elapsed = 0; - - const timer = setInterval(() => { - elapsed += interval; - const pct = Math.min((elapsed / duration) * 100, 100); - setProgress(pct); - - if (elapsed >= duration) { - clearInterval(timer); - router.push("/evaluations"); - } - }, interval); - - return () => clearInterval(timer); - }, [status, router]); return ( -
-
-
-
-
- -
-
-

- Kaapi Konsole -

-

by Tech4Dev

-
- -
-
- -
-
-
- {status === "verifying" && ( - - )} - {status === "success" && ( - - )} - {status === "error" && ( - - )} -
-
- - {/* Title */} -

- {status === "verifying" && "Verifying invitation"} - {status === "success" && "Welcome aboard!"} - {status === "error" && "Something went wrong"} -

- -

- {status === "verifying" && - "Please wait while we verify your invitation and set up your account."} - {status === "success" && - "Your account has been activated. Redirecting you to the dashboard..."} - {status === "error" && error} -

- - {status === "success" && ( -
-
-
-
-
- )} - - {status === "verifying" && ( -
- {[0, 1, 2].map((i) => ( -
- ))} -
- )} - - {status === "error" && ( -
- - -
- )} -
-
- - {status === "error" && ( -

- If this keeps happening, please contact your organization - administrator for a new invitation link. -

- )} -
-
+ ); } diff --git a/app/(auth)/verify/page.tsx b/app/(auth)/verify/page.tsx new file mode 100644 index 0000000..20caf11 --- /dev/null +++ b/app/(auth)/verify/page.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { Suspense } from "react"; +import { useSearchParams } from "next/navigation"; +import { SpinnerIcon } from "@/app/components/icons"; +import TokenVerifyPage from "@/app/components/auth/TokenVerifyPage"; + +function VerifyContent() { + const searchParams = useSearchParams(); + + return ( + + ); +} + +export default function VerifyPage() { + return ( + + +
+ } + > + + + ); +} diff --git a/app/api/auth/magic-link/route.ts b/app/api/auth/magic-link/route.ts new file mode 100644 index 0000000..b88f67b --- /dev/null +++ b/app/api/auth/magic-link/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { apiClient } from "@/app/lib/apiClient"; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { status, data } = await apiClient( + request, + "/api/v1/auth/magic-link", + { + method: "POST", + body: JSON.stringify(body), + }, + ); + return NextResponse.json(data, { status }); + } catch { + return NextResponse.json( + { success: false, error: "Failed to connect to backend" }, + { status: 500 }, + ); + } +} diff --git a/app/api/auth/magic-link/verify/route.ts b/app/api/auth/magic-link/verify/route.ts new file mode 100644 index 0000000..fc436f5 --- /dev/null +++ b/app/api/auth/magic-link/verify/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from "next/server"; +import { apiClient } from "@/app/lib/apiClient"; + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const token = searchParams.get("token"); + + if (!token) { + return NextResponse.json( + { success: false, error: "Missing login token" }, + { status: 400 }, + ); + } + + const { status, data, headers } = await apiClient( + request, + `/api/v1/auth/magic-link/verify?token=${encodeURIComponent(token)}`, + ); + + const res = NextResponse.json(data, { status }); + + const setCookies = headers.getSetCookie?.() ?? []; + for (const cookie of setCookies) { + res.headers.append("Set-Cookie", cookie); + } + + return res; + } catch { + return NextResponse.json( + { success: false, error: "Failed to connect to backend" }, + { status: 500 }, + ); + } +} diff --git a/app/components/Field.tsx b/app/components/Field.tsx index eb32b1a..f6a3a99 100644 --- a/app/components/Field.tsx +++ b/app/components/Field.tsx @@ -11,6 +11,7 @@ interface FieldProps { error?: string; type?: string; disabled?: boolean; + className?: string; } export default function Field({ @@ -21,6 +22,7 @@ export default function Field({ error, type = "text", disabled = false, + className = "", }: FieldProps) { const [showPassword, setShowPassword] = useState(false); const isPassword = type === "password"; @@ -40,7 +42,7 @@ export default function Field({ disabled={disabled} className={`w-full px-3 py-2 rounded-lg border text-sm text-text-primary bg-white placeholder:text-neutral-400 focus:outline-none focus:ring-accent-primary/20 focus:border-accent-primary transition-colors ${ isPassword ? "pr-10" : "" - } ${error ? "border-red-400" : "border-border"} ${disabled ? "opacity-50 cursor-not-allowed" : ""}`} + } ${error ? "border-red-400" : "border-border"} ${disabled ? "opacity-50 cursor-not-allowed" : ""} ${className}`} /> {isPassword && ( -

+
+
+ OR +
+
+ + {linkSent ? ( +
+
+ +
+

+ Check your email +

+

+ We sent a login link to{" "} + {email}. + Click the link in the email to sign in. +

+ +
+ ) : ( +
+ { + setEmail(val); + if (emailError) setEmailError(""); + }} + placeholder="Email address" + error={emailError} + className="rounded-full! px-5! py-3!" + /> + +
+ )} + + {!linkSent && ( +

+ Want to use an X-API key instead?{" "} + +

+ )}
); diff --git a/app/components/auth/TokenVerifyPage.tsx b/app/components/auth/TokenVerifyPage.tsx new file mode 100644 index 0000000..cb31452 --- /dev/null +++ b/app/components/auth/TokenVerifyPage.tsx @@ -0,0 +1,238 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { useAuth } from "@/app/lib/context/AuthContext"; +import { AuthTokenResponse } from "@/app/lib/types/auth"; +import { + CheckCircleIcon, + WarningIcon, + SpinnerIcon, +} from "@/app/components/icons"; +import { Button } from "@/app/components"; +import { APP_NAME } from "@/app/lib/constants"; + +type Status = "verifying" | "success" | "error"; + +interface TokenVerifyPageProps { + token: string | null; + apiUrl: string; + title: { + verifying: string; + success: string; + error: string; + }; + description: { + verifying: string; + success: string; + }; + errorFallback: string; + helpText?: string; +} + +export default function TokenVerifyPage({ + token, + apiUrl, + title, + description, + errorFallback, + helpText, +}: TokenVerifyPageProps) { + const router = useRouter(); + const { loginWithToken } = useAuth(); + const [status, setStatus] = useState("verifying"); + const [error, setError] = useState(""); + const [progress, setProgress] = useState(0); + + useEffect(() => { + if (!token) { + setStatus("error"); + setError("Invalid link. No token found."); + return; + } + + let cancelled = false; + + (async () => { + try { + const res = await fetch( + `${apiUrl}?token=${encodeURIComponent(token)}`, + { credentials: "include" }, + ); + + const data: AuthTokenResponse & { + data?: { + user?: { + id: number; + email: string; + full_name: string; + is_active: boolean; + is_superuser: boolean; + }; + }; + } = await res.json(); + + if (cancelled) return; + + if (!res.ok || !data.success || !data.data) { + setStatus("error"); + setError(data.error || errorFallback); + return; + } + + loginWithToken(data.data.access_token, data.data.user); + setStatus("success"); + } catch { + if (!cancelled) { + setStatus("error"); + setError("Failed to verify. Please try again."); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [token, apiUrl, loginWithToken, errorFallback]); + + // Drive progress bar and redirect on success + useEffect(() => { + if (status !== "success") return; + + const duration = 2000; + const interval = 30; + let elapsed = 0; + + const timer = setInterval(() => { + elapsed += interval; + const pct = Math.min((elapsed / duration) * 100, 100); + setProgress(pct); + + if (elapsed >= duration) { + clearInterval(timer); + router.push("/evaluations"); + } + }, interval); + + return () => clearInterval(timer); + }, [status, router]); + + return ( +
+
+
+
+
+ +
+
+

+ {APP_NAME} +

+

by Tech4Dev

+
+ +
+
+ +
+
+
+ {status === "verifying" && ( + + )} + {status === "success" && ( + + )} + {status === "error" && ( + + )} +
+
+ +

+ {status === "verifying" && title.verifying} + {status === "success" && title.success} + {status === "error" && title.error} +

+ +

+ {status === "verifying" && description.verifying} + {status === "success" && description.success} + {status === "error" && error} +

+ + {status === "success" && ( +
+
+
+
+
+ )} + + {status === "verifying" && ( +
+ {[0, 1, 2].map((i) => ( +
+ ))} +
+ )} + + {status === "error" && ( +
+ + +
+ )} +
+
+ + {status === "error" && helpText && ( +

+ {helpText} +

+ )} +
+
+ ); +} diff --git a/app/components/auth/index.ts b/app/components/auth/index.ts index 366a852..3cf3b46 100644 --- a/app/components/auth/index.ts +++ b/app/components/auth/index.ts @@ -1,3 +1,4 @@ export { default as LoginModal } from "./LoginModal"; export { default as FeatureGateModal } from "./FeatureGateModal"; export { default as ProtectedPage } from "./ProtectedPage"; +export { default as TokenVerifyPage } from "./TokenVerifyPage"; diff --git a/app/components/icons/common/MailIcon.tsx b/app/components/icons/common/MailIcon.tsx new file mode 100644 index 0000000..1b8a834 --- /dev/null +++ b/app/components/icons/common/MailIcon.tsx @@ -0,0 +1,21 @@ +export default function MailIcon({ + className = "w-5 h-5", +}: { + className?: string; +}) { + return ( + + + + ); +} diff --git a/app/components/icons/index.tsx b/app/components/icons/index.tsx index b8d3dfe..ce9e2f5 100644 --- a/app/components/icons/index.tsx +++ b/app/components/icons/index.tsx @@ -10,6 +10,7 @@ export { default as WarningTriangleIcon } from "./common/WarningTriangleIcon"; export { default as PlusIcon } from "./common/PlusIcon"; export { default as SearchIcon } from "./common/SearchIcon"; export { default as SidebarToggleIcon } from "./common/SidebarToggleIcon"; +export { default as MailIcon } from "./common/MailIcon"; // Evaluations Icons export { default as ChevronUpIcon } from "./evaluations/ChevronUpIcon"; diff --git a/app/components/user-menu/Branding.tsx b/app/components/user-menu/Branding.tsx index 19353d4..55c259e 100644 --- a/app/components/user-menu/Branding.tsx +++ b/app/components/user-menu/Branding.tsx @@ -1,8 +1,10 @@ +import { APP_NAME } from "@/app/lib/constants"; + const Branding = () => { return (

- Kaapi Konsole + {APP_NAME}

Tech4Dev

diff --git a/app/layout.tsx b/app/layout.tsx index 77bc11b..b743fd1 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { Inter, JetBrains_Mono } from "next/font/google"; import "./globals.css"; import { Providers } from "@/app/components/providers"; +import { APP_NAME } from "@/app/lib/constants"; const inter = Inter({ variable: "--font-sans", @@ -16,7 +17,7 @@ const jetbrainsMono = JetBrains_Mono({ }); export const metadata: Metadata = { - title: "Kaapi Konsole", + title: APP_NAME, description: "", }; diff --git a/app/lib/constants.ts b/app/lib/constants.ts index 274db8d..5fdbd86 100644 --- a/app/lib/constants.ts +++ b/app/lib/constants.ts @@ -4,7 +4,8 @@ import { ConfigBlob } from "@/app/lib/types/promptEditor"; -// localStorage keys +export const APP_NAME = "Kaapi Konsole"; + export const STORAGE_KEYS = { API_KEYS: "kaapi_api_keys", SESSION: "kaapi_session",