mirror of
https://github.com/juanfont/headscale.git
synced 2025-11-10 01:20:58 +01:00
Merge 16cf9dfa85 into 2024219bd1
This commit is contained in:
commit
a841b8d00d
@ -1,307 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Headscale Authentication Succeeded</title>
|
||||
<style>
|
||||
body {
|
||||
font-size: 14px;
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
"Roboto",
|
||||
"Oxygen",
|
||||
"Ubuntu",
|
||||
"Cantarell",
|
||||
"Fira Sans",
|
||||
"Droid Sans",
|
||||
"Helvetica Neue",
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
hr {
|
||||
border-color: #fdfdfe;
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 70vh;
|
||||
}
|
||||
|
||||
#logo {
|
||||
display: block;
|
||||
margin-left: -20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
min-width: 40vw;
|
||||
background: #fafdfa;
|
||||
border: 1px solid #c6e9c9;
|
||||
margin-bottom: 12px;
|
||||
padding: 12px 16px 16px 12px;
|
||||
position: relative;
|
||||
border-radius: 2px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.message #checkbox {
|
||||
fill: #2eb039;
|
||||
}
|
||||
|
||||
.message .message-title {
|
||||
color: #1e7125;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.message .message-body {
|
||||
border: 0;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.message p {
|
||||
font-size: 12px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color: #17421b;
|
||||
}
|
||||
|
||||
a {
|
||||
display: block;
|
||||
margin: 8px 0;
|
||||
color: #1563ff;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: black;
|
||||
}
|
||||
|
||||
a svg {
|
||||
fill: currentcolor;
|
||||
}
|
||||
|
||||
.icon {
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
height: 21px;
|
||||
width: 21px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 17.5px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
h1 + p {
|
||||
margin: 8px 0 16px 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body translate="no">
|
||||
<div class="container">
|
||||
<div>
|
||||
<svg
|
||||
id="logo"
|
||||
width="146"
|
||||
height="51"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xml:space="preserve"
|
||||
style="
|
||||
fill-rule: evenodd;
|
||||
clip-rule: evenodd;
|
||||
stroke-linejoin: round;
|
||||
stroke-miterlimit: 2;
|
||||
"
|
||||
viewBox="0 0 1280 640"
|
||||
>
|
||||
<path
|
||||
d="M.08 0v-.736h.068v.3C.203-.509.27-.545.347-.545c.029 0 .055.005.079.015.024.01.045.025.062.045.017.02.031.045.041.075.009.03.014.065.014.105V0H.475v-.289C.475-.352.464-.4.443-.433.422-.466.385-.483.334-.483c-.027 0-.052.006-.075.017C.236-.455.216-.439.2-.419c-.017.02-.029.044-.038.072-.009.028-.014.059-.014.093V0H.08Z"
|
||||
style="fill: #f8b5cb; fill-rule: nonzero"
|
||||
transform="translate(32.92220721 521.8022953) scale(235.3092)"
|
||||
/>
|
||||
<path
|
||||
d="M.051-.264c0-.036.007-.071.02-.105.013-.034.031-.064.055-.09.023-.026.052-.047.086-.063.033-.015.071-.023.112-.023.039 0 .076.007.109.021.033.014.062.033.087.058.025.025.044.054.058.088.014.035.021.072.021.113v.005H.121c.001.031.007.059.018.084.01.025.024.047.042.065.018.019.04.033.065.043.025.01.052.015.082.015.026 0 .049-.003.069-.01.02-.007.038-.016.054-.028C.466-.102.48-.115.492-.13c.011-.015.022-.03.032-.046l.057.03C.556-.097.522-.058.48-.03.437-.001.387.013.328.013.284.013.245.006.21-.01.175-.024.146-.045.123-.07.1-.095.082-.125.07-.159.057-.192.051-.227.051-.264ZM.128-.32h.396C.51-.375.485-.416.449-.441.412-.466.371-.479.325-.479c-.048 0-.089.013-.123.039-.034.026-.059.066-.074.12Z"
|
||||
style="fill: #8d8d8d; fill-rule: nonzero"
|
||||
transform="translate(177.16674681 521.8022953) scale(235.3092)"
|
||||
/>
|
||||
<path
|
||||
d="M.051-.267c0-.038.007-.074.021-.108.014-.033.033-.063.058-.088.025-.025.054-.045.087-.06.033-.015.069-.022.108-.022.043 0 .083.009.119.027.035.019.066.047.093.084v-.097h.067V0H.537v-.091C.508-.056.475-.029.44-.013.404.005.365.013.323.013.284.013.248.006.215-.01.182-.024.153-.045.129-.071.104-.096.085-.126.072-.16.058-.193.051-.229.051-.267Zm.279.218c.027 0 .054-.005.079-.015.025-.01.048-.024.068-.043.019-.018.035-.04.047-.067.012-.027.018-.056.018-.089 0-.031-.005-.059-.016-.086C.515-.375.501-.398.482-.417.462-.436.44-.452.415-.463.389-.474.361-.479.331-.479c-.031 0-.059.006-.084.017C.221-.45.199-.434.18-.415c-.019.02-.033.043-.043.068-.011.026-.016.053-.016.082 0 .029.005.056.016.082.011.026.025.049.044.069.019.02.041.036.066.047.025.012.053.018.083.018Z"
|
||||
style="fill: #8d8d8d; fill-rule: nonzero"
|
||||
transform="translate(327.76463481 521.8022953) scale(235.3092)"
|
||||
/>
|
||||
<path
|
||||
d="M.051-.267c0-.038.007-.074.021-.108.014-.033.033-.063.058-.088.025-.025.054-.045.087-.06.033-.015.069-.022.108-.022.043 0 .083.009.119.027.035.019.066.047.093.084v-.302h.068V0H.537v-.091C.508-.056.475-.029.44-.013.404.005.365.013.323.013.284.013.248.006.215-.01.182-.024.153-.045.129-.071.104-.096.085-.126.072-.16.058-.193.051-.229.051-.267Zm.279.218c.027 0 .054-.005.079-.015.025-.01.048-.024.068-.043.019-.018.035-.04.047-.067.011-.027.017-.056.017-.089 0-.031-.005-.059-.016-.086C.514-.375.5-.398.481-.417.462-.436.439-.452.414-.463.389-.474.361-.479.331-.479c-.031 0-.059.006-.084.017C.221-.45.199-.434.18-.415c-.019.02-.033.043-.043.068-.011.026-.016.053-.016.082 0 .029.005.056.016.082.011.026.025.049.044.069.019.02.041.036.066.047.025.012.053.018.083.018Z"
|
||||
style="fill: #8d8d8d; fill-rule: nonzero"
|
||||
transform="translate(488.71612761 521.8022953) scale(235.3092)"
|
||||
/>
|
||||
<path
|
||||
d="m.034-.062.043-.049c.017.019.035.034.054.044.018.01.037.015.057.015.013 0 .026-.002.038-.007.011-.004.021-.01.031-.018.009-.008.016-.017.021-.028.005-.011.008-.022.008-.035 0-.019-.005-.034-.014-.047C.263-.199.248-.21.229-.221.205-.234.183-.247.162-.259.14-.271.122-.284.107-.298.092-.311.08-.327.071-.344.062-.361.058-.381.058-.404c0-.021.004-.04.012-.058.007-.016.018-.031.031-.044.013-.013.028-.022.046-.029.018-.007.037-.01.057-.01.029 0 .056.006.079.019s.045.031.068.053l-.044.045C.291-.443.275-.456.258-.465.241-.474.221-.479.2-.479c-.022 0-.041.007-.056.02C.128-.445.12-.428.12-.408c0 .019.006.035.017.048.011.013.027.026.048.037.027.015.05.028.071.04.021.013.038.026.052.039.014.013.025.028.032.044.007.016.011.035.011.057 0 .021-.004.041-.011.059-.008.019-.019.036-.033.05-.014.015-.031.026-.05.035C.237.01.215.014.191.014c-.03 0-.059-.006-.086-.02C.077-.019.053-.037.034-.062Z"
|
||||
style="fill: #8d8d8d; fill-rule: nonzero"
|
||||
transform="translate(649.90292961 521.8022953) scale(235.3092)"
|
||||
/>
|
||||
<path
|
||||
d="M.051-.266c0-.04.007-.077.022-.111.014-.034.034-.063.059-.089.025-.025.054-.044.089-.058.035-.014.072-.021.113-.021.051 0 .098.01.139.03.041.021.075.049.1.085l-.05.043C.498-.418.47-.441.439-.456.408-.471.372-.479.331-.479c-.03 0-.058.005-.083.016C.222-.452.2-.436.181-.418.162-.399.148-.376.137-.35c-.011.026-.016.054-.016.084 0 .031.005.06.016.086.011.027.025.049.044.068.019.019.041.034.067.044.025.011.053.016.084.016.077 0 .141-.03.191-.09l.051.04c-.028.036-.062.064-.103.085C.43.004.384.014.332.014.291.014.254.007.219-.008.184-.022.155-.042.13-.067.105-.092.086-.121.072-.156.058-.19.051-.227.051-.266Z"
|
||||
style="fill: #8d8d8d; fill-rule: nonzero"
|
||||
transform="translate(741.20289921 521.8022953) scale(235.3092)"
|
||||
/>
|
||||
<path
|
||||
d="M.051-.267c0-.038.007-.074.021-.108.014-.033.033-.063.058-.088.025-.025.054-.045.087-.06.033-.015.069-.022.108-.022.043 0 .083.009.119.027.035.019.066.047.093.084v-.097h.067V0H.537v-.091C.508-.056.475-.029.44-.013.404.005.365.013.323.013.284.013.248.006.215-.01.182-.024.153-.045.129-.071.104-.096.085-.126.072-.16.058-.193.051-.229.051-.267Zm.279.218c.027 0 .054-.005.079-.015.025-.01.048-.024.068-.043.019-.018.035-.04.047-.067.012-.027.018-.056.018-.089 0-.031-.005-.059-.016-.086C.515-.375.501-.398.482-.417.462-.436.44-.452.415-.463.389-.474.361-.479.331-.479c-.031 0-.059.006-.084.017C.221-.45.199-.434.18-.415c-.019.02-.033.043-.043.068-.011.026-.016.053-.016.082 0 .029.005.056.016.082.011.026.025.049.044.069.019.02.041.036.066.047.025.012.053.018.083.018Z"
|
||||
style="fill: #8d8d8d; fill-rule: nonzero"
|
||||
transform="translate(884.27089281 521.8022953) scale(235.3092)"
|
||||
/>
|
||||
<path
|
||||
d="M.066-.736h.068V0H.066z"
|
||||
style="fill: #8d8d8d; fill-rule: nonzero"
|
||||
transform="translate(1045.22238561 521.8022953) scale(235.3092)"
|
||||
/>
|
||||
<path
|
||||
d="M.051-.264c0-.036.007-.071.02-.105.013-.034.031-.064.055-.09.023-.026.052-.047.086-.063.033-.015.071-.023.112-.023.039 0 .076.007.109.021.033.014.062.033.087.058.025.025.044.054.058.088.014.035.021.072.021.113v.005H.121c.001.031.007.059.018.084.01.025.024.047.042.065.018.019.04.033.065.043.025.01.052.015.082.015.026 0 .049-.003.069-.01.02-.007.038-.016.054-.028C.466-.102.48-.115.492-.13c.011-.015.022-.03.032-.046l.057.03C.556-.097.522-.058.48-.03.437-.001.387.013.328.013.284.013.245.006.21-.01.175-.024.146-.045.123-.07.1-.095.082-.125.07-.159.057-.192.051-.227.051-.264ZM.128-.32h.396C.51-.375.485-.416.449-.441.412-.466.371-.479.325-.479c-.048 0-.089.013-.123.039-.034.026-.059.066-.074.12Z"
|
||||
style="fill: #8d8d8d; fill-rule: nonzero"
|
||||
transform="translate(1092.28422561 521.8022953) scale(235.3092)"
|
||||
/>
|
||||
<circle
|
||||
cx="141.023"
|
||||
cy="338.36"
|
||||
r="117.472"
|
||||
style="fill: #f8b5cb"
|
||||
transform="matrix(.581302 0 0 .58613 40.06479894 12.59842153)"
|
||||
/>
|
||||
<circle
|
||||
cx="352.014"
|
||||
cy="268.302"
|
||||
r="33.095"
|
||||
style="fill: #a2a2a2"
|
||||
transform="matrix(.59308 0 0 .58289 32.39345942 21.2386)"
|
||||
/>
|
||||
<circle
|
||||
cx="352.014"
|
||||
cy="268.302"
|
||||
r="33.095"
|
||||
style="fill: #a2a2a2"
|
||||
transform="matrix(.59308 0 0 .58289 32.39345942 88.80371146)"
|
||||
/>
|
||||
<circle
|
||||
cx="352.014"
|
||||
cy="268.302"
|
||||
r="33.095"
|
||||
style="fill: #a2a2a2"
|
||||
transform="matrix(.59308 0 0 .58289 120.7528627 88.80371146)"
|
||||
/>
|
||||
<circle
|
||||
cx="352.014"
|
||||
cy="268.302"
|
||||
r="33.095"
|
||||
style="fill: #a2a2a2"
|
||||
transform="matrix(.59308 0 0 .58289 120.99825939 21.2386)"
|
||||
/>
|
||||
<circle
|
||||
cx="805.557"
|
||||
cy="336.915"
|
||||
r="118.199"
|
||||
style="fill: #8d8d8d"
|
||||
transform="matrix(.5782 0 0 .58289 36.19871106 15.26642564)"
|
||||
/>
|
||||
<circle
|
||||
cx="805.557"
|
||||
cy="336.915"
|
||||
r="118.199"
|
||||
style="fill: #8d8d8d"
|
||||
transform="matrix(.5782 0 0 .58289 183.24041937 15.26642564)"
|
||||
/>
|
||||
<path
|
||||
d="M680.282 124.808h-68.093v390.325h68.081v-28.23H640V153.228h40.282v-28.42Z"
|
||||
style="fill: #303030"
|
||||
transform="translate(34.2345 21.2386) scale(.58289)"
|
||||
/>
|
||||
<path
|
||||
d="M680.282 124.808h-68.093v390.325h68.081v-28.23H640V153.228h40.282v-28.42Z"
|
||||
style="fill: #303030"
|
||||
transform="matrix(-.58289 0 0 .58289 1116.7719791 21.2386)"
|
||||
/>
|
||||
</svg>
|
||||
<div class="message is-success">
|
||||
<svg
|
||||
id="checkbox"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 512 512"
|
||||
>
|
||||
<path
|
||||
d="M256 32C132.3 32 32 132.3 32 256s100.3 224 224 224 224-100.3 224-224S379.7 32 256 32zm114.9 149.1L231.8 359.6c-1.1 1.1-2.9 3.5-5.1 3.5-2.3 0-3.8-1.6-5.1-2.9-1.3-1.3-78.9-75.9-78.9-75.9l-1.5-1.5c-.6-.9-1.1-2-1.1-3.2 0-1.2.5-2.3 1.1-3.2.4-.4.7-.7 1.1-1.2 7.7-8.1 23.3-24.5 24.3-25.5 1.3-1.3 2.4-3 4.8-3 2.5 0 4.1 2.1 5.3 3.3 1.2 1.2 45 43.3 45 43.3l111.3-143c1-.8 2.2-1.4 3.5-1.4 1.3 0 2.5.5 3.5 1.3l30.6 24.1c.8 1 1.3 2.2 1.3 3.5.1 1.3-.4 2.4-1 3.3z"
|
||||
></path>
|
||||
</svg>
|
||||
<div class="message-content">
|
||||
<div class="message-title">Signed in via your OIDC provider</div>
|
||||
<p class="message-body">
|
||||
{{.Verb}} as {{.User}}, you can now close this window.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
<h1>Not sure how to get started?</h1>
|
||||
<p class="learn">
|
||||
Check out beginner and advanced guides on, or read more in the
|
||||
documentation.
|
||||
</p>
|
||||
<a
|
||||
href="https://github.com/juanfont/headscale/tree/main/docs"
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
>
|
||||
<span class="icon">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M13.307 1H11.5a.5.5 0 1 1 0-1h3a.499.499 0 0 1 .5.65V3.5a.5.5 0 1 1-1 0V1.72l-1.793 1.774a.5.5 0 0 1-.713-.701L13.307 1zM12 14V8a.5.5 0 1 1 1 0v6.5a.5.5 0 0 1-.5.5H.563a.5.5 0 0 1-.5-.5v-13a.5.5 0 0 1 .5-.5H8a.5.5 0 0 1 0 1H1v12h11zM4 6a.5.5 0 0 1 0-1h3a.5.5 0 0 1 0 1H4zm0 2.5a.5.5 0 0 1 0-1h5a.5.5 0 0 1 0 1H4zM4 11a.5.5 0 1 1 0-1h5a.5.5 0 1 1 0 1H4z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
View the headscale documentation
|
||||
</a>
|
||||
<a
|
||||
href="https://tailscale.com/kb/"
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
>
|
||||
<span class="icon">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M13.307 1H11.5a.5.5 0 1 1 0-1h3a.499.499 0 0 1 .5.65V3.5a.5.5 0 1 1-1 0V1.72l-1.793 1.774a.5.5 0 0 1-.713-.701L13.307 1zM12 14V8a.5.5 0 1 1 1 0v6.5a.5.5 0 0 1-.5.5H.563a.5.5 0 0 1-.5-.5v-13a.5.5 0 0 1 .5-.5H8a.5.5 0 0 1 0 1H1v12h11zM4 6a.5.5 0 0 1 0-1h3a.5.5 0 0 1 0 1H4zm0 2.5a.5.5 0 0 1 0-1h5a.5.5 0 0 1 0 1H4zM4 11a.5.5 0 1 1 0-1h5a.5.5 0 1 1 0 1H4z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
View the tailscale documentation
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -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) {
|
||||
|
||||
67
hscontrol/oidc_template_test.go
Normal file
67
hscontrol/oidc_template_test.go
Normal file
@ -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, "<!DOCTYPE html>")
|
||||
assert.Contains(t, html, "<title>Headscale Authentication Succeeded</title>")
|
||||
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, "<svg")
|
||||
assert.Contains(t, html, "id=\"logo\"")
|
||||
assert.Contains(t, html, "id=\"checkbox\"")
|
||||
|
||||
// Save the output for manual inspection
|
||||
testDataDir := filepath.Join("testdata", "oidc_templates")
|
||||
err := os.MkdirAll(testDataDir, 0o755)
|
||||
require.NoError(t, err)
|
||||
|
||||
outputFile := filepath.Join(testDataDir, tt.name+".html")
|
||||
err = os.WriteFile(outputFile, []byte(html), 0o644)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -5,48 +5,43 @@ import (
|
||||
|
||||
"github.com/chasefleming/elem-go"
|
||||
"github.com/chasefleming/elem-go/attrs"
|
||||
"github.com/chasefleming/elem-go/styles"
|
||||
)
|
||||
|
||||
func Apple(url string) *elem.Element {
|
||||
return HtmlStructure(
|
||||
elem.Title(nil,
|
||||
elem.Text("headscale - Apple")),
|
||||
elem.Body(attrs.Props{
|
||||
attrs.Style: bodyStyle.ToInline(),
|
||||
},
|
||||
headerOne("headscale: iOS configuration"),
|
||||
headerTwo("GUI"),
|
||||
elem.Ol(nil,
|
||||
mdTypesetBody(
|
||||
headscaleLogo(),
|
||||
H1(elem.Text("iOS configuration")),
|
||||
H2(elem.Text("GUI")),
|
||||
Ol(
|
||||
elem.Li(
|
||||
nil,
|
||||
elem.Text("Install the official Tailscale iOS client from the "),
|
||||
elem.A(
|
||||
attrs.Props{
|
||||
attrs.Href: "https://apps.apple.com/app/tailscale/id1470499037",
|
||||
},
|
||||
elem.Text("App store"),
|
||||
),
|
||||
externalLink("https://apps.apple.com/app/tailscale/id1470499037", "App Store"),
|
||||
),
|
||||
elem.Li(
|
||||
nil,
|
||||
elem.Text("Open the Tailscale app"),
|
||||
elem.Text("Open the "),
|
||||
elem.Strong(nil, elem.Text("Tailscale")),
|
||||
elem.Text(" app"),
|
||||
),
|
||||
elem.Li(
|
||||
nil,
|
||||
elem.Text(`Click the account icon in the top-right corner and select "Log in…".`),
|
||||
elem.Text("Click the account icon in the top-right corner and select "),
|
||||
elem.Strong(nil, elem.Text("Log in…")),
|
||||
),
|
||||
elem.Li(
|
||||
nil,
|
||||
elem.Text(`Tap the top-right options menu button and select "Use custom coordination server".`),
|
||||
elem.Text("Tap the top-right options menu button and select "),
|
||||
elem.Strong(nil, elem.Text("Use custom coordination server")),
|
||||
),
|
||||
elem.Li(
|
||||
nil,
|
||||
elem.Text(
|
||||
fmt.Sprintf(
|
||||
`Enter your instance URL: "%s"`,
|
||||
url,
|
||||
),
|
||||
),
|
||||
elem.Text("Enter your instance URL: "),
|
||||
Code(elem.Text(url)),
|
||||
),
|
||||
elem.Li(
|
||||
nil,
|
||||
@ -55,65 +50,50 @@ func Apple(url string) *elem.Element {
|
||||
),
|
||||
),
|
||||
),
|
||||
headerOne("headscale: macOS configuration"),
|
||||
headerTwo("Command line"),
|
||||
elem.P(nil,
|
||||
H1(elem.Text("macOS configuration")),
|
||||
H2(elem.Text("Command line")),
|
||||
P(
|
||||
elem.Text("Use Tailscale's login command to add your profile:"),
|
||||
),
|
||||
elem.Pre(nil,
|
||||
elem.Code(nil,
|
||||
elem.Text("tailscale login --login-server "+url),
|
||||
),
|
||||
),
|
||||
headerTwo("GUI"),
|
||||
elem.Ol(nil,
|
||||
Pre(PreCode("tailscale login --login-server "+url)),
|
||||
H2(elem.Text("GUI")),
|
||||
Ol(
|
||||
elem.Li(
|
||||
nil,
|
||||
elem.Text(
|
||||
"Option + Click the Tailscale icon in the menu and hover over the Debug menu",
|
||||
),
|
||||
elem.Text("Option + Click the "),
|
||||
elem.Strong(nil, elem.Text("Tailscale")),
|
||||
elem.Text(" icon in the menu and hover over the "),
|
||||
elem.Strong(nil, elem.Text("Debug")),
|
||||
elem.Text(" menu"),
|
||||
),
|
||||
elem.Li(nil,
|
||||
elem.Text(`Under "Custom Login Server", select "Add Account..."`),
|
||||
elem.Text("Under "),
|
||||
elem.Strong(nil, elem.Text("Custom Login Server")),
|
||||
elem.Text(", select "),
|
||||
elem.Strong(nil, elem.Text("Add Account...")),
|
||||
),
|
||||
elem.Li(
|
||||
nil,
|
||||
elem.Text(
|
||||
fmt.Sprintf(
|
||||
`Enter "%s" of the headscale instance and press "Add Account"`,
|
||||
url,
|
||||
),
|
||||
),
|
||||
elem.Text("Enter "),
|
||||
Code(elem.Text(url)),
|
||||
elem.Text(" of the headscale instance and press "),
|
||||
elem.Strong(nil, elem.Text("Add Account")),
|
||||
),
|
||||
elem.Li(nil,
|
||||
elem.Text(`Follow the login procedure in the browser`),
|
||||
elem.Text("Follow the login procedure in the browser"),
|
||||
),
|
||||
),
|
||||
headerTwo("Profiles"),
|
||||
elem.P(
|
||||
nil,
|
||||
H2(elem.Text("Profiles")),
|
||||
P(
|
||||
elem.Text(
|
||||
"Headscale can be set to the default server by installing a Headscale configuration profile:",
|
||||
),
|
||||
),
|
||||
elem.P(
|
||||
nil,
|
||||
elem.A(
|
||||
attrs.Props{
|
||||
attrs.Href: "/apple/macos-app-store",
|
||||
attrs.Download: "headscale_macos.mobileconfig",
|
||||
},
|
||||
elem.Text("macOS AppStore profile "),
|
||||
),
|
||||
elem.A(
|
||||
attrs.Props{
|
||||
attrs.Href: "/apple/macos-standalone",
|
||||
attrs.Download: "headscale_macos.mobileconfig",
|
||||
},
|
||||
elem.Text("macOS Standalone profile"),
|
||||
),
|
||||
elem.Div(attrs.Props{attrs.Style: styles.Props{styles.MarginTop: spaceL, styles.MarginBottom: spaceL}.ToInline()},
|
||||
downloadButton("/apple/macos-app-store", "macOS AppStore profile"),
|
||||
downloadButton("/apple/macos-standalone", "macOS Standalone profile"),
|
||||
),
|
||||
elem.Ol(nil,
|
||||
Ol(
|
||||
elem.Li(
|
||||
nil,
|
||||
elem.Text(
|
||||
@ -121,105 +101,82 @@ func Apple(url string) *elem.Element {
|
||||
),
|
||||
),
|
||||
elem.Li(nil,
|
||||
elem.Text(`Open System Preferences and go to "Profiles"`),
|
||||
elem.Text("Open "),
|
||||
elem.Strong(nil, elem.Text("System Preferences")),
|
||||
elem.Text(" and go to "),
|
||||
elem.Strong(nil, elem.Text("Profiles")),
|
||||
),
|
||||
elem.Li(nil,
|
||||
elem.Text(`Find and install the Headscale profile`),
|
||||
elem.Text("Find and install the "),
|
||||
elem.Strong(nil, elem.Text("Headscale")),
|
||||
elem.Text(" profile"),
|
||||
),
|
||||
elem.Li(nil,
|
||||
elem.Text(`Restart Tailscale.app and log in`),
|
||||
elem.Text("Restart "),
|
||||
elem.Strong(nil, elem.Text("Tailscale.app")),
|
||||
elem.Text(" and log in"),
|
||||
),
|
||||
),
|
||||
elem.P(nil, elem.Text("Or")),
|
||||
elem.P(
|
||||
nil,
|
||||
orDivider(),
|
||||
P(
|
||||
elem.Text(
|
||||
"Use your terminal to configure the default setting for Tailscale by issuing:",
|
||||
"Use your terminal to configure the default setting for Tailscale by issuing one of the following commands:",
|
||||
),
|
||||
),
|
||||
elem.Ul(nil,
|
||||
elem.Li(nil,
|
||||
elem.Text(`for app store client:`),
|
||||
elem.Code(
|
||||
nil,
|
||||
elem.Text(
|
||||
"defaults write io.tailscale.ipn.macos ControlURL "+url,
|
||||
),
|
||||
),
|
||||
),
|
||||
elem.Li(nil,
|
||||
elem.Text(`for standalone client:`),
|
||||
elem.Code(
|
||||
nil,
|
||||
elem.Text(
|
||||
"defaults write io.tailscale.ipn.macsys ControlURL "+url,
|
||||
),
|
||||
),
|
||||
),
|
||||
P(elem.Text("For app store client:")),
|
||||
Pre(PreCode("defaults write io.tailscale.ipn.macos ControlURL "+url)),
|
||||
P(elem.Text("For standalone client:")),
|
||||
Pre(PreCode("defaults write io.tailscale.ipn.macsys ControlURL "+url)),
|
||||
P(
|
||||
elem.Text("Restart "),
|
||||
elem.Strong(nil, elem.Text("Tailscale.app")),
|
||||
elem.Text(" and log in."),
|
||||
),
|
||||
elem.P(nil,
|
||||
elem.Text("Restart Tailscale.app and log in."),
|
||||
),
|
||||
headerThree("Caution"),
|
||||
elem.P(
|
||||
nil,
|
||||
elem.Text(
|
||||
"You should always download and inspect the profile before installing it:",
|
||||
),
|
||||
),
|
||||
elem.Ul(nil,
|
||||
elem.Li(nil,
|
||||
elem.Text(`for app store client: `),
|
||||
elem.Code(nil,
|
||||
elem.Text(fmt.Sprintf(`curl %s/apple/macos-app-store`, url)),
|
||||
),
|
||||
),
|
||||
elem.Li(nil,
|
||||
elem.Text(`for standalone client: `),
|
||||
elem.Code(nil,
|
||||
elem.Text(fmt.Sprintf(`curl %s/apple/macos-standalone`, url)),
|
||||
),
|
||||
),
|
||||
),
|
||||
headerOne("headscale: tvOS configuration"),
|
||||
headerTwo("GUI"),
|
||||
elem.Ol(nil,
|
||||
warningBox("Caution", "You should always download and inspect the profile before installing it."),
|
||||
P(elem.Text("For app store client:")),
|
||||
Pre(PreCode(fmt.Sprintf(`curl %s/apple/macos-app-store`, url))),
|
||||
P(elem.Text("For standalone client:")),
|
||||
Pre(PreCode(fmt.Sprintf(`curl %s/apple/macos-standalone`, url))),
|
||||
H1(elem.Text("tvOS configuration")),
|
||||
H2(elem.Text("GUI")),
|
||||
Ol(
|
||||
elem.Li(
|
||||
nil,
|
||||
elem.Text("Install the official Tailscale tvOS client from the "),
|
||||
elem.A(
|
||||
attrs.Props{
|
||||
attrs.Href: "https://apps.apple.com/app/tailscale/id1470499037",
|
||||
},
|
||||
elem.Text("App store"),
|
||||
),
|
||||
externalLink("https://apps.apple.com/app/tailscale/id1470499037", "App Store"),
|
||||
),
|
||||
elem.Li(
|
||||
nil,
|
||||
elem.Text(
|
||||
"Open Settings (the Apple tvOS settings) > 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(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
482
hscontrol/templates/design.go
Normal file
482
hscontrol/templates/design.go
Normal file
@ -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(`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink: 0; margin-top: 2px;"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>`),
|
||||
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))
|
||||
}
|
||||
@ -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,
|
||||
|
||||
1
hscontrol/templates/headscale.svg
Normal file
1
hscontrol/templates/headscale.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 7.8 KiB |
69
hscontrol/templates/oidc_callback.go
Normal file
69
hscontrol/templates/oidc_callback.go
Normal file
@ -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(`<svg id="checkbox" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 512 512">
|
||||
<path d="M256 32C132.3 32 32 132.3 32 256s100.3 224 224 224 224-100.3 224-224S379.7 32 256 32zm114.9 149.1L231.8 359.6c-1.1 1.1-2.9 3.5-5.1 3.5-2.3 0-3.8-1.6-5.1-2.9-1.3-1.3-78.9-75.9-78.9-75.9l-1.5-1.5c-.6-.9-1.1-2-1.1-3.2 0-1.2.5-2.3 1.1-3.2.4-.4.7-.7 1.1-1.2 7.7-8.1 23.3-24.5 24.3-25.5 1.3-1.3 2.4-3 4.8-3 2.5 0 4.1 2.1 5.3 3.3 1.2 1.2 45 43.3 45 43.3l111.3-143c1-.8 2.2-1.4 3.5-1.4 1.3 0 2.5.5 3.5 1.3l30.6 24.1c.8 1 1.3 2.2 1.3 3.5.1 1.3-.4 2.4-1 3.3z"></path>
|
||||
</svg>`)
|
||||
}
|
||||
|
||||
// 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(),
|
||||
),
|
||||
)
|
||||
}
|
||||
@ -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(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
143
hscontrol/templates/style.css
Normal file
143
hscontrol/templates/style.css
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
212
hscontrol/templates_consistency_test.go
Normal file
212
hscontrol/templates_consistency_test.go
Normal file
@ -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, "<!DOCTYPE html>"),
|
||||
"%s should start with <!DOCTYPE html>", tc.name)
|
||||
|
||||
// Check HTML5 lang attribute
|
||||
assert.Contains(t, tc.html, `<html lang="en">`,
|
||||
"%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, "</html>",
|
||||
"%s should have closing html tag", tc.name)
|
||||
assert.Contains(t, tc.html, "</head>",
|
||||
"%s should have closing head tag", tc.name)
|
||||
assert.Contains(t, tc.html, "</body>",
|
||||
"%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, "<font",
|
||||
"%s should not use deprecated <font> tag", tc.name)
|
||||
assert.NotContains(t, tc.html, "<center",
|
||||
"%s should not use deprecated <center> tag", tc.name)
|
||||
|
||||
// Check modern structure
|
||||
assert.Contains(t, tc.html, "<head>",
|
||||
"%s should have <head> section", tc.name)
|
||||
assert.Contains(t, tc.html, "<body",
|
||||
"%s should have <body> section", tc.name)
|
||||
assert.Contains(t, tc.html, "<title>",
|
||||
"%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)
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user