Skip to content
Merged
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
4 changes: 3 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,6 @@ SLACK_BOT_TOKEN=
SLACK_ONBOARDING_CHANNEL_ID=

# Optional: used by manager control plane in hybrid mode.
# Managers can always add links in the UI; provider credentials unlock richer ingestion.
# Managers can always add links in the UI; provider credentials unlock richer ingestion.
# Set to "false" to require real auth on hire-scoped manager/sync/chat endpoints.
RUNBOOK_ALLOW_UNAUTH_HIRE_ACCESS=true
Comment on lines 13 to +16

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

Permissive default + missing trailing newline.

Two small things:

  1. The example sets RUNBOOK_ALLOW_UNAUTH_HIRE_ACCESS=true, which is the unsafe configuration. Consider shipping .env.example with false so the principle of least surprise is "this needs auth unless you opt out for demo." (Root-cause concern lives in src/lib/apiAuth.ts.)
  2. dotenv-linter flags missing trailing newline at line 16.
🛡️ Suggested change
 # Optional: used by manager control plane in hybrid mode.
 # Managers can always add links in the UI; provider credentials unlock richer ingestion.
-# Set to "false" to require real auth on hire-scoped manager/sync/chat endpoints.
-RUNBOOK_ALLOW_UNAUTH_HIRE_ACCESS=true
+# Set to "true" ONLY for local/demo to skip auth on hire-scoped manager/sync/chat endpoints.
+RUNBOOK_ALLOW_UNAUTH_HIRE_ACCESS=false
+
📝 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
# Optional: used by manager control plane in hybrid mode.
# Managers can always add links in the UI; provider credentials unlock richer ingestion.
\ No newline at end of file
# Managers can always add links in the UI; provider credentials unlock richer ingestion.
# Set to "false" to require real auth on hire-scoped manager/sync/chat endpoints.
RUNBOOK_ALLOW_UNAUTH_HIRE_ACCESS=true
# Optional: used by manager control plane in hybrid mode.
# Managers can always add links in the UI; provider credentials unlock richer ingestion.
# Set to "true" ONLY for local/demo to skip auth on hire-scoped manager/sync/chat endpoints.
RUNBOOK_ALLOW_UNAUTH_HIRE_ACCESS=false
🧰 Tools
🪛 dotenv-linter (4.0.0)

[warning] 16-16: [EndingBlankLine] No blank line at the end of the file

(EndingBlankLine)

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

In @.env.example around lines 13 - 16, Change the permissive default in the
.env.example by setting RUNBOOK_ALLOW_UNAUTH_HIRE_ACCESS to false (so the
example defaults to requiring auth) and add a trailing newline at the end of the
file; this aligns the example with the principle of least privilege and resolves
the dotenv-linter warning — check references to RUNBOOK_ALLOW_UNAUTH_HIRE_ACCESS
and related logic in src/lib/apiAuth.ts to ensure the example matches expected
behavior.

23 changes: 16 additions & 7 deletions src/app/api/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ function escapeRegExp(string: string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

/** Strip hire scope lines and collapse whitespace for compact source previews. */
function excerptForChatSource(content: string, maxLen: number): string {
const lines = (content || "").split("\n").filter((line) => !/^\[hire:[^\]]+\]$/.test(line.trim()));
const body = lines.join(" ").replace(/\s+/g, " ").trim();
if (!body) return "";
return body.length > maxLen ? `${body.slice(0, maxLen)}…` : body;
}

