feat(web): role-gate the nav and show the post-setup passkey banner
The shell now renders the CORE nav entries — Home · Account for everyone, Users · Modules only for an admin — alongside the registry entries, with the discovered `home` dashboard collapsed onto the core Home entry (SPEC-010 §3). A signed-in user with no passkey, who has not opted out and is not on a loopback origin, now sees a prominent "Add a passkey" banner linking to the Account page (SPEC-010 §6). This closes the D-041 gap where the wizard's direct session mint bypasses the TOTP-login nudge, so the admin was never prompted. The door computes (admin, banner) in serveGUID — it already holds the session, identity and user — and attaches it to the request context; the new web.PageFor copies it onto every authenticated page (RequestContext is untouched, so identification stays separate from authorisation, D-003).
This commit is contained in:
parent
34f8ddbcfe
commit
b8ead2bf2d
9 changed files with 282 additions and 28 deletions
|
|
@ -141,7 +141,7 @@ func (a *Actions) EnrollScreen(w http.ResponseWriter, r *http.Request, guid stri
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return renderHTML(w, http.StatusOK, enrollPage(guid, token))
|
||||
return renderHTML(w, r, http.StatusOK, enrollPage(guid, token))
|
||||
}
|
||||
|
||||
// LoginScreen renders the login screen for an active GUID with no session
|
||||
|
|
@ -165,7 +165,7 @@ func (a *Actions) LoginScreen(w http.ResponseWriter, r *http.Request, guid strin
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return renderHTML(w, http.StatusOK, loginPage(guid, token))
|
||||
return renderHTML(w, r, http.StatusOK, loginPage(guid, token))
|
||||
}
|
||||
|
||||
// ── enrollment actions ──────────────────────────────────────────────────────
|
||||
|
|
@ -606,7 +606,7 @@ func (a *Actions) PasskeyPrompt(w http.ResponseWriter, r *http.Request, guid str
|
|||
if err := a.putCeremony(w, r, begin); err != nil {
|
||||
return err
|
||||
}
|
||||
return renderHTML(w, http.StatusOK, passkeyPage(guid, httpx.TokenFromRequest(r), string(begin.Options)))
|
||||
return renderHTML(w, r, http.StatusOK, passkeyPage(guid, httpx.TokenFromRequest(r), string(begin.Options)))
|
||||
}
|
||||
|
||||
// PasskeyAdd completes the WebAuthn registration begun by PasskeyPrompt and
|
||||
|
|
|
|||
|
|
@ -13,9 +13,13 @@ import (
|
|||
//
|
||||
// F-2: the enrollment screen carries the TOTP ceremony and the login screen a
|
||||
// session — neither may be cached, so every flow response is `no-store`.
|
||||
func renderHTML(w http.ResponseWriter, status int, page web.Page) error {
|
||||
//
|
||||
// web.PageFor applies the request's shell state (the role-gated nav and the
|
||||
// post-setup passkey banner, SPEC-010) — a guest screen (enroll/login) carries
|
||||
// none, and returns unchanged.
|
||||
func renderHTML(w http.ResponseWriter, r *http.Request, status int, page web.Page) error {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
return web.Render(w, status, page)
|
||||
return web.Render(w, status, web.PageFor(r, page))
|
||||
}
|
||||
|
||||
// enrollPage is the enrollment screen (SPEC-006 §4). The passkey ceremony is
|
||||
|
|
|
|||
|
|
@ -193,13 +193,13 @@ func (p *Page) render(w http.ResponseWriter, r *http.Request, rc *server.Request
|
|||
}
|
||||
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
return web.Render(w, http.StatusOK, web.Page{
|
||||
return web.Render(w, http.StatusOK, web.PageFor(r, web.Page{
|
||||
Title: "Modules",
|
||||
GUID: rc.Workflow,
|
||||
Active: ConsoleModule,
|
||||
CSRF: data.Token,
|
||||
Content: web.Markup(body.String()),
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
// rowsLocked snapshots what the page lists. The caller holds p.mu.
|
||||
|
|
|
|||
|
|
@ -21,9 +21,11 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
|
@ -35,6 +37,7 @@ import (
|
|||
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/middleware"
|
||||
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/routing"
|
||||
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users"
|
||||
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/web"
|
||||
)
|
||||
|
||||
// Deps assembles the door's dependencies.
|
||||
|
|
@ -50,6 +53,10 @@ type Deps struct {
|
|||
// Identities resolves a GUID to a user (the door's own lookup, distinct
|
||||
// from the pure Resolver surface).
|
||||
Identities auth.Identities
|
||||
// Credentials answers "does this identity have a passkey?" — the door's
|
||||
// post-setup passkey banner (SPEC-010 §6). When nil the banner never
|
||||
// shows (it is only meaningful once a passkey store is wired).
|
||||
Credentials auth.Credentials
|
||||
// Users loads the authenticated user's role for the role gate.
|
||||
Users users.Users
|
||||
// Audit receives the door's and the services' audit rows.
|
||||
|
|
@ -94,6 +101,7 @@ type Server struct {
|
|||
reg Registry
|
||||
sessions auth.Sessions
|
||||
ids auth.Identities
|
||||
creds auth.Credentials
|
||||
users users.Users
|
||||
audit audit.Audit
|
||||
csrf *httpx.CSRF
|
||||
|
|
@ -190,6 +198,7 @@ func New(d Deps) (*Server, error) {
|
|||
reg: d.Registry,
|
||||
sessions: d.Sessions,
|
||||
ids: d.Identities,
|
||||
creds: d.Credentials,
|
||||
users: d.Users,
|
||||
audit: d.Audit,
|
||||
csrf: d.CSRF,
|
||||
|
|
@ -646,11 +655,53 @@ func (s *Server) serveGUID(w http.ResponseWriter, r *http.Request, res routing.R
|
|||
return
|
||||
}
|
||||
ctx = httpx.WithToken(ctx, s.csrf.Token(sess.ID))
|
||||
// SPEC-010 §3/§6: attach the shell decoration — the role-gated nav and the
|
||||
// post-setup passkey banner — so every authenticated page renders it
|
||||
// consistently (web.PageFor reads it). The door already loaded the user for
|
||||
// the role gate, so this adds no extra user lookup.
|
||||
ctx = web.WithShell(ctx, u.Role == users.RoleAdmin, s.passkeyBanner(ctx, r, u, idn))
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
s.invoke(w, r, h, newRequestContext(res, sess.UserID, cid, ip))
|
||||
}
|
||||
|
||||
// passkeyBanner reports whether the shell should show the prominent post-setup
|
||||
// "add a passkey" prompt for this request (SPEC-010 §6): the signed-in user has
|
||||
// no passkey, has NOT opted out, and the origin is not loopback. Any lookup
|
||||
// error fails safe — no banner.
|
||||
//
|
||||
// It is deliberately the door's call, not a handler's: the door is the one
|
||||
// place that already holds the session, the identity and the user, so the
|
||||
// banner is consistent on the dashboard, the Modules page, the account page and
|
||||
// the passkey screen alike. It mirrors the SPEC-006 §8 nudge predicate
|
||||
// (opt-out · loopback · no passkey) but is a separate surface — the nudge
|
||||
// redirects a TOTP login; this decorates an already-signed-in page.
|
||||
func (s *Server) passkeyBanner(ctx context.Context, r *http.Request, u users.User, idn auth.Identity) bool {
|
||||
if s.creds == nil || u.PasskeyOptOut {
|
||||
return false
|
||||
}
|
||||
if httpx.IsLoopbackHost(s.originHost(r)) {
|
||||
return false
|
||||
}
|
||||
has, err := s.creds.HasWebAuthnCredential(ctx, idn.ID)
|
||||
if err != nil {
|
||||
s.log.Warn("door: passkey banner lookup failed", "error", err)
|
||||
return false
|
||||
}
|
||||
return !has
|
||||
}
|
||||
|
||||
// originHost is the origin the banner is judged against: the CONFIGURED
|
||||
// base_url's host when set — so a reverse proxy that rewrites `Host` cannot
|
||||
// wrongly suppress (or enable) it — falling back to the request `Host` (L7c,
|
||||
// the same signal as the SPEC-006 nudge).
|
||||
func (s *Server) originHost(r *http.Request) string {
|
||||
if u, err := url.Parse(strings.TrimSpace(s.cfg.Server.BaseURL)); err == nil && u.Host != "" {
|
||||
return u.Host
|
||||
}
|
||||
return r.Host
|
||||
}
|
||||
|
||||
// loginRequired hands off to the login screen without revealing why the session
|
||||
// was rejected (missing, empty, invalid, or belonging to another user). The
|
||||
// screen is a RENDERED PAGE and answers 200; only a failed credential attempt is
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
{{if .Nav}}<nav aria-label="Main"><ul>{{range .Nav}}<li><a href="{{.Href}}"{{if .Active}} aria-current="page"{{end}}>{{.Label}}</a></li>{{end}}</ul></nav>{{end}}
|
||||
</header>
|
||||
{{if .Flash}}<p class="flash {{.FlashKind}}" role="status">{{.Flash}}</p>{{end}}
|
||||
{{if .PasskeyBanner}}<p class="banner" role="status"><strong>You have no passkey.</strong> <a href="/{{.GUID}}/account">Add one</a> to sign in without the one-time code.</p>{{end}}
|
||||
<main id="main"{{.MainAttrs}}>
|
||||
{{.Content}}
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package web
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
_ "embed"
|
||||
"html/template"
|
||||
"net/http"
|
||||
|
|
@ -78,6 +79,16 @@ type Page struct {
|
|||
Guest bool
|
||||
// Active is the PARAM2 module to mark as the current nav entry ("" none).
|
||||
Active string
|
||||
// Admin marks a signed-in administrator, so the shell shows the
|
||||
// role-gated nav entries (Users · Modules — SPEC-010 §3). A non-admin sees
|
||||
// Home and Account only. It is normally filled by PageFor from the shell
|
||||
// state the door attached; set it directly only in a unit test.
|
||||
Admin bool
|
||||
// PasskeyBanner shows the prominent post-setup "add a passkey" prompt
|
||||
// (SPEC-010 §6) while the signed-in user has no passkey and has not opted
|
||||
// out (and the origin is not loopback). The door decides; the shell draws.
|
||||
// It is normally filled by PageFor.
|
||||
PasskeyBanner bool
|
||||
// Flash is a notice or error banner ("" for none).
|
||||
Flash string
|
||||
// FlashKind is "error" for an error banner; anything else is a notice.
|
||||
|
|
@ -102,6 +113,8 @@ type layoutData struct {
|
|||
Nav []NavItem
|
||||
HomeHref string
|
||||
StaticPath string
|
||||
GUID string
|
||||
PasskeyBanner bool
|
||||
Content template.HTML
|
||||
MainAttrs template.HTMLAttr
|
||||
}
|
||||
|
|
@ -130,7 +143,9 @@ func Render(w http.ResponseWriter, status int, p Page) error {
|
|||
data.HomeHref = "/"
|
||||
} else {
|
||||
data.HomeHref = "/" + p.GUID + "/home"
|
||||
data.Nav = navItems(p.GUID, p.Active)
|
||||
data.GUID = p.GUID
|
||||
data.PasskeyBanner = p.PasskeyBanner
|
||||
data.Nav = navItems(p.GUID, p.Active, p.Admin)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
|
@ -151,29 +166,105 @@ func flashKind(kind string) string {
|
|||
return "notice"
|
||||
}
|
||||
|
||||
// navItems builds the nav from the installed NavLister (SPEC-008 §7: the nav is
|
||||
// driven by the registry, so a disabled module's entry is absent — D-038).
|
||||
func navItems(guid, active string) []NavItem {
|
||||
// navItems builds the nav from the CORE entries plus the installed NavLister
|
||||
// (SPEC-009 §4: a disabled module's entry is absent — D-038; SPEC-010 §3: the
|
||||
// core entries Home · Account · Users · Modules, role-gated). A duplicate
|
||||
// target — the registry's `home`, say — is collapsed onto the core entry, so
|
||||
// the nav never lists the same page twice.
|
||||
func navItems(guid, active string, admin bool) []NavItem {
|
||||
l := NavSource()
|
||||
if l == nil || guid == "" {
|
||||
return nil
|
||||
}
|
||||
names := l.Nav()
|
||||
items := make([]NavItem, 0, len(names))
|
||||
for _, name := range names {
|
||||
items := coreNav(guid, active, admin)
|
||||
seen := make(map[string]bool, len(items))
|
||||
for _, it := range items {
|
||||
seen[it.Href] = true
|
||||
}
|
||||
for _, name := range l.Nav() {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
href := "/" + guid + "/" + name
|
||||
if seen[href] {
|
||||
continue // a core entry already covers this module (the dashboard)
|
||||
}
|
||||
seen[href] = true
|
||||
items = append(items, NavItem{
|
||||
Label: ModuleLabel(name),
|
||||
Href: "/" + guid + "/" + name,
|
||||
Href: href,
|
||||
Active: name == active,
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// coreNav is the built-in navigation every app page carries (SPEC-010 §3): Home
|
||||
// and Account for everyone; Users and Modules for an admin. The admin entries
|
||||
// mirror the role gate (`server.Tiers`) — the door is the authority; this only
|
||||
// decides what the shell advertises.
|
||||
func coreNav(guid, active string, admin bool) []NavItem {
|
||||
items := []NavItem{
|
||||
{Label: "Home", Href: "/" + guid + "/home", Active: active == "home"},
|
||||
{Label: "Account", Href: "/" + guid + "/account", Active: active == "account"},
|
||||
}
|
||||
if admin {
|
||||
items = append(items,
|
||||
NavItem{Label: "Users", Href: "/" + guid + "/users", Active: active == "users"},
|
||||
NavItem{Label: "Modules", Href: "/" + guid + "/core/modules", Active: active == "core"},
|
||||
)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// ── the per-request shell state (SPEC-010 §3/§6) ─────────────────────────────
|
||||
//
|
||||
// The door is the ONE place that knows the signed-in user's role and whether
|
||||
// the passkey banner applies (it already loaded the user for the role gate). It
|
||||
// attaches that decoration to the request context; a handler renders an
|
||||
// authenticated page with PageFor, which copies it onto the Page — so the nav
|
||||
// and the banner are consistent on every screen without each handler
|
||||
// re-deriving them.
|
||||
|
||||
// shellState is the shell decoration for one authenticated request.
|
||||
type shellState struct {
|
||||
admin bool
|
||||
banner bool
|
||||
}
|
||||
|
||||
// shellCtxKey is the unexported context key for the request's shell state.
|
||||
type shellCtxKey struct{}
|
||||
|
||||
// WithShell attaches the signed-in user's shell state to the request context.
|
||||
// The door calls it once per authenticated request.
|
||||
func WithShell(ctx context.Context, admin, banner bool) context.Context {
|
||||
return context.WithValue(ctx, shellCtxKey{}, shellState{admin: admin, banner: banner})
|
||||
}
|
||||
|
||||
// ShellFromContext returns the shell state the door attached; the zero value
|
||||
// (no nav decoration, no banner) for an anonymous screen or an untouched test.
|
||||
func ShellFromContext(ctx context.Context) (admin, banner bool) {
|
||||
s, _ := ctx.Value(shellCtxKey{}).(shellState)
|
||||
return s.admin, s.banner
|
||||
}
|
||||
|
||||
// PageFor fills the shell decoration — the role-gated nav and the passkey
|
||||
// banner — from the request context the door attached, and returns the Page. A
|
||||
// handler rendering an authenticated page writes:
|
||||
//
|
||||
// web.Render(w, http.StatusOK, web.PageFor(r, web.Page{...}))
|
||||
//
|
||||
// A guest page (Guest, or no GUID) is returned unchanged: there is no shell
|
||||
// state to apply.
|
||||
func PageFor(r *http.Request, p Page) Page {
|
||||
if r == nil || p.Guest || strings.TrimSpace(p.GUID) == "" {
|
||||
return p
|
||||
}
|
||||
p.Admin, p.PasskeyBanner = ShellFromContext(r.Context())
|
||||
return p
|
||||
}
|
||||
|
||||
// ModuleLabel renders a module name as a nav label: the first letter upcased
|
||||
// and separators read as spaces ("home" -> "Home", "time_off" -> "Time off").
|
||||
func ModuleLabel(name string) string {
|
||||
|
|
|
|||
|
|
@ -145,6 +145,45 @@ button.secondary {
|
|||
.flash.notice { background: var(--notice-bg); color: var(--notice-ink); }
|
||||
.flash.error { background: var(--error-bg); color: var(--danger); }
|
||||
|
||||
/* The post-setup passkey prompt (SPEC-010 §6): prominent, above the content. */
|
||||
.banner {
|
||||
max-width: 42rem;
|
||||
margin: 1rem auto 0;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid var(--brand);
|
||||
border-left-width: 4px;
|
||||
border-radius: 8px;
|
||||
background: #eef2ff;
|
||||
color: var(--ink);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.banner a { color: var(--brand); font-weight: 600; }
|
||||
|
||||
/* A link styled as a button (Add a passkey — SPEC-010 §4). */
|
||||
a.button {
|
||||
display: inline-block;
|
||||
font-weight: 600;
|
||||
padding: 0.55rem 0.9rem;
|
||||
border: 1px solid var(--brand);
|
||||
border-radius: 8px;
|
||||
background: var(--brand);
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
}
|
||||
a.button:hover { background: #1a3fb8; }
|
||||
|
||||
code, .invite {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
padding: 0.2rem 0.4rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
#passkey-status { font-size: 0.95rem; color: var(--ink-soft); }
|
||||
#passkey-status.error { color: var(--danger); }
|
||||
|
||||
|
|
|
|||
|
|
@ -132,8 +132,76 @@ func TestShellEscapesAndTrustsOnlyMarkup(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// ── AC-3: no inline scripts ──────────────────────────────────────────────────
|
||||
// ── SPEC-010: the role-gated core nav and the passkey banner ─────────────────
|
||||
|
||||
// TestShellCoreNavIsRoleGated: Home · Account appear for everyone; Users ·
|
||||
// Modules appear only when the Page marks an admin (SPEC-010 §3).
|
||||
func TestShellCoreNavIsRoleGated(t *testing.T) {
|
||||
web.SetNav(fakeNav{"home"})
|
||||
t.Cleanup(func() { web.SetNav(nil) })
|
||||
|
||||
admin := renderShell(t, web.Page{GUID: "g", Active: "home", Admin: true, Content: web.Markup("x")})
|
||||
for _, want := range []string{
|
||||
`href="/g/home"`, `href="/g/account"`, `href="/g/users"`, `href="/g/core/modules"`,
|
||||
} {
|
||||
if !strings.Contains(admin, want) {
|
||||
t.Errorf("an admin's nav is missing %q:\n%s", want, admin)
|
||||
}
|
||||
}
|
||||
// The discovered `home` module is collapsed onto the core Home entry — the
|
||||
// nav never lists the same target twice (the brand link also points home,
|
||||
// so we count the labelled nav entry).
|
||||
if got := strings.Count(admin, ">Home</a>"); got != 1 {
|
||||
t.Errorf("the nav lists Home %d times, want 1", got)
|
||||
}
|
||||
|
||||
member := renderShell(t, web.Page{GUID: "g", Active: "home", Content: web.Markup("x")})
|
||||
if !strings.Contains(member, `href="/g/home"`) || !strings.Contains(member, `href="/g/account"`) {
|
||||
t.Errorf("a non-admin is missing Home/Account:\n%s", member)
|
||||
}
|
||||
if strings.Contains(member, `href="/g/users"`) || strings.Contains(member, `href="/g/core/modules"`) {
|
||||
t.Errorf("a non-admin sees the admin nav entries (SPEC-010 §3):\n%s", member)
|
||||
}
|
||||
}
|
||||
|
||||
// TestShellPasskeyBanner: the prominent prompt renders with its account link
|
||||
// only when the Page asks for it (SPEC-010 §6).
|
||||
func TestShellPasskeyBanner(t *testing.T) {
|
||||
web.SetNav(fakeNav{"home"})
|
||||
t.Cleanup(func() { web.SetNav(nil) })
|
||||
|
||||
on := renderShell(t, web.Page{GUID: "g", PasskeyBanner: true, Content: web.Markup("x")})
|
||||
if !strings.Contains(on, "You have no passkey") {
|
||||
t.Errorf("the banner is missing:\n%s", on)
|
||||
}
|
||||
if !strings.Contains(on, `href="/g/account"`) {
|
||||
t.Errorf("the banner does not link to the account page:\n%s", on)
|
||||
}
|
||||
|
||||
off := renderShell(t, web.Page{GUID: "g", Content: web.Markup("x")})
|
||||
if strings.Contains(off, "You have no passkey") {
|
||||
t.Errorf("the banner rendered without being asked:\n%s", off)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPageForReadsTheShellState: PageFor copies the door-attached role + banner
|
||||
// onto the Page, and leaves a guest page untouched (SPEC-010 §3/§6).
|
||||
func TestPageForReadsTheShellState(t *testing.T) {
|
||||
r := httptest.NewRequest("GET", "/g/home", nil)
|
||||
r = r.WithContext(web.WithShell(r.Context(), true, true))
|
||||
|
||||
got := web.PageFor(r, web.Page{GUID: "g"})
|
||||
if !got.Admin || !got.PasskeyBanner {
|
||||
t.Errorf("PageFor did not apply the shell state: %+v", got)
|
||||
}
|
||||
|
||||
guest := web.PageFor(r, web.Page{Guest: true})
|
||||
if guest.Admin || guest.PasskeyBanner {
|
||||
t.Errorf("PageFor decorated a guest page: %+v", guest)
|
||||
}
|
||||
}
|
||||
|
||||
// ── AC-3: no inline scripts ──────────────────────────────────────────────────
|
||||
// TestShellHasNoInlineScripts: the layout references same-origin files only.
|
||||
// The CSP is `script-src 'self'` (SPEC-003 §4) — one inline script would force
|
||||
// weakening it, so there must never be one (AC-3).
|
||||
|
|
|
|||
|
|
@ -17,11 +17,11 @@ import (
|
|||
// the flash area.
|
||||
func dashboard(w http.ResponseWriter, r *http.Request, rc *server.RequestContext) error {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
return web.Render(w, http.StatusOK, web.Page{
|
||||
return web.Render(w, http.StatusOK, web.PageFor(r, web.Page{
|
||||
Title: "MyDay",
|
||||
GUID: rc.Workflow,
|
||||
Active: "home",
|
||||
Content: web.Markup(`<h1>MyDay</h1>
|
||||
<p>Your day, at a glance.</p>`),
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue