carlos / platform Public

merge: the account switcher becomes a dropdown; New account gets its own screen

Replaces the CARLOS console wordmark with a <details> disclosure naming
the current account — no JS framework, no build step, keyboard-operable,
and it degrades to a plain open list without JavaScript at all. New
account moves out of the header into its own gated screen.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 67cccf2ace28acca8ab607f8d0236a2d9523514f parent a30a45dparent 293983b
5 files changed, +390 −31
  • README.md +5 −1
  • internal/console/server.go +75 −14
  • internal/console/server_account_test.go +208 −0
  • internal/console/templates/layout.html +84 −16
  • internal/console/templates/newaccount.html +18 −0
diff --git a/README.md b/README.md
index c73305cec..72dd3b053 100644
--- a/README.md
+++ b/README.md
@@ -57,7 +57,11 @@ console-less self-hoster is unaffected by any of this.
Every account has at least one owner; the last one can be neither removed nor
demoted (transfer first).
-**In the console.** The account switcher sits top left on every account page.
+**In the console.** The account switcher sits top left on every account page,
+where the wordmark used to be: a dropdown whose trigger names the account you
+are in, listing the accounts you are a member of and linking to the
+**New account** screen at `/accounts/new`. It is a `<details>` disclosure, so
+it is keyboard-operable and works with scripting off.
URLs are explicit and shareable — `/a/<sqid>/apps/<app>` — so a link a
teammate opens lands where you meant; `/` redirects to the account you were
last in (or your first membership, or one minted on the spot). A signed-in
diff --git a/internal/console/server.go b/internal/console/server.go
index cd252ba3f..712ef50d3 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -150,6 +150,7 @@ func NewServer(cfg Config, st *Store) (*Server, error) {
s.mux.HandleFunc("POST /a/{sqid}/invite", s.handleInvite)
s.mux.HandleFunc("POST /a/{sqid}/invite/revoke", s.handleRevokeInvite)
s.mux.HandleFunc("GET /invite/{token}", s.handleAcceptInvite)
+ s.mux.HandleFunc("GET /accounts/new", s.handleNewAccountForm)
s.mux.HandleFunc("POST /accounts/new", s.handleNewAccount)
// The pre-accounts URLs, kept as redirects so old links and bookmarks
// still land somewhere sensible rather than 404ing.
@@ -341,31 +342,50 @@ func (s *Server) redirectToCurrentAccount(w http.ResponseWriter, r *http.Request
// if still a member — an account you were removed from must not strand you),
// first membership, or mint.
func (s *Server) currentAccount(ctx context.Context, addr string) (string, error) {
- if id, err := s.store.LastAccount(addr); err != nil {
+ a, err := s.memberAccount(addr)
+ if err != nil {
+ return "", err
+ }
+ if a != nil {
+ return a.Sqid, nil
+ }
+ m, err := s.mintAccount(ctx, addr, addr)
+ if err != nil {
return "", err
+ }
+ return m.Sqid, nil
+}
+
+// memberAccount is currentAccount's first two cases WITHOUT the mint: the MRU
+// account if the viewer is still a member of it, else their first membership
+// by creation order, else nil. Split out because pages that merely want the
+// header's account context — /accounts/new — must not create an account as a
+// side effect of being looked at. Only landing mints.
+func (s *Server) memberAccount(addr string) (*Account, error) {
+ if id, err := s.store.LastAccount(addr); err != nil {
+ return nil, err
} else if id != 0 {
if role, err := s.store.Role(id, addr); err != nil {
- return "", err
+ return nil, err
} else if role != "" {
- if a, err := s.store.AccountByID(id); err != nil {
- return "", err
- } else if a != nil {
- return a.Sqid, nil
+ a, err := s.store.AccountByID(id)
+ if err != nil {
+ return nil, err
+ }
+ if a != nil {
+ a.Role = role
+ return a, nil
}
}
}
accts, err := s.store.AccountsFor(addr)
if err != nil {
- return "", err
+ return nil, err
}
if len(accts) > 0 {
- return accts[0].Sqid, nil
- }
- a, err := s.mintAccount(ctx, addr, addr)
- if err != nil {
- return "", err
+ return &accts[0], nil
}
- return a.Sqid, nil
+ return nil, nil
}
// mintAccount creates an account owned by addr: the next free id allocated in
@@ -476,6 +496,38 @@ func (s *Server) handleLegacyApp(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/a/"+url.PathEscape(sqid)+"/apps/"+url.PathEscape(r.PathValue("app")), http.StatusFound)
}
+// handleNewAccountForm is the "New account" screen: its own gated page at its
+// own URL, linked from the switcher, rather than a text box wedged into the
+// header. It takes no account parameter of any kind — there is nothing here
+// to point at another account, so it cannot become a way to probe one — and
+// it deliberately does NOT record an MRU: you are not "in" an account here,
+// and moving the pointer would send the next sign-in somewhere never chosen.
+func (s *Server) handleNewAccountForm(w http.ResponseWriter, r *http.Request) {
+ addr, raw, ok := s.session(r)
+ if !ok {
+ http.Redirect(w, r, "/", http.StatusFound)
+ return
+ }
+ p := page{Title: "New account", Address: addr, CSRF: raw}
+ p.Flash, p.FlashErr = s.takeFlash(w, r)
+ if accts, err := s.store.AccountsFor(addr); err != nil {
+ log.Printf("console: switcher: %v", err)
+ } else {
+ p.Accounts = accts
+ }
+ // The header chrome wants an account to name in the switcher's trigger.
+ // memberAccount, not currentAccount: looking at this page must never mint.
+ // A user with no memberships at all simply gets the wordmark header.
+ if a, err := s.memberAccount(addr); err != nil {
+ log.Printf("console: new-account chrome: %v", err)
+ } else if a != nil {
+ p.Account = a
+ p.Role = a.Role
+ p.Base = "/a/" + url.PathEscape(a.Sqid)
+ }
+ s.render(w, http.StatusOK, "newaccount", p)
+}
+
// handleNewAccount creates an account for any signed-in user.
func (s *Server) handleNewAccount(w http.ResponseWriter, r *http.Request) {
addr, raw, ok := s.session(r)
@@ -489,8 +541,10 @@ func (s *Server) handleNewAccount(w http.ResponseWriter, r *http.Request) {
}
name := strings.TrimSpace(r.FormValue("name"))
if name == "" || len(name) > 64 {
+ // Back to the screen the mistake was made on, not off to some other
+ // account: the form is a page now, and a page can show its own error.
s.setFlash(w, "err", "An account needs a name (up to 64 characters).")
- s.redirectToCurrentAccount(w, r, addr, http.StatusSeeOther)
+ http.Redirect(w, r, "/accounts/new", http.StatusSeeOther)
return
}
a, err := s.mintAccount(r.Context(), name, addr)
@@ -895,6 +949,13 @@ func (s *Server) accountPage(w http.ResponseWriter, r *http.Request, addr, raw s
} else {
p.Accounts = accts
}
+ // The dropdown's trigger names the current account, so the menu must
+ // contain it — a list that lost it (a failed query) would open onto a
+ // menu with no "you are here" row at all. The gate already proved the
+ // membership, so this is a display fallback, not a permission decision.
+ if len(p.Accounts) == 0 {
+ p.Accounts = []Account{*acct}
+ }
return p
}
diff --git a/internal/console/server_account_test.go b/internal/console/server_account_test.go
index d94213795..f0969ea6c 100644
--- a/internal/console/server_account_test.go
+++ b/internal/console/server_account_test.go
@@ -197,6 +197,214 @@ func TestSwitcherListsOnlyMyAccounts(t *testing.T) {
}
}
+// header returns everything between <header> and </header> — the chrome, on
+// its own, so a switcher assertion cannot be satisfied by the page body
+// happening to contain an account name.
+func header(t *testing.T, body string) string {
+ t.Helper()
+ _, rest, ok := strings.Cut(body, "<header>")
+ if !ok {
+ t.Fatalf("no <header> in:\n%s", body)
+ }
+ head, _, ok := strings.Cut(rest, "</header>")
+ if !ok {
+ t.Fatalf("unterminated <header> in:\n%s", body)
+ }
+ return head
+}
+
+// The switcher IS the top-left of the header: a <details>/<summary>
+// disclosure naming the current account, in place of the old wordmark. The
+// disclosure element matters — it is what makes the dropdown keyboard
+// operable and screen-reader sane with no JavaScript at all.
+func TestHeaderSwitcherIsADropdownNamingTheCurrentAccount(t *testing.T) {
+ fx := newConsoleFixture(t)
+ otherID := testAccountID()
+ if err := fx.st.CreateAccount(otherID, accountSqid(otherID), "Kass", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ c := signInPaul(t, fx)
+ _, _, body := get(t, c, fx.u("/"))
+ h := header(t, body)
+
+ if !strings.Contains(h, "<details class=\"switcher\"") {
+ t.Errorf("the header switcher is not a <details> disclosure:\n%s", h)
+ }
+ if !strings.Contains(h, "<summary") {
+ t.Errorf("the switcher has no <summary> trigger:\n%s", h)
+ }
+ // The trigger says what it is — the current account's name, not an icon.
+ summary, _, _ := strings.Cut(h, "</summary>")
+ if !strings.Contains(summary, "ops") {
+ t.Errorf("the switcher trigger does not name the current account:\n%s", summary)
+ }
+ // The wordmark it replaces is gone from an account page's header.
+ if strings.Contains(h, "CARLOS console") {
+ t.Errorf("the wordmark is still in the account page header:\n%s", h)
+ }
+ // The current account is marked as such, and the others are plain links.
+ if !strings.Contains(h, `aria-current="true"`) {
+ t.Errorf("the current account is not marked in the menu:\n%s", h)
+ }
+ if !strings.Contains(h, `href="/a/`+accountSqid(otherID)+`/"`) {
+ t.Errorf("the menu has no link to the user's other account:\n%s", h)
+ }
+ // And "New account" is a link to its own screen, not an inline form.
+ if !strings.Contains(h, `href="/accounts/new"`) {
+ t.Errorf("the menu has no link to the new-account screen:\n%s", h)
+ }
+ if strings.Contains(h, `action="/accounts/new"`) {
+ t.Errorf("the header still carries the inline new-account form:\n%s", h)
+ }
+}
+
+// A user with exactly one account still gets a coherent header: the
+// dropdown names it, marks it current, and still offers a new account.
+func TestSingleAccountHeaderIsCoherent(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ _, _, body := get(t, c, fx.u("/"))
+ h := header(t, body)
+
+ if accts, _ := fx.st.AccountsFor("paul@keymail.dev"); len(accts) != 1 {
+ t.Fatalf("fixture user has %d accounts, want exactly 1", len(accts))
+ }
+ if !strings.Contains(h, "<summary") || !strings.Contains(h, `aria-current="true"`) {
+ t.Errorf("single-account header is not a coherent switcher:\n%s", h)
+ }
+ if strings.Count(h, `aria-current="true"`) != 1 {
+ t.Errorf("single-account menu marks something other than one current account:\n%s", h)
+ }
+ if !strings.Contains(h, `href="/accounts/new"`) {
+ t.Errorf("single-account header lost the new-account link:\n%s", h)
+ }
+}
+
+// Keyless (read-only) deployments degrade MUTATIONS, never the chrome: the
+// switcher is navigation, and losing it would strand a read-only operator in
+// whichever account they happened to land in.
+func TestKeylessStillRendersTheSwitcher(t *testing.T) {
+ fx := newConsoleFixtureKeyed(t, false)
+ c := signInPaul(t, fx)
+ _, _, body := get(t, c, fx.u("/"))
+ h := header(t, body)
+ if !strings.Contains(h, "<details class=\"switcher\"") || !strings.Contains(h, "ops") {
+ t.Errorf("keyless mode dropped the account switcher:\n%s", h)
+ }
+ if !strings.Contains(h, `href="/accounts/new"`) {
+ t.Errorf("keyless mode dropped the new-account link:\n%s", h)
+ }
+}
+
+// Signed-out pages have no account to name, so they keep the wordmark —
+// a header with an empty dropdown in it would be worse than no dropdown.
+func TestSignedOutHeaderKeepsTheWordmark(t *testing.T) {
+ fx := newConsoleFixture(t)
+ _, _, body := get(t, newBrowser(t), fx.ts.URL+"/")
+ h := header(t, body)
+ if !strings.Contains(h, "CARLOS console") {
+ t.Errorf("signed-out header lost the wordmark:\n%s", h)
+ }
+ if strings.Contains(h, "<details class=\"switcher\"") {
+ t.Errorf("signed-out header renders an accountless switcher:\n%s", h)
+ }
+}
+
+// "New account" is a screen of its own at its own URL: gated on a session,
+// CSRF on its POST, and creating from it lands you in the new account.
+func TestNewAccountScreen(t *testing.T) {
+ fx := newConsoleFixture(t)
+
+ // Signed out, it is not a page you can see.
+ if st, loc, _ := get(t, newBrowser(t), fx.ts.URL+"/accounts/new"); st != http.StatusFound || loc != "/" {
+ t.Fatalf("signed-out /accounts/new = %d -> %q, want 302 -> /", st, loc)
+ }
+
+ c := signInPaul(t, fx)
+ st, _, body := get(t, c, fx.ts.URL+"/accounts/new")
+ if st != http.StatusOK {
+ t.Fatalf("/accounts/new = %d, want 200", st)
+ }
+ if !strings.Contains(body, `action="/accounts/new"`) || !strings.Contains(body, `name="csrf"`) {
+ t.Fatalf("the new-account screen has no CSRF-bearing form:\n%s", body)
+ }
+ // It is a page, not a header ornament: the chrome is still there.
+ if !strings.Contains(header(t, body), "<details class=\"switcher\"") {
+ t.Errorf("the new-account screen lost the header switcher:\n%s", body)
+ }
+
+ // The POST still refuses a forged CSRF token.
+ if st, _, _ := postForm(t, c, fx.ts.URL+"/accounts/new",
+ url.Values{"csrf": {"wrong"}, "name": {"Nope"}}); st != http.StatusForbidden {
+ t.Errorf("new account with a bad csrf = %d, want 403", st)
+ }
+
+ // Creating from the screen lands you in the account it made.
+ csrf := sessionToken(t, fx, c)
+ st, loc, _ := postForm(t, c, fx.ts.URL+"/accounts/new",
+ url.Values{"csrf": {csrf}, "name": {"Woodstar"}})
+ if st != http.StatusSeeOther {
+ t.Fatalf("create = %d -> %q, want 303", st, loc)
+ }
+ accts, _ := fx.st.AccountsFor("paul@keymail.dev")
+ var made *Account
+ for i := range accts {
+ if accts[i].Name == "Woodstar" {
+ made = &accts[i]
+ }
+ }
+ if made == nil {
+ t.Fatalf("AccountsFor = %+v, want a Woodstar", accts)
+ }
+ if loc != "/a/"+made.Sqid+"/" {
+ t.Fatalf("landed at %q, want /a/%s/", loc, made.Sqid)
+ }
+ // And the account you landed in is the one the header now names.
+ _, _, body = get(t, c, fx.ts.URL+loc)
+ if h := header(t, body); !strings.Contains(h, "Woodstar") {
+ t.Errorf("the header does not name the account just created:\n%s", h)
+ }
+}
+
+// A nameless submission is a mistake to correct on the screen you are on,
+// not a bounce back to some other account.
+func TestNewAccountScreenRejectsAnEmptyNameInPlace(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+
+ st, loc, _ := postForm(t, c, fx.ts.URL+"/accounts/new", url.Values{"csrf": {csrf}, "name": {" "}})
+ if st != http.StatusSeeOther || loc != "/accounts/new" {
+ t.Fatalf("empty name = %d -> %q, want 303 -> /accounts/new", st, loc)
+ }
+ if _, _, body := get(t, c, fx.ts.URL+"/accounts/new"); !strings.Contains(body, "needs a name") {
+ t.Errorf("the screen does not say why it refused:\n%s", body)
+ }
+}
+
+// Visiting an account page updates the MRU; visiting /accounts/new does not
+// — it is not an account you are "in", and moving the MRU there would send
+// the next sign-in somewhere the user never chose.
+func TestNewAccountScreenLeavesTheMRUAlone(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+
+ id := testAccountID()
+ sqid := accountSqid(id)
+ if err := fx.st.CreateAccount(id, sqid, "Second", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if st, _, _ := get(t, c, fx.ts.URL+"/a/"+sqid+"/"); st != http.StatusOK {
+ t.Fatalf("second account home = %d", st)
+ }
+ if st, _, _ := get(t, c, fx.ts.URL+"/accounts/new"); st != http.StatusOK {
+ t.Fatalf("/accounts/new = %d", st)
+ }
+ if st, loc, _ := get(t, c, fx.ts.URL+"/"); st != http.StatusFound || loc != "/a/"+sqid+"/" {
+ t.Fatalf("landing = %d -> %q, want the MRU account /a/%s/", st, loc, sqid)
+ }
+}
+
// The audit trail is per (account, app), not per app name. Two accounts can
// both own a "blog"; sharing one activity stream would show each account the
// other's actions — including the other account's member addresses, which is
diff --git a/internal/console/templates/layout.html b/internal/console/templates/layout.html
index ec1fc570a..7d2975305 100644
--- a/internal/console/templates/layout.html
+++ b/internal/console/templates/layout.html
@@ -51,18 +51,61 @@
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; font-size: 0.9rem;
}
header .addr { color: var(--muted); }
+ /* The account switcher sits where the wordmark used to: a <details>
+ disclosure, so it is a real button to a screen reader, opens on
+ Enter/Space, and needs no JavaScript to work at all. The inline script
+ at the foot only adds Escape and click-away, which are enhancements. */
header .switcher {
- display: flex; align-items: baseline; gap: 0.5rem; flex-wrap: wrap;
- font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; font-size: 0.9rem;
+ position: relative;
+ font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
}
- header .switcher form { display: flex; gap: 0.4rem; align-items: baseline; margin: 0; }
- header .switcher input[type=text] { font-size: 0.8rem; padding: 0.2rem 0.4rem; width: 8rem; }
+ header .switcher > summary {
+ display: inline-flex; align-items: baseline; gap: 0.4rem;
+ list-style: none; cursor: pointer;
+ padding: 0.25rem 0.5rem; margin: -0.25rem -0.5rem; border-radius: 0.5rem;
+ }
+ header .switcher > summary::-webkit-details-marker { display: none; }
+ header .switcher > summary:hover { background: var(--accent-soft); }
+ /* Names are free text up to 64 characters, which unbounded would shove the
+ nav off the header — truncate in the trigger, wrap in the menu. */
+ header .switcher .current-name {
+ font-weight: 700; min-width: 0; max-width: 14rem;
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+ }
+ header .switcher .caret { font-size: 0.7rem; color: var(--muted); }
+ header .switcher[open] > summary .caret { transform: rotate(180deg); display: inline-block; }
+ header .switcher-menu {
+ position: absolute; z-index: 20; top: calc(100% + 0.6rem); left: -0.5rem;
+ min-width: 13rem; max-width: 20rem;
+ background: var(--bg); border: 1px solid var(--rule); border-radius: 0.6rem;
+ padding: 0.35rem; font-size: 0.9rem;
+ box-shadow: 0 0.4rem 1.5rem rgba(0, 0, 0, 0.14);
+ }
+ header .switcher-menu ul { list-style: none; margin: 0; padding: 0; }
+ header .switcher-menu a, header .switcher-menu .current {
+ display: flex; align-items: baseline; gap: 0.5rem;
+ padding: 0.35rem 0.55rem; border-radius: 0.4rem;
+ color: var(--ink); text-decoration: none; overflow-wrap: anywhere;
+ }
+ header .switcher-menu a:hover, header .switcher-menu a:focus { background: var(--accent-soft); }
+ header .switcher-menu .current { font-weight: 600; background: var(--accent-soft); }
+ /* The tick reserves its width on every row so the names stay in one column. */
+ header .switcher-menu .tick { color: var(--accent); width: 0.9rem; flex: none; }
+ header .switcher-menu a .tick { visibility: hidden; }
+ header .switcher-menu hr { border: 0; border-top: 1px solid var(--rule); margin: 0.35rem 0.2rem; }
+ header .switcher-menu .new a { color: var(--accent); }
main { max-width: 56rem; margin: 0 auto; padding: 2.5rem 0 3rem; min-height: 55vh; }
h1 { font-size: 1.9rem; line-height: 1.15; margin: 0 0 1rem; }
h2 { font-size: 1.25rem; margin: 2rem 0 0.75rem; }
p { max-width: 40rem; margin: 0 0 1rem; }
.lede { font-size: 1.15rem; color: var(--muted); }
.small { font-size: 0.9rem; color: var(--muted); }
+ /* Present to a screen reader, absent to the eye — for labels the visual
+ design carries in placement or placeholder text alone. */
+ .visually-hidden {
+ position: absolute; width: 1px; height: 1px; overflow: hidden;
+ clip-path: inset(50%); white-space: nowrap;
+ }
.error { color: var(--bad); }
.banner {
background: var(--accent-soft); border-radius: 0.5rem;
@@ -117,19 +160,28 @@
</head>
<body>
<header>
- <div class="brand"><a href="/">CARLOS console</a></div>
{{if .Account}}
- <div class="switcher">
- <span class="small">Account</span>
- <strong>{{.Account.Name}}</strong>
- <span class="small">·</span>
- {{range .Accounts}}{{if ne .Sqid $.Account.Sqid}}<a href="/a/{{.Sqid}}/">{{.Name}}</a> {{end}}{{end}}
- <form method="post" action="/accounts/new">
- <input type="hidden" name="csrf" value="{{.CSRF}}">
- <input type="text" name="name" placeholder="New account">
- <button class="linkish" type="submit">New account</button>
- </form>
- </div>
+ <details class="switcher" id="switcher">
+ <summary>
+ <span class="small">Account</span>
+ <span class="current-name">{{.Account.Name}}</span>
+ <span class="caret" aria-hidden="true">▾</span>
+ </summary>
+ <div class="switcher-menu">
+ <ul>
+ {{range .Accounts}}<li>{{if eq .Sqid $.Account.Sqid}}
+ <span class="current" aria-current="true"><span class="tick" aria-hidden="true">✓</span>{{.Name}}</span>
+ {{else}}
+ <a href="/a/{{.Sqid}}/"><span class="tick" aria-hidden="true">✓</span>{{.Name}}</a>
+ {{end}}</li>
+ {{end}}
+ </ul>
+ <hr>
+ <div class="new"><a href="/accounts/new"><span class="tick" aria-hidden="true">✓</span>New account</a></div>
+ </div>
+ </details>
+ {{else}}
+ <div class="brand"><a href="/">CARLOS console</a></div>
{{end}}
{{if .Address}}<nav>
{{if .Account}}<a href="{{.Base}}/">Apps</a>{{end}}
@@ -146,6 +198,22 @@
{{define "bottom"}}
</main>
<footer>🤖 Console text written by an LLM, per <a href="https://11factor.org/#x">factor X</a>.</footer>
+<script>
+// Enhancement only: <details> already opens, closes and focuses on its own.
+// This adds the two behaviours a menu is expected to have and the element
+// does not — Escape closes it (returning focus to the trigger), and a click
+// anywhere else closes it. With JS off, the dropdown is unchanged.
+(function () {
+ var d = document.getElementById("switcher");
+ if (!d) return;
+ document.addEventListener("keydown", function (e) {
+ if (e.key === "Escape" && d.open) { d.open = false; d.firstElementChild.focus(); }
+ });
+ document.addEventListener("click", function (e) {
+ if (d.open && !d.contains(e.target)) d.open = false;
+ });
+})();
+</script>
</body>
</html>
{{end}}
diff --git a/internal/console/templates/newaccount.html b/internal/console/templates/newaccount.html
new file mode 100644
index 000000000..8bcfe1d3a
--- /dev/null
+++ b/internal/console/templates/newaccount.html
@@ -0,0 +1,18 @@
+{{define "newaccount"}}{{template "top" .}}
+<h1>New account</h1>
+<p class="lede">An account is a project or a team: it owns apps, has members,
+and deploys independently of your other accounts.</p>
+{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}}
+{{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}}
+<form class="signin" method="post" action="/accounts/new">
+ <input type="hidden" name="csrf" value="{{.CSRF}}">
+ <label class="visually-hidden" for="account-name">Account name</label>
+ <input id="account-name" type="text" name="name" placeholder="Kass" autofocus>
+ <button type="submit">Create account</button>
+</form>
+<p class="small">Names are free text, up to 64 characters, and deliberately
+not unique — two accounts may both be called “Blog”. Identity is the short
+id in the URL, so there is nothing to squat. You will be its first owner,
+and you can invite teammates from the account's page.</p>
+{{if .Account}}<p class="small"><a href="{{.Base}}/">Back to {{.Account.Name}}</a></p>{{end}}
+{{template "bottom" .}}{{end}}