From 07cbcd858c475caa3636246992f6ec67c524cdfe Mon Sep 17 00:00:00 2001 From: damienriehl Date: Fri, 3 Apr 2026 11:17:42 -0500 Subject: [PATCH 1/9] feat(08-02): create auth-mode helper and update env validation - Add lib/auth-mode.ts with getAuthMode(), isZitadelConfigured(), isAuthRequired() - Conditionally skip Zitadel env validation when auth mode is not required - AUTH_MODE=optional|disabled makes all Zitadel vars optional with sensible defaults --- lib/auth-mode.ts | 27 +++++++++++++++++++++++++++ lib/env.ts | 19 +++++++++++++++++-- 2 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 lib/auth-mode.ts 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 { From 82c2c3ad21de5999e584edef7a10511dbfd7b1d6 Mon Sep 17 00:00:00 2001 From: damienriehl Date: Fri, 3 Apr 2026 11:18:12 -0500 Subject: [PATCH 2/9] feat(08-02): configure auth.ts for conditional Zitadel provider - Import getAuthMode and isZitadelConfigured from auth-mode helper - Default NEXTAUTH_SECRET when auth mode is not required - Conditionally include Zitadel provider (empty array when disabled or unconfigured) - Skip token refresh when Zitadel is not configured - Conditional custom sign-in pages only in required mode --- auth.ts | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) 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", }; From e5d5b9a8acd0d99ffb02176d7ff4ebe45b5c75e8 Mon Sep 17 00:00:00 2001 From: damienriehl Date: Fri, 3 Apr 2026 11:19:13 -0500 Subject: [PATCH 3/9] feat(08-02): conditionally show Sign In button based on Zitadel availability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Expose NEXT_PUBLIC_AUTH_MODE and NEXT_PUBLIC_ZITADEL_CONFIGURED via next.config.ts - Header: compute showAuthUI from auth mode + Zitadel config; hide NotificationBell and UserMenu when no auth - UserMenu: return null when showAuthUI=false and no active session (graceful degradation) - AUTH_MODE=disabled or optional without Zitadel → no sign-in UI shown --- components/auth/user-menu.tsx | 6 ++++++ components/layout/header.tsx | 9 +++++++-- next.config.ts | 2 ++ 3 files changed, 15 insertions(+), 2 deletions(-) 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 ( @@ -897,16 +900,16 @@ export default function EditorPage() { )} - {/* Sign-in CTA for unauthenticated users */} - {!hasValidAccess && ( + {/* Sign-in CTA for unauthenticated users (only when Zitadel is configured) */} + {!hasValidAccess && zitadelConfigured && ( )} @@ -1105,6 +1108,8 @@ export default function EditorPage() { onReparentClass={handleReparentClass} reparentOptimistic={reparentOptimistic} rollbackReparent={rollbackReparent} + showSignInToEdit={!hasValidAccess && zitadelConfigured} + onSignInToEdit={() => signIn("zitadel", { callbackUrl: window.location.href })} /> ) : ( @@ -1143,6 +1148,8 @@ export default function EditorPage() { onReparentClass={handleReparentClass} reparentOptimistic={reparentOptimistic} rollbackReparent={rollbackReparent} + showSignInToEdit={!hasValidAccess && zitadelConfigured} + onSignInToEdit={() => signIn("zitadel", { callbackUrl: window.location.href })} /> )} diff --git a/components/editor/ClassDetailPanel.tsx b/components/editor/ClassDetailPanel.tsx index 9119b552..db6fd573 100644 --- a/components/editor/ClassDetailPanel.tsx +++ b/components/editor/ClassDetailPanel.tsx @@ -26,6 +26,7 @@ import { StickyNote, Hash, Link2, + LogIn, } from "lucide-react"; import { projectOntologyApi, type OWLClassDetail, type ClassUpdatePayload, type AnnotationUpdate } from "@/lib/api/client"; import type { LocalizedString } from "@/lib/api/client"; @@ -75,6 +76,10 @@ interface ClassDetailPanelProps { refreshKey?: number; /** Extra actions rendered in the header row (e.g. Graph button) */ headerActions?: ReactNode; + /** Show "Sign in to edit" button for anonymous users when Zitadel is configured */ + showSignInToEdit?: boolean; + /** Called when anonymous user clicks "Sign in to edit" */ + onSignInToEdit?: () => void; } export function ClassDetailPanel({ @@ -90,6 +95,8 @@ export function ClassDetailPanel({ onUpdateClass, refreshKey, headerActions, + showSignInToEdit, + onSignInToEdit, }: ClassDetailPanelProps) { const [classDetail, setClassDetail] = useState(null); const [classIssues, setClassIssues] = useState([]); @@ -590,6 +597,16 @@ export function ClassDetailPanel({
{headerActions} + {!canEdit && !isEditing && showSignInToEdit && onSignInToEdit && ( + + )}
diff --git a/components/editor/developer/DeveloperEditorLayout.tsx b/components/editor/developer/DeveloperEditorLayout.tsx index d6638d7b..501c9009 100644 --- a/components/editor/developer/DeveloperEditorLayout.tsx +++ b/components/editor/developer/DeveloperEditorLayout.tsx @@ -117,6 +117,10 @@ 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; } export function DeveloperEditorLayout(props: DeveloperEditorLayoutProps) { @@ -165,6 +169,8 @@ export function DeveloperEditorLayout(props: DeveloperEditorLayoutProps) { onReparentClass, reparentOptimistic, rollbackReparent, + showSignInToEdit, + onSignInToEdit, } = props; const toast = useToast(); @@ -563,6 +569,8 @@ export function DeveloperEditorLayout(props: DeveloperEditorLayoutProps) { canEdit={canEdit || isSuggestionMode} onUpdateClass={onUpdateClass} refreshKey={detailRefreshKey} + showSignInToEdit={showSignInToEdit} + onSignInToEdit={onSignInToEdit} /> ) : activeTab === "properties" ? ( void) | null>; + + // Sign-in-to-edit affordance for anonymous users + showSignInToEdit?: boolean; + onSignInToEdit?: () => void; } export function StandardEditorLayout(props: StandardEditorLayoutProps) { @@ -126,6 +130,8 @@ export function StandardEditorLayout(props: StandardEditorLayoutProps) { onReparentClass, reparentOptimistic, rollbackReparent, + showSignInToEdit, + onSignInToEdit, } = props; const toast = useToast(); @@ -459,6 +465,8 @@ export function StandardEditorLayout(props: StandardEditorLayoutProps) { canEdit={canEdit || isSuggestionMode} onUpdateClass={onUpdateClass} refreshKey={detailRefreshKey} + showSignInToEdit={showSignInToEdit} + onSignInToEdit={onSignInToEdit} headerActions={selectedIri ? ( + + + + + ); +} 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, + }; +} From 65567bb95230cd1c3cadf6f7ecce24628ccb66fb Mon Sep 17 00:00:00 2001 From: damienriehl Date: Fri, 3 Apr 2026 17:57:19 -0500 Subject: [PATCH 8/9] feat(10-03): add Propose Edit button and anonymous proposal mode props - Add canPropose, onProposeEdit, isAnonymousProposalMode props to ClassDetailPanelProps - Render emerald-colored "Propose Edit" button when canPropose && !canEnterEdit - Show "Sign in for full editing" alongside Propose Edit when Zitadel configured - Thread new props through DeveloperEditorLayout and StandardEditorLayout - Existing "Edit Item" and "Suggest Changes" buttons unchanged --- components/editor/ClassDetailPanel.tsx | 34 ++++++++++++++++++- .../developer/DeveloperEditorLayout.tsx | 11 ++++++ .../editor/standard/StandardEditorLayout.tsx | 11 ++++++ 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/components/editor/ClassDetailPanel.tsx b/components/editor/ClassDetailPanel.tsx index db6fd573..6124b474 100644 --- a/components/editor/ClassDetailPanel.tsx +++ b/components/editor/ClassDetailPanel.tsx @@ -27,6 +27,7 @@ import { Hash, Link2, LogIn, + Pencil, } from "lucide-react"; import { projectOntologyApi, type OWLClassDetail, type ClassUpdatePayload, type AnnotationUpdate } from "@/lib/api/client"; import type { LocalizedString } from "@/lib/api/client"; @@ -80,6 +81,12 @@ interface ClassDetailPanelProps { showSignInToEdit?: boolean; /** Called when anonymous user clicks "Sign in to edit" */ onSignInToEdit?: () => void; + /** Show "Propose Edit" button for anonymous users (AUTH_MODE != required) */ + canPropose?: boolean; + /** Called when anonymous user clicks "Propose Edit" */ + onProposeEdit?: () => void; + /** True when anonymous proposal mode is active (editing is allowed) */ + isAnonymousProposalMode?: boolean; } export function ClassDetailPanel({ @@ -97,6 +104,9 @@ export function ClassDetailPanel({ headerActions, showSignInToEdit, onSignInToEdit, + canPropose, + onProposeEdit, + isAnonymousProposalMode: _isAnonymousProposalMode, }: ClassDetailPanelProps) { const [classDetail, setClassDetail] = useState(null); const [classIssues, setClassIssues] = useState([]); @@ -597,7 +607,29 @@ export function ClassDetailPanel({
{headerActions} - {!canEdit && !isEditing && showSignInToEdit && onSignInToEdit && ( + {!canEdit && canPropose && !isEditing && onProposeEdit && ( + <> + + {showSignInToEdit && onSignInToEdit && ( + + )} + + )} + {!canEdit && !canPropose && !isEditing && showSignInToEdit && onSignInToEdit && ( + )} + + {/* Discard Proposal button (anonymous mode) */} + {isAnonymousProposalMode && ( + + )} + {/* Suggestions link */} {isSuggestionMode && ( @@ -1066,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} /> )}
@@ -1247,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/lib/api/suggestions.ts b/lib/api/suggestions.ts index e6a768b7..2352a472 100644 --- a/lib/api/suggestions.ts +++ b/lib/api/suggestions.ts @@ -64,6 +64,7 @@ export interface SuggestionSessionSummary { reviewed_at?: string; revision?: number; summary?: string; + is_anonymous?: boolean; } export interface SuggestionSessionListResponse {