From 0eed4ddf21a92fbecf3cad6164d7231dfe4ae243 Mon Sep 17 00:00:00 2001 From: Helder Mendes Date: Mon, 23 Feb 2026 12:30:57 +0100 Subject: [PATCH] feat(auth): protect create/upload routes and improve edit form UX Previously, unauthenticated users clicking Add/Upload received a 404. Dataset edit had no visible save feedback and a misleading error message when users had no valid OpenML API key. Auth guards: - /datasets/upload, /tasks/create, /collections/create: server-side auth check; unauthenticated users redirect to sign-in with a contextual ?reason= param (multilingual: en/nl/fr/de) Sign-in page: - Reads ?reason= query param and shows a localized info banner above the sign-in card explaining why sign-in is required Dataset edit form: - Replace inline success/error divs (invisible when scrolled) with useToast() fixed-position notifications - Fix locale prefix in all navigation after save (useLocale()) - Pass hasApiKey + isLocalUser flags from server session to the form; show amber warning banner and disable Save button when the session lacks a valid OpenML API key (local dev / OAuth test accounts) - Improve 401/403 error message in the API route to explain that local test accounts cannot save to the OpenML backend Co-Authored-By: Claude Sonnet 4.6 --- app-next/messages/de.json | 6 ++ app-next/messages/en.json | 6 ++ app-next/messages/fr.json | 6 ++ app-next/messages/nl.json | 6 ++ .../(explore)/collections/create/page.tsx | 26 +++++++++ .../(explore)/datasets/[id]/edit/page.tsx | 10 +++- .../(explore)/datasets/upload/page.tsx | 26 +++++++++ .../[locale]/(explore)/tasks/create/page.tsx | 26 +++++++++ .../[locale]/(extra)/auth/sign-in/page.tsx | 49 +++++++++++----- .../src/app/api/datasets/[id]/edit/route.ts | 6 +- .../components/dataset/dataset-edit-form.tsx | 57 +++++++++++-------- 11 files changed, 186 insertions(+), 38 deletions(-) create mode 100644 app-next/src/app/[locale]/(explore)/collections/create/page.tsx create mode 100644 app-next/src/app/[locale]/(explore)/datasets/upload/page.tsx create mode 100644 app-next/src/app/[locale]/(explore)/tasks/create/page.tsx diff --git a/app-next/messages/de.json b/app-next/messages/de.json index f8eac243..0547b33b 100644 --- a/app-next/messages/de.json +++ b/app-next/messages/de.json @@ -706,6 +706,12 @@ }, "auth": { "recommended": "EMPFOHLEN", + "signInRequired": { + "uploadDataset": "Sie müssen sich anmelden, um einen Datensatz hochzuladen", + "createTask": "Sie müssen sich anmelden, um eine Aufgabe zu definieren", + "createCollection": "Sie müssen sich anmelden, um eine Sammlung zu erstellen", + "default": "Sie müssen sich anmelden, um fortzufahren" + }, "signIn": { "title": "Anmelden - OpenML", "description": "Melden Sie sich bei Ihrem OpenML-Konto an", diff --git a/app-next/messages/en.json b/app-next/messages/en.json index fa458f77..ecf2b6ad 100644 --- a/app-next/messages/en.json +++ b/app-next/messages/en.json @@ -689,6 +689,12 @@ }, "auth": { "recommended": "RECOMMENDED", + "signInRequired": { + "uploadDataset": "You need to sign in to upload a dataset", + "createTask": "You need to sign in to define a task", + "createCollection": "You need to sign in to create a collection", + "default": "You need to sign in to continue" + }, "signIn": { "title": "Sign In - OpenML", "description": "Sign in to your OpenML account", diff --git a/app-next/messages/fr.json b/app-next/messages/fr.json index d72be02e..3a7abf1c 100644 --- a/app-next/messages/fr.json +++ b/app-next/messages/fr.json @@ -706,6 +706,12 @@ }, "auth": { "recommended": "RECOMMANDÉ", + "signInRequired": { + "uploadDataset": "Vous devez vous connecter pour télécharger un jeu de données", + "createTask": "Vous devez vous connecter pour définir une tâche", + "createCollection": "Vous devez vous connecter pour créer une collection", + "default": "Vous devez vous connecter pour continuer" + }, "signIn": { "title": "Se connecter - OpenML", "description": "Connectez-vous à votre compte OpenML", diff --git a/app-next/messages/nl.json b/app-next/messages/nl.json index 7f4c61c4..86645523 100644 --- a/app-next/messages/nl.json +++ b/app-next/messages/nl.json @@ -706,6 +706,12 @@ }, "auth": { "recommended": "AANBEVOLEN", + "signInRequired": { + "uploadDataset": "U moet inloggen om een dataset te uploaden", + "createTask": "U moet inloggen om een taak te definiëren", + "createCollection": "U moet inloggen om een collectie aan te maken", + "default": "U moet inloggen om verder te gaan" + }, "signIn": { "title": "Inloggen - OpenML", "description": "Log in op uw OpenML-account", diff --git a/app-next/src/app/[locale]/(explore)/collections/create/page.tsx b/app-next/src/app/[locale]/(explore)/collections/create/page.tsx new file mode 100644 index 00000000..f33adaf2 --- /dev/null +++ b/app-next/src/app/[locale]/(explore)/collections/create/page.tsx @@ -0,0 +1,26 @@ +import { getServerSession } from "next-auth"; +import { redirect } from "next/navigation"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; + +export default async function CollectionCreatePage({ + params, +}: { + params: Promise<{ locale: string }>; +}) { + const { locale } = await params; + const session = await getServerSession(authOptions); + + if (!session) { + redirect( + `/${locale}/auth/sign-in?reason=createCollection&callbackUrl=/${locale}/collections/create` + ); + } + + // TODO: Implement collection creation form + return ( +
+

Create Collection

+

Coming soon.

+
+ ); +} diff --git a/app-next/src/app/[locale]/(explore)/datasets/[id]/edit/page.tsx b/app-next/src/app/[locale]/(explore)/datasets/[id]/edit/page.tsx index 0154c364..c64b66b9 100644 --- a/app-next/src/app/[locale]/(explore)/datasets/[id]/edit/page.tsx +++ b/app-next/src/app/[locale]/(explore)/datasets/[id]/edit/page.tsx @@ -30,7 +30,7 @@ export default async function DatasetEditPage({ // Auth check — redirect to sign-in if not authenticated const session = await getServerSession(authOptions); if (!session?.user) { - redirect(`/auth/signin?callbackUrl=/datasets/${id}/edit`); + redirect(`/${locale}/auth/sign-in?reason=uploadDataset&callbackUrl=/${locale}/datasets/${id}/edit`); } const dataset = await fetchDataset(id); @@ -39,12 +39,20 @@ export default async function DatasetEditPage({ const userId = (session.user as { id?: string }).id; const isOwner = userId ? Number(userId) === dataset.uploader_id : false; + // Check whether the session has a valid OpenML API key + const hasApiKey = !!(session as { apikey?: string }).apikey; + // OAuth users created in local dev environments don't have a real OpenML API key + const isLocalUser = + (session.user as { isLocalUser?: boolean }).isLocalUser ?? false; + return (
; +}) { + const { locale } = await params; + const session = await getServerSession(authOptions); + + if (!session) { + redirect( + `/${locale}/auth/sign-in?reason=uploadDataset&callbackUrl=/${locale}/datasets/upload` + ); + } + + // TODO: Implement dataset upload form + return ( +
+

Upload Dataset

+

Coming soon.

+
+ ); +} diff --git a/app-next/src/app/[locale]/(explore)/tasks/create/page.tsx b/app-next/src/app/[locale]/(explore)/tasks/create/page.tsx new file mode 100644 index 00000000..73dbecf7 --- /dev/null +++ b/app-next/src/app/[locale]/(explore)/tasks/create/page.tsx @@ -0,0 +1,26 @@ +import { getServerSession } from "next-auth"; +import { redirect } from "next/navigation"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; + +export default async function TaskCreatePage({ + params, +}: { + params: Promise<{ locale: string }>; +}) { + const { locale } = await params; + const session = await getServerSession(authOptions); + + if (!session) { + redirect( + `/${locale}/auth/sign-in?reason=createTask&callbackUrl=/${locale}/tasks/create` + ); + } + + // TODO: Implement task creation form + return ( +
+

Define Task

+

Coming soon.

+
+ ); +} diff --git a/app-next/src/app/[locale]/(extra)/auth/sign-in/page.tsx b/app-next/src/app/[locale]/(extra)/auth/sign-in/page.tsx index 6f444ca1..04df4b17 100644 --- a/app-next/src/app/[locale]/(extra)/auth/sign-in/page.tsx +++ b/app-next/src/app/[locale]/(extra)/auth/sign-in/page.tsx @@ -8,6 +8,7 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; +import { LogIn } from "lucide-react"; export async function generateMetadata(): Promise { const t = await getTranslations("auth"); @@ -20,28 +21,50 @@ export async function generateMetadata(): Promise { export default async function SignInPage({ params, + searchParams, }: { params: Promise<{ locale: string }>; + searchParams: Promise<{ reason?: string }>; }) { const { locale } = await params; + const { reason } = await searchParams; setRequestLocale(locale); const t = await getTranslations("auth"); + // Map reason param to translated message + const reasonMessages: Record = { + uploadDataset: t("signInRequired.uploadDataset"), + createTask: t("signInRequired.createTask"), + createCollection: t("signInRequired.createCollection"), + }; + const reasonMessage = reason + ? (reasonMessages[reason] ?? t("signInRequired.default")) + : null; + return (
- - - - {t("signIn.welcome")} - - - {t("signIn.subtitle")} - - - - - - +
+ {/* Contextual message shown when redirected from a protected action */} + {reasonMessage && ( +
+ +

{reasonMessage}

+
+ )} + + + + {t("signIn.welcome")} + + + {t("signIn.subtitle")} + + + + + + +
); } diff --git a/app-next/src/app/api/datasets/[id]/edit/route.ts b/app-next/src/app/api/datasets/[id]/edit/route.ts index d73b8bb6..bc5e5546 100644 --- a/app-next/src/app/api/datasets/[id]/edit/route.ts +++ b/app-next/src/app/api/datasets/[id]/edit/route.ts @@ -72,8 +72,12 @@ export async function POST( if (!response.ok) { const text = await response.text(); console.error(`OpenML API error editing dataset ${id}:`, text); + const message = + response.status === 401 || response.status === 403 + ? "Your API key is not accepted by the OpenML server. If you are using a local test account, dataset editing is not supported — only real OpenML accounts can save changes." + : "Failed to save changes. Please try again."; return NextResponse.json( - { error: "Failed to save changes. Please try again." }, + { error: message }, { status: response.status }, ); } diff --git a/app-next/src/components/dataset/dataset-edit-form.tsx b/app-next/src/components/dataset/dataset-edit-form.tsx index 4a4270e5..eaba012f 100644 --- a/app-next/src/components/dataset/dataset-edit-form.tsx +++ b/app-next/src/components/dataset/dataset-edit-form.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; +import { useLocale } from "next-intl"; import Link from "next/link"; import { ArrowLeft, Save, Loader2, AlertTriangle } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -9,11 +10,14 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; +import { useToast } from "@/hooks/use-toast"; interface DatasetEditFormProps { datasetId: number; datasetName: string; isOwner: boolean; + hasApiKey: boolean; + isLocalUser: boolean; initialValues: { description: string; creator: string; @@ -33,29 +37,27 @@ export function DatasetEditForm({ datasetId, datasetName, isOwner, + hasApiKey, + isLocalUser, initialValues, features, }: DatasetEditFormProps) { const router = useRouter(); + const locale = useLocale(); + const { toast } = useToast(); const [values, setValues] = useState(initialValues); const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); - const [success, setSuccess] = useState(false); const handleChange = ( field: keyof typeof values, value: string, ) => { setValues((prev) => ({ ...prev, [field]: value })); - setError(null); - setSuccess(false); }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setSaving(true); - setError(null); - setSuccess(false); try { const res = await fetch(`/api/datasets/${datasetId}/edit`, { @@ -72,14 +74,21 @@ export function DatasetEditForm({ throw new Error(data.error || `Failed to save (${res.status})`); } - setSuccess(true); - // Redirect back to dataset page after short delay + toast({ + title: "Changes saved", + description: "Redirecting back to dataset...", + }); + setTimeout(() => { - router.push(`/datasets/${datasetId}`); + router.push(`/${locale}/datasets/${datasetId}`); router.refresh(); }, 1500); } catch (err) { - setError(err instanceof Error ? err.message : "Failed to save changes"); + toast({ + title: "Failed to save", + description: err instanceof Error ? err.message : "Failed to save changes", + variant: "destructive", + }); } finally { setSaving(false); } @@ -90,7 +99,7 @@ export function DatasetEditForm({ {/* Header */}
@@ -103,16 +112,18 @@ export function DatasetEditForm({

- {/* Status messages */} - {error && ( -
- - {error} -
- )} - {success && ( -
- Changes saved successfully! Redirecting... + {/* Warning: user has no valid OpenML API key (e.g. local dev account) */} + {(!hasApiKey || isLocalUser) && ( +
+ +
+

Saving is unavailable in this environment

+

+ {isLocalUser + ? "This account was created locally and does not have a valid OpenML API key. Dataset edits cannot be saved to the OpenML backend in a local development environment." + : "Your session does not include an OpenML API key. Saving changes requires signing in with a valid OpenML account."} +

+
)} @@ -284,12 +295,12 @@ export function DatasetEditForm({ {/* Actions */}
- + -