diff --git a/app/(auth)/invite/page.tsx b/app/(auth)/invite/page.tsx new file mode 100644 index 0000000..d807ccd --- /dev/null +++ b/app/(auth)/invite/page.tsx @@ -0,0 +1,224 @@ +"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"; + +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. +

+ )} +
+
+ ); +} + +export default function InvitePage() { + return ( + + +
+ } + > + + + ); +} diff --git a/app/api/auth/invite/route.ts b/app/api/auth/invite/route.ts new file mode 100644 index 0000000..96984c1 --- /dev/null +++ b/app/api/auth/invite/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 invitation token" }, + { status: 400 }, + ); + } + + const { status, data, headers } = await apiClient( + request, + `/api/v1/auth/invite/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/auth/LoginModal.tsx b/app/components/auth/LoginModal.tsx index 37c5b6d..0ff850b 100644 --- a/app/components/auth/LoginModal.tsx +++ b/app/components/auth/LoginModal.tsx @@ -16,7 +16,7 @@ interface LoginModalProps { export default function LoginModal({ open, onClose }: LoginModalProps) { const router = useRouter(); - const { loginWithGoogle } = useAuth(); + const { loginWithToken } = useAuth(); const toast = useToast(); const [isLoggingIn, setIsLoggingIn] = useState(false); @@ -38,7 +38,7 @@ export default function LoginModal({ open, onClose }: LoginModalProps) { }); const { access_token, user, google_profile } = res.data; - loginWithGoogle(access_token, user, google_profile); + loginWithToken(access_token, user, google_profile); onClose(); } catch (err) { toast.error( diff --git a/app/globals.css b/app/globals.css index 92c8a4c..b691cc4 100644 --- a/app/globals.css +++ b/app/globals.css @@ -161,3 +161,12 @@ a { border: 3px solid #e5e5e5; border-top-color: #171717; } + +@keyframes progress { + from { + width: 0%; + } + to { + width: 100%; + } +} diff --git a/app/lib/context/AuthContext.tsx b/app/lib/context/AuthContext.tsx index 1bf4131..eb91b32 100644 --- a/app/lib/context/AuthContext.tsx +++ b/app/lib/context/AuthContext.tsx @@ -107,7 +107,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { ); const setKeys = useCallback((keys: APIKey[]) => persist(keys), [persist]); - const loginWithGoogle = useCallback( + const loginWithToken = useCallback( (accessToken: string, user?: User, googleProfile?: GoogleProfile) => { const newSession: Session = { accessToken, @@ -160,7 +160,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { addKey, removeKey, setKeys, - loginWithGoogle, + loginWithToken, logout, }} > diff --git a/app/lib/types/auth.ts b/app/lib/types/auth.ts index 0c8f8cf..5c69a1a 100644 --- a/app/lib/types/auth.ts +++ b/app/lib/types/auth.ts @@ -44,6 +44,16 @@ export interface AuthTokenResponse { error?: string; } +export interface InviteVerifyResponse { + success: boolean; + data?: { + access_token: string; + token_type: string; + user: User; + }; + error?: string; +} + export interface Session { accessToken: string; user: User | null; @@ -61,7 +71,7 @@ export interface AuthContextValue { addKey: (key: APIKey) => void; removeKey: (id: string) => void; setKeys: (keys: APIKey[]) => void; - loginWithGoogle: ( + loginWithToken: ( accessToken: string, user?: User, googleProfile?: GoogleProfile,