authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-06-11 00:26:10-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-06-11 01:38:21-07:00
log7724489e4f16f750a945aef25084e7b30bbde95b
tree00e35a228374f5b30e25dbb49600628d1113fb73
parent345d7576fee9ee12c5032dec7e5d0ac999e29880
signaturebadge-check Signed by SSH key SHA256:xbd+BjjhyBfwk7GVoURf9Yx0gzDerHbvYv7SddNWmAs

feat: app list filters by signed-in user, drops non-web services

#16 /apps now sits behind forward-auth; the page is a caddy template that shows only entries whose role (media / media-manage / admin) appears in the User-Groups header forward-auth sets. flat list, no section headers.

3 files changed, 189 insertions(+), 104 deletions(-)

config/Caddyfile+17-1
...@@ -43,10 +43,26 @@...@@ -43,10 +43,26 @@
43# services are sorted alphabetically43# services are sorted alphabetically
44auth.{$HOME_DOMAIN} {44auth.{$HOME_DOMAIN} {
45 handle / {45 handle / {
46 redir / /realms/master/account46 redir / /apps
47 }
48 handle /snow.oauth2/* {
49 reverse_proxy "http://forward-auth" {
50 header_up X-Real-IP {remote_host}
51 header_up X-Forwarded-Uri {uri}
52 }
53 }
54 forward_auth /apps "http://forward-auth" {
55 uri /snow.oauth2/auth
56 header_up X-Real-IP {remote_host}
57 copy_headers X-Auth-Request-Groups>User-Groups X-Auth-Request-Preferred-Username>User-Name
58 @error status 401
59 handle_response @error {
60 redir * /snow.oauth2/sign_in?rd={scheme}://{host}{uri}
61 }
47 }62 }
48 handle /apps {63 handle /apps {
49 rewrite * /static/apps.html64 rewrite * /static/apps.html
65 templates
50 file_server {66 file_server {
51 root /etc/caddy67 root /etc/caddy
52 }68 }
config/static/apps.html+87-56
...@@ -1,5 +1,9 @@...@@ -1,5 +1,9 @@
1<!doctype html>1<!doctype html>
2<!-- generated by generate-app-list.py — do not edit by hand -->2<!-- generated by generate-app-list.py — do not edit by hand.
3 rendered by caddy's templates directive; entries are filtered by the
4 User-Groups header that forward-auth sets for the signed-in user. -->
5{{ $groups := .Req.Header.Get "User-Groups" }}
6{{ $name := .Req.Header.Get "User-Name" }}
3<html lang="en">7<html lang="en">
4<head>8<head>
5<meta charset="utf-8">9<meta charset="utf-8">
...@@ -10,74 +14,101 @@...@@ -10,74 +14,101 @@
10:root { color-scheme: light dark; }14:root { color-scheme: light dark; }
11body {15body {
12 font-family: "national-medium", system-ui, sans-serif;16 font-family: "national-medium", system-ui, sans-serif;
13 background-color: light-dark(#e8eefa, #2f4b67);17 background-color: light-dark(#e8eefa, #152c42);
14 color: light-dark(black, white);18 color: light-dark(black, white);
15 max-width: 34rem;19 max-width: 34rem;
16 margin: 0 auto;20 margin: 0 auto;
17 padding: 2rem 1rem 4rem;21 padding: 2rem 1rem 4rem;
18}22}
19h1, h2, a { color: light-dark(#1a46cd, #938cff); }23h1, a { color: light-dark(#1a46cd, #938cff); }
20h1 { text-align: center; font-weight: normal; }24h1 { text-align: center; font-weight: normal; }
21a { text-decoration: dotted underline; }25a { text-decoration: dotted underline; }
22a:hover { text-decoration: underline; }26a:hover { text-decoration: underline; }
23.who { opacity: 0.6; font-size: 0.9rem; margin-top: -0.5rem; }27header { text-align: center; }
28header svg { display: block; margin: 0 auto; }
29#logout {
30 position: absolute;
31 top: 1rem;
32 right: 1.25rem;
33 font-size: 0.9rem;
34 opacity: 0.75;
35}
36#logout:hover { opacity: 1; }
24.domain { opacity: 0.5; font-size: 0.85rem; }37.domain { opacity: 0.5; font-size: 0.85rem; }
25ul { list-style: "\2744\FE0E "; padding-left: 1.5rem; }38ul { list-style: none; padding-left: 0.25rem; }
26li { margin: 0.4rem 0; }39li { margin: 0.55rem 0; }
27footer { margin-top: 3rem; opacity: 0.7; font-size: 0.9rem; text-align: center; }40li::before {
41 content: "";
42 display: inline-block;
43 width: 1em;
44 height: 1em;
45 margin-right: 0.55em;
46 vertical-align: -0.12em;
47 background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%22-2%20-2%2028%2028%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Cpath%20d%3D%22M10%2020L8.75%2017.5L6%2018M10%204L8.75%206.5L6%206M14%2020L15.25%2017.5L18%2018M14%204L15.25%206.5L18%206M17%2021L14%2015M14%2015H10M14%2015L15.5%2012M10%2015L7%2021M10%2015L8.5%2012M17%203L14%209M14%209L15.5%2012M14%209H10M15.5%2012H22M2%2012H8.5M8.5%2012L10%209M10%209L7%203M20%2010L18.5%2012L20%2014M4%2010L5.5%2012L4%2014%22%20stroke%3D%22url%28%23sfo%29%22%20stroke-width%3D%223%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%0A%3Cpath%20d%3D%22M10%2020L8.75%2017.5L6%2018M10%204L8.75%206.5L6%206M14%2020L15.25%2017.5L18%2018M14%204L15.25%206.5L18%206M17%2021L14%2015M14%2015H10M14%2015L15.5%2012M10%2015L7%2021M10%2015L8.5%2012M17%203L14%209M14%209L15.5%2012M14%209H10M15.5%2012H22M2%2012H8.5M8.5%2012L10%209M10%209L7%203M20%2010L18.5%2012L20%2014M4%2010L5.5%2012L4%2014%22%20stroke%3D%22url%28%23sfi%29%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%0A%3Cdefs%3E%0A%3ClinearGradient%20id%3D%22sfo%22%20x1%3D%224%22%20y1%3D%22-2%22%20x2%3D%2218.5%22%20y2%3D%2225%22%20gradientUnits%3D%22userSpaceOnUse%22%3E%0A%3Cstop%20stop-color%3D%22%23223D99%22%2F%3E%3Cstop%20offset%3D%221%22%20stop-color%3D%22%23154392%22%2F%3E%0A%3C%2FlinearGradient%3E%0A%3ClinearGradient%20id%3D%22sfi%22%20x1%3D%2210%22%20y1%3D%22-3%22%20x2%3D%2218%22%20y2%3D%2227%22%20gradientUnits%3D%22userSpaceOnUse%22%3E%0A%3Cstop%20stop-color%3D%22%23F2E3FF%22%2F%3E%3Cstop%20offset%3D%221%22%20stop-color%3D%22%23F2F8FF%22%2F%3E%0A%3C%2FlinearGradient%3E%0A%3C%2Fdefs%3E%0A%3C%2Fsvg%3E");
48 background-size: contain;
49 background-repeat: no-repeat;
50}
28</style>51</style>
29</head>52</head>
30<body>53<body>
31 <h1>&#x2744;&#xFE0E; apps on paperclover.net</h1>54 <a id="logout" href="/snow.oauth2/sign_out?rd=https://auth.paperclover.net/apps">log out</a>
55 <header>
56<svg width="88" height="88" viewBox="-2 -2 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
57<path d="M10 20L8.75 17.5L6 18M10 4L8.75 6.5L6 6M14 20L15.25 17.5L18 18M14 4L15.25 6.5L18 6M17 21L14 15M14 15H10M14 15L15.5 12M10 15L7 21M10 15L8.5 12M17 3L14 9M14 9L15.5 12M14 9H10M15.5 12H22M2 12H8.5M8.5 12L10 9M10 9L7 3M20 10L18.5 12L20 14M4 10L5.5 12L4 14" stroke="url(#sfo)" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
58<path d="M10 20L8.75 17.5L6 18M10 4L8.75 6.5L6 6M14 20L15.25 17.5L18 18M14 4L15.25 6.5L18 6M17 21L14 15M14 15H10M14 15L15.5 12M10 15L7 21M10 15L8.5 12M17 3L14 9M14 9L15.5 12M14 9H10M15.5 12H22M2 12H8.5M8.5 12L10 9M10 9L7 3M20 10L18.5 12L20 14M4 10L5.5 12L4 14" stroke="url(#sfi)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
59<defs>
60<linearGradient id="sfo" x1="4" y1="-2" x2="18.5" y2="25" gradientUnits="userSpaceOnUse">
61<stop stop-color="#223D99"/><stop offset="1" stop-color="#154392"/>
62</linearGradient>
63<linearGradient id="sfi" x1="10" y1="-3" x2="18" y2="27" gradientUnits="userSpaceOnUse">
64<stop stop-color="#F2E3FF"/><stop offset="1" stop-color="#F2F8FF"/>
65</linearGradient>
66</defs>
67</svg>
68 <h1>hi {{ $name }}</h1>
69 </header>
32 <main>70 <main>
33 <section>71 <ul>
34 <h2>for everyone</h2>72 {{ if contains "role:media" $groups }}
35 <p class="who">any snow sign on account</p>73 <li><a href="https://jelly.paperclover.net">cloverfin</a> <span class="domain">jelly.paperclover.net</span></li>
36 <ul>74 {{ end }}
37 <li><a href="https://xmpp.paperclover.net">paper clover xmpp</a> <span class="domain">xmpp.paperclover.net</span></li>75 {{ if contains "role:media" $groups }}
38 <li><a href="https://spindle.paperclover.net">tangled spindle</a> <span class="domain">spindle.paperclover.net</span></li>76 <li><a href="https://music.paperclover.net">navidrome music</a> <span class="domain">music.paperclover.net</span></li>
39 <li><a href="https://knot.paperclover.net">tangled knot</a> <span class="domain">knot.paperclover.net</span></li>77 {{ end }}
40 <li><a href="https://git.paperclover.net">clover git forge</a> <span class="domain">git.paperclover.net</span></li>78 <li><a href="https://git.paperclover.net">clover git forge</a> <span class="domain">git.paperclover.net</span></li>
41 <li><a href="https://file.paperclover.net">copyparty file storage</a> <span class="domain">file.paperclover.net</span></li>79 <li><a href="https://file.paperclover.net">copyparty file storage</a> <span class="domain">file.paperclover.net</span></li>
42 <li><a href="https://speedtest.paperclover.net">open speed test</a> <span class="domain">speedtest.paperclover.net</span></li>80 {{ if contains "role:admin" $groups }}
43 </ul>81 <li><a href="https://opencode.paperclover.net">opencode</a> <span class="domain">opencode.paperclover.net</span></li>
44 </section>82 {{ end }}
45 <section>83 {{ if contains "role:media-manage" $groups }}
46 <h2>media</h2>84 <li><a href="https://qbt.paperclover.net">qbittorrent</a> <span class="domain">qbt.paperclover.net</span></li>
47 <p class="who">members of the media group</p>85 {{ end }}
48 <ul>86 {{ if contains "role:media-manage" $groups }}
49 <li><a href="https://jelly.paperclover.net">cloverfin</a> <span class="domain">jelly.paperclover.net</span></li>87 <li><a href="https://snr.paperclover.net">sonarr tv pvr</a> <span class="domain">snr.paperclover.net</span></li>
50 <li><a href="https://music.paperclover.net">navidrome music</a> <span class="domain">music.paperclover.net</span></li>88 {{ end }}
51 </ul>89 {{ if contains "role:media-manage" $groups }}
52 </section>90 <li><a href="https://rdr.paperclover.net">radarr movie organizer</a> <span class="domain">rdr.paperclover.net</span></li>
53 <section>91 {{ end }}
54 <h2>media management</h2>92 {{ if contains "role:media-manage" $groups }}
55 <p class="who">members of the media-manage group</p>93 <li><a href="https://jkt.paperclover.net">jackett api adapter</a> <span class="domain">jkt.paperclover.net</span></li>
56 <ul>94 {{ end }}
57 <li><a href="https://qbt.paperclover.net">qbittorrent</a> <span class="domain">qbt.paperclover.net</span></li>95 {{ if contains "role:admin" $groups }}
58 <li><a href="https://snr.paperclover.net">sonarr tv pvr</a> <span class="domain">snr.paperclover.net</span></li>96 <li><a href="https://sync.paperclover.net">syncthing</a> <span class="domain">sync.paperclover.net</span></li>
59 <li><a href="https://rdr.paperclover.net">radarr movie organizer</a> <span class="domain">rdr.paperclover.net</span></li>97 {{ end }}
60 <li><a href="https://jkt.paperclover.net">jackett api adapter</a> <span class="domain">jkt.paperclover.net</span></li>98 {{ if contains "role:admin" $groups }}
61 </ul>99 <li><a href="https://dawarich.paperclover.net">dawarich location history</a> <span class="domain">dawarich.paperclover.net</span></li>
62 </section>100 {{ end }}
63 <section>101 {{ if contains "role:admin" $groups }}
64 <h2>personal</h2>102 <li><a href="https://pg.paperclover.net">pgadmin</a> <span class="domain">pg.paperclover.net</span></li>
65 <p class="who">just clover</p>103 {{ end }}
66 <ul>104 {{ if contains "role:admin" $groups }}
67 <li><a href="https://opencode.paperclover.net">opencode</a> <span class="domain">opencode.paperclover.net</span></li>105 <li><a href="https://redis.paperclover.net">redis insight</a> <span class="domain">redis.paperclover.net</span></li>
68 <li><a href="https://sync.paperclover.net">syncthing</a> <span class="domain">sync.paperclover.net</span></li>106 {{ end }}
69 <li><a href="https://dawarich.paperclover.net">dawarich location history</a> <span class="domain">dawarich.paperclover.net</span></li>107 <li><a href="https://speedtest.paperclover.net">open speed test</a> <span class="domain">speedtest.paperclover.net</span></li>
70 <li><a href="https://pg.paperclover.net">pgadmin</a> <span class="domain">pg.paperclover.net</span></li>108 {{ if contains "role:admin" $groups }}
71 <li><a href="https://redis.paperclover.net">redis insight</a> <span class="domain">redis.paperclover.net</span></li>109 <li><a href="https://dns.paperclover.net">dns</a> <span class="domain">dns.paperclover.net</span></li>
72 <li><a href="https://dns.paperclover.net">dns</a> <span class="domain">dns.paperclover.net</span></li>110 {{ end }}
73 <li>ddns <span class="domain">(no web ui)</span></li>111 </ul>
74 </ul>
75 </section>
76 </main>112 </main>
77 <footer>
78 <p>signed in with <a href="https://auth.paperclover.net/realms/master/account">snow sign on</a> —
79 one account for everything here.</p>
80 <p>missing access? contact clover.</p>
81 </footer>
82</body>113</body>
83</html>114</html>
generate-app-list.py+85-47
...@@ -1,21 +1,48 @@...@@ -1,21 +1,48 @@
1#!/usr/bin/env python31#!/usr/bin/env python3
2# generates config/static/apps.html from the net.paperclover.list.* labels2# generates config/static/apps.html from the net.paperclover.list.* labels
3# in compose.yaml. usage:3# in compose.yaml. the output is a caddy template: caddy's `templates`
4# directive renders it per-request, filtering entries by the User-Groups
5# header and greeting the User-Name that forward-auth populates. usage:
4# sh docker.sh compose config --format json | python3 generate-app-list.py > config/static/apps.html6# sh docker.sh compose config --format json | python3 generate-app-list.py > config/static/apps.html
5# (or local: docker compose config --format json | ...)7# (or local: docker compose config --format json | ...)
6import json8import json
7import os9import os
8import sys10import sys
11import urllib.parse
912
10PFX = "net.paperclover.list."13PFX = "net.paperclover.list."
11DOMAIN = os.environ.get("HOME_DOMAIN", "paperclover.net")14DOMAIN = os.environ.get("HOME_DOMAIN", "paperclover.net")
1215
13ACCESS_GROUPS = [16# access label -> keycloak role required to see the entry (None = everyone)
14 (None, "for everyone", "any snow sign on account"),17ACCESS_ROLE = {
15 ("media", "media", "members of the media group"),18 None: None,
16 ("media-manage", "media management", "members of the media-manage group"),19 "media": "role:media",
17 ("personal", "personal", "just clover"),20 "media-manage": "role:media-manage",
18]21 "personal": "role:admin",
22}
23
24# the snow sign on snowflake (same artwork as the login theme, viewBox
25# pre-padded so the round caps don't clip)
26SNOWFLAKE_PATH = ("M10 20L8.75 17.5L6 18M10 4L8.75 6.5L6 6M14 20L15.25 17.5L18 18"
27 "M14 4L15.25 6.5L18 6M17 21L14 15M14 15H10M14 15L15.5 12M10 15L7 21M10 15L8.5 12"
28 "M17 3L14 9M14 9L15.5 12M14 9H10M15.5 12H22M2 12H8.5M8.5 12L10 9M10 9L7 3"
29 "M20 10L18.5 12L20 14M4 10L5.5 12L4 14")
30
31def snowflake_svg(size):
32 return f'''<svg width="{size}" height="{size}" viewBox="-2 -2 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
33<path d="{SNOWFLAKE_PATH}" stroke="url(#sfo)" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
34<path d="{SNOWFLAKE_PATH}" stroke="url(#sfi)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
35<defs>
36<linearGradient id="sfo" x1="4" y1="-2" x2="18.5" y2="25" gradientUnits="userSpaceOnUse">
37<stop stop-color="#223D99"/><stop offset="1" stop-color="#154392"/>
38</linearGradient>
39<linearGradient id="sfi" x1="10" y1="-3" x2="18" y2="27" gradientUnits="userSpaceOnUse">
40<stop stop-color="#F2E3FF"/><stop offset="1" stop-color="#F2F8FF"/>
41</linearGradient>
42</defs>
43</svg>'''
44
45BULLET_URI = "data:image/svg+xml," + urllib.parse.quote(snowflake_svg(16), safe="")
1946
20cfg = json.load(sys.stdin)47cfg = json.load(sys.stdin)
21apps = []48apps = []
...@@ -25,46 +52,37 @@ for name, svc in cfg["services"].items():...@@ -25,46 +52,37 @@ for name, svc in cfg["services"].items():
25 labels = dict(l.split("=", 1) for l in labels)52 labels = dict(l.split("=", 1) for l in labels)
26 if PFX + "name" not in labels:53 if PFX + "name" not in labels:
27 continue54 continue
55 if labels.get(PFX + "web") == "false" or not labels.get(PFX + "domain"):
56 continue
28 apps.append({57 apps.append({
29 "name": labels[PFX + "name"],58 "name": labels[PFX + "name"],
30 "domain": labels.get(PFX + "domain"),59 "domain": labels[PFX + "domain"],
31 "priority": int(labels.get(PFX + "priority", 0)),60 "priority": int(labels.get(PFX + "priority", 0)),
32 "access": labels.get(PFX + "access"),61 "access": labels.get(PFX + "access"),
33 })62 })
3463
35apps.sort(key=lambda a: -a["priority"])64apps.sort(key=lambda a: -a["priority"])
3665
37def entries(access):66items = []
38 out = []67for a in apps:
39 for a in apps:68 role = ACCESS_ROLE.get(a["access"], "role:admin")
40 if a["access"] != access:69 li = (
41 continue70 f' <li><a href="https://{a["domain"]}.{DOMAIN}">'
42 if a["domain"]:71 f'{a["name"].lower()}</a>'
43 out.append(72 f' <span class="domain">{a["domain"]}.{DOMAIN}</span></li>'
44 f' <li><a href="https://{a["domain"]}.{DOMAIN}">'
45 f'{a["name"].lower()}</a>'
46 f' <span class="domain">{a["domain"]}.{DOMAIN}</span></li>'
47 )
48 else:
49 out.append(f' <li>{a["name"].lower()} <span class="domain">(no web ui)</span></li>')
50 return out
51
52sections = []
53for access, title, who in ACCESS_GROUPS:
54 items = entries(access)
55 if not items:
56 continue
57 sections.append(
58 f' <section>\n <h2>{title}</h2>\n'
59 f' <p class="who">{who}</p>\n <ul>\n'
60 + "\n".join(" " + i for i in items)
61 + "\n </ul>\n </section>"
62 )73 )
74 if role:
75 li = f' {{{{ if contains "{role}" $groups }}}}\n{li}\n {{{{ end }}}}'
76 items.append(li)
6377
64body = "\n".join(sections)78body = "\n".join(items)
6579
66print(f"""<!doctype html>80print(f"""<!doctype html>
67<!-- generated by generate-app-list.py — do not edit by hand -->81<!-- generated by generate-app-list.py — do not edit by hand.
82 rendered by caddy's templates directive; entries are filtered by the
83 User-Groups header that forward-auth sets for the signed-in user. -->
84{{{{ $groups := .Req.Header.Get "User-Groups" }}}}
85{{{{ $name := .Req.Header.Get "User-Name" }}}}
68<html lang="en">86<html lang="en">
69<head>87<head>
70<meta charset="utf-8">88<meta charset="utf-8">
...@@ -75,32 +93,52 @@ print(f"""<!doctype html>...@@ -75,32 +93,52 @@ print(f"""<!doctype html>
75:root {{ color-scheme: light dark; }}93:root {{ color-scheme: light dark; }}
76body {{94body {{
77 font-family: "national-medium", system-ui, sans-serif;95 font-family: "national-medium", system-ui, sans-serif;
78 background-color: light-dark(#e8eefa, #2f4b67);96 background-color: light-dark(#e8eefa, #152c42);
79 color: light-dark(black, white);97 color: light-dark(black, white);
80 max-width: 34rem;98 max-width: 34rem;
81 margin: 0 auto;99 margin: 0 auto;
82 padding: 2rem 1rem 4rem;100 padding: 2rem 1rem 4rem;
83}}101}}
84h1, h2, a {{ color: light-dark(#1a46cd, #938cff); }}102h1, a {{ color: light-dark(#1a46cd, #938cff); }}
85h1 {{ text-align: center; font-weight: normal; }}103h1 {{ text-align: center; font-weight: normal; }}
86a {{ text-decoration: dotted underline; }}104a {{ text-decoration: dotted underline; }}
87a:hover {{ text-decoration: underline; }}105a:hover {{ text-decoration: underline; }}
88.who {{ opacity: 0.6; font-size: 0.9rem; margin-top: -0.5rem; }}106header {{ text-align: center; }}
107header svg {{ display: block; margin: 0 auto; }}
108#logout {{
109 position: absolute;
110 top: 1rem;
111 right: 1.25rem;
112 font-size: 0.9rem;
113 opacity: 0.75;
114}}
115#logout:hover {{ opacity: 1; }}
89.domain {{ opacity: 0.5; font-size: 0.85rem; }}116.domain {{ opacity: 0.5; font-size: 0.85rem; }}
90ul {{ list-style: "\\2744\\FE0E "; padding-left: 1.5rem; }}117ul {{ list-style: none; padding-left: 0.25rem; }}
91li {{ margin: 0.4rem 0; }}118li {{ margin: 0.55rem 0; }}
92footer {{ margin-top: 3rem; opacity: 0.7; font-size: 0.9rem; text-align: center; }}119li::before {{
120 content: "";
121 display: inline-block;
122 width: 1em;
123 height: 1em;
124 margin-right: 0.55em;
125 vertical-align: -0.12em;
126 background-image: url("{BULLET_URI}");
127 background-size: contain;
128 background-repeat: no-repeat;
129}}
93</style>130</style>
94</head>131</head>
95<body>132<body>
96 <h1>&#x2744;&#xFE0E; apps on {DOMAIN}</h1>133 <a id="logout" href="/snow.oauth2/sign_out?rd=https://auth.{DOMAIN}/apps">log out</a>
134 <header>
135{snowflake_svg(88)}
136 <h1>hi {{{{ $name }}}}</h1>
137 </header>
97 <main>138 <main>
139 <ul>
98{body}140{body}
141 </ul>
99 </main>142 </main>
100 <footer>
101 <p>signed in with <a href="https://auth.{DOMAIN}/realms/master/account">snow sign on</a> —
102 one account for everything here.</p>
103 <p>missing access? contact clover.</p>
104 </footer>
105</body>143</body>
106</html>""")144</html>""")