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
16 changes: 15 additions & 1 deletion app-next/src/app/[locale]/dashboard/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { getTranslations } from "next-intl/server";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { UserDashboard } from "@/components/dashboard/user-dashboard";
import type { Metadata } from "next";

Expand All @@ -23,6 +26,17 @@ export async function generateMetadata({
};
}

export default function DashboardPage() {
export default async function DashboardPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const session = await getServerSession(authOptions);

if (!session) {
redirect(`/${locale}/auth/sign-in?callbackUrl=/${locale}/dashboard`);
}

return <UserDashboard />;
}
37 changes: 37 additions & 0 deletions app-next/src/app/api/(dev)/debug-env/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { NextResponse } from "next/server";

/**
* DEBUG ENDPOINT - Remove in production!
* Shows server-side environment variables
*/
export async function GET() {
// Only allow in development/testing
if (process.env.NODE_ENV === "production") {
return NextResponse.json({ error: "Not available in production" }, { status: 403 });
}

const serverEnv = {
// Database
MYSQL_HOST: process.env.MYSQL_HOST || "(not set)",
MYSQL_PORT: process.env.MYSQL_PORT || "(not set)",
MYSQL_DATABASE: process.env.MYSQL_DATABASE || "(not set)",
MYSQL_USER: process.env.MYSQL_USER || "(not set)",
MYSQL_PASSWORD: process.env.MYSQL_PASSWORD ? "***SET***" : "(not set)",

// GitHub
GITHUB_CLIENT_ID: process.env.GITHUB_CLIENT_ID || "(not set)",
GITHUB_CLIENT_SECRET: process.env.GITHUB_CLIENT_SECRET ? "***SET***" : "(not set)",

// Other secrets
JWT_SECRET: process.env.JWT_SECRET ? "***SET***" : "(not set)",

// Public URLs (for comparison)
NEXT_PUBLIC_OPENML_API_URL: process.env.NEXT_PUBLIC_OPENML_API_URL || "(not set)",
NEXT_PUBLIC_ELASTICSEARCH_URL: process.env.NEXT_PUBLIC_ELASTICSEARCH_URL || "(not set)",
};

return NextResponse.json({
message: "Server-side environment variables",
env: serverEnv,
});
}
53 changes: 41 additions & 12 deletions app-next/src/app/api/auth/[...nextauth]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,6 @@ export const authOptions: NextAuthOptions = {

try {
// Direct database authentication - bypasses Flask
console.log("[Auth] Direct DB login for:", credentials.email);

// Find user by email or username
// Query only columns guaranteed to exist in the legacy schema
const user = await queryOne(
Expand All @@ -75,15 +73,13 @@ export const authOptions: NextAuthOptions = {
);

if (!user) {
console.log("[Auth] User not found:", credentials.email);
return null;
}

const dbUser = user as DbUser;

// Check if user is active
if (!dbUser.active) {
console.log("[Auth] User not activated:", credentials.email);
return null;
}

Expand All @@ -94,12 +90,9 @@ export const authOptions: NextAuthOptions = {
);

if (!isValid) {
console.log("[Auth] Invalid password for:", credentials.email);
return null;
}

console.log("[Auth] Login successful for:", dbUser.username);

// Try to get session_hash (API key) if column exists
let sessionHash: string | null = null;
try {
Expand All @@ -112,6 +105,36 @@ export const authOptions: NextAuthOptions = {
// session_hash column may not exist in all deployments
}

// Resolve real OpenML user ID from API key (handles local dev ID mismatch)
let openmlUserId: string | undefined;
if (sessionHash) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 4000);
try {
const openmlApiUrl =
process.env.OPENML_API_URL || "https://www.openml.org";
// Try /user/whoami first, fall back to /user/data
for (const path of ["/api/v1/json/user/whoami", "/api/v1/json/user/data"]) {
const res = await fetch(
`${openmlApiUrl}${path}?api_key=${encodeURIComponent(sessionHash)}`,
{ signal: controller.signal },
);
if (res.ok) {
const data = await res.json();
const rawId = data?.user?.id ?? data?.id;
if (rawId != null) {
openmlUserId = String(rawId);
break;
}
}
}
} catch {
// Non-critical: fall back to local DB ID for ownership checks
} finally {
clearTimeout(timeoutId);
}
}

// Return user object
return {
id: dbUser.id.toString(),
Expand All @@ -123,6 +146,7 @@ export const authOptions: NextAuthOptions = {
dbUser.image && dbUser.image !== "0000" ? dbUser.image : null,
username: dbUser.username,
session_hash: sessionHash,
openmlUserId,
};
} catch (error) {
console.error("Login error:", error);
Expand Down Expand Up @@ -309,7 +333,7 @@ export const authOptions: NextAuthOptions = {
user.session_hash = dbUser.session_hash || null;
}
// Mark as local user (OAuth users don't exist on openml.org)
(user as any).isLocalUser = true;
user.isLocalUser = true;
return true;
} catch (error) {
console.error("SignIn Callback Error:", error);
Expand All @@ -336,7 +360,8 @@ export const authOptions: NextAuthOptions = {
token.firstName = user.firstName;
token.lastName = user.lastName;
token.picture = user.image;
token.isLocalUser = (user as any).isLocalUser || false;
token.isLocalUser = user.isLocalUser || false;
token.openmlUserId = user.openmlUserId;
}

return token;
Expand All @@ -352,14 +377,18 @@ export const authOptions: NextAuthOptions = {
session.user.lastName = token.lastName;
// Add API key to session for likes/votes
if (token.apikey) {
session.apikey = token.apikey as string;
session.apikey = token.apikey;
}
// Add profile image to session
if (token.picture) {
session.user.image = token.picture as string;
session.user.image = token.picture;
}
// Mark if user is local-only (not from openml.org)
(session.user as any).isLocalUser = token.isLocalUser || false;
session.user.isLocalUser = token.isLocalUser || false;
// Real OpenML user ID (may differ from local DB ID in dev environments)
if (token.openmlUserId) {
session.user.openmlUserId = token.openmlUserId;
}
}
return session;
},
Expand Down
150 changes: 150 additions & 0 deletions app-next/src/app/api/collections/create/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { sendCreationConfirmationEmail } from "@/lib/mail";

const OPENML_API =
process.env.OPENML_API_URL ||
process.env.NEXT_PUBLIC_OPENML_API_URL ||
"https://www.openml.org";

function buildStudyXml(fields: {
name: string;
description?: string;
mainEntityType: "task" | "run";
taskIds?: number[];
runIds?: number[];
}): string {
const esc = (s: string) =>
s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");

// OpenML uses `alias` as the human-readable name; must be URL-safe
const alias = fields.name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");

const taskSection = fields.taskIds?.length
? ` <oml:tasks>\n` +
fields.taskIds.map((id) => ` <oml:task_id>${id}</oml:task_id>\n`).join("") +
` </oml:tasks>\n`
: "";

const runSection = fields.runIds?.length
? ` <oml:runs>\n` +
fields.runIds.map((id) => ` <oml:run_id>${id}</oml:run_id>\n`).join("") +
` </oml:runs>\n`
: "";

return (
`<?xml version="1.0" encoding="UTF-8"?>\n` +
`<oml:study xmlns:oml="http://openml.org/openml">\n` +
` <oml:alias>${esc(alias)}</oml:alias>\n` +
` <oml:main_entity_type>${fields.mainEntityType}</oml:main_entity_type>\n` +
` <oml:name>${esc(fields.name)}</oml:name>\n` +
(fields.description
? ` <oml:description>${esc(fields.description)}</oml:description>\n`
: "") +
taskSection +
runSection +
`</oml:study>`
);
}

export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const apiKey = (session as { apikey?: string }).apikey;
if (!apiKey) {
return NextResponse.json(
{ error: "No API key found. Please re-sign in." },
{ status: 401 },
);
}

const body = await request.json();
const { collectionname, description, taskids, runids, collectiontype } = body;

if (!collectionname || (!taskids && !runids)) {
return NextResponse.json(
{ error: "collectionname and at least one task or run ID are required." },
{ status: 400 },
);
}

const parseIds = (raw: string | undefined) =>
raw
? String(raw).split(/[\s,]+/).map((s) => parseInt(s.trim())).filter((n) => !isNaN(n))
: [];

const taskIdList = parseIds(taskids);
const runIdList = parseIds(runids);

if (taskIdList.length === 0 && runIdList.length === 0) {
return NextResponse.json(
{ error: "No valid task or run IDs provided." },
{ status: 400 },
);
}

const xml = buildStudyXml({
name: collectionname,
description: description || undefined,
mainEntityType: collectiontype === "runs" ? "run" : "task",
taskIds: taskIdList.length ? taskIdList : undefined,
runIds: runIdList.length ? runIdList : undefined,
});

const openmlForm = new FormData();
openmlForm.append("api_key", apiKey);
openmlForm.append(
"description",
new Blob([xml], { type: "text/xml" }),
"description.xml",
);

const response = await fetch(`${OPENML_API}/api/v1/study`, {
method: "POST",
body: openmlForm,
});

if (!response.ok) {
const text = await response.text();
console.error("OpenML study create error:", text);
let message = "Failed to create collection. Please try again.";
if (response.status === 401 || response.status === 403) {
message = "Your API key was rejected.";
} else {
const msgMatch = text.match(/<oml:message>([^<]+)<\/oml:message>/);
const infoMatch = text.match(/<oml:additional_information>([^<]+)<\/oml:additional_information>/);
if (msgMatch) message = msgMatch[1].trim();
if (infoMatch) message += ` — ${infoMatch[1].trim()}`;
}
return NextResponse.json({ error: message }, { status: response.status });
}

const text = await response.text();
const idMatch = text.match(/<oml:id>(\d+)<\/oml:id>/);
const studyId: string = idMatch ? idMatch[1] : "new";

if (session.user.email) {
sendCreationConfirmationEmail(
session.user.email,
"collection",
collectionname,
studyId,
).catch((err: unknown) =>
console.error("Failed to send collection creation email:", err),
);
}

return NextResponse.json({ success: true, id: studyId });
} catch (error) {
console.error("Collection create error:", error);
return NextResponse.json(
{ error: "Failed to create collection." },
{ status: 500 },
);
}
}
9 changes: 0 additions & 9 deletions app-next/src/app/api/count/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,6 @@ export async function GET() {
(i) => i !== "user" && i !== "benchmark",
);

// console.log("🔍 [Count API] Elasticsearch URL:", elasticsearchEndpoint);
// console.log("📦 [Count API] Indices:", indices);

// Build NDJSON body for _msearch - correct format
// For datasets (data index), only count active ones per team leader request
let requestBody = "";
Expand Down Expand Up @@ -44,16 +41,11 @@ export async function GET() {
const startTime = Date.now();

try {
// console.log("⏳ [Count API] Sending request...");

const response = await axios.post(elasticsearchEndpoint, requestBody, {
headers: { "Content-Type": "application/x-ndjson" },
timeout: 30000, // 30 second timeout
});

const duration = Date.now() - startTime;
// console.log(`✅ [Count API] Success in ${duration}ms`);

// Extract counts safely
const allLabels = [...indices, ...extraLabels];
const counts = response.data.responses.map((r: any, i: number) => ({
Expand All @@ -62,7 +54,6 @@ export async function GET() {
typeof r.hits.total === "number" ? r.hits.total : r.hits.total.value,
}));

// console.log("📊 [Count API] Counts:", counts);
return NextResponse.json(counts);
} catch (error) {
const duration = Date.now() - startTime;
Expand Down
Loading