abono: magic-link example, unrecognised subscriptions on the members screen, and tests that don't read copy
Summary
The example signs in with rastrillo's magic links (auth, SigninScreen, links logged in development) instead of passwords, so a user abono's Holder made for a buyer is reachable only through that address's inbox; sign-up is gone. The generated members page lists paying subscriptions on unknown prices with an adopt form that returns to it; AbonoPages gains an optional Label for the owner screens (the example shows addresses); the comp confirm page fails through p.fail when Offers fails; an unknown comp recipient says abono.owner.free_no_member and adopting onto an unknown plan says abono.error.unknown_plan. The example's tests read pages through a test catalog mapping each key to T:<key>, element attributes and statuses, so they pass whatever English ships.
Agent notes
Fix round 1 for review findings 1-5 under rulings R25-R28. New tests: TestHolderAccountNeedsItsInbox (an intruder asking for a link, guessing a token, or posting to the removed /signup gets no session; the mailed link works) and TestMembersShowUnrecognisedSubscriptions (abonotest Stripe, sync, the row on the members page, unknown plan 422, adopt back to members with the holder's address). Keys needing review: abono.owner.free_no_member, example.signout. Report appended to task-9-report.md.
21 files changed,
+735
−336
cmd/abono/main_test.go+7 −3cmd/abono/pages/abono_pages.go.tmpl+77 −20cmd/abono/pages/owner-end.html.tmpl+2 −1cmd/abono/pages/owner-members.html.tmpl+30 −7cmd/abono/pages/owner-offers.html.tmpl+4 −3example/README.md+15 −12example/abono_pages.go+82 −25example/app.go+106 −50example/app_test.go+291 −79example/browser_test.go+18 −23example/go.mod+1 −1example/go.sum+0 −2example/main.go+13 −4example/migrations/0001_init.sql+1 −1example/models.go+51 −69example/templates/abono/owner-end.html+2 −1example/templates/abono/owner-members.html+30 −7example/templates/abono/owner-offers.html+4 −3example/templates/layout.html+0 −1example/templates/signin.html+1 −11example/templates/signup.html+0 −13
CI passed — run details
CI log
=== vet === go vet ./... go: downloading github.com/keymaildev/signin v0.1.1 === fmt === === test === go test ./... -count=1 ok amadan.net/rastrillo/abono 0.754s ok amadan.net/rastrillo/abono/abonotest 0.059s ok amadan.net/rastrillo/abono/cmd/abono 0.803s ok amadan.net/rastrillo/abono/internal/fakestripe 0.022s ok amadan.net/rastrillo/abono/internal/stripeapi 0.007s ok example 0.132s === race === CGO_ENABLED=1 go test ./... -race -count=1 ok amadan.net/rastrillo/abono 5.861s ok amadan.net/rastrillo/abono/abonotest 1.415s ok amadan.net/rastrillo/abono/cmd/abono 2.015s ok amadan.net/rastrillo/abono/internal/fakestripe 1.070s ok amadan.net/rastrillo/abono/internal/stripeapi 1.019s ok example 1.947s === browser === ok example 2.138s
Intent
diff --git a/cmd/abono/main_test.go b/cmd/abono/main_test.go| index d9626c2..2b6dcce 100644 |
| --- a/cmd/abono/main_test.go |
| +++ b/cmd/abono/main_test.go |
| @@ -282,10 +282,14 @@ var starterKeys = func() []string { |
| // partialKeys are keys the pages may name that the partials already use. |
| var partialKeys = []string{"abono.price.per_month", "abono.price.per_year"} |
| +// newKeys are keys the pages need that the draft lacks (ruling R28), |
| +// going through their own copy review. The pages must use each. |
| +var newKeys = []string{"abono.owner.free_no_member"} |
| + |
| var keyLiteral = regexp.MustCompile(`"(abono\.[a-z][a-z_.]*[a-z])"`) |
| // TestPagesUseTheDraftKeys: every word on the starter pages is a key of |
| -// the draft copy, and no draft key is left out. abono.button.subscribe |
| +// the draft copy or one of newKeys, and no draft key is left out. abono.button.subscribe |
| // and abono.button.manage are the partials' own, reached through them. |
| func TestPagesUseTheDraftKeys(t *testing.T) { |
| dir := generate(t, "shop") |
| @@ -300,11 +304,11 @@ func TestPagesUseTheDraftKeys(t *testing.T) { |
| } |
| } |
| for k := range used { |
| - if !slices.Contains(starterKeys, k) && !slices.Contains(partialKeys, k) { |
| + if !slices.Contains(starterKeys, k) && !slices.Contains(partialKeys, k) && !slices.Contains(newKeys, k) { |
| t.Errorf("the pages use %s, which is not a draft key", k) |
| } |
| } |
| - for _, k := range starterKeys { |
| + for _, k := range append(append([]string{}, starterKeys...), newKeys...) { |
| if !used[k] { |
| t.Errorf("the pages never use the draft key %s", k) |
| } |
diff --git a/cmd/abono/pages/abono_pages.go.tmpl b/cmd/abono/pages/abono_pages.go.tmpl| index fee7730..7fc9adb 100644 |
| --- a/cmd/abono/pages/abono_pages.go.tmpl |
| +++ b/cmd/abono/pages/abono_pages.go.tmpl |
| @@ -78,6 +78,9 @@ type AbonoPages struct { |
| // for the owner's free-membership form. Nil, or not found, refuses |
| // the form. |
| SubjectForEmail func(ctx context.Context, email string) (subject string, ok bool) |
| + // Label is what the owner's pages call the person with this |
| + // subject: their address or name. Nil, or "", shows the subject. |
| + Label func(ctx context.Context, subject string) string |
| } |
| // Mount adds the pages to r. The reader's pages are open to anyone (the |
| @@ -368,6 +371,8 @@ func (p *AbonoPages) ownerOnly(next http.Handler) http.Handler { |
| // abonoMember is one row of the members table. |
| type abonoMember struct { |
| abono.Member |
| + // Label is what to call the member (AbonoPages.Label). |
| + Label string |
| // EndURL is the confirm page that ends a comp still granting; "" |
| // for anything else. |
| EndURL string |
| @@ -430,9 +435,16 @@ func (p *AbonoPages) renderMembers(w http.ResponseWriter, r *http.Request, statu |
| p.fail(w, r, "offers", err) |
| return |
| } |
| + var unknown []abonoUnrecognised |
| + if p.Service.On() { |
| + if unknown, err = p.unrecognised(ctx); err != nil { |
| + p.fail(w, r, "unrecognised", err) |
| + return |
| + } |
| + } |
| rows := make([]abonoMember, len(ms)) |
| for i, m := range ms { |
| - rows[i] = abonoMember{Member: m} |
| + rows[i] = abonoMember{Member: m, Label: p.label(ctx, m.Subject)} |
| if m.Source == abono.SourceComp && m.Grants { |
| rows[i].EndURL = abonoMembersPath + "/comps/" + strconv.FormatInt(m.CompID, 10) + "/end" |
| } |
| @@ -440,13 +452,16 @@ func (p *AbonoPages) renderMembers(w http.ResponseWriter, r *http.Request, statu |
| p.render(w, r, status, "abono/owner-members", map[string]any{ |
| "Title": "abono.owner.members_title", |
| "On": p.Service.On(), |
| - "Members": rows, |
| - "Now": p.Service.Now(), |
| - "Offers": offers, |
| - "PlanOptions": abonoPlanOptions(offers, form.Plan), |
| - "Form": form, |
| - "SyncAction": abonoMembersPath + "/sync", |
| - "CompAction": abonoMembersPath + "/comps", |
| + "Members": rows, |
| + "Unrecognised": unknown, |
| + "Now": p.Service.Now(), |
| + "Offers": offers, |
| + "PlanOptions": abonoPlanOptions(offers, form.Plan), |
| + "AdoptOptions": abonoPlanOptions(offers, ""), |
| + "Form": form, |
| + "SyncAction": abonoMembersPath + "/sync", |
| + "CompAction": abonoMembersPath + "/comps", |
| + "AdoptAction": abonoOffersPath + "/adopt", |
| "StripeURL": abonoStripePath, |
| "Done": abonoReadNotice(r, offers), |
| "Error": errKey, |
| @@ -498,7 +513,7 @@ func (p *AbonoPages) comp(w http.ResponseWriter, r *http.Request) { |
| subject, found = p.SubjectForEmail(ctx, form.Member) |
| } |
| if !found { |
| - form.Errors["member"] = "abono.error.signin_needed" |
| + form.Errors["member"] = "abono.owner.free_no_member" |
| } |
| if len(form.Errors) > 0 { |
| p.renderMembers(w, r, http.StatusUnprocessableEntity, form, "") |
| @@ -564,17 +579,21 @@ func (p *AbonoPages) endCompPage(w http.ResponseWriter, r *http.Request) { |
| http.NotFound(w, r) |
| return |
| } |
| + offers, err := p.Service.Offers(r.Context()) |
| + if err != nil { |
| + p.fail(w, r, "offers", err) |
| + return |
| + } |
| plan := m.Plan |
| - if offers, err := p.Service.Offers(r.Context()); err == nil { |
| - for _, o := range offers { |
| - if o.Plan == m.Plan { |
| - plan = o.Name |
| - } |
| + for _, o := range offers { |
| + if o.Plan == m.Plan { |
| + plan = o.Name |
| } |
| } |
| p.render(w, r, http.StatusOK, "abono/owner-end", map[string]any{ |
| "Title": "abono.owner.end_title", |
| "Member": m, |
| + "Label": p.label(r.Context(), m.Subject), |
| "Plan": plan, |
| "Action": r.URL.Path, |
| "MembersURL": abonoMembersPath, |
| @@ -611,9 +630,9 @@ func (p *AbonoPages) renderOffers(w http.ResponseWriter, r *http.Request, status |
| p.fail(w, r, "offers", err) |
| return |
| } |
| - var unknown []abono.Unrecognised |
| + var unknown []abonoUnrecognised |
| if p.Service.On() { |
| - if unknown, err = p.Service.Unrecognised(ctx); err != nil { |
| + if unknown, err = p.unrecognised(ctx); err != nil { |
| p.fail(w, r, "unrecognised", err) |
| return |
| } |
| @@ -633,25 +652,31 @@ func (p *AbonoPages) renderOffers(w http.ResponseWriter, r *http.Request, status |
| // POST /admin/offers/adopt: put an unrecognised subscription, and every |
| // other on its price, onto the posted plan. Its holder is found as a sync |
| -// finds one. |
| +// finds one. The form is on the members page and the offers page; a |
| +// posted from=members goes back to the first, anything else to the |
| +// second. |
| func (p *AbonoPages) adopt(w http.ResponseWriter, r *http.Request) { |
| ctx := r.Context() |
| sub, plan := r.PostFormValue("subscription"), r.PostFormValue("plan") |
| + back, again := abonoOffersPath, func(status int, errKey string) { p.renderOffers(w, r, status, errKey) } |
| + if r.PostFormValue("from") == "members" { |
| + back, again = abonoMembersPath, func(status int, errKey string) { p.renderMembers(w, r, status, abonoCompForm{}, errKey) } |
| + } |
| offers, err := p.Service.Offers(ctx) |
| if err != nil { |
| p.fail(w, r, "offers", err) |
| return |
| } |
| if !abonoIsPlan(offers, plan) { |
| - p.renderOffers(w, r, http.StatusUnprocessableEntity, "abono.error.free_plan") |
| + again(http.StatusUnprocessableEntity, "abono.error.unknown_plan") |
| return |
| } |
| if err := p.Service.Adopt(ctx, sub, "", plan); err != nil { |
| slog.ErrorContext(ctx, "abono pages: adopt", "subscription", sub, "err", err) |
| - p.renderOffers(w, r, http.StatusUnprocessableEntity, "abono.error.adopt_failed") |
| + again(http.StatusUnprocessableEntity, "abono.error.adopt_failed") |
| return |
| } |
| - http.Redirect(w, r, abonoOffersPath+"?done=adopt&plan="+url.QueryEscape(plan), http.StatusSeeOther) |
| + http.Redirect(w, r, back+"?done=adopt&plan="+url.QueryEscape(plan), http.StatusSeeOther) |
| } |
| // GET /admin/stripe: whether Stripe is connected, how, and the webhook's |
| @@ -675,6 +700,38 @@ func (p *AbonoPages) stripe(w http.ResponseWriter, r *http.Request) { |
| // ---------------------------------------------------------------- plumbing |
| +// abonoUnrecognised is a paying subscription no plan recognises, with |
| +// what to call its holder when it has one. |
| +type abonoUnrecognised struct { |
| + abono.Unrecognised |
| + Label string |
| +} |
| + |
| +func (p *AbonoPages) unrecognised(ctx context.Context) ([]abonoUnrecognised, error) { |
| + us, err := p.Service.Unrecognised(ctx) |
| + if err != nil { |
| + return nil, err |
| + } |
| + out := make([]abonoUnrecognised, len(us)) |
| + for i, u := range us { |
| + out[i] = abonoUnrecognised{Unrecognised: u} |
| + if u.Subject != "" { |
| + out[i].Label = p.label(ctx, u.Subject) |
| + } |
| + } |
| + return out, nil |
| +} |
| + |
| +// label is what the owner's pages call subject. |
| +func (p *AbonoPages) label(ctx context.Context, subject string) string { |
| + if p.Label != nil { |
| + if l := p.Label(ctx, subject); l != "" { |
| + return l |
| + } |
| + } |
| + return subject |
| +} |
| + |
| func (p *AbonoPages) subject(r *http.Request) (string, bool) { |
| if p.Subject == nil { |
| return "", false |
diff --git a/cmd/abono/pages/owner-end.html.tmpl b/cmd/abono/pages/owner-end.html.tmpl| index 9ab4722..e203a31 100644 |
| --- a/cmd/abono/pages/owner-end.html.tmpl |
| +++ b/cmd/abono/pages/owner-end.html.tmpl |
| @@ -5,6 +5,7 @@ |
| Data: |
| Title string, the page title's message key |
| Member abono.Member, the comp |
| + Label string, what to call the member |
| Plan string, the plan's name |
| Action string, where the form posts |
| MembersURL string, the members page |
| @@ -12,6 +13,6 @@ |
| {{define "content"}}{{$L := localizer $}} |
| {{template "back-nav" dict "Href" .MembersURL "Label" (T $L "abono.owner.members_title")}} |
| {{template "page-header" dict "L" $L "Title" (T $L .Title)}} |
| -<p>{{Tf $L "abono.owner.end_body" "plan" .Plan "member" .Member.Subject}}</p> |
| +<p>{{Tf $L "abono.owner.end_body" "plan" .Plan "member" .Label}}</p> |
| {{template "confirm-form" dict "L" $L "Action" .Action "Label" (T $L "abono.button.end_free_confirm") "Danger" true "CancelHref" .MembersURL "CancelLabel" (T $L "abono.button.keep_free")}} |
| {{end}} |
diff --git a/cmd/abono/pages/owner-members.html.tmpl b/cmd/abono/pages/owner-members.html.tmpl| index f797db7..421dd63 100644 |
| --- a/cmd/abono/pages/owner-members.html.tmpl |
| +++ b/cmd/abono/pages/owner-members.html.tmpl |
| @@ -1,27 +1,34 @@ |
| {{/* abono/owner-members: GET /admin/members, every holding with its |
| - status and where it came from, "sync with Stripe now", and the form |
| - that gives a free membership (a comp). A comp still granting has a |
| - link to its own confirm page, which ends it. A subscription held only |
| - by abono's 30-day backstop is flagged with the day access stops. |
| + status and where it came from, "sync with Stripe now", the paying |
| + subscriptions no plan recognises (each with a form that puts it onto |
| + a plan), and the form that gives a free membership (a comp). A comp |
| + still granting has a link to its own confirm page, which ends it. A |
| + subscription held only by abono's 30-day backstop is flagged with the |
| + day access stops. |
| Data: |
| Title string, the page title's message key |
| On bool, Stripe (or the sandbox) is configured |
| - Members []abonoMember, {Subject, Holding, EndURL} |
| + Members []abonoMember, {Subject, Label, Holding, EndURL}: |
| + Label is what to call the member |
| + Unrecognised []abonoUnrecognised, {SubscriptionID, PriceID, |
| + Subject, Label, ...}; empty while Stripe is off |
| Now time.Time, the moment holdings are described at |
| Offers []abono.Offer, the plans a comp can give |
| PlanOptions []abonoOption, the same as field-select options |
| + AdoptOptions []abonoOption, the plans, none selected, for adopting |
| Form abonoCompForm, {Member, Plan, Ends, Note, Errors}: the |
| comp form as posted, Errors a message key by field |
| SyncAction string, where "sync now" posts |
| CompAction string, where the comp form posts |
| + AdoptAction string, where an adopt form posts (with from=members) |
| StripeURL string, the Stripe page |
| Done *abonoNotice, {Kind, N, Plan}: what the last action did |
| Error string, a message key, or "" |
| L the request's localizer */}} |
| {{define "content"}}{{$L := localizer $}} |
| {{template "page-header" dict "L" $L "Title" (T $L .Title) "Sub" (T $L "abono.owner.members_intro")}} |
| -{{with .Done}}{{if eq .Kind "sync"}}{{if eq .N 1}}{{template "notice" (T $L "abono.owner.sync_done_one")}}{{else}}{{template "notice" (Tf $L "abono.owner.sync_done" "n" .N)}}{{end}}{{else if eq .Kind "comp"}}{{template "notice" (Tf $L "abono.owner.free_given" "plan" .Plan)}}{{else if eq .Kind "end"}}{{template "notice" (T $L "abono.owner.end_done")}}{{end}}{{end}} |
| +{{with .Done}}{{if eq .Kind "sync"}}{{if eq .N 1}}{{template "notice" (T $L "abono.owner.sync_done_one")}}{{else}}{{template "notice" (Tf $L "abono.owner.sync_done" "n" .N)}}{{end}}{{else if eq .Kind "comp"}}{{template "notice" (Tf $L "abono.owner.free_given" "plan" .Plan)}}{{else if eq .Kind "end"}}{{template "notice" (T $L "abono.owner.end_done")}}{{else if eq .Kind "adopt"}}{{template "notice" (Tf $L "abono.owner.adopted" "plan" .Plan)}}{{end}}{{end}} |
| {{with .Error}}{{template "callout" dict "L" $L "Tone" "negative" "Focus" true "Body" (T $L .)}}{{end}} |
| {{if .On}}<form rst-form-actions method="post" action="{{.SyncAction}}"><button type="submit" rst-btn>{{T $L "abono.button.sync"}}</button></form> |
| <p>{{T $L "abono.owner.sync_help"}}</p> |
| @@ -32,7 +39,7 @@ |
| <thead><tr><th scope="col">{{T $L "abono.owner.col_member"}}</th><th scope="col">{{T $L "abono.owner.col_plan"}}</th><th scope="col">{{T $L "abono.owner.col_status"}}</th><th scope="col">{{T $L "abono.owner.col_source"}}</th></tr></thead> |
| <tbody> |
| {{range .Members}}<tr> |
| -<td>{{.Subject}}</td> |
| +<td>{{.Label}}</td> |
| <td>{{.Plan}}</td> |
| <td>{{template "abono-status" dict "L" $L "Holding" .Holding "Now" $.Now}}{{if .Holding.Backstop}}{{$s := .Holding.Describe $.Now}} <small>{{$s.Sentence (T $L "abono.owner.backstop") ""}}</small>{{end}}</td> |
| <td>{{if eq .Source "comp"}}{{T $L "abono.owner.source_free"}}{{with .EndURL}} <a href="{{.}}">{{T $L "abono.button.end_free"}}</a>{{end}}{{else}}{{T $L "abono.owner.source_paid"}}{{end}}</td> |
| @@ -42,6 +49,22 @@ |
| </div> |
| {{else}}{{template "empty-state" dict "L" $L "Body" (T $L "abono.owner.members_empty")}} |
| {{end}} |
| +{{if .On}}<section rst-box aria-labelledby="abono-unrecognised-title"> |
| +<h2 id="abono-unrecognised-title">{{T $L "abono.owner.unrecognised_title"}}</h2> |
| +{{if .Unrecognised}}<p>{{T $L "abono.owner.unrecognised_body"}}</p> |
| +<p>{{T $L "abono.owner.unrecognised_warn"}}</p> |
| +{{range .Unrecognised}}<form rst-form method="post" action="{{$.AdoptAction}}" data-abono-unrecognised="{{.SubscriptionID}}"> |
| +<p><code>{{.SubscriptionID}}</code> <code>{{.PriceID}}</code> {{.Label}}</p> |
| +<input type="hidden" name="subscription" value="{{.SubscriptionID}}"> |
| +<input type="hidden" name="from" value="members"> |
| +{{template "field-select" dict "L" $L "ID" (printf "abono-adopt-%s" .SubscriptionID) "Name" "plan" "Label" (T $L "abono.owner.adopt_plan") "Options" $.AdoptOptions "Required" true}} |
| +<button type="submit" rst-btn>{{T $L "abono.button.adopt"}}</button> |
| +</form> |
| +{{end}} |
| +{{else}}<p>{{T $L "abono.owner.unrecognised_none"}}</p> |
| +{{end}} |
| +</section> |
| +{{end}} |
| {{if .Offers}}{{$memberErr := ""}}{{$planErr := ""}}{{with .Form.Errors}}{{with index . "member"}}{{$memberErr = T $L .}}{{end}}{{with index . "plan"}}{{$planErr = T $L .}}{{end}}{{end}}<section rst-box aria-labelledby="abono-free-title"> |
| <h2 id="abono-free-title">{{T $L "abono.owner.free_title"}}</h2> |
| <p>{{T $L "abono.owner.free_help"}}</p> |
diff --git a/cmd/abono/pages/owner-offers.html.tmpl b/cmd/abono/pages/owner-offers.html.tmpl| index 457d622..1ef14b1 100644 |
| --- a/cmd/abono/pages/owner-offers.html.tmpl |
| +++ b/cmd/abono/pages/owner-offers.html.tmpl |
| @@ -7,7 +7,8 @@ |
| Title string, the page title's message key |
| On bool, Stripe (or the sandbox) is configured |
| Offers []abono.Offer |
| - Unrecognised []abono.Unrecognised, empty while Stripe is off |
| + Unrecognised []abonoUnrecognised, {SubscriptionID, PriceID, |
| + Subject, Label, ...}; empty while Stripe is off |
| PlanOptions []abonoOption, the plans, as field-select options |
| AdoptAction string, where an adopt form posts |
| StripeURL string, the Stripe page |
| @@ -32,8 +33,8 @@ |
| <h2 id="abono-unrecognised-title">{{T $L "abono.owner.unrecognised_title"}}</h2> |
| {{if .Unrecognised}}<p>{{T $L "abono.owner.unrecognised_body"}}</p> |
| <p>{{T $L "abono.owner.unrecognised_warn"}}</p> |
| -{{range .Unrecognised}}<form rst-form method="post" action="{{$.AdoptAction}}"> |
| -<p><code>{{.SubscriptionID}}</code> <code>{{.PriceID}}</code> {{.Subject}}</p> |
| +{{range .Unrecognised}}<form rst-form method="post" action="{{$.AdoptAction}}" data-abono-unrecognised="{{.SubscriptionID}}"> |
| +<p><code>{{.SubscriptionID}}</code> <code>{{.PriceID}}</code> {{.Label}}</p> |
| <input type="hidden" name="subscription" value="{{.SubscriptionID}}"> |
| {{template "field-select" dict "L" $L "ID" (printf "abono-adopt-%s" .SubscriptionID) "Name" "plan" "Label" (T $L "abono.owner.adopt_plan") "Options" $.PlanOptions "Required" true}} |
| <button type="submit" rst-btn>{{T $L "abono.button.adopt"}}</button> |
diff --git a/example/README.md b/example/README.md| index 221f2f8..74e007a 100644 |
| --- a/example/README.md |
| +++ b/example/README.md |
| @@ -1,8 +1,8 @@ |
| # abono example |
| -A small rastrillo app that sells memberships with abono. It has password |
| -sign-in, the starter pages, and the sandbox in place of Stripe, so you can |
| -click through the whole flow without a Stripe account. |
| +A small rastrillo app that sells memberships with abono. It has sign-in by |
| +emailed link, the starter pages, and the sandbox in place of Stripe, so you |
| +can click through the whole flow without a Stripe account. |
| ## Run it |
| @@ -13,14 +13,17 @@ go run . |
| Open http://localhost:8080. It listens on 127.0.0.1 only. |
| -1. Sign up with any address and buy a plan. The checkout and billing pages |
| - are the sandbox's stand-ins for Stripe's: nothing is charged. |
| -2. Sign up as `owner@example.com` to see the owner's pages under `/admin`: |
| - members, free memberships, plans and prices, and the Stripe status. |
| +1. Sign in with any address. No mail is sent: the sign-in link is written |
| + to the log, so open it from there. |
| +2. Buy a plan. The checkout and billing pages are the sandbox's stand-ins |
| + for Stripe's: nothing is charged. |
| +3. Sign in as `owner@example.com` to see the owner's pages under `/admin`: |
| + members, free memberships, subscriptions on prices no plan has, plans |
| + and prices, and the Stripe status. |
| The words on the pages are message keys (`abono.pages.pricing_title`) until |
| -abono's English catalog lands; the layout and sign-in pages use a few |
| -`example.*` keys of their own. |
| +abono's English catalog lands. The layout has one key of its own, |
| +`example.signout`; the sign-in screen is rastrillo's. |
| Settings are environment variables; `main.go` lists them. Set `STRIPE_KEY` |
| and `STRIPE_WEBHOOK_SECRET` to use a Stripe test account instead of the |
| @@ -31,12 +34,12 @@ sandbox. |
| - `abono_pages.go` and `templates/abono/` were written by |
| `abono pages -pkg main -dir .` and are not edited here, so they stay |
| exactly what an app gets. abono's `cmd/abono` tests fail if they drift. |
| -- `app.go` wires it together: sessions, password sign-in, `abono.New` with |
| +- `app.go` wires it together: magic-link sign-in (rastrillo `auth`), `abono.New` with |
| the sandbox, the pages, and the webhook and daily-sync endpoints, which |
| `csrf.Exempt` lets through. |
| - `models.go` has the user table, and the `Holder` that makes a user for |
| - someone who pays before signing up. Signing up later with the same |
| - address takes over that user and its membership. |
| + someone who pays before signing in. The only way into that user is a |
| + sign-in link sent to the address it was made for. |
| ## Test it |
diff --git a/example/abono_pages.go b/example/abono_pages.go| index 043a253..f975d86 100644 |
| --- a/example/abono_pages.go |
| +++ b/example/abono_pages.go |
| @@ -78,6 +78,9 @@ type AbonoPages struct { |
| // for the owner's free-membership form. Nil, or not found, refuses |
| // the form. |
| SubjectForEmail func(ctx context.Context, email string) (subject string, ok bool) |
| + // Label is what the owner's pages call the person with this |
| + // subject: their address or name. Nil, or "", shows the subject. |
| + Label func(ctx context.Context, subject string) string |
| } |
| // Mount adds the pages to r. The reader's pages are open to anyone (the |
| @@ -368,6 +371,8 @@ func (p *AbonoPages) ownerOnly(next http.Handler) http.Handler { |
| // abonoMember is one row of the members table. |
| type abonoMember struct { |
| abono.Member |
| + // Label is what to call the member (AbonoPages.Label). |
| + Label string |
| // EndURL is the confirm page that ends a comp still granting; "" |
| // for anything else. |
| EndURL string |
| @@ -430,26 +435,36 @@ func (p *AbonoPages) renderMembers(w http.ResponseWriter, r *http.Request, statu |
| p.fail(w, r, "offers", err) |
| return |
| } |
| + var unknown []abonoUnrecognised |
| + if p.Service.On() { |
| + if unknown, err = p.unrecognised(ctx); err != nil { |
| + p.fail(w, r, "unrecognised", err) |
| + return |
| + } |
| + } |
| rows := make([]abonoMember, len(ms)) |
| for i, m := range ms { |
| - rows[i] = abonoMember{Member: m} |
| + rows[i] = abonoMember{Member: m, Label: p.label(ctx, m.Subject)} |
| if m.Source == abono.SourceComp && m.Grants { |
| rows[i].EndURL = abonoMembersPath + "/comps/" + strconv.FormatInt(m.CompID, 10) + "/end" |
| } |
| } |
| p.render(w, r, status, "abono/owner-members", map[string]any{ |
| - "Title": "abono.owner.members_title", |
| - "On": p.Service.On(), |
| - "Members": rows, |
| - "Now": p.Service.Now(), |
| - "Offers": offers, |
| - "PlanOptions": abonoPlanOptions(offers, form.Plan), |
| - "Form": form, |
| - "SyncAction": abonoMembersPath + "/sync", |
| - "CompAction": abonoMembersPath + "/comps", |
| - "StripeURL": abonoStripePath, |
| - "Done": abonoReadNotice(r, offers), |
| - "Error": errKey, |
| + "Title": "abono.owner.members_title", |
| + "On": p.Service.On(), |
| + "Members": rows, |
| + "Unrecognised": unknown, |
| + "Now": p.Service.Now(), |
| + "Offers": offers, |
| + "PlanOptions": abonoPlanOptions(offers, form.Plan), |
| + "AdoptOptions": abonoPlanOptions(offers, ""), |
| + "Form": form, |
| + "SyncAction": abonoMembersPath + "/sync", |
| + "CompAction": abonoMembersPath + "/comps", |
| + "AdoptAction": abonoOffersPath + "/adopt", |
| + "StripeURL": abonoStripePath, |
| + "Done": abonoReadNotice(r, offers), |
| + "Error": errKey, |
| }) |
| } |
| @@ -498,7 +513,7 @@ func (p *AbonoPages) comp(w http.ResponseWriter, r *http.Request) { |
| subject, found = p.SubjectForEmail(ctx, form.Member) |
| } |
| if !found { |
| - form.Errors["member"] = "abono.error.signin_needed" |
| + form.Errors["member"] = "abono.owner.free_no_member" |
| } |
| if len(form.Errors) > 0 { |
| p.renderMembers(w, r, http.StatusUnprocessableEntity, form, "") |
| @@ -564,17 +579,21 @@ func (p *AbonoPages) endCompPage(w http.ResponseWriter, r *http.Request) { |
| http.NotFound(w, r) |
| return |
| } |
| + offers, err := p.Service.Offers(r.Context()) |
| + if err != nil { |
| + p.fail(w, r, "offers", err) |
| + return |
| + } |
| plan := m.Plan |
| - if offers, err := p.Service.Offers(r.Context()); err == nil { |
| - for _, o := range offers { |
| - if o.Plan == m.Plan { |
| - plan = o.Name |
| - } |
| + for _, o := range offers { |
| + if o.Plan == m.Plan { |
| + plan = o.Name |
| } |
| } |
| p.render(w, r, http.StatusOK, "abono/owner-end", map[string]any{ |
| "Title": "abono.owner.end_title", |
| "Member": m, |
| + "Label": p.label(r.Context(), m.Subject), |
| "Plan": plan, |
| "Action": r.URL.Path, |
| "MembersURL": abonoMembersPath, |
| @@ -611,9 +630,9 @@ func (p *AbonoPages) renderOffers(w http.ResponseWriter, r *http.Request, status |
| p.fail(w, r, "offers", err) |
| return |
| } |
| - var unknown []abono.Unrecognised |
| + var unknown []abonoUnrecognised |
| if p.Service.On() { |
| - if unknown, err = p.Service.Unrecognised(ctx); err != nil { |
| + if unknown, err = p.unrecognised(ctx); err != nil { |
| p.fail(w, r, "unrecognised", err) |
| return |
| } |
| @@ -633,25 +652,31 @@ func (p *AbonoPages) renderOffers(w http.ResponseWriter, r *http.Request, status |
| // POST /admin/offers/adopt: put an unrecognised subscription, and every |
| // other on its price, onto the posted plan. Its holder is found as a sync |
| -// finds one. |
| +// finds one. The form is on the members page and the offers page; a |
| +// posted from=members goes back to the first, anything else to the |
| +// second. |
| func (p *AbonoPages) adopt(w http.ResponseWriter, r *http.Request) { |
| ctx := r.Context() |
| sub, plan := r.PostFormValue("subscription"), r.PostFormValue("plan") |
| + back, again := abonoOffersPath, func(status int, errKey string) { p.renderOffers(w, r, status, errKey) } |
| + if r.PostFormValue("from") == "members" { |
| + back, again = abonoMembersPath, func(status int, errKey string) { p.renderMembers(w, r, status, abonoCompForm{}, errKey) } |
| + } |
| offers, err := p.Service.Offers(ctx) |
| if err != nil { |
| p.fail(w, r, "offers", err) |
| return |
| } |
| if !abonoIsPlan(offers, plan) { |
| - p.renderOffers(w, r, http.StatusUnprocessableEntity, "abono.error.free_plan") |
| + again(http.StatusUnprocessableEntity, "abono.error.unknown_plan") |
| return |
| } |
| if err := p.Service.Adopt(ctx, sub, "", plan); err != nil { |
| slog.ErrorContext(ctx, "abono pages: adopt", "subscription", sub, "err", err) |
| - p.renderOffers(w, r, http.StatusUnprocessableEntity, "abono.error.adopt_failed") |
| + again(http.StatusUnprocessableEntity, "abono.error.adopt_failed") |
| return |
| } |
| - http.Redirect(w, r, abonoOffersPath+"?done=adopt&plan="+url.QueryEscape(plan), http.StatusSeeOther) |
| + http.Redirect(w, r, back+"?done=adopt&plan="+url.QueryEscape(plan), http.StatusSeeOther) |
| } |
| // GET /admin/stripe: whether Stripe is connected, how, and the webhook's |
| @@ -675,6 +700,38 @@ func (p *AbonoPages) stripe(w http.ResponseWriter, r *http.Request) { |
| // ---------------------------------------------------------------- plumbing |
| +// abonoUnrecognised is a paying subscription no plan recognises, with |
| +// what to call its holder when it has one. |
| +type abonoUnrecognised struct { |
| + abono.Unrecognised |
| + Label string |
| +} |
| + |
| +func (p *AbonoPages) unrecognised(ctx context.Context) ([]abonoUnrecognised, error) { |
| + us, err := p.Service.Unrecognised(ctx) |
| + if err != nil { |
| + return nil, err |
| + } |
| + out := make([]abonoUnrecognised, len(us)) |
| + for i, u := range us { |
| + out[i] = abonoUnrecognised{Unrecognised: u} |
| + if u.Subject != "" { |
| + out[i].Label = p.label(ctx, u.Subject) |
| + } |
| + } |
| + return out, nil |
| +} |
| + |
| +// label is what the owner's pages call subject. |
| +func (p *AbonoPages) label(ctx context.Context, subject string) string { |
| + if p.Label != nil { |
| + if l := p.Label(ctx, subject); l != "" { |
| + return l |
| + } |
| + } |
| + return subject |
| +} |
| + |
| func (p *AbonoPages) subject(r *http.Request) (string, bool) { |
| if p.Subject == nil { |
| return "", false |
diff --git a/example/app.go b/example/app.go| index 8069c38..79adcc4 100644 |
| --- a/example/app.go |
| +++ b/example/app.go |
| @@ -8,17 +8,19 @@ import ( |
| "log/slog" |
| "maps" |
| "net/http" |
| + "net/url" |
| "strings" |
| "github.com/go-chi/chi/v5" |
| "amadan.net/rastrillo/rastrillo" |
| + "amadan.net/rastrillo/rastrillo/auth" |
| "amadan.net/rastrillo/rastrillo/csrf" |
| "amadan.net/rastrillo/rastrillo/db" |
| "amadan.net/rastrillo/rastrillo/locales" |
| _ "amadan.net/rastrillo/rastrillo/locales/en" |
| + "amadan.net/rastrillo/rastrillo/mail" |
| "amadan.net/rastrillo/rastrillo/migrate" |
| - "amadan.net/rastrillo/rastrillo/password" |
| "amadan.net/rastrillo/rastrillo/sessions" |
| "amadan.net/rastrillo/rastrillo/ui" |
| @@ -38,6 +40,11 @@ type Config struct { |
| // Origin is the site's address. With no Key it must be on this |
| // machine (localhost, 127.0.0.1): the sandbox refuses anything else. |
| Origin string |
| + // InstanceKey seals sign-in state (auth.Config.InstanceKey). |
| + InstanceKey string |
| + // Mailer sends the sign-in links. Nil logs them, which is the |
| + // development setup: the link to open is in the log. |
| + Mailer mail.Sender |
| // Owner is the address of the account that may use the owner's pages. |
| Owner string |
| // Plans is what is for sale, in abono.ParsePlans' format. |
| @@ -46,7 +53,12 @@ type Config struct { |
| // sandbox stands in for Stripe. |
| Key string |
| WebhookSecrets []string |
| - Logger *slog.Logger |
| + // Abono, when set, adjusts abono's configuration last (tests point |
| + // it at abonotest's Stripe with abonotest.Stripe.Config). |
| + Abono func(abono.Config) abono.Config |
| + // Catalog is the base message catalog; abono.Catalog() when nil. |
| + Catalog rastrillo.Catalog |
| + Logger *slog.Logger |
| } |
| // Options is the example as rastrillo serves it: main adds an address, |
| @@ -55,6 +67,9 @@ func Options(cfg Config) (rastrillo.Options, error) { |
| if cfg.Logger == nil { |
| cfg.Logger = slog.Default() |
| } |
| + if cfg.Catalog == nil { |
| + cfg.Catalog = abono.Catalog() |
| + } |
| mux, err := App(cfg) |
| if err != nil { |
| return rastrillo.Options{}, err |
| @@ -63,18 +78,21 @@ func Options(cfg Config) (rastrillo.Options, error) { |
| Mux: mux, |
| Locales: []string{"en"}, |
| DefaultLocale: "en", |
| - BaseCatalog: abono.Catalog(), |
| + BaseCatalog: cfg.Catalog, |
| Logger: cfg.Logger, |
| }, nil |
| } |
| -// App wires the example: its schema and abono's, sessions and password |
| -// sign-in, abono with the sandbox, the generated pages, and the two |
| -// machine endpoints (Stripe's webhook, carlos's daily tick), which |
| -// csrf.Protect lets through because each checks its own credential. |
| +// App wires the example: its schema and abono's, magic-link sign-in, |
| +// abono with the sandbox, the generated pages, and the two machine |
| +// endpoints (Stripe's webhook, carlos's daily tick), which csrf.Protect |
| +// lets through because each checks its own credential. |
| func App(cfg Config) (*http.ServeMux, error) { |
| ctx := context.Background() |
| logger := cfg.Logger |
| + if cfg.Catalog == nil { |
| + cfg.Catalog = abono.Catalog() |
| + } |
| if _, err := migrate.Apply(ctx, cfg.DB, BootSchema); err != nil { |
| return nil, err |
| } |
| @@ -82,16 +100,43 @@ func App(cfg Config) (*http.ServeMux, error) { |
| if err != nil { |
| return nil, err |
| } |
| - sess, err := sessions.New(sessions.Config{DB: writer, Origin: cfg.Origin, Logger: logger}) |
| + g := cfg.DB.G |
| + |
| + a := &app{db: g, owner: normalEmail(cfg.Owner), logger: logger} |
| + if u, err := url.Parse(cfg.Origin); err == nil { |
| + a.host = u.Host |
| + } |
| + mailer := cfg.Mailer |
| + if mailer == nil { |
| + mailer = mail.Logged(logger) |
| + } |
| + a.auth, err = auth.New(auth.Config{ |
| + DB: writer, |
| + Origin: cfg.Origin, |
| + InstanceKey: cfg.InstanceKey, |
| + Mailer: mailer, |
| + // A verified address signs in as its user, made on first sign-in. |
| + // The address is the only key: a user abono's Holder made for a |
| + // buyer is reached by a link to that buyer's mail, and only so. |
| + SubjectFor: subjectForAddress(g), |
| + SigninScreen: true, |
| + RenderConfirm: func(w http.ResponseWriter, r *http.Request, _ auth.ConfirmPageData) { a.signinPage(w, r) }, |
| + // No keymail federation here: every sign-in is a link by email, |
| + // and no address's domain is ever looked up. |
| + KeymailServers: []string{}, |
| + // The example skips pow's front door to stay small; a real app |
| + // keeps it (see rastrillo's examples/notes). |
| + ProofOff: true, |
| + Logger: logger, |
| + }) |
| if err != nil { |
| return nil, err |
| } |
| - svc, err := abono.New(abono.Config{ |
| - DB: cfg.DB.G, |
| + ac := abono.Config{ |
| + DB: g, |
| Key: cfg.Key, |
| WebhookSecrets: cfg.WebhookSecrets, |
| - Sandbox: cfg.Key == "", |
| Origin: cfg.Origin, |
| Holder: holder, |
| // A real app moves roles here (Patrix settles a reader's role). |
| @@ -101,7 +146,12 @@ func App(cfg Config) (*http.ServeMux, error) { |
| return nil |
| }, |
| Logger: logger, |
| - }) |
| + } |
| + if cfg.Abono != nil { |
| + ac = cfg.Abono(ac) |
| + } |
| + ac.Sandbox = ac.Key == "" |
| + svc, err := abono.New(ac) |
| if err != nil { |
| return nil, err |
| } |
| @@ -126,44 +176,30 @@ func App(cfg Config) (*http.ServeMux, error) { |
| if err != nil { |
| return nil, err |
| } |
| - pages, err := newPages(locCfg) |
| - if err != nil { |
| + if a.pages, err = newPages(locCfg, cfg.Catalog); err != nil { |
| return nil, err |
| } |
| - a := &app{db: cfg.DB.G, pages: pages, owner: strings.ToLower(strings.TrimSpace(cfg.Owner)), logger: logger} |
| a.abono = &AbonoPages{ |
| Service: svc, |
| Render: a.render, |
| Subject: subject, |
| IsOwner: a.isOwner, |
| - SubjectForEmail: userByEmail(cfg.DB.G), |
| - } |
| - |
| - ph, err := password.New(password.Config{ |
| - Sessions: sess, |
| - Lookup: lookupUser(cfg.DB.G), |
| - Create: createUser(cfg.DB.G), |
| - RenderSignin: a.renderSignin, |
| - RenderSignup: a.renderSignup, |
| - // The example skips pow's front door to stay small; a real app |
| - // keeps it (see rastrillo's examples/notes). |
| - ProofOff: true, |
| - Logger: logger, |
| - }) |
| - if err != nil { |
| - return nil, err |
| + SubjectForEmail: userByEmail(g), |
| + Label: emailOf(g), |
| } |
| r := chi.NewRouter() |
| r.Use(csrf.Protect(cfg.Origin, csrf.Exempt(abono.WebhookPath, abono.SyncPath))) |
| - r.Use(sess.Middleware) |
| + r.Use(a.resolveSession) |
| r.Get("/", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, abonoPricingPath, http.StatusFound) }) |
| - r.Get("/signin", ph.SigninPage) |
| - r.Post("/signin", ph.Signin) |
| - r.Get("/signup", ph.SignupPage) |
| - r.Post("/signup", ph.Signup) |
| - r.Post("/signout", ph.Signout) |
| - r.With(sess.Require).Get(abonoAccountPath, a.account) |
| + r.Get("/signin", a.signinPage) |
| + r.Post("/signin", a.auth.Begin) |
| + r.Post("/signin/forget", a.auth.Forget) |
| + r.Get("/auth/callback", a.auth.Callback) |
| + r.Get("/auth/verify", a.auth.Verify) |
| + r.Post("/auth/verify", a.auth.Verify) |
| + r.Post("/signout", a.auth.Signout) |
| + r.With(a.auth.RequireSession).Get(abonoAccountPath, a.account) |
| a.abono.Mount(r) |
| r.Post(abono.WebhookPath, svc.ServeWebhook) |
| @@ -180,7 +216,7 @@ func App(cfg Config) (*http.ServeMux, error) { |
| }) |
| mux := http.NewServeMux() |
| - mux.Handle("/", pages.Middleware(r)) |
| + mux.Handle("/", a.pages.Middleware(r)) |
| return mux, nil |
| } |
| @@ -210,7 +246,7 @@ func (f withPartials) Open(name string) (fs.File, error) { |
| // newPages is every page: the example's own, and the generated ones, |
| // over one layout, with ui's partials, abono's, and the generated |
| // account section in the base. |
| -func newPages(cfg locales.Config) (*ui.Pages, error) { |
| +func newPages(cfg locales.Config, catalog rastrillo.Catalog) (*ui.Pages, error) { |
| partials, err := fs.Glob(abono.Templates(), "*.html") |
| if err != nil { |
| return nil, err |
| @@ -220,7 +256,7 @@ func newPages(cfg locales.Config) (*ui.Pages, error) { |
| shared = append(shared, partialsDir+p) |
| } |
| files := map[string]ui.PageFile{} |
| - for _, name := range []string{"signin", "signup", "account"} { |
| + for _, name := range []string{"signin", "account"} { |
| files[name] = ui.PageFile{Path: "templates/" + name + ".html"} |
| } |
| for _, name := range []string{"pricing", "plan", "done", "owner-members", "owner-end", "owner-offers", "owner-stripe"} { |
| @@ -232,19 +268,33 @@ func newPages(cfg locales.Config) (*ui.Pages, error) { |
| Shared: shared, |
| Pages: files, |
| Locale: cfg, |
| - Catalog: abono.Catalog(), |
| + Catalog: catalog, |
| }) |
| } |
| type app struct { |
| db *gorm.DB |
| + auth *auth.Auth |
| pages *ui.Pages |
| abono *AbonoPages |
| owner string |
| + host string |
| logger *slog.Logger |
| } |
| -// subject is the signed-in person's subject, the user's id. |
| +// resolveSession puts the signed-in person, if any, on every request, |
| +// for sessions.Current: public pages (the plans, the layout's nav) want |
| +// to know who is there without requiring anyone. |
| +func (a *app) resolveSession(next http.Handler) http.Handler { |
| + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if id, ok := a.auth.SessionFrom(r); ok { |
| + r = sessions.WithSession(r, sessions.Session{Subject: id.Address, Method: string(id.Method), AuthTime: id.AuthTime, At: id.At}) |
| + } |
| + next.ServeHTTP(w, r) |
| + }) |
| +} |
| + |
| +// subject is the signed-in person's subject, their user's id. |
| func subject(r *http.Request) (string, bool) { |
| s, ok := sessions.Current(r) |
| return s.Subject, ok |
| @@ -253,7 +303,7 @@ func subject(r *http.Request) (string, bool) { |
| // isOwner says whether the signed-in person is the owner, by address. |
| func (a *app) isOwner(r *http.Request) bool { |
| s, ok := sessions.Current(r) |
| - return ok && a.owner != "" && strings.EqualFold(emailOf(a.db, r.Context(), s.Subject), a.owner) |
| + return ok && a.owner != "" && emailOf(a.db)(r.Context(), s.Subject) == a.owner |
| } |
| // render executes name with data (a map) plus what the layout reads: the |
| @@ -279,12 +329,18 @@ func (a *app) render(w http.ResponseWriter, r *http.Request, name string, data a |
| buf.WriteTo(w) |
| } |
| -func (a *app) renderSignin(w http.ResponseWriter, r *http.Request, d password.PageData) { |
| - a.render(w, r, "signin", map[string]any{"Title": "rastrillo.ui.signin_confirm_submit", "Form": d}) |
| -} |
| - |
| -func (a *app) renderSignup(w http.ResponseWriter, r *http.Request, d password.PageData) { |
| - a.render(w, r, "signup", map[string]any{"Title": "example.signup", "Form": d}) |
| +// signinPage is the sign-in screen, ui's signin partial fed by auth: ask |
| +// for an address, say a link was sent, and (as auth's RenderConfirm) the |
| +// page the emailed link lands on, whose button signs in. |
| +func (a *app) signinPage(w http.ResponseWriter, r *http.Request) { |
| + st := a.auth.SigninState(r) |
| + a.auth.PrepareSigninResponse(w, st) |
| + a.render(w, r, "signin", map[string]any{ |
| + "Title": "rastrillo.ui.signin_confirm_submit", |
| + "State": st, |
| + // The site's own address names it: there is no name to translate. |
| + "Brand": map[string]any{"Name": a.host}, |
| + }) |
| } |
| // account is the example's account page: just abono's section. |
diff --git a/example/app_test.go b/example/app_test.go| index e4801d3..005b089 100644 |
| --- a/example/app_test.go |
| +++ b/example/app_test.go |
| @@ -1,27 +1,112 @@ |
| package main |
| import ( |
| + "context" |
| "io" |
| + "io/fs" |
| "log/slog" |
| "net/http" |
| "net/http/cookiejar" |
| "net/http/httptest" |
| "net/url" |
| + "os" |
| + "path" |
| "path/filepath" |
| "regexp" |
| "strings" |
| + "sync" |
| "testing" |
| + "time" |
| "amadan.net/rastrillo/rastrillo" |
| "amadan.net/rastrillo/rastrillo/db" |
| + |
| + "amadan.net/rastrillo/abono" |
| + "amadan.net/rastrillo/abono/abonotest" |
| ) |
| const owner = "owner@example.com" |
| -// newApp serves the whole example, as main does, over a fresh database, |
| -// with the sandbox standing in for Stripe. The server's own address is |
| -// the origin, so a POST carrying it as Origin passes csrf.Protect. |
| -func newApp(t *testing.T) *httptest.Server { |
| +// keyLiteral is a message key written as a string literal. |
| +var keyLiteral = regexp.MustCompile(`"((?:abono|example|rastrillo)\.[a-z][a-z_.]*[a-z])"`) |
| + |
| +// testCatalog maps every message key the example's pages, abono's |
| +// partials and the generated Go name to "T:<key>", so a test reads |
| +// which sentence a page chose whatever English the shipped catalogs |
| +// hold. say(key) is what a page then shows for key. |
| +var testCatalog = sync.OnceValue(func() rastrillo.Catalog { |
| + cat := rastrillo.Catalog{} |
| + add := func(src []byte) { |
| + for _, m := range keyLiteral.FindAllSubmatch(src, -1) { |
| + cat[string(m[1])] = say(string(m[1])) |
| + } |
| + } |
| + for _, fsys := range []fs.FS{os.DirFS("."), abono.Templates()} { |
| + err := fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error { |
| + if err != nil || d.IsDir() || (path.Ext(p) != ".html" && p != "abono_pages.go") { |
| + return err |
| + } |
| + src, err := fs.ReadFile(fsys, p) |
| + add(src) |
| + return err |
| + }) |
| + if err != nil { |
| + panic(err) |
| + } |
| + } |
| + for _, k := range []string{abono.StatusActive, abono.StatusRenews, abono.StatusCancelling, abono.StatusFailing, |
| + abono.StatusFailingNoDate, abono.StatusEnded, abono.StatusComp, abono.StatusCompUntil} { |
| + cat[k] = say(k) |
| + } |
| + return cat |
| +}) |
| + |
| +func say(key string) string { return "T:" + key } |
| + |
| +// inbox is the example's mailer in tests: it keeps every message, so a |
| +// test can follow the link the way the address's owner would. |
| +type inbox struct { |
| + mu sync.Mutex |
| + mail map[string][]string |
| +} |
| + |
| +func (b *inbox) Send(_ context.Context, to, _, body string) error { |
| + b.mu.Lock() |
| + defer b.mu.Unlock() |
| + if b.mail == nil { |
| + b.mail = map[string][]string{} |
| + } |
| + b.mail[strings.ToLower(to)] = append(b.mail[strings.ToLower(to)], body) |
| + return nil |
| +} |
| + |
| +var magicLink = regexp.MustCompile(`https?://\S+/auth/verify\?token=[^\s]+`) |
| + |
| +// link is the newest sign-in link sent to address. |
| +func (b *inbox) link(t testing.TB, address string) string { |
| + t.Helper() |
| + b.mu.Lock() |
| + defer b.mu.Unlock() |
| + msgs := b.mail[address] |
| + if len(msgs) == 0 { |
| + t.Fatalf("no mail for %s", address) |
| + } |
| + l := magicLink.FindString(msgs[len(msgs)-1]) |
| + if l == "" { |
| + t.Fatalf("no sign-in link in the mail to %s:\n%s", address, msgs[len(msgs)-1]) |
| + } |
| + return l |
| +} |
| + |
| +func (b *inbox) count(address string) int { |
| + b.mu.Lock() |
| + defer b.mu.Unlock() |
| + return len(b.mail[address]) |
| +} |
| + |
| +// testConfig is the example as tests run it: a fresh database, quiet |
| +// logs, the test catalog, mail into box, and the sandbox for Stripe. |
| +func testConfig(t testing.TB, origin string, box *inbox) Config { |
| t.Helper() |
| quiet := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| d, err := db.Open(filepath.Join(t.TempDir(), "example.db"), quiet) |
| @@ -29,8 +114,27 @@ func newApp(t *testing.T) *httptest.Server { |
| t.Fatalf("db.Open: %v", err) |
| } |
| t.Cleanup(func() { d.Close() }) |
| + return Config{DB: d, Origin: origin, Owner: owner, InstanceKey: "example-test-instance-key", |
| + Mailer: box, Catalog: testCatalog(), Logger: quiet} |
| +} |
| + |
| +type testApp struct { |
| + *httptest.Server |
| + inbox *inbox |
| +} |
| + |
| +// newApp serves the whole example, as main does. The server's own |
| +// address is the origin, so a POST carrying it as Origin passes |
| +// csrf.Protect. adjust, when given, changes the configuration first. |
| +func newApp(t *testing.T, adjust ...func(*Config)) *testApp { |
| + t.Helper() |
| ts := httptest.NewUnstartedServer(nil) |
| - opts, err := Options(Config{DB: d, Origin: "http://" + ts.Listener.Addr().String(), Owner: owner, Logger: quiet}) |
| + app := &testApp{Server: ts, inbox: &inbox{}} |
| + cfg := testConfig(t, "http://"+ts.Listener.Addr().String(), app.inbox) |
| + for _, f := range adjust { |
| + f(&cfg) |
| + } |
| + opts, err := Options(cfg) |
| if err != nil { |
| t.Fatalf("Options: %v", err) |
| } |
| @@ -42,21 +146,21 @@ func newApp(t *testing.T) *httptest.Server { |
| ts.Config.Handler = h |
| ts.Start() |
| t.Cleanup(ts.Close) |
| - return ts |
| + return app |
| } |
| // client is one visitor: a cookie jar, and the Origin a browser's form |
| // POST carries. It never follows a redirect on its own. |
| type client struct { |
| - t *testing.T |
| - ts *httptest.Server |
| - c *http.Client |
| + t *testing.T |
| + app *testApp |
| + c *http.Client |
| } |
| -func newClient(t *testing.T, ts *httptest.Server) *client { |
| +func newClient(t *testing.T, app *testApp) *client { |
| t.Helper() |
| jar, _ := cookiejar.New(nil) |
| - return &client{t: t, ts: ts, c: &http.Client{Jar: jar, CheckRedirect: func(*http.Request, []*http.Request) error { |
| + return &client{t: t, app: app, c: &http.Client{Jar: jar, CheckRedirect: func(*http.Request, []*http.Request) error { |
| return http.ErrUseLastResponse |
| }}} |
| } |
| @@ -68,7 +172,7 @@ func (cl *client) do(method, path string, form url.Values, origin string) (int, |
| cl.t.Helper() |
| u := path |
| if strings.HasPrefix(path, "/") { |
| - u = cl.ts.URL + path |
| + u = cl.app.URL + path |
| } |
| var body io.Reader |
| if form != nil { |
| @@ -100,25 +204,48 @@ func (cl *client) get(path string) (int, string) { |
| } |
| // post is a same-origin form POST, as a browser sends one; it returns |
| -// the status and where it redirects. |
| +// the status, where it redirects and the body. |
| func (cl *client) post(path string, form url.Values) (int, string, string) { |
| cl.t.Helper() |
| if form == nil { |
| form = url.Values{} |
| } |
| - return cl.do(http.MethodPost, path, form, cl.ts.URL) |
| + return cl.do(http.MethodPost, path, form, cl.app.URL) |
| } |
| -// signup makes an account and leaves the client signed in to it. |
| -func (cl *client) signup(email string) { |
| +// askForLink submits address on the sign-in page. |
| +func (cl *client) askForLink(address string) { |
| cl.t.Helper() |
| - status, _, body := cl.post("/signup", url.Values{"email": {email}, "password": {"a long enough password"}}) |
| - if status != http.StatusSeeOther { |
| - cl.t.Fatalf("signup %s: %d\n%s", email, status, body) |
| + status, loc, body := cl.post("/signin", url.Values{"address": {address}}) |
| + if status != http.StatusSeeOther || !strings.HasPrefix(loc, "/signin?sent=1") { |
| + cl.t.Fatalf("POST /signin for %s: %d to %q\n%s", address, status, loc, body) |
| } |
| } |
| -func want(t *testing.T, what string, got, wantStatus int, body string, parts ...string) { |
| +// follow opens a sign-in link and presses its Sign in button: what the |
| +// address's owner does from their inbox. |
| +func (cl *client) follow(link string) { |
| + cl.t.Helper() |
| + status, page := cl.get(link) |
| + want(cl.t, "the link's confirm page", status, http.StatusOK, page, `action="/auth/verify"`, `name="token"`) |
| + u, err := url.Parse(link) |
| + if err != nil { |
| + cl.t.Fatal(err) |
| + } |
| + status, loc, body := cl.post("/auth/verify", url.Values{"token": {u.Query().Get("token")}}) |
| + if status != http.StatusSeeOther || strings.Contains(loc, "err=") { |
| + cl.t.Fatalf("redeem the link: %d to %q\n%s", status, loc, body) |
| + } |
| +} |
| + |
| +// signin signs the client in as address by its emailed link. |
| +func (cl *client) signin(address string) { |
| + cl.t.Helper() |
| + cl.askForLink(address) |
| + cl.follow(cl.app.inbox.link(cl.t, address)) |
| +} |
| + |
| +func want(t testing.TB, what string, got, wantStatus int, body string, parts ...string) { |
| t.Helper() |
| if got != wantStatus { |
| t.Fatalf("%s: status %d, want %d\n%s", what, got, wantStatus, body) |
| @@ -137,89 +264,116 @@ var payAction = regexp.MustCompile(`action="(/abono/sandbox/checkout/[^"/]+/pay) |
| func (cl *client) checkout(plan, email string) string { |
| cl.t.Helper() |
| status, loc, body := cl.post("/subscribe/"+plan, nil) |
| - if status != http.StatusSeeOther || !strings.HasPrefix(loc, cl.ts.URL+"/abono/sandbox/checkout/") { |
| + if status != http.StatusSeeOther || !strings.HasPrefix(loc, cl.app.URL+"/abono/sandbox/checkout/") { |
| cl.t.Fatalf("POST /subscribe/%s: %d to %q\n%s", plan, status, loc, body) |
| } |
| status, page := cl.get(loc) |
| - want(cl.t, "the sandbox checkout", status, http.StatusOK, page, "abono.sandbox.pay") |
| m := payAction.FindStringSubmatch(page) |
| - if m == nil { |
| - cl.t.Fatalf("no pay form on the sandbox page:\n%s", page) |
| + if status != http.StatusOK || m == nil { |
| + cl.t.Fatalf("the sandbox checkout: %d, no pay form:\n%s", status, page) |
| } |
| form := url.Values{} |
| if email != "" { |
| form.Set("email", email) |
| } |
| status, back, body := cl.post(m[1], form) |
| - if status != http.StatusSeeOther || !strings.HasPrefix(back, cl.ts.URL+"/subscribe/done?session_id=") { |
| + if status != http.StatusSeeOther || !strings.HasPrefix(back, cl.app.URL+"/subscribe/done?session_id=") { |
| cl.t.Fatalf("pay: %d to %q\n%s", status, back, body) |
| } |
| return back |
| } |
| -// TestSandboxSubscribe is the reader's whole flow over HTTP: the plans, |
| -// Checkout on the sandbox's page, Checkout's return, and the account |
| -// page showing the membership. |
| +// TestSandboxSubscribe is the reader's whole flow over HTTP: sign in by |
| +// link, the plans, Checkout on the sandbox's page, Checkout's return, |
| +// and the account page showing the membership. |
| func TestSandboxSubscribe(t *testing.T) { |
| - ts := newApp(t) |
| - reader := newClient(t, ts) |
| - reader.signup("reader@example.com") |
| + app := newApp(t) |
| + reader := newClient(t, app) |
| + reader.signin("reader@example.com") |
| status, body := reader.get("/subscribe") |
| want(t, "the plans", status, http.StatusOK, body, |
| - "abono.pages.pricing_title", `action="/subscribe/annual"`, `action="/subscribe/monthly"`) |
| + say("abono.pages.pricing_title"), `action="/subscribe/annual"`, `action="/subscribe/monthly"`) |
| status, body = reader.get("/subscribe/annual") |
| - want(t, "the annual plan", status, http.StatusOK, body, `action="/subscribe/annual"`, `name="code"`, "abono.pages.plan_back") |
| + want(t, "the annual plan", status, http.StatusOK, body, `action="/subscribe/annual"`, `name="code"`, `href="/subscribe"`) |
| status, body = reader.get("/account") |
| - want(t, "the account before paying", status, http.StatusOK, body, "abono.pages.account_none", "abono.button.see_plans") |
| + want(t, "the account before paying", status, http.StatusOK, body, say("abono.pages.account_none"), `href="/subscribe"`) |
| done := reader.checkout("annual", "") |
| status, body = reader.get(done) |
| - want(t, "Checkout's return", status, http.StatusOK, body, "abono.pages.done_paid", "abono.button.go_to_account") |
| + want(t, "Checkout's return", status, http.StatusOK, body, say("abono.pages.done_paid"), `href="/account"`) |
| status, body = reader.get("/account") |
| want(t, "the account after paying", status, http.StatusOK, body, |
| - "<strong>annual</strong>", `rst-tone="positive"`, "abono.status.renews", `action="/account/billing"`) |
| + "<strong>annual</strong>", `rst-tone="positive"`, say(abono.StatusRenews), `action="/account/billing"`) |
| // The billing button opens the sandbox's portal. |
| st, loc, _ := reader.post("/account/billing", nil) |
| - if st != http.StatusSeeOther || !strings.HasPrefix(loc, ts.URL+"/abono/sandbox/portal/") { |
| + if st != http.StatusSeeOther || !strings.HasPrefix(loc, app.URL+"/abono/sandbox/portal/") { |
| t.Errorf("billing: %d to %q", st, loc) |
| } |
| // A promotion code Stripe does not know is said so, on the plan's page. |
| st, _, body = reader.post("/subscribe/annual", url.Values{"code": {"NOPE"}}) |
| - want(t, "an unknown code", st, http.StatusUnprocessableEntity, body, "abono.error.unknown_code") |
| + want(t, "an unknown code", st, http.StatusUnprocessableEntity, body, say("abono.error.unknown_code")) |
| st, _, body = reader.post("/subscribe/nonsense", nil) |
| - want(t, "an unknown plan", st, http.StatusNotFound, body, "abono.error.unknown_plan") |
| + want(t, "an unknown plan", st, http.StatusNotFound, body, say("abono.error.unknown_plan")) |
| } |
| -// TestSignedOutBuyerSignsUpLater: paying signed out makes a user row for |
| -// the address (the example's Holder), and signing up with that address |
| -// later takes the row, membership and all. |
| -func TestSignedOutBuyerSignsUpLater(t *testing.T) { |
| - ts := newApp(t) |
| - buyer := newClient(t, ts) |
| - done := buyer.checkout("monthly", "walkin@example.com") |
| - status, body := buyer.get(done) |
| - want(t, "Checkout's return, signed out", status, http.StatusOK, body, "abono.pages.done_signin") |
| - |
| - buyer.signup("walkin@example.com") |
| - status, body = buyer.get("/account") |
| - want(t, "the account after signing up", status, http.StatusOK, body, "<strong>monthly</strong>", "abono.status.renews") |
| +// TestHolderAccountNeedsItsInbox: paying signed out makes a user for the |
| +// address (the example's Holder), and only someone who can read that |
| +// address's mail gets into it. Asking for a link from another browser |
| +// sends it to the inbox, not to the asker; there is no sign-up to claim |
| +// the account with. |
| +func TestHolderAccountNeedsItsInbox(t *testing.T) { |
| + app := newApp(t) |
| + buyer := newClient(t, app) |
| + status, body := buyer.get(buyer.checkout("monthly", "walkin@example.com")) |
| + want(t, "Checkout's return, signed out", status, http.StatusOK, body, say("abono.pages.done_signin")) |
| + |
| + intruder := newClient(t, app) |
| + for _, path := range []string{"/signup"} { |
| + if st, _ := intruder.get(path); st != http.StatusNotFound { |
| + t.Errorf("GET %s: %d, want 404 (no sign-up)", path, st) |
| + } |
| + if st, _, _ := intruder.post(path, url.Values{"email": {"walkin@example.com"}, "password": {"a long enough password"}}); st != http.StatusNotFound && st != http.StatusMethodNotAllowed { |
| + t.Errorf("POST %s: %d, want 404 or 405 (no sign-up)", path, st) |
| + } |
| + } |
| + intruder.askForLink("walkin@example.com") |
| + st, loc, _ := intruder.post("/auth/verify", url.Values{"token": {"guessed"}}) |
| + if st != http.StatusSeeOther || !strings.Contains(loc, "err=expired") { |
| + t.Errorf("a guessed token: %d to %q, want the expired answer", st, loc) |
| + } |
| + if st, loc, _ := intruder.do(http.MethodGet, "/account", nil, ""); st != http.StatusSeeOther || !strings.HasPrefix(loc, "/signin") { |
| + t.Errorf("the intruder's /account: %d to %q, want sign-in", st, loc) |
| + } |
| + if st, loc, _ := intruder.post("/account/billing", nil); st != http.StatusSeeOther || loc != "/account?billing=signin" { |
| + t.Errorf("the intruder's billing: %d to %q", st, loc) |
| + } |
| + |
| + // The link went to the address. Whoever reads it is in, with the |
| + // membership. |
| + if n := app.inbox.count("walkin@example.com"); n != 1 { |
| + t.Fatalf("mail to the address: %d, want 1", n) |
| + } |
| + owner := newClient(t, app) |
| + owner.follow(app.inbox.link(t, "walkin@example.com")) |
| + status, body = owner.get("/account") |
| + want(t, "the account, signed in by link", status, http.StatusOK, body, "<strong>monthly</strong>", say(abono.StatusRenews)) |
| } |
| // TestOwnerGivesAndEndsAComp: the owner gives a reader a free |
| // membership by address, the reader has it, and the owner ends it on its |
| // own confirm page. Nobody else reaches the owner's pages. |
| func TestOwnerGivesAndEndsAComp(t *testing.T) { |
| - ts := newApp(t) |
| - reader := newClient(t, ts) |
| - reader.signup("reader@example.com") |
| - boss := newClient(t, ts) |
| - boss.signup(owner) |
| + app := newApp(t) |
| + reader := newClient(t, app) |
| + reader.signin("reader@example.com") |
| + boss := newClient(t, app) |
| + boss.signin(owner) |
| for _, path := range []string{"/admin/members", "/admin/offers", "/admin/stripe", "/admin/members/comps/1/end"} { |
| if status, _ := reader.get(path); status != http.StatusForbidden { |
| @@ -229,17 +383,18 @@ func TestOwnerGivesAndEndsAComp(t *testing.T) { |
| if status, _, _ := reader.post("/admin/members/comps", url.Values{"member": {"reader@example.com"}, "plan": {"monthly"}}); status != http.StatusForbidden { |
| t.Errorf("a reader POST comps: %d, want 403", status) |
| } |
| - if status, _ := newClient(t, ts).get("/admin/members"); status != http.StatusForbidden { |
| + if status, _ := newClient(t, app).get("/admin/members"); status != http.StatusForbidden { |
| t.Errorf("signed out GET /admin/members: %d, want 403", status) |
| } |
| status, body := boss.get("/admin/members") |
| - want(t, "the members page", status, http.StatusOK, body, "abono.owner.members_empty", "abono.owner.free_title", "abono.button.sync") |
| + want(t, "the members page", status, http.StatusOK, body, |
| + say("abono.owner.members_empty"), say("abono.owner.free_title"), `action="/admin/members/sync"`, say("abono.owner.unrecognised_none")) |
| st, _, body := boss.post("/admin/members/comps", url.Values{"member": {"nobody@example.com"}, "plan": {"monthly"}}) |
| - want(t, "a comp for an unknown address", st, http.StatusUnprocessableEntity, body, "abono.error.signin_needed", `value="nobody@example.com"`) |
| + want(t, "a comp for an unknown address", st, http.StatusUnprocessableEntity, body, say("abono.owner.free_no_member"), `value="nobody@example.com"`) |
| st, _, body = boss.post("/admin/members/comps", url.Values{"member": {"reader@example.com"}, "plan": {"gold"}}) |
| - want(t, "a comp of an unknown plan", st, http.StatusUnprocessableEntity, body, "abono.error.free_plan") |
| + want(t, "a comp of an unknown plan", st, http.StatusUnprocessableEntity, body, say("abono.error.free_plan")) |
| st, loc, _ := boss.post("/admin/members/comps", url.Values{"member": {"reader@example.com"}, "plan": {"monthly"}, "note": {"a friend"}}) |
| if st != http.StatusSeeOther || loc != "/admin/members?done=comp&plan=monthly" { |
| @@ -247,20 +402,22 @@ func TestOwnerGivesAndEndsAComp(t *testing.T) { |
| } |
| status, body = boss.get(loc) |
| want(t, "the members page after a comp", status, http.StatusOK, body, |
| - "abono.owner.free_given", "abono.owner.source_free", "abono.status.comp", `href="/admin/members/comps/1/end"`) |
| + say("abono.owner.free_given"), say("abono.owner.source_free"), say(abono.StatusComp), |
| + "<td>reader@example.com</td>", `href="/admin/members/comps/1/end"`) |
| status, body = reader.get("/account") |
| - want(t, "the reader's account with a comp", status, http.StatusOK, body, "abono.status.comp") |
| + want(t, "the reader's account with a comp", status, http.StatusOK, body, say(abono.StatusComp)) |
| status, body = boss.get("/admin/members/comps/1/end") |
| want(t, "the confirm page", status, http.StatusOK, body, |
| - "abono.owner.end_title", "abono.owner.end_body", `action="/admin/members/comps/1/end"`, "abono.button.end_free_confirm", "abono.button.keep_free") |
| + say("abono.owner.end_title"), say("abono.owner.end_body"), `action="/admin/members/comps/1/end"`, |
| + say("abono.button.end_free_confirm"), `href="/admin/members"`) |
| st, loc, _ = boss.post("/admin/members/comps/1/end", nil) |
| if st != http.StatusSeeOther || loc != "/admin/members?done=end" { |
| t.Fatalf("end the comp: %d to %q", st, loc) |
| } |
| status, body = boss.get(loc) |
| - want(t, "the members page after ending", status, http.StatusOK, body, "abono.owner.end_done", "abono.status.ended") |
| + want(t, "the members page after ending", status, http.StatusOK, body, say("abono.owner.end_done"), say(abono.StatusEnded)) |
| if strings.Contains(body, `href="/admin/members/comps/1/end"`) { |
| t.Error("an ended comp still offers to be ended") |
| } |
| @@ -269,33 +426,88 @@ func TestOwnerGivesAndEndsAComp(t *testing.T) { |
| } |
| status, body = reader.get("/account") |
| - want(t, "the reader's account after", status, http.StatusOK, body, "abono.status.ended") |
| + want(t, "the reader's account after", status, http.StatusOK, body, say(abono.StatusEnded)) |
| } |
| // TestOwnerSeesSyncsAndStripe: the owner's other pages, in the sandbox. |
| func TestOwnerSeesSyncsAndStripe(t *testing.T) { |
| - ts := newApp(t) |
| - reader := newClient(t, ts) |
| - reader.signup("reader@example.com") |
| + app := newApp(t) |
| + reader := newClient(t, app) |
| + reader.signin("reader@example.com") |
| reader.get(reader.checkout("annual", "")) |
| - boss := newClient(t, ts) |
| - boss.signup(owner) |
| + boss := newClient(t, app) |
| + boss.signin(owner) |
| status, body := boss.get("/admin/members") |
| - want(t, "the members page", status, http.StatusOK, body, "abono.owner.source_paid", "abono.status.renews") |
| + want(t, "the members page", status, http.StatusOK, body, |
| + say("abono.owner.source_paid"), say(abono.StatusRenews), "<td>reader@example.com</td>") |
| st, loc, _ := boss.post("/admin/members/sync", nil) |
| if st != http.StatusSeeOther || loc != "/admin/members?done=sync&n=1" { |
| t.Fatalf("sync: %d to %q", st, loc) |
| } |
| status, body = boss.get(loc) |
| - want(t, "after a sync", status, http.StatusOK, body, "abono.owner.sync_done_one") |
| + want(t, "after a sync", status, http.StatusOK, body, say("abono.owner.sync_done_one")) |
| status, body = boss.get("/admin/offers") |
| want(t, "the offers page", status, http.StatusOK, body, |
| - "price_annual", "price_monthly", "abono.owner.price_sold", "abono.owner.unrecognised_none") |
| + "price_annual", "price_monthly", say("abono.owner.price_sold"), say("abono.owner.unrecognised_none")) |
| status, body = boss.get("/admin/stripe") |
| - want(t, "the Stripe page", status, http.StatusOK, body, "abono.owner.stripe_sandbox") |
| + want(t, "the Stripe page", status, http.StatusOK, body, say("abono.owner.stripe_sandbox")) |
| +} |
| + |
| +// TestMembersShowUnrecognisedSubscriptions: a paying subscription on a |
| +// price no plan has is listed on the members page (spec 4.1), and |
| +// adopting it there puts it, with its holder, onto the plan. It runs |
| +// against abonotest's Stripe, where a test can put such a subscription. |
| +func TestMembersShowUnrecognisedSubscriptions(t *testing.T) { |
| + stripe := abonotest.New(t) |
| + stripe.AddPrice("price_annual", "annual", 6000, "eur", "year") |
| + stripe.AddPrice("price_monthly", "monthly", 600, "eur", "month") |
| + app := newApp(t, func(c *Config) { c.Abono = stripe.Config }) |
| + stripe.SetWebhookURL(app.URL + abono.WebhookPath) |
| + now := stripe.Now() |
| + stripe.AddSubscription(abonotest.Subscription{ |
| + ID: "sub_foreign", Status: "active", Price: "price_foreign", Interval: "month", |
| + Email: "old@example.com", PeriodStart: now, PeriodEnd: now.Add(30 * 24 * time.Hour), |
| + }) |
| + |
| + boss := newClient(t, app) |
| + boss.signin(owner) |
| + st, loc, _ := boss.post("/admin/members/sync", nil) |
| + if st != http.StatusSeeOther { |
| + t.Fatalf("sync: %d to %q", st, loc) |
| + } |
| + status, body := boss.get("/admin/members") |
| + want(t, "the members page", status, http.StatusOK, body, |
| + say("abono.owner.unrecognised_title"), `data-abono-unrecognised="sub_foreign"`, "price_foreign", |
| + `name="from" value="members"`, say("abono.button.adopt")) |
| + if strings.Contains(body, say("abono.owner.unrecognised_none")) { |
| + t.Error("the members page says every subscription is recognised") |
| + } |
| + |
| + adopt := url.Values{"subscription": {"sub_foreign"}, "from": {"members"}} |
| + adopt.Set("plan", "gold") |
| + st, _, body = boss.post("/admin/offers/adopt", adopt) |
| + want(t, "adopting onto an unknown plan", st, http.StatusUnprocessableEntity, body, |
| + say("abono.error.unknown_plan"), `data-abono-unrecognised="sub_foreign"`) |
| + |
| + adopt.Set("plan", "monthly") |
| + st, loc, _ = boss.post("/admin/offers/adopt", adopt) |
| + if st != http.StatusSeeOther || loc != "/admin/members?done=adopt&plan=monthly" { |
| + t.Fatalf("adopt: %d to %q", st, loc) |
| + } |
| + status, body = boss.get(loc) |
| + want(t, "after adopting", status, http.StatusOK, body, |
| + say("abono.owner.adopted"), "<td>old@example.com</td>", "<td>monthly</td>", say("abono.owner.source_paid"), |
| + say("abono.owner.unrecognised_none")) |
| + if strings.Contains(body, `data-abono-unrecognised=`) { |
| + t.Error("the adopted subscription is still listed as unrecognised") |
| + } |
| + |
| + // From the offers page, the form goes back there. |
| + status, body = boss.get("/admin/offers") |
| + want(t, "the offers page", status, http.StatusOK, body, say("abono.owner.unrecognised_none")) |
| } |
| // TestMachinePathsSkipCSRF: Stripe's webhook and carlos's tick carry no |
| @@ -303,8 +515,8 @@ func TestOwnerSeesSyncsAndStripe(t *testing.T) { |
| // every other POST without browser evidence is refused. |
| func TestMachinePathsSkipCSRF(t *testing.T) { |
| t.Setenv("CARLOS_ADMIN_TOKEN", "tick-token") |
| - ts := newApp(t) |
| - machine := newClient(t, ts) |
| + app := newApp(t) |
| + machine := newClient(t, app) |
| // The webhook checks Stripe's signature: an unsigned delivery is 400, |
| // not csrf's 403. |
| @@ -313,7 +525,7 @@ func TestMachinePathsSkipCSRF(t *testing.T) { |
| t.Errorf("unsigned webhook: %d, want 400\n%s", status, body) |
| } |
| - req, _ := http.NewRequest(http.MethodPost, ts.URL+"/jobs/abono-sync", nil) |
| + req, _ := http.NewRequest(http.MethodPost, app.URL+"/jobs/abono-sync", nil) |
| req.Header.Set("Authorization", "Bearer tick-token") |
| resp, err := http.DefaultClient.Do(req) |
| if err != nil { |
| @@ -324,7 +536,7 @@ func TestMachinePathsSkipCSRF(t *testing.T) { |
| t.Errorf("the daily tick: %d, want 204", resp.StatusCode) |
| } |
| - for _, path := range []string{"/subscribe/annual", "/account/billing", "/admin/members/sync", "/signup"} { |
| + for _, path := range []string{"/subscribe/annual", "/account/billing", "/admin/members/sync", "/signin", "/auth/verify"} { |
| if status, _, _ := machine.do(http.MethodPost, path, url.Values{}, ""); status != http.StatusForbidden { |
| t.Errorf("POST %s with no Origin: %d, want 403", path, status) |
| } |
diff --git a/example/browser_test.go b/example/browser_test.go| index 366da6b..9a68c6d 100644 |
| --- a/example/browser_test.go |
| +++ b/example/browser_test.go |
| @@ -3,36 +3,28 @@ |
| package main |
| import ( |
| - "io" |
| - "log/slog" |
| "net/http" |
| - "path/filepath" |
| "strings" |
| "testing" |
| "github.com/chromedp/chromedp" |
| "amadan.net/rastrillo/rastrillo" |
| - "amadan.net/rastrillo/rastrillo/db" |
| "amadan.net/rastrillo/rastrillo/harness" |
| + |
| + "amadan.net/rastrillo/abono" |
| ) |
| // TestBrowserSubscribeAndCancel drives the example in a real Chromium, |
| -// through rastrillo's harness: sign up, the plans, Checkout on the |
| +// through rastrillo's harness: sign in by emailed link, the plans, Checkout on the |
| // sandbox's page, Checkout's return, the account page, the sandbox's |
| // billing page, cancelling, and the account page saying so. Any console |
| // error, failed request or 4xx/5xx fails it at the screen it happened |
| // on. Without a browser it fails (harness.ChromePath); it never skips. |
| func TestBrowserSubscribeAndCancel(t *testing.T) { |
| - quiet := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| - d, err := db.Open(filepath.Join(t.TempDir(), "example.db"), quiet) |
| - if err != nil { |
| - t.Fatalf("db.Open: %v", err) |
| - } |
| - t.Cleanup(func() { d.Close() }) |
| - |
| + box := &inbox{} |
| rig := harness.New(t, func(origin string) http.Handler { |
| - opts, err := Options(Config{DB: d, Origin: origin, Owner: owner, Logger: quiet}) |
| + opts, err := Options(testConfig(t, origin, box)) |
| if err != nil { |
| t.Fatalf("Options: %v", err) |
| } |
| @@ -44,27 +36,30 @@ func TestBrowserSubscribeAndCancel(t *testing.T) { |
| return h |
| }) |
| - // Sign up, and land on the plans. |
| - rig.Run(chromedp.Navigate(rig.Origin + "/signup")) |
| - rig.Screen(`form[action="/signup"]`, "sign up") |
| + // Ask for a sign-in link, open it from the inbox, and press Sign in. |
| + rig.Run(chromedp.Navigate(rig.Origin + "/signin")) |
| + rig.Screen(`#rst-signin-email`, "sign in") |
| rig.Run( |
| - chromedp.SendKeys(`#email`, "reader@example.com", chromedp.ByQuery), |
| - chromedp.SendKeys(`#password`, "a long enough password", chromedp.ByQuery), |
| - chromedp.Click(`form[action="/signup"] button[type=submit]`, chromedp.ByQuery), |
| + chromedp.SendKeys(`#rst-signin-email`, "reader@example.com", chromedp.ByQuery), |
| + chromedp.Click(`form[action="/signin"] button[type=submit]`, chromedp.ByQuery), |
| ) |
| - rig.Screen(`form[action="/subscribe/annual"]`, "the plans") |
| + rig.Screen(`form[action="/signin/forget"]`, "the link was sent") |
| + rig.Run(chromedp.Navigate(box.link(t, "reader@example.com"))) |
| + rig.Screen(`form[action="/auth/verify"] button[type=submit]`, "the link's confirm page") |
| + rig.Run(chromedp.Click(`form[action="/auth/verify"] button[type=submit]`, chromedp.ByQuery)) |
| + rig.Screen(`form[action="/subscribe/annual"]`, "the plans, signed in") |
| // Buy the annual plan on the sandbox's Checkout page. |
| rig.Run(chromedp.Click(`form[action="/subscribe/annual"] button[type=submit]`, chromedp.ByQuery)) |
| rig.Screen(`form[action$="/pay"]`, "the sandbox's checkout") |
| rig.Run(chromedp.Click(`form[action$="/pay"] button[type=submit]`, chromedp.ByQuery)) |
| rig.Screen(`main a[href="/account"]`, "Checkout's return") |
| - mustSay(t, rig, "main", "abono.pages.done_paid") |
| + mustSay(t, rig, "main", say("abono.pages.done_paid")) |
| // The account page shows it renewing. |
| rig.Run(chromedp.Click(`main a[href="/account"]`, chromedp.ByQuery)) |
| rig.Screen(`form[action="/account/billing"]`, "the account, paid") |
| - mustSay(t, rig, "main", "abono.status.renews") |
| + mustSay(t, rig, "main", say(abono.StatusRenews)) |
| // Cancel on the sandbox's billing page, and come back. |
| rig.Run(chromedp.Click(`form[action="/account/billing"] button[type=submit]`, chromedp.ByQuery)) |
| @@ -76,7 +71,7 @@ func TestBrowserSubscribeAndCancel(t *testing.T) { |
| rig.Screen(`a[href$="/account"]`, "the sandbox's billing page, cancelled") |
| rig.Run(chromedp.Click(`a[href$="/account"]`, chromedp.ByQuery)) |
| rig.Screen(`form[action="/account/billing"]`, "the account, cancelling") |
| - mustSay(t, rig, "main", "abono.status.cancelling") |
| + mustSay(t, rig, "main", say(abono.StatusCancelling)) |
| } |
| // mustSay fails unless sel's text on the current screen contains text. |
diff --git a/example/go.mod b/example/go.mod| index b8db037..7051911 100644 |
| --- a/example/go.mod |
| +++ b/example/go.mod |
| @@ -22,10 +22,10 @@ require ( |
| github.com/google/uuid v1.6.0 // indirect |
| github.com/jinzhu/inflection v1.0.0 // indirect |
| github.com/jinzhu/now v1.1.5 // indirect |
| + github.com/keymaildev/signin v0.1.1 // indirect |
| github.com/mattn/go-isatty v0.0.20 // indirect |
| github.com/ncruces/go-strftime v1.0.0 // indirect |
| github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect |
| - golang.org/x/crypto v0.53.0 // indirect |
| golang.org/x/sys v0.46.0 // indirect |
| golang.org/x/text v0.39.0 // indirect |
| gorm.io/plugin/dbresolver v1.6.2 // indirect |
diff --git a/example/go.sum b/example/go.sum| index e999b35..fecf27d 100644 |
| --- a/example/go.sum |
| +++ b/example/go.sum |
| @@ -48,8 +48,6 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94 |
| github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= |
| github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= |
| github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= |
| -golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= |
| -golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= |
| golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= |
| golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= |
| golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= |
diff --git a/example/main.go b/example/main.go| index 46e8f59..1ad0600 100644 |
| --- a/example/main.go |
| +++ b/example/main.go |
| @@ -1,7 +1,9 @@ |
| -// Command example is abono's example app: password sign-in, the starter |
| -// pages `abono pages` wrote (abono_pages.go, templates/abono/), and the |
| -// sandbox standing in for Stripe, so the whole flow is clickable with no |
| -// Stripe account. Run it with `go run .` and open http://localhost:8080. |
| +// Command example is abono's example app: sign-in by emailed link, the |
| +// starter pages `abono pages` wrote (abono_pages.go, templates/abono/), |
| +// and the sandbox standing in for Stripe, so the whole flow is clickable |
| +// with no Stripe account. Run it with `go run .` and open |
| +// http://localhost:8080. Nothing is mailed: each sign-in link is written |
| +// to the log instead, to be opened from there. |
| // |
| // Its settings are environment variables, all optional: |
| // |
| @@ -10,6 +12,7 @@ |
| // ABONO_EXAMPLE_OWNER the account that sees the owner's pages; owner@example.com |
| // ABONO_EXAMPLE_PLANS what is for sale; DefaultPlans |
| // ABONO_EXAMPLE_DB the database file; example.db |
| +// ABONO_EXAMPLE_INSTANCE_KEY seals sign-in state; a development-only key |
| // STRIPE_KEY, STRIPE_WEBHOOK_SECRET a Stripe test account instead of the sandbox |
| package main |
| @@ -34,8 +37,14 @@ func main() { |
| if s := os.Getenv("STRIPE_WEBHOOK_SECRET"); s != "" { |
| secrets = []string{s} |
| } |
| + instanceKey := os.Getenv("ABONO_EXAMPLE_INSTANCE_KEY") |
| + if instanceKey == "" { |
| + instanceKey = "abono-example-development-only-instance-key" |
| + logger.Warn("ABONO_EXAMPLE_INSTANCE_KEY not set; using a development-only key") |
| + } |
| opts, err := Options(Config{ |
| DB: d, |
| + InstanceKey: instanceKey, |
| Origin: env("ABONO_EXAMPLE_ORIGIN", "http://localhost:8080"), |
| Owner: env("ABONO_EXAMPLE_OWNER", "owner@example.com"), |
| Plans: os.Getenv("ABONO_EXAMPLE_PLANS"), |
diff --git a/example/migrations/0001_init.sql b/example/migrations/0001_init.sql| index 818de7e..dfcba8c 100644 |
| --- a/example/migrations/0001_init.sql |
| +++ b/example/migrations/0001_init.sql |
| @@ -1,3 +1,3 @@ |
| -CREATE TABLE `users` (`id` integer PRIMARY KEY AUTOINCREMENT,`email` text,`password_hash` text,`created_at` datetime); |
| +CREATE TABLE `users` (`id` integer PRIMARY KEY AUTOINCREMENT,`email` text,`created_at` datetime); |
| CREATE UNIQUE INDEX `idx_users_email` ON `users`(`email`); |
diff --git a/example/models.go b/example/models.go| index 50e0941..19337ba 100644 |
| --- a/example/models.go |
| +++ b/example/models.go |
| @@ -2,7 +2,6 @@ package main |
| import ( |
| "context" |
| - "database/sql" |
| "embed" |
| "errors" |
| "strconv" |
| @@ -11,20 +10,21 @@ import ( |
| "gorm.io/gorm" |
| + "amadan.net/rastrillo/rastrillo/auth" |
| "amadan.net/rastrillo/rastrillo/migrate" |
| "amadan.net/rastrillo/rastrillo/sessions" |
| "amadan.net/rastrillo/abono" |
| ) |
| -// User is a person who can sign in. Someone who pays before having an |
| -// account gets a row with no password (holder, below), and signing up |
| -// with that address later sets one (createUser). |
| +// User is a person, known by the address they sign in with. A row is |
| +// made the first time an address signs in by its emailed link, or pays |
| +// (holder, below), whichever comes first. Either way the only way into |
| +// it is a link sent to that address. |
| type User struct { |
| - ID int64 |
| - Email string `gorm:"uniqueIndex"` |
| - PasswordHash string |
| - CreatedAt time.Time |
| + ID int64 |
| + Email string `gorm:"uniqueIndex"` |
| + CreatedAt time.Time |
| } |
| // Models is every model the schema generator manages. |
| @@ -34,94 +34,76 @@ var Models = []any{&User{}} |
| var migrationFS embed.FS |
| // Schema is the example's own migrations. BootSchema is everything |
| -// applied at boot: sessions, abono's tables, then the example's. |
| +// applied at boot: sessions, auth (whose backfill reads sessions), |
| +// abono's tables, then the example's. |
| var ( |
| Schema = migrate.MustFromFS(migrationFS, "example") |
| - BootSchema = migrate.Merge(sessions.Schema, abono.Schema, Schema) |
| + BootSchema = migrate.Merge(sessions.Schema, auth.Schema, abono.Schema, Schema) |
| ) |
| func subjectOf(id int64) string { return strconv.FormatInt(id, 10) } |
| -// lookupUser is password.Config.Lookup. A row with no password (a buyer |
| -// who has not signed up yet) cannot sign in. |
| -func lookupUser(g *gorm.DB) func(context.Context, string) (int64, string, error) { |
| - return func(ctx context.Context, email string) (int64, string, error) { |
| - var u User |
| - err := g.WithContext(ctx).Where("email = ? AND password_hash <> ''", email).Take(&u).Error |
| - if errors.Is(err, gorm.ErrRecordNotFound) { |
| - return 0, "", sql.ErrNoRows |
| - } |
| - return u.ID, u.PasswordHash, err |
| - } |
| -} |
| +func normalEmail(s string) string { return strings.ToLower(strings.TrimSpace(s)) } |
| -// createUser is password.Config.Create. An address that paid before |
| -// signing up already has a row with no password: signing up claims it, |
| -// and with it the membership. Any other existing address is refused as a |
| -// duplicate. |
| -func createUser(g *gorm.DB) func(context.Context, string, string) (int64, error) { |
| - return func(ctx context.Context, email, hash string) (int64, error) { |
| - var id int64 |
| - err := g.WithContext(ctx).Transaction(func(tx *gorm.DB) error { |
| - res := tx.Model(&User{}).Where("email = ? AND password_hash = ''", email).Update("password_hash", hash) |
| - if res.Error != nil { |
| - return res.Error |
| - } |
| - if res.RowsAffected == 1 { |
| - return tx.Model(&User{}).Where("email = ?", email).Select("id").Scan(&id).Error |
| - } |
| - u := User{Email: email, PasswordHash: hash, CreatedAt: time.Now().UTC()} |
| - if err := tx.Create(&u).Error; err != nil { |
| - return err |
| - } |
| - id = u.ID |
| - return nil |
| - }) |
| - return id, err |
| +// ensureUser is the user with this address, made if there is none, as |
| +// a subject. g may be a transaction. |
| +func ensureUser(ctx context.Context, g *gorm.DB, email string) (string, error) { |
| + var u User |
| + err := g.WithContext(ctx).Where("email = ?", email).Take(&u).Error |
| + if errors.Is(err, gorm.ErrRecordNotFound) { |
| + u = User{Email: email, CreatedAt: time.Now().UTC()} |
| + err = g.WithContext(ctx).Create(&u).Error |
| } |
| + if err != nil { |
| + return "", err |
| + } |
| + return subjectOf(u.ID), nil |
| } |
| // holder is abono's Holder: paying is a way in, so an address abono has |
| -// no subject for gets a user row, with no password until its owner signs |
| -// up. It runs inside abono's transaction, so it uses tx. |
| +// no subject for gets a user. Nobody is signed in by it; whoever reads |
| +// that address's mail can sign in later and finds the membership there. |
| +// It runs inside abono's transaction, so it uses tx. |
| func holder(ctx context.Context, tx *gorm.DB, email, _ string) (string, error) { |
| - email = strings.ToLower(strings.TrimSpace(email)) |
| + email = normalEmail(email) |
| if email == "" { |
| return "", nil |
| } |
| - var u User |
| - err := tx.WithContext(ctx).Where("email = ?", email).Take(&u).Error |
| - if errors.Is(err, gorm.ErrRecordNotFound) { |
| - u = User{Email: email, CreatedAt: time.Now().UTC()} |
| - err = tx.WithContext(ctx).Create(&u).Error |
| - } |
| - if err != nil { |
| - return "", err |
| + return ensureUser(ctx, tx, email) |
| +} |
| + |
| +// subjectForAddress is auth's SubjectFor: a verified address signs in |
| +// as its user, made on first sign-in. |
| +func subjectForAddress(g *gorm.DB) func(string) (string, error) { |
| + return func(address string) (string, error) { |
| + return ensureUser(context.Background(), g, normalEmail(address)) |
| } |
| - return subjectOf(u.ID), nil |
| } |
| // userByEmail is the subject of the person with this address, for the |
| -// owner's free-membership form. |
| +// owner's free-membership form. It makes nobody. |
| func userByEmail(g *gorm.DB) func(context.Context, string) (string, bool) { |
| return func(ctx context.Context, email string) (string, bool) { |
| var u User |
| - if err := g.WithContext(ctx).Where("email = ?", strings.ToLower(strings.TrimSpace(email))).Take(&u).Error; err != nil { |
| + if err := g.WithContext(ctx).Where("email = ?", normalEmail(email)).Take(&u).Error; err != nil { |
| return "", false |
| } |
| return subjectOf(u.ID), true |
| } |
| } |
| -// emailOf is the address of subject, "" when there is none. |
| -func emailOf(g *gorm.DB, ctx context.Context, subject string) string { |
| - id, err := strconv.ParseInt(subject, 10, 64) |
| - if err != nil { |
| - return "" |
| - } |
| - var u User |
| - if err := g.WithContext(ctx).Where("id = ?", id).Take(&u).Error; err != nil { |
| - return "" |
| +// emailOf is the address of subject, "" when there is none. It is the |
| +// owner's pages' Label too. |
| +func emailOf(g *gorm.DB) func(context.Context, string) string { |
| + return func(ctx context.Context, subject string) string { |
| + id, err := strconv.ParseInt(subject, 10, 64) |
| + if err != nil { |
| + return "" |
| + } |
| + var u User |
| + if err := g.WithContext(ctx).Where("id = ?", id).Take(&u).Error; err != nil { |
| + return "" |
| + } |
| + return u.Email |
| } |
| - return u.Email |
| } |
diff --git a/example/templates/abono/owner-end.html b/example/templates/abono/owner-end.html| index 9ab4722..e203a31 100644 |
| --- a/example/templates/abono/owner-end.html |
| +++ b/example/templates/abono/owner-end.html |
| @@ -5,6 +5,7 @@ |
| Data: |
| Title string, the page title's message key |
| Member abono.Member, the comp |
| + Label string, what to call the member |
| Plan string, the plan's name |
| Action string, where the form posts |
| MembersURL string, the members page |
| @@ -12,6 +13,6 @@ |
| {{define "content"}}{{$L := localizer $}} |
| {{template "back-nav" dict "Href" .MembersURL "Label" (T $L "abono.owner.members_title")}} |
| {{template "page-header" dict "L" $L "Title" (T $L .Title)}} |
| -<p>{{Tf $L "abono.owner.end_body" "plan" .Plan "member" .Member.Subject}}</p> |
| +<p>{{Tf $L "abono.owner.end_body" "plan" .Plan "member" .Label}}</p> |
| {{template "confirm-form" dict "L" $L "Action" .Action "Label" (T $L "abono.button.end_free_confirm") "Danger" true "CancelHref" .MembersURL "CancelLabel" (T $L "abono.button.keep_free")}} |
| {{end}} |
diff --git a/example/templates/abono/owner-members.html b/example/templates/abono/owner-members.html| index f797db7..421dd63 100644 |
| --- a/example/templates/abono/owner-members.html |
| +++ b/example/templates/abono/owner-members.html |
| @@ -1,27 +1,34 @@ |
| {{/* abono/owner-members: GET /admin/members, every holding with its |
| - status and where it came from, "sync with Stripe now", and the form |
| - that gives a free membership (a comp). A comp still granting has a |
| - link to its own confirm page, which ends it. A subscription held only |
| - by abono's 30-day backstop is flagged with the day access stops. |
| + status and where it came from, "sync with Stripe now", the paying |
| + subscriptions no plan recognises (each with a form that puts it onto |
| + a plan), and the form that gives a free membership (a comp). A comp |
| + still granting has a link to its own confirm page, which ends it. A |
| + subscription held only by abono's 30-day backstop is flagged with the |
| + day access stops. |
| Data: |
| Title string, the page title's message key |
| On bool, Stripe (or the sandbox) is configured |
| - Members []abonoMember, {Subject, Holding, EndURL} |
| + Members []abonoMember, {Subject, Label, Holding, EndURL}: |
| + Label is what to call the member |
| + Unrecognised []abonoUnrecognised, {SubscriptionID, PriceID, |
| + Subject, Label, ...}; empty while Stripe is off |
| Now time.Time, the moment holdings are described at |
| Offers []abono.Offer, the plans a comp can give |
| PlanOptions []abonoOption, the same as field-select options |
| + AdoptOptions []abonoOption, the plans, none selected, for adopting |
| Form abonoCompForm, {Member, Plan, Ends, Note, Errors}: the |
| comp form as posted, Errors a message key by field |
| SyncAction string, where "sync now" posts |
| CompAction string, where the comp form posts |
| + AdoptAction string, where an adopt form posts (with from=members) |
| StripeURL string, the Stripe page |
| Done *abonoNotice, {Kind, N, Plan}: what the last action did |
| Error string, a message key, or "" |
| L the request's localizer */}} |
| {{define "content"}}{{$L := localizer $}} |
| {{template "page-header" dict "L" $L "Title" (T $L .Title) "Sub" (T $L "abono.owner.members_intro")}} |
| -{{with .Done}}{{if eq .Kind "sync"}}{{if eq .N 1}}{{template "notice" (T $L "abono.owner.sync_done_one")}}{{else}}{{template "notice" (Tf $L "abono.owner.sync_done" "n" .N)}}{{end}}{{else if eq .Kind "comp"}}{{template "notice" (Tf $L "abono.owner.free_given" "plan" .Plan)}}{{else if eq .Kind "end"}}{{template "notice" (T $L "abono.owner.end_done")}}{{end}}{{end}} |
| +{{with .Done}}{{if eq .Kind "sync"}}{{if eq .N 1}}{{template "notice" (T $L "abono.owner.sync_done_one")}}{{else}}{{template "notice" (Tf $L "abono.owner.sync_done" "n" .N)}}{{end}}{{else if eq .Kind "comp"}}{{template "notice" (Tf $L "abono.owner.free_given" "plan" .Plan)}}{{else if eq .Kind "end"}}{{template "notice" (T $L "abono.owner.end_done")}}{{else if eq .Kind "adopt"}}{{template "notice" (Tf $L "abono.owner.adopted" "plan" .Plan)}}{{end}}{{end}} |
| {{with .Error}}{{template "callout" dict "L" $L "Tone" "negative" "Focus" true "Body" (T $L .)}}{{end}} |
| {{if .On}}<form rst-form-actions method="post" action="{{.SyncAction}}"><button type="submit" rst-btn>{{T $L "abono.button.sync"}}</button></form> |
| <p>{{T $L "abono.owner.sync_help"}}</p> |
| @@ -32,7 +39,7 @@ |
| <thead><tr><th scope="col">{{T $L "abono.owner.col_member"}}</th><th scope="col">{{T $L "abono.owner.col_plan"}}</th><th scope="col">{{T $L "abono.owner.col_status"}}</th><th scope="col">{{T $L "abono.owner.col_source"}}</th></tr></thead> |
| <tbody> |
| {{range .Members}}<tr> |
| -<td>{{.Subject}}</td> |
| +<td>{{.Label}}</td> |
| <td>{{.Plan}}</td> |
| <td>{{template "abono-status" dict "L" $L "Holding" .Holding "Now" $.Now}}{{if .Holding.Backstop}}{{$s := .Holding.Describe $.Now}} <small>{{$s.Sentence (T $L "abono.owner.backstop") ""}}</small>{{end}}</td> |
| <td>{{if eq .Source "comp"}}{{T $L "abono.owner.source_free"}}{{with .EndURL}} <a href="{{.}}">{{T $L "abono.button.end_free"}}</a>{{end}}{{else}}{{T $L "abono.owner.source_paid"}}{{end}}</td> |
| @@ -42,6 +49,22 @@ |
| </div> |
| {{else}}{{template "empty-state" dict "L" $L "Body" (T $L "abono.owner.members_empty")}} |
| {{end}} |
| +{{if .On}}<section rst-box aria-labelledby="abono-unrecognised-title"> |
| +<h2 id="abono-unrecognised-title">{{T $L "abono.owner.unrecognised_title"}}</h2> |
| +{{if .Unrecognised}}<p>{{T $L "abono.owner.unrecognised_body"}}</p> |
| +<p>{{T $L "abono.owner.unrecognised_warn"}}</p> |
| +{{range .Unrecognised}}<form rst-form method="post" action="{{$.AdoptAction}}" data-abono-unrecognised="{{.SubscriptionID}}"> |
| +<p><code>{{.SubscriptionID}}</code> <code>{{.PriceID}}</code> {{.Label}}</p> |
| +<input type="hidden" name="subscription" value="{{.SubscriptionID}}"> |
| +<input type="hidden" name="from" value="members"> |
| +{{template "field-select" dict "L" $L "ID" (printf "abono-adopt-%s" .SubscriptionID) "Name" "plan" "Label" (T $L "abono.owner.adopt_plan") "Options" $.AdoptOptions "Required" true}} |
| +<button type="submit" rst-btn>{{T $L "abono.button.adopt"}}</button> |
| +</form> |
| +{{end}} |
| +{{else}}<p>{{T $L "abono.owner.unrecognised_none"}}</p> |
| +{{end}} |
| +</section> |
| +{{end}} |
| {{if .Offers}}{{$memberErr := ""}}{{$planErr := ""}}{{with .Form.Errors}}{{with index . "member"}}{{$memberErr = T $L .}}{{end}}{{with index . "plan"}}{{$planErr = T $L .}}{{end}}{{end}}<section rst-box aria-labelledby="abono-free-title"> |
| <h2 id="abono-free-title">{{T $L "abono.owner.free_title"}}</h2> |
| <p>{{T $L "abono.owner.free_help"}}</p> |
diff --git a/example/templates/abono/owner-offers.html b/example/templates/abono/owner-offers.html| index 457d622..1ef14b1 100644 |
| --- a/example/templates/abono/owner-offers.html |
| +++ b/example/templates/abono/owner-offers.html |
| @@ -7,7 +7,8 @@ |
| Title string, the page title's message key |
| On bool, Stripe (or the sandbox) is configured |
| Offers []abono.Offer |
| - Unrecognised []abono.Unrecognised, empty while Stripe is off |
| + Unrecognised []abonoUnrecognised, {SubscriptionID, PriceID, |
| + Subject, Label, ...}; empty while Stripe is off |
| PlanOptions []abonoOption, the plans, as field-select options |
| AdoptAction string, where an adopt form posts |
| StripeURL string, the Stripe page |
| @@ -32,8 +33,8 @@ |
| <h2 id="abono-unrecognised-title">{{T $L "abono.owner.unrecognised_title"}}</h2> |
| {{if .Unrecognised}}<p>{{T $L "abono.owner.unrecognised_body"}}</p> |
| <p>{{T $L "abono.owner.unrecognised_warn"}}</p> |
| -{{range .Unrecognised}}<form rst-form method="post" action="{{$.AdoptAction}}"> |
| -<p><code>{{.SubscriptionID}}</code> <code>{{.PriceID}}</code> {{.Subject}}</p> |
| +{{range .Unrecognised}}<form rst-form method="post" action="{{$.AdoptAction}}" data-abono-unrecognised="{{.SubscriptionID}}"> |
| +<p><code>{{.SubscriptionID}}</code> <code>{{.PriceID}}</code> {{.Label}}</p> |
| <input type="hidden" name="subscription" value="{{.SubscriptionID}}"> |
| {{template "field-select" dict "L" $L "ID" (printf "abono-adopt-%s" .SubscriptionID) "Name" "plan" "Label" (T $L "abono.owner.adopt_plan") "Options" $.PlanOptions "Required" true}} |
| <button type="submit" rst-btn>{{T $L "abono.button.adopt"}}</button> |
diff --git a/example/templates/layout.html b/example/templates/layout.html| index 3e18cc8..589d10f 100644 |
| --- a/example/templates/layout.html |
| +++ b/example/templates/layout.html |
| @@ -16,7 +16,6 @@ |
| <a href="/admin/stripe">{{T $L "abono.owner.stripe_title"}}</a> |
| {{end}}<form method="post" action="/signout"><button type="submit" rst-btn="ghost">{{T $L "example.signout"}}</button></form> |
| {{else}}<a href="/signin">{{T $L "rastrillo.ui.signin_confirm_submit"}}</a> |
| -<a href="/signup">{{T $L "example.signup"}}</a> |
| {{end}} |
| </nav> |
| </header> |
diff --git a/example/templates/signin.html b/example/templates/signin.html| index 1c6ae8f..5b82789 100644 |
| --- a/example/templates/signin.html |
| +++ b/example/templates/signin.html |
| @@ -1,13 +1,3 @@ |
| {{define "content"}}{{$L := localizer $}} |
| -{{template "page-header" dict "L" $L "Title" (T $L "rastrillo.ui.signin_confirm_submit")}} |
| -{{with .Form}} |
| -{{template "form-error" .Error}} |
| -<form rst-form method="post" action="/signin"> |
| -<input type="hidden" name="return_to" value="{{.ReturnTo}}"> |
| -{{template "field-text" dict "L" $L "Name" "email" "Label" (T $L "rastrillo.ui.signin_email") "Type" "email" "Value" .Email "Required" true "Autofocus" true "Autocomplete" "email"}} |
| -{{template "field-text" dict "L" $L "Name" "password" "Label" (T $L "example.password") "Type" "password" "Required" true "Autocomplete" "current-password"}} |
| -{{template "form-foot" dict "L" $L "Submit" (T $L "rastrillo.ui.signin_confirm_submit")}} |
| -</form> |
| -{{end}} |
| -<p><a href="/signup">{{T $L "example.signup"}}</a></p> |
| +{{template "signin" dict "L" $L "State" .State "Brand" .Brand}} |
| {{end}} |
diff --git a/example/templates/signup.html b/example/templates/signup.html| deleted file mode 100644 |
| index f79eba0..0000000 |
| --- a/example/templates/signup.html |
| +++ /dev/null |
| @@ -1,13 +0,0 @@ |
| -{{define "content"}}{{$L := localizer $}} |
| -{{template "page-header" dict "L" $L "Title" (T $L "example.signup")}} |
| -{{with .Form}} |
| -{{template "form-error" .Error}} |
| -<form rst-form method="post" action="/signup"> |
| -<input type="hidden" name="return_to" value="{{.ReturnTo}}"> |
| -{{template "field-text" dict "L" $L "Name" "email" "Label" (T $L "rastrillo.ui.signin_email") "Type" "email" "Value" .Email "Required" true "Autofocus" true "Autocomplete" "email"}} |
| -{{template "field-text" dict "L" $L "Name" "password" "Label" (T $L "example.password") "Type" "password" "Required" true "Autocomplete" "new-password" "Minlength" "8"}} |
| -{{template "form-foot" dict "L" $L "Submit" (T $L "example.signup")}} |
| -</form> |
| -{{end}} |
| -<p><a href="/signin">{{T $L "rastrillo.ui.signin_confirm_submit"}}</a></p> |
| -{{end}} |