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
176 changes: 159 additions & 17 deletions app/projects/[id]/editor/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";

Expand Down Expand Up @@ -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";
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const authMode = process.env.NEXT_PUBLIC_AUTH_MODE || "required";

// Branch state
const queryClient = useQueryClient();
const [activeBranch, setActiveBranch] = useState<string | undefined>(undefined);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -796,8 +873,8 @@ export default function EditorPage() {
{error || "Project not found"}
</h2>
<div className="mt-4 flex items-center justify-center gap-3">
{errorKind === "private-403" && (
<Button onClick={() => signIn("zitadel")} className="gap-2">
{errorKind === "private-403" && zitadelConfigured && (
<Button onClick={() => signIn("zitadel", { callbackUrl: window.location.href })} className="gap-2">
<LogIn className="h-4 w-4" />
Sign In
</Button>
Expand Down Expand Up @@ -897,16 +974,24 @@ export default function EditorPage() {
</span>
)}

{/* Sign-in CTA for unauthenticated users */}
{!hasValidAccess && (
{/* Anonymous proposal mode indicator */}
{isAnonymousProposalMode && (
<span className="flex items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400">
<Pencil className="h-3 w-3" />
Proposing
</span>
)}

{/* Sign-in CTA for unauthenticated users (only when Zitadel is configured) */}
{!hasValidAccess && zitadelConfigured && (
<Button
variant="ghost"
size="sm"
onClick={() => signIn("zitadel")}
onClick={() => signIn("zitadel", { callbackUrl: window.location.href })}
className="gap-1 rounded-full bg-primary-50 px-3 py-1 text-xs font-medium text-primary-700 hover:bg-primary-100 dark:bg-primary-900/20 dark:text-primary-400 dark:hover:bg-primary-900/30"
>
<LogIn className="h-3 w-3" />
Sign in to suggest edits
Sign in to edit
</Button>
)}
</div>
Expand All @@ -927,6 +1012,34 @@ export default function EditorPage() {
</Button>
)}

{/* Submit Proposal button (anonymous mode) */}
{isAnonymousProposalMode && anonymousSuggestion.changesCount > 0 && (
<Button
variant="primary"
size="sm"
className="gap-2 bg-emerald-600 hover:bg-emerald-700"
onClick={() => setCreditModalOpen(true)}
>
<Pencil className="h-4 w-4" />
Submit Proposal
<span className="rounded-full bg-emerald-500/30 px-1.5 py-0.5 text-xs">
{anonymousSuggestion.changesCount}
</span>
</Button>
)}

{/* Discard Proposal button (anonymous mode) */}
{isAnonymousProposalMode && (
<Button
variant="ghost"
size="sm"
className="gap-2 text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
onClick={() => anonymousSuggestion.discardSession()}
>
Discard
</Button>
)}

{/* Suggestions link */}
{isSuggestionMode && (
<Link href={`/projects/${projectId}/suggestions`}>
Expand Down Expand Up @@ -1063,11 +1176,11 @@ export default function EditorPage() {
<DeveloperEditorLayout
projectId={projectId}
accessToken={session?.accessToken}
activeBranch={activeBranch}
canEdit={!!canEdit}
activeBranch={isAnonymousProposalMode && anonymousSuggestion.branch ? anonymousSuggestion.branch : activeBranch}
canEdit={isAnonymousProposalMode ? true : !!canEdit}
entityNavigationRef={entityNavigationRef}
canSuggest={!!canSuggest}
isSuggestionMode={isSuggestionMode}
isSuggestionMode={isAnonymousProposalMode ? true : isSuggestionMode}
nodes={nodes}
isTreeLoading={isTreeLoading}
treeError={treeError}
Expand Down Expand Up @@ -1098,24 +1211,35 @@ export default function EditorPage() {
onDeleteClass={handleDeleteClass}
onCopyIri={handleCopyIri}
selectedNodeFallback={selectedNodeFallback}
onUpdateClass={isSuggestionMode ? handleSuggestClassUpdate : handleUpdateClass}
onUpdateClass={
isAnonymousProposalMode
? handleAnonymousClassUpdate
: isSuggestionMode
? handleSuggestClassUpdate
: handleUpdateClass
}
detailRefreshKey={detailRefreshKey}
onUpdateProperty={isSuggestionMode ? handleSuggestPropertyUpdate : handleUpdateProperty}
onUpdateIndividual={isSuggestionMode ? handleSuggestIndividualUpdate : handleUpdateIndividual}
onReparentClass={handleReparentClass}
reparentOptimistic={reparentOptimistic}
rollbackReparent={rollbackReparent}
showSignInToEdit={!hasValidAccess && zitadelConfigured && !canPropose}
onSignInToEdit={() => signIn("zitadel", { callbackUrl: window.location.href })}
canPropose={canPropose && !isAnonymousProposalMode}
onProposeEdit={handleProposeEdit}
isAnonymousProposalMode={isAnonymousProposalMode}
/>
</div>
) : (
<StandardEditorLayout
projectId={projectId}
accessToken={session?.accessToken}
activeBranch={activeBranch}
canEdit={!!canEdit}
activeBranch={isAnonymousProposalMode && anonymousSuggestion.branch ? anonymousSuggestion.branch : activeBranch}
canEdit={isAnonymousProposalMode ? true : !!canEdit}
canSuggest={!!canSuggest}
entityNavigationRef={entityNavigationRef}
isSuggestionMode={isSuggestionMode}
isSuggestionMode={isAnonymousProposalMode ? true : isSuggestionMode}
nodes={nodes}
isTreeLoading={isTreeLoading}
treeError={treeError}
Expand All @@ -1135,14 +1259,25 @@ export default function EditorPage() {
onDeleteClass={handleDeleteClass}
onCopyIri={handleCopyIri}
selectedNodeFallback={selectedNodeFallback}
onUpdateClass={isSuggestionMode ? handleSuggestClassUpdate : handleUpdateClass}
onUpdateClass={
isAnonymousProposalMode
? handleAnonymousClassUpdate
: isSuggestionMode
? handleSuggestClassUpdate
: handleUpdateClass
}
detailRefreshKey={detailRefreshKey}
sourceContent={sourceContent}
onUpdateProperty={isSuggestionMode ? handleSuggestPropertyUpdate : handleUpdateProperty}
onUpdateIndividual={isSuggestionMode ? handleSuggestIndividualUpdate : handleUpdateIndividual}
onReparentClass={handleReparentClass}
reparentOptimistic={reparentOptimistic}
rollbackReparent={rollbackReparent}
showSignInToEdit={!hasValidAccess && zitadelConfigured && !canPropose}
onSignInToEdit={() => signIn("zitadel", { callbackUrl: window.location.href })}
canPropose={canPropose && !isAnonymousProposalMode}
onProposeEdit={handleProposeEdit}
isAnonymousProposalMode={isAnonymousProposalMode}
/>
)}
</div>
Expand Down Expand Up @@ -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 */}
<CreditModal
open={creditModalOpen}
onClose={() => handleAnonymousSubmit(null, null)}
onSubmitCredit={handleAnonymousSubmit}
/>
Comment on lines +1380 to +1384

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

CreditModal onClose triggers submission — dismissing the modal submits with null credit.

The onClose handler is set to () => handleAnonymousSubmit(null, null), which means:

  • Clicking outside the modal → submits proposal with no credit
  • Pressing ESC → submits proposal with no credit
  • Clicking "Skip" → correctly skips (via handleSkiponClose)
  • Clicking "Save" → submits with credit, then onClose tries to submit again (see CreditModal review)

This behavior may be intentional for "Skip" but is surprising for dismiss-by-backdrop/ESC. Users might accidentally submit when trying to cancel.

🛡️ Suggested fix: separate skip from dismiss
       <CreditModal
         open={creditModalOpen}
-        onClose={() => handleAnonymousSubmit(null, null)}
+        onClose={() => setCreditModalOpen(false)}
         onSubmitCredit={handleAnonymousSubmit}
       />

Then update CreditModal's handleSkip to call onSubmitCredit(null, null) before onClose(), and update handleSave to not call onClose() (parent closes after submit).

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<CreditModal
open={creditModalOpen}
onClose={() => handleAnonymousSubmit(null, null)}
onSubmitCredit={handleAnonymousSubmit}
/>
<CreditModal
open={creditModalOpen}
onClose={() => setCreditModalOpen(false)}
onSubmitCredit={handleAnonymousSubmit}
/>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/projects/`[id]/editor/page.tsx around lines 1544 - 1548, The CreditModal
is wired so onClose calls handleAnonymousSubmit(null, null), causing
backdrop/ESC dismissals to submit; change the parent JSX so onClose only closes
the modal (e.g., setCreditModalOpen(false)) and keep onSubmitCredit pointing at
handleAnonymousSubmit; inside CreditModal update handleSkip to call
onSubmitCredit(null, null) then call onClose(), and update handleSave to call
onSubmitCredit(...) without calling onClose itself (the parent will close after
successful submit) so dismissing the modal does not trigger a submit.

</BranchProvider>
);
}
20 changes: 16 additions & 4 deletions app/projects/[id]/suggestions/review/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -407,8 +407,13 @@ export default function SuggestionReviewPage() {
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-slate-900 dark:text-white">
{s.submitter?.name || s.submitter?.email || "Unknown user"}
{s.submitter?.name || s.submitter?.email || (s.is_anonymous ? "Anonymous" : "Unknown user")}
</span>
{s.is_anonymous && (
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-600 dark:bg-slate-700 dark:text-slate-300">
Anonymous
</span>
Comment on lines +410 to +415

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Avoid the duplicated “Anonymous” label.

When submitter.name/email is absent, the text fallback already becomes "Anonymous", so the extra badge renders Anonymous Anonymous in both views. Gate the badge on actual credited identity, or change the fallback copy when the badge is shown.

Also applies to: 523-531

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/projects/`[id]/suggestions/review/page.tsx around lines 415 - 420, The
code currently renders the literal "Anonymous" twice because the text fallback
uses s.submitter?.name || s.submitter?.email || (s.is_anonymous ? "Anonymous" :
"Unknown user") and you always render the Anonymous badge; change the logic so
the badge is only shown when there is an actual credited identity (i.e.
s.is_anonymous && (s.submitter?.name || s.submitter?.email)), or alternatively
keep the badge for any anonymous but change the text fallback when the badge is
shown; update the rendering around s, s.submitter, and s.is_anonymous in the
component (page.tsx) to implement one of these fixes to avoid "Anonymous
Anonymous".

)}
{(s.revision ?? 1) > 1 && (
<span className="rounded-sm bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
v{s.revision}
Expand Down Expand Up @@ -510,9 +515,16 @@ export default function SuggestionReviewPage() {
<h4 className="text-xs font-medium uppercase tracking-wider text-slate-400 dark:text-slate-500">
Submitted by
</h4>
<p className="mt-1 text-sm text-slate-900 dark:text-white">
{s.submitter?.name || s.submitter?.email || "Unknown user"}
</p>
<div className="mt-1 flex items-center gap-2">
<p className="text-sm text-slate-900 dark:text-white">
{s.submitter?.name || s.submitter?.email || (s.is_anonymous ? "Anonymous" : "Unknown user")}
</p>
{s.is_anonymous && (
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-600 dark:bg-slate-700 dark:text-slate-300">
Anonymous
</span>
)}
</div>
<p className="text-xs text-slate-500 dark:text-slate-400">
<Clock className="mr-1 inline h-3 w-3" />
{lastActivity.toLocaleString()}
Expand Down
22 changes: 17 additions & 5 deletions auth.ts
Original file line number Diff line number Diff line change
@@ -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";
}
Comment on lines +6 to +9

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Expected: there should be a single source of truth for NEXTAUTH_SECRET, and production auth-capable modes should not rely on a public fallback."

echo
echo "== secret fallbacks and reads =="
rg -nP --type=ts 'NEXTAUTH_SECRET|AUTH_SECRET|ontokit-optional-auth-secret|ontokit-dev-secret' auth.ts lib

echo
echo "== auth.ts =="
sed -n '1,140p' auth.ts

Repository: CatholicOS/ontokit-web

Length of output: 4639


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Checking lib/env.ts context around NEXTAUTH_SECRET defaults ==="
rg -B5 -A5 'NEXTAUTH_SECRET.*default' lib/env.ts

echo
echo "=== Checking how lib/env.ts is imported/used in auth.ts ==="
rg -n 'env|import.*env' auth.ts | head -20

echo
echo "=== Full lib/env.ts to understand the schema structure ==="
wc -l lib/env.ts && head -50 lib/env.ts

Repository: CatholicOS/ontokit-web

Length of output: 133


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Listing files in lib directory ==="
fd . lib --max-depth 2 -t f

echo
echo "=== Check if lib/env.ts exists ==="
ls -la lib/env.ts 2>&1 || echo "File not found"

echo
echo "=== Reading lib/env.ts entirely ==="
cat -n lib/env.ts 2>&1 || echo "Cannot read file"

Repository: CatholicOS/ontokit-web

Length of output: 5855


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Reading lib/auth-mode.ts to understand getAuthMode() and isAuthRequired() ==="
cat -n lib/auth-mode.ts

echo
echo "=== Checking where auth.ts is imported to understand initialization order ==="
rg -l 'import.*auth' --type=ts --type=tsx | head -10

Repository: CatholicOS/ontokit-web

Length of output: 1395


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Check if auth.ts is imported in middleware or app entry points ==="
rg 'import.*auth' . --type ts -l | grep -E '(middleware|layout|page)' | head -10

echo
echo "=== Check middleware.ts ==="
cat -n middleware.ts 2>/dev/null || echo "No middleware.ts found"

echo
echo "=== Check app layout ==="
find . -name 'layout.ts' -o -name 'layout.tsx' | head -3 | xargs cat -n 2>/dev/null || echo "Layout file not found or error"

echo
echo "=== Key question: When auth.ts sets NEXTAUTH_SECRET, does lib/env.ts validation run before or after? ==="
echo "Checking if auth.ts is imported at module level in app initialization..."
rg 'from.*auth' . --type ts | grep -v node_modules | head -10

Repository: CatholicOS/ontokit-web

Length of output: 5610


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Check if lib/env.ts is imported anywhere that would run at module init ==="
rg 'import.*env' . --type ts | grep -v node_modules | head -20

echo
echo "=== Check app/providers.ts which is imported at root layout ==="
cat -n app/providers.ts 2>/dev/null || echo "Not found"

echo
echo "=== Search for where serverEnv from lib/env.ts is actually used ==="
rg 'serverEnv|validateServerEnv|clientEnv|validateClientEnv' . --type ts | grep -v node_modules | head -15

Repository: CatholicOS/ontokit-web

Length of output: 1480


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Search for actual usage of serverEnv export from lib/env.ts ==="
rg 'import.*serverEnv|from.*lib/env' . --type ts | grep -v test | grep -v node_modules

echo
echo "=== Check if NextAuth initialization in auth.ts happens before env validation anywhere ==="
echo "Looking for any files that import from both auth.ts and lib/env.ts..."
rg 'import.*auth' . --type ts | grep -v node_modules | xargs rg 'import.*env' | head -10

echo
echo "=== Check what actually imports auth.ts at the app level ==="
rg 'import.*auth' app --type ts 2>/dev/null | head -20

Repository: CatholicOS/ontokit-web

Length of output: 133


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Check what happens when auth.ts is loaded - look at auth.ts more closely ==="
cat -n auth.ts | head -15

echo
echo "=== Confirm: the NEXTAUTH_SECRET fallback in auth.ts is set BEFORE NextAuth() is called ==="
grep -n 'NextAuth(' auth.ts

echo
echo "=== When does lib/env.ts validation actually run? Check if serverEnv is exported/used ==="
rg 'serverEnv' . --type ts | grep -v test | grep -v 'function validateServerEnv' | grep -v 'export const serverEnv' | grep -v 'return validateServerEnv'

echo
echo "=== Summary: Is auth.ts imported/executed at module level by NextAuth setup? ==="
echo "NextAuth handlers are exported from auth.ts, so auth.ts runs immediately when imported."
echo "The NEXTAUTH_SECRET assignment on lines 7-8 runs BEFORE NextAuth(authConfig) on line 107."
echo "This means the fallback secret is set into process.env before any other code can read it."

Repository: CatholicOS/ontokit-web

Length of output: 1031


Don't ship a shared fallback NEXTAUTH_SECRET.

Lines 7–8 set NEXTAUTH_SECRET to "ontokit-optional-auth-secret" when auth mode is not "required" and the env var is missing. In optional mode, the app still issues authenticated sessions via Zitadel, so every deployment that omits this env var will sign sessions with the same public secret—a session-forgery risk. Additionally, lib/env.ts (lines 31–34) defines a different hard-coded fallback ("ontokit-dev-secret"), creating configuration drift between the two auth entry points. Require an explicit secret whenever auth can issue sessions; limit any fallback to isolated dev/test environments only.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@auth.ts` around lines 6 - 9, Remove the hard-coded shared NEXTAUTH_SECRET
fallback and require an explicit secret whenever auth can issue sessions: stop
setting process.env.NEXTAUTH_SECRET = "ontokit-optional-auth-secret" in the
getAuthMode() branch; instead, if getAuthMode() indicates auth may issue
sessions and process.env.NEXTAUTH_SECRET is missing, throw/log a clear error and
exit (or surface a startup failure) unless running in an isolated dev/test
environment (check NODE_ENV === "development" or "test" or use the existing dev
helper); if you must provide a fallback for dev/test only, reuse the single
canonical dev fallback defined in lib/env.ts (or centralize that value) so both
entry points match.


// Zitadel provider configuration
const zitadelProvider = {
Expand All @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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",
};

Expand Down
Loading
Loading