diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml deleted file mode 100644 index a73008d..0000000 --- a/.github/workflows/deploy.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: Deploy to GitHub Pages - -on: - push: - branches: - - main - -jobs: - build: - name: Build Fumadocs - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - uses: actions/setup-node@v5 - with: - node-version: "24" - - - name: Setup bun - uses: oven-sh/setup-bun@v2 - - - name: Cache bun dependencies - uses: actions/cache@v4 - with: - path: ~/.bun/install/cache - key: ${{ runner.os }}-bun-web-${{ hashFiles('bun.lock') }} - restore-keys: | - ${{ runner.os }}-bun-web- - - - name: Install dependencies - run: bun install --frozen-lockfile - - - name: Build website - run: bun run build - - - name: Upload Build Artifact - uses: actions/upload-pages-artifact@v3 - with: - path: build/client - - deploy: - name: Deploy to GitHub Pages - needs: build - permissions: - pages: write - id-token: write - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - runs-on: ubuntu-latest - steps: - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 6b6716d..6240da8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,20 +1,21 @@ -# Dependencies -/node_modules +# build output +dist/ +# generated types +.astro/ -# Production -/build - -# Generated files -.react-router -.cache-loader - -# Misc -.DS_Store -.env.local -.env.development.local -.env.test.local -.env.production.local +# dependencies +node_modules/ +# logs npm-debug.log* yarn-debug.log* yarn-error.log* +pnpm-debug.log* + + +# environment variables +.env +.env.production + +# macOS-specific files +.DS_Store diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index dbe41a6..0000000 --- a/.prettierignore +++ /dev/null @@ -1,5 +0,0 @@ -# React router build output -.react-router - -# Node modules -node_modules \ No newline at end of file diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index 0967ef4..0000000 --- a/.prettierrc +++ /dev/null @@ -1 +0,0 @@ -{} diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index b9556ba..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "cSpell.enabled": true, - "cSpell.words": [ - "appstore", - "Healthcheck", - "homelab", - "homeserver", - "Runtipi", - "Tinyauth", - "TOTP", - "Traefik" - ] -} diff --git a/app/app.css b/app/app.css deleted file mode 100644 index 50b3bc2..0000000 --- a/app/app.css +++ /dev/null @@ -1,3 +0,0 @@ -@import 'tailwindcss'; -@import 'fumadocs-ui/css/neutral.css'; -@import 'fumadocs-ui/css/preset.css'; diff --git a/app/cli.json b/app/cli.json deleted file mode 100644 index 1584439..0000000 --- a/app/cli.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "aliases": { - "uiDir": "./components/ui", - "componentsDir": "./components", - "blockDir": "./components", - "cssDir": "./styles", - "libDir": "./lib" - }, - "baseDir": "", - "commands": {} -} \ No newline at end of file diff --git a/app/components/card.tsx b/app/components/card.tsx deleted file mode 100644 index 83fb87d..0000000 --- a/app/components/card.tsx +++ /dev/null @@ -1,24 +0,0 @@ -interface FeatureCardProps { - title: string; - description: string; - icon?: React.ReactNode; - children?: React.ReactNode; -} - -export const Card = ({ - title, - description, - icon, - children, -}: FeatureCardProps) => { - return ( -
- {icon} -
-

{title}

-

{description}

-
- {children} -
- ); -}; diff --git a/app/components/countup.tsx b/app/components/countup.tsx deleted file mode 100644 index 4054c03..0000000 --- a/app/components/countup.tsx +++ /dev/null @@ -1,125 +0,0 @@ -// https://reactbits.dev/text-animations/count-up - -import { useEffect, useRef } from "react"; -import { useInView, useMotionValue, useSpring } from "motion/react"; - -interface CountUpProps { - to: number; - from?: number; - direction?: "up" | "down"; - delay?: number; - duration?: number; - className?: string; - startWhen?: boolean; - separator?: string; - onStart?: () => void; - onEnd?: () => void; -} - -export default function CountUp({ - to, - from = 0, - direction = "up", - delay = 0, - duration = 2, - className = "", - startWhen = true, - separator = "", - onStart, - onEnd, -}: CountUpProps) { - const ref = useRef(null); - const motionValue = useMotionValue(direction === "down" ? to : from); - - const damping = 20 + 40 * (1 / duration); - const stiffness = 100 * (1 / duration); - - const springValue = useSpring(motionValue, { - damping, - stiffness, - }); - - const isInView = useInView(ref, { once: true, margin: "0px" }); - - const getDecimalPlaces = (num: number): number => { - const str = num.toString(); - if (str.includes(".")) { - const decimals = str.split(".")[1]; - if (parseInt(decimals) !== 0) { - return decimals.length; - } - } - return 0; - }; - - const maxDecimals = Math.max(getDecimalPlaces(from), getDecimalPlaces(to)); - - useEffect(() => { - if (ref.current) { - ref.current.textContent = String(direction === "down" ? to : from); - } - }, [from, to, direction]); - - useEffect(() => { - if (isInView && startWhen) { - if (typeof onStart === "function") { - onStart(); - } - - const timeoutId = setTimeout(() => { - motionValue.set(direction === "down" ? from : to); - }, delay * 1000); - - const durationTimeoutId = setTimeout( - () => { - if (typeof onEnd === "function") { - onEnd(); - } - }, - delay * 1000 + duration * 1000, - ); - - return () => { - clearTimeout(timeoutId); - clearTimeout(durationTimeoutId); - }; - } - }, [ - isInView, - startWhen, - motionValue, - direction, - from, - to, - delay, - onStart, - onEnd, - duration, - ]); - - useEffect(() => { - const unsubscribe = springValue.on("change", (latest) => { - if (ref.current) { - const hasDecimals = maxDecimals > 0; - - const options: Intl.NumberFormatOptions = { - useGrouping: !!separator, - minimumFractionDigits: hasDecimals ? maxDecimals : 0, - maximumFractionDigits: hasDecimals ? maxDecimals : 0, - }; - - const formattedNumber = Intl.NumberFormat("en-US", options).format( - latest, - ); - - ref.current.textContent = separator - ? formattedNumber.replace(/,/g, separator) - : formattedNumber; - } - }); - - return () => unsubscribe(); - }, [springValue, separator, maxDecimals]); - - return ; -} diff --git a/app/components/discord.tsx b/app/components/discord.tsx deleted file mode 100644 index 20b2bcb..0000000 --- a/app/components/discord.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import type { SVGProps } from "react"; - -export function IcBaselineDiscord(props: SVGProps) { - return ( - - - - ); -} diff --git a/app/components/github.tsx b/app/components/github.tsx deleted file mode 100644 index 9be446d..0000000 --- a/app/components/github.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import type { SVGProps } from "react"; - -export function MdiGithub(props: SVGProps) { - return ( - - - - ); -} diff --git a/app/components/language-toggle.tsx b/app/components/language-toggle.tsx deleted file mode 100644 index 40a5443..0000000 --- a/app/components/language-toggle.tsx +++ /dev/null @@ -1,68 +0,0 @@ -'use client'; -import { type ButtonHTMLAttributes, type HTMLAttributes } from 'react'; -import { useI18n } from 'fumadocs-ui/contexts/i18n'; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from './ui/popover'; -import { cn } from '../lib/cn'; -import { buttonVariants } from './ui/button'; - -export type LanguageSelectProps = ButtonHTMLAttributes; - -export function LanguageToggle(props: LanguageSelectProps): React.ReactElement { - const context = useI18n(); - if (!context.locales) throw new Error('Missing ``'); - - return ( - - - {props.children} - - -

- {context.text.chooseLanguage} -

