diff --git a/app/projects/[id]/editor/page.tsx b/app/projects/[id]/editor/page.tsx index 186e3773..d9973da9 100644 --- a/app/projects/[id]/editor/page.tsx +++ b/app/projects/[id]/editor/page.tsx @@ -4,7 +4,7 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { useSession, signIn } from "next-auth/react"; import { useParams, useSearchParams, useRouter, usePathname } from "next/navigation"; import Link from "next/link"; -import { ArrowLeft, Settings, FileCode, GitPullRequest, Activity, RefreshCw, Lightbulb, Eye, Keyboard, LogIn } from "lucide-react"; +import { ArrowLeft, Settings, FileCode, GitPullRequest, Activity, RefreshCw, Lightbulb, Eye, Keyboard, LogIn, Pencil } from "lucide-react"; import { Header } from "@/components/layout/header"; import { Button } from "@/components/ui/button"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; @@ -42,6 +42,8 @@ import { useSuggestionBeacon } from "@/lib/hooks/useSuggestionBeacon"; import { DeleteImpactAnalysis } from "@/components/editor/DeleteImpactAnalysis"; import { RemoteSyncIndicator } from "@/components/editor/RemoteSyncIndicator"; import { ShareButton } from "@/components/editor/ShareButton"; +import { useAnonymousSuggestion } from "@/lib/hooks/useAnonymousSuggestion"; +import { CreditModal } from "@/components/suggestions/CreditModal"; import type { OntologySourceEditorRef } from "@/components/editor/OntologySourceEditor"; @@ -80,6 +82,10 @@ export default function EditorPage() { setProjectViewMode("editor"); }, [setProjectViewMode]); + // Auth mode — set at build time by next.config.ts + const zitadelConfigured = process.env.NEXT_PUBLIC_ZITADEL_CONFIGURED === "true"; + const authMode = process.env.NEXT_PUBLIC_AUTH_MODE || "required"; + // Branch state const queryClient = useQueryClient(); const [activeBranch, setActiveBranch] = useState(undefined); @@ -192,6 +198,20 @@ export default function EditorPage() { // Suggestion session (only active for suggesters who can't directly edit) const [submitDialogOpen, setSubmitDialogOpen] = useState(false); + // Anonymous proposal mode: available when AUTH_MODE != required and user is NOT signed in as editor/suggester + const canPropose = authMode !== "required" && !canSuggest; + const [creditModalOpen, setCreditModalOpen] = useState(false); + + const anonymousSuggestion = useAnonymousSuggestion({ + projectId, + onSubmitted: (prNumber) => { + toast.success(`Proposal submitted as PR #${prNumber}`); + }, + onError: (msg) => toast.error("Proposal error", msg), + }); + + const isAnonymousProposalMode = canPropose && anonymousSuggestion.isActive; + const suggestionSession = useSuggestionSession({ projectId, accessToken: session?.accessToken, @@ -637,6 +657,59 @@ export default function EditorPage() { iriPatternDetectedRef.current = false; }, [session, projectId, activeBranch, project, sourceContent, toast, suggestionSession, setSourceContent, setSourceIriIndex]); + // Handle anonymous proposal mode class update + // Routes through anonymousSuggestion.saveToSession() instead of the normal commit path + const handleAnonymousClassUpdate = useCallback(async (classIri: string, data: ClassUpdatePayload) => { + if (!activeBranch && !anonymousSuggestion.branch) { + throw new Error("No branch selected"); + } + + // Ensure session exists before saving + if (!anonymousSuggestion.sessionId) { + await anonymousSuggestion.startSession(); + } + + // Load source from the anonymous suggestion branch (or current active branch as fallback) + const branchToLoad = anonymousSuggestion.branch || activeBranch; + let source = sourceContent; + if (!source) { + const response = await revisionsApi.getFileAtVersion( + projectId, + branchToLoad!, + undefined, // no Bearer token needed for anonymous + project?.git_ontology_path, + ); + source = response.content; + } + + const modifiedSource = updateClassInTurtle(source, classIri, data); + const label = data.labels[0]?.value || getLocalName(classIri); + + await anonymousSuggestion.saveToSession(modifiedSource, classIri, label); + + setSourceContent(modifiedSource); + toast.success(`Proposed update to "${label}"`); + updateNodeLabel(classIri, label); + setDetailRefreshKey((k) => k + 1); + setSourceIriIndex(new Map()); + iriPatternDetectedRef.current = false; + }, [activeBranch, anonymousSuggestion, projectId, project?.git_ontology_path, sourceContent, toast, updateNodeLabel, setSourceContent, setSourceIriIndex]); + + // Handle anonymous proposal "Propose Edit" button click + const handleProposeEdit = useCallback(async () => { + if (!anonymousSuggestion.isActive) { + await anonymousSuggestion.startSession(); + } + // After session starts, isAnonymousProposalMode becomes true (canPropose && isActive), + // which re-renders ClassDetailPanel with canEdit=true allowing form editing + }, [anonymousSuggestion]); + + // Handle "Submit Proposal" — called by CreditModal onSubmitCredit after user fills in name/email (or skips) + const handleAnonymousSubmit = useCallback(async (name: string | null, email: string | null) => { + setCreditModalOpen(false); + await anonymousSuggestion.submitSession(undefined, name ?? undefined, email ?? undefined); + }, [anonymousSuggestion]); + // Handle drag-and-drop reparent class // Fetches full class detail, modifies parent_iris, then routes through the appropriate save handler const handleReparentClass = useCallback(async ( @@ -690,9 +763,13 @@ export default function EditorPage() { }; // Route through the appropriate save handler - const saveHandler = isSuggestionMode ? handleSuggestClassUpdate : handleUpdateClass; + const saveHandler = isAnonymousProposalMode + ? handleAnonymousClassUpdate + : isSuggestionMode + ? handleSuggestClassUpdate + : handleUpdateClass; await saveHandler(classIri, payload); - }, [session, projectId, activeBranch, isSuggestionMode, handleUpdateClass, handleSuggestClassUpdate]); + }, [session, projectId, activeBranch, isAnonymousProposalMode, isSuggestionMode, handleUpdateClass, handleSuggestClassUpdate, handleAnonymousClassUpdate]); // Handle branch change const handleBranchChange = useCallback((branchName: string) => { @@ -796,8 +873,8 @@ export default function EditorPage() { {error || "Project not found"}
- {errorKind === "private-403" && ( - @@ -897,16 +974,24 @@ export default function EditorPage() { )} - {/* Sign-in CTA for unauthenticated users */} - {!hasValidAccess && ( + {/* Anonymous proposal mode indicator */} + {isAnonymousProposalMode && ( + + + Proposing + + )} + + {/* Sign-in CTA for unauthenticated users (only when Zitadel is configured) */} + {!hasValidAccess && zitadelConfigured && ( )}
@@ -927,6 +1012,34 @@ export default function EditorPage() { )} + {/* Submit Proposal button (anonymous mode) */} + {isAnonymousProposalMode && anonymousSuggestion.changesCount > 0 && ( + + )} + + {/* Discard Proposal button (anonymous mode) */} + {isAnonymousProposalMode && ( + + )} + {/* Suggestions link */} {isSuggestionMode && ( @@ -1063,11 +1176,11 @@ export default function EditorPage() { signIn("zitadel", { callbackUrl: window.location.href })} + canPropose={canPropose && !isAnonymousProposalMode} + onProposeEdit={handleProposeEdit} + isAnonymousProposalMode={isAnonymousProposalMode} /> ) : ( signIn("zitadel", { callbackUrl: window.location.href })} + canPropose={canPropose && !isAnonymousProposalMode} + onProposeEdit={handleProposeEdit} + isAnonymousProposalMode={isAnonymousProposalMode} /> )} @@ -1240,6 +1375,13 @@ export default function EditorPage() { onOpenChange={setShortcutDialogOpen} shortcuts={keyboardShortcuts} /> + + {/* Credit Modal for anonymous proposal submissions — opens before submit to collect optional credit info */} + handleAnonymousSubmit(null, null)} + onSubmitCredit={handleAnonymousSubmit} + /> ); } diff --git a/app/projects/[id]/suggestions/review/page.tsx b/app/projects/[id]/suggestions/review/page.tsx index ff37440f..dc8d575c 100644 --- a/app/projects/[id]/suggestions/review/page.tsx +++ b/app/projects/[id]/suggestions/review/page.tsx @@ -407,8 +407,13 @@ export default function SuggestionReviewPage() {
- {s.submitter?.name || s.submitter?.email || "Unknown user"} + {s.submitter?.name || s.submitter?.email || (s.is_anonymous ? "Anonymous" : "Unknown user")} + {s.is_anonymous && ( + + Anonymous + + )} {(s.revision ?? 1) > 1 && ( v{s.revision} @@ -510,9 +515,16 @@ export default function SuggestionReviewPage() {

Submitted by

-

- {s.submitter?.name || s.submitter?.email || "Unknown user"} -

+
+

+ {s.submitter?.name || s.submitter?.email || (s.is_anonymous ? "Anonymous" : "Unknown user")} +

+ {s.is_anonymous && ( + + Anonymous + + )} +

{lastActivity.toLocaleString()} diff --git a/auth.ts b/auth.ts index 6da8d61d..71d476a8 100644 --- a/auth.ts +++ b/auth.ts @@ -1,6 +1,12 @@ import NextAuth from "next-auth"; import type { NextAuthConfig, User } from "next-auth"; import "next-auth/jwt"; +import { getAuthMode, isZitadelConfigured } from "@/lib/auth-mode"; + +// When auth is not required, provide a default secret so NextAuth doesn't crash +if (getAuthMode() !== "required" && !process.env.NEXTAUTH_SECRET) { + process.env.NEXTAUTH_SECRET = "ontokit-optional-auth-secret"; +} // Zitadel provider configuration const zitadelProvider = { @@ -26,7 +32,9 @@ const zitadelProvider = { }; export const authConfig: NextAuthConfig = { - providers: [zitadelProvider], + providers: getAuthMode() === "disabled" || !isZitadelConfigured() + ? [] + : [zitadelProvider], events: { async signOut(_message) { // This event fires after local session is cleared @@ -46,6 +54,11 @@ export const authConfig: NextAuthConfig = { }; } + // Skip token refresh when Zitadel is not configured + if (!isZitadelConfigured()) { + return token; + } + // Return previous token if the access token has not expired yet if (Date.now() < ((token.expiresAt as number) ?? 0) * 1000) { return token; @@ -95,10 +108,9 @@ export const authConfig: NextAuthConfig = { return session; }, }, - pages: { - signIn: "/auth/signin", - error: "/auth/error", - }, + pages: getAuthMode() === "required" + ? { signIn: "/auth/signin", error: "/auth/error" } + : {}, debug: process.env.NODE_ENV === "development", }; diff --git a/components/auth/user-menu.tsx b/components/auth/user-menu.tsx index 53ab5aa8..fdf9735c 100644 --- a/components/auth/user-menu.tsx +++ b/components/auth/user-menu.tsx @@ -9,6 +9,11 @@ import { useState, useRef, useEffect } from "react"; const ZITADEL_ISSUER = process.env.NEXT_PUBLIC_ZITADEL_ISSUER || "http://localhost:8080"; const ZITADEL_CLIENT_ID = process.env.NEXT_PUBLIC_ZITADEL_CLIENT_ID || ""; +// Auth mode flags — set at build time by next.config.ts +const authMode = process.env.NEXT_PUBLIC_AUTH_MODE || "required"; +const zitadelConfigured = process.env.NEXT_PUBLIC_ZITADEL_CONFIGURED === "true"; +const showAuthUI = authMode === "required" || (authMode === "optional" && zitadelConfigured); + export function UserMenu() { const { data: session, status } = useSession(); const [isOpen, setIsOpen] = useState(false); @@ -42,6 +47,7 @@ export function UserMenu() { } if (!session) { + if (!showAuthUI) return null; return ( + {showSignInToEdit && onSignInToEdit && ( + + )} + + )} + {!canEdit && !canPropose && !isEditing && showSignInToEdit && onSignInToEdit && ( + + )}

diff --git a/components/editor/developer/DeveloperEditorLayout.tsx b/components/editor/developer/DeveloperEditorLayout.tsx index d6638d7b..fc6f6d1d 100644 --- a/components/editor/developer/DeveloperEditorLayout.tsx +++ b/components/editor/developer/DeveloperEditorLayout.tsx @@ -117,6 +117,15 @@ export interface DeveloperEditorLayoutProps { /** Ref populated with a function to navigate to any entity type */ entityNavigationRef?: React.RefObject<((iri: string, type?: string) => void) | null>; + + // Sign-in-to-edit affordance for anonymous users + showSignInToEdit?: boolean; + onSignInToEdit?: () => void; + + // Anonymous proposal mode + canPropose?: boolean; + onProposeEdit?: () => void; + isAnonymousProposalMode?: boolean; } export function DeveloperEditorLayout(props: DeveloperEditorLayoutProps) { @@ -165,6 +174,11 @@ export function DeveloperEditorLayout(props: DeveloperEditorLayoutProps) { onReparentClass, reparentOptimistic, rollbackReparent, + showSignInToEdit, + onSignInToEdit, + canPropose, + onProposeEdit, + isAnonymousProposalMode, } = props; const toast = useToast(); @@ -563,6 +577,11 @@ export function DeveloperEditorLayout(props: DeveloperEditorLayoutProps) { canEdit={canEdit || isSuggestionMode} onUpdateClass={onUpdateClass} refreshKey={detailRefreshKey} + showSignInToEdit={showSignInToEdit} + onSignInToEdit={onSignInToEdit} + canPropose={canPropose} + onProposeEdit={onProposeEdit} + isAnonymousProposalMode={isAnonymousProposalMode} /> ) : activeTab === "properties" ? ( void) | null>; + + // Sign-in-to-edit affordance for anonymous users + showSignInToEdit?: boolean; + onSignInToEdit?: () => void; + + // Anonymous proposal mode + canPropose?: boolean; + onProposeEdit?: () => void; + isAnonymousProposalMode?: boolean; } export function StandardEditorLayout(props: StandardEditorLayoutProps) { @@ -126,6 +135,11 @@ export function StandardEditorLayout(props: StandardEditorLayoutProps) { onReparentClass, reparentOptimistic, rollbackReparent, + showSignInToEdit, + onSignInToEdit, + canPropose, + onProposeEdit, + isAnonymousProposalMode, } = props; const toast = useToast(); @@ -459,6 +473,11 @@ export function StandardEditorLayout(props: StandardEditorLayoutProps) { canEdit={canEdit || isSuggestionMode} onUpdateClass={onUpdateClass} refreshKey={detailRefreshKey} + showSignInToEdit={showSignInToEdit} + onSignInToEdit={onSignInToEdit} + canPropose={canPropose} + onProposeEdit={onProposeEdit} + isAnonymousProposalMode={isAnonymousProposalMode} headerActions={selectedIri ? (
diff --git a/components/suggestions/CreditModal.tsx b/components/suggestions/CreditModal.tsx new file mode 100644 index 00000000..cf792af7 --- /dev/null +++ b/components/suggestions/CreditModal.tsx @@ -0,0 +1,166 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { UserCheck } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { useAnonymousCreditStore } from "@/lib/stores/anonymousCreditStore"; + +interface CreditModalProps { + open: boolean; + onClose: () => void; + onSubmitCredit: (name: string | null, email: string | null) => void; +} + +/** + * Post-submission modal that lets anonymous contributors optionally + * provide their name and email for credit attribution. + * + * Appears AFTER a successful suggestion submit — it does not block the + * submission itself. Credit info is remembered in localStorage via + * useAnonymousCreditStore for future proposals. + * + * Includes a hidden honeypot field (name="website") that bots will fill + * but human users won't see. The parent should pass this value through + * to the submit payload as website="" (already handled by useAnonymousSuggestion). + */ +export function CreditModal({ open, onClose, onSubmitCredit }: CreditModalProps) { + const creditStore = useAnonymousCreditStore(); + + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [honeypot, setHoneypot] = useState(""); + + // Pre-fill from cached credit on open + useEffect(() => { + if (open) { + setName(creditStore.name ?? ""); + setEmail(creditStore.email ?? ""); + setHoneypot(""); // Always reset honeypot + } + }, [open, creditStore.name, creditStore.email]); + + const handleSave = () => { + // If the honeypot field was filled (bot behavior), silently treat as skip + if (honeypot) { + onClose(); + return; + } + + const trimmedName = name.trim() || null; + const trimmedEmail = email.trim() || null; + + // Cache credit info for future proposals (even if both are null = skip) + if (trimmedName || trimmedEmail) { + creditStore.setCredit(trimmedName, trimmedEmail); + } + + onSubmitCredit(trimmedName, trimmedEmail); + onClose(); + }; + + const handleSkip = () => { + onClose(); + }; + + return ( + { if (!nextOpen) onClose(); }}> + + + + + Want credit for your suggestions? + + + Your changes have been submitted for review. Optionally share your name and email so the project maintainers can follow up. + + + +
+ {/* Name field */} +
+ + setName(e.target.value)} + placeholder="Your name" + autoComplete="name" + className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm placeholder:text-slate-400 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100 dark:placeholder:text-slate-500" + /> +
+ + {/* Email field */} +
+ + setEmail(e.target.value)} + placeholder="your@email.com" + autoComplete="email" + className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm placeholder:text-slate-400 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100 dark:placeholder:text-slate-500" + /> +
+ + {/* Honeypot field — invisible to humans, filled by bots */} + setHoneypot(e.target.value)} + tabIndex={-1} + autoComplete="off" + aria-hidden="true" + style={{ + position: "absolute", + left: "-9999px", + opacity: 0, + pointerEvents: "none", + }} + /> + +

+ Your info will only be used to attribute your contribution. We won't add you to any mailing list. +

+
+ + + + + +
+
+ ); +} diff --git a/lib/api/suggestions.ts b/lib/api/suggestions.ts index 17cbf5c8..2352a472 100644 --- a/lib/api/suggestions.ts +++ b/lib/api/suggestions.ts @@ -4,6 +4,9 @@ * Manages the suggestion workflow for non-technical users (suggesters). * Each session maps to a suggestion branch; edits auto-save as commits, * and explicit submission creates a PR for review. + * + * Also provides anonymousSuggestionsApi for unauthenticated users — + * uses X-Anonymous-Token header instead of Authorization: Bearer. */ import { api } from "./client"; @@ -61,6 +64,7 @@ export interface SuggestionSessionSummary { reviewed_at?: string; revision?: number; summary?: string; + is_anonymous?: boolean; } export interface SuggestionSessionListResponse { @@ -94,6 +98,32 @@ export interface SuggestionBeaconPayload { content: string; } +// --- Anonymous suggestion types --- + +/** + * Response from creating an anonymous suggestion session. + * The anonymous_token must be stored client-side and passed as + * X-Anonymous-Token on all subsequent calls for this session. + */ +export interface AnonymousSessionCreateResponse { + session_id: string; + branch: string; + created_at: string; + anonymous_token: string; +} + +/** + * Payload for submitting an anonymous suggestion session. + * submitter_name and submitter_email are optional credit fields. + * website is a honeypot — always send as empty string; bots fill it. + */ +export interface AnonymousSubmitPayload { + summary?: string; + submitter_name?: string; + submitter_email?: string; + website?: string; // honeypot field — always send empty string +} + // --- API --- export const suggestionsApi = { @@ -241,3 +271,84 @@ export const suggestionsApi = { { headers: { Authorization: `Bearer ${token}` } }, ), }; + +// --- Anonymous suggestion API --- + +/** + * API client for anonymous (unauthenticated) suggestion sessions. + * + * All methods use X-Anonymous-Token header rather than Authorization: Bearer. + * The anonymous token is returned on session creation and must be stored + * in localStorage and passed to all subsequent calls. + * + * Only available when AUTH_MODE is "optional" or "disabled" on the server. + */ +export const anonymousSuggestionsApi = { + /** + * Create a new anonymous suggestion session. + * No token required — returns an anonymous_token to use for subsequent calls. + */ + createSession: (projectId: string) => + api.post( + `/api/v1/projects/${projectId}/suggestions/anonymous/sessions`, + ), + + /** + * Save content to the anonymous suggestion branch. + */ + save: ( + projectId: string, + sessionId: string, + data: SuggestionSavePayload, + anonymousToken: string, + ) => + api.put( + `/api/v1/projects/${projectId}/suggestions/anonymous/sessions/${sessionId}/save`, + data, + { headers: { "X-Anonymous-Token": anonymousToken } }, + ), + + /** + * Submit the anonymous suggestion session — creates a PR for review. + * Optionally includes submitter_name/email for credit attribution. + * Always send website as empty string (honeypot — bots fill this, humans don't). + */ + submit: ( + projectId: string, + sessionId: string, + data: AnonymousSubmitPayload, + anonymousToken: string, + ) => + api.post( + `/api/v1/projects/${projectId}/suggestions/anonymous/sessions/${sessionId}/submit`, + data, + { headers: { "X-Anonymous-Token": anonymousToken } }, + ), + + /** + * Discard an anonymous suggestion session (deletes the suggestion branch). + */ + discard: (projectId: string, sessionId: string, anonymousToken: string) => + api.post( + `/api/v1/projects/${projectId}/suggestions/anonymous/sessions/${sessionId}/discard`, + undefined, + { headers: { "X-Anonymous-Token": anonymousToken } }, + ), + + /** + * Send a beacon payload to flush the last draft on browser close. + * Uses navigator.sendBeacon — cannot set X-Anonymous-Token header, + * so the token is passed as a query param. + */ + beacon: ( + projectId: string, + sessionId: string, + content: string, + anonymousToken: string, + ) => { + const url = `${API_BASE}/api/v1/projects/${projectId}/suggestions/anonymous/beacon?token=${encodeURIComponent(anonymousToken)}`; + const payload = JSON.stringify({ session_id: sessionId, content }); + const blob = new Blob([payload], { type: "application/json" }); + return navigator.sendBeacon(url, blob); + }, +}; diff --git a/lib/auth-mode.ts b/lib/auth-mode.ts new file mode 100644 index 00000000..73cac0b9 --- /dev/null +++ b/lib/auth-mode.ts @@ -0,0 +1,27 @@ +/** + * Centralized auth mode configuration. + * + * AUTH_MODE controls authentication behavior: + * - "required" (default): Zitadel OIDC, must sign in to use app + * - "optional": Browse anonymously, sign in available if Zitadel configured + * - "disabled": No auth at all, anonymous browsing only + */ + +export type AuthMode = "required" | "optional" | "disabled"; + +/** Server-side auth mode (reads process.env directly) */ +export function getAuthMode(): AuthMode { + const mode = process.env.AUTH_MODE?.toLowerCase(); + if (mode === "optional" || mode === "disabled") return mode; + return "required"; +} + +/** Whether Zitadel OIDC is configured (server-side check) */ +export function isZitadelConfigured(): boolean { + return !!(process.env.ZITADEL_ISSUER && process.env.ZITADEL_CLIENT_ID); +} + +/** Whether auth is required (must sign in to use the app) */ +export function isAuthRequired(): boolean { + return getAuthMode() === "required"; +} diff --git a/lib/env.ts b/lib/env.ts index b229ff4b..b8495d68 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { isAuthRequired } from "./auth-mode"; const serverSchema = z.object({ ZITADEL_ISSUER: z.url("ZITADEL_ISSUER must be a valid URL"), @@ -19,7 +20,21 @@ export type ServerEnv = z.infer; export type ClientEnv = z.infer; function validateServerEnv(): ServerEnv { - const result = serverSchema.safeParse(process.env); + // When auth is not required, Zitadel vars are optional + const schema = isAuthRequired() + ? serverSchema + : z.object({ + ZITADEL_ISSUER: z.url().optional(), + ZITADEL_CLIENT_ID: z.string().optional(), + ZITADEL_CLIENT_SECRET: z.string().optional(), + NEXTAUTH_URL: z.url().optional(), + NEXTAUTH_SECRET: z + .string() + .min(1, "NEXTAUTH_SECRET is required") + .default("ontokit-dev-secret"), + }); + + const result = schema.safeParse(process.env); if (!result.success) { const tree = z.treeifyError(result.error); const messages = Object.entries(tree.properties ?? {}) @@ -33,7 +48,7 @@ function validateServerEnv(): ServerEnv { `Set the required variables before starting the server.` ); } - return result.data; + return result.data as ServerEnv; } function validateClientEnv(): ClientEnv { diff --git a/lib/hooks/useAnonymousSuggestion.ts b/lib/hooks/useAnonymousSuggestion.ts new file mode 100644 index 00000000..14313083 --- /dev/null +++ b/lib/hooks/useAnonymousSuggestion.ts @@ -0,0 +1,220 @@ +"use client"; + +import { useState, useCallback, useRef, useEffect } from "react"; +import { + anonymousSuggestionsApi, + type SuggestionSavePayload, +} from "@/lib/api/suggestions"; +import { + useAnonymousTokenStore, +} from "@/lib/stores/anonymousCreditStore"; + +import type { SuggestionStatus } from "@/lib/hooks/useSuggestionSession"; + +// Re-export SuggestionStatus so callers can use it from this module too +export type { SuggestionStatus }; + +export interface UseAnonymousSuggestionReturn { + sessionId: string | null; + branch: string | null; + anonymousToken: string | null; + changesCount: number; + status: SuggestionStatus; + error: string | null; + entitiesModified: string[]; + isActive: boolean; + startSession: () => Promise; + saveToSession: (content: string, entityIri: string, entityLabel: string) => Promise; + submitSession: (summary?: string, submitterName?: string, submitterEmail?: string) => Promise; + discardSession: () => Promise; +} + +interface UseAnonymousSuggestionOptions { + projectId: string; + onSubmitted?: (prNumber: number, prUrl: string | null) => void; + onError?: (msg: string) => void; +} + +/** + * Manages the full anonymous suggestion session lifecycle. + * + * Mirrors useSuggestionSession but uses anonymous tokens + * (X-Anonymous-Token header) instead of Bearer tokens. + * The anonymous token is persisted in localStorage via useAnonymousTokenStore + * and restored on mount so sessions survive page navigations. + * + * Only usable when AUTH_MODE is "optional" or "disabled" on the server. + */ +export function useAnonymousSuggestion({ + projectId, + onSubmitted, + onError, +}: UseAnonymousSuggestionOptions): UseAnonymousSuggestionReturn { + const tokenStore = useAnonymousTokenStore(); + + const [sessionId, setSessionId] = useState(null); + const [branch, setBranch] = useState(null); + const [anonymousToken, setAnonymousToken] = useState(null); + const [changesCount, setChangesCount] = useState(0); + const [status, setStatus] = useState("idle"); + const [error, setError] = useState(null); + const [entitiesModified, setEntitiesModified] = useState([]); + + const savingRef = useRef(false); + const restoredRef = useRef(false); + + // Restore any active session from localStorage on mount + useEffect(() => { + if (restoredRef.current) return; + restoredRef.current = true; + + const entry = tokenStore.getToken(projectId); + if (entry) { + setSessionId(entry.sessionId); + setBranch(entry.branch); + setAnonymousToken(entry.token); + setStatus("active"); + } + // Only run once on mount — tokenStore.getToken is stable + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [projectId]); + + const startSession = useCallback(async () => { + // Don't create a duplicate session if one already exists + if (sessionId) return; + + try { + const session = await anonymousSuggestionsApi.createSession(projectId); + + // Persist token to localStorage before updating state + tokenStore.setToken(projectId, session.anonymous_token, session.session_id, session.branch); + + setSessionId(session.session_id); + setBranch(session.branch); + setAnonymousToken(session.anonymous_token); + setStatus("active"); + setError(null); + } catch (err) { + const msg = err instanceof Error ? err.message : "Failed to start anonymous suggestion session"; + setStatus("error"); + setError(msg); + onError?.(msg); + } + }, [sessionId, projectId, tokenStore, onError]); + + const saveToSession = useCallback(async ( + content: string, + entityIri: string, + entityLabel: string, + ) => { + if (!sessionId || !anonymousToken || savingRef.current) return; + + savingRef.current = true; + setStatus("saving"); + setError(null); + + try { + const payload: SuggestionSavePayload = { + content, + entity_iri: entityIri, + entity_label: entityLabel, + }; + const result = await anonymousSuggestionsApi.save(projectId, sessionId, payload, anonymousToken); + setChangesCount(result.changes_count); + + // Track modified entities (deduplicated by label) + setEntitiesModified((prev) => { + if (prev.includes(entityLabel)) return prev; + return [...prev, entityLabel]; + }); + + setStatus("active"); + } catch (err) { + const msg = err instanceof Error ? err.message : "Failed to save anonymous suggestion"; + setStatus("error"); + setError(msg); + onError?.(msg); + } finally { + savingRef.current = false; + } + }, [sessionId, anonymousToken, projectId, onError]); + + const submitSession = useCallback(async ( + summary?: string, + submitterName?: string, + submitterEmail?: string, + ) => { + if (!sessionId || !anonymousToken) return; + + setStatus("submitting"); + setError(null); + + try { + const result = await anonymousSuggestionsApi.submit( + projectId, + sessionId, + { + summary, + submitter_name: submitterName, + submitter_email: submitterEmail, + website: "", // honeypot — always empty for legitimate users + }, + anonymousToken, + ); + + // Clear persisted token on successful submit + tokenStore.clearToken(projectId); + + setStatus("submitted"); + onSubmitted?.(result.pr_number, result.pr_url); + + // Reset session state so a new session can start + setSessionId(null); + setBranch(null); + setAnonymousToken(null); + setChangesCount(0); + setEntitiesModified([]); + } catch (err) { + const msg = err instanceof Error ? err.message : "Failed to submit anonymous suggestions"; + setStatus("error"); + setError(msg); + onError?.(msg); + } + }, [sessionId, anonymousToken, projectId, tokenStore, onSubmitted, onError]); + + const discardSession = useCallback(async () => { + if (!sessionId || !anonymousToken) return; + + try { + await anonymousSuggestionsApi.discard(projectId, sessionId, anonymousToken); + } catch { + // Best-effort discard — don't block UX on network failure + } + + // Clear persisted token regardless of API success + tokenStore.clearToken(projectId); + + setSessionId(null); + setBranch(null); + setAnonymousToken(null); + setChangesCount(0); + setEntitiesModified([]); + setStatus("idle"); + setError(null); + }, [sessionId, anonymousToken, projectId, tokenStore]); + + return { + sessionId, + branch, + anonymousToken, + changesCount, + status, + error, + entitiesModified, + isActive: status === "active" || status === "saving", + startSession, + saveToSession, + submitSession, + discardSession, + }; +} diff --git a/lib/stores/anonymousCreditStore.ts b/lib/stores/anonymousCreditStore.ts new file mode 100644 index 00000000..e22e4d80 --- /dev/null +++ b/lib/stores/anonymousCreditStore.ts @@ -0,0 +1,104 @@ +/** + * Anonymous suggestion stores + * + * Two persisted Zustand stores for anonymous suggestion sessions: + * + * 1. useAnonymousCreditStore — remembers submitter name/email in localStorage + * so repeat anonymous contributors don't have to retype their info. + * + * 2. useAnonymousTokenStore — persists the anonymous session token, sessionId, + * and branch per projectId so the session survives page navigations. + */ + +import { create } from "zustand"; +import { persist, createJSONStorage } from "zustand/middleware"; + +// --- Credit store --- + +interface AnonymousCreditState { + name: string | null; + email: string | null; + setCredit: (name: string | null, email: string | null) => void; + clearCredit: () => void; + hasCredit: () => boolean; +} + +/** + * Persisted store for optional submitter credit info (name + email). + * Pre-fills the CreditModal on subsequent submissions. + * Stored under localStorage key "ontokit-anonymous-credit". + */ +export const useAnonymousCreditStore = create()( + persist( + (set, get) => ({ + name: null, + email: null, + + setCredit: (name, email) => set({ name, email }), + + clearCredit: () => set({ name: null, email: null }), + + hasCredit: () => { + const { name, email } = get(); + return !!(name || email); + }, + }), + { + name: "ontokit-anonymous-credit", + storage: createJSONStorage(() => localStorage), + }, + ), +); + +// --- Anonymous token store --- + +interface AnonymousTokenEntry { + token: string; + sessionId: string; + branch: string; +} + +interface AnonymousTokenState { + tokens: Record; + setToken: ( + projectId: string, + token: string, + sessionId: string, + branch: string, + ) => void; + getToken: (projectId: string) => AnonymousTokenEntry | null; + clearToken: (projectId: string) => void; +} + +/** + * Persisted store for anonymous session tokens, keyed by projectId. + * Allows resuming an in-progress anonymous session after page reload/navigation. + * Stored under localStorage key "ontokit-anonymous-token". + */ +export const useAnonymousTokenStore = create()( + persist( + (set, get) => ({ + tokens: {}, + + setToken: (projectId, token, sessionId, branch) => + set((state) => ({ + tokens: { + ...state.tokens, + [projectId]: { token, sessionId, branch }, + }, + })), + + getToken: (projectId) => get().tokens[projectId] ?? null, + + clearToken: (projectId) => + set((state) => { + const { [projectId]: _, ...rest } = state.tokens; + return { tokens: rest }; + }), + }), + { + name: "ontokit-anonymous-token", + storage: createJSONStorage(() => localStorage), + }, + ), +); diff --git a/next.config.ts b/next.config.ts index a27457fa..8a540b80 100644 --- a/next.config.ts +++ b/next.config.ts @@ -25,6 +25,8 @@ const nextConfig: NextConfig = { env: { NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL, NEXT_PUBLIC_WS_URL: process.env.NEXT_PUBLIC_WS_URL, + NEXT_PUBLIC_AUTH_MODE: process.env.AUTH_MODE || "required", + NEXT_PUBLIC_ZITADEL_CONFIGURED: process.env.ZITADEL_ISSUER ? "true" : "false", }, // WSL2: use polling for file watching since inotify doesn't work across the VM boundary