diff --git a/hscontrol/assets/oidc_callback_template.html b/hscontrol/assets/oidc_callback_template.html deleted file mode 100644 index 2236f365..00000000 --- a/hscontrol/assets/oidc_callback_template.html +++ /dev/null @@ -1,307 +0,0 @@ - - - - - - Headscale Authentication Succeeded - - - -
-
- -
- -
-
Signed in via your OIDC provider
-

- {{.Verb}} as {{.User}}, you can now close this window. -

-
-
-
-

Not sure how to get started?

-

- Check out beginner and advanced guides on, or read more in the - documentation. -

- - - - - - - View the headscale documentation - - - - - - - - View the tailscale documentation - -
-
- - diff --git a/hscontrol/oidc.go b/hscontrol/oidc.go index 84d00712..fa1e942c 100644 --- a/hscontrol/oidc.go +++ b/hscontrol/oidc.go @@ -4,10 +4,8 @@ import ( "bytes" "cmp" "context" - _ "embed" "errors" "fmt" - "html/template" "net/http" "slices" "strings" @@ -16,6 +14,7 @@ import ( "github.com/coreos/go-oidc/v3/oidc" "github.com/gorilla/mux" "github.com/juanfont/headscale/hscontrol/db" + "github.com/juanfont/headscale/hscontrol/templates" "github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/hscontrol/types/change" "github.com/juanfont/headscale/hscontrol/util" @@ -191,13 +190,6 @@ type oidcCallbackTemplateConfig struct { Verb string } -//go:embed assets/oidc_callback_template.html -var oidcCallbackTemplateContent string - -var oidcCallbackTemplate = template.Must( - template.New("oidccallback").Parse(oidcCallbackTemplateContent), -) - // OIDCCallbackHandler handles the callback from the OIDC endpoint // Retrieves the nkey from the state cache and adds the node to the users email user // TODO: A confirmation page for new nodes should be added to avoid phishing vulnerabilities @@ -567,21 +559,12 @@ func (a *AuthProviderOIDC) handleRegistration( return !nodeChange.Empty(), nil } -// TODO(kradalby): -// Rewrite in elem-go. func renderOIDCCallbackTemplate( user *types.User, verb string, ) (*bytes.Buffer, error) { - var content bytes.Buffer - if err := oidcCallbackTemplate.Execute(&content, oidcCallbackTemplateConfig{ - User: user.Display(), - Verb: verb, - }); err != nil { - return nil, fmt.Errorf("rendering OIDC callback template: %w", err) - } - - return &content, nil + html := templates.OIDCCallback(user.Display(), verb).Render() + return bytes.NewBufferString(html), nil } func setCSRFCookie(w http.ResponseWriter, r *http.Request, name string) (string, error) { diff --git a/hscontrol/oidc_template_test.go b/hscontrol/oidc_template_test.go new file mode 100644 index 00000000..7634282c --- /dev/null +++ b/hscontrol/oidc_template_test.go @@ -0,0 +1,67 @@ +package hscontrol + +import ( + "os" + "path/filepath" + "testing" + + "github.com/juanfont/headscale/hscontrol/templates" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOIDCCallbackTemplate(t *testing.T) { + tests := []struct { + name string + userName string + verb string + }{ + { + name: "logged_in_user", + userName: "test@example.com", + verb: "Logged in", + }, + { + name: "registered_user", + userName: "newuser@example.com", + verb: "Registered", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Render using the elem-go template + html := templates.OIDCCallback(tt.userName, tt.verb).Render() + + // Verify the HTML contains expected elements + assert.Contains(t, html, "") + assert.Contains(t, html, "Headscale Authentication Succeeded") + assert.Contains(t, html, tt.verb) + assert.Contains(t, html, tt.userName) + assert.Contains(t, html, "Signed in via your OIDC provider") + assert.Contains(t, html, "you can now close this window") + assert.Contains(t, html, "View the headscale documentation") + assert.Contains(t, html, "View the tailscale documentation") + + // Verify CSS styles are present + assert.Contains(t, html, "font-family:") + assert.Contains(t, html, "system-ui") + assert.Contains(t, html, ".container") + assert.Contains(t, html, ".message") + + // Verify SVG elements are present + assert.Contains(t, html, " Apps > Tailscale", - ), + elem.Text("Open "), + elem.Strong(nil, elem.Text("Settings")), + elem.Text(" (the Apple tvOS settings) > "), + elem.Strong(nil, elem.Text("Apps")), + elem.Text(" > "), + elem.Strong(nil, elem.Text("Tailscale")), ), elem.Li( nil, - elem.Text( - fmt.Sprintf( - `Enter "%s" under "ALTERNATE COORDINATION SERVER URL"`, - url, - ), - ), + elem.Text("Enter "), + Code(elem.Text(url)), + elem.Text(" under "), + elem.Strong(nil, elem.Text("ALTERNATE COORDINATION SERVER URL")), ), elem.Li(nil, - elem.Text("Return to the tvOS Home screen"), + elem.Text("Return to the tvOS "), + elem.Strong(nil, elem.Text("Home")), + elem.Text(" screen"), ), elem.Li(nil, - elem.Text("Open Tailscale"), + elem.Text("Open "), + elem.Strong(nil, elem.Text("Tailscale")), ), elem.Li(nil, - elem.Text(`Select "Install VPN configuration"`), + elem.Text("Select "), + elem.Strong(nil, elem.Text("Install VPN configuration")), ), elem.Li(nil, - elem.Text(`Select "Allow"`), + elem.Text("Select "), + elem.Strong(nil, elem.Text("Allow")), ), elem.Li(nil, elem.Text("Scan the QR code and follow the login procedure"), @@ -228,6 +185,7 @@ func Apple(url string) *elem.Element { elem.Text("Headscale should now be working on your tvOS device"), ), ), + pageFooter(), ), ) } diff --git a/hscontrol/templates/design.go b/hscontrol/templates/design.go new file mode 100644 index 00000000..04c86064 --- /dev/null +++ b/hscontrol/templates/design.go @@ -0,0 +1,482 @@ +package templates + +import ( + "github.com/chasefleming/elem-go" + "github.com/chasefleming/elem-go/attrs" + "github.com/chasefleming/elem-go/styles" +) + +// Design System Constants +// These constants define the visual language for all Headscale HTML templates. +// They ensure consistency across all pages and make it easy to maintain and update the design. + +// Color System +// EXTRACTED FROM: https://headscale.net/stable/assets/stylesheets/main.342714a4.min.css +// Material for MkDocs design system - exact values from official docs. +const ( + // Text colors - from --md-default-fg-color CSS variables + colorTextPrimary = "#000000de" //nolint:unused // rgba(0,0,0,0.87) - Body text + colorTextSecondary = "#0000008a" //nolint:unused // rgba(0,0,0,0.54) - Headings (--md-default-fg-color--light) + colorTextTertiary = "#00000052" //nolint:unused // rgba(0,0,0,0.32) - Lighter text + colorTextLightest = "#00000012" //nolint:unused // rgba(0,0,0,0.07) - Lightest text + + // Code colors - from --md-code-* CSS variables + colorCodeFg = "#36464e" //nolint:unused // Code text color (--md-code-fg-color) + colorCodeBg = "#f5f5f5" //nolint:unused // Code background (--md-code-bg-color) + + // Border colors + colorBorderLight = "#e5e7eb" //nolint:unused // Light borders + colorBorderMedium = "#d1d5db" //nolint:unused // Medium borders + + // Background colors + colorBackgroundPage = "#ffffff" //nolint:unused // Page background + colorBackgroundCard = "#ffffff" //nolint:unused // Card/content background + + // Accent colors - from --md-primary/accent-fg-color + colorPrimaryAccent = "#4051b5" //nolint:unused // Primary accent (links) + colorAccent = "#526cfe" //nolint:unused // Secondary accent + + // Success colors + colorSuccess = "#059669" //nolint:unused // Success states + colorSuccessLight = "#d1fae5" //nolint:unused // Success backgrounds +) + +// Spacing System +// Based on 4px/8px base unit for consistent rhythm. +// Uses rem units for scalability with user font size preferences. +const ( + spaceXS = "0.25rem" //nolint:unused // 4px - Tight spacing + spaceS = "0.5rem" //nolint:unused // 8px - Small spacing + spaceM = "1rem" //nolint:unused // 16px - Medium spacing (base) + spaceL = "1.5rem" //nolint:unused // 24px - Large spacing + spaceXL = "2rem" //nolint:unused // 32px - Extra large spacing + space2XL = "3rem" //nolint:unused // 48px - 2x extra large spacing + space3XL = "4rem" //nolint:unused // 64px - 3x extra large spacing +) + +// Typography System +// EXTRACTED FROM: https://headscale.net/stable/assets/stylesheets/main.342714a4.min.css +// Material for MkDocs typography - exact values from .md-typeset CSS. +const ( + // Font families - from CSS custom properties + fontFamilySystem = `"Roboto", -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif` //nolint:unused + fontFamilyCode = `"Roboto Mono", "SF Mono", Monaco, "Cascadia Code", Consolas, "Courier New", monospace` //nolint:unused + + // Font sizes - from .md-typeset CSS rules + fontSizeBase = "0.8rem" //nolint:unused // 12.8px - Base text (.md-typeset) + fontSizeH1 = "2em" //nolint:unused // 2x base - Main headings + fontSizeH2 = "1.5625em" //nolint:unused // 1.5625x base - Section headings + fontSizeH3 = "1.25em" //nolint:unused // 1.25x base - Subsection headings + fontSizeSmall = "0.8em" //nolint:unused // 0.8x base - Small text + fontSizeCode = "0.85em" //nolint:unused // 0.85x base - Inline code + + // Line heights - from .md-typeset CSS rules + lineHeightBase = "1.6" //nolint:unused // Body text (.md-typeset) + lineHeightH1 = "1.3" //nolint:unused // H1 headings + lineHeightH2 = "1.4" //nolint:unused // H2 headings + lineHeightH3 = "1.5" //nolint:unused // H3 headings + lineHeightCode = "1.4" //nolint:unused // Code blocks (pre) +) + +// Responsive Container Component +// Creates a centered container with responsive padding and max-width. +// Mobile-first approach: starts at 100% width with padding, constrains on larger screens. +// +//nolint:unused // Reserved for future use in Phase 4. +func responsiveContainer(children ...elem.Node) *elem.Element { + return elem.Div(attrs.Props{ + attrs.Style: styles.Props{ + styles.Width: "100%", + styles.MaxWidth: "min(800px, 90vw)", // Responsive: 90% of viewport or 800px max + styles.Margin: "0 auto", // Center horizontally + styles.Padding: "clamp(1rem, 5vw, 2.5rem)", // Fluid padding: 16px to 40px + }.ToInline(), + }, children...) +} + +// Card Component +// Reusable card for grouping related content with visual separation. +// Parameters: +// - title: Optional title for the card (empty string for no title) +// - children: Content elements to display in the card +// +//nolint:unused // Reserved for future use in Phase 4. +func card(title string, children ...elem.Node) *elem.Element { + cardContent := children + if title != "" { + // Prepend title as H3 if provided + cardContent = append([]elem.Node{ + elem.H3(attrs.Props{ + attrs.Style: styles.Props{ + styles.MarginTop: "0", + styles.MarginBottom: spaceM, + styles.FontSize: fontSizeH3, + styles.LineHeight: lineHeightH3, // 1.5 - H3 line height + styles.Color: colorTextSecondary, + }.ToInline(), + }, elem.Text(title)), + }, children...) + } + + return elem.Div(attrs.Props{ + attrs.Style: styles.Props{ + styles.Background: colorBackgroundCard, + styles.Border: "1px solid " + colorBorderLight, + styles.BorderRadius: "0.5rem", // 8px rounded corners + styles.Padding: "clamp(1rem, 3vw, 1.5rem)", // Responsive padding + styles.MarginBottom: spaceL, + styles.BoxShadow: "0 1px 3px rgba(0,0,0,0.1)", // Subtle shadow + }.ToInline(), + }, cardContent...) +} + +// Code Block Component +// EXTRACTED FROM: .md-typeset pre CSS rules +// Exact styling from Material for MkDocs documentation. +// +//nolint:unused // Used across apple.go, windows.go, register_web.go templates. +func codeBlock(code string) *elem.Element { + return elem.Pre(attrs.Props{ + attrs.Style: styles.Props{ + styles.Display: "block", + styles.Padding: "0.77em 1.18em", // From .md-typeset pre + styles.Border: "none", // No border in original + styles.BorderRadius: "0.1rem", // From .md-typeset code + styles.BackgroundColor: colorCodeBg, // #f5f5f5 + styles.FontFamily: fontFamilyCode, // Roboto Mono + styles.FontSize: fontSizeCode, // 0.85em + styles.LineHeight: lineHeightCode, // 1.4 + styles.OverflowX: "auto", // Horizontal scroll + "overflow-wrap": "break-word", // Word wrapping + "word-wrap": "break-word", // Legacy support + styles.WhiteSpace: "pre-wrap", // Preserve whitespace + styles.MarginTop: spaceM, // 1em + styles.MarginBottom: spaceM, // 1em + styles.Color: colorCodeFg, // #36464e + styles.BoxShadow: "none", // No shadow in original + }.ToInline(), + }, + elem.Code(nil, elem.Text(code)), + ) +} + +// Base Typeset Styles +// Returns inline styles for the main content container that matches .md-typeset. +// EXTRACTED FROM: .md-typeset CSS rule from Material for MkDocs. +// +//nolint:unused // Used in general.go for mdTypesetBody. +func baseTypesetStyles() styles.Props { + return styles.Props{ + styles.FontSize: fontSizeBase, // 0.8rem + styles.LineHeight: lineHeightBase, // 1.6 + styles.Color: colorTextPrimary, + styles.FontFamily: fontFamilySystem, + "overflow-wrap": "break-word", + styles.TextAlign: "left", + } +} + +// H1 Styles +// Returns inline styles for H1 headings that match .md-typeset h1. +// EXTRACTED FROM: .md-typeset h1 CSS rule from Material for MkDocs. +// +//nolint:unused // Used across templates for main headings. +func h1Styles() styles.Props { + return styles.Props{ + styles.Color: colorTextSecondary, // rgba(0, 0, 0, 0.54) + styles.FontSize: fontSizeH1, // 2em + styles.LineHeight: lineHeightH1, // 1.3 + styles.Margin: "0 0 1.25em", + styles.FontWeight: "300", + "letter-spacing": "-0.01em", + styles.FontFamily: fontFamilySystem, // Roboto + "overflow-wrap": "break-word", + } +} + +// H2 Styles +// Returns inline styles for H2 headings that match .md-typeset h2. +// EXTRACTED FROM: .md-typeset h2 CSS rule from Material for MkDocs. +// +//nolint:unused // Used across templates for section headings. +func h2Styles() styles.Props { + return styles.Props{ + styles.FontSize: fontSizeH2, // 1.5625em + styles.LineHeight: lineHeightH2, // 1.4 + styles.Margin: "1.6em 0 0.64em", + styles.FontWeight: "300", + "letter-spacing": "-0.01em", + styles.Color: colorTextSecondary, // rgba(0, 0, 0, 0.54) + styles.FontFamily: fontFamilySystem, // Roboto + "overflow-wrap": "break-word", + } +} + +// H3 Styles +// Returns inline styles for H3 headings that match .md-typeset h3. +// EXTRACTED FROM: .md-typeset h3 CSS rule from Material for MkDocs. +// +//nolint:unused // Used across templates for subsection headings. +func h3Styles() styles.Props { + return styles.Props{ + styles.FontSize: fontSizeH3, // 1.25em + styles.LineHeight: lineHeightH3, // 1.5 + styles.Margin: "1.6em 0 0.8em", + styles.FontWeight: "400", + "letter-spacing": "-0.01em", + styles.Color: colorTextSecondary, // rgba(0, 0, 0, 0.54) + styles.FontFamily: fontFamilySystem, // Roboto + "overflow-wrap": "break-word", + } +} + +// Paragraph Styles +// Returns inline styles for paragraphs that match .md-typeset p. +// EXTRACTED FROM: .md-typeset p CSS rule from Material for MkDocs. +// +//nolint:unused // Used for consistent paragraph spacing. +func paragraphStyles() styles.Props { + return styles.Props{ + styles.Margin: "1em 0", + styles.FontFamily: fontFamilySystem, // Roboto + styles.FontSize: fontSizeBase, // 0.8rem - inherited from .md-typeset + styles.LineHeight: lineHeightBase, // 1.6 - inherited from .md-typeset + styles.Color: colorTextPrimary, // rgba(0, 0, 0, 0.87) + "overflow-wrap": "break-word", + } +} + +// Ordered List Styles +// Returns inline styles for ordered lists that match .md-typeset ol. +// EXTRACTED FROM: .md-typeset ol CSS rule from Material for MkDocs. +// +//nolint:unused // Used for numbered instruction lists. +func orderedListStyles() styles.Props { + return styles.Props{ + styles.MarginBottom: "1em", + styles.MarginTop: "1em", + styles.PaddingLeft: "2em", + styles.FontFamily: fontFamilySystem, // Roboto - inherited from .md-typeset + styles.FontSize: fontSizeBase, // 0.8rem - inherited from .md-typeset + styles.LineHeight: lineHeightBase, // 1.6 - inherited from .md-typeset + styles.Color: colorTextPrimary, // rgba(0, 0, 0, 0.87) - inherited from .md-typeset + "overflow-wrap": "break-word", + } +} + +// Unordered List Styles +// Returns inline styles for unordered lists that match .md-typeset ul. +// EXTRACTED FROM: .md-typeset ul CSS rule from Material for MkDocs. +// +//nolint:unused // Used for bullet point lists. +func unorderedListStyles() styles.Props { + return styles.Props{ + styles.MarginBottom: "1em", + styles.MarginTop: "1em", + styles.PaddingLeft: "2em", + styles.FontFamily: fontFamilySystem, // Roboto - inherited from .md-typeset + styles.FontSize: fontSizeBase, // 0.8rem - inherited from .md-typeset + styles.LineHeight: lineHeightBase, // 1.6 - inherited from .md-typeset + styles.Color: colorTextPrimary, // rgba(0, 0, 0, 0.87) - inherited from .md-typeset + "overflow-wrap": "break-word", + } +} + +// Link Styles +// Returns inline styles for links that match .md-typeset a. +// EXTRACTED FROM: .md-typeset a CSS rule from Material for MkDocs. +// Note: Hover states cannot be implemented with inline styles. +// +//nolint:unused // Used for text links. +func linkStyles() styles.Props { + return styles.Props{ + styles.Color: colorPrimaryAccent, // #4051b5 - var(--md-primary-fg-color) + styles.TextDecoration: "none", + "word-break": "break-word", + styles.FontFamily: fontFamilySystem, // Roboto - inherited from .md-typeset + } +} + +// Inline Code Styles (updated) +// Returns inline styles for inline code that matches .md-typeset code. +// EXTRACTED FROM: .md-typeset code CSS rule from Material for MkDocs. +// +//nolint:unused // Used for inline code snippets. +func inlineCodeStyles() styles.Props { + return styles.Props{ + styles.BackgroundColor: colorCodeBg, // #f5f5f5 + styles.Color: colorCodeFg, // #36464e + styles.BorderRadius: "0.1rem", + styles.FontSize: fontSizeCode, // 0.85em + styles.FontFamily: fontFamilyCode, // Roboto Mono + styles.Padding: "0 0.2941176471em", + "word-break": "break-word", + } +} + +// Inline Code Component +// For inline code snippets within text. +// +//nolint:unused // Reserved for future inline code usage. +func inlineCode(code string) *elem.Element { + return elem.Code(attrs.Props{ + attrs.Style: inlineCodeStyles().ToInline(), + }, elem.Text(code)) +} + +// orDivider creates a visual "or" divider between sections. +// Styled with lines on either side for better visual separation. +// +//nolint:unused // Used in apple.go template. +func orDivider() *elem.Element { + return elem.Div(attrs.Props{ + attrs.Style: styles.Props{ + styles.Display: "flex", + styles.AlignItems: "center", + styles.Gap: spaceM, + styles.MarginTop: space2XL, + styles.MarginBottom: space2XL, + styles.Width: "100%", + }.ToInline(), + }, + elem.Div(attrs.Props{ + attrs.Style: styles.Props{ + styles.Flex: "1", + styles.Height: "1px", + styles.BackgroundColor: colorBorderLight, + }.ToInline(), + }), + elem.Strong(attrs.Props{ + attrs.Style: styles.Props{ + styles.Color: colorTextSecondary, + styles.FontSize: fontSizeBase, + styles.FontWeight: "500", + "text-transform": "uppercase", + "letter-spacing": "0.05em", + }.ToInline(), + }, elem.Text("or")), + elem.Div(attrs.Props{ + attrs.Style: styles.Props{ + styles.Flex: "1", + styles.Height: "1px", + styles.BackgroundColor: colorBorderLight, + }.ToInline(), + }), + ) +} + +// warningBox creates a warning message box with icon and content. +// +//nolint:unused // Used in apple.go template. +func warningBox(title, message string) *elem.Element { + return elem.Div(attrs.Props{ + attrs.Style: styles.Props{ + styles.Display: "flex", + styles.AlignItems: "flex-start", + styles.Gap: spaceM, + styles.Padding: spaceL, + styles.BackgroundColor: "#fef3c7", // yellow-100 + styles.Border: "1px solid #f59e0b", // yellow-500 + styles.BorderRadius: "0.5rem", + styles.MarginTop: spaceL, + styles.MarginBottom: spaceL, + }.ToInline(), + }, + elem.Raw(``), + elem.Div(nil, + elem.Strong(attrs.Props{ + attrs.Style: styles.Props{ + styles.Display: "block", + styles.Color: "#92400e", // yellow-800 + styles.FontSize: fontSizeH3, + styles.MarginBottom: spaceXS, + }.ToInline(), + }, elem.Text(title)), + elem.Div(attrs.Props{ + attrs.Style: styles.Props{ + styles.Color: colorTextPrimary, + styles.FontSize: fontSizeBase, + }.ToInline(), + }, elem.Text(message)), + ), + ) +} + +// downloadButton creates a nice button-style link for downloads. +// +//nolint:unused // Used in apple.go template. +func downloadButton(href, text string) *elem.Element { + return elem.A(attrs.Props{ + attrs.Href: href, + attrs.Download: "headscale_macos.mobileconfig", + attrs.Style: styles.Props{ + styles.Display: "inline-block", + styles.Padding: "0.75rem 1.5rem", + styles.BackgroundColor: "#3b82f6", // blue-500 + styles.Color: "#ffffff", + styles.TextDecoration: "none", + styles.BorderRadius: "0.5rem", + styles.FontWeight: "500", + styles.Transition: "background-color 0.2s", + styles.MarginRight: spaceM, + styles.MarginBottom: spaceM, + }.ToInline(), + }, elem.Text(text)) +} + +// External Link Component +// Creates a link with proper security attributes for external URLs. +// Automatically adds rel="noreferrer noopener" and target="_blank". +// +//nolint:unused // Used in apple.go, oidc_callback.go templates. +func externalLink(href, text string) *elem.Element { + return elem.A(attrs.Props{ + attrs.Href: href, + attrs.Rel: "noreferrer noopener", + attrs.Target: "_blank", + attrs.Style: styles.Props{ + styles.Color: colorPrimaryAccent, // #4051b5 - base link color + styles.TextDecoration: "none", + }.ToInline(), + }, elem.Text(text)) +} + +// Instruction Step Component +// For numbered instruction lists with consistent formatting. +// +//nolint:unused // Reserved for future use in Phase 4. +func instructionStep(_ int, text string) *elem.Element { + return elem.Li(attrs.Props{ + attrs.Style: styles.Props{ + styles.MarginBottom: spaceS, + styles.LineHeight: lineHeightBase, + }.ToInline(), + }, elem.Text(text)) +} + +// Status Message Component +// For displaying success/error/info messages with appropriate styling. +// +//nolint:unused // Reserved for future use in Phase 4. +func statusMessage(message string, isSuccess bool) *elem.Element { + bgColor := colorSuccessLight + textColor := colorSuccess + + if !isSuccess { + bgColor = "#fee2e2" // red-100 + textColor = "#dc2626" // red-600 + } + + return elem.Div(attrs.Props{ + attrs.Style: styles.Props{ + styles.Padding: spaceM, + styles.BackgroundColor: bgColor, + styles.Color: textColor, + styles.BorderRadius: "0.5rem", + styles.Border: "1px solid " + textColor, + styles.MarginBottom: spaceL, + styles.FontSize: fontSizeBase, + styles.LineHeight: lineHeightBase, + }.ToInline(), + }, elem.Text(message)) +} diff --git a/hscontrol/templates/general.go b/hscontrol/templates/general.go index 3728b736..6e484a54 100644 --- a/hscontrol/templates/general.go +++ b/hscontrol/templates/general.go @@ -1,43 +1,179 @@ package templates import ( + _ "embed" + "github.com/chasefleming/elem-go" "github.com/chasefleming/elem-go/attrs" "github.com/chasefleming/elem-go/styles" ) -var bodyStyle = styles.Props{ - styles.Margin: "40px auto", - styles.MaxWidth: "800px", - styles.LineHeight: "1.5", - styles.FontSize: "16px", - styles.Color: "#444", - styles.Padding: "0 10px", - styles.FontFamily: "Sans-serif", +//go:embed style.css +var headscaleCSS string + +//go:embed headscale.svg +var headscaleSVG string + + + +// mdTypesetBody creates a body element with md-typeset styling +// that matches the official Headscale documentation design. +// Uses CSS classes with styles defined in headscaleCSS. +func mdTypesetBody(children ...elem.Node) *elem.Element { + return elem.Body(attrs.Props{ + attrs.Style: styles.Props{ + styles.MinHeight: "100vh", + styles.Display: "flex", + styles.FlexDirection: "column", + styles.AlignItems: "center", + styles.BackgroundColor: "#ffffff", + styles.Padding: "3rem 1.5rem", + }.ToInline(), + "translate": "no", + }, + elem.Div(attrs.Props{ + attrs.Class: "md-typeset", + attrs.Style: styles.Props{ + styles.MaxWidth: "min(800px, 90vw)", + styles.Width: "100%", + }.ToInline(), + }, children...), + ) } -var headerStyle = styles.Props{ - styles.LineHeight: "1.2", +// Styled Element Wrappers +// These functions wrap elem-go elements using CSS classes. +// Styling is handled by the CSS in headscaleCSS. + +// H1 creates a H1 element styled by .md-typeset h1 +func H1(children ...elem.Node) *elem.Element { + return elem.H1(nil, children...) } +// H2 creates a H2 element styled by .md-typeset h2 +func H2(children ...elem.Node) *elem.Element { + return elem.H2(nil, children...) +} + +// H3 creates a H3 element styled by .md-typeset h3 +func H3(children ...elem.Node) *elem.Element { + return elem.H3(nil, children...) +} + +// P creates a paragraph element styled by .md-typeset p +func P(children ...elem.Node) *elem.Element { + return elem.P(nil, children...) +} + +// Ol creates an ordered list element styled by .md-typeset ol +func Ol(children ...elem.Node) *elem.Element { + return elem.Ol(nil, children...) +} + +// Ul creates an unordered list element styled by .md-typeset ul +func Ul(children ...elem.Node) *elem.Element { + return elem.Ul(nil, children...) +} + +// A creates a link element styled by .md-typeset a +func A(href string, children ...elem.Node) *elem.Element { + return elem.A(attrs.Props{attrs.Href: href}, children...) +} + +// Code creates an inline code element styled by .md-typeset code +func Code(children ...elem.Node) *elem.Element { + return elem.Code(nil, children...) +} + +// Pre creates a preformatted text block styled by .md-typeset pre +func Pre(children ...elem.Node) *elem.Element { + return elem.Pre(nil, children...) +} + +// PreCode creates a code block inside Pre styled by .md-typeset pre > code +func PreCode(code string) *elem.Element { + return elem.Code(nil, elem.Text(code)) +} + +// Deprecated: use H1, H2, H3 instead func headerOne(text string) *elem.Element { - return elem.H1(attrs.Props{attrs.Style: headerStyle.ToInline()}, elem.Text(text)) + return H1(elem.Text(text)) } +// Deprecated: use H1, H2, H3 instead func headerTwo(text string) *elem.Element { - return elem.H2(attrs.Props{attrs.Style: headerStyle.ToInline()}, elem.Text(text)) + return H2(elem.Text(text)) } +// Deprecated: use H1, H2, H3 instead func headerThree(text string) *elem.Element { - return elem.H3(attrs.Props{attrs.Style: headerStyle.ToInline()}, elem.Text(text)) + return H3(elem.Text(text)) } +// contentContainer wraps page content with proper width. +// Content inside is left-aligned by default. +func contentContainer(children ...elem.Node) *elem.Element { + containerStyle := styles.Props{ + styles.MaxWidth: "720px", + styles.Width: "100%", + styles.Display: "flex", + styles.FlexDirection: "column", + styles.AlignItems: "flex-start", // Left-align all children + } + + return elem.Div(attrs.Props{attrs.Style: containerStyle.ToInline()}, children...) +} + +// headscaleLogo returns the Headscale SVG logo for consistent branding across all pages. +// The logo is styled by the .headscale-logo CSS class. +func headscaleLogo() elem.Node { + // Return the embedded SVG as-is + return elem.Raw(headscaleSVG) +} + +// pageFooter creates a consistent footer for all pages. +func pageFooter() *elem.Element { + footerStyle := styles.Props{ + styles.MarginTop: space3XL, + styles.TextAlign: "center", + styles.FontSize: fontSizeSmall, + styles.Color: colorTextSecondary, + styles.LineHeight: lineHeightBase, + } + + linkStyle := styles.Props{ + styles.Color: colorTextSecondary, + styles.TextDecoration: "underline", + } + + return elem.Div(attrs.Props{attrs.Style: footerStyle.ToInline()}, + elem.Text("Powered by "), + elem.A(attrs.Props{ + attrs.Href: "https://github.com/juanfont/headscale", + attrs.Rel: "noreferrer noopener", + attrs.Target: "_blank", + attrs.Style: linkStyle.ToInline(), + }, elem.Text("Headscale")), + ) +} + +// listStyle provides consistent styling for ordered and unordered lists +// EXTRACTED FROM: .md-typeset ol, .md-typeset ul CSS rules +var listStyle = styles.Props{ + styles.LineHeight: lineHeightBase, // 1.6 - From .md-typeset + styles.MarginTop: "1em", // From CSS: margin-top: 1em + styles.MarginBottom: "1em", // From CSS: margin-bottom: 1em + styles.PaddingLeft: "clamp(1.5rem, 5vw, 2.5rem)", // Responsive indentation +} + +// HtmlStructure creates a complete HTML document structure with proper meta tags +// and semantic HTML5 structure. The head and body elements are passed as parameters +// to allow for customization of each page. +// Styling is provided via a CSS stylesheet (Material for MkDocs design system) with +// minimal inline styles for layout and positioning. func HtmlStructure(head, body *elem.Element) *elem.Element { - return elem.Html(nil, - elem.Head( - attrs.Props{ - attrs.Lang: "en", - }, + return elem.Html(attrs.Props{attrs.Lang: "en"}, + elem.Head(nil, elem.Meta(attrs.Props{ attrs.Charset: "UTF-8", }), @@ -49,6 +185,18 @@ func HtmlStructure(head, body *elem.Element) *elem.Element { attrs.Name: "viewport", attrs.Content: "width=device-width, initial-scale=1.0", }), + // Google Fonts for Roboto and Roboto Mono + elem.Link(attrs.Props{ + attrs.Rel: "preconnect", + attrs.Href: "https://fonts.gstatic.com", + "crossorigin": "", + }), + elem.Link(attrs.Props{ + attrs.Rel: "stylesheet", + attrs.Href: "https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&family=Roboto+Mono:wght@400;700&display=swap", + }), + // Material for MkDocs CSS styles + elem.Style(attrs.Props{attrs.Type: "text/css"}, elem.Raw(headscaleCSS)), head, ), body, diff --git a/hscontrol/templates/headscale.svg b/hscontrol/templates/headscale.svg new file mode 100644 index 00000000..42aa0e9e --- /dev/null +++ b/hscontrol/templates/headscale.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/hscontrol/templates/oidc_callback.go b/hscontrol/templates/oidc_callback.go new file mode 100644 index 00000000..2b68b703 --- /dev/null +++ b/hscontrol/templates/oidc_callback.go @@ -0,0 +1,69 @@ +package templates + +import ( + "github.com/chasefleming/elem-go" + "github.com/chasefleming/elem-go/attrs" + "github.com/chasefleming/elem-go/styles" +) + +// checkboxIcon returns the success checkbox SVG icon as raw HTML. +func checkboxIcon() elem.Node { + return elem.Raw(``) +} + +// OIDCCallback renders the OIDC authentication success callback page. +func OIDCCallback(user, verb string) *elem.Element { + // Success message box + successBox := elem.Div(attrs.Props{ + attrs.Style: styles.Props{ + styles.Display: "flex", + styles.AlignItems: "center", + styles.Gap: spaceM, + styles.Padding: spaceL, + styles.BackgroundColor: colorSuccessLight, + styles.Border: "1px solid " + colorSuccess, + styles.BorderRadius: "0.5rem", + styles.MarginBottom: spaceXL, + }.ToInline(), + }, + checkboxIcon(), + elem.Div(nil, + elem.Strong(attrs.Props{ + attrs.Style: styles.Props{ + styles.Display: "block", + styles.Color: colorSuccess, + styles.FontSize: fontSizeH3, + styles.MarginBottom: spaceXS, + }.ToInline(), + }, elem.Text("Signed in successfully")), + elem.P(attrs.Props{ + attrs.Style: styles.Props{ + styles.Margin: "0", + styles.Color: colorTextPrimary, + styles.FontSize: fontSizeBase, + }.ToInline(), + }, elem.Text(verb), elem.Text(" as "), elem.Strong(nil, elem.Text(user)), elem.Text(". You can now close this window.")), + ), + ) + + return HtmlStructure( + elem.Title(nil, elem.Text("Headscale Authentication Succeeded")), + mdTypesetBody( + headscaleLogo(), + successBox, + H2(elem.Text("Getting started")), + P(elem.Text("Check out the documentation to learn more about headscale and Tailscale:")), + Ul( + elem.Li(nil, + externalLink("https://github.com/juanfont/headscale/tree/main/docs", "Headscale documentation"), + ), + elem.Li(nil, + externalLink("https://tailscale.com/kb/", "Tailscale knowledge base"), + ), + ), + pageFooter(), + ), + ) +} diff --git a/hscontrol/templates/register_web.go b/hscontrol/templates/register_web.go index 967b6573..829af7fb 100644 --- a/hscontrol/templates/register_web.go +++ b/hscontrol/templates/register_web.go @@ -4,32 +4,18 @@ import ( "fmt" "github.com/chasefleming/elem-go" - "github.com/chasefleming/elem-go/attrs" - "github.com/chasefleming/elem-go/styles" "github.com/juanfont/headscale/hscontrol/types" ) -var codeStyleRegisterWebAPI = styles.Props{ - styles.Display: "block", - styles.Padding: "20px", - styles.Border: "1px solid #bbb", - styles.BackgroundColor: "#eee", -} - func RegisterWeb(registrationID types.RegistrationID) *elem.Element { return HtmlStructure( elem.Title(nil, elem.Text("Registration - Headscale")), - elem.Body(attrs.Props{ - attrs.Style: styles.Props{ - styles.FontFamily: "sans", - }.ToInline(), - }, - elem.H1(nil, elem.Text("headscale")), - elem.H2(nil, elem.Text("Machine registration")), - elem.P(nil, elem.Text("Run the command below in the headscale server to add this machine to your network: ")), - elem.Code(attrs.Props{attrs.Style: codeStyleRegisterWebAPI.ToInline()}, - elem.Text(fmt.Sprintf("headscale nodes register --key %s --user USERNAME", registrationID.String())), - ), + mdTypesetBody( + headscaleLogo(), + H1(elem.Text("Machine registration")), + P(elem.Text("Run the command below in the headscale server to add this machine to your network:")), + Pre(PreCode(fmt.Sprintf("headscale nodes register --key %s --user USERNAME", registrationID.String()))), + pageFooter(), ), ) } diff --git a/hscontrol/templates/style.css b/hscontrol/templates/style.css new file mode 100644 index 00000000..d1eac385 --- /dev/null +++ b/hscontrol/templates/style.css @@ -0,0 +1,143 @@ +/* CSS Variables from Material for MkDocs */ +:root { + --md-default-fg-color: rgba(0, 0, 0, 0.87); + --md-default-fg-color--light: rgba(0, 0, 0, 0.54); + --md-default-fg-color--lighter: rgba(0, 0, 0, 0.32); + --md-default-fg-color--lightest: rgba(0, 0, 0, 0.07); + --md-code-fg-color: #36464e; + --md-code-bg-color: #f5f5f5; + --md-primary-fg-color: #4051b5; + --md-accent-fg-color: #526cfe; + --md-typeset-a-color: var(--md-primary-fg-color); + --md-text-font: "Roboto", -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif; + --md-code-font: "Roboto Mono", "SF Mono", Monaco, "Cascadia Code", Consolas, "Courier New", monospace; +} + +/* Base Typography */ +.md-typeset { + font-size: 0.8rem; + line-height: 1.6; + color: var(--md-default-fg-color); + font-family: var(--md-text-font); + overflow-wrap: break-word; + text-align: left; +} + +/* Headings */ +.md-typeset h1 { + color: var(--md-default-fg-color--light); + font-size: 2em; + line-height: 1.3; + margin: 0 0 1.25em; + font-weight: 300; + letter-spacing: -0.01em; +} + +.md-typeset h1:not(:first-child) { + margin-top: 2em; +} + +.md-typeset h2 { + font-size: 1.5625em; + line-height: 1.4; + margin: 2.4em 0 0.64em; + font-weight: 300; + letter-spacing: -0.01em; + color: var(--md-default-fg-color--light); +} + +.md-typeset h3 { + font-size: 1.25em; + line-height: 1.5; + margin: 2em 0 0.8em; + font-weight: 400; + letter-spacing: -0.01em; + color: var(--md-default-fg-color--light); +} + +/* Paragraphs and block elements */ +.md-typeset p { + margin: 1em 0; +} + +.md-typeset blockquote, +.md-typeset dl, +.md-typeset figure, +.md-typeset ol, +.md-typeset pre, +.md-typeset ul { + margin-bottom: 1em; + margin-top: 1em; +} + +/* Lists */ +.md-typeset ol, +.md-typeset ul { + padding-left: 2em; +} + +/* Links */ +.md-typeset a { + color: var(--md-typeset-a-color); + text-decoration: none; + word-break: break-word; +} + +.md-typeset a:hover, +.md-typeset a:focus { + color: var(--md-accent-fg-color); +} + +/* Code (inline) */ +.md-typeset code { + background-color: var(--md-code-bg-color); + color: var(--md-code-fg-color); + border-radius: 0.1rem; + font-size: 0.85em; + font-family: var(--md-code-font); + padding: 0 0.2941176471em; + word-break: break-word; +} + +/* Code blocks (pre) */ +.md-typeset pre { + display: block; + line-height: 1.4; + margin: 1em 0; + overflow-x: auto; +} + +.md-typeset pre > code { + background-color: var(--md-code-bg-color); + color: var(--md-code-fg-color); + display: block; + padding: 0.7720588235em 1.1764705882em; + font-family: var(--md-code-font); + font-size: 0.85em; + line-height: 1.4; + overflow-wrap: break-word; + word-wrap: break-word; + white-space: pre-wrap; +} + +/* Links in code */ +.md-typeset a code { + color: currentcolor; +} + +/* Logo */ +.headscale-logo { + display: block; + width: 400px; + max-width: 100%; + height: auto; + margin: 0 0 3rem 0; + padding: 0; +} + +@media (max-width: 768px) { + .headscale-logo { + width: 200px; + margin-left: 0; + } +} diff --git a/hscontrol/templates/windows.go b/hscontrol/templates/windows.go index ecf7d77c..f649509a 100644 --- a/hscontrol/templates/windows.go +++ b/hscontrol/templates/windows.go @@ -2,7 +2,6 @@ package templates import ( "github.com/chasefleming/elem-go" - "github.com/chasefleming/elem-go/attrs" ) func Windows(url string) *elem.Element { @@ -10,28 +9,19 @@ func Windows(url string) *elem.Element { elem.Title(nil, elem.Text("headscale - Windows"), ), - elem.Body(attrs.Props{ - attrs.Style: bodyStyle.ToInline(), - }, - headerOne("headscale: Windows configuration"), - elem.P(nil, + mdTypesetBody( + headscaleLogo(), + H1(elem.Text("Windows configuration")), + P( elem.Text("Download "), - elem.A(attrs.Props{ - attrs.Href: "https://tailscale.com/download/windows", - attrs.Rel: "noreferrer noopener", - attrs.Target: "_blank", - }, - elem.Text("Tailscale for Windows ")), - elem.Text("and install it."), + externalLink("https://tailscale.com/download/windows", "Tailscale for Windows"), + elem.Text(" and install it."), ), - elem.P(nil, - elem.Text("Open a Command Prompt or Powershell and use Tailscale's login command to connect with headscale: "), - ), - elem.Pre(nil, - elem.Code(nil, - elem.Text("tailscale login --login-server "+url), - ), + P( + elem.Text("Open a Command Prompt or PowerShell and use Tailscale's login command to connect with headscale:"), ), + Pre(PreCode("tailscale login --login-server "+url)), + pageFooter(), ), ) } diff --git a/hscontrol/templates_consistency_test.go b/hscontrol/templates_consistency_test.go new file mode 100644 index 00000000..273f57dc --- /dev/null +++ b/hscontrol/templates_consistency_test.go @@ -0,0 +1,212 @@ +package hscontrol + +import ( + "strings" + "testing" + + "github.com/juanfont/headscale/hscontrol/templates" + "github.com/juanfont/headscale/hscontrol/types" + "github.com/stretchr/testify/assert" +) + +func TestTemplateHTMLConsistency(t *testing.T) { + // Test all templates produce consistent modern HTML + testCases := []struct { + name string + html string + }{ + { + name: "OIDC Callback", + html: templates.OIDCCallback("test@example.com", "Logged in").Render(), + }, + { + name: "Register Web", + html: templates.RegisterWeb(types.RegistrationID("test-key-123")).Render(), + }, + { + name: "Windows Config", + html: templates.Windows("https://example.com").Render(), + }, + { + name: "Apple Config", + html: templates.Apple("https://example.com").Render(), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Check DOCTYPE + assert.True(t, strings.HasPrefix(tc.html, ""), + "%s should start with ", tc.name) + + // Check HTML5 lang attribute + assert.Contains(t, tc.html, ``, + "%s should have html lang=\"en\"", tc.name) + + // Check UTF-8 charset + assert.Contains(t, tc.html, `charset="UTF-8"`, + "%s should have UTF-8 charset", tc.name) + + // Check viewport meta tag + assert.Contains(t, tc.html, `name="viewport"`, + "%s should have viewport meta tag", tc.name) + + // Check IE compatibility meta tag + assert.Contains(t, tc.html, `X-UA-Compatible`, + "%s should have X-UA-Compatible meta tag", tc.name) + + // Check closing tags + assert.Contains(t, tc.html, "", + "%s should have closing html tag", tc.name) + assert.Contains(t, tc.html, "", + "%s should have closing head tag", tc.name) + assert.Contains(t, tc.html, "", + "%s should have closing body tag", tc.name) + }) + } +} + +func TestTemplateModernHTMLFeatures(t *testing.T) { + testCases := []struct { + name string + html string + }{ + { + name: "OIDC Callback", + html: templates.OIDCCallback("test@example.com", "Logged in").Render(), + }, + { + name: "Register Web", + html: templates.RegisterWeb(types.RegistrationID("test-key-123")).Render(), + }, + { + name: "Windows Config", + html: templates.Windows("https://example.com").Render(), + }, + { + name: "Apple Config", + html: templates.Apple("https://example.com").Render(), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Check no deprecated tags + assert.NotContains(t, tc.html, " tag", tc.name) + assert.NotContains(t, tc.html, " tag", tc.name) + + // Check modern structure + assert.Contains(t, tc.html, "", + "%s should have section", tc.name) + assert.Contains(t, tc.html, " section", tc.name) + assert.Contains(t, tc.html, "", + "%s should have <title> tag", tc.name) + }) + } +} + +func TestTemplateExternalLinkSecurity(t *testing.T) { + // Test that all external links (http/https) have proper security attributes + testCases := []struct { + name string + html string + externalURLs []string // URLs that should have security attributes + }{ + { + name: "OIDC Callback", + html: templates.OIDCCallback("test@example.com", "Logged in").Render(), + externalURLs: []string{ + "https://github.com/juanfont/headscale/tree/main/docs", + "https://tailscale.com/kb/", + }, + }, + { + name: "Register Web", + html: templates.RegisterWeb(types.RegistrationID("test-key-123")).Render(), + externalURLs: []string{}, // No external links + }, + { + name: "Windows Config", + html: templates.Windows("https://example.com").Render(), + externalURLs: []string{ + "https://tailscale.com/download/windows", + }, + }, + { + name: "Apple Config", + html: templates.Apple("https://example.com").Render(), + externalURLs: []string{ + "https://apps.apple.com/app/tailscale/id1470499037", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + for _, url := range tc.externalURLs { + // Find the link tag containing this URL + if !strings.Contains(tc.html, url) { + t.Errorf("%s should contain external link %s", tc.name, url) + continue + } + + // Check for rel="noreferrer noopener" + // We look for the pattern: href="URL"...rel="noreferrer noopener" + // The attributes might be in any order, so we check within a reasonable window + idx := strings.Index(tc.html, url) + if idx == -1 { + continue + } + + // Look for the closing > of the <a> tag (within 200 chars should be safe) + endIdx := strings.Index(tc.html[idx:idx+200], ">") + if endIdx == -1 { + endIdx = 200 + } + linkTag := tc.html[idx : idx+endIdx] + + assert.Contains(t, linkTag, `rel="noreferrer noopener"`, + "%s external link %s should have rel=\"noreferrer noopener\"", tc.name, url) + assert.Contains(t, linkTag, `target="_blank"`, + "%s external link %s should have target=\"_blank\"", tc.name, url) + } + }) + } +} + +func TestTemplateAccessibilityAttributes(t *testing.T) { + // Test that all templates have proper accessibility attributes + testCases := []struct { + name string + html string + }{ + { + name: "OIDC Callback", + html: templates.OIDCCallback("test@example.com", "Logged in").Render(), + }, + { + name: "Register Web", + html: templates.RegisterWeb(types.RegistrationID("test-key-123")).Render(), + }, + { + name: "Windows Config", + html: templates.Windows("https://example.com").Render(), + }, + { + name: "Apple Config", + html: templates.Apple("https://example.com").Render(), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Check for translate="no" on body tag to prevent browser translation + // This is important for technical documentation with commands + assert.Contains(t, tc.html, `translate="no"`, + "%s should have translate=\"no\" attribute on body tag", tc.name) + }) + } +}