- {context.locales.map((item) => ( - - ))} -
-
- ); -} - -export function LanguageToggleText( - props: HTMLAttributes, -): React.ReactElement { - const context = useI18n(); - const text = context.locales?.find( - (item) => item.locale === context.locale, - )?.name; - - return {text}; -} diff --git a/app/components/layout/docs/client.tsx b/app/components/layout/docs/client.tsx deleted file mode 100644 index 3a7183a..0000000 --- a/app/components/layout/docs/client.tsx +++ /dev/null @@ -1,81 +0,0 @@ -'use client'; - -import { Sidebar as SidebarIcon } from 'lucide-react'; -import { type ComponentProps } from 'react'; -import { cn } from '../../../lib/cn'; -import { buttonVariants } from '../../ui/button'; -import { useSidebar } from 'fumadocs-ui/contexts/sidebar'; -import { useNav } from 'fumadocs-ui/contexts/layout'; -import { SidebarCollapseTrigger } from '../../sidebar'; -import { SearchToggle } from '../../search-toggle'; - -export function Navbar(props: ComponentProps<'header'>) { - const { isTransparent } = useNav(); - - return ( -
- {props.children} -
- ); -} - -export function LayoutBody(props: ComponentProps<'main'>) { - const { collapsed } = useSidebar(); - - return ( -
- {props.children} -
- ); -} - -export function CollapsibleControl() { - const { collapsed } = useSidebar(); - - return ( -
- - - - -
- ); -} diff --git a/app/components/layout/docs/index.tsx b/app/components/layout/docs/index.tsx deleted file mode 100644 index 6a9e53f..0000000 --- a/app/components/layout/docs/index.tsx +++ /dev/null @@ -1,385 +0,0 @@ -import type { PageTree } from "fumadocs-core/server"; -import { - type ComponentProps, - type HTMLAttributes, - type ReactNode, - useMemo, -} from "react"; -import { Languages, Sidebar as SidebarIcon } from "lucide-react"; -import { cn } from "../../../lib/cn"; -import { buttonVariants } from "../../ui/button"; -import { - Sidebar, - SidebarCollapseTrigger, - type SidebarComponents, - SidebarContent, - SidebarContentMobile, - SidebarFolder, - SidebarFolderContent, - SidebarFolderLink, - SidebarFolderTrigger, - SidebarFooter, - SidebarHeader, - SidebarItem, - SidebarPageTree, - type SidebarProps, - SidebarTrigger, - SidebarViewport, -} from "../../sidebar"; -import { type Option, RootToggle } from "../../root-toggle"; -import { - type BaseLayoutProps, - BaseLinkItem, - getLinks, - type IconItemType, - type LinkItemType, -} from "../shared/index"; -import { LanguageToggle, LanguageToggleText } from "../../language-toggle"; -import { CollapsibleControl, LayoutBody, Navbar } from "./client"; -import { TreeContextProvider } from "fumadocs-ui/contexts/tree"; -import { ThemeToggle } from "../../theme-toggle"; -import { NavProvider } from "fumadocs-ui/contexts/layout"; -import Link from "fumadocs-core/link"; -import { LargeSearchToggle, SearchToggle } from "../../search-toggle"; -import { HideIfEmpty } from "fumadocs-core/hide-if-empty"; -import { - getSidebarTabs, - type GetSidebarTabsOptions, -} from "fumadocs-ui/utils/get-sidebar-tabs"; -import { MdiGithub } from "@/components/github"; - -const githubLink: string = "https://github.com/steveiliop56/tinyauth"; - -export interface DocsLayoutProps extends BaseLayoutProps { - tree: PageTree.Root; - - sidebar?: SidebarOptions; - - /** - * Props for the `div` container - */ - containerProps?: HTMLAttributes; -} - -interface SidebarOptions - extends ComponentProps<"aside">, - Pick { - enabled?: boolean; - component?: ReactNode; - components?: Partial; - - /** - * Root Toggle options - */ - tabs?: Option[] | GetSidebarTabsOptions | false; - - banner?: ReactNode; - footer?: ReactNode; - - /** - * Support collapsing the sidebar on desktop mode - * - * @defaultValue true - */ - collapsible?: boolean; -} - -export function DocsLayout({ - nav: { transparentMode, ...nav } = {}, - sidebar: { - tabs: sidebarTabs, - enabled: sidebarEnabled = true, - ...sidebarProps - } = {}, - searchToggle = {}, - disableThemeSwitch = false, - themeSwitch = { enabled: !disableThemeSwitch }, - i18n = false, - children, - ...props -}: DocsLayoutProps) { - const tabs = useMemo(() => { - if (Array.isArray(sidebarTabs)) { - return sidebarTabs; - } - if (typeof sidebarTabs === "object") { - return getSidebarTabs(props.tree, sidebarTabs); - } - if (sidebarTabs !== false) { - return getSidebarTabs(props.tree); - } - return []; - }, [sidebarTabs, props.tree]); - const links = getLinks(props.links ?? [], props.githubUrl); - const sidebarVariables = cn( - "md:[--fd-sidebar-width:268px] lg:[--fd-sidebar-width:286px]" - ); - - function sidebar() { - const { - footer, - banner, - collapsible = true, - component, - components, - defaultOpenLevel, - prefetch, - ...rest - } = sidebarProps; - if (component) return component; - - const iconLinks = links.filter( - (item): item is IconItemType => item.type === "icon" - ); - - const viewport = ( - - {links - .filter((v) => v.type !== "icon") - .map((item, i, list) => ( - - ))} - - - ); - - const mobile = ( - - -
-
- {iconLinks.map((item, i) => ( - - {item.icon} - - ))} -
- {i18n ? ( - - - - - ) : null} - {themeSwitch.enabled !== false && - (themeSwitch.component ?? ( - - ))} - - - - - - -
- {tabs.length > 0 && } - {banner} -
- {viewport} - {footer} -
- ); - - const content = ( - - -
- Tinyauth - - {nav.title} - - {nav.children} - {collapsible && ( - - - - )} -
- {searchToggle.enabled !== false && - (searchToggle.components?.lg ?? ( - - ))} - {tabs.length > 0 && } - - {banner} -
- {viewport} - -
- - - - {i18n ? ( - - - - ) : null} - {iconLinks.map((item, i) => ( - - {item.icon} - - ))} - {themeSwitch.enabled !== false && - (themeSwitch.component ?? ( - - ))} -
- {footer} -
-
- ); - - return ( - - {collapsible && } - {content} - - } - /> - ); - } - - return ( - - - {nav.enabled !== false && - (nav.component ?? ( - - Tinyauth - - {nav.title} - -
{nav.children}
- {searchToggle.enabled !== false && - (searchToggle.components?.sm ?? ( - - ))} - {sidebarEnabled && ( - - - - )} -
- ))} - - {sidebarEnabled && sidebar()} - {children} - -
-
- ); -} - -function SidebarLinkItem({ - item, - ...props -}: { - item: Exclude; - className?: string; -}) { - if (item.type === "menu") - return ( - - {item.url ? ( - - {item.icon} - {item.text} - - ) : ( - - {item.icon} - {item.text} - - )} - - {item.items.map((child, i) => ( - - ))} - - - ); - - if (item.type === "custom") return
{item.children}
; - - return ( - - {item.text} - - ); -} - -export { CollapsibleControl, Navbar, SidebarTrigger, type LinkItemType }; diff --git a/app/components/layout/docs/page-client.tsx b/app/components/layout/docs/page-client.tsx deleted file mode 100644 index 39d90df..0000000 --- a/app/components/layout/docs/page-client.tsx +++ /dev/null @@ -1,417 +0,0 @@ -'use client'; - -import { - type ComponentProps, - Fragment, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; -import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react'; -import Link from 'fumadocs-core/link'; -import { cn } from '../../../lib/cn'; -import { useI18n } from 'fumadocs-ui/contexts/i18n'; -import { useTreeContext, useTreePath } from 'fumadocs-ui/contexts/tree'; -import type { PageTree } from 'fumadocs-core/server'; -import { createContext, usePathname } from 'fumadocs-core/framework'; -import { - type BreadcrumbOptions, - getBreadcrumbItemsFromPath, -} from 'fumadocs-core/breadcrumb'; -import { useNav } from 'fumadocs-ui/contexts/layout'; -import { isActive } from '../../../lib/is-active'; -import { useEffectEvent } from 'fumadocs-core/utils/use-effect-event'; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from '../../ui/collapsible'; -import { useSidebar } from 'fumadocs-ui/contexts/sidebar'; -import { useTOCItems } from '../../ui/toc'; -import { useActiveAnchor } from 'fumadocs-core/toc'; - -const TocPopoverContext = createContext<{ - open: boolean; - setOpen: (open: boolean) => void; -}>('TocPopoverContext'); - -export function PageTOCPopoverTrigger(props: ComponentProps<'button'>) { - const { text } = useI18n(); - const { open } = TocPopoverContext.use(); - const items = useTOCItems(); - const active = useActiveAnchor(); - const selected = useMemo( - () => items.findIndex((item) => active === item.url.slice(1)), - [items, active], - ); - const path = useTreePath().at(-1); - const showItem = selected !== -1 && !open; - - return ( - - - - - {path?.name ?? text.toc} - - - {items[selected]?.title} - - - - - ); -} - -interface ProgressCircleProps - extends Omit, 'strokeWidth'> { - value: number; - strokeWidth?: number; - size?: number; - min?: number; - max?: number; -} - -function clamp(input: number, min: number, max: number): number { - if (input < min) return min; - if (input > max) return max; - return input; -} - -function ProgressCircle({ - value, - strokeWidth = 2, - size = 24, - min = 0, - max = 100, - ...restSvgProps -}: ProgressCircleProps) { - const normalizedValue = clamp(value, min, max); - const radius = (size - strokeWidth) / 2; - const circumference = 2 * Math.PI * radius; - const progress = (normalizedValue / max) * circumference; - const circleProps = { - cx: size / 2, - cy: size / 2, - r: radius, - fill: 'none', - strokeWidth, - }; - - return ( - - - - - ); -} - -export function PageTOCPopoverContent(props: ComponentProps<'div'>) { - return ( - - {props.children} - - ); -} - -export function PageTOCPopover(props: ComponentProps<'div'>) { - const ref = useRef(null); - const [open, setOpen] = useState(false); - const { collapsed } = useSidebar(); - const { isTransparent } = useNav(); - - const onClick = useEffectEvent((e: Event) => { - if (!open) return; - - if (ref.current && !ref.current.contains(e.target as HTMLElement)) - setOpen(false); - }); - - useEffect(() => { - window.addEventListener('click', onClick); - - return () => { - window.removeEventListener('click', onClick); - }; - }, [onClick]); - - return ( - ({ - open, - setOpen, - }), - [setOpen, open], - )} - > - -
- {props.children} -
-
-
- ); -} - -export function PageLastUpdate({ - date: value, - ...props -}: Omit, 'children'> & { date: Date | string }) { - const { text } = useI18n(); - const [date, setDate] = useState(''); - - useEffect(() => { - // to the timezone of client - setDate(new Date(value).toLocaleDateString()); - }, [value]); - - return ( -

- {text.lastUpdate} {date} -

- ); -} - -type Item = Pick; -export interface FooterProps extends ComponentProps<'div'> { - /** - * Items including information for the next and previous page - */ - items?: { - previous?: Item; - next?: Item; - }; -} - -function scanNavigationList(tree: PageTree.Node[]) { - const list: PageTree.Item[] = []; - - tree.forEach((node) => { - if (node.type === 'folder') { - if (node.index) { - list.push(node.index); - } - - list.push(...scanNavigationList(node.children)); - return; - } - - if (node.type === 'page' && !node.external) { - list.push(node); - } - }); - - return list; -} - -const listCache = new Map(); - -export function PageFooter({ items, ...props }: FooterProps) { - const { root } = useTreeContext(); - const pathname = usePathname(); - - const { previous, next } = useMemo(() => { - if (items) return items; - - const cached = listCache.get(root.$id); - const list = cached ?? scanNavigationList(root.children); - listCache.set(root.$id, list); - - const idx = list.findIndex((item) => isActive(item.url, pathname, false)); - - if (idx === -1) return {}; - return { - previous: list[idx - 1], - next: list[idx + 1], - }; - }, [items, pathname, root]); - - return ( -
- {previous ? : null} - {next ? : null} -
- ); -} - -function FooterItem({ item, index }: { item: Item; index: 0 | 1 }) { - const { text } = useI18n(); - const Icon = index === 0 ? ChevronLeft : ChevronRight; - - return ( - -
- -

{item.name}

-
-

- {item.description ?? (index === 0 ? text.previousPage : text.nextPage)} -

- - ); -} - -export type BreadcrumbProps = BreadcrumbOptions & ComponentProps<'div'>; - -export function PageBreadcrumb({ - includeRoot, - includeSeparator, - includePage, - ...props -}: BreadcrumbProps) { - const path = useTreePath(); - const { root } = useTreeContext(); - const items = useMemo(() => { - return getBreadcrumbItemsFromPath(root, path, { - includePage, - includeSeparator, - includeRoot, - }); - }, [includePage, includeRoot, includeSeparator, path, root]); - - if (items.length === 0) return null; - - return ( -
- {items.map((item, i) => { - const className = cn( - 'truncate', - i === items.length - 1 && 'text-fd-primary font-medium', - ); - - return ( - - {i !== 0 && } - {item.url ? ( - - {item.name} - - ) : ( - {item.name} - )} - - ); - })} -
- ); -} - -export function PageTOC(props: ComponentProps<'div'>) { - const { collapsed } = useSidebar(); - const offset = collapsed ? '0px' : 'var(--fd-layout-offset)'; - - return ( -
-
- {props.children} -
-
- ); -} diff --git a/app/components/layout/docs/page.tsx b/app/components/layout/docs/page.tsx deleted file mode 100644 index 25b4219..0000000 --- a/app/components/layout/docs/page.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { type ComponentProps } from "react"; -import { cn } from "../../../lib/cn"; -import { - type BreadcrumbProps, - type FooterProps, - PageBreadcrumb, - PageFooter, - PageLastUpdate, - PageTOC, - PageTOCPopover, - PageTOCPopoverContent, - PageTOCPopoverTrigger, -} from "./page-client"; -import { TOCItems, TOCProvider, TOCScrollArea } from "../../ui/toc"; -import { Text } from "lucide-react"; -import { I18nLabel } from "fumadocs-ui/contexts/i18n"; -import ClerkTOCItems from "../../ui/toc-clerk"; -import type { AnchorProviderProps } from "fumadocs-core/toc"; - -export function PageTOCTitle(props: ComponentProps<"h2">) { - return ( -

- - -

- ); -} - -export function PageTOCItems({ - variant = "normal", - ...props -}: ComponentProps<"div"> & { variant?: "clerk" | "normal" }) { - return ( - - {variant === "clerk" ? : } - - ); -} - -export function PageTOCPopoverItems({ - variant = "normal", - ...props -}: ComponentProps<"div"> & { variant?: "clerk" | "normal" }) { - return ( - - {variant === "clerk" ? : } - - ); -} - -export function PageArticle(props: ComponentProps<"article">) { - return ( -
- {props.children} -
- ); -} - -export interface RootProps extends ComponentProps<"div"> { - toc?: Omit | false; -} - -export function PageRoot({ toc = false, children, ...props }: RootProps) { - const content = ( -
- {children} -
- ); - - if (toc) return {content}; - return content; -} - -export { - PageBreadcrumb, - PageFooter, - PageLastUpdate, - PageTOC, - PageTOCPopover, - PageTOCPopoverTrigger, - PageTOCPopoverContent, - type FooterProps, - type BreadcrumbProps, -}; diff --git a/app/components/layout/home/index.tsx b/app/components/layout/home/index.tsx deleted file mode 100644 index 8cce0f0..0000000 --- a/app/components/layout/home/index.tsx +++ /dev/null @@ -1,275 +0,0 @@ -import { Fragment, type HTMLAttributes, useMemo } from "react"; -import { cn } from "../../../lib/cn"; -import { - type BaseLayoutProps, - getLinks, - type LinkItemType, - type NavOptions, -} from "../shared/index"; -import { NavProvider } from "fumadocs-ui/contexts/layout"; -import { - Navbar, - NavbarLink, - NavbarMenu, - NavbarMenuContent, - NavbarMenuLink, - NavbarMenuTrigger, -} from "./navbar"; -import { LargeSearchToggle, SearchToggle } from "../../search-toggle"; -import { ThemeToggle } from "../../theme-toggle"; -import { LanguageToggle, LanguageToggleText } from "../../language-toggle"; -import { ChevronDown, Languages } from "lucide-react"; -import Link from "fumadocs-core/link"; -import { Menu, MenuContent, MenuLinkItem, MenuTrigger } from "./menu"; -import { buttonVariants } from "../../ui/button"; -import { MdiGithub } from "@/components/github"; - -const githubLink: string = "https://github.com/steveiliop56/tinyauth"; - -export interface HomeLayoutProps extends BaseLayoutProps { - nav?: Partial< - NavOptions & { - /** - * Open mobile menu when hovering the trigger - */ - enableHoverToOpen?: boolean; - } - >; -} - -export function HomeLayout( - props: HomeLayoutProps & HTMLAttributes -) { - const { - nav = {}, - links, - githubUrl, - i18n, - disableThemeSwitch = false, - themeSwitch = { enabled: !disableThemeSwitch }, - searchToggle, - ...rest - } = props; - - return ( - -
- {nav.enabled !== false && - (nav.component ?? ( -
- ))} - {props.children} -
-
- ); -} - -export function Header({ - nav = {}, - i18n = false, - links, - githubUrl, - themeSwitch = {}, - searchToggle = {}, -}: HomeLayoutProps) { - const finalLinks = useMemo( - () => getLinks(links, githubUrl), - [links, githubUrl] - ); - - const navItems = finalLinks.filter((item) => - ["nav", "all"].includes(item.on ?? "all") - ); - const menuItems = finalLinks.filter((item) => - ["menu", "all"].includes(item.on ?? "all") - ); - - return ( - -
- Tinyauth - - {nav.title} - -
- {nav.children} -
    - {navItems - .filter((item) => !isSecondary(item)) - .map((item, i) => ( - - ))} -
-
- {searchToggle.enabled !== false && - (searchToggle.components?.lg ?? ( - - ))} - {themeSwitch.enabled !== false && - (themeSwitch.component ?? )} - - - - {i18n ? ( - - - - ) : null} -
- {navItems.filter(isSecondary).map((item, i) => ( - - ))} -
-
-
    - {searchToggle.enabled !== false && - (searchToggle.components?.sm ?? ( - - ))} - - - - - - {menuItems - .filter((item) => !isSecondary(item)) - .map((item, i) => ( - - ))} -
    - {menuItems.filter(isSecondary).map((item, i) => ( - - ))} -
    - {i18n ? ( - - - - - - ) : null} - {themeSwitch.enabled !== false && - (themeSwitch.component ?? ( - - ))} - - - -
    - -
    -
-
- ); -} - -function NavbarLinkItem({ - item, - ...props -}: { - item: LinkItemType; - className?: string; -}) { - if (item.type === "custom") return
{item.children}
; - - if (item.type === "menu") { - const children = item.items.map((child, j) => { - if (child.type === "custom") { - return {child.children}; - } - - const { - banner = child.icon ? ( -
- {child.icon} -
- ) : null, - ...rest - } = child.menu ?? {}; - - return ( - - {rest.children ?? ( - <> - {banner} -

{child.text}

-

- {child.description} -

- - )} -
- ); - }); - - return ( - - - {item.url ? ( - - {item.text} - - ) : ( - item.text - )} - - {children} - - ); - } - - return ( - - {item.type === "icon" ? item.icon : item.text} - - ); -} - -function isSecondary(item: LinkItemType): boolean { - if ("secondary" in item && item.secondary != null) return item.secondary; - - return item.type === "icon"; -} diff --git a/app/components/layout/home/menu.tsx b/app/components/layout/home/menu.tsx deleted file mode 100644 index 7748c34..0000000 --- a/app/components/layout/home/menu.tsx +++ /dev/null @@ -1,121 +0,0 @@ -'use client'; -import { BaseLinkItem, type LinkItemType } from '../shared/index'; -import { cn } from '../../../lib/cn'; -import { - NavigationMenuContent, - NavigationMenuItem, - NavigationMenuLink, - NavigationMenuTrigger, -} from '../../navigation-menu'; -import Link from 'fumadocs-core/link'; -import { cva } from 'class-variance-authority'; -import { buttonVariants } from '../../ui/button'; -import type { ComponentPropsWithoutRef } from 'react'; - -const menuItemVariants = cva('', { - variants: { - variant: { - main: 'inline-flex items-center gap-2 py-1.5 transition-colors hover:text-fd-popover-foreground/50 data-[active=true]:font-medium data-[active=true]:text-fd-primary [&_svg]:size-4', - icon: buttonVariants({ - size: 'icon', - color: 'ghost', - }), - button: buttonVariants({ - color: 'secondary', - className: 'gap-1.5 [&_svg]:size-4', - }), - }, - }, - defaultVariants: { - variant: 'main', - }, -}); - -export function MenuLinkItem({ - item, - ...props -}: { - item: LinkItemType; - className?: string; -}) { - if (item.type === 'custom') - return
{item.children}
; - - if (item.type === 'menu') { - const header = ( - <> - {item.icon} - {item.text} - - ); - - return ( -
-

- {item.url ? ( - - - {header} - - - ) : ( - header - )} -

- {item.items.map((child, i) => ( - - ))} -
- ); - } - - return ( - - - {item.icon} - {item.type === 'icon' ? undefined : item.text} - - - ); -} - -export const Menu = NavigationMenuItem; - -export function MenuTrigger({ - enableHover = false, - ...props -}: ComponentPropsWithoutRef & { - /** - * Enable hover to trigger - */ - enableHover?: boolean; -}) { - return ( - e.preventDefault()} - > - {props.children} - - ); -} - -export function MenuContent( - props: ComponentPropsWithoutRef, -) { - return ( - - {props.children} - - ); -} diff --git a/app/components/layout/home/navbar.tsx b/app/components/layout/home/navbar.tsx deleted file mode 100644 index 64e81fa..0000000 --- a/app/components/layout/home/navbar.tsx +++ /dev/null @@ -1,136 +0,0 @@ -"use client"; -import { type ComponentProps, useState } from "react"; -import { cva, type VariantProps } from "class-variance-authority"; -import Link, { type LinkProps } from "fumadocs-core/link"; -import { cn } from "../../../lib/cn"; -import { BaseLinkItem } from "../shared/index"; -import { - NavigationMenu, - NavigationMenuContent, - NavigationMenuItem, - NavigationMenuLink, - NavigationMenuList, - NavigationMenuTrigger, - NavigationMenuViewport, -} from "../../navigation-menu"; -import { useNav } from "fumadocs-ui/contexts/layout"; -import type { - NavigationMenuContentProps, - NavigationMenuTriggerProps, -} from "@radix-ui/react-navigation-menu"; -import { buttonVariants } from "../../ui/button"; - -const navItemVariants = cva( - "inline-flex items-center gap-1 p-2 text-fd-muted-foreground transition-colors hover:text-fd-accent-foreground data-[active=true]:text-fd-primary [&_svg]:size-4" -); - -export function Navbar(props: ComponentProps<"div">) { - const [value, setValue] = useState(""); - const { isTransparent } = useNav(); - - return ( - -
0 && "max-lg:shadow-lg max-lg:rounded-b-2xl", - (!isTransparent || value.length > 0) && "bg-fd-background/80", - props.className - )} - > - - - - - -
-
- ); -} - -export const NavbarMenu = NavigationMenuItem; - -export function NavbarMenuContent(props: NavigationMenuContentProps) { - return ( - - {props.children} - - ); -} - -export function NavbarMenuTrigger(props: NavigationMenuTriggerProps) { - return ( - - {props.children} - - ); -} - -export function NavbarMenuLink(props: LinkProps) { - return ( - - - {props.children} - - - ); -} - -const linkVariants = cva("", { - variants: { - variant: { - main: navItemVariants(), - button: buttonVariants({ - color: "secondary", - className: "gap-1.5 [&_svg]:size-4", - }), - icon: buttonVariants({ - color: "ghost", - size: "icon", - }), - }, - }, - defaultVariants: { - variant: "main", - }, -}); - -export function NavbarLink({ - item, - variant, - ...props -}: ComponentProps & VariantProps) { - return ( - - - - {props.children} - - - - ); -} diff --git a/app/components/layout/page.tsx b/app/components/layout/page.tsx deleted file mode 100644 index cf5f9f5..0000000 --- a/app/components/layout/page.tsx +++ /dev/null @@ -1,295 +0,0 @@ -import { type ComponentProps, forwardRef, type ReactNode } from "react"; -import { cn } from "../../lib/cn"; -import { buttonVariants } from "../ui/button"; -import { Edit } from "lucide-react"; -import { I18nLabel } from "fumadocs-ui/contexts/i18n"; -import { - type BreadcrumbProps, - type FooterProps, - PageArticle, - PageBreadcrumb, - PageFooter, - PageLastUpdate, - PageRoot, - PageTOC, - PageTOCItems, - PageTOCPopover, - PageTOCPopoverContent, - PageTOCPopoverItems, - PageTOCPopoverTrigger, - PageTOCTitle, -} from "./docs/page"; -import type { AnchorProviderProps } from "fumadocs-core/toc"; -import type { TOCItemType } from "fumadocs-core/server"; - -interface EditOnGitHubOptions - extends Omit, "href" | "children"> { - owner: string; - repo: string; - - /** - * SHA or ref (branch or tag) name. - * - * @defaultValue main - */ - sha?: string; - - /** - * File path in the repo - */ - path: string; -} - -interface BreadcrumbOptions extends BreadcrumbProps { - enabled: boolean; - component: ReactNode; - - /** - * Show the full path to the current page - * - * @defaultValue false - * @deprecated use `includePage` instead - */ - full?: boolean; -} - -interface FooterOptions extends FooterProps { - enabled: boolean; - component: ReactNode; -} - -export interface DocsPageProps { - toc?: TOCItemType[]; - tableOfContent?: Partial; - tableOfContentPopover?: Partial; - - /** - * Extend the page to fill all available space - * - * @defaultValue false - */ - full?: boolean; - - /** - * Replace or disable breadcrumb - */ - breadcrumb?: Partial; - - /** - * Footer navigation, you can disable it by passing `false` - */ - footer?: Partial; - - editOnGithub?: EditOnGitHubOptions; - lastUpdate?: Date | string | number; - - container?: ComponentProps<"div">; - article?: ComponentProps<"article">; - children?: ReactNode; -} - -type TableOfContentOptions = Pick & { - /** - * Custom content in TOC container, before the main TOC - */ - header?: ReactNode; - - /** - * Custom content in TOC container, after the main TOC - */ - footer?: ReactNode; - - enabled: boolean; - component: ReactNode; - - /** - * @defaultValue 'normal' - */ - style?: "normal" | "clerk"; -}; - -type TableOfContentPopoverOptions = Omit; - -export function DocsPage({ - editOnGithub, - breadcrumb: { - enabled: breadcrumbEnabled = true, - component: breadcrumb, - ...breadcrumbProps - } = {}, - footer = {}, - lastUpdate, - container, - full = false, - tableOfContentPopover: { - enabled: tocPopoverEnabled, - component: tocPopover, - ...tocPopoverOptions - } = {}, - tableOfContent: { - enabled: tocEnabled, - component: tocReplace, - ...tocOptions - } = {}, - toc = [], - article, - children, -}: DocsPageProps) { - // disable TOC on full mode, you can still enable it with `enabled` option. - tocEnabled ??= - !full && - (toc.length > 0 || - tocOptions.footer !== undefined || - tocOptions.header !== undefined); - - tocPopoverEnabled ??= - toc.length > 0 || - tocPopoverOptions.header !== undefined || - tocPopoverOptions.footer !== undefined; - - return ( - - {tocPopoverEnabled && - (tocPopover ?? ( - - - - {tocPopoverOptions.header} - - {tocPopoverOptions.footer} - - - ))} - - {breadcrumbEnabled && - (breadcrumb ?? )} - {children} -
- {editOnGithub && ( - - )} - {lastUpdate && } -
- {footer.enabled !== false && - (footer.component ?? )} -
- {tocEnabled && - (tocReplace ?? ( - - {tocOptions.header} - - - {tocOptions.footer} - - ))} -
- ); -} - -export function EditOnGitHub(props: ComponentProps<"a">) { - return ( - - {props.children ?? ( - <> - - - - )} - - ); -} - -/** - * Add typography styles - */ -export const DocsBody = forwardRef>( - (props, ref) => ( -
- {props.children} -
- ), -); - -DocsBody.displayName = "DocsBody"; - -export const DocsDescription = forwardRef< - HTMLParagraphElement, - ComponentProps<"p"> ->((props, ref) => { - // don't render if no description provided - if (props.children === undefined) return null; - - return ( -

- {props.children} -

- ); -}); - -DocsDescription.displayName = "DocsDescription"; - -export const DocsTitle = forwardRef>( - (props, ref) => { - return ( -

- {props.children} -

- ); - }, -); - -DocsTitle.displayName = "DocsTitle"; - -/** - * For separate MDX page - */ -export function withArticle(props: ComponentProps<"main">): ReactNode { - return ( -
-
{props.children}
-
- ); -} diff --git a/app/components/layout/shared/client.tsx b/app/components/layout/shared/client.tsx deleted file mode 100644 index d685015..0000000 --- a/app/components/layout/shared/client.tsx +++ /dev/null @@ -1,30 +0,0 @@ -'use client'; -import type { ComponentProps } from 'react'; -import { usePathname } from 'fumadocs-core/framework'; -import { isActive } from '../../../lib/is-active'; -import Link from 'fumadocs-core/link'; -import type { BaseLinkType } from './index'; - -export function BaseLinkItem({ - ref, - item, - ...props -}: Omit, 'href'> & { item: BaseLinkType }) { - const pathname = usePathname(); - const activeType = item.active ?? 'url'; - const active = - activeType !== 'none' && - isActive(item.url, pathname, activeType === 'nested-url'); - - return ( - - {props.children} - - ); -} diff --git a/app/components/layout/shared/index.tsx b/app/components/layout/shared/index.tsx deleted file mode 100644 index c73090b..0000000 --- a/app/components/layout/shared/index.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import type { HTMLAttributes, ReactNode } from 'react'; -import type { NavProviderProps } from 'fumadocs-ui/contexts/layout'; -import type { I18nConfig } from 'fumadocs-core/i18n'; - -export interface NavOptions extends NavProviderProps { - enabled: boolean; - component: ReactNode; - - title?: ReactNode; - - /** - * Redirect url of title - * @defaultValue '/' - */ - url?: string; - - children?: ReactNode; -} - -export interface BaseLayoutProps { - themeSwitch?: { - enabled?: boolean; - component?: ReactNode; - mode?: 'light-dark' | 'light-dark-system'; - }; - - searchToggle?: Partial<{ - enabled: boolean; - components: Partial<{ - sm: ReactNode; - lg: ReactNode; - }>; - }>; - - /** - * Remove theme switcher component - * - * @deprecated Use `themeSwitch.enabled` instead. - */ - disableThemeSwitch?: boolean; - - /** - * I18n options - * - * @defaultValue false - */ - i18n?: boolean | I18nConfig; - - /** - * GitHub url - */ - githubUrl?: string; - - links?: LinkItemType[]; - /** - * Replace or disable navbar - */ - nav?: Partial; - - children?: ReactNode; -} - -interface BaseItem { - /** - * Restrict where the item is displayed - * - * @defaultValue 'all' - */ - on?: 'menu' | 'nav' | 'all'; -} - -export interface BaseLinkType extends BaseItem { - url: string; - /** - * When the item is marked as active - * - * @defaultValue 'url' - */ - active?: 'url' | 'nested-url' | 'none'; - external?: boolean; -} - -export interface MainItemType extends BaseLinkType { - type?: 'main'; - icon?: ReactNode; - text: ReactNode; - description?: ReactNode; -} - -export interface IconItemType extends BaseLinkType { - type: 'icon'; - /** - * `aria-label` of icon button - */ - label?: string; - icon: ReactNode; - text: ReactNode; - /** - * @defaultValue true - */ - secondary?: boolean; -} - -export interface ButtonItemType extends BaseLinkType { - type: 'button'; - icon?: ReactNode; - text: ReactNode; - /** - * @defaultValue false - */ - secondary?: boolean; -} - -export interface MenuItemType extends Partial { - type: 'menu'; - icon?: ReactNode; - text: ReactNode; - - items: ( - | (MainItemType & { - /** - * Options when displayed on navigation menu - */ - menu?: HTMLAttributes & { - banner?: ReactNode; - }; - }) - | CustomItemType - )[]; - - /** - * @defaultValue false - */ - secondary?: boolean; -} - -export interface CustomItemType extends BaseItem { - type: 'custom'; - /** - * @defaultValue false - */ - secondary?: boolean; - children: ReactNode; -} - -export type LinkItemType = - | MainItemType - | IconItemType - | ButtonItemType - | MenuItemType - | CustomItemType; - -/** - * Get Links Items with shortcuts - */ -export function getLinks( - links: LinkItemType[] = [], - githubUrl?: string, -): LinkItemType[] { - let result = links ?? []; - - if (githubUrl) - result = [ - ...result, - { - type: 'icon', - url: githubUrl, - text: 'Github', - label: 'GitHub', - icon: ( - - - - ), - external: true, - }, - ]; - - return result; -} - -export { BaseLinkItem } from './client'; diff --git a/app/components/mdx-components.tsx b/app/components/mdx-components.tsx deleted file mode 100644 index d631e25..0000000 --- a/app/components/mdx-components.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import defaultMdxComponents from "fumadocs-ui/mdx"; -import { Mermaid } from "@/components/mdx/mermaid"; -import type { MDXComponents } from "mdx/types"; - -export function getMDXComponents(components?: MDXComponents): MDXComponents { - return { - ...defaultMdxComponents, - Mermaid, - ...components, - }; -} diff --git a/app/components/mdx/mermaid.tsx b/app/components/mdx/mermaid.tsx deleted file mode 100644 index 807417c..0000000 --- a/app/components/mdx/mermaid.tsx +++ /dev/null @@ -1,60 +0,0 @@ -"use client"; - -import { use, useEffect, useId, useState } from "react"; -import { useTheme } from "next-themes"; - -export function Mermaid({ chart }: { chart: string }) { - const [mounted, setMounted] = useState(false); - - useEffect(() => { - setMounted(true); - }, []); - - if (!mounted) return; - return ; -} - -const cache = new Map>(); - -function cachePromise( - key: string, - setPromise: () => Promise -): Promise { - const cached = cache.get(key); - if (cached) return cached as Promise; - - const promise = setPromise(); - cache.set(key, promise); - return promise; -} - -function MermaidContent({ chart }: { chart: string }) { - const id = useId(); - const { resolvedTheme } = useTheme(); - const { default: mermaid } = use( - cachePromise("mermaid", () => import("mermaid")) - ); - - mermaid.initialize({ - startOnLoad: false, - securityLevel: "loose", - fontFamily: "inherit", - themeCSS: "margin: 1.5rem auto 0;", - theme: resolvedTheme === "dark" ? "dark" : "default", - }); - - const { svg, bindFunctions } = use( - cachePromise(`${chart}-${resolvedTheme}`, () => { - return mermaid.render(id, chart.replaceAll("\\n", "\n")); - }) - ); - - return ( -
{ - if (container) bindFunctions?.(container); - }} - dangerouslySetInnerHTML={{ __html: svg }} - /> - ); -} diff --git a/app/components/navigation-menu.tsx b/app/components/navigation-menu.tsx deleted file mode 100644 index 9143b49..0000000 --- a/app/components/navigation-menu.tsx +++ /dev/null @@ -1,80 +0,0 @@ -'use client'; -import * as React from 'react'; -import * as Primitive from '@radix-ui/react-navigation-menu'; -import { cn } from '../lib/cn'; - -const NavigationMenu = Primitive.Root; - -const NavigationMenuList = Primitive.List; - -const NavigationMenuItem = React.forwardRef< - React.ComponentRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - {children} - -)); - -NavigationMenuItem.displayName = Primitive.NavigationMenuItem.displayName; - -const NavigationMenuTrigger = React.forwardRef< - React.ComponentRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - {children} - -)); -NavigationMenuTrigger.displayName = Primitive.Trigger.displayName; - -const NavigationMenuContent = React.forwardRef< - React.ComponentRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -NavigationMenuContent.displayName = Primitive.Content.displayName; - -const NavigationMenuLink = Primitive.Link; - -const NavigationMenuViewport = React.forwardRef< - React.ComponentRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( -
- -
-)); -NavigationMenuViewport.displayName = Primitive.Viewport.displayName; - -export { - NavigationMenu, - NavigationMenuList, - NavigationMenuItem, - NavigationMenuContent, - NavigationMenuTrigger, - NavigationMenuLink, - NavigationMenuViewport, -}; diff --git a/app/components/root-toggle.tsx b/app/components/root-toggle.tsx deleted file mode 100644 index 02fed5d..0000000 --- a/app/components/root-toggle.tsx +++ /dev/null @@ -1,103 +0,0 @@ -'use client'; -import { Check, ChevronsUpDown } from 'lucide-react'; -import { type ComponentProps, type ReactNode, useMemo, useState } from 'react'; -import Link from 'fumadocs-core/link'; -import { usePathname } from 'fumadocs-core/framework'; -import { cn } from '../lib/cn'; -import { isTabActive } from '../lib/is-active'; -import { useSidebar } from 'fumadocs-ui/contexts/sidebar'; -import { Popover, PopoverContent, PopoverTrigger } from './ui/popover'; -import type { SidebarTab } from 'fumadocs-ui/utils/get-sidebar-tabs'; - -export interface Option extends SidebarTab { - props?: ComponentProps<'a'>; -} - -export function RootToggle({ - options, - placeholder, - ...props -}: { - placeholder?: ReactNode; - options: Option[]; -} & ComponentProps<'button'>) { - const [open, setOpen] = useState(false); - const { closeOnRedirect } = useSidebar(); - const pathname = usePathname(); - - const selected = useMemo(() => { - return options.findLast((item) => isTabActive(item, pathname)); - }, [options, pathname]); - - const onClick = () => { - closeOnRedirect.current = false; - setOpen(false); - }; - - const item = selected ? ( - <> -
{selected.icon}
-
-

{selected.title}

-

- {selected.description} -

-
- - ) : ( - placeholder - ); - - return ( - - {item && ( - - {item} - - - )} - - {options.map((item) => { - const isActive = selected && item.url === selected.url; - if (!isActive && item.unlisted) return; - - return ( - -
- {item.icon} -
-
-

{item.title}

-

- {item.description} -

-
- - - - ); - })} -
-
- ); -} diff --git a/app/components/search-toggle.tsx b/app/components/search-toggle.tsx deleted file mode 100644 index 0952940..0000000 --- a/app/components/search-toggle.tsx +++ /dev/null @@ -1,79 +0,0 @@ -'use client'; -import type { ComponentProps } from 'react'; -import { Search } from 'lucide-react'; -import { useSearchContext } from 'fumadocs-ui/contexts/search'; -import { useI18n } from 'fumadocs-ui/contexts/i18n'; -import { cn } from '../lib/cn'; -import { type ButtonProps, buttonVariants } from './ui/button'; - -interface SearchToggleProps - extends Omit, 'color'>, - ButtonProps { - hideIfDisabled?: boolean; -} - -export function SearchToggle({ - hideIfDisabled, - size = 'icon-sm', - color = 'ghost', - ...props -}: SearchToggleProps) { - const { setOpenSearch, enabled } = useSearchContext(); - if (hideIfDisabled && !enabled) return null; - - return ( - - ); -} - -export function LargeSearchToggle({ - hideIfDisabled, - ...props -}: ComponentProps<'button'> & { - hideIfDisabled?: boolean; -}) { - const { enabled, hotKey, setOpenSearch } = useSearchContext(); - const { text } = useI18n(); - if (hideIfDisabled && !enabled) return null; - - return ( - - ); -} diff --git a/app/components/search.tsx b/app/components/search.tsx deleted file mode 100644 index a88ce51..0000000 --- a/app/components/search.tsx +++ /dev/null @@ -1,51 +0,0 @@ -"use client"; -import { - SearchDialog, - SearchDialogClose, - SearchDialogContent, - SearchDialogHeader, - SearchDialogIcon, - SearchDialogInput, - SearchDialogList, - SearchDialogOverlay, - type SharedProps, -} from "fumadocs-ui/components/dialog/search"; -import { useDocsSearch } from "fumadocs-core/search/client"; -import { create } from "@orama/orama"; -import { useI18n } from "fumadocs-ui/contexts/i18n"; - -function initOrama() { - return create({ - schema: { _: "string" }, - // https://docs.orama.com/docs/orama-js/supported-languages - language: "english", - }); -} - -export default function DefaultSearchDialog(props: SharedProps) { - const { locale } = useI18n(); // (optional) for i18n - const { search, setSearch, query } = useDocsSearch({ - type: "static", - initOrama, - locale, - }); - - return ( - - - - - - - - - - - - ); -} diff --git a/app/components/sidebar.tsx b/app/components/sidebar.tsx deleted file mode 100644 index e34acb4..0000000 --- a/app/components/sidebar.tsx +++ /dev/null @@ -1,559 +0,0 @@ -"use client"; -import { ChevronDown, ExternalLink } from "lucide-react"; -import { usePathname } from "fumadocs-core/framework"; -import { - type ComponentProps, - createContext, - type FC, - Fragment, - type ReactNode, - useContext, - useMemo, - useRef, - useState, -} from "react"; -import Link, { type LinkProps } from "fumadocs-core/link"; -import { useOnChange } from "fumadocs-core/utils/use-on-change"; -import { cn } from "../lib/cn"; -import { ScrollArea, ScrollViewport } from "./ui/scroll-area"; -import { isActive } from "../lib/is-active"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "./ui/collapsible"; -import { type ScrollAreaProps } from "@radix-ui/react-scroll-area"; -import { useSidebar } from "fumadocs-ui/contexts/sidebar"; -import { cva } from "class-variance-authority"; -import type { - CollapsibleContentProps, - CollapsibleTriggerProps, -} from "@radix-ui/react-collapsible"; -import type { PageTree } from "fumadocs-core/server"; -import { useTreeContext, useTreePath } from "fumadocs-ui/contexts/tree"; -import { useMediaQuery } from "fumadocs-core/utils/use-media-query"; -import { Presence } from "@radix-ui/react-presence"; - -export interface SidebarProps { - /** - * Open folders by default if their level is lower or equal to a specific level - * (Starting from 1) - * - * @defaultValue 0 - */ - defaultOpenLevel?: number; - - /** - * Prefetch links - * - * @defaultValue true - */ - prefetch?: boolean; - - /** - * Children to render - */ - Content: ReactNode; - - /** - * Alternative children for mobile - */ - Mobile?: ReactNode; -} - -interface InternalContext { - defaultOpenLevel: number; - prefetch: boolean; - level: number; -} - -const itemVariants = cva( - "mt-0.5 relative flex flex-row items-center gap-2 rounded-lg p-2 ps-(--sidebar-item-offset) text-start text-fd-muted-foreground [overflow-wrap:anywhere] [&_svg]:size-4 [&_svg]:shrink-0", - { - variants: { - active: { - true: "bg-fd-primary/10 text-fd-primary", - false: - "transition-colors hover:bg-fd-accent/50 hover:text-fd-accent-foreground/80 hover:transition-none", - }, - }, - }, -); - -const Context = createContext(null); -const FolderContext = createContext<{ - open: boolean; - setOpen: React.Dispatch>; -} | null>(null); - -export function Sidebar({ - defaultOpenLevel = 0, - prefetch = true, - Mobile, - Content, -}: SidebarProps) { - const isMobile = useMediaQuery("(width < 768px)") ?? false; - const context = useMemo(() => { - return { - defaultOpenLevel, - prefetch, - level: 1, - }; - }, [defaultOpenLevel, prefetch]); - - return ( - - {isMobile && Mobile != null ? Mobile : Content} - - ); -} - -export function SidebarContent(props: ComponentProps<"aside">) { - const { collapsed } = useSidebar(); - const [hover, setHover] = useState(false); - const timerRef = useRef(0); - const closeTimeRef = useRef(0); - - useOnChange(collapsed, () => { - setHover(false); - closeTimeRef.current = Date.now() + 150; - }); - - return ( - - ); -} - -export function SidebarContentMobile({ - className, - children, - ...props -}: ComponentProps<"aside">) { - const { open, setOpen } = useSidebar(); - const state = open ? "open" : "closed"; - - return ( - <> - -
setOpen(false)} - /> - - - {({ present }) => ( - - )} - - - ); -} - -export function SidebarHeader(props: ComponentProps<"div">) { - return ( -
- {props.children} -
- ); -} - -export function SidebarFooter(props: ComponentProps<"div">) { - return ( -
- {props.children} -
- ); -} - -export function SidebarViewport(props: ScrollAreaProps) { - return ( - - - {props.children} - - - ); -} - -export function SidebarSeparator(props: ComponentProps<"p">) { - return ( -

- {props.children} -

- ); -} - -export function SidebarItem({ - icon, - ...props -}: LinkProps & { - icon?: ReactNode; -}) { - const pathname = usePathname(); - const active = - props.href !== undefined && isActive(props.href, pathname, false); - const { prefetch } = useInternalContext(); - - return ( - - {icon ?? (props.external ? : null)} - {props.children} - - ); -} - -export function SidebarFolder({ - defaultOpen = false, - ...props -}: ComponentProps<"div"> & { - defaultOpen?: boolean; -}) { - const [open, setOpen] = useState(defaultOpen); - - useOnChange(defaultOpen, (v) => { - if (v) setOpen(v); - }); - - return ( - - ({ open, setOpen }), [open])} - > - {props.children} - - - ); -} - -export function SidebarFolderTrigger({ - className, - ...props -}: CollapsibleTriggerProps) { - const { open } = useFolderContext(); - - return ( - - {props.children} - - - ); -} - -export function SidebarFolderLink(props: LinkProps) { - const { open, setOpen } = useFolderContext(); - const { prefetch } = useInternalContext(); - - const pathname = usePathname(); - const active = - props.href !== undefined && isActive(props.href, pathname, false); - - return ( - { - if ( - e.target instanceof Element && - e.target.matches("[data-icon], [data-icon] *") - ) { - setOpen(!open); - e.preventDefault(); - } else { - setOpen(active ? !open : true); - } - }} - prefetch={prefetch} - > - {props.children} - - - ); -} - -export function SidebarFolderContent(props: CollapsibleContentProps) { - const { level, ...ctx } = useInternalContext(); - - return ( - - ({ - ...ctx, - level: level + 1, - }), - [ctx, level], - )} - > - {props.children} - - - ); -} - -export function SidebarTrigger({ - children, - ...props -}: ComponentProps<"button">) { - const { setOpen } = useSidebar(); - - return ( - - ); -} - -export function SidebarCollapseTrigger(props: ComponentProps<"button">) { - const { collapsed, setCollapsed } = useSidebar(); - - return ( - - ); -} - -function useFolderContext() { - const ctx = useContext(FolderContext); - if (!ctx) throw new Error("Missing sidebar folder"); - - return ctx; -} - -function useInternalContext() { - const ctx = useContext(Context); - if (!ctx) throw new Error(" component required."); - - return ctx; -} - -export interface SidebarComponents { - Item: FC<{ item: PageTree.Item }>; - Folder: FC<{ item: PageTree.Folder; level: number; children: ReactNode }>; - Separator: FC<{ item: PageTree.Separator }>; -} - -/** - * Render sidebar items from page tree - */ -export function SidebarPageTree(props: { - components?: Partial; -}) { - const { root } = useTreeContext(); - - return useMemo(() => { - const { Separator, Item, Folder } = props.components ?? {}; - - function renderSidebarList( - items: PageTree.Node[], - level: number, - ): ReactNode[] { - return items.map((item, i) => { - if (item.type === "separator") { - if (Separator) return ; - return ( - - {item.icon} - {item.name} - - ); - } - - if (item.type === "folder") { - const children = renderSidebarList(item.children, level + 1); - - if (Folder) - return ( - - {children} - - ); - return ( - - {children} - - ); - } - - if (Item) return ; - return ( - - {item.name} - - ); - }); - } - - return ( - {renderSidebarList(root.children, 1)} - ); - }, [props.components, root]); -} - -function PageTreeFolder({ - item, - ...props -}: { - item: PageTree.Folder; - children: ReactNode; -}) { - const { defaultOpenLevel, level } = useInternalContext(); - const path = useTreePath(); - - return ( - = level) || path.includes(item) - } - > - {item.index ? ( - - {item.icon} - {item.name} - - ) : ( - - {item.icon} - {item.name} - - )} - {props.children} - - ); -} diff --git a/app/components/theme-toggle.tsx b/app/components/theme-toggle.tsx deleted file mode 100644 index 9ab62be..0000000 --- a/app/components/theme-toggle.tsx +++ /dev/null @@ -1,88 +0,0 @@ -"use client"; -import { cva } from "class-variance-authority"; -import { Moon, Sun, Airplay } from "lucide-react"; -import { useTheme } from "next-themes"; -import { type HTMLAttributes, useLayoutEffect, useState } from "react"; -import { cn } from "../lib/cn"; - -const itemVariants = cva( - "size-6.5 rounded-full p-1.5 text-fd-muted-foreground cursor-pointer", - { - variants: { - active: { - true: "bg-fd-accent text-fd-accent-foreground", - false: - "text-fd-muted-foreground hover:opacity-80 hover:scale-105 transform-translate delay-100", - }, - }, - } -); - -const full = [ - ["light", Sun] as const, - ["dark", Moon] as const, - ["system", Airplay] as const, -]; - -export function ThemeToggle({ - className, - mode = "light-dark", - ...props -}: HTMLAttributes & { - mode?: "light-dark" | "light-dark-system"; -}) { - const { setTheme, theme, resolvedTheme } = useTheme(); - const [mounted, setMounted] = useState(false); - - useLayoutEffect(() => { - setMounted(true); - }, []); - - const container = cn( - "inline-flex items-center rounded-full border p-1", - className - ); - - if (mode === "light-dark") { - const value = mounted ? resolvedTheme : null; - - return ( - - ); - } - - const value = mounted ? theme : null; - - return ( -
- {full.map(([key, Icon]) => ( - - ))} -
- ); -} diff --git a/app/components/ui/button.tsx b/app/components/ui/button.tsx deleted file mode 100644 index 5fca2ad..0000000 --- a/app/components/ui/button.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { cva, type VariantProps } from 'class-variance-authority'; - -const variants = { - primary: 'bg-fd-primary text-fd-primary-foreground hover:bg-fd-primary/80', - outline: 'border hover:bg-fd-accent hover:text-fd-accent-foreground', - ghost: 'hover:bg-fd-accent hover:text-fd-accent-foreground', - secondary: - 'border bg-fd-secondary text-fd-secondary-foreground hover:bg-fd-accent hover:text-fd-accent-foreground', -} as const; - -export const buttonVariants = cva( - 'inline-flex items-center justify-center rounded-md p-2 text-sm font-medium transition-colors duration-100 disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none', - { - variants: { - variant: variants, - // fumadocs use `color` instead of `variant` - color: variants, - size: { - sm: 'gap-1 px-2 py-1.5 text-xs', - icon: 'p-1.5 [&_svg]:size-5', - 'icon-sm': 'p-1.5 [&_svg]:size-4.5', - 'icon-xs': 'p-1 [&_svg]:size-4', - }, - }, - }, -); - -export type ButtonProps = VariantProps; diff --git a/app/components/ui/collapsible.tsx b/app/components/ui/collapsible.tsx deleted file mode 100644 index dbcf3f0..0000000 --- a/app/components/ui/collapsible.tsx +++ /dev/null @@ -1,39 +0,0 @@ -'use client'; -import * as CollapsiblePrimitive from '@radix-ui/react-collapsible'; -import { forwardRef, useEffect, useState } from 'react'; -import { cn } from '../../lib/cn'; - -const Collapsible = CollapsiblePrimitive.Root; - -const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger; - -const CollapsibleContent = forwardRef< - HTMLDivElement, - React.ComponentPropsWithoutRef ->(({ children, ...props }, ref) => { - const [mounted, setMounted] = useState(false); - - useEffect(() => { - setMounted(true); - }, []); - - return ( - - {children} - - ); -}); - -CollapsibleContent.displayName = - CollapsiblePrimitive.CollapsibleContent.displayName; - -export { Collapsible, CollapsibleTrigger, CollapsibleContent }; diff --git a/app/components/ui/popover.tsx b/app/components/ui/popover.tsx deleted file mode 100644 index e033f5d..0000000 --- a/app/components/ui/popover.tsx +++ /dev/null @@ -1,32 +0,0 @@ -'use client'; -import * as PopoverPrimitive from '@radix-ui/react-popover'; -import * as React from 'react'; -import { cn } from '../../lib/cn'; - -const Popover = PopoverPrimitive.Root; - -const PopoverTrigger = PopoverPrimitive.Trigger; - -const PopoverContent = React.forwardRef< - React.ComponentRef, - React.ComponentPropsWithoutRef ->(({ className, align = 'center', sideOffset = 4, ...props }, ref) => ( - - - -)); -PopoverContent.displayName = PopoverPrimitive.Content.displayName; - -const PopoverClose = PopoverPrimitive.PopoverClose; - -export { Popover, PopoverTrigger, PopoverContent, PopoverClose }; diff --git a/app/components/ui/scroll-area.tsx b/app/components/ui/scroll-area.tsx deleted file mode 100644 index 86cb150..0000000 --- a/app/components/ui/scroll-area.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'; -import * as React from 'react'; -import { cn } from '../../lib/cn'; - -const ScrollArea = React.forwardRef< - React.ComponentRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - {children} - - - -)); - -ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; - -const ScrollViewport = React.forwardRef< - React.ComponentRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - {children} - -)); - -ScrollViewport.displayName = ScrollAreaPrimitive.Viewport.displayName; - -const ScrollBar = React.forwardRef< - React.ComponentRef, - React.ComponentPropsWithoutRef ->(({ className, orientation = 'vertical', ...props }, ref) => ( - - - -)); -ScrollBar.displayName = ScrollAreaPrimitive.Scrollbar.displayName; - -export { ScrollArea, ScrollBar, ScrollViewport }; diff --git a/app/components/ui/toc-clerk.tsx b/app/components/ui/toc-clerk.tsx deleted file mode 100644 index f8466ab..0000000 --- a/app/components/ui/toc-clerk.tsx +++ /dev/null @@ -1,179 +0,0 @@ -'use client'; -import type { TOCItemType } from 'fumadocs-core/server'; -import * as Primitive from 'fumadocs-core/toc'; -import { type ComponentProps, useEffect, useRef, useState } from 'react'; -import { cn } from '../../lib/cn'; -import { TocThumb } from './toc-thumb'; -import { useTOCItems } from './toc'; -import { mergeRefs } from '../../lib/merge-refs'; -import { useI18n } from 'fumadocs-ui/contexts/i18n'; - -export default function ClerkTOCItems({ - ref, - className, - ...props -}: ComponentProps<'div'>) { - const containerRef = useRef(null); - const items = useTOCItems(); - const { text } = useI18n(); - - const [svg, setSvg] = useState<{ - path: string; - width: number; - height: number; - }>(); - - useEffect(() => { - if (!containerRef.current) return; - const container = containerRef.current; - - function onResize(): void { - if (container.clientHeight === 0) return; - let w = 0, - h = 0; - const d: string[] = []; - for (let i = 0; i < items.length; i++) { - const element: HTMLElement | null = container.querySelector( - `a[href="#${items[i].url.slice(1)}"]`, - ); - if (!element) continue; - - const styles = getComputedStyle(element); - const offset = getLineOffset(items[i].depth) + 1, - top = element.offsetTop + parseFloat(styles.paddingTop), - bottom = - element.offsetTop + - element.clientHeight - - parseFloat(styles.paddingBottom); - - w = Math.max(offset, w); - h = Math.max(h, bottom); - - d.push(`${i === 0 ? 'M' : 'L'}${offset} ${top}`); - d.push(`L${offset} ${bottom}`); - } - - setSvg({ - path: d.join(' '), - width: w + 1, - height: h, - }); - } - - const observer = new ResizeObserver(onResize); - onResize(); - - observer.observe(container); - return () => { - observer.disconnect(); - }; - }, [items]); - - if (items.length === 0) - return ( -
- {text.tocNoHeadings} -
- ); - - return ( - <> - {svg ? ( -
`, - ) - }")`, - }} - > - -
- ) : null} -
- {items.map((item, i) => ( - - ))} -
- - ); -} - -function getItemOffset(depth: number): number { - if (depth <= 2) return 14; - if (depth === 3) return 26; - return 36; -} - -function getLineOffset(depth: number): number { - return depth >= 3 ? 10 : 0; -} - -function TOCItem({ - item, - upper = item.depth, - lower = item.depth, -}: { - item: TOCItemType; - upper?: number; - lower?: number; -}) { - const offset = getLineOffset(item.depth), - upperOffset = getLineOffset(upper), - lowerOffset = getLineOffset(lower); - - return ( - - {offset !== upperOffset ? ( - - - - ) : null} -
- {item.title} - - ); -} diff --git a/app/components/ui/toc-thumb.tsx b/app/components/ui/toc-thumb.tsx deleted file mode 100644 index bdced31..0000000 --- a/app/components/ui/toc-thumb.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { type HTMLAttributes, type RefObject, useEffect, useRef } from 'react'; -import * as Primitive from 'fumadocs-core/toc'; -import { useOnChange } from 'fumadocs-core/utils/use-on-change'; -import { useEffectEvent } from 'fumadocs-core/utils/use-effect-event'; - -export type TOCThumb = [top: number, height: number]; - -function calc(container: HTMLElement, active: string[]): TOCThumb { - if (active.length === 0 || container.clientHeight === 0) { - return [0, 0]; - } - - let upper = Number.MAX_VALUE, - lower = 0; - - for (const item of active) { - const element = container.querySelector(`a[href="#${item}"]`); - if (!element) continue; - - const styles = getComputedStyle(element); - upper = Math.min(upper, element.offsetTop + parseFloat(styles.paddingTop)); - lower = Math.max( - lower, - element.offsetTop + - element.clientHeight - - parseFloat(styles.paddingBottom), - ); - } - - return [upper, lower - upper]; -} - -function update(element: HTMLElement, info: TOCThumb): void { - element.style.setProperty('--fd-top', `${info[0]}px`); - element.style.setProperty('--fd-height', `${info[1]}px`); -} - -export function TocThumb({ - containerRef, - ...props -}: HTMLAttributes & { - containerRef: RefObject; -}) { - const active = Primitive.useActiveAnchors(); - const thumbRef = useRef(null); - - const onResize = useEffectEvent(() => { - if (!containerRef.current || !thumbRef.current) return; - - update(thumbRef.current, calc(containerRef.current, active)); - }); - - useEffect(() => { - if (!containerRef.current) return; - const container = containerRef.current; - - onResize(); - const observer = new ResizeObserver(onResize); - observer.observe(container); - - return () => { - observer.disconnect(); - }; - }, [containerRef, onResize]); - - useOnChange(active, () => { - if (!containerRef.current || !thumbRef.current) return; - - update(thumbRef.current, calc(containerRef.current, active)); - }); - - return
; -} diff --git a/app/components/ui/toc.tsx b/app/components/ui/toc.tsx deleted file mode 100644 index 81cebc2..0000000 --- a/app/components/ui/toc.tsx +++ /dev/null @@ -1,101 +0,0 @@ -'use client'; -import type { TOCItemType } from 'fumadocs-core/server'; -import * as Primitive from 'fumadocs-core/toc'; -import { type ComponentProps, createContext, useContext, useRef } from 'react'; -import { cn } from '../../lib/cn'; -import { useI18n } from 'fumadocs-ui/contexts/i18n'; -import { TocThumb } from './toc-thumb'; -import { mergeRefs } from '../../lib/merge-refs'; - -const TOCContext = createContext([]); - -export function useTOCItems(): TOCItemType[] { - return useContext(TOCContext); -} - -export function TOCProvider({ - toc, - children, - ...props -}: ComponentProps) { - return ( - - - {children} - - - ); -} - -export function TOCScrollArea({ - ref, - className, - ...props -}: ComponentProps<'div'>) { - const viewRef = useRef(null); - - return ( -
- - {props.children} - -
- ); -} - -export function TOCItems({ ref, className, ...props }: ComponentProps<'div'>) { - const containerRef = useRef(null); - const items = useTOCItems(); - const { text } = useI18n(); - - if (items.length === 0) - return ( -
- {text.tocNoHeadings} -
- ); - - return ( - <> - -
- {items.map((item) => ( - - ))} -
- - ); -} - -function TOCItem({ item }: { item: TOCItemType }) { - return ( - = 4 && 'ps-8', - )} - > - {item.title} - - ); -} diff --git a/app/docs/page.tsx b/app/docs/page.tsx deleted file mode 100644 index 8ae640d..0000000 --- a/app/docs/page.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import type { Route } from "./+types/page"; -import { DocsLayout } from "@/components/layout/docs"; -import { - DocsBody, - DocsDescription, - DocsPage, - DocsTitle, -} from "@/components/layout/page"; -import { source } from "@/lib/source"; -import { baseOptions } from "@/lib/layout.shared"; -import { type PageTree } from "fumadocs-core/server"; -import { getMDXComponents } from "../components/mdx-components"; -import { docs } from "../../source.generated"; -import { toClientRenderer } from "fumadocs-mdx/runtime/vite"; - -export async function loader({ params }: Route.LoaderArgs) { - const slugs = params["*"].split("/").filter((v) => v.length > 0); - const page = source.getPage(slugs); - if (!page) throw new Response("Not found", { status: 404 }); - - return { - path: page.path, - tree: source.pageTree, - }; -} - -const renderer = toClientRenderer( - docs.doc, - ({ toc, default: Mdx, frontmatter }) => { - return ( - - {frontmatter.title} - - {frontmatter.title} - {frontmatter.description} - - - - - ); - } -); - -export default function Page(props: Route.ComponentProps) { - const { tree, path } = props.loaderData; - const Content = renderer[path]; - - return ( - - - - ); -} diff --git a/app/docs/search.ts b/app/docs/search.ts deleted file mode 100644 index 411f65b..0000000 --- a/app/docs/search.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { createFromSource } from "fumadocs-core/search/server"; -import { source } from "@/lib/source"; - -const server = createFromSource(source, { - // https://docs.orama.com/docs/orama-js/supported-languages - language: "english", -}); - -export async function loader() { - return server.staticGET(); -} diff --git a/app/lib/cn.ts b/app/lib/cn.ts deleted file mode 100644 index ba66fd2..0000000 --- a/app/lib/cn.ts +++ /dev/null @@ -1 +0,0 @@ -export { twMerge as cn } from 'tailwind-merge'; diff --git a/app/lib/is-active.ts b/app/lib/is-active.ts deleted file mode 100644 index d41bf69..0000000 --- a/app/lib/is-active.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { SidebarTab } from 'fumadocs-ui/utils/get-sidebar-tabs'; - -function normalize(url: string) { - if (url.length > 1 && url.endsWith('/')) return url.slice(0, -1); - return url; -} - -export function isActive( - url: string, - pathname: string, - nested = true, -): boolean { - url = normalize(url); - pathname = normalize(pathname); - - return url === pathname || (nested && pathname.startsWith(`${url}/`)); -} - -export function isTabActive(tab: SidebarTab, pathname: string) { - if (tab.urls) return tab.urls.has(normalize(pathname)); - - return isActive(tab.url, pathname, true); -} diff --git a/app/lib/layout.shared.tsx b/app/lib/layout.shared.tsx deleted file mode 100644 index 8f9a840..0000000 --- a/app/lib/layout.shared.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared"; - -export function baseOptions(): BaseLayoutProps { - return { - nav: { - title: "Tinyauth", - }, - }; -} diff --git a/app/lib/merge-refs.ts b/app/lib/merge-refs.ts deleted file mode 100644 index 7d05f74..0000000 --- a/app/lib/merge-refs.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type * as React from 'react'; - -export function mergeRefs( - ...refs: (React.Ref | undefined)[] -): React.RefCallback { - return (value) => { - refs.forEach((ref) => { - if (typeof ref === 'function') { - ref(value); - } else if (ref) { - ref.current = value; - } - }); - }; -} diff --git a/app/lib/source.ts b/app/lib/source.ts deleted file mode 100644 index c13211c..0000000 --- a/app/lib/source.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { loader } from 'fumadocs-core/source'; -import { create, docs } from '../../source.generated'; - -export const source = loader({ - source: await create.sourceAsync(docs.doc, docs.meta), - baseUrl: '/docs', -}); diff --git a/app/root.tsx b/app/root.tsx deleted file mode 100644 index 999da05..0000000 --- a/app/root.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { - isRouteErrorResponse, - Link, - Links, - Meta, - Outlet, - Scripts, - ScrollRestoration, -} from "react-router"; -import { RootProvider } from "fumadocs-ui/provider/base"; -import { ReactRouterProvider } from "fumadocs-core/framework/react-router"; -import type { Route } from "./+types/root"; -import "./app.css"; -import SearchDialog from "@/components/search"; - -export const links: Route.LinksFunction = () => [ - { rel: "preconnect", href: "https://fonts.googleapis.com" }, - { - rel: "preconnect", - href: "https://fonts.gstatic.com", - crossOrigin: "anonymous", - }, - { - rel: "stylesheet", - href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap", - }, -]; - -export function Layout({ children }: { children: React.ReactNode }) { - return ( - - - - - + +
+
+ +
TINYAUTH_SERVER_PORT=8080
--server.address=0.0.0.0
+
+ +
diff --git a/src/content/docs/docs/breaking-updates/4-to-5.mdx b/src/content/docs/docs/breaking-updates/4-to-5.mdx new file mode 100644 index 0000000..ece5ecd --- /dev/null +++ b/src/content/docs/docs/breaking-updates/4-to-5.mdx @@ -0,0 +1,50 @@ +--- +title: Updating from v4 to v5 +description: A guide to help migrate from Tinyauth v4 to v5. +--- + +import ConfigMigrator from "./4-to-5-migrator.astro"; + +:::note +In order to follow this migration guide, you need to have Tinyauth v4 running. In case you are coming from Tinyauth v3, you need to migrate to v4 first. For migrating from Tinyauth v3, please refer to the [migration guide](/docs/breaking-updates/3-to-4). +::: + +Tinyauth v5 introduces only configuration changes. + +## Configuration Changes + +In Tinyauth v4, the configuration was a mess - some options were not doing what they were supposed to do, they were hard to keep track of and generally not very intuitive. In Tinyauth v5, we've simplified the configuration format into one unified scheme across all configuration mediums. + +### Environment Variables + +Environment variables, now, follow the following format: + +``` +TINYAUTH_
_= +``` + +The `TINYAUTH_` prefix is used to distinguish Tinyauth configuration from other environment variables. All environment variables that are prefixed with `TINYAUTH_` are considered configuration options and in case of a mis-configuration, unlike previous versions, Tinyauth will refuse to start. + +### CLI Flags + +CLI flags follow the following format: + +``` +--section.key=value +``` + +This format may seem unintuitive at first, but it's actually quite powerful and better than the previous delimiter-based format. It allows you to specify configuration options in a way that's easy to remember and easy to type. + +### Configuration Migrator + +Since the entire configuration format has changed, you will need to update your entire configuration. To make this process easier, we've provided a configuration migrator that can help you migrate your CLI flags and environment variables to the new format. + + + +:::caution +Dynamic configuration is not supported by this migrator. You will need to manually update your configuration files and environment variables to use the new format. For more information, see the [configuration](/docs/reference/configuration) reference. +::: + +:::note +For the migrator to work correctly, please specify one environment variable or CLI flag per line (separated by the equal sign **not** space). Even though the migrator supports comments and blank lines, it's recommended to keep the input clean and easy to read. Additionally, you should ideally not mix CLI flags and environment variables in one go (even though the migrator supports it). +::: diff --git a/content/docs/community/caddy.mdx b/src/content/docs/docs/community/caddy.mdx similarity index 83% rename from content/docs/community/caddy.mdx rename to src/content/docs/docs/community/caddy.mdx index 467257c..623365a 100644 --- a/content/docs/community/caddy.mdx +++ b/src/content/docs/docs/community/caddy.mdx @@ -34,19 +34,21 @@ The `caddy-docker-proxy` service might look like this if the labels are added: ```yaml services: caddy: - container_name: caddy - image: lucaslorentz/caddy-docker-proxy:latest + image: lucaslorentz/caddy-docker-proxy:2.10.0 restart: unless-stopped ports: - 80:80 - 443:443 volumes: - /var/run/docker.sock:/var/run/docker.sock - - ./data/caddy:/data + - caddy-data:/data labels: caddy: (tinyauth_forwarder) caddy.forward_auth: tinyauth:3000 caddy.forward_auth.uri: /api/auth/caddy + +volumes: + caddy-data: ``` ## Tinyauth Configuration @@ -56,12 +58,11 @@ Add Tinyauth and place it behind Caddy with the `caddy` and `caddy.reverse_proxy ```yaml services: tinyauth: - container_name: tinyauth - image: ghcr.io/steveiliop56/tinyauth:v4 + image: ghcr.io/steveiliop56/tinyauth:v5 restart: unless-stopped environment: - - APP_URL=http://auth.example.com - - USERS=your-username-password-hash + - TINYAUTH_APPURL=http://auth.example.com + - TINYAUTH_AUTH_USERS=your-username-password-hash labels: caddy: http://auth.example.com caddy.reverse_proxy: "{{upstreams 3000}}" @@ -80,7 +81,6 @@ Using Whoami as an example, it might look like this: ```yaml services: whoami: - container_name: whoami image: traefik/whoami:latest restart: unless-stopped labels: @@ -96,15 +96,14 @@ Here is a complete example with all the services together: ```yaml services: caddy: - container_name: caddy - image: lucaslorentz/caddy-docker-proxy:latest + image: lucaslorentz/caddy-docker-proxy:2.10.0 restart: unless-stopped ports: - 80:80 - 443:443 volumes: - /var/run/docker.sock:/var/run/docker.sock - - ./data/caddy:/data + - caddy-data:/data labels: caddy: (tinyauth_forwarder) caddy.forward_auth: tinyauth:3000 @@ -112,22 +111,23 @@ services: caddy.forward_auth.copy_headers: Remote-User Remote-Name Remote-Email Remote-Groups # optional when you want to make headers available to your service tinyauth: - container_name: tinyauth - image: ghcr.io/steveiliop56/tinyauth:v4 + image: ghcr.io/steveiliop56/tinyauth:v5 restart: unless-stopped environment: - - APP_URL=http://auth.example.com - - USERS=your-username-password-hash + - TINYAUTH_APPURL=http://auth.example.com + - TINYAUTH_AUTH_USERS=your-username-password-hash labels: caddy: http://auth.example.com caddy.reverse_proxy: "{{upstreams 3000}}" whoami: - container_name: whoami image: traefik/whoami:latest restart: unless-stopped labels: caddy: http://whoami.example.com caddy.reverse_proxy: "{{upstreams 80}}" caddy.import: tinyauth_forwarder * + +volumes: + caddy-data: ``` diff --git a/src/content/docs/docs/community/kubernetes.mdx b/src/content/docs/docs/community/kubernetes.mdx new file mode 100644 index 0000000..c4e35ac --- /dev/null +++ b/src/content/docs/docs/community/kubernetes.mdx @@ -0,0 +1,222 @@ +--- +title: Kubernetes +description: Learn how to set up Tinyauth with Kubernetes resources. +--- + +_Contributors: [@kdwils](https://github.com/kdwils), [@pushpinderbal](https://github.com/pushpinderbal)_ + +## Use Case + +Kubernetes-hosted applications are commonly exposed externally using [Ingress Controllers](https://kubernetes.io/docs/concepts/services-networking/ingress-controllers/) or the newer [Gateway API](https://kubernetes.io/docs/concepts/services-networking/gateway/). These can act as a gateway to enforce authentication and authorization policies before traffic reaches your self-hosted applications. This is useful for protecting internal tools, admin interfaces, or services exposed to the internet - without needing to modify the applications themselves, especially those that do not have built-in authentication mechanisms. + +Popular reverse proxies like Nginx, Traefik, and Envoy provide Ingress controller and Gateway API implementations for Kubernetes that can be integrated with Tinyauth. + +## Prerequisites + +This guide assumes the following prerequisites: + +- An operational Kubernetes cluster +- An operational Ingress controller or Gateway API implementation (this guide demonstrates `ingress-nginx` and `Istio`, but `traefik` can be used as well). +- Experience with Kubernetes + +## Create a Namespace + +Firstly, create a namespace for Tinyauth: + +```yaml +apiVersion: v1 +kind: Namespace +metadata: + name: tinyauth +``` + +## Create a Deployment + +Create the Tinyauth deployment: + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: tinyauth + labels: + app: tinyauth +spec: + replicas: 1 + selector: + matchLabels: + app: tinyauth + template: + metadata: + labels: + app: tinyauth + spec: + containers: + - name: tinyauth + image: ghcr.io/steveiliop56/tinyauth:v5 + ports: + - containerPort: 3000 + env: + - name: TINYAUTH_APPURL + value: http://auth.example.com + - name: TINYAUTH_AUTH_USERS + value: user:$$2a$$10$$UdLYoJ5lgPsC0RKqYH/jMua7zIn0g9kPqWmhYayJYLaZQ/FTmH2/u # Username is user and password is password + livenessProbe: + httpGet: + path: /api/healthz + port: 3000 + readinessProbe: + httpGet: + path: /api/healthz + port: 3000 +``` + +## Create a Service + +Create the service: + +```yaml +apiVersion: v1 +kind: Service +metadata: + name: tinyauth +spec: + selector: + app: tinyauth + ports: + - port: 3000 + targetPort: 3000 + type: ClusterIP +``` + +## Using with ingress-nginx + +:::caution +`ingress-nginx` is set to be [retired in March 2026](https://kubernetes.io/blog/2025/11/11/ingress-nginx-retirement/). Consider migrating to a supported ingress component. +::: + +This ingress resource configures `ingress-nginx` to forward authentication checks for the host `my-host.domain.com` to a specific URL (`auth-url`). If the user is not authenticated, they will be redirected to a login page (`auth-signin`). + +Documentation for these annotations can be found in the ingress-nginx repository [annotations.md](https://github.com/kubernetes/ingress-nginx/blob/main/docs/user-guide/nginx-configuration/annotations.md#annotations). + +- `nginx.ingress.kubernetes.io/auth-url` specifies the URL where `ingress-nginx` should send requests to verify if the user is authenticated. +- `nginx.ingress.kubernetes.io/auth-signin` specifies the URL where `ingress-nginx` should send unauthenticated users to sign in. +- `nginx.ingress.kubernetes.io/auth-signin-redirect-param` specifies the key of the query parameter used to set the redirect URI. + +:::note +This example uses the `..svc.cluster.local` in-cluster URI based on the above example for the `auth-url`. The `auth-signin` annotation should be a reference to a URI that is accessible to the user. +::: + +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: my-ingress + namespace: my-namespace + annotations: + nginx.ingress.kubernetes.io/auth-url: "http://tinyauth.tinyauth.svc.cluster.local:3000/api/auth/nginx" + nginx.ingress.kubernetes.io/auth-signin: "http://auth.example.com/login" + nginx.ingress.kubernetes.io/auth-signin-redirect-param: redirect_uri +spec: + ingressClassName: nginx + rules: + - host: my-host.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: my-service + port: + number: 8080 +``` + +## Using with Istio + +External authorization in Istio is configured using the `AuthorizationPolicy` CRD and can be set up to use Tinyauth as the external authorization provider for both Ingress and Gateway API resources. Istio uses Envoy proxy under the hood, so this configuration can also be adapted for standalone [Envoy filters](https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/ext_authz_filter). + +### Define the External Authorizer + +Add Tinyauth as an external authorization provider in your Istio mesh configuration. + +:::note +This example uses the `..svc.cluster.local` in-cluster URI with the assumption that Istio and Tinyauth exist in the same Kubernetes cluster and the Tinyauth service is accessible from the Istio ingress pods. The URL accessible to end users (e.g., `http://auth.example.com`) is configured with `TINYAUTH_APPURL`. +::: + +```yaml +extensionProviders: +- name: "tinyauth" + envoyExtAuthzHttp: + service: "tinyauth.tinyauth.svc.cluster.local" + port: "3000" + pathPrefix: "/api/auth/envoy?path=" + includeRequestHeadersInCheck: ["cookie", "x-forwarded-for", "x-forwarded-proto", "x-forwarded-host", "accept"] + includeAdditionalHeadersInCheck: + "x-forwarded-proto": "%REQ(:SCHEME)%" + "x-forwarded-host": "%REQ(:AUTHORITY)%" + "x-forwarded-uri": "%REQ(:PATH)%" + "x-forwarded-method": "%REQ(:METHOD)%" + headersToDownstreamOnAllow: ["set-cookie"] + headersToDownstreamOnDeny: ["content-type", "set-cookie"] +``` + +:::note +- Envoy forwards requests to the external authorizer with the original path from the client request. The `pathPrefix` configuration above prefixes the path with an endpoint that Tinyauth recognizes, while Tinyauth ignores the original request path. +- Unlike other proxy implementations, Envoy connects to the external authorization backend using the original HTTP method from the client request and cannot be configured to use a static method. Tinyauth handles this by allowing all standard HTTP methods on the `/api/auth/envoy` endpoint. See [envoyproxy/envoy#5357](https://github.com/envoyproxy/envoy/issues/5357) for more information related to this behavior. +::: + +If you install Istio using helm, you can supply `extensionProviders` configuration in the `values.yaml` files as follows: + +```yaml +meshConfig: + extensionProviders: + - name: "tinyauth" + ........ +``` + +### Create Authorization Policy + +Given that you have a `HTTPRoute` under a Gateway that exposes your application, you can now create an `AuthorizationPolicy` to protect it using Tinyauth. + +```yaml +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: myapp-http-route + labels: + app: myapp +spec: + parentRefs: + - name: my-public-gateway + namespace: ingress + sectionName: https + hostnames: + - myapp.example.com + rules: + - backendRefs: + - name: myapp-service + port: 80 +``` + +```yaml +apiVersion: security.istio.io/v1 +kind: AuthorizationPolicy +metadata: + name: tinyauth-policy + namespace: ingress +spec: + targetRefs: + - kind: Gateway + group: gateway.networking.k8s.io + name: my-public-gateway + action: CUSTOM + provider: + name: "tinyauth" + rules: + - to: + - operation: + hosts: ["myapp.example.com"] +``` + +For more information, refer to the Istio [External Authorization](https://istio.io/latest/docs/tasks/security/authorization/authz-custom/) and [Authorization Policy](https://istio.io/latest/docs/reference/config/security/authorization-policy/) documentation. diff --git a/src/content/docs/docs/community/zitadel-oauth.mdx b/src/content/docs/docs/community/zitadel-oauth.mdx new file mode 100644 index 0000000..f036eb8 --- /dev/null +++ b/src/content/docs/docs/community/zitadel-oauth.mdx @@ -0,0 +1,73 @@ +--- +title: Zitadel +description: Learn how to set up Tinyauth with the Zitadel OAuth provider. +--- + +_Contributor: [@WilliamB78](https://github.com/WilliamB78)._ + +Tinyauth has built-in support for any generic OAuth provider. This guide demonstrates how to use Zitadel to authenticate users. + +## Requirements + +- A domain name (gTLD required) +- A Zitadel instance (cloud or self-hosted) + +## Creating the Zitadel OAuth App + +Begin by creating an app in Zitadel. Visit the Zitadel Console and create a new project. For project name use Tinyauth and for framework select other. + +![Zitadel new project](/screenshots/zitadel/project-new.png) + +Then, create a new application by clicking the **+** button. Follow the wizard and configure the app as follows: + +| Name | Value | +| --------------------- | --------------------------------------------------------- | +| Name | Tinyauth | +| Type | Web | +| Grant Types | Authorization Code | +| Response Types | Code | +| Authentication Method | Basic | +| Redirect URI | `https://tinyauth.example.com/api/oauth/callback/zitadel` | + +![Zitadel new app](/screenshots/zitadel/app-new.png) + +Finalize by clicking the **Create** button. Copy the client ID and client secret. + +![Zitadel app credentials](/screenshots/zitadel/app-creds.png) + +## Configuring Tinyauth + +To integrate Zitadel with Tinyauth, add the following environment variables to the Tinyauth Docker container: + +```yaml +services: + tinyauth: + environment: + - TINYAUTH_OAUTH_PROVIDERS_ZITADEL_SCOPES=openid profile email preferred_username groups + - TINYAUTH_OAUTH_PROVIDERS_ZITADEL_AUTHURL=https://zitadel.example.com/oauth/v2/authorize + - TINYAUTH_OAUTH_PROVIDERS_ZITADEL_TOKENURL=https://zitadel.example.com/oauth/v2/token + - TINYAUTH_OAUTH_PROVIDERS_ZITADEL_USERINFOURL=https://zitadel.example.com/oidc/v1/userinfo + - TINYAUTH_OAUTH_PROVIDERS_ZITADEL_REDIRECTURL=https://tinyauth.example.com/api/oauth/callback/zitadel + - TINYAUTH_OAUTH_PROVIDERS_ZITADEL_CLIENTID=your-zitadel-client-id + - TINYAUTH_OAUTH_PROVIDERS_ZITADEL_CLIENTSECRET=your-zitadel-client-secret + - TINYAUTH_OAUTH_PROVIDERS_ZITADEL_NAME=Zitadel +``` + +:::note + Zitadel should be accessed using HTTPS and a trusted certificate. In case this is not possible (e.g. self-signed certificates), you will need to use `TINYAUTH_OAUTH_PROVIDERS_ZITADEL_INSECURE=true` in order for Tinyauth to skip the certificate check. +::: + + +:::caution + OAuth alone does not guarantee security. By default, any Zitadel account can + log in as a normal user. To restrict access, use the `TINYAUTH_OAUTH_WHITELIST` + environment variable to allow specific email addresses. Refer to the + [configuration](/docs/reference/configuration) page for details. +::: + +:::note + With OAuth enabled, the `TINYAUTH_AUTH_USERS` or `TINYAUTH_AUTH_USERSFILE` environment variable can be + removed to allow login exclusively through the OAuth provider. +::: + +Restart Tinyauth. Upon visiting the login screen, an additional option to log in with Zitadel will appear. diff --git a/content/docs/contributing.mdx b/src/content/docs/docs/contributing/contributing.mdx similarity index 61% rename from content/docs/contributing.mdx rename to src/content/docs/docs/contributing/contributing.mdx index a1e261f..943db95 100644 --- a/content/docs/contributing.mdx +++ b/src/content/docs/docs/contributing/contributing.mdx @@ -3,14 +3,15 @@ title: Contributing description: Interested in contributing to Tinyauth? Below are the steps to get started. --- -Contributing to Tinyauth is straightforward. Follow the steps below to set up a development server in under five minutes. +Contributing to Tinyauth is straightforward. Follow the steps below to set up a development server. ## Requirements - Bun -- Golang v1.23.2 or later +- Golang v1.24.0 or later - Git - Docker +- Make ## Cloning the Repository @@ -21,6 +22,23 @@ git clone https://github.com/steveiliop56/tinyauth cd tinyauth ``` +## Initialize Submodules + +The project uses Git submodules for some dependencies, so you need to initialize them with: + +```sh +git submodule init +git submodule update +``` + +## Apply patches + +Some of the dependencies must be patched in order to work correctly with the project, you can apply the patches by running: + +```sh +git apply --directory paerser/ patches/nested_maps.diff +``` + ## Installing Requirements While development occurs within Docker, installing the requirements locally is recommended to avoid import errors. Install the Go dependencies: @@ -36,7 +54,7 @@ cd frontend/ bun install ``` -## Creating the `.env` File +## Create the `.env` file Configuration requires an environment file. Copy the `.env.example` file to `.env` and adjust the environment variables as needed. @@ -49,18 +67,24 @@ The development workflow is designed to run entirely within Docker, ensuring com dev.example.com -> 127.0.0.1 ``` - +:::note A domain from [sslip.io](https://sslip.io) can be used if a custom domain is - unavailable. - + unavailable. For example, set the Tinyauth domain to `tinyauth.127.0.0.1.sslip.io` and the whoami domain to `whoami.127.0.0.1.sslip.io`. +::: Ensure the domains are correctly configured in the development Docker Compose file, then start the development environment: ```sh -docker compose -f docker-compose.dev.yml up --build +make dev +``` + +In case you need to build the binary locally, you can run: + +```sh +make binary ``` - +:::note Copying the example `docker-compose.dev.yml` file to `docker-compose.test.yml` - is recommended to prevent accidental commits of sensitive information. - + is recommended to prevent accidental commits of sensitive information. The make recipe will automatically use `docker-compose.test.yml` as well as `docker-compose.test.prod.yml` (for the `make prod` recipe) if it exists. +::: diff --git a/content/docs/getting-started.mdx b/src/content/docs/docs/getting-started.mdx similarity index 83% rename from content/docs/getting-started.mdx rename to src/content/docs/docs/getting-started.mdx index 0e57b84..2f39872 100644 --- a/content/docs/getting-started.mdx +++ b/src/content/docs/docs/getting-started.mdx @@ -3,16 +3,18 @@ title: Getting Started description: Learn how to set up and configure Tinyauth. --- - +import { Tabs, TabItem } from '@astrojs/starlight/components'; + +:::note By default, Tinyauth integrates with the Traefik reverse proxy. For alternative proxies, detailed guides are available for [Nginx Proxy Manager](/docs/guides/nginx-proxy-manager) and [Caddy](/docs/community/caddy). - +::: - +:::note Running Kubernetes? Check out the [Kubernetes](/docs/community/kubernetes) guide and the (experimental) [Helm chart](https://helm.tinyauth.app). - +::: ## Community Resources @@ -21,10 +23,10 @@ Community-driven tutorials and guides offer additional insights: - A tutorial by [Jim's Garage](https://youtube.com/watch?v=qmlHirOpzpc). - A guide on integrating Tinyauth with Pangolin by [ivobrett](https://forum.hhf.technology/t/implementing-external-authentication-in-pangolin-using-tinyauth-and-the-middleware-manager/1417) (requires account). - +:::caution Always refer to the official documentation for the latest deployment instructions and configuration updates. - +::: ## User Creation @@ -40,16 +42,25 @@ flowchart BR The following CLI command facilitates user creation: -```sh -docker run -i -t --rm ghcr.io/steveiliop56/tinyauth:v4 user create --interactive -``` + + + ```sh + docker run -i -t --rm ghcr.io/steveiliop56/tinyauth:v5 user create --interactive + ``` + + + ```sh + ./tinyauth user create --interactive + ``` + + This command prompts for a username and password, generating the required user configuration. Additional details are available in the CLI [reference](/docs/reference/cli#create-user-command). - +:::note When using Docker Compose or environment variables, selecting the "format for docker" option ensures proper escaping of the bcrypt hash. - +::: Multiple users can be created by repeating this process and separating entries with commas. @@ -64,12 +75,12 @@ flowchart BR domain --> app["app.example.com"] ``` - +:::caution Direct usage with DDNS services (e.g., `tinyauth562.duckdns.org`) is not supported due to browser cookie restrictions. Subdomains (e.g., `tinyauth.mylab562.duckdns.org`) must be used for both Tinyauth and applications. - +::: ## Deployment @@ -77,12 +88,11 @@ The following `docker-compose.yml` configuration deploys Tinyauth: ```yaml title="docker-compose.yml" tinyauth: - image: ghcr.io/steveiliop56/tinyauth:v4 - container_name: tinyauth + image: ghcr.io/steveiliop56/tinyauth:v5 restart: unless-stopped environment: - - APP_URL=https://tinyauth.example.com - - USERS=your-username-password-hash + - TINYAUTH_APPURL=https://tinyauth.example.com + - TINYAUTH_AUTH_USERS=your-username-password-hash labels: traefik.enable: true traefik.http.routers.tinyauth.rule: Host(`tinyauth.example.com`) @@ -105,7 +115,6 @@ Below is a complete example integrating Traefik, Whoami, and Tinyauth: services: traefik: image: traefik:v3.3 - container_name: traefik command: --api.insecure=true --providers.docker restart: unless-stopped ports: @@ -115,7 +124,6 @@ services: whoami: image: traefik/whoami:latest - container_name: whoami restart: unless-stopped labels: traefik.enable: true @@ -123,12 +131,11 @@ services: traefik.http.routers.whoami.middlewares: tinyauth tinyauth: - image: ghcr.io/steveiliop56/tinyauth:v4 - container_name: tinyauth + image: ghcr.io/steveiliop56/tinyauth:v5 restart: unless-stopped environment: - - APP_URL=https://tinyauth.example.com - - USERS=user:$$2a$$10$$UdLYoJ5lgPsC0RKqYH/jMua7zIn0g9kPqWmhYayJYLaZQ/FTmH2/u # user:password + - TINYAUTH_APPURL=https://tinyauth.example.com + - TINYAUTH_AUTH_USERS=user:$$2a$$10$$UdLYoJ5lgPsC0RKqYH/jMua7zIn0g9kPqWmhYayJYLaZQ/FTmH2/u # user:password labels: traefik.enable: true traefik.http.routers.tinyauth.rule: Host(`tinyauth.example.com`) diff --git a/content/docs/guides/access-controls.mdx b/src/content/docs/docs/guides/access-controls.mdx similarity index 54% rename from content/docs/guides/access-controls.mdx rename to src/content/docs/docs/guides/access-controls.mdx index 890b4a1..5af38bf 100644 --- a/content/docs/guides/access-controls.mdx +++ b/src/content/docs/docs/guides/access-controls.mdx @@ -3,10 +3,14 @@ title: Access controls description: Tinyauth has support for docker label-based access controls. --- -Tinyauth supports basic access controls with Docker labels. These labels can restrict or allow access to applications. +Tinyauth supports basic access controls with either Docker labels or environment variables. These labels (or environment variables) can restrict or allow access to applications. ## Modifying the Tinyauth Container +:::note +You can skip this step if you're using environment variables. +::: + To enable access controls, add the following volume to the Tinyauth container: ```yaml @@ -18,8 +22,8 @@ services: Restart Tinyauth after setting the volume. - - For increased security, use a Docker socket proxy like [Tecnativa's](https://github.com/Tecnativa/docker-socket-proxy). Configure Tinyauth to use the proxy by adding the following environment variable: +:::note +For increased security, use a Docker socket proxy like [Tecnativa's](https://github.com/Tecnativa/docker-socket-proxy). Configure Tinyauth to use the proxy by adding the following environment variable: ```yaml services: @@ -30,9 +34,20 @@ services: Ensure Tinyauth can reach the Docker socket proxy container. - +If you like, you can also restrict Tinyauth's access to the Docker socket using the following labels: + +```yaml +services: + tinyauth: + labels: + socket-proxy.allow.get: /v1\..*/(version|containers/.*|events.*)|/_ping + socket-proxy.allow.head: /_ping +``` + +*Thank you [@dombyte](https://github.com/dombyte) for the suggestion.* +::: -## Label Structure +## Access Controls Structure Access control labels follow this structure: @@ -40,26 +55,44 @@ Access control labels follow this structure: tinyauth.apps.[app].[key]: [value] ``` +Similarly environment variables follow this structure: + +```sh +TINYAUTH_APPS_[APP]_[KEY]=[VALUE] +``` + Where `[app]` is the name of the app to protect. This app ID must be unique for each protected app. -## Label Discovery +## Access Controls Discovery -Tinyauth uses the app ID in labels and the request subdomain to match labels with the app. For example, a request to `app1.example.com` triggers Tinyauth to search for containers with the `tinyauth.apps.app1.foo: bar` label. To use the domain instead, add the following label: +Tinyauth uses the app ID in labels (or environment variables) and the request subdomain to match the configuration with the app. For example, a request to `app1.example.com` triggers Tinyauth to search for containers with the `tinyauth.apps.app1.foo: bar` label or the `TINYAUTH_APPS_APP1_FOO=bar` environment variable. To use the domain instead, add the following label: ```yaml tinyauth.apps.myapp.config.domain: myapp.example.com ``` -Tinyauth will now use the domain to match labels instead of the app ID. +Or the following environment variable: + +```sh +TINYAUTH_APPS_MYAPP_CONFIG_DOMAIN=myapp.example.com +``` - +Tinyauth will now use the domain to match the configuration instead of the app ID. + +:::note Labels can be set either on the Tinyauth container or the app. However, if you use multiple hosts and the app is running on a different host than Tinyauth, labels must be set on the Tinyauth container. - +::: + +:::caution +Labels are dynamic and can be updated at runtime, environment variables are not and require a restart of Tinyauth to take effect. +::: ## User ACLs +*Going forward, the guide will use the labels format but everything mentioned also applies to environment variables.* + To restrict access to specific users, use the `users.allow` label: ```yaml @@ -72,12 +105,14 @@ Only `user1` will be able to access the app. To block specific users, use the `u tinyauth.apps.myapp.users.block: user2 ``` - +:::note Both `users.allow` and `users.block` labels can accept a comma-separated list of users or a regex string (enclosed with `/`). - +::: -These labels also apply to LDAP users. +:::note +These labels also apply to LDAP users. +::: ## OAuth Whitelist @@ -89,10 +124,10 @@ tinyauth.apps.myapp.oauth.whitelist: user1@example.com Only `user1@example.com` will be able to access the app. - +:::note The `oauth.whitelist` label can accept a comma-separated list of users or a regex string (enclosed with `/`). - +::: ## Path ACLs @@ -108,10 +143,10 @@ To block access to specific paths, use the `path.block` label: tinyauth.apps.myapp.path.block: ^\/admin ``` - +:::caution Path labels use regex strings. For example, `^\/api` matches paths starting with `/api`, while `^\/ping$` matches the exact path `/ping`. - +::: ## IP-Based Access Controls @@ -145,7 +180,21 @@ tinyauth.apps.myapp.oauth.groups: admin Only users in the `admin` group will be allowed to access the app. - +:::caution The `oauth.groups` label is only supported for custom OAuth providers, not for Google or GitHub. - +::: + +## Access Controls Using LDAP Groups + +Tinyauth also supports fetching the user's groups from the LDAP server and using them for access control. To use LDAP groups, add the `ldap.groups` label: + +```yaml +tinyauth.apps.myapp.ldap.groups: admin +``` + +Only users in the `admin` group will be allowed to access the app. + +:::note +For performance reasons, LDAP groups are not fetched for every request. Instead, they are fetched periodically and cached. By default, the cache is refreshed every 15 minutes. If you need to refresh the cache more frequently you can change the `TINYAUTH_LDAP_GROUPCACHETTL` environment variable (accepts interval in seconds). +::: diff --git a/content/docs/guides/advanced.mdx b/src/content/docs/docs/guides/advanced.mdx similarity index 94% rename from content/docs/guides/advanced.mdx rename to src/content/docs/docs/guides/advanced.mdx index 5534316..d7af8b3 100644 --- a/content/docs/guides/advanced.mdx +++ b/src/content/docs/docs/guides/advanced.mdx @@ -7,7 +7,7 @@ Below, you can find some guides for advanced setups. ## Authenticating to Apps with Basic Auth -Some apps already offer authentication methods like basic auth (e.g., browser pop-ups). This can be inconvenient as it requires logging in to both Tinyauth and the protected app. Tinyauth supports authenticating to apps automatically by adding basic auth labels to the protected app. +Some apps already offer authentication methods like basic auth (e.g. browser pop-ups). This can be inconvenient as it requires logging in to both Tinyauth and the protected app. Tinyauth supports authenticating to apps automatically by adding basic auth labels to the protected app. 1. For Traefik, add the following label to the Tinyauth container: diff --git a/content/docs/guides/github-app-oauth.mdx b/src/content/docs/docs/guides/github-app-oauth.mdx similarity index 64% rename from content/docs/guides/github-app-oauth.mdx rename to src/content/docs/docs/guides/github-app-oauth.mdx index 1d0c098..bcedcb1 100644 --- a/content/docs/guides/github-app-oauth.mdx +++ b/src/content/docs/docs/guides/github-app-oauth.mdx @@ -3,7 +3,7 @@ title: GitHub Apps OAuth description: Use the GitHub Apps OAuth screen for authenticating to Tinyauth. --- -Tinyauth also supports GitHub Apps for authentication instead of OAuth Apps. GitHub Apps allow more control over permissions and are slightly more complex to set up. For simpler setups, the [OAuth Apps](/docs/guides/github-oauth.md) guide is recommended. +Tinyauth supports GitHub Apps for authentication instead of OAuth Apps. GitHub Apps allow for more control over permissions and are slightly more complex to set up. For simpler setups, [OAuth Apps](/docs/guides/github-oauth) are recommended. ## Requirements @@ -14,11 +14,9 @@ GitHub requires the following to set up an app: ## Creating the GitHub App -Open the [GitHub Apps](https://github.com/settings/apps) site and click **New GitHub App**. The following screen will appear: +Open the [GitHub Apps](https://github.com/settings/apps) site and click **New GitHub App**. -![GitHub New App Screen](/screenshots/github-app-new.png) - -Fill in the following information: +Then, fill in the following information: | Name | Value | | --------------- | ------------------------------------------------------------------------------------------------------------------------------ | @@ -26,19 +24,21 @@ Fill in the following information: | Homepage URL | Any URL, e.g., `https://tinyauth.app`. | | Callback URL | The Tinyauth app URL followed by `/api/oauth/callback/github`, e.g., `https://tinyauth.example.com/api/oauth/callback/github`. | -Under webhook, ensure the **Active** checkbox is unchecked as webhooks are not required. +![GitHub New App Screen](/screenshots/github/app-new.png) + +Under webhook, ensure the **Active** checkbox is unchecked since webhooks are not required. In the **Permissions** section, click **Account permissions** and set the **Email Addresses** option to **Read-only**: -![GitHub Emails Section](/screenshots/github-app-email.png) +![GitHub Emails Section](/screenshots/github/app-email.png) -Create the app. The following screen will appear: +Finally, create the app. The following screen will appear: -![GitHub App Home](/screenshots/github-app-home.png) +![GitHub App Home](/screenshots/github/app-home.png) Note the client ID. To generate the client secret, click **Generate new client secret**. After authentication, the secret will appear: -![GitHub Client Secret](/screenshots/github-app-client-secret.png) +![GitHub Client Secret](/screenshots/github/app-secret.png) Note down the client ID and secret as they will be required for Tinyauth. @@ -50,20 +50,20 @@ Add the following environment variables to the Tinyauth Docker container: services: tinyauth: environment: - - PROVIDERS_GITHUB_CLIENT_ID=your-github-client-id - - PROVIDERS_GITHUB_CLIENT_SECRET=your-github-secret + - TINYAUTH_OAUTH_PROVIDERS_GITHUB_CLIENTID=your-github-client-id + - TINYAUTH_OAUTH_PROVIDERS_GITHUB_CLIENTSECRET=your-github-secret ``` - +:::caution OAuth alone does not guarantee security. By default, any GitHub account can - log in as a normal user. To restrict access, use the `OAUTH_WHITELIST` + log in as a normal user. To restrict access, use the `TINYAUTH_OAUTH_WHITELIST` environment variable to allow specific email addresses. Refer to the [configuration](/docs/reference/configuration) page for details. - +::: - - With OAuth enabled, the `USERS` or `USERS_FILE` environment variables can be +:::note + With OAuth enabled, the `TINYAUTH_AUTH_USERS` or `TINYAUTH_AUTH_USERSFILE` environment variable can be removed to allow login exclusively through the OAuth provider. - +::: Restart Tinyauth. Upon visiting the login screen, an additional option to log in with GitHub will appear. diff --git a/content/docs/guides/github-oauth.mdx b/src/content/docs/docs/guides/github-oauth.mdx similarity index 80% rename from content/docs/guides/github-oauth.mdx rename to src/content/docs/docs/guides/github-oauth.mdx index aaa0c82..f24487b 100644 --- a/content/docs/guides/github-oauth.mdx +++ b/src/content/docs/docs/guides/github-oauth.mdx @@ -20,7 +20,7 @@ Begin by creating a GitHub OAuth app. Navigate to the [GitHub developer settings | Homepage URL | Can be any URL, e.g., `https://tinyauth.app`. | | Authorization Callback URL | Enter the domain followed by `/api/oauth/callback/github`, e.g., `https://tinyauth.example.com/api/oauth/callback/github`. | -![GitHub new OAuth app](/screenshots/github-new-oauth-app.png) +![GitHub new OAuth app](/screenshots/github/oauth-new.png) After entering the details, click **Register Application**. @@ -28,11 +28,11 @@ After entering the details, click **Register Application**. Once the application is created, the following screen will appear: -![GitHub OAuth app homepage](/screenshots/github-oauth-app-homepage.png) +![GitHub OAuth app homepage](/screenshots/github/oauth-home.png) Note down the client ID. To generate the client secret, click **Generate a new client secret**. GitHub will prompt for login confirmation and then display the secret: -![GitHub OAuth Client Secret](/screenshots/github-oauth-client-secret.png) +![GitHub OAuth Client Secret](/screenshots/github/oauth-secret.png) Note down the client ID and secret for later use. @@ -44,20 +44,20 @@ Add the following environment variables to the Tinyauth Docker container: services: tinyauth: environment: - - PROVIDERS_GITHUB_CLIENT_ID=your-github-client-id - - PROVIDERS_GITHUB_CLIENT_SECRET=your-github-secret + - TINYAUTH_OAUTH_PROVIDERS_GITHUB_CLIENTID=your-github-client-id + - TINYAUTH_OAUTH_PROVIDERS_GITHUB_CLIENTSECRET=your-github-secret ``` - +:::caution OAuth alone does not guarantee security. By default, any GitHub account can - log in as a normal user. To restrict access, use the `OAUTH_WHITELIST` + log in as a normal user. To restrict access, use the `TINYAUTH_OAUTH_WHITELIST` environment variable to allow specific email addresses. Refer to the [configuration](/docs/reference/configuration) page for details. - +::: - - With OAuth enabled, the `USERS` or `USERS_FILE` environment variables can be +:::note + With OAuth enabled, the `TINYAUTH_AUTH_USERS` or `TINYAUTH_AUTH_USERSFILE` environment variable can be removed to allow login exclusively through the OAuth provider. - +::: Restart Tinyauth. Upon visiting the login screen, an additional option to log in with GitHub will appear. diff --git a/content/docs/guides/google-oauth.mdx b/src/content/docs/docs/guides/google-oauth.mdx similarity index 59% rename from content/docs/guides/google-oauth.mdx rename to src/content/docs/docs/guides/google-oauth.mdx index 0d694cc..dd17f4c 100644 --- a/content/docs/guides/google-oauth.mdx +++ b/src/content/docs/docs/guides/google-oauth.mdx @@ -12,21 +12,21 @@ Tinyauth has built-in support for Google OAuth, making it straightforward to set ## Creating the Google OAuth App -To begin, create an app in the [Google Cloud Console](https://console.cloud.google.com/). Create a new project (a default project may already exist). After creating the project, the following screen should appear: +To begin, create an app in the [Google Cloud Console](https://console.cloud.google.com). Create a new project (a default project may already exist). After creating the project, the following screen should appear: -![Google Cloud Console Home](/screenshots/google-cloud-home.png) +![Google Cloud Console Home](/screenshots/google/cloud-home.png) From the quick access menu, click **APIs & Services**, then select **OAuth consent screen** from the sidebar. Click the **Get Started** button in the middle of the screen. - +:::note Google has updated the OAuth section. This guide uses the new OAuth experience. If a button appears saying "Try the new OAuth experience," click it to match the steps in this guide. - +::: After clicking the button, the following screen should appear: -![Configure OAuth Consent Screen](/screenshots/google-cloud-oauth-configure.png) +![Configure OAuth Consent Screen](/screenshots/google/oauth-configure.png) - **App Name**: Use `Tinyauth`. - **Support Email**: Select the available email address. @@ -36,24 +36,23 @@ After clicking the button, the following screen should appear: After some time, the OAuth homepage will appear: -![Google Cloud OAuth Home](/screenshots/google-cloud-oauth-home.png) +![Google Cloud OAuth Home](/screenshots/google/oauth-home.png) Click **Create OAuth Client**. - - If a warning appears about the OAuth Consent Screen not being configured, - refresh the page a few times to proceed. - +:::note + There is a chance a warning appears about the OAuth Consent Screen not being configured. In this case, wait a few minutes and refresh the page. +::: - **Application Type**: Select **Web Application**. - **Name**: Optionally rename the client (default is `Web Client 1`). -- **Authorized Redirect URIs**: Add the domain with the `/api/oauth/callback/google` suffix, e.g., `https://tinyauth.example.com/api/oauth/callback/google`. +- **Authorized Redirect URIs**: Add the domain with the `/api/oauth/callback/google` suffix, e.g. `https://tinyauth.example.com/api/oauth/callback/google`. Click **Create**. Once the application is created, the following screen will appear: -![Google Cloud OAuth Clients](/screenshots/google-cloud-oauth-created.png) +![Google Cloud OAuth Clients](/screenshots/google/client-created.png) -Click the client (e.g., `Web Client 1`) and copy the Client ID and Client Secret from the Additional Information section. +Click the client (e.g. `Web Client 1`) and copy the Client ID and Client Secret from the Additional Information section. ## Configuring Tinyauth @@ -63,20 +62,21 @@ Add the following environment variables to the Tinyauth Docker container: services: tinyauth: environment: - - PROVIDERS_GOOGLE_CLIENT_ID=your-google-client-id - - PROVIDERS_GOOGLE_CLIENT_SECRET=your-google-secret + - TINYAUTH_OAUTH_PROVIDERS_GOOGLE_CLIENTID=your-google-client-id + - TINYAUTH_OAUTH_PROVIDERS_GOOGLE_CLIENTSECRET=your-google-secret ``` - - OAuth alone does not guarantee security. By default, any GitHub account can - log in as a normal user. To restrict access, use the `OAUTH_WHITELIST` +:::caution + OAuth alone does not guarantee security. By default, any Google account can + log in as a normal user. To restrict access, use the `TINYAUTH_OAUTH_WHITELIST` environment variable to allow specific email addresses. Refer to the [configuration](/docs/reference/configuration) page for details. - +::: - - With OAuth enabled, the `USERS` or `USERS_FILE` environment variables can be +:::note + With OAuth enabled, the `TINYAUTH_AUTH_USERS` or `TINYAUTH_AUTH_USERSFILE` environment variable can be removed to allow login exclusively through the OAuth provider. - +::: + Restart Tinyauth. Upon visiting the login screen, an additional option to log in with Google will appear. diff --git a/content/docs/guides/ldap.mdx b/src/content/docs/docs/guides/ldap.mdx similarity index 51% rename from content/docs/guides/ldap.mdx rename to src/content/docs/docs/guides/ldap.mdx index a0e279d..40ac58e 100644 --- a/content/docs/guides/ldap.mdx +++ b/src/content/docs/docs/guides/ldap.mdx @@ -16,13 +16,13 @@ Tinyauth requires at least two users: an observer user with read-only access to ### Creating the Observer User 1. Navigate to the **Users** tab in LLDAP and click **Create a user**. -2. Provide a username, password, and email address, then click **Submit**. +2. Provide a username, password and email address, then click **Submit**. -![LLDAP Create a User](/screenshots/lldap-create-user.png) +![LLDAP Create a User](/screenshots/lldap/new-user.png) 3. After creating the user, select it from the list and scroll to the group memberships section. Add the user to the `lldap_strict_readonly` group by clicking **Add to Group**. -![LLDAP Groups](/screenshots/lldap-groups.png) +![LLDAP Groups](/screenshots/lldap/user-groups.png) ### Creating Additional Users @@ -36,29 +36,56 @@ To connect Tinyauth to the LDAP server, add the following environment variables services: tinyauth: environment: - - LDAP_ADDRESS=ldap://my-lldap-server:3890 - - LDAP_BIND_DN=uid=your-observer-user,ou=people,dc=example,dc=com - - LDAP_BIND_PASSWORD=your-observer-user-password - - LDAP_BASE_DN=dc=example,dc=com - - LDAP_SEARCH_FILTER=(uid=%s) - - LDAP_INSECURE=true + - TINYAUTH_LDAP_ADDRESS=ldap://my-lldap-server:3890 + - TINYAUTH_LDAP_BINDDN=uid=your-observer-user,ou=people,dc=example,dc=com + - TINYAUTH_LDAP_BINDPASSWORD=your-observer-user-password + - TINYAUTH_LDAP_BASEDN=dc=example,dc=com + - TINYAUTH_LDAP_SEARCHFILTER=(uid=%s) + - TINYAUTH_LDAP_INSECURE=true ``` - +:::note The search filter can be customized as needed. The base filter `(uid=%s)` searches for users based on their UID, with `%s` replaced by the username of the user attempting to log in. - +::: - +:::note Replace the bind DN and base DN with values specific to the LDAP server configuration. - +::: After restarting, logging in to Tinyauth with the second user created in LLDAP should be possible. Additional users can be created and used for login as needed. - +:::note LDAP users are treated the same as users from files or environment variables. There is no indication in the UI that a user is logged in via LDAP. All access controls apply equally to LDAP users and standard users. - +::: + +## Using LDAP Groups for access controls + +Tinyauth supports extracting the group information from the LDAP provider. This allows you to configure application groups straight from the LDAP server. Groups are extracted using the `(&(objectclass=groupOfUniqueNames)(uniquemember=%s))` filter where `%s` is replaced by the username of the user attempting to log in. This filter should work with most LDAP servers. + +:::warning + LDAP groups are not refreshed on every request for performance reasons. Instead, they are cached for a short period of time to minimize the number of requests to the LDAP server. The cache duration can be configured using the `TINYAUTH_LDAP_GROUPCACHETTL` environment variable. The default cache duration is 900 seconds (15 minutes). +::: + +After you create a group in LLDAP: + +![LLDAP Create Group](/screenshots/lldap/new-group.png) + +You can then assign users to the group: + +![LLDAP Assign User to Group](/screenshots/lldap/user-assign-group.png) + +Finally, use the [LDAP Group ACL](/docs/guides/access-controls/#access-controls-using-ldap-groups) to allow only users within the `admins` group in your application: + +```yaml +services: + foo: + labels: + tinyauth.apps.foo.ldap.groups: admins +``` + +If an LDAP user is not a member of the `admins` group, they will not be granted access to the application and they will be redirected to an unauthorized page. diff --git a/content/docs/guides/nginx-proxy-manager.mdx b/src/content/docs/docs/guides/nginx-proxy-manager.mdx similarity index 66% rename from content/docs/guides/nginx-proxy-manager.mdx rename to src/content/docs/docs/guides/nginx-proxy-manager.mdx index cef39d1..9590a62 100644 --- a/content/docs/guides/nginx-proxy-manager.mdx +++ b/src/content/docs/docs/guides/nginx-proxy-manager.mdx @@ -5,18 +5,17 @@ description: Use Tinyauth with the Nginx Proxy Manager reverse proxy. Nginx Proxy Manager is a popular tool in the homelab community for managing reverse proxies. While it differs from Traefik and Caddy due to Nginx's lack of native 302 redirect support in the `auth_request` module, Tinyauth provides API paths specifically designed to work with it. - +:::note This guide assumes familiarity with Nginx Proxy Manager. - +::: ## Example Docker Compose File -The following Docker Compose file demonstrates how to set up Nginx Proxy Manager, Nginx, and Tinyauth: +The following Docker Compose file demonstrates how to set up Nginx Proxy Manager, Whoami, and Tinyauth: ```yaml title="docker-compose.yml" services: npm: - container_name: npm image: jc21/nginx-proxy-manager:2 restart: unless-stopped ports: @@ -27,25 +26,23 @@ services: - npm-data:/data - npm-letsencrypt:/etc/letsencrypt - nginx: - container_name: nginx - image: nginx:latest + whoami: + image: traefik/whoami:latest restart: unless-stopped tinyauth: - container_name: tinyauth - image: ghcr.io/steveiliop56/tinyauth:v4 + image: ghcr.io/steveiliop56/tinyauth:v5 restart: unless-stopped environment: - - APP_URL=http://tinyauth.example.com - - USERS=user:$$2a$$10$$UdLYoJ5lgPsC0RKqYH/jMua7zIn0g9kPqWmhYayJYLaZQ/FTmH2/u # user:password + - TINYAUTH_APPURL=http://tinyauth.example.com + - TINYAUTH_AUTH_USERS=user:$$2a$$10$$UdLYoJ5lgPsC0RKqYH/jMua7zIn0g9kPqWmhYayJYLaZQ/FTmH2/u # user:password volumes: npm-data: npm-letsencrypt: ``` -OAuth and access controls can be configured using Docker labels and environment variables. All other configurations are managed through the Nginx Proxy Manager UI. +OAuth and access controls can be configured using Docker labels and environment variables. All other configuration is managed through the Nginx Proxy Manager UI. ## Configuring Nginx Proxy Manager @@ -53,40 +50,32 @@ OAuth and access controls can be configured using Docker labels and environment Create a host for Tinyauth in Nginx Proxy Manager. Configure it as any other host: -Nginx Proxy Manager Tinyauth host +![Create Tinyauth Host](/screenshots/npm/tinyauth-host.png) SSL can be set up if certificates are available. - +:::caution Ensure the "Block Common Exploits" option is disabled. If enabled, Nginx will block URLs in query parameters, which are required for Tinyauth to function. - +::: ### Configuring Protected Hosts -For protected hosts, such as Nginx, configure the Details tab similarly to the Tinyauth host: +For protected hosts, such as Whoami, configure the Details tab similarly to the Tinyauth host: -Nginx Proxy Manager Nginx host +![Create Whoami Host](/screenshots/npm/whoami-host.png) SSL can be configured as needed. - +:::note The "Block Common Exploits" option can remain enabled for protected hosts. - +::: ### Advanced Configuration Add the following configuration in the Advanced tab to enable Tinyauth authentication: -```shell +```sh # Root location location / { # Pass the request to the app @@ -116,13 +105,32 @@ location @tinyauth_login { } ``` - +It should look like this: + +![Whoami Host Advanced](/screenshots/npm/whoami-advanced.png) + +:::note The `/tinyauth` path can be renamed for convenience. - +::: - +:::note Additional configuration may be required under the `/` location for technologies like WebSockets. - +::: + +:::note + Due to the way Nginx handles forward auth, Tinyauth cannot automatically redirect to the unauthorized page. Thus, users may be redirected to a blank 403 Forbidden page in case of a failed authentication. This can be somehow mitigated by configuring a custom error page for the 403 status code: + + ```sh + location / { + # Rest of your configuration + error_page 403 = @tinyauth_unauthorized; + } + + location @tinyauth_unauthorized { + return 302 http://tinyauth.example.com/unauthorized?username=unavailable; # Replace with your app URL + } + ``` +::: Save the host configuration. Accessing the protected host will redirect to the Tinyauth login page if not already logged in. Repeat this process for each host to be protected by Tinyauth. diff --git a/src/content/docs/docs/guides/oidc.mdx b/src/content/docs/docs/guides/oidc.mdx new file mode 100644 index 0000000..a9edae4 --- /dev/null +++ b/src/content/docs/docs/guides/oidc.mdx @@ -0,0 +1,154 @@ +--- +title: OIDC Server +description: Use Tinyauth's OIDC server to authenticate applications. +--- + +import { Tabs, TabItem } from '@astrojs/starlight/components'; + +In Tinyauth v5, a major milestone was the introduction of the OIDC server, which allows Tinyauth not only to use other identity providers but also to act as an identity provider itself. This means that Tinyauth can serve as a central authentication gateway for multiple applications, providing a single sign-on experience for users. + +## What is OpenID Connect? + +From [https://openid.net](https://openid.net/developers/discover-openid-and-openid-connect/): + +> OpenID is an easy and safe way for people to reuse an existing account and user profile from an identity provider, for example Apple, Google, or Microsoft to sign-in to any OpenID-enabled applications and websites without creating a new registration and password. You choose the provider, such as Google and enter your Gmail address and password to sign-in. + +## Limitations + +Tinyauth implements the Core and Discovery parts of the OpenID Connect protocol. + +![OpenID Connect Protocol Suite](https://openid.net/wp-content/uploads/2023/06/OpenIDConnect-Map-December2023.png) + +Before using Tinyauth as an OIDC server, please ensure that the implementation meets your requirements. + +Supported response types: + +- `code` + +Supported authorization grant types: + +- `authorization_code` +- `refresh_token` + +Supported scopes: + +- `openid` +- `profile` +- `email` +- `groups` + +Supported claims: + +- `sub` +- `name` +- `email` +- `preferred_username` +- `groups` +- `updated_at` + +Supported token endpoint authentication methods: + +- `client_secret_basic` +- `client_secret_post` + +Due to the *mostly* stateless nature of Tinyauth, the user `sub` is based on the client ID and the username. This means that if the username or client ID changes, the `sub` will also change. This can cause issues with some OIDC clients that rely on the `sub` claim to identify the user consistently. + +:::note +While no promises can be made, if you feel a required OpenID Connect feature is missing, please open an issue on [GitHub](https://github.com/steveiliop56/tinyauth/issues). +::: + +## Important Considerations + +Tinyauth’s core idea is to be a stateless application, but OIDC requires persistence for session and key storage. Everything is stored in the `/data` directory so, if you are using Docker, add the corresponding volume to your `docker-compose.yml` file: + +```yaml +services: + tinyauth: + volumes: + - ./data:/data +``` + +If you are using the binary, you can specify the database directory using the `TINYAUTH_DATABASE_PATH` environment variable or the `--database.path` CLI flag. + +You also need to specify the paths where the public and private keys are stored. This can be done with `TINYAUTH_OIDC_PRIVATEKEYPATH` (`--oidc.privatekeypath`) and `TINYAUTH_OIDC_PUBLICKEYPATH` (`--oidc.publickeypath`) respectively. + +Lastly, for the OIDC server to work, HTTPS is **required** on the app URL, which will become the issuer URL. You can use a self‑signed certificate or a certificate from a trusted CA. + +## Creating OIDC Clients + +To create an OIDC client, use the `oidc create` command. For example: + + + + ```sh + docker run -i -t --rm ghcr.io/steveiliop56/tinyauth:v5 oidc create myapp + ``` + + + ```sh + ./tinyauth oidc create myapp + ``` + + + +This will produce output similar to: + +``` +Client Name: myapp +Client ID: client-id +Client Secret: ta-client-secret +``` + +We will use these values for the rest of this guide so, make sure to keep them secure. + +:::caution +The name of the client must be unique and contain only alphanumeric characters and hyphens. +::: + +You can repeat this process for as many clients as you need. + +## Tinyauth Configuration + +Each OIDC client must be configured with the following environment variables (using the values from the previous step): + +```sh +TINYAUTH_OIDC_CLIENTS_MYAPP_CLIENTID=client-id +TINYAUTH_OIDC_CLIENTS_MYAPP_CLIENTSECRET=ta-client-secret +TINYAUTH_OIDC_CLIENTS_MYAPP_TRUSTEDREDIRECTURIS=https://example.com/callback +TINYAUTH_OIDC_CLIENTS_MYAPP_NAME=myapp +``` + +Or with the following set of CLI flags: + +```sh +--oidc.clients.myapp.clientid=client-id +--oidc.clients.myapp.clientsecret=ta-client-secret +--oidc.clients.myapp.trustedredirecturis=https://example.com/callback +--oidc.clients.myapp.name=myapp +``` + +:::note +If you prefer, you can use `TINYAUTH_OIDC_CLIENTS_[NAME]_CLIENTSECRETFILE` (`--oidc.clients.[name].clientsecretfile`) to specify the path where the client secret is stored. +::: + +## App Configuration + +Tinyauth exposes a well-known path at `/.well-known/openid-configuration` that apps can use to automatically discover the OIDC server configuration. If your app does not support discovery, you can configure it with the following endpoints: + +| Name | URL | +| --------------------- | ---------------------------------------------------- | +| Authorization Endpoint | `https://tinyauth.example.com/authorize` | +| Token Endpoint | `https://tinyauth.example.com/api/oidc/token` | +| Userinfo Endpoint | `https://tinyauth.example.com/api/oidc/userinfo` | + +## Usage + +After everything is set up, start Tinyauth and access your application. After you select Tinyauth as the authentication source, you should be redirected to Tinyauth, where you can log in and authorize the application. + +![Tinyauth Authorize Screen](/screenshots/oidc/authorize.png) + +:::note +For local and LDAP accounts, Tinyauth will preserve the OIDC request while logging in. For OAuth logins, you must first log in to Tinyauth in another window or tab and then authorize the application. This will be improved in the future. +::: + +Tinyauth will pass user information from the OIDC or LDAP provider to the application while acting as a proxy. Enjoy! diff --git a/content/docs/guides/pocket-id.mdx b/src/content/docs/docs/guides/pocket-id.mdx similarity index 59% rename from content/docs/guides/pocket-id.mdx rename to src/content/docs/docs/guides/pocket-id.mdx index 6d6c130..149f53b 100644 --- a/content/docs/guides/pocket-id.mdx +++ b/src/content/docs/docs/guides/pocket-id.mdx @@ -3,7 +3,7 @@ title: Pocket ID OAuth description: Use Pocket ID as an OAuth provider in Tinyauth. --- -[Pocket ID](https://pocket-id.org) is a popular OIDC server that enables login to apps with passkeys. Most proxies do not support OIDC/OAuth servers for authentication, meaning Pocket ID cannot be connected with them. With Tinyauth, Pocket ID can be integrated with proxies to secure apps. +[Pocket ID](https://pocket-id.org) is a popular OIDC server that enables login to apps with passkeys. Most proxies do not support OIDC/OAuth servers for authentication, meaning Pocket ID cannot be used with them. With Tinyauth, Pocket ID can be integrated with proxies to secure apps. ## Requirements @@ -13,22 +13,22 @@ A working Pocket ID installation is required. Refer to Pocket ID's [documentatio Begin by accessing Pocket ID's admin dashboard: -![Pocket ID Admin Page](/screenshots/pocket-id-home.png) +![Pocket ID Admin Page](/screenshots/pocketid/home.png) -Navigate to the **OIDC Clients** tab and click **Add OIDC Client**. Provide the following details: +Navigate to the **OIDC Clients** tab (under **Administration**) and click **Add OIDC Client**. Provide the following details: | Name | Value | | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | Name | Assign a name to the client, such as `Tinyauth`. | | Callback URLs | Enter the Tinyauth app URL followed by `/api/oauth/callback/pocketid`. For example: `https://tinyauth.example.com/api/oauth/callback/pocketid`. | -![Pocket ID Create Client](/screenshots/pocket-id-new-client.png) +![Pocket ID Create Client](/screenshots/pocketid/new-client.png) Optionally, upload a logo for the OIDC client. The Tinyauth logo is available on [GitHub](https://github.com/steveiliop56/tinyauth/blob/main/assets/logo.png). Click **Save**. A new page will display the OIDC credentials: -![Pocket ID Client Page](/screenshots/pocket-id-client-page.png) +![Pocket ID Client Page](/screenshots/pocketid/client-page.png) Note down the client ID and secret for later use. @@ -40,27 +40,36 @@ To integrate Tinyauth with Pocket ID, add the following environment variables to services: tinyauth: environment: - - PROVIDERS_POCKETID_CLIENT_ID=your-pocket-id-client-id - - PROVIDERS_POCKETID_CLIENT_SECRET=your-pocket-id-client-secret - - PROVIDERS_POCKETID_AUTH_URL=https://pocket-id.example.com/authorize - - PROVIDERS_POCKETID_TOKEN_URL=https://pocket-id.example.com/api/oidc/token - - PROVIDERS_POCKETID_USER_INFO_URL=https://pocket-id.example.com/api/oidc/userinfo - - PROVIDERS_POCKETID_REDIRECT_URL=https://tinyauth.example.com/api/oauth/callback/pocketid - - PROVIDERS_POCKETID_SCOPES=openid email profile groups - - PROVIDERS_POCKETID_NAME=Pocket ID + - TINYAUTH_OAUTH_PROVIDERS_POCKETID_CLIENTID=your-pocket-id-client-id + - TINYAUTH_OAUTH_PROVIDERS_POCKETID_CLIENTSECRET=your-pocket-id-client-secret + - TINYAUTH_OAUTH_PROVIDERS_POCKETID_AUTHURL=https://pocket-id.example.com/authorize + - TINYAUTH_OAUTH_PROVIDERS_POCKETID_TOKENURL=https://pocket-id.example.com/api/oidc/token + - TINYAUTH_OAUTH_PROVIDERS_POCKETID_USERINFOURL=https://pocket-id.example.com/api/oidc/userinfo + - TINYAUTH_OAUTH_PROVIDERS_POCKETID_REDIRECTURL=https://tinyauth.example.com/api/oauth/callback/pocketid + - TINYAUTH_OAUTH_PROVIDERS_POCKETID_SCOPES=openid email profile groups + - TINYAUTH_OAUTH_PROVIDERS_POCKETID_NAME=Pocket ID ``` - - Set the `OAUTH_AUTO_REDIRECT` environment variable to `pocketid` to enable +:::note + Pocket ID should be accessed using HTTPS and a trusted certificate. In case this is not possible (e.g. self-signed certificates), you will need to use `TINYAUTH_OAUTH_PROVIDERS_POCKETID_INSECURE=true` in order for Tinyauth to skip the certificate check. +::: + +:::note + Set the `TINYAUTH_OAUTH_AUTOREDIRECT` environment variable to `pocketid` to enable automatic redirection to Pocket ID for Tinyauth-protected apps. - +::: - +:::caution OAuth alone does not guarantee security. By default, any Pocket ID account can - log in as a normal user. To restrict access, use the `OAUTH_WHITELIST` + log in as a normal user. To restrict access, use the `TINYAUTH_OAUTH_WHITELIST` environment variable to allow specific email addresses. Refer to the [configuration](/docs/reference/configuration) page for details. - +::: + +:::note + With OAuth enabled, the `TINYAUTH_AUTH_USERS` or `TINYAUTH_AUTH_USERSFILE` environment variable can be + removed to allow login exclusively through the OAuth provider. +::: Restart Tinyauth to apply the changes. The login screen will now include an option to log in with Pocket ID. @@ -68,11 +77,11 @@ Restart Tinyauth to apply the changes. The login screen will now include an opti Pocket ID supports user groups, which can simplify access control management. To use groups, create one by navigating to the **User Groups** tab and clicking **Add Group**. Assign a name and save the group: -![Pocket ID New Group](/screenshots/pocket-id-new-group.png) +![Pocket ID New Group](/screenshots/pocketid/new-group.png) Select users to include in the group: -![Pocket ID Group Home](/screenshots/pocket-id-group-home.png) +![Pocket ID Group Home](/screenshots/pocketid/group-home.png) Configure Tinyauth-protected apps to require OAuth groups by adding the `oauth.groups` label: @@ -82,10 +91,10 @@ tinyauth.apps.myapp.oauth.groups: admins In this example, only Pocket ID users in the `admins` group can access the app. Users outside the group will be redirected to an unauthorized page. - +:::caution By default, Tinyauth uses the subdomain name of the request to find a matching container for labels. For example, a request to `myapp.example.com` checks for labels in the container named `myapp`. This behavior can be modified using the `tinyauth.apps.[app].config.domain` label. Refer to the [access - controls](./access-controls.md#label-discovery) guide for more information. - + controls](/docs/guides/access-controls#access-controls-discovery) guide for more information. +::: diff --git a/content/docs/guides/runtipi.mdx b/src/content/docs/docs/guides/runtipi.mdx similarity index 61% rename from content/docs/guides/runtipi.mdx rename to src/content/docs/docs/guides/runtipi.mdx index f7cb666..d2dbc0a 100644 --- a/content/docs/guides/runtipi.mdx +++ b/src/content/docs/docs/guides/runtipi.mdx @@ -9,17 +9,13 @@ Runtipi is an open-source personal homeserver helper designed to manage and run Users can be created using the Tinyauth [CLI](/docs/reference/cli#create-user-command). Ensure the "format for docker" option is selected to allow Tinyauth to parse the user correctly. -The Runtipi app includes inputs for GitHub and Google. To use OAuth, refer to the OAuth guides and note the client IDs and secrets. +The Runtipi app includes inputs for GitHub and Google OAuth. To use OAuth, refer to the OAuth guides and note the client IDs and secrets. ## Modifying the Forward Auth Middleware By default, Runtipi uses its own login screen for authentication. To replace it with Tinyauth, enable advanced settings: -Enable advanced settings +![Enable advanced settings](/screenshots/runtipi/advanced-settings-enable.png) Set the forward auth URL to: @@ -27,40 +23,31 @@ Set the forward auth URL to: http://tinyauth:3000/api/auth/traefik ``` -![Set forward auth URL](/screenshots/runtipi-forward-auth-url.png) +![Set forward auth URL](/screenshots/runtipi/forward-auth-url.png) Save the settings and restart Runtipi. - - From Runtipi version v4, multiple appstore support was added. This may change +:::note + From Runtipi version v4, multiple app stores support was added. This may change the container name. If redirection to the Tinyauth login screen fails, use: `http://tinyauth_migrated-tinyauth-1:3000/api/auth/traefik` as the forward - auth URL. - + auth URL (assuming you are installing Tinyauth from the official appstore). +::: ## Installing Tinyauth -Navigate to the appstore tab, select the Tinyauth app, and fill in the users, OAuth credentials, and other required information. Before installation, enable either the local domain switch or the expose switch to ensure Tinyauth is accessible via a domain. This is necessary for proper cookie handling. Depending on the setup, use either the local domain or the exposed domain as the app URL (ensure HTTPS is used). Complete the installation process. +Navigate to the appstore tab, select the Tinyauth app, and fill in the users, OAuth credentials, and other required information. Before installation, enable either the local domain switch or the expose switch to ensure Tinyauth is accessible via a domain. This is necessary for proper cookie handling. Depending on the setup, use either the local domain or the exposed domain as the app URL (ensure HTTPS is used). Finally, complete the installation process. - - Additional customization options, such as adding more OAuth providers, are - available through Runtipi's - [user-config](https://runtipi.io/docs/guides/customize-app-config). - +:::note + Additional customization options such as adding more OAuth providers are available through Runtipi's [user-config](https://runtipi.io/docs/guides/customize-app-config). +::: ## Enabling Authentication for Applications Authentication can be enabled for any application by opening its settings and toggling the enable authentication switch: -Install app example - - - For authentication to function correctly, ensure the local domain or exposed - domain shares the same root-level domain as Tinyauth. For example, - `tinyauth.example.com` and `nginx.example.com` will work, but - `tinyauth.domain1.com` and `nginx.domain2.com` will not. - +![Enable authentication for app](/screenshots/runtipi/app-auth-enable.png) + +:::caution + For authentication to function correctly, ensure the local domain or exposed domain shares the same root-level domain as Tinyauth. For example, `tinyauth.example.com` and `nginx.example.com` will work, but `tinyauth.domain1.com` and `nginx.domain2.com` will not. +::: diff --git a/src/content/docs/docs/guides/totp.mdx b/src/content/docs/docs/guides/totp.mdx new file mode 100644 index 0000000..fc517da --- /dev/null +++ b/src/content/docs/docs/guides/totp.mdx @@ -0,0 +1,50 @@ +--- +title: Two factor authentication +description: Use TOTP to add an additional layer of security to your accounts. +--- + +import { Tabs, TabItem } from '@astrojs/starlight/components'; + +Tinyauth has built-in support for TOTP, enabling the use of authenticator apps to generate 2FA codes for logging in. + +## Generating the Secret + +A TOTP secret must first be generated. This requires the current `username:hash`. Use the Tinyauth CLI to create the new user: + + + + ```sh + docker run -i -t --rm ghcr.io/steveiliop56/tinyauth:v5 totp generate --interactive + ``` + + + ```sh + ./tinyauth totp generate --interactive + ``` + + + +The command prompts for the user and generates a QR code to scan with an authenticator app. Once added, copy the newly generated user (displayed after the `user=` log message) and include it in the Tinyauth user list. Restart Tinyauth to apply changes. From this point, logging in will require a TOTP code. + +:::note +Due to the way the QR code library works in Tinyauth, the QR code may be **massive** and may not fit on a standard screen. If this happens, you can try resizing your terminal window or using a different terminal emulator. +::: + +## Verifying the User + +If you want to ensure that the user is configured correctly, you can use the following command: + + + + ```sh + docker run -i -t --rm ghcr.io/steveiliop56/tinyauth:v5 user verify --interactive + ``` + + + ```sh + ./tinyauth user verify --interactive + ``` + + + +The command prompts for the `username:hash:totp`, username, password, and a TOTP code from the authenticator app. If successful, a user verified message is displayed. diff --git a/content/docs/guides/using-the-binary.mdx b/src/content/docs/docs/guides/using-the-binary.mdx similarity index 77% rename from content/docs/guides/using-the-binary.mdx rename to src/content/docs/docs/guides/using-the-binary.mdx index 352aad4..6547969 100644 --- a/content/docs/guides/using-the-binary.mdx +++ b/src/content/docs/docs/guides/using-the-binary.mdx @@ -21,7 +21,17 @@ Configuration can be achieved using either CLI flags or environment variables (r curl -o .env https://raw.githubusercontent.com/steveiliop56/tinyauth/refs/heads/main/.env.example ``` -Edit the `.env` file to replace template values with actual values or remove unnecessary variables. Alternatively, CLI flags can be used for configuration, though this method is less recommended due to potential complexity and shell parsing issues. Always use quotes (`'`) to ensure values are passed correctly. +:::note +It is recommended to use a tag when downloading the example `.env` file to ensure you are using the latest stable version and not a development one. For example: + +```sh +curl -o .env https://raw.githubusercontent.com/steveiliop56/tinyauth/refs/tags/v5.0.0/.env.example +``` + +Will download the example `.env` file for the `v5.0.0` tag. +::: + +Edit the `.env` file to replace template values with actual values or remove unnecessary ones. Alternatively, CLI flags can be used for configuration, though this method is less recommended due to potential complexity and shell parsing issues. Always use quotes (`'`) to ensure values are passed correctly. A complete list of environment variables and CLI flags is available on the [configuration](/docs/reference/configuration) page. @@ -33,10 +43,10 @@ Once configured, start Tinyauth. For environment variable-based setups, export t export $(grep -v '^#' .env | xargs -d '\n') ``` - +:::note To unset the environment variables for security purposes, use: `unset $(grep -v '^#' .env | sed -E 's/(.*)=.*/\1/' | xargs)`. - +::: Start the Tinyauth server: @@ -47,7 +57,7 @@ Start the Tinyauth server: For CLI flag-based setups, pass the flags directly: ```sh -./tinyauth --app-url=https://tinyauth.example.com +./tinyauth --appurl=https://tinyauth.example.com ``` ## Running as a systemd Service @@ -68,16 +78,16 @@ Restart=on-failure WantedBy=multi-user.target ``` - +:::note Replace the paths in the service file with the actual locations of the environment and binary files. - +::: - +:::note For CLI flag-based setups, remove the `EnvironmentFile` line and append the flags to the `ExecStart` line, e.g., `ExecStart=/some/path/tinyauth - --app-url=https://tinyauth.example.com`. - + --appurl=https://tinyauth.example.com`. +::: Reload the systemd daemon: diff --git a/content/docs/changelog.mdx b/src/content/docs/docs/reference/changelog.mdx similarity index 86% rename from content/docs/changelog.mdx rename to src/content/docs/docs/reference/changelog.mdx index f4da6b7..cf3aa18 100644 --- a/content/docs/changelog.mdx +++ b/src/content/docs/docs/reference/changelog.mdx @@ -3,6 +3,51 @@ title: Changelog description: Overview of changes and updates in Tinyauth versions. --- +## v5.0.0 + +:::caution +This is a breaking release, please refer to the [documentation](/docs/breaking-updates/4-to-5) for migration instructions. +::: + +### New Features + +- Experimental config file support +- Add support for Envoy proxy [@pushpinderbal](https://github.com/pushpinderbal) +- Refresh session cookie whilst session is active +- Forward `sub` claim from OAuth providers in the `Remote-Sub` header +- Support for ACLs using environment variables/CLI flags/config file +- Add mTLS / client certificate authentication support in LDAP [@plaes](https://github.com/plaes) +- Add support for global IP filters +- Configurable component-level logging [@pushpinderbal](https://github.com/pushpinderbal) +- LDAP group ACLs +- Auto submit TOTP code when it gets typed in +- OIDC server + +### Improvements + +- Auto create database directory if it doesn't exist [@modrin](https://github.com/modrin) +- Use one unified config format for environment variables, CLI flags and config file +- Improve frontend performance by minimizing use-effect calls and chunk size [@nicotsx](https://github.com/nicotsx) + +### Fixes + +- Fix language detection storing incorrect code in local storage +- Add rate-limiting in the forward auth endpoint to prevent brute-force attacks using basic auth [@offw0rld](https://github.com/offw0rld) +- Hide username provider when no users are configured [@pushpinderbal](https://github.com/pushpinderbal) +- Set Gin mode in code rather than environment variables +- Ensure safe redirect check checks for periods + +### Technical + +- Bump dependencies +- Update translations +- Fix [CVE-2025-55182](https://github.com/advisories/GHSA-fv66-9v8q-g76r "CVE-2025-55182") in React [@d3vv3](https://github.com/d3vv3) +- Split app bootstrap into smaller jobs for better readability and maintainability +- Use correct module name - Tinyauth now listed in [pkg.go.dev](https://pkg.go.dev/github.com/steveiliop56/tinyauth) +- Replace GORM with vanilla SQL and SQLC for smaller size and more maintainable code +- Add a `Makefile` to simplify development +- Simplify user parsing logic since we can offload things to paerser + ## v4.1.0 ### New Features @@ -34,10 +79,10 @@ description: Overview of changes and updates in Tinyauth versions. ## v4.0.1 - +:::caution This release contains a security fix regarding label discovery, please update as soon as possible. - +::: ### Improvements @@ -60,10 +105,10 @@ description: Overview of changes and updates in Tinyauth versions. ## v4.0.0 - +:::caution This is a breaking release, please follow the migration guide in the [documentation](/docs/breaking-updates/3-to-4). - +::: ### New Features diff --git a/src/content/docs/docs/reference/cli.mdx b/src/content/docs/docs/reference/cli.mdx new file mode 100644 index 0000000..eb67b0e --- /dev/null +++ b/src/content/docs/docs/reference/cli.mdx @@ -0,0 +1,197 @@ +--- +title: CLI +description: Reference on the Tinyauth CLI. +--- + +import { Tabs, TabItem } from '@astrojs/starlight/components'; + +Tinyauth offers a simple CLI to configure the app and manage users. + +## Commands + +All commands can be run from the standalone Tinyauth binary: + +```sh +./tinyauth [options] +``` + +Alternatively, when running the app through Docker: + +```sh +docker run -i -t --rm ghcr.io/steveiliop56/tinyauth:v5 [options] +``` + +:::note + When using Docker Compose, the command `docker compose run tinyauth [options]` + can also be used. +::: + +### Main Command + +The main command starts the API and web UI, waiting for incoming connections. All options are configurable with CLI flags or environment variables. A complete list of configuration options is available on the [configuration](/docs/reference/configuration) page. + +### Healthcheck command + +The health check command verifies if Tinyauth is running correctly: + + + + ```sh + docker compose exec tinyauth healthcheck + ``` + + + ```sh + ./tinyauth healthcheck + ``` + + + +By default, it will use `http://127.0.0.1:3000` to check the health endpoint. The URL will automatically change if you set the `TINYAUTH_SERVER_PORT` and the `TINYAUTH_SERVER_ADDRESS` environment variables. You can also specify a custom URL with: + + + + ```sh + docker compose exec tinyauth healthcheck http://tinyauth.example.com + ``` + + + ```sh + ./tinyauth healthcheck http://tinyauth.example.com + ``` + + + +:::note +It is advised to not use the healthcheck command with the public URL of Tinyauth as it can result in connection issues. It is recommended to use the healthcheck command with the internal URL of Tinyauth (e.g., `http://127.0.0.1:3000`). +::: + +### Create User Command + +The create command simplifies user creation. To create a user interactively: + + + + ```sh + docker run -i -t --rm ghcr.io/steveiliop56/tinyauth:v5 user create --interactive + ``` + + + ```sh + ./tinyauth user create --interactive + ``` + + + + +This launches an interactive TUI to input a username and password, generating the `username:hash` format required by Tinyauth. It can also format the user for Docker Compose or environment variables. For non-interactive creation: + + + + ```sh + docker run -i -t --rm ghcr.io/steveiliop56/tinyauth:v5 user create --username user@example.com --password password + ``` + + + ```sh + ./tinyauth user create --username user@example.com --password password + ``` + + + + +| Flag | Description | Default | Required | +| ---------------------- | ---------------------------------------------------------- | ------- | -------- | +| `--username` | Username for creating the user. | `` | yes | +| `--password` | Password for creating the user. | `` | yes | +| `--docker` | Format output for Docker Compose or environment variables. | `false` | no | +| `--interactive` | Use an interactive TUI for user creation. | `false` | no | + +### Verify User Command + +The verify command checks if a username and password match the `username:hash`. For interactive verification: + + + + ```sh + docker run -i -t --rm ghcr.io/steveiliop56/tinyauth:v5 user verify --interactive + ``` + + + ```sh + ./tinyauth user verify --interactive + ``` + + + +A TUI prompts for the `username:hash:secret`, username, password and optional TOTP code, verifying the credentials. For non-interactive verification: + + + + ```sh + docker run -i -t --rm ghcr.io/steveiliop56/tinyauth:v5 user verify --user 'user@example.com:$2a$10$UdLYoJ5lgPsC0RKqYH/jMua7zIn0g9kPqWmhYayJYLaZQ/FTmH2/u' --username user@example.com --password password + ``` + + + ```sh + ./tinyauth user verify --user 'user@example.com:$2a$10$UdLYoJ5lgPsC0RKqYH/jMua7zIn0g9kPqWmhYayJYLaZQ/FTmH2/u' --username user@example.com --password password + ``` + + + +:::note + Use quotes (`'`) in bash shells to ensure the hash is passed correctly. +::: + +| Flag | Description | Default | Required | +| ---------------------- | ------------------------------------------ | ------- | -------- | +| `--user` | The `username:hash` combination to verify. | `` | yes | +| `--username` | Username for verification. | `` | yes | +| `--password` | Password for verification. | `` | yes | +| `--interactive` | Use an interactive TUI for verification. | `false` | no | +| `--totp` | Optional TOTP code for verification. | `` | no | + +### Generate TOTP Command + +Tinyauth can auto-generate TOTP codes for you, the combination is `username:hash:secret`. You can generate a TOTP user with: + + + + ```sh + docker run -i -t --rm ghcr.io/steveiliop56/tinyauth:v5 totp generate --interactive + ``` + + + ```sh + ./tinyauth totp generate --interactive + ``` + + + +This prompts for the current `username:hash` and generates a `username:hash:secret` along with a QR code for adding to an authenticator app. For non-interactive generation: + + + + ```sh + docker run -i -t --rm ghcr.io/steveiliop56/tinyauth:v5 totp generate --user 'user@example.com:$2a$10$UdLYoJ5lgPsC0RKqYH/jMua7zIn0g9kPqWmhYayJYLaZQ/FTmH2/u' + ``` + + + ```sh + ./tinyauth totp generate --user 'user@example.com:$2a$10$UdLYoJ5lgPsC0RKqYH/jMua7zIn0g9kPqWmhYayJYLaZQ/FTmH2/u' + ``` + + + +:::note + Use quotes (`'`) in bash shells to ensure the hash is passed correctly. +::: + +:::note +Due to the way the QR code library works in Tinyauth, the QR code may be **massive** and may not fit on a standard screen. If this happens, you can try resizing your terminal window or using a different terminal emulator. +::: + +| Flag | Description | Default | Required | +| ---------------------- | -------------------------------------------------- | ------- | -------- | +| `--user` | The current `username:hash` combination. | `` | yes | +| `--interactive` | Use an interactive TUI for creating the TOTP user. | `false` | no | diff --git a/src/content/docs/docs/reference/configuration.mdx b/src/content/docs/docs/reference/configuration.mdx new file mode 100644 index 0000000..6669ce7 --- /dev/null +++ b/src/content/docs/docs/reference/configuration.mdx @@ -0,0 +1,160 @@ +--- +title: Configuration +description: Reference on Tinyauth's configuration. +--- + +Tinyauth can be configured using environment variables or CLI flags. The table below provides a comprehensive list of configuration options. + +:::note + Configuration options with a `FILE_` equivalent (e.g., `USERS` and + `USERS_FILE`) allow the `FILE_` environment variable or CLI flag to be used as + an alternative. +::: + +## General Configuration + +| Environment | Flag | Description | Default | +| - | - | - | - | +| `TINYAUTH_APPURL` | `--appurl` | The base URL where the app is hosted. | `` | + +## Database Configuration + +| Environment | Flag | Description | Default | +| - | - | - | - | +| `TINYAUTH_DATABASE_PATH` | `--database.path` | The path to the database, including file name. | `./tinyauth.db` | + +## Analytics Configuration + +| Environment | Flag | Description | Default | +| - | - | - | - | +| `TINYAUTH_ANALYTICS_ENABLED` | `--analytics.enabled` | Enable periodic version information collection. | `true` | + +## Resources Configuration + +| Environment | Flag | Description | Default | +| - | - | - | - | +| `TINYAUTH_RESOURCES_ENABLED` | `--resources.enabled` | Enable the resources server. | `true` | +| `TINYAUTH_RESOURCES_PATH` | `--resources.path` | The directory where resources are stored. | `./resources` | + +## Server Configuration + +| Environment | Flag | Description | Default | +| - | - | - | - | +| `TINYAUTH_SERVER_PORT` | `--server.port` | The port on which the server listens. | `3000` | +| `TINYAUTH_SERVER_ADDRESS` | `--server.address` | The address on which the server listens. | `0.0.0.0` | +| `TINYAUTH_SERVER_SOCKETPATH` | `--server.socketpath` | The path to the Unix socket. | `` | + +## Authentication Configuration + +| Environment | Flag | Description | Default | +| - | - | - | - | +| `TINYAUTH_AUTH_IP_ALLOW` | `--auth.ip.allow` | List of allowed IPs or CIDR ranges. | `` | +| `TINYAUTH_AUTH_IP_BLOCK` | `--auth.ip.block` | List of blocked IPs or CIDR ranges. | `` | +| `TINYAUTH_AUTH_USERS` | `--auth.users` | Comma-separated list of users (username:hashed_password). | `` | +| `TINYAUTH_AUTH_USERSFILE` | `--auth.usersfile` | Path to the users file. | `` | +| `TINYAUTH_AUTH_SECURECOOKIE` | `--auth.securecookie` | Enable secure cookies. | `false` | +| `TINYAUTH_AUTH_SESSIONEXPIRY` | `--auth.sessionexpiry` | Session expiry time in seconds. | `86400` | +| `TINYAUTH_AUTH_SESSIONMAXLIFETIME` | `--auth.sessionmaxlifetime` | Maximum session lifetime in seconds. | `0` | +| `TINYAUTH_AUTH_LOGINTIMEOUT` | `--auth.logintimeout` | Login timeout in seconds. | `300` | +| `TINYAUTH_AUTH_LOGINMAXRETRIES` | `--auth.loginmaxretries` | Maximum login retries. | `3` | +| `TINYAUTH_AUTH_TRUSTEDPROXIES` | `--auth.trustedproxies` | Comma-separated list of trusted proxy addresses. | `` | + +## ACLs Configuration + +| Environment | Flag | Description | Default | +| - | - | - | - | +| `TINYAUTH_APPS_[NAME]_CONFIG_DOMAIN` | `--apps.[name].config.domain` | The domain of the app. | `` | +| `TINYAUTH_APPS_[NAME]_USERS_ALLOW` | `--apps.[name].users.allow` | Comma-separated list of allowed users. | `` | +| `TINYAUTH_APPS_[NAME]_USERS_BLOCK` | `--apps.[name].users.block` | Comma-separated list of blocked users. | `` | +| `TINYAUTH_APPS_[NAME]_OAUTH_WHITELIST` | `--apps.[name].oauth.whitelist` | Comma-separated list of allowed OAuth groups. | `` | +| `TINYAUTH_APPS_[NAME]_OAUTH_GROUPS` | `--apps.[name].oauth.groups` | Comma-separated list of required OAuth groups. | `` | +| `TINYAUTH_APPS_[NAME]_IP_ALLOW` | `--apps.[name].ip.allow` | List of allowed IPs or CIDR ranges. | `` | +| `TINYAUTH_APPS_[NAME]_IP_BLOCK` | `--apps.[name].ip.block` | List of blocked IPs or CIDR ranges. | `` | +| `TINYAUTH_APPS_[NAME]_IP_BYPASS` | `--apps.[name].ip.bypass` | List of IPs or CIDR ranges that bypass authentication. | `` | +| `TINYAUTH_APPS_[NAME]_RESPONSE_HEADERS` | `--apps.[name].response.headers` | Custom headers to add to the response. | `` | +| `TINYAUTH_APPS_[NAME]_RESPONSE_BASICAUTH_USERNAME` | `--apps.[name].response.basicauth.username` | Basic auth username. | `` | +| `TINYAUTH_APPS_[NAME]_RESPONSE_BASICAUTH_PASSWORD` | `--apps.[name].response.basicauth.password` | Basic auth password. | `` | +| `TINYAUTH_APPS_[NAME]_RESPONSE_BASICAUTH_PASSWORDFILE` | `--apps.[name].response.basicauth.passwordfile` | Path to the file containing the basic auth password. | `` | +| `TINYAUTH_APPS_[NAME]_PATH_ALLOW` | `--apps.[name].path.allow` | Comma-separated list of allowed paths. | `` | +| `TINYAUTH_APPS_[NAME]_PATH_BLOCK` | `--apps.[name].path.block` | Comma-separated list of blocked paths. | `` | +| `TINYAUTH_APPS_[NAME]_LDAP_GROUPS` | `--apps.[name].ldap.groups` | Comma-separated list of required LDAP groups. | `` | + +## OAuth Configuration + +| Environment | Flag | Description | Default | +| - | - | - | - | +| `TINYAUTH_OAUTH_WHITELIST` | `--oauth.whitelist` | Comma-separated list of allowed OAuth domains. | `` | +| `TINYAUTH_OAUTH_AUTOREDIRECT` | `--oauth.autoredirect` | The OAuth provider to use for automatic redirection. | `` | +| `TINYAUTH_OAUTH_PROVIDERS_[NAME]_CLIENTID` | `--oauth.providers.[name].clientid` | OAuth client ID. | `` | +| `TINYAUTH_OAUTH_PROVIDERS_[NAME]_CLIENTSECRET` | `--oauth.providers.[name].clientsecret` | OAuth client secret. | `` | +| `TINYAUTH_OAUTH_PROVIDERS_[NAME]_CLIENTSECRETFILE` | `--oauth.providers.[name].clientsecretfile` | Path to the file containing the OAuth client secret. | `` | +| `TINYAUTH_OAUTH_PROVIDERS_[NAME]_SCOPES` | `--oauth.providers.[name].scopes` | OAuth scopes. | `` | +| `TINYAUTH_OAUTH_PROVIDERS_[NAME]_REDIRECTURL` | `--oauth.providers.[name].redirecturl` | OAuth redirect URL. | `` | +| `TINYAUTH_OAUTH_PROVIDERS_[NAME]_AUTHURL` | `--oauth.providers.[name].authurl` | OAuth authorization URL. | `` | +| `TINYAUTH_OAUTH_PROVIDERS_[NAME]_TOKENURL` | `--oauth.providers.[name].tokenurl` | OAuth token URL. | `` | +| `TINYAUTH_OAUTH_PROVIDERS_[NAME]_USERINFOURL` | `--oauth.providers.[name].userinfourl` | OAuth userinfo URL. | `` | +| `TINYAUTH_OAUTH_PROVIDERS_[NAME]_INSECURE` | `--oauth.providers.[name].insecure` | Allow insecure OAuth connections. | `false` | +| `TINYAUTH_OAUTH_PROVIDERS_[NAME]_NAME` | `--oauth.providers.[name].name` | Provider name in UI. | `` | + +:::note + Using `google` or `github` as provider IDs, triggers automatic filling of the + required information (e.g., auth URLs, scopes). You will only have to + provide the client ID and secret. +::: + +## OIDC Configuration + +| Environment | Flag | Description | Default | +| - | - | - | - | +| `TINYAUTH_OIDC_PRIVATEKEYPATH` | `--oidc.privatekeypath` | Path to the private key file, including file name. | `./tinyauth_oidc_key` | +| `TINYAUTH_OIDC_PUBLICKEYPATH` | `--oidc.publickeypath` | Path to the public key file, including file name. | `./tinyauth_oidc_key.pub` | +| `TINYAUTH_OIDC_CLIENTS_[NAME]_CLIENTID` | `--oidc.clients.[name].clientid` | OIDC client ID. | `` | +| `TINYAUTH_OIDC_CLIENTS_[NAME]_CLIENTSECRET` | `--oidc.clients.[name].clientsecret` | OIDC client secret. | `` | +| `TINYAUTH_OIDC_CLIENTS_[NAME]_CLIENTSECRETFILE` | `--oidc.clients.[name].clientsecretfile` | Path to the file containing the OIDC client secret. | `` | +| `TINYAUTH_OIDC_CLIENTS_[NAME]_TRUSTEDREDIRECTURIS` | `--oidc.clients.[name].trustedredirecturis` | List of trusted redirect URIs. | `` | +| `TINYAUTH_OIDC_CLIENTS_[NAME]_NAME` | `--oidc.clients.[name].name` | Client name in UI. | `` | + +## UI Configuration + +| Environment | Flag | Description | Default | +| - | - | - | - | +| `TINYAUTH_UI_TITLE` | `--ui.title` | The title of the UI. | `Tinyauth` | +| `TINYAUTH_UI_FORGOTPASSWORDMESSAGE` | `--ui.forgotpasswordmessage` | Message displayed on the forgot password page. | `You can change your password by changing the configuration.` | +| `TINYAUTH_UI_BACKGROUNDIMAGE` | `--ui.backgroundimage` | Path to the background image. | `/background.jpg` | +| `TINYAUTH_UI_WARNINGSENABLED` | `--ui.warningsenabled` | Enable UI warnings. | `true` | + +## LDAP Configuration + +| Environment | Flag | Description | Default | +| - | - | - | - | +| `TINYAUTH_LDAP_ADDRESS` | `--ldap.address` | LDAP server address. | `` | +| `TINYAUTH_LDAP_BINDDN` | `--ldap.binddn` | Bind DN for LDAP authentication. | `` | +| `TINYAUTH_LDAP_BINDPASSWORD` | `--ldap.bindpassword` | Bind password for LDAP authentication. | `` | +| `TINYAUTH_LDAP_BASEDN` | `--ldap.basedn` | Base DN for LDAP searches. | `` | +| `TINYAUTH_LDAP_INSECURE` | `--ldap.insecure` | Allow insecure LDAP connections. | `false` | +| `TINYAUTH_LDAP_SEARCHFILTER` | `--ldap.searchfilter` | LDAP search filter. | `(uid=%s)` | +| `TINYAUTH_LDAP_AUTHCERT` | `--ldap.authcert` | Certificate for mTLS authentication. | `` | +| `TINYAUTH_LDAP_AUTHKEY` | `--ldap.authkey` | Certificate key for mTLS authentication. | `` | +| `TINYAUTH_LDAP_GROUPCACHETTL` | `--ldap.groupcachettl` | Cache duration for LDAP group membership in seconds. | `900` | + +:::note + For Windows LDAP, use the following search filter: `(&(sAMAccountName=%s))`. +::: + +## Logging Configuration + +| Environment | Flag | Description | Default | +| - | - | - | - | +| `TINYAUTH_LOG_LEVEL` | `--log.level` | Log level (trace, debug, info, warn, error). | `info` | +| `TINYAUTH_LOG_JSON` | `--log.json` | Enable JSON formatted logs. | `false` | +| `TINYAUTH_LOG_STREAMS_HTTP_ENABLED` | `--log.streams.http.enabled` | Enable this log stream. | `true` | +| `TINYAUTH_LOG_STREAMS_HTTP_LEVEL` | `--log.streams.http.level` | Log level for this stream. Use global if empty. | `` | +| `TINYAUTH_LOG_STREAMS_APP_ENABLED` | `--log.streams.app.enabled` | Enable this log stream. | `true` | +| `TINYAUTH_LOG_STREAMS_APP_LEVEL` | `--log.streams.app.level` | Log level for this stream. Use global if empty. | `` | +| `TINYAUTH_LOG_STREAMS_AUDIT_ENABLED` | `--log.streams.audit.enabled` | Enable this log stream. | `false` | +| `TINYAUTH_LOG_STREAMS_AUDIT_LEVEL` | `--log.streams.audit.level` | Log level for this stream. Use global if empty. | `` | + +:::caution + The `trace` log level will log sensitive information such as usernames, emails + and access controls. Use with caution. +::: diff --git a/content/docs/reference/flow.mdx b/src/content/docs/docs/reference/flow.mdx similarity index 100% rename from content/docs/reference/flow.mdx rename to src/content/docs/docs/reference/flow.mdx diff --git a/content/docs/reference/headers.mdx b/src/content/docs/docs/reference/headers.mdx similarity index 84% rename from content/docs/reference/headers.mdx rename to src/content/docs/docs/reference/headers.mdx index b8bed3b..195a16b 100644 --- a/content/docs/reference/headers.mdx +++ b/src/content/docs/docs/reference/headers.mdx @@ -5,10 +5,10 @@ description: Reference on Tinyauth's header support. Setting headers can be useful for authenticating to apps with the credentials from Tinyauth. While Tinyauth offers some defaults, it also allows setting custom headers that are automatically returned in the authentication server response. This is particularly useful for applications that support header-based authentication, where the app relies on the reverse proxy to provide authentication and user information. - +:::note Headers are case-insensitive. For example, both `Remote-User` and `remote-user` are valid. - +::: ## Supported Headers @@ -26,12 +26,16 @@ The `Remote-Name` header contains the full name of the currently logged-in user. ### Remote groups -The `Remote-Groups` header contains the groups of the currently logged-in user, retrieved from the `groups` claim in the OIDC server. These can be used to allow access to specific user groups configured by the OIDC server. More details are available in the [OIDC access controls](/docs/guides/access-controls.md#access-controls-using-oidc-groups) guide. +The `Remote-Groups` header contains the groups of the currently logged-in user, retrieved from the `groups` claim in the OIDC server or from the LDAP server. These can be used to allow access to specific user groups configured by the OIDC or LDAP server. More details are available in the [OIDC access controls](/docs/guides/access-controls#access-controls-using-oidc-groups) and [LDAP access controls](/docs/guides/access-controls#access-controls-using-ldap-groups) guides. - +:::caution Remote groups are only available for OIDC providers that support the `groups` - claim. LDAP groups are **not** supported. - + claim. +::: + +### Remote sub + +The `Remote-Sub` header contains the subject identifier of the currently logged-in user, retrieved from the `sub` claim in the OIDC server. This can be used to uniquely identify the user across different authentication providers. ### Custom headers @@ -43,19 +47,19 @@ tinyauth.apps.[app].response.headers: my-header=cool When authenticating through Tinyauth, the app will receive the `my-header` header. - +:::caution Ensure a list of trusted proxy URLs is configured for the app. Accepting headers from untrusted proxies can lead to security vulnerabilities. - +::: - +:::note By default, Tinyauth uses the subdomain name of the request to find a matching container for labels. For example, a request to `myapp.example.com` checks for labels that have the subdomain as the app ID. This behavior can be modified using the `tinyauth.apps.[app].config.domain` label. More details are available in the [access controls](/docs/guides/access-controls#label-discovery) guide. - +::: ## Adding Headers to Proxy diff --git a/content/docs/reference/labels.mdx b/src/content/docs/docs/reference/labels.mdx similarity index 95% rename from content/docs/reference/labels.mdx rename to src/content/docs/docs/reference/labels.mdx index d73a945..392c0cf 100644 --- a/content/docs/reference/labels.mdx +++ b/src/content/docs/docs/reference/labels.mdx @@ -15,11 +15,11 @@ tinyauth.apps.myapp.config.domain: myapp.example.com Tinyauth will now use the domain to match labels instead of the app ID. - +:::note Labels can be set either on the Tinyauth container or the app. However, if you use multiple hosts and the app is running on a different host than Tinyauth, labels must be set on the Tinyauth container. - +::: ## Full List @@ -39,3 +39,4 @@ Tinyauth will now use the domain to match labels instead of the app ID. | `tinyauth.apps.[app].response.basicauth.passwordfile` | A path to a file containing the password used by Tinyauth to authenticate to a target app using basic authentication. | | `tinyauth.apps.[app].path.allow` | A regex of paths that do not need authentication. | | `tinyauth.apps.[app].path.block` | A regex of paths that will require authentication (meaning that all other paths are allowed). | +| `tinyauth.apps.[app].ldap.groups` | A comma separated list of LDAP groups required by a user to access the app. | diff --git a/content/docs/reference/telemetry.mdx b/src/content/docs/docs/reference/telemetry.mdx similarity index 89% rename from content/docs/reference/telemetry.mdx rename to src/content/docs/docs/reference/telemetry.mdx index 468e52a..3d67be6 100644 --- a/content/docs/reference/telemetry.mdx +++ b/src/content/docs/docs/reference/telemetry.mdx @@ -9,20 +9,20 @@ Tinyauth includes a heartbeat feature that send anonymous version information to - Instance UUID (generated with UUID v4 from the app URL) - Time of the request - +:::note The UUID generated from the app URL is an irreversible hash and cannot be used to identify the instance. - +::: The data is sent to `https://api.tinyauth.app/v1/instances/hearbeat` every 12 hours. No personal, sensitive or identifiable information is collected. The heartbeat can be disabled with: | Environment | CLI Flag | Default | | ------------------- | --------------------- | ------- | -| `DISABLE_ANALYTICS` | `--disable-analytics` | `false` | +| `TINYAUTH_ANALYTICS_ENABLED` | `--analytics.enabled` | `false` | - +:::note Your IP address may be stored in an in-memory cache for a short period to prevent abuse of the API server. - +::: The telemetry server source is available on [GitHub](https://github.com/steveiliop56/tinyauth-analytics) licensed under the MIT license. diff --git a/src/lib/4-to-5-config-migrator.ts b/src/lib/4-to-5-config-migrator.ts new file mode 100644 index 0000000..a76d544 --- /dev/null +++ b/src/lib/4-to-5-config-migrator.ts @@ -0,0 +1,166 @@ +const CONFIG_ENV_KEYS_MAP: Record = { + PORT: "TINYAUTH_SERVER_PORT", + ADDRESS: "TINYAUTH_SERVER_ADDRESS", + APP_URL: "TINYAUTH_APPURL", + USERS: "TINYAUTH_AUTH_USERS", + USERS_FILE: "TINYAUTH_AUTH_USERSFILE", + SECURE_COOKIE: "TINYAUTH_AUTH_SECURECOOKIE", + OAUTH_WHITELIST: "TINYAUTH_OAUTH_WHITELIST", + OAUTH_AUTO_REDIRECT: "TINYAUTH_OAUTH_AUTOREDIRECT", + SESSION_EXPIRY: "TINYAUTH_AUTH_SESSIONEXPIRY", + LOGIN_TIMEOUT: "TINYAUTH_AUTH_LOGINTIMEOUT", + LOGIN_MAX_RETRIES: "TINYAUTH_AUTH_LOGINMAXRETRIES", + LOG_LEVEL: "TINYAUTH_LOG_LEVEL", + APP_TITLE: "TINYAUTH_UI_TITLE", + FORGOT_PASSWORD_MESSAGE: "TINYAUTH_UI_FORGOTPASSWORDMESSAGE", + BACKGROUND_IMAGE: "TINYAUTH_UI_BACKGROUNDIMAGE", + LDAP_ADDRESS: "TINYAUTH_LDAP_ADDRESS", + LDAP_BIND_DN: "TINYAUTH_LDAP_BINDDN", + LDAP_BIND_PASSWORD: "TINYAUTH_LDAP_BINDPASSWORD", + LDAP_BASE_DN: "TINYAUTH_LDAP_BASEDN", + LDAP_INSECURE: "TINYAUTH_LDAP_INSECURE", + LDAP_SEARCH_FILTER: "TINYAUTH_LDAP_SEARCHFILTER", + RESOURCES_DIR: "TINYAUTH_RESOURCES_PATH", + DATABASE_PATH: "TINYAUTH_DATABASE_PATH", + TRUSTED_PROXIES: "TINYAUTH_AUTH_TRUSTEDPROXIES", + DISABLE_ANALYTICS: "TINYAUTH_ANALYTICS_ENABLED", + DISABLE_RESOURCES: "TINYAUTH_RESOURCES_ENABLED", + SOCKET_PATH: "TINYAUTH_SERVER_SOCKETPATH", + DISABLE_UI_WARNINGS: "TINYAUTH_UI_WARNINGSENABLED", +}; + +const CONFIG_CLI_KEYS_MAP: Record = { + port: "server.port", + address: "server.address", + "app-url": "appurl", + users: "auth.users", + "users-file": "auth.usersfile", + "secure-cookie": "auth.securecookie", + "oauth-whitelist": "oauth.whitelist", + "oauth-auto-redirect": "oauth.autoredirect", + "session-expiry": "auth.sessionexpiry", + "login-timeout": "auth.logintimeout", + "login-max-retries": "auth.loginmaxretries", + "log-level": "log.level", + "app-title": "ui.title", + "forgot-password-message": "ui.forgotpasswordmessage", + "background-image": "ui.backgroundimage", + "ldap-address": "ldap.address", + "ldap-bind-dn": "ldap.binddn", + "ldap-bind-password": "ldap.bindpassword", + "ldap-base-dn": "ldap.basedn", + "ldap-insecure": "ldap.insecure", + "ldap-search-filter": "ldap.searchfilter", + "resources-dir": "resources.path", + "database-path": "database.path", + "trusted-proxies": "auth.trustedproxies", + "disable-analytics": "analytics.enabled", + "disable-resources": "resources.enabled", + "socket-path": "server.socketpath", + "disable-ui-warnings": "ui.warningsenabled", +}; + +const FLIP_FLAGS = [ + "disable-resources", + "disable-analytics", + "disable-ui-warnings", +]; +const FLIP_ENV = [ + "DISABLE_RESOURCES", + "DISABLE_ANALYTICS", + "DISABLE_UI_WARNINGS", +]; + +function buildEnvMap(env: string): Record { + const lines = env.split("\n"); + let res: Record = {}; + + for (const line of lines) { + if (line.trim() == "") { + continue; + } + const lineTrimmed = line.trim(); + if (lineTrimmed.startsWith("#") || lineTrimmed.startsWith("--")) { + continue; + } + const lineSplit = line.trim().split("="); + const key = lineSplit[0]; + let value = lineSplit.slice(1).join("="); + if (FLIP_ENV.includes(key)) { + value = value === "true" ? "false" : "true"; + } + res[key] = value; + } + + return res; +} + +function buildCliMap(cli: string): Record { + const lines = cli.split("\n"); + let res: Record = {}; + + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed == "" || !trimmed.startsWith("--")) { + continue; + } + const flag = trimmed.substring(2); + const flagSplit = flag.split("="); + const key = flagSplit[0]; + let value = flagSplit.slice(1).join("="); + if (FLIP_FLAGS.includes(key)) { + value = value === "true" ? "false" : "true"; + } + res[key] = value; + continue; + } + + return res; +} + +function migrateMap< + T extends typeof CONFIG_ENV_KEYS_MAP | typeof CONFIG_CLI_KEYS_MAP, +>(old: Record, IMAP: T): Record { + const res: Record = {}; + + for (const key in old) { + const newKey = IMAP[key as keyof typeof IMAP] as string | undefined; + if (!newKey) { + continue; + } + res[newKey] = old[key]; + } + + return res; +} + +function recompileCliToString(map: Record): string { + let res = ""; + for (const key in map) { + res += `--${key}=${map[key]}\n`; + } + return res; +} + +function recompileEnvToString(map: Record): string { + let res = ""; + for (const key in map) { + res += `${key}=${map[key]}\n`; + } + return res; +} + +export function migrateConfig(input: string): string { + let res = ""; + + const env = buildEnvMap(input); + const cli = buildCliMap(input); + + const envMigrated = migrateMap(env, CONFIG_ENV_KEYS_MAP); + const cliMigrated = migrateMap(cli, CONFIG_CLI_KEYS_MAP); + + res += recompileEnvToString(envMigrated); + res += recompileCliToString(cliMigrated); + + return res; +} diff --git a/src/lib/get-instances.ts b/src/lib/get-instances.ts new file mode 100644 index 0000000..32a19a0 --- /dev/null +++ b/src/lib/get-instances.ts @@ -0,0 +1,11 @@ +const apiUrl = "https://api.tinyauth.app"; + +interface InstancesRes { + total: number; +} + +export const getInstances = async (): Promise => { + const res = await fetch(apiUrl + "/v1/instances/all"); + const data = await res.json(); + return data as InstancesRes; +}; diff --git a/src/pages/index.astro b/src/pages/index.astro new file mode 100644 index 0000000..224d093 --- /dev/null +++ b/src/pages/index.astro @@ -0,0 +1,110 @@ +--- +import StarlightPage from "@astrojs/starlight/components/StarlightPage.astro"; +import { Card } from "@astrojs/starlight/components"; +--- + + + +migration guide.", + }, + }} +> +
+

Tinyauth

+

+ Tinyauth is a tiny OpenID Connect (OIDC) authentication and + authorization server for your self-hosted applications. +

+ +

+ 0+ Active Instances +

+
+ + Screenshot +
+ Tinyauth works out of the box using only environment variables. + No dashboards or config files needed. + Tinyauth ships as a single statically linked binary with no + dependencies and requires practically no resources to run. + With Tinyauth you can easily log in to your apps using your + favorite OAuth providers or by using a centralized LDAP server. + Tinyauth is the tiniest OIDC server you have ever seen. It is + designed to be simple and easy to use, with a focus on security + and performance. +
+

Join the community

+
+ +
+

+ Join our Discord server to chat with the community, ask + questions, and get help. +

+ Discord +
+
+ +
+

+ Check out the source code, report issues, and contribute + to the project on GitHub. +

+ Browse the source +
+
+
+
+
diff --git a/src/styles/4-to-5.css b/src/styles/4-to-5.css new file mode 100644 index 0000000..9b18e67 --- /dev/null +++ b/src/styles/4-to-5.css @@ -0,0 +1,73 @@ +.cfg-form { + margin-top: 1rem; + margin-bottom: 0; + margin-left: 0; + margin-right: 0; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.cfg-form-io-container { + margin: 0; + display: grid; + grid-template-rows: 1fr; + min-height: 16rem; + gap: 1rem; +} + +@media (min-width: 768px) { + .cfg-form-io-container { + grid-template-columns: 1fr 1fr; + } +} + +.cfg-input { + margin: 0; + resize: none; + border: 1px solid var(--sl-color-gray-5); + padding: 0.75rem 1rem; + font-size: var(--sl-text-code); + tab-size: 2; + background-color: var(--sl-color-bg); + color: var(--sl-color-text); +} + +.cfg-input:focus { + outline: none; + border-color: var(--sl-color-primary-5); +} + +.cfg-new { + margin: 0; + overflow: auto; +} + +.cfg-submit { + margin: 0; + background-color: var(--sl-color-bg); + padding-top: 0.625rem; + padding-bottom: 0.625rem; + padding-left: 1rem; + padding-right: 1rem; + border-radius: 0.375rem; + font-size: 0.875rem; + line-height: 1.25rem; + font-weight: 600; + color: var(--sl-color-text); + transition-property: border-color, color; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 100ms; + transition-delay: 50ms; + text-decoration: none; + border: 1px solid var(--sl-color-gray-5); +} + +.cfg-submit { + &:is(:hover, :focus-visible) { + cursor: pointer; + border: 1px solid var(--sl-color-gray-2); + color: var(--sl-color-text-accent); + outline: none; + } +} diff --git a/src/styles/home.css b/src/styles/home.css new file mode 100644 index 0000000..7b94a70 --- /dev/null +++ b/src/styles/home.css @@ -0,0 +1,130 @@ +main:has(.home-container) { + --sl-content-gap-y: 0; + + &:has(.sl-banner) .content-panel:nth-child(2) { + display: none; + } + + &:not(.content-panel:has(.sl-banner)) .content-panel:first-of-type { + display: none; + } + + footer { + margin-top: 1rem; + + .meta { + display: none; + } + + .kudos { + margin: 1rem auto; + } + } + + .sl-banner { + margin-bottom: 0.5rem; + } +} + +.home-container { + display: flex; + flex-direction: column; + align-items: center; + padding: 1rem; + text-align: center; + gap: 1.25rem; +} + +.home-title { + font-size: 2.25rem; + line-height: calc(2.5 / 2.25); + font-weight: 700; + margin: 0; +} + +.home-description { + margin: 0; + max-width: 42rem; +} + +.instances-count-container { + margin: 0px; +} + +.instances-count { + color: var(--sl-color-green-high); + background-color: var(--sl-color-green-low); + border: 1px solid var(--sl-color-green); + border-radius: 9999px; + font-size: 0.875rem; + line-height: calc(1.25 / 0.875); + font-weight: 500; + padding: 0.25rem 0.75rem; +} + +.nav-buttons-container { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + gap: 0.75rem; +} + +.link-button { + padding-top: 0.625rem; + padding-bottom: 0.625rem; + padding-left: 1rem; + padding-right: 1rem; + border-radius: 0.375rem; + font-size: 0.875rem; + line-height: 1.25rem; + font-weight: 600; + color: var(--sl-color-bg); + transition-property: transform, opacity; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 200ms; + transition-delay: 50ms; + text-decoration: none; + /* Prevent text from "jumping" */ + will-change: transform, opacity; +} + +.link-button:hover { + transform: scale(1.05); + opacity: 0.8; +} + +.link-button-primary { + background-color: #3b82f6; +} + +.link-button-secondary { + color: var(--sl-color-text); + border: 1px solid; + border-color: var(--sl-color-gray-5); + background-color: var(--sl-color-gray-7); +} + +.feature-card { + display: grid; + grid-auto-flow: row; + text-align: start; + gap: 1rem; +} + +.community-card-body { + display: flex; + flex-direction: column; + align-items: start; + gap: 0.75rem; +} + +@media (min-width: 768px) { + .hero-image { + max-width: 42rem; + } + + .feature-card { + grid-auto-flow: column; + } +} diff --git a/src/styles/theme.css b/src/styles/theme.css new file mode 100644 index 0000000..4795b3c --- /dev/null +++ b/src/styles/theme.css @@ -0,0 +1,46 @@ +:root { + --sl-hue-base: 140; +} + +:root { + --sl-color-accent: #fafafa; + --sl-color-accent-high: #f5f5f5; +} + +:root[data-theme="light"] { + --sl-color-accent: #171717; + --sl-color-accent-high: #0a0a0a; +} + +.site-title { + gap: 0.5rem; +} + +.site-title img { + height: 2rem; +} + +td, +th { + padding-left: 0.5rem; +} + +.sl-banner { + background-color: var(--sl-color-blue-low); + border: 1px solid var(--sl-color-blue); + color: var(--sl-color-blue-high); + + a { + color: var(--sl-color-blue-accent); + text-underline-offset: 0.25em; + + &:is(:hover, :focus-visible) { + color: var(--sl-color-blue-accent-invert); + } + } +} + +.sl-markdown-content img { + border-radius: 0.375rem; + border: 1px solid var(--sl-color-gray-5); +} diff --git a/tsconfig.json b/tsconfig.json index 3b1c1ca..8bf91d3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,27 +1,5 @@ { - "include": [ - "**/*", - "**/.server/**/*", - "**/.client/**/*", - ".react-router/types/**/*" - ], - "compilerOptions": { - "lib": ["DOM", "DOM.Iterable", "ES2022"], - "types": ["node", "vite/client"], - "target": "esnext", - "module": "esnext", - "moduleResolution": "bundler", - "jsx": "react-jsx", - "rootDirs": [".", "./.react-router/types"], - "baseUrl": ".", - "paths": { - "@/*": ["./app/*"] - }, - "esModuleInterop": true, - "verbatimModuleSyntax": true, - "noEmit": true, - "resolveJsonModule": true, - "skipLibCheck": true, - "strict": true - } + "extends": "astro/tsconfigs/strict", + "include": [".astro/types.d.ts", "**/*"], + "exclude": ["dist"] } diff --git a/vite.config.ts b/vite.config.ts deleted file mode 100644 index 99d4f7a..0000000 --- a/vite.config.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { reactRouter } from "@react-router/dev/vite"; -import tailwindcss from "@tailwindcss/vite"; -import { defineConfig } from "vite"; -import tsconfigPaths from "vite-tsconfig-paths"; -import mdx from "fumadocs-mdx/vite"; -import * as MdxConfig from "./source.config"; - -export default defineConfig({ - plugins: [mdx(MdxConfig), tailwindcss(), reactRouter(), tsconfigPaths()], - server: { - allowedHosts: true, - }, -});