export async function POST(req: Request) {
try {
const body = await req.json();
Expand All @@ -58,14 +66,15 @@ export async function POST(req: Request) {
}

const retrieved = await retrieveDocs(question, hireId);
const validDocs = retrieved.filter(r => r.score > 0).map(r => r.doc);
// NOTE: `match_documents` uses cosine *distance* semantics; similarity scores are not guaranteed to be > 0.
const validDocs = retrieved
.slice()
.sort((a, b) => b.score - a.score)
.map((r) => r.doc);

const sources: ChatSource[] = validDocs.map((d) => ({
title: d.title,
excerpt: (() => {
const content = d.content || "";
return content.length > 180 ? `${content.slice(0, 180)}...` : content;
})(),
title: d.title.replace(/^\[hire:[^\]]+\]\s*/i, "").trim() || d.title,
excerpt: excerptForChatSource(d.content || "", 260),
url: d.url || undefined,
}));
const context = validDocs
Expand All @@ -83,7 +92,7 @@ export async function POST(req: Request) {
}

try {
const userPrompt = `Company Context:\n${context || "No context found."}\n\nUser Question: ${question}\n\nAnswer with concise guidance and ground it in the provided sources.`;
const userPrompt = `Company Context:\n${context || "No context found."}\n\nUser Question: ${question}\n\nWrite the answer in clear, scannable markdown: use "## " section headings, "- " bullets or numbered steps where appropriate, and **bold** for key terms. Ground every claim in the sources above.`;
const answer = await generateFromGemini(CHAT_SYSTEM_PROMPT, userPrompt);
return NextResponse.json({ answer, sources });
} catch (e) {
Expand Down
17 changes: 11 additions & 6 deletions src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { DEMO_PERSONAS, DEMO_QUESTIONS } from "@/lib/demoScenario";
import { ChatSource, Hire, Lesson, LessonSlide, OnboardingTask } from "@/lib/types";
import { AppButton } from "@/components/ui/AppButton";
import { ChatMessageBody } from "@/components/ui/ChatMessageBody";
import { SectionCard } from "@/components/ui/SectionCard";
import { StatusBadge } from "@/components/ui/StatusBadge";

Expand Down Expand Up @@ -301,13 +302,15 @@ export default function DashboardPage() {
</div>
{messages.map((message) => (
<div key={message.id} className={`space-y-2 ${message.role === "user" ? "text-right" : ""}`}>
<p
className={`inline-block max-w-[95%] rounded-lg px-3 py-2 text-sm ${
message.role === "user" ? "bg-cyan-500 text-slate-900" : "bg-slate-800 text-slate-100"
<div
className={`inline-block rounded-lg px-3 py-2 text-sm ${
message.role === "user"
? "max-w-[min(92%,28rem)] bg-cyan-500 text-slate-900"
: "max-w-[min(96%,42rem)] bg-slate-800 text-slate-100"
}`}
>
{message.text}
</p>
<ChatMessageBody role={message.role} text={message.text} />
</div>
{message.role === "assistant" && message.sources?.length ? (
<div className="grid gap-2">
{message.sources.map((source, idx) => (
Expand All @@ -324,7 +327,9 @@ export default function DashboardPage() {
) : (
<p className="text-xs font-semibold text-cyan-200">{source.title}</p>
)}
<p className="text-xs text-slate-300">{source.excerpt}</p>
<p className="mt-1 whitespace-pre-wrap break-words text-xs leading-relaxed text-slate-300">
{source.excerpt}
</p>
</article>
))}
</div>
Expand Down
80 changes: 69 additions & 11 deletions src/app/manager/tasks/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ export default function ManagerTasksPage() {
const [form, setForm] = useState<TaskFormState>({ title: "", description: "", assigneeId: "", estimatedTime: "", sourceTitle: "" });
const [duplicateTargets, setDuplicateTargets] = useState<Record<string, string[]>>({});

const activeHires = hires.filter((hire) => hire.active);
const archivedHires = hires.filter((hire) => !hire.active);

useEffect(() => {
const timer = window.setTimeout(async () => {
try {
Expand All @@ -33,9 +36,9 @@ export default function ManagerTasksPage() {
const hiresData = await hiresRes.json();
if (tasksRes.ok) setTasks(tasksData);
if (hiresRes.ok) {
const activeHires = (hiresData.hires || []).filter((hire: Hire) => hire.active);
setHires(activeHires);
const defaultHire = activeHires[0]?.id || "";
const allHires = (hiresData.hires || []) as Hire[];
const defaultHire = allHires.find((hire) => hire.active)?.id || "";
setHires(allHires);
setSelectedHireId(defaultHire);
setForm((prev) => ({ ...prev, assigneeId: defaultHire }));
}
Expand Down Expand Up @@ -137,21 +140,45 @@ export default function ManagerTasksPage() {
const data = await res.json();
if (!res.ok) return setMessage(data.error || "Failed to create hire.");
const created = data.hire as Hire;
setHires((prev) => [...prev, created]);
setHires((prev) => [...prev, { ...created, active: true }]);
setSelectedHireId(created.id);
setForm((prev) => ({ ...prev, assigneeId: created.id }));
setHireForm({ name: "", role: "", email: "" });
}

async function deleteHire(hireId: string) {
const res = await fetch(`/api/manager/hires/${hireId}`, { method: "DELETE" });
const firstConfirm = window.confirm("Remove this hire from active onboarding?");
if (!firstConfirm) return;
const secondConfirm = window.confirm(
"Please confirm again: this hire will be archived and removed from active workflows."
);
if (!secondConfirm) return;
const res = await fetch(`/api/manager/hires/${hireId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ active: false })
});
const data = await res.json();
if (!res.ok) return setMessage(data.error || "Failed to remove hire.");
setHires((prev) => prev.filter((hire) => hire.id !== hireId));
setSelectedHireId("");
setHires((prev) => prev.map((hire) => (hire.id === hireId ? { ...hire, active: false } : hire)));
setSelectedHireId((current) => (current === hireId ? (activeHires.find((hire) => hire.id !== hireId)?.id || "") : current));
setSources([]);
setForm((prev) => (prev.assigneeId === hireId ? { ...prev, assigneeId: "" } : prev));
setMessage("Hire removed.");
setMessage("Hire archived. You can restore them below.");
}

async function restoreHire(hireId: string) {
const res = await fetch(`/api/manager/hires/${hireId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ active: true })
});
const data = await res.json();
if (!res.ok) return setMessage(data.error || "Failed to restore hire.");
setHires((prev) => prev.map((hire) => (hire.id === hireId ? { ...hire, active: true } : hire)));
setSelectedHireId(hireId);
setForm((prev) => ({ ...prev, assigneeId: hireId }));
setMessage("Hire restored.");
}

async function addSource(event: FormEvent<HTMLFormElement>) {
Expand All @@ -166,6 +193,20 @@ export default function ManagerTasksPage() {
if (!res.ok) return setMessage(data.error || "Failed to add source.");
setSources((prev) => [...prev, data.source]);
setSourceForm((prev) => ({ ...prev, title: "", url: "" }));
setMessage("Source added. Syncing now...");
setSyncing(true);
const syncRes = await fetch("/api/sync/knowledge", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ hireId: selectedHireId }),
});
const syncData = await syncRes.json().catch(() => ({}));
setSyncing(false);
if (!syncRes.ok) {
setMessage(syncData.error || "Source added, but sync failed. Try syncing manually.");
return;
}
setMessage(`Source added and synced: ${syncData.result?.synced ?? 0}/${syncData.result?.scanned ?? 0} docs.`);
}

async function deleteSource(sourceId: string) {
Expand Down Expand Up @@ -208,7 +249,7 @@ export default function ManagerTasksPage() {
<AppButton variant="primary" type="submit">Add hire</AppButton>
</form>
<div className="mt-3 flex flex-wrap gap-2">
{hires.map((hire) => (
{activeHires.map((hire) => (
<AppButton
key={hire.id}
type="button"
Expand All @@ -222,6 +263,23 @@ export default function ManagerTasksPage() {
))}
</div>
{selectedHireId ? <AppButton type="button" variant="danger" onClick={() => void deleteHire(selectedHireId)} className="mt-3 px-3 py-1 text-xs">Remove selected hire</AppButton> : null}
{archivedHires.length > 0 ? (
<div className="mt-4 rounded border border-slate-700 bg-slate-950 p-3">
<p className="text-xs font-semibold text-slate-300">Archived hires</p>
<div className="mt-2 flex flex-wrap gap-2">
{archivedHires.map((hire) => (
<AppButton
key={hire.id}
variant="ghost"
className="rounded-full px-3 py-1 text-xs"
onClick={() => void restoreHire(hire.id)}
>
Restore {hire.name}
</AppButton>
))}
</div>
</div>
) : null}
</SectionCard>

<SectionCard
Expand Down Expand Up @@ -254,7 +312,7 @@ export default function ManagerTasksPage() {
<textarea className="w-full rounded border border-slate-700 bg-slate-950 px-3 py-2 text-sm" placeholder="Task description" value={form.description} onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))} required />
<div className="grid gap-3 sm:grid-cols-3">
<select className="rounded border border-slate-700 bg-slate-950 px-3 py-2 text-sm" value={form.assigneeId} onChange={(e) => setForm((p) => ({ ...p, assigneeId: e.target.value }))}>
{hires.map((hire) => <option key={hire.id} value={hire.id}>{hire.name}</option>)}
{activeHires.map((hire) => <option key={hire.id} value={hire.id}>{hire.name}</option>)}
</select>
<input className="rounded border border-slate-700 bg-slate-950 px-3 py-2 text-sm" placeholder="Estimated time" value={form.estimatedTime} onChange={(e) => setForm((p) => ({ ...p, estimatedTime: e.target.value }))} />
<input className="rounded border border-slate-700 bg-slate-950 px-3 py-2 text-sm" placeholder="Source title" value={form.sourceTitle} onChange={(e) => setForm((p) => ({ ...p, sourceTitle: e.target.value }))} />
Expand All @@ -279,7 +337,7 @@ export default function ManagerTasksPage() {
<div className="mt-3 space-y-2 rounded border border-slate-800 bg-slate-900 p-2">
<p className="text-xs text-slate-300">Duplicate to hires:</p>
<div className="flex flex-wrap gap-2">
{hires.filter((hire) => hire.id !== task.assigneeId).map((hire) => {
{activeHires.filter((hire) => hire.id !== task.assigneeId).map((hire) => {
const active = (duplicateTargets[task.id] || []).includes(hire.id);
return (
<AppButton
Expand Down
92 changes: 92 additions & 0 deletions src/components/ui/ChatMessageBody.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { Fragment, type ReactNode } from "react";

function formatInline(text: string): ReactNode[] {
const parts = text.split(/(\*\*[^*]+\*\*)/g);
return parts.map((part, i) => {
const m = part.match(/^\*\*(.+)\*\*$/);
if (m) {
return (
<strong key={i} className="font-semibold text-slate-50">
{m[1]}
</strong>
);
}
return part ? <span key={i}>{part}</span> : null;
});
}

function isBulletLine(line: string): boolean {
return /^\s*[-*]\s+/.test(line) || /^\s*\d+\.\s+/.test(line);
}

function stripBulletPrefix(line: string): string {
return line.replace(/^\s*[-*]\s+/, "").replace(/^\s*\d+\.\s+/, "");
}

/**
* Renders assistant chat with readable structure (markdown-lite from the model).
* User messages stay plain.
*/
export function ChatMessageBody({ role, text }: { role: "user" | "assistant"; text: string }) {
if (role === "user") {
return <span className="whitespace-pre-wrap break-words">{text}</span>;
}

const blocks = text.trim().split(/\n\n+/).filter((b) => b.trim());
if (blocks.length === 0) {
return <span className="text-slate-500">—</span>;
}

return (
<div className="max-w-none space-y-4 text-left text-sm leading-relaxed">
{blocks.map((block, bi) => {
const lines = block.split("\n");
const first = lines[0]?.trim() ?? "";

if (first.startsWith("## ")) {
const title = first.replace(/^##\s+/, "");
const rest = lines
.slice(1)
.join("\n")
.trim();
return (
<div key={bi} className="space-y-2 border-b border-slate-700/60 pb-3 last:border-0 last:pb-0">
<h4 className="text-[13px] font-semibold tracking-wide text-cyan-200">{formatInline(title)}</h4>
{rest ? (
<p className="whitespace-pre-wrap break-words text-slate-200">{formatInline(rest)}</p>
) : null}
</div>
);
}

const nonEmpty = lines.filter((l) => l.trim());
const allBullets = nonEmpty.length > 0 && nonEmpty.every((l) => isBulletLine(l));
if (allBullets) {
return (
<ul
key={bi}
className="list-disc space-y-2 pl-5 text-slate-200 marker:text-cyan-400 [&>li]:pl-1"
>
{nonEmpty.map((line, li) => (
<li key={li} className="whitespace-pre-wrap break-words pl-0.5">
{formatInline(stripBulletPrefix(line))}
</li>
))}
</ul>
);
}

return (
<p key={bi} className="whitespace-pre-wrap break-words text-slate-200">
{lines.map((line, li) => (
<Fragment key={li}>
{li > 0 ? <br /> : null}
{formatInline(line)}
</Fragment>
))}
</p>
);
})}
</div>
);
}
11 changes: 10 additions & 1 deletion src/lib/apiAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ type AuthResult = {
userId?: string;
};

const allowUnauthedDemoAccess =
process.env.RUNBOOK_ALLOW_UNAUTH_HIRE_ACCESS !== "false";

async function getCurrentUserId(): Promise<string | null> {
try {
const cookieStore = await cookies();
Expand All @@ -29,7 +32,13 @@ async function isUserAuthorizedForHire(_userId: string, hireId: string): Promise
export async function requireHireAccess(hireId: string): Promise<AuthResult> {
const userId = await getCurrentUserId();
if (!userId) {
return { ok: false, status: 401 };
if (!allowUnauthedDemoAccess) {
return { ok: false, status: 401 };
}
const hires = await getHires();
const exists = hires.some((hire) => hire.id === hireId);
if (!exists) return { ok: false, status: 403 };
return { ok: true, status: 200, userId: "demo-user" };
}
const authorized = await isUserAuthorizedForHire(userId, hireId);
if (!authorized) {
Expand Down
Loading
Loading