camelmind 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +142 -0
- package/package.json +51 -0
- package/template/_env.example +16 -0
- package/template/_gitignore +32 -0
- package/template/_package.json +39 -0
- package/template/app/[...slug]/page.tsx +150 -0
- package/template/app/api/auth/callback/route.ts +47 -0
- package/template/app/api/auth/login/route.ts +39 -0
- package/template/app/api/auth/logout/route.ts +8 -0
- package/template/app/api/auth/signin/route.ts +64 -0
- package/template/app/api/download/route.ts +67 -0
- package/template/app/api/raw/route.ts +43 -0
- package/template/app/api/search/route.ts +138 -0
- package/template/app/favicon.ico +0 -0
- package/template/app/globals.css +524 -0
- package/template/app/home/page.tsx +136 -0
- package/template/app/icon.svg +29 -0
- package/template/app/layout.tsx +44 -0
- package/template/app/login/LoginForm.tsx +105 -0
- package/template/app/login/page.tsx +9 -0
- package/template/app/page.tsx +5 -0
- package/template/camelmind.config.ts +33 -0
- package/template/components/Breadcrumbs/Breadcrumbs.tsx +41 -0
- package/template/components/Code/CodeBlock.tsx +55 -0
- package/template/components/DocActions/DocActions.tsx +62 -0
- package/template/components/Nav/MobileDrawer.tsx +221 -0
- package/template/components/Nav/ThemeToggle.tsx +62 -0
- package/template/components/Nav/TopNav.tsx +173 -0
- package/template/components/Nav/VersionSelector.tsx +107 -0
- package/template/components/PageNav/PageNav.tsx +68 -0
- package/template/components/Search/SearchModal.tsx +278 -0
- package/template/components/SectionCards/SectionCards.tsx +33 -0
- package/template/components/Sidebar/Sidebar.tsx +196 -0
- package/template/components/Toc/Toc.tsx +57 -0
- package/template/components/ZoomImages/ZoomImages.tsx +24 -0
- package/template/components/mdx/Callout.tsx +43 -0
- package/template/components/mdx/Details.tsx +65 -0
- package/template/components/mdx/Icon.tsx +25 -0
- package/template/components/mdx/Steps.tsx +33 -0
- package/template/components/mdx/Tabs.tsx +69 -0
- package/template/components/mdx/index.tsx +34 -0
- package/template/content/getting-started/installation.mdx +51 -0
- package/template/content/getting-started/overview.mdx +39 -0
- package/template/content/guides/writing-docs.mdx +72 -0
- package/template/eslint.config.mjs +18 -0
- package/template/lib/auth-providers/dev-mock.ts +45 -0
- package/template/lib/auth-providers/oidc.ts +95 -0
- package/template/lib/auth-roles.ts +28 -0
- package/template/lib/auth.ts +76 -0
- package/template/lib/config-types.ts +30 -0
- package/template/lib/config.ts +29 -0
- package/template/lib/mdx.ts +53 -0
- package/template/lib/nav-types.ts +31 -0
- package/template/lib/nav.ts +125 -0
- package/template/lib/versions.ts +61 -0
- package/template/nav/nav.yml +20 -0
- package/template/next.config.ts +35 -0
- package/template/postcss.config.mjs +7 -0
- package/template/proxy.ts +50 -0
- package/template/public/2f-logo.png +0 -0
- package/template/public/favicon.png +0 -0
- package/template/public/file.svg +1 -0
- package/template/public/globe.svg +1 -0
- package/template/public/images/gwa-app-central-main.png +0 -0
- package/template/public/next.svg +1 -0
- package/template/public/window.svg +1 -0
- package/template/scripts/build-offline.sh +189 -0
- package/template/scripts/build-pdf.sh +41 -0
- package/template/scripts/build-search-index.ts +90 -0
- package/template/scripts/generate-master-pdf.ts +260 -0
- package/template/scripts/generate-pdfs.ts +185 -0
- package/template/tsconfig.json +34 -0
- package/template/versions.yml +5 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef } from "react"
|
|
4
|
+
import { Link } from "lucide-react"
|
|
5
|
+
|
|
6
|
+
type Props = {
|
|
7
|
+
id?: string
|
|
8
|
+
summary: string
|
|
9
|
+
children: React.ReactNode
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function Details({ id, summary, children }: Props) {
|
|
13
|
+
const ref = useRef<HTMLDetailsElement>(null)
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
if (!id) return
|
|
17
|
+
const open = () => {
|
|
18
|
+
if (window.location.hash === `#${id}`) {
|
|
19
|
+
ref.current?.setAttribute("open", "")
|
|
20
|
+
ref.current?.scrollIntoView({ behavior: "smooth", block: "start" })
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
open()
|
|
24
|
+
window.addEventListener("hashchange", open)
|
|
25
|
+
return () => window.removeEventListener("hashchange", open)
|
|
26
|
+
}, [id])
|
|
27
|
+
|
|
28
|
+
function copyAnchor() {
|
|
29
|
+
if (!id) return
|
|
30
|
+
const url = `${window.location.origin}${window.location.pathname}#${id}`
|
|
31
|
+
navigator.clipboard.writeText(url)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<details
|
|
36
|
+
id={id}
|
|
37
|
+
ref={ref}
|
|
38
|
+
className="group my-3 rounded-lg border border-gray-200 bg-white overflow-hidden"
|
|
39
|
+
>
|
|
40
|
+
<summary className="flex items-center justify-between gap-3 px-4 py-3 cursor-pointer select-none list-none hover:bg-gray-50 transition-colors">
|
|
41
|
+
<span className="text-sm font-medium text-gray-800">{summary}</span>
|
|
42
|
+
<div className="flex items-center gap-2 shrink-0">
|
|
43
|
+
{id && (
|
|
44
|
+
<button
|
|
45
|
+
onClick={(e) => { e.preventDefault(); copyAnchor() }}
|
|
46
|
+
title="Copy link to this section"
|
|
47
|
+
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded hover:bg-gray-100 text-gray-400 hover:text-gray-600"
|
|
48
|
+
>
|
|
49
|
+
<Link size={13} />
|
|
50
|
+
</button>
|
|
51
|
+
)}
|
|
52
|
+
<svg
|
|
53
|
+
className="w-4 h-4 text-gray-400 transition-transform duration-200 group-open:rotate-180"
|
|
54
|
+
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
|
|
55
|
+
>
|
|
56
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
|
57
|
+
</svg>
|
|
58
|
+
</div>
|
|
59
|
+
</summary>
|
|
60
|
+
<div className="px-4 pb-4 pt-2 text-sm text-gray-700 border-t border-gray-100">
|
|
61
|
+
{children}
|
|
62
|
+
</div>
|
|
63
|
+
</details>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import * as LucideIcons from "lucide-react"
|
|
2
|
+
|
|
3
|
+
type Props = {
|
|
4
|
+
name: string
|
|
5
|
+
size?: number
|
|
6
|
+
className?: string
|
|
7
|
+
color?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function Icon({ name, size = 18, className, color }: Props) {
|
|
11
|
+
// Convert kebab-case to PascalCase: "shield-alert" → "ShieldAlert"
|
|
12
|
+
const pascalName = name
|
|
13
|
+
.split("-")
|
|
14
|
+
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
|
|
15
|
+
.join("")
|
|
16
|
+
|
|
17
|
+
const LucideIcon = (LucideIcons as unknown as Record<string, React.ComponentType<{ size?: number; className?: string; color?: string }>>)[pascalName]
|
|
18
|
+
|
|
19
|
+
if (!LucideIcon) {
|
|
20
|
+
console.warn(`Icon "${name}" (${pascalName}) not found in lucide-react`)
|
|
21
|
+
return null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return <LucideIcon size={size} className={className} color={color} />
|
|
25
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export function Steps({ children }: { children: React.ReactNode }) {
|
|
2
|
+
return (
|
|
3
|
+
<div className="timeline">
|
|
4
|
+
<div className="timeline-content">
|
|
5
|
+
{children}
|
|
6
|
+
</div>
|
|
7
|
+
</div>
|
|
8
|
+
)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function slugify(title: string) {
|
|
12
|
+
return title
|
|
13
|
+
.toLowerCase()
|
|
14
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
15
|
+
.replace(/(^-|-$)/g, "")
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function Step({ n, title, children }: { n: string | number; title: string; children: React.ReactNode }) {
|
|
19
|
+
const id = slugify(String(title))
|
|
20
|
+
return (
|
|
21
|
+
<div className="timeline-item" id={id} data-step={n} style={{ scrollMarginTop: "5rem" }}>
|
|
22
|
+
<a
|
|
23
|
+
href={`#${id}`}
|
|
24
|
+
className="timeline-step-anchor"
|
|
25
|
+
aria-label={`Link to step ${n}: ${title}`}
|
|
26
|
+
>
|
|
27
|
+
{n}
|
|
28
|
+
</a>
|
|
29
|
+
<h3>{title}</h3>
|
|
30
|
+
<div>{children}</div>
|
|
31
|
+
</div>
|
|
32
|
+
)
|
|
33
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useState, Children, isValidElement } from "react"
|
|
4
|
+
|
|
5
|
+
type TabProps = {
|
|
6
|
+
label: string
|
|
7
|
+
children: React.ReactNode
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function Tab({ children }: TabProps) {
|
|
11
|
+
return <>{children}</>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function Tabs({ children }: { children: React.ReactNode }) {
|
|
15
|
+
const tabs = Children.toArray(children).filter(
|
|
16
|
+
(child) => isValidElement(child) && (
|
|
17
|
+
(child.type as { displayName?: string })?.displayName === "Tab" ||
|
|
18
|
+
(child as React.ReactElement<Record<string, unknown>>).props?.label !== undefined
|
|
19
|
+
)
|
|
20
|
+
) as React.ReactElement<TabProps>[]
|
|
21
|
+
|
|
22
|
+
const [active, setActive] = useState(0)
|
|
23
|
+
|
|
24
|
+
if (tabs.length === 0) return null
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div className="my-4 not-prose">
|
|
28
|
+
{/* Screen: tabbed UI — hidden in PDF via data-print="hide" */}
|
|
29
|
+
<div data-print="hide" className="rounded-lg border border-gray-200 overflow-hidden">
|
|
30
|
+
{/* Tab bar */}
|
|
31
|
+
<div className="flex border-b border-gray-200 bg-gray-50">
|
|
32
|
+
{tabs.map((tab, i) => (
|
|
33
|
+
<button
|
|
34
|
+
key={i}
|
|
35
|
+
onClick={() => setActive(i)}
|
|
36
|
+
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
|
37
|
+
i === active
|
|
38
|
+
? "border-gray-900 text-gray-900 bg-white"
|
|
39
|
+
: "border-transparent text-gray-500 hover:text-gray-700 hover:bg-gray-100"
|
|
40
|
+
}`}
|
|
41
|
+
>
|
|
42
|
+
{tab.props.label}
|
|
43
|
+
</button>
|
|
44
|
+
))}
|
|
45
|
+
</div>
|
|
46
|
+
{/* Active tab content */}
|
|
47
|
+
<div className="p-4 prose prose-gray max-w-none">
|
|
48
|
+
{tabs[active]?.props.children}
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
{/* PDF: all panels expanded — hidden on screen via class, shown by removing it in generator */}
|
|
53
|
+
<div data-print="show" className="hidden">
|
|
54
|
+
{tabs.map((tab, i) => (
|
|
55
|
+
<div key={i} className="mb-4">
|
|
56
|
+
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 mb-2 pb-1 border-b border-gray-200">
|
|
57
|
+
{tab.props.label}
|
|
58
|
+
</div>
|
|
59
|
+
<div className="prose prose-gray max-w-none">
|
|
60
|
+
{tab.props.children}
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
))}
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
Tab.displayName = "Tab"
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Callout } from "./Callout"
|
|
2
|
+
import { Steps, Step } from "./Steps"
|
|
3
|
+
import { Tabs, Tab } from "./Tabs"
|
|
4
|
+
import { Icon } from "./Icon"
|
|
5
|
+
import { Details } from "./Details"
|
|
6
|
+
import { CodeBlock } from "@/components/Code/CodeBlock"
|
|
7
|
+
|
|
8
|
+
export const mdxComponents = {
|
|
9
|
+
Callout,
|
|
10
|
+
Steps,
|
|
11
|
+
Step,
|
|
12
|
+
Tabs,
|
|
13
|
+
Tab,
|
|
14
|
+
Icon,
|
|
15
|
+
Details,
|
|
16
|
+
pre: ({ children }: { children: React.ReactNode }) => {
|
|
17
|
+
// If the child code block has a language class, let CodeBlock handle it
|
|
18
|
+
const child = children as React.ReactElement<{ className?: string }>
|
|
19
|
+
if (child?.props?.className) return <>{children}</>
|
|
20
|
+
// No language — render as a plain preformatted block
|
|
21
|
+
const child2 = children as React.ReactElement<{ children: string }>
|
|
22
|
+
return (
|
|
23
|
+
<pre className="overflow-x-auto font-mono text-sm bg-gray-900 text-gray-100 p-4 rounded-lg my-4 whitespace-pre leading-relaxed">
|
|
24
|
+
{child2?.props?.children ?? children}
|
|
25
|
+
</pre>
|
|
26
|
+
)
|
|
27
|
+
},
|
|
28
|
+
code: ({ className, children }: { className?: string; children: string }) => {
|
|
29
|
+
if (className) {
|
|
30
|
+
return <CodeBlock className={className}>{children}</CodeBlock>
|
|
31
|
+
}
|
|
32
|
+
return <code className="bg-gray-100 text-gray-800 px-1.5 py-0.5 rounded text-sm font-mono">{children}</code>
|
|
33
|
+
},
|
|
34
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Installation
|
|
3
|
+
description: Get a CamelMind documentation site running locally in minutes.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Installation
|
|
7
|
+
|
|
8
|
+
## Prerequisites
|
|
9
|
+
|
|
10
|
+
- **Node.js 20+** — [nodejs.org](https://nodejs.org)
|
|
11
|
+
- **npm**, pnpm, or yarn
|
|
12
|
+
|
|
13
|
+
## Scaffold a new site
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npx camelmind init my-docs
|
|
17
|
+
cd my-docs
|
|
18
|
+
npm run dev
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Open [http://localhost:3000](http://localhost:3000) in your browser.
|
|
22
|
+
|
|
23
|
+
## Project structure
|
|
24
|
+
|
|
25
|
+
```text
|
|
26
|
+
my-docs/
|
|
27
|
+
├── content/ # MDX documentation files
|
|
28
|
+
│ └── getting-started/
|
|
29
|
+
│ ├── overview.mdx
|
|
30
|
+
│ └── installation.mdx
|
|
31
|
+
├── nav/
|
|
32
|
+
│ └── nav.yml # Site navigation (source of truth for URLs)
|
|
33
|
+
├── versions.yml # Version registry
|
|
34
|
+
├── camelmind.config.ts # Site configuration
|
|
35
|
+
└── package.json
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Environment variables
|
|
39
|
+
|
|
40
|
+
Copy `.env.example` to `.env.local` and edit as needed:
|
|
41
|
+
|
|
42
|
+
| Variable | Default | Description |
|
|
43
|
+
|---|---|---|
|
|
44
|
+
| `CAMELMIND_URL` | `http://localhost:3000` | Public site URL |
|
|
45
|
+
| `CAMELMIND_AUTH_ENABLED` | `false` | Enable auth/SSO |
|
|
46
|
+
| `CAMELMIND_AUTH_REQUIRE_LOGIN` | `false` | Require login for all pages |
|
|
47
|
+
| `SESSION_SECRET` | — | Required when auth is enabled |
|
|
48
|
+
|
|
49
|
+
<Callout type="note">
|
|
50
|
+
Auth is disabled by default — your site is fully public until you enable it.
|
|
51
|
+
</Callout>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Welcome to My Docs
|
|
3
|
+
description: A CamelMind documentation site — write docs in Markdown, configure nav in YAML.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Welcome
|
|
7
|
+
|
|
8
|
+
This is your new CamelMind documentation site. Write docs in Markdown and MDX, configure navigation in `nav/nav.yml`, and customize the site in `camelmind.config.ts`.
|
|
9
|
+
|
|
10
|
+
<Callout type="tip">
|
|
11
|
+
Edit this file at `content/getting-started/overview.mdx` to get started.
|
|
12
|
+
</Callout>
|
|
13
|
+
|
|
14
|
+
## What you can do
|
|
15
|
+
|
|
16
|
+
- **Write in Markdown** — all standard Markdown plus MDX components
|
|
17
|
+
- **Configure nav in YAML** — decouple URLs from file paths in `nav/nav.yml`
|
|
18
|
+
- **Add versions** — manage multiple doc versions via `versions.yml`
|
|
19
|
+
- **Enable auth** — gate pages by role with optional SSO (OIDC/Keycloak)
|
|
20
|
+
|
|
21
|
+
## Next steps
|
|
22
|
+
|
|
23
|
+
<Steps>
|
|
24
|
+
<Step n={1} title="Edit your content">
|
|
25
|
+
|
|
26
|
+
Add Markdown files to the `content/` folder and register them in `nav/nav.yml`.
|
|
27
|
+
|
|
28
|
+
</Step>
|
|
29
|
+
<Step n={2} title="Customize your site">
|
|
30
|
+
|
|
31
|
+
Edit `camelmind.config.ts` to set your site title, tagline, and GitHub link.
|
|
32
|
+
|
|
33
|
+
</Step>
|
|
34
|
+
<Step n={3} title="Deploy">
|
|
35
|
+
|
|
36
|
+
Run `npm run build` to build a production bundle, then deploy to any Node.js host or use `OFFLINE_MODE=true npm run build` for a static export.
|
|
37
|
+
|
|
38
|
+
</Step>
|
|
39
|
+
</Steps>
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Writing Docs
|
|
3
|
+
description: How to write and organize documentation in CamelMind.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Writing Docs
|
|
7
|
+
|
|
8
|
+
CamelMind uses MDX — Markdown with support for React components. Any `.mdx` file in `content/` can be referenced from `nav/nav.yml`.
|
|
9
|
+
|
|
10
|
+
## Frontmatter
|
|
11
|
+
|
|
12
|
+
Every doc file starts with YAML frontmatter:
|
|
13
|
+
|
|
14
|
+
```mdx
|
|
15
|
+
---
|
|
16
|
+
title: My Page
|
|
17
|
+
description: A short description shown in search results.
|
|
18
|
+
---
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Headings and structure
|
|
22
|
+
|
|
23
|
+
Use standard Markdown headings. The right-side table of contents auto-generates from `##` and `###` headings.
|
|
24
|
+
|
|
25
|
+
## MDX components
|
|
26
|
+
|
|
27
|
+
### Callout
|
|
28
|
+
|
|
29
|
+
```mdx
|
|
30
|
+
<Callout type="tip">This is a tip.</Callout>
|
|
31
|
+
<Callout type="warning">Watch out!</Callout>
|
|
32
|
+
<Callout type="note">Just a note.</Callout>
|
|
33
|
+
<Callout type="danger">Critical warning.</Callout>
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Steps
|
|
37
|
+
|
|
38
|
+
```mdx
|
|
39
|
+
<Steps>
|
|
40
|
+
<Step n={1} title="First step">
|
|
41
|
+
Step content goes here.
|
|
42
|
+
</Step>
|
|
43
|
+
<Step n={2} title="Second step">
|
|
44
|
+
More content.
|
|
45
|
+
</Step>
|
|
46
|
+
</Steps>
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Code blocks
|
|
50
|
+
|
|
51
|
+
Use standard fenced code blocks with a language identifier:
|
|
52
|
+
|
|
53
|
+
````mdx
|
|
54
|
+
```bash
|
|
55
|
+
npm run dev
|
|
56
|
+
```
|
|
57
|
+
````
|
|
58
|
+
|
|
59
|
+
## Registering pages in nav.yml
|
|
60
|
+
|
|
61
|
+
Every page must be listed in `nav/nav.yml` to appear in navigation:
|
|
62
|
+
|
|
63
|
+
```yaml
|
|
64
|
+
nav:
|
|
65
|
+
- label: "My Section"
|
|
66
|
+
dropdown: true
|
|
67
|
+
items:
|
|
68
|
+
- label: "My Page"
|
|
69
|
+
slug: /my-section/my-page # URL served at this path
|
|
70
|
+
file: content/my-section/my-page.mdx # source file
|
|
71
|
+
roles: [] # [] = public; ["admin"] = admin-only
|
|
72
|
+
```
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { defineConfig, globalIgnores } from "eslint/config";
|
|
2
|
+
import nextVitals from "eslint-config-next/core-web-vitals";
|
|
3
|
+
import nextTs from "eslint-config-next/typescript";
|
|
4
|
+
|
|
5
|
+
const eslintConfig = defineConfig([
|
|
6
|
+
...nextVitals,
|
|
7
|
+
...nextTs,
|
|
8
|
+
// Override default ignores of eslint-config-next.
|
|
9
|
+
globalIgnores([
|
|
10
|
+
// Default ignores of eslint-config-next:
|
|
11
|
+
".next/**",
|
|
12
|
+
"out/**",
|
|
13
|
+
"build/**",
|
|
14
|
+
"next-env.d.ts",
|
|
15
|
+
]),
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
export default eslintConfig;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { SessionUser } from "@/lib/auth"
|
|
2
|
+
|
|
3
|
+
/** Demo personas for local development — simulates what an IdP returns after OIDC login. */
|
|
4
|
+
export const DEMO_USERS: Record<string, SessionUser> = {
|
|
5
|
+
reader: {
|
|
6
|
+
name: "Alex Reader",
|
|
7
|
+
email: "alex@example.com",
|
|
8
|
+
roles: [],
|
|
9
|
+
},
|
|
10
|
+
editor: {
|
|
11
|
+
name: "Sam Editor",
|
|
12
|
+
email: "sam@example.com",
|
|
13
|
+
roles: ["editor"],
|
|
14
|
+
},
|
|
15
|
+
admin: {
|
|
16
|
+
name: "Taylor Admin",
|
|
17
|
+
email: "taylor@example.com",
|
|
18
|
+
roles: ["editor", "admin"],
|
|
19
|
+
},
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function getDemoUser(persona: string): SessionUser | null {
|
|
23
|
+
return DEMO_USERS[persona] ?? null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const DEMO_PERSONAS = [
|
|
27
|
+
{
|
|
28
|
+
id: "reader",
|
|
29
|
+
label: "Reader",
|
|
30
|
+
description: "Public docs only — no special roles",
|
|
31
|
+
badge: "public",
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: "editor",
|
|
35
|
+
label: "Editor",
|
|
36
|
+
description: "Access to editor-only guides and internal docs",
|
|
37
|
+
badge: "editor",
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: "admin",
|
|
41
|
+
label: "Admin",
|
|
42
|
+
description: "Full access including admin-only pages",
|
|
43
|
+
badge: "admin",
|
|
44
|
+
},
|
|
45
|
+
] as const
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { createRemoteJWKSet, jwtVerify } from "jose"
|
|
2
|
+
import { getConfig } from "@/lib/config"
|
|
3
|
+
import { extractRoles } from "@/lib/auth-roles"
|
|
4
|
+
import type { SessionUser } from "@/lib/auth"
|
|
5
|
+
|
|
6
|
+
type OidcDiscovery = {
|
|
7
|
+
authorization_endpoint: string
|
|
8
|
+
token_endpoint: string
|
|
9
|
+
jwks_uri: string
|
|
10
|
+
issuer: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let _discovery: OidcDiscovery | null = null
|
|
14
|
+
let _jwks: ReturnType<typeof createRemoteJWKSet> | null = null
|
|
15
|
+
|
|
16
|
+
async function getDiscovery(): Promise<OidcDiscovery> {
|
|
17
|
+
if (_discovery) return _discovery
|
|
18
|
+
const { issuer } = getConfig().auth.oidc
|
|
19
|
+
if (!issuer) throw new Error("OIDC issuer is not configured")
|
|
20
|
+
|
|
21
|
+
const res = await fetch(`${issuer.replace(/\/$/, "")}/.well-known/openid-configuration`)
|
|
22
|
+
if (!res.ok) throw new Error(`OIDC discovery failed: ${res.status}`)
|
|
23
|
+
_discovery = (await res.json()) as OidcDiscovery
|
|
24
|
+
return _discovery
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getJwks(jwksUri: string) {
|
|
28
|
+
if (!_jwks) _jwks = createRemoteJWKSet(new URL(jwksUri))
|
|
29
|
+
return _jwks
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function getOidcAuthorizationUrl(returnTo: string, state: string): Promise<string> {
|
|
33
|
+
const { oidc } = getConfig().auth
|
|
34
|
+
const { url } = getConfig()
|
|
35
|
+
const discovery = await getDiscovery()
|
|
36
|
+
|
|
37
|
+
const redirectUri = `${url.replace(/\/$/, "")}/api/auth/callback`
|
|
38
|
+
const params = new URLSearchParams({
|
|
39
|
+
client_id: oidc.clientId,
|
|
40
|
+
redirect_uri: redirectUri,
|
|
41
|
+
response_type: "code",
|
|
42
|
+
scope: "openid profile email",
|
|
43
|
+
state: `${state}:${encodeURIComponent(returnTo)}`,
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
return `${discovery.authorization_endpoint}?${params}`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function exchangeCodeForSession(code: string): Promise<SessionUser> {
|
|
50
|
+
const { oidc } = getConfig().auth
|
|
51
|
+
const { url } = getConfig()
|
|
52
|
+
const discovery = await getDiscovery()
|
|
53
|
+
const redirectUri = `${url.replace(/\/$/, "")}/api/auth/callback`
|
|
54
|
+
|
|
55
|
+
const body = new URLSearchParams({
|
|
56
|
+
grant_type: "authorization_code",
|
|
57
|
+
code,
|
|
58
|
+
redirect_uri: redirectUri,
|
|
59
|
+
client_id: oidc.clientId,
|
|
60
|
+
client_secret: oidc.clientSecret,
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
const tokenRes = await fetch(discovery.token_endpoint, {
|
|
64
|
+
method: "POST",
|
|
65
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
66
|
+
body,
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
if (!tokenRes.ok) {
|
|
70
|
+
throw new Error(`OIDC token exchange failed: ${tokenRes.status}`)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const tokens = (await tokenRes.json()) as { id_token?: string }
|
|
74
|
+
if (!tokens.id_token) throw new Error("OIDC response missing id_token")
|
|
75
|
+
|
|
76
|
+
const jwks = getJwks(discovery.jwks_uri)
|
|
77
|
+
const { payload } = await jwtVerify(tokens.id_token, jwks, {
|
|
78
|
+
issuer: discovery.issuer,
|
|
79
|
+
audience: oidc.clientId,
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
const claims = payload as Record<string, unknown>
|
|
83
|
+
const roles = extractRoles(claims, oidc)
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
name: (claims.name as string) ?? (claims.preferred_username as string) ?? "User",
|
|
87
|
+
email: (claims.email as string) ?? "",
|
|
88
|
+
roles,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function isOidcConfigured(): boolean {
|
|
93
|
+
const { oidc } = getConfig().auth
|
|
94
|
+
return Boolean(oidc.issuer && oidc.clientId && oidc.clientSecret)
|
|
95
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { OidcConfig } from "./config-types"
|
|
2
|
+
|
|
3
|
+
/** Read a nested claim path like "realm_access.roles" from a JWT payload. */
|
|
4
|
+
export function getClaimValue(claims: Record<string, unknown>, path: string): unknown {
|
|
5
|
+
const parts = path.split(".")
|
|
6
|
+
let current: unknown = claims
|
|
7
|
+
for (const part of parts) {
|
|
8
|
+
if (current == null || typeof current !== "object") return undefined
|
|
9
|
+
current = (current as Record<string, unknown>)[part]
|
|
10
|
+
}
|
|
11
|
+
return current
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function extractRoles(
|
|
15
|
+
claims: Record<string, unknown>,
|
|
16
|
+
oidc: Pick<OidcConfig, "rolesClaim" | "roleMapping">
|
|
17
|
+
): string[] {
|
|
18
|
+
const raw = getClaimValue(claims, oidc.rolesClaim)
|
|
19
|
+
const fromToken = Array.isArray(raw)
|
|
20
|
+
? raw.filter((r): r is string => typeof r === "string")
|
|
21
|
+
: typeof raw === "string"
|
|
22
|
+
? [raw]
|
|
23
|
+
: []
|
|
24
|
+
|
|
25
|
+
if (Object.keys(oidc.roleMapping).length === 0) return fromToken
|
|
26
|
+
|
|
27
|
+
return fromToken.map((r) => oidc.roleMapping[r] ?? r)
|
|
28
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { SignJWT, jwtVerify } from "jose"
|
|
2
|
+
import { cookies } from "next/headers"
|
|
3
|
+
import { getAuthConfig, isAuthEnabled } from "@/lib/config"
|
|
4
|
+
|
|
5
|
+
export type SessionUser = {
|
|
6
|
+
name: string
|
|
7
|
+
email: string
|
|
8
|
+
roles: string[]
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const COOKIE_NAME = "camelmind_session"
|
|
12
|
+
|
|
13
|
+
const OFFLINE_SESSION: SessionUser = {
|
|
14
|
+
name: "Offline User",
|
|
15
|
+
email: "",
|
|
16
|
+
roles: ["editor", "admin"],
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getSecret(): Uint8Array {
|
|
20
|
+
const secret = process.env.SESSION_SECRET
|
|
21
|
+
if (!secret && process.env.OFFLINE_MODE !== "true" && isAuthEnabled()) {
|
|
22
|
+
throw new Error("SESSION_SECRET environment variable is not set")
|
|
23
|
+
}
|
|
24
|
+
return new TextEncoder().encode(secret ?? "offline-mode-placeholder")
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function createSession(user: SessionUser): Promise<string> {
|
|
28
|
+
return new SignJWT({ ...user })
|
|
29
|
+
.setProtectedHeader({ alg: "HS256" })
|
|
30
|
+
.setExpirationTime("8h")
|
|
31
|
+
.sign(getSecret())
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function getSession(): Promise<SessionUser | null> {
|
|
35
|
+
if (process.env.OFFLINE_MODE === "true") return OFFLINE_SESSION
|
|
36
|
+
if (!isAuthEnabled()) return null
|
|
37
|
+
|
|
38
|
+
const cookieStore = await cookies()
|
|
39
|
+
const token = cookieStore.get(COOKIE_NAME)?.value
|
|
40
|
+
if (!token) return null
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const { payload } = await jwtVerify(token, getSecret())
|
|
44
|
+
return {
|
|
45
|
+
name: payload.name as string,
|
|
46
|
+
email: payload.email as string,
|
|
47
|
+
roles: payload.roles as string[],
|
|
48
|
+
}
|
|
49
|
+
} catch {
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function hasAccess(requiredRoles: string[], userRoles: string[]): boolean {
|
|
55
|
+
if (requiredRoles.length === 0) return true
|
|
56
|
+
return requiredRoles.some((r) => userRoles.includes(r))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Whether a page with these nav roles requires the user to sign in. */
|
|
60
|
+
export function pageRequiresAuth(requiredRoles: string[]): boolean {
|
|
61
|
+
if (!isAuthEnabled()) return false
|
|
62
|
+
const { requireLogin } = getAuthConfig()
|
|
63
|
+
if (requireLogin) return true
|
|
64
|
+
return requiredRoles.length > 0
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function shouldRedirectToLogin(
|
|
68
|
+
requiredRoles: string[],
|
|
69
|
+
session: SessionUser | null
|
|
70
|
+
): boolean {
|
|
71
|
+
if (!pageRequiresAuth(requiredRoles)) return false
|
|
72
|
+
if (!session) return true
|
|
73
|
+
return !hasAccess(requiredRoles, session.roles)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export { getSecret }
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export type AuthProvider = "dev-mock" | "oidc"
|
|
2
|
+
|
|
3
|
+
export type OidcConfig = {
|
|
4
|
+
issuer: string
|
|
5
|
+
clientId: string
|
|
6
|
+
clientSecret: string
|
|
7
|
+
rolesClaim: string
|
|
8
|
+
roleMapping: Record<string, string>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type AuthConfig = {
|
|
12
|
+
enabled: boolean
|
|
13
|
+
requireLogin: boolean
|
|
14
|
+
provider: AuthProvider
|
|
15
|
+
oidc: OidcConfig
|
|
16
|
+
publicPaths: string[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type CamelMindConfig = {
|
|
20
|
+
title: string
|
|
21
|
+
tagline: string
|
|
22
|
+
url: string
|
|
23
|
+
contentDir: string
|
|
24
|
+
navFile: string
|
|
25
|
+
versionsFile: string
|
|
26
|
+
auth: AuthConfig
|
|
27
|
+
links: {
|
|
28
|
+
github?: string
|
|
29
|
+
}
|
|
30
|
+
}
|