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.
Files changed (73) hide show
  1. package/dist/index.js +142 -0
  2. package/package.json +51 -0
  3. package/template/_env.example +16 -0
  4. package/template/_gitignore +32 -0
  5. package/template/_package.json +39 -0
  6. package/template/app/[...slug]/page.tsx +150 -0
  7. package/template/app/api/auth/callback/route.ts +47 -0
  8. package/template/app/api/auth/login/route.ts +39 -0
  9. package/template/app/api/auth/logout/route.ts +8 -0
  10. package/template/app/api/auth/signin/route.ts +64 -0
  11. package/template/app/api/download/route.ts +67 -0
  12. package/template/app/api/raw/route.ts +43 -0
  13. package/template/app/api/search/route.ts +138 -0
  14. package/template/app/favicon.ico +0 -0
  15. package/template/app/globals.css +524 -0
  16. package/template/app/home/page.tsx +136 -0
  17. package/template/app/icon.svg +29 -0
  18. package/template/app/layout.tsx +44 -0
  19. package/template/app/login/LoginForm.tsx +105 -0
  20. package/template/app/login/page.tsx +9 -0
  21. package/template/app/page.tsx +5 -0
  22. package/template/camelmind.config.ts +33 -0
  23. package/template/components/Breadcrumbs/Breadcrumbs.tsx +41 -0
  24. package/template/components/Code/CodeBlock.tsx +55 -0
  25. package/template/components/DocActions/DocActions.tsx +62 -0
  26. package/template/components/Nav/MobileDrawer.tsx +221 -0
  27. package/template/components/Nav/ThemeToggle.tsx +62 -0
  28. package/template/components/Nav/TopNav.tsx +173 -0
  29. package/template/components/Nav/VersionSelector.tsx +107 -0
  30. package/template/components/PageNav/PageNav.tsx +68 -0
  31. package/template/components/Search/SearchModal.tsx +278 -0
  32. package/template/components/SectionCards/SectionCards.tsx +33 -0
  33. package/template/components/Sidebar/Sidebar.tsx +196 -0
  34. package/template/components/Toc/Toc.tsx +57 -0
  35. package/template/components/ZoomImages/ZoomImages.tsx +24 -0
  36. package/template/components/mdx/Callout.tsx +43 -0
  37. package/template/components/mdx/Details.tsx +65 -0
  38. package/template/components/mdx/Icon.tsx +25 -0
  39. package/template/components/mdx/Steps.tsx +33 -0
  40. package/template/components/mdx/Tabs.tsx +69 -0
  41. package/template/components/mdx/index.tsx +34 -0
  42. package/template/content/getting-started/installation.mdx +51 -0
  43. package/template/content/getting-started/overview.mdx +39 -0
  44. package/template/content/guides/writing-docs.mdx +72 -0
  45. package/template/eslint.config.mjs +18 -0
  46. package/template/lib/auth-providers/dev-mock.ts +45 -0
  47. package/template/lib/auth-providers/oidc.ts +95 -0
  48. package/template/lib/auth-roles.ts +28 -0
  49. package/template/lib/auth.ts +76 -0
  50. package/template/lib/config-types.ts +30 -0
  51. package/template/lib/config.ts +29 -0
  52. package/template/lib/mdx.ts +53 -0
  53. package/template/lib/nav-types.ts +31 -0
  54. package/template/lib/nav.ts +125 -0
  55. package/template/lib/versions.ts +61 -0
  56. package/template/nav/nav.yml +20 -0
  57. package/template/next.config.ts +35 -0
  58. package/template/postcss.config.mjs +7 -0
  59. package/template/proxy.ts +50 -0
  60. package/template/public/2f-logo.png +0 -0
  61. package/template/public/favicon.png +0 -0
  62. package/template/public/file.svg +1 -0
  63. package/template/public/globe.svg +1 -0
  64. package/template/public/images/gwa-app-central-main.png +0 -0
  65. package/template/public/next.svg +1 -0
  66. package/template/public/window.svg +1 -0
  67. package/template/scripts/build-offline.sh +189 -0
  68. package/template/scripts/build-pdf.sh +41 -0
  69. package/template/scripts/build-search-index.ts +90 -0
  70. package/template/scripts/generate-master-pdf.ts +260 -0
  71. package/template/scripts/generate-pdfs.ts +185 -0
  72. package/template/tsconfig.json +34 -0
  73. 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
+ }