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
220 changes: 19 additions & 201 deletions app/(auth)/invite/page.tsx
Original file line number Diff line number Diff line change
@@ -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<Status>("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 (
<div className="min-h-screen bg-bg-secondary flex flex-col items-center justify-center p-4 relative overflow-hidden">
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute -top-1/2 -right-1/4 w-[600px] h-[600px] rounded-full bg-linear-to-br from-blue-50 to-purple-50 opacity-60 blur-3xl" />
<div className="absolute -bottom-1/2 -left-1/4 w-[500px] h-[500px] rounded-full bg-linear-to-tr from-green-50 to-blue-50 opacity-40 blur-3xl" />
</div>

<div className="w-full max-w-sm relative z-10">
<div className="text-center mb-8">
<h2 className="text-lg font-semibold text-text-primary tracking-tight">
Kaapi Konsole
</h2>
<p className="text-xs text-text-secondary mt-0.5">by Tech4Dev</p>
</div>

<div
className={`bg-white rounded-2xl border shadow-sm overflow-hidden transition-all duration-500 ${
status === "error"
? "border-red-200"
: status === "success"
? "border-green-200"
: "border-border"
}`}
>
<div
className="h-1 transition-all duration-700"
style={{
background:
status === "error"
? "linear-gradient(90deg, #fca5a5, #ef4444)"
: status === "success"
? "linear-gradient(90deg, #86efac, #22c55e)"
: "linear-gradient(90deg, #dbeafe, #c7d2fe, #ddd6fe)",
}}
/>

<div className="px-8 py-10">
<div className="flex justify-center mb-5">
<div
className={`w-16 h-16 rounded-full flex items-center justify-center transition-all duration-500 ${
status === "verifying"
? "bg-neutral-50 border border-border"
: status === "success"
? "bg-green-50 border border-green-200"
: "bg-red-50 border border-red-200"
}`}
>
{status === "verifying" && (
<SpinnerIcon className="w-7 h-7 text-text-secondary animate-spin" />
)}
{status === "success" && (
<CheckCircleIcon className="w-8 h-8 text-green-600" />
)}
{status === "error" && (
<WarningIcon className="w-8 h-8 text-red-500" />
)}
</div>
</div>

{/* Title */}
<h1 className="text-center text-xl font-semibold text-text-primary mb-2">
{status === "verifying" && "Verifying invitation"}
{status === "success" && "Welcome aboard!"}
{status === "error" && "Something went wrong"}
</h1>

<p className="text-sm text-text-secondary leading-relaxed">
{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}
</p>

{status === "success" && (
<div className="mt-6 flex justify-center">
<div className="h-1 w-32 rounded-full bg-neutral-100 overflow-hidden">
<div
className="h-full bg-green-500 rounded-full transition-[width] duration-75 ease-linear"
style={{ width: `${progress}%` }}
/>
</div>
</div>
)}

{status === "verifying" && (
<div className="mt-6 flex justify-center gap-1.5">
{[0, 1, 2].map((i) => (
<div
key={i}
className="w-1.5 h-1.5 rounded-full bg-text-secondary animate-pulse"
style={{ animationDelay: `${i * 200}ms` }}
/>
))}
</div>
)}

{status === "error" && (
<div className="mt-8 space-y-3">
<Button fullWidth onClick={() => router.push("/evaluations")}>
Go to Dashboard
</Button>
<button
onClick={() => window.location.reload()}
className="w-full text-center text-xs text-text-secondary hover:text-text-primary transition-colors cursor-pointer"
>
Try again
</button>
</div>
)}
</div>
</div>

{status === "error" && (
<p className="text-center text-xs text-text-secondary mt-5 leading-relaxed">
If this keeps happening, please contact your organization
administrator for a new invitation link.
</p>
)}
</div>
</div>
<TokenVerifyPage
token={searchParams.get("token")}
apiUrl="/api/auth/invite"
title={{
verifying: "Verifying invitation",
success: "Welcome aboard!",
error: "Something went wrong",
}}
description={{
verifying: "Hang tight — we're setting up your account.",
success: "Your account has been activated. Redirecting to dashboard...",
}}
errorFallback="Invitation link is invalid or has expired."
helpText="If this keeps happening, please contact your organization administrator for a new invitation link."
/>
);
}

Expand Down
42 changes: 42 additions & 0 deletions app/(auth)/verify/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<TokenVerifyPage
token={searchParams.get("token")}
apiUrl="/api/auth/magic-link/verify"
title={{
verifying: "Signing you in",
success: "You're in!",
error: "Login link failed",
}}
description={{
verifying: "Verifying your login link...",
success: "Redirecting to dashboard...",
}}
errorFallback="Login link is invalid or has expired."
helpText="Login links expire after 15 minutes. Please request a new one from the login page."
/>
);
}

export default function VerifyPage() {
return (
<Suspense
fallback={
<div className="min-h-screen bg-bg-secondary flex items-center justify-center">
<SpinnerIcon className="w-8 h-8 text-text-secondary animate-spin" />
</div>
}
>
<VerifyContent />
</Suspense>
);
}
22 changes: 22 additions & 0 deletions app/api/auth/magic-link/route.ts
Original file line number Diff line number Diff line change
@@ -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 },
);
}
}
35 changes: 35 additions & 0 deletions app/api/auth/magic-link/verify/route.ts
Original file line number Diff line number Diff line change
@@ -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 },
);
}
}
4 changes: 3 additions & 1 deletion app/components/Field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ interface FieldProps {
error?: string;
type?: string;
disabled?: boolean;
className?: string;
}

export default function Field({
Expand All @@ -21,6 +22,7 @@ export default function Field({
error,
type = "text",
disabled = false,
className = "",
}: FieldProps) {
const [showPassword, setShowPassword] = useState(false);
const isPassword = type === "password";
Expand All @@ -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 && (
<button
Expand Down
Loading
Loading