feat(P1): Reach signup handler + templates + tests (REQ-040, G-026)
web/handlers/reach.go: GET /reach (list), GET /reach/new (form),
GET /reach/{id} (detail), POST /reach (atomic Reach+Stash create
per D-071, redirect 302). Labels "Create a Reach" (not the banned
legacy word). G-027 validation (non-empty, <=128, no path separators,
no template syntax). 3 Reach templates extend base.html.
reach_test.go: httptest for all 4 routes + atomic create + 400/409
error paths + rendered-HTML lexicon check on BOTH 200 and error bodies
(G-026). handlers/server.go: clone-per-page template pattern (avoids
content-block collision across pages). Coverage 81.2% on web/handlers.
---ci---
project: oy
phase: 1
milestone: v0.6
status: execute
---/ci---
This commit is contained in:
@@ -0,0 +1,90 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
identitytypes "github.com/oy/openyield/x/identity/types"
|
||||||
|
stashtypes "github.com/oy/openyield/x/stash/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// registerReach wires the Reach signup routes (REQ-040) into the mux.
|
||||||
|
// Go 1.22 method-pattern routing: GET /reach (list), GET /reach/new (form),
|
||||||
|
// POST /reach (atomic create + redirect per D-071), GET /reach/{id} (detail).
|
||||||
|
func (s *Server) registerReach(mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("GET /reach", s.handleReachList)
|
||||||
|
mux.HandleFunc("GET /reach/new", s.handleReachNew)
|
||||||
|
mux.HandleFunc("POST /reach", s.handleReachCreate)
|
||||||
|
mux.HandleFunc("GET /reach/{id}", s.handleReachDetail)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleReachList renders all Reaches (seeded + created).
|
||||||
|
func (s *Server) handleReachList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
reaches := s.Store.ListReaches()
|
||||||
|
s.render(w, "reach_list.html", map[string]any{"Reaches": reaches})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleReachNew renders the "Create a Reach" form. Lexicon-clean: "Create a
|
||||||
|
// Reach", NOT a legacy custodial-position label (REQ-012 bans that word).
|
||||||
|
func (s *Server) handleReachNew(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.render(w, "reach_new.html", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleReachCreate handles the POST from the "Create a Reach" form. Calls
|
||||||
|
// store.CreateReach (atomic Reach + Stash per D-071). On validation error
|
||||||
|
// (G-027) returns 400 with a lexicon-clean message; on duplicate returns 409.
|
||||||
|
// On success redirects (302) to the new Reach detail page.
|
||||||
|
func (s *Server) handleReachCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
holderID := r.FormValue("holder_id")
|
||||||
|
publicKey := r.FormValue("public_key")
|
||||||
|
reach, _, err := s.Store.CreateReach(holderID, publicKey)
|
||||||
|
if err != nil {
|
||||||
|
// G-026: rendered-HTML lexicon check scans error response bodies too;
|
||||||
|
// keep the error message lexicon-clean (no banned terms).
|
||||||
|
status := http.StatusBadRequest
|
||||||
|
if isDuplicate(err) {
|
||||||
|
status = http.StatusConflict
|
||||||
|
}
|
||||||
|
http.Error(w, "Could not create a Reach: "+err.Error(), status)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/reach/"+reach.HolderID, http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleReachDetail renders one Reach + its associated Stash (BalanceGrain).
|
||||||
|
func (s *Server) handleReachDetail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
reach, ok := s.Store.GetReach(id)
|
||||||
|
if !ok {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stash, _ := s.Store.GetStash(id)
|
||||||
|
s.render(w, "reach_detail.html", map[string]any{
|
||||||
|
"Reach": reach,
|
||||||
|
"Stash": stash,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// isDuplicate reports whether err is a duplicate-holder error from
|
||||||
|
// store.CreateReach. Kept as a string match to avoid exporting store errors.
|
||||||
|
func isDuplicate(err error) bool {
|
||||||
|
return err != nil && contains(err.Error(), "already has a Reach")
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(s, sub string) bool {
|
||||||
|
return len(s) >= len(sub) && (s == sub || indexOf(s, sub) >= 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexOf(s, sub string) int {
|
||||||
|
for i := 0; i+len(sub) <= len(s); i++ {
|
||||||
|
if s[i:i+len(sub)] == sub {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compile-time assertions that the handlers use the real x/*/types structs
|
||||||
|
// (D-067: the UI grounds in the real Go type definitions).
|
||||||
|
var _ identitytypes.Reach
|
||||||
|
var _ stashtypes.Stash
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/oy/openyield/lexicon"
|
||||||
|
"github.com/oy/openyield/web/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newTestServer builds a Server with a fresh store + templates parsed from
|
||||||
|
// web/templates (relative to repo root via the handlers test working dir).
|
||||||
|
func newTestServer(t *testing.T) *Server {
|
||||||
|
t.Helper()
|
||||||
|
srv, err := New(store.NewStore(), "../../web/templates")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new handlers server: %v", err)
|
||||||
|
}
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertNoBannedTerms checks the rendered response body for banned terms
|
||||||
|
// (G-026: applies to BOTH 200 happy-path AND error response bodies).
|
||||||
|
func assertNoBannedTerms(t *testing.T, body string) {
|
||||||
|
t.Helper()
|
||||||
|
if term, ok := lexicon.FindBannedTerm(body); ok {
|
||||||
|
t.Errorf("rendered HTML contains banned term %q (REQ-012/G-026)", term)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReachListReturnsSeededReaches(t *testing.T) {
|
||||||
|
srv := newTestServer(t)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
srv.Register(mux)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest("GET", "/reach", nil)
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET /reach: status %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
body := rec.Body.String()
|
||||||
|
if !strings.Contains(body, "holder-alia") {
|
||||||
|
t.Errorf("GET /reach: body missing seeded reach holder-alia")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "holder-bryn") {
|
||||||
|
t.Errorf("GET /reach: body missing seeded reach holder-bryn")
|
||||||
|
}
|
||||||
|
assertNoBannedTerms(t, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReachNewReturnsForm(t *testing.T) {
|
||||||
|
srv := newTestServer(t)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
srv.Register(mux)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest("GET", "/reach/new", nil)
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET /reach/new: status %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
body := rec.Body.String()
|
||||||
|
if !strings.Contains(body, "Create a Reach") {
|
||||||
|
t.Errorf("GET /reach/new: body missing 'Create a Reach' label")
|
||||||
|
}
|
||||||
|
// The legacy custodial-position word is BANNED (REQ-012) — must not appear.
|
||||||
|
// Check the full banned-terms list via the lexicon package (no literals in
|
||||||
|
// source); FindBannedTerm does word-boundary matching so this is stricter
|
||||||
|
// than a naive substring check.
|
||||||
|
if term, ok := lexicon.FindBannedTerm(body); ok {
|
||||||
|
t.Errorf("GET /reach/new: body contains banned word %q", term)
|
||||||
|
}
|
||||||
|
assertNoBannedTerms(t, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReachCreateValidRedirectsAndAtomicallyCreates(t *testing.T) {
|
||||||
|
srv := newTestServer(t)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
srv.Register(mux)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest("POST", "/reach", strings.NewReader("holder_id=holder-new&public_key=pk-new"))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusFound {
|
||||||
|
t.Fatalf("POST /reach valid: status %d, want 302 (Found)", rec.Code)
|
||||||
|
}
|
||||||
|
loc := rec.Header().Get("Location")
|
||||||
|
if !strings.Contains(loc, "/reach/holder-new") {
|
||||||
|
t.Errorf("POST /reach: Location %q, want redirect to /reach/holder-new", loc)
|
||||||
|
}
|
||||||
|
// D-071: atomic creation — both Reach + Stash must be present.
|
||||||
|
reach, ok := srv.Store.GetReach("holder-new")
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("POST /reach: GetReach miss after create (atomicity broken)")
|
||||||
|
}
|
||||||
|
if !reach.IsNomad {
|
||||||
|
t.Errorf("POST /reach: created Reach IsNomad=false, want true (D-071)")
|
||||||
|
}
|
||||||
|
stash, ok := srv.Store.GetStash("holder-new")
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("POST /reach: GetStash miss after create (atomicity broken — D-071)")
|
||||||
|
}
|
||||||
|
if stash.HolderID != reach.HolderID {
|
||||||
|
t.Errorf("POST /reach: stash.HolderID %q != reach.HolderID %q (D-071)", stash.HolderID, reach.HolderID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReachCreateEmptyHolderIDReturns400(t *testing.T) {
|
||||||
|
srv := newTestServer(t)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
srv.Register(mux)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest("POST", "/reach", strings.NewReader("holder_id=&public_key=pk"))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("POST /reach empty holder: status %d, want 400", rec.Code)
|
||||||
|
}
|
||||||
|
// G-026: rendered-HTML lexicon check scans the ERROR response body too.
|
||||||
|
assertNoBannedTerms(t, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReachCreatePathSeparatorReturns400(t *testing.T) {
|
||||||
|
srv := newTestServer(t)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
srv.Register(mux)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest("POST", "/reach", strings.NewReader("holder_id=h/x&public_key=pk"))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("POST /reach path separator: status %d, want 400", rec.Code)
|
||||||
|
}
|
||||||
|
assertNoBannedTerms(t, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReachCreateDuplicateReturns409(t *testing.T) {
|
||||||
|
srv := newTestServer(t)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
srv.Register(mux)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest("POST", "/reach", strings.NewReader("holder_id=holder-alia&public_key=pk"))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusConflict {
|
||||||
|
t.Fatalf("POST /reach duplicate: status %d, want 409", rec.Code)
|
||||||
|
}
|
||||||
|
assertNoBannedTerms(t, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReachDetailSeededReturnsReachAndStash(t *testing.T) {
|
||||||
|
srv := newTestServer(t)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
srv.Register(mux)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest("GET", "/reach/holder-alia", nil)
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET /reach/holder-alia: status %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
body := rec.Body.String()
|
||||||
|
if !strings.Contains(body, "reach-holder-alia") {
|
||||||
|
t.Errorf("GET /reach/holder-alia: body missing reach-holder-alia")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "stash-holder-alia") {
|
||||||
|
t.Errorf("GET /reach/holder-alia: body missing associated stash-holder-alia")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "Grain") {
|
||||||
|
t.Errorf("GET /reach/holder-alia: body missing Stash balance in Grain")
|
||||||
|
}
|
||||||
|
assertNoBannedTerms(t, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReachDetailMissingReturns404(t *testing.T) {
|
||||||
|
srv := newTestServer(t)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
srv.Register(mux)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest("GET", "/reach/nobody", nil)
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("GET /reach/nobody: status %d, want 404", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
// Package handlers holds the HTTP handlers for the OpenYield web UI screens.
|
||||||
|
//
|
||||||
|
// Each screen (Reach signup, Stash dashboard, Window authorization, Standing
|
||||||
|
// progress, Bloom accrual) gets its own handler file. handlers/server.go wires
|
||||||
|
// routes into the mux from web/server.go. Handlers render html/template
|
||||||
|
// templates against the mock store (web/store). Lexicon-clean by construction
|
||||||
|
// (REQ-012 / REQ-045): the lexicon_meta_web firewall scans these files.
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/oy/openyield/web/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Server bundles the mock store + per-page templates + route registration.
|
||||||
|
// Each screen handler is a method on Server so it shares the store + tmpl.
|
||||||
|
//
|
||||||
|
// Template loading: base.html is parsed once, then each page template is
|
||||||
|
// parsed in a CLONE of the base set so the per-page "content" block does not
|
||||||
|
// collide across pages (Go html/template shares the block namespace within
|
||||||
|
// one set; cloning per page isolates each page's content block). This is the
|
||||||
|
// standard Go template pattern for layouts + pages.
|
||||||
|
type Server struct {
|
||||||
|
Store *store.Store
|
||||||
|
Pages map[string]*template.Template
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs a Server with the given store + per-page templates loaded
|
||||||
|
// from templatesDir (the absolute or relative path to web/templates/).
|
||||||
|
func New(s *store.Store, templatesDir string) (*Server, error) {
|
||||||
|
basePath := filepath.Join(templatesDir, "base.html")
|
||||||
|
base, err := template.ParseFiles(basePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse base: %w", err)
|
||||||
|
}
|
||||||
|
pages := map[string]*template.Template{}
|
||||||
|
pageGlob := filepath.Join(templatesDir, "*.html")
|
||||||
|
matches, err := filepath.Glob(pageGlob)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("glob pages: %w", err)
|
||||||
|
}
|
||||||
|
for _, p := range matches {
|
||||||
|
name := filepath.Base(p)
|
||||||
|
if name == "base.html" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
clone, cerr := base.Clone()
|
||||||
|
if cerr != nil {
|
||||||
|
return nil, fmt.Errorf("clone for %s: %w", name, cerr)
|
||||||
|
}
|
||||||
|
pt, perr := clone.ParseFiles(p)
|
||||||
|
if perr != nil {
|
||||||
|
return nil, fmt.Errorf("parse %s: %w", name, perr)
|
||||||
|
}
|
||||||
|
pages[name] = pt
|
||||||
|
}
|
||||||
|
return &Server{Store: s, Pages: pages}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register wires all screen routes into the given mux (Go 1.22 method
|
||||||
|
// patterns). Called by web/server.go after constructing the Server.
|
||||||
|
func (s *Server) Register(mux *http.ServeMux) {
|
||||||
|
s.registerReach(mux)
|
||||||
|
// P2..P5 register their own routes (stash, window, standing, bloom).
|
||||||
|
}
|
||||||
|
|
||||||
|
// render executes the named page template with the given data, writing HTML
|
||||||
|
// to w. The page template invokes base.html and overrides the "content" block.
|
||||||
|
func (s *Server) render(w http.ResponseWriter, name string, data any) {
|
||||||
|
tmpl, ok := s.Pages[name]
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "template not found: "+name, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if err := tmpl.ExecuteTemplate(w, "base.html", data); err != nil {
|
||||||
|
http.Error(w, "render error", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderHome renders the home page (public so web/server.go can call it for
|
||||||
|
// the "/" route which lives outside handlers.Register).
|
||||||
|
func (s *Server) RenderHome(w http.ResponseWriter, data any) {
|
||||||
|
s.render(w, "home.html", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// templatesDir returns the default web/templates directory relative to the
|
||||||
|
// working directory. Used by web/server.go when constructing via New().
|
||||||
|
func DefaultTemplatesDir() string {
|
||||||
|
dir, _ := os.Getwd()
|
||||||
|
if filepath.Base(dir) == "web" || filepath.Base(dir) == "handlers" {
|
||||||
|
return filepath.Join(dir, "templates")
|
||||||
|
}
|
||||||
|
return "web/templates"
|
||||||
|
}
|
||||||
+13
-12
@@ -1,10 +1,12 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"html/template"
|
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"github.com/oy/openyield/web/handlers"
|
||||||
|
"github.com/oy/openyield/web/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
func runServer() {
|
func runServer() {
|
||||||
@@ -13,29 +15,28 @@ func runServer() {
|
|||||||
port = "8080"
|
port = "8080"
|
||||||
}
|
}
|
||||||
|
|
||||||
tmpl, err := template.ParseGlob("web/templates/*.html")
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("parse templates: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
srv, err := handlers.New(store.NewStore(), "web/templates")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("init handlers: %v", err)
|
||||||
|
}
|
||||||
|
srv.Register(mux)
|
||||||
|
|
||||||
|
// Home page (rendered via the handlers' page machinery too).
|
||||||
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path != "/" {
|
if r.URL.Path != "/" {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
srv.RenderHome(w, nil)
|
||||||
if err := tmpl.ExecuteTemplate(w, "home.html", nil); err != nil {
|
|
||||||
log.Printf("render home: %v", err)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("web/static"))))
|
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("web/static"))))
|
||||||
|
|
||||||
srv := &http.Server{Addr: ":" + port, Handler: mux}
|
server := &http.Server{Addr: ":" + port, Handler: mux}
|
||||||
log.Printf("OpenYield web on :%s", port)
|
log.Printf("OpenYield web on :%s", port)
|
||||||
if err := srv.ListenAndServe(); err != nil {
|
if err := server.ListenAndServe(); err != nil {
|
||||||
log.Fatalf("server: %v", err)
|
log.Fatalf("server: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// seed populates the store with a few pre-existing Reach/Stash pairs for the
|
// seed populates the store with a few pre-existing Reach/Stash pairs for the
|
||||||
// list view. All strings lexicon-clean ("Holder"/"Reach"/"Stash"; NOT
|
// list view. All strings lexicon-clean ("Holder"/"Reach"/"Stash"; NOT the
|
||||||
// "account"/"bank"/"deposit"). Two fixtures: one mature (90+ active days),
|
// banned financial terms). Two fixtures: one mature (90+ active days),
|
||||||
// one immature (45 active days) so the Stash dashboard (P2) can show both
|
// one immature (45 active days) so the Stash dashboard (P2) can show both
|
||||||
// states.
|
// states.
|
||||||
func (s *Store) seed() {
|
func (s *Store) seed() {
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
{{template "base.html" .}}
|
|
||||||
|
|
||||||
{{define "title"}}OpenYield — real production on the mesh{{end}}
|
{{define "title"}}OpenYield — real production on the mesh{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{{define "title"}}{{.Reach.ReachID}} — OpenYield{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<section class="panel">
|
||||||
|
<h1>{{.Reach.ReachID}}</h1>
|
||||||
|
<table class="kv">
|
||||||
|
<tr><th>Reach ID</th><td>{{.Reach.ReachID}}</td></tr>
|
||||||
|
<tr><th>Holder ID</th><td>{{.Reach.HolderID}}</td></tr>
|
||||||
|
<tr><th>Public Key</th><td><code>{{.Reach.PublicKey}}</code></td></tr>
|
||||||
|
<tr><th>Created</th><td>{{.Reach.CreatedAt}}</td></tr>
|
||||||
|
<tr><th>Nomad</th><td>{{if .Reach.IsNomad}}yes{{else}}no{{end}}</td></tr>
|
||||||
|
<tr><th>Freeholder</th><td>{{if .Reach.IsFreeholder}}yes{{else}}no{{end}}</td></tr>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{if .Stash.StashID}}
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Stash</h2>
|
||||||
|
<table class="kv">
|
||||||
|
<tr><th>Stash ID</th><td>{{.Stash.StashID}}</td></tr>
|
||||||
|
<tr><th>Balance</th><td>{{.Stash.BalanceGrain}} Grain</td></tr>
|
||||||
|
<tr><th>Created</th><td>{{.Stash.CreatedAt}}</td></tr>
|
||||||
|
<tr><th>Last active</th><td>{{.Stash.LastActive}}</td></tr>
|
||||||
|
<tr><th>Still</th><td>{{if .Stash.IsStill}}paused{{else}}active{{end}}</td></tr>
|
||||||
|
</table>
|
||||||
|
<p><a href="/stash/{{.Stash.HolderID}}">View Stash dashboard</a></p>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<p><a href="/reach">Back to Reach list</a></p>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{{define "title"}}Reach — OpenYield{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<section class="panel">
|
||||||
|
<h1>Reach</h1>
|
||||||
|
<p>A Reach is the mesh-native identity a Holder uses to act on the mesh
|
||||||
|
without a custodian, a gatekeeper, or a legacy financial position. A Nomad
|
||||||
|
is a Holder who has a Reach and a Stash and is on the way to earning the
|
||||||
|
four Freeholder signals.</p>
|
||||||
|
<p><a href="/reach/new" class="btn">Create a Reach</a></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Holders on the mesh</h2>
|
||||||
|
{{if .Reaches}}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Reach ID</th><th>Holder ID</th><th>Nomad</th><th>Freeholder</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Reaches}}
|
||||||
|
<tr>
|
||||||
|
<td><a href="/reach/{{.HolderID}}">{{.ReachID}}</a></td>
|
||||||
|
<td>{{.HolderID}}</td>
|
||||||
|
<td>{{if .IsNomad}}yes{{else}}no{{end}}</td>
|
||||||
|
<td>{{if .IsFreeholder}}yes{{else}}no{{end}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{else}}
|
||||||
|
<p>No Reaches yet. <a href="/reach/new">Create a Reach</a> to begin.</p>
|
||||||
|
{{end}}
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{{define "title"}}Create a Reach — OpenYield{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<section class="panel">
|
||||||
|
<h1>Create a Reach</h1>
|
||||||
|
<p>A Reach is an identity, not a custodial position. The protocol does not
|
||||||
|
require KYC at the protocol layer; the Reach is the unit of self-service.
|
||||||
|
Creating a Reach also opens a Stash for you (the place a Nomad holds
|
||||||
|
Grain) — that pair is enough to begin on the mesh.</p>
|
||||||
|
|
||||||
|
<form method="POST" action="/reach" hx-post="/reach" hx-target="body">
|
||||||
|
<label for="holder_id">Holder ID</label>
|
||||||
|
<input type="text" id="holder_id" name="holder_id" required
|
||||||
|
maxlength="128" placeholder="a by-ID-string of your choosing">
|
||||||
|
<label for="public_key">Public Key</label>
|
||||||
|
<input type="text" id="public_key" name="public_key" required
|
||||||
|
maxlength="128" placeholder="a public key for your Reach">
|
||||||
|
<button type="submit">Create a Reach</button>
|
||||||
|
</form>
|
||||||
|
<p><a href="/reach">Back to Reach list</a></p>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
Reference in New Issue
Block a user