Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,16 @@ does is give every installed game somewhere to belong — your adds and removes
mirror to a library on your account, though nothing restores that onto a new
machine yet.

Signing up claims a **username** — your publishing handle, and the author
segment of every game you release. `nicodes/pong` and `aviorstudio/tetris` are
the same kind of name: the second belongs to an org, which is a studio more
than one person can publish under. Being a member is enough to publish; admin
governs the studio itself.

The same works from the command line:

```sh
termcade signup # create an account (or: termcade login)
termcade signup # create an account + claim a handle
termcade add aviorstudio/brickough # add straight from the marketplace
termcade add <file-or-url>.tcade # or from a package you have (also signed in)
termcade list # what's here
Expand Down
33 changes: 31 additions & 2 deletions account.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,23 +69,52 @@ func cmdLogin(args []string) error {
return nil
}

// promptUsername collects the handle a new account claims. It is not
// optional: a handle is the author segment of every game published from this
// account, and an account without one cannot publish at all.
func promptUsername(reader *bufio.Reader) (string, error) {
fmt.Print("username (this is your publishing handle, e.g. nicodes): ")
line, err := reader.ReadString('\n')
if err != nil {
return "", err
}
name := strings.TrimSpace(line)
if name == "" {
return "", fmt.Errorf("a username is required")
}
return name, nil
}

func cmdSignup(args []string) error {
if len(args) > 1 {
return fmt.Errorf("usage: termcade signup [email]")
}
username, err := promptUsername(bufio.NewReader(os.Stdin))
if err != nil {
return err
}
email, password, err := promptCredentials(args, true)
if err != nil {
return err
}
client := registry.New(registry.URL(nil), "")
session, err := client.Signup(email, password)
session, err := client.Signup(email, password, username)
if err != nil {
return err
}
if err := registry.SaveSession(session); err != nil {
return err
}
fmt.Printf("welcome to termcade, %s\n", session.Email)
// The handle is the useful half of the greeting: it is what a game id
// starts with, so it is what an author needs to know they have.
if session.Username != "" {
fmt.Printf("welcome to termcade, %s — publish as %s/<game>\n", session.Email, session.Username)
} else {
fmt.Printf("welcome to termcade, %s\n", session.Email)
}
if session.Notice != "" {
fmt.Fprintln(os.Stderr, "note:", session.Notice)
}
return nil
}

Expand Down
19 changes: 16 additions & 3 deletions internal/registry/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,13 @@ type Session struct {
Registry string `json:"registry"`
Email string `json:"email"`
Token string `json:"token"`
// Username is the handle this account publishes under. Empty is a real
// state — an account whose signup lost a handle race still logs in — and
// means publishing is refused until one is claimed.
Username string `json:"username,omitempty"`
// Notice is a server-side remark about an otherwise usable session. Not
// persisted: it describes the moment the session was created.
Notice string `json:"notice,omitempty"`
}

type Client struct {
Expand Down Expand Up @@ -308,11 +315,14 @@ func (c *Client) Publish(repo, tag, asset string) (Published, error) {
type credentials struct {
Email string `json:"email"`
Password string `json:"password"`
// Username is sent on signup and omitted on login, where the account
// already has one.
Username string `json:"username,omitempty"`
}

func (c *Client) Login(email, password string) (Session, error) {
var out Session
err := c.do(http.MethodPost, "/v1/auth/login", credentials{email, password}, &out)
err := c.do(http.MethodPost, "/v1/auth/login", credentials{Email: email, Password: password}, &out)
if errors.Is(err, ErrLoginRequired) {
return Session{}, errors.New("wrong email or password")
}
Expand All @@ -323,9 +333,12 @@ func (c *Client) Login(email, password string) (Session, error) {
return out, nil
}

func (c *Client) Signup(email, password string) (Session, error) {
// Signup creates an account and claims its handle in one call. The handle is
// required: it is the author segment of every game this account publishes.
func (c *Client) Signup(email, password, username string) (Session, error) {
var out Session
if err := c.do(http.MethodPost, "/v1/auth/signup", credentials{email, password}, &out); err != nil {
body := credentials{Email: email, Password: password, Username: username}
if err := c.do(http.MethodPost, "/v1/auth/signup", body, &out); err != nil {
return Session{}, err
}
out.Registry = c.baseURL
Expand Down
52 changes: 39 additions & 13 deletions internal/shell/market.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ type Marketplace struct {
// Account reports the signed-in email, or ok=false when signed out.
Account func() (string, bool)
SignIn func(email, password string) error
SignUp func(email, password string) error
SignUp func(username, email, password string) error
SignOut func() error
// Reload re-discovers installed games after an install/remove.
Reload func() []engine.Registration
Expand Down Expand Up @@ -64,11 +64,24 @@ type authState struct {
stage int
chooseIdx int
signup bool
focus int // 0 email, 1 password
email string
password string
err string
busy bool
// focus indexes authFields, which is one longer when signing up: a new
// account claims a handle, and an existing one already has it.
focus int
username string
email string
password string
err string
busy bool
}

// authFields is the form, in tab order. Signing up asks for a handle first —
// it is the name games are published under, so it is the decision being made,
// not an afterthought below the password.
func (a *authState) fields() []*string {
if a.signup {
return []*string{&a.username, &a.email, &a.password}
}
return []*string{&a.email, &a.password}
}

func (m Model) loadMarket() tea.Cmd {
Expand All @@ -93,11 +106,11 @@ func (m Model) removeCmd(id string) tea.Cmd {
}
}

func (m Model) authCmd(signup bool, email, password string) tea.Cmd {
func (m Model) authCmd(signup bool, username, email, password string) tea.Cmd {
mp := m.mp
return func() tea.Msg {
if signup {
return authDoneMsg{err: mp.SignUp(email, password)}
return authDoneMsg{err: mp.SignUp(username, email, password)}
}
return authDoneMsg{err: mp.SignIn(email, password)}
}
Expand Down Expand Up @@ -264,19 +277,25 @@ func (m Model) updateAuthKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
m.auth.err = ""
return m, nil
case "tab", "down":
m.auth.focus = (m.auth.focus + 1) % 2
n := len(m.auth.fields())
m.auth.focus = (m.auth.focus + 1) % n
return m, nil
case "shift+tab", "up":
m.auth.focus = (m.auth.focus + 1) % 2
n := len(m.auth.fields())
m.auth.focus = (m.auth.focus + n - 1) % n
return m, nil
case "enter":
if m.auth.email == "" || m.auth.password == "" {
m.auth.err = "email and password are required"
return m, nil
}
if m.auth.signup && m.auth.username == "" {
m.auth.err = "a username is required — it is what your games are published under"
return m, nil
}
m.auth.busy = true
m.auth.err = ""
return m, m.authCmd(m.auth.signup, m.auth.email, m.auth.password)
return m, m.authCmd(m.auth.signup, m.auth.username, m.auth.email, m.auth.password)
case "backspace":
field := m.authField()
if *field != "" {
Expand All @@ -294,10 +313,11 @@ func (m Model) updateAuthKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
}

func (m *Model) authField() *string {
if m.auth.focus == 0 {
fields := m.auth.fields()
if m.auth.focus < 0 || m.auth.focus >= len(fields) {
return &m.auth.email
}
return &m.auth.password
return fields[m.auth.focus]
}

// --------------------------------------------------------------- rendering --
Expand Down Expand Up @@ -390,6 +410,12 @@ func (m Model) viewAuth() string {
{"email ", m.auth.email, false},
{"password", m.auth.password, true},
}
if m.auth.signup {
fields = append([]struct {
label, value string
mask bool
}{{"username", m.auth.username, false}}, fields...)
}
for i, f := range fields {
value := f.value
if f.mask {
Expand Down
35 changes: 28 additions & 7 deletions internal/shell/shell_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -363,8 +363,12 @@ func fakeMarket(signedIn *bool, installed *[]engine.Registration) *Marketplace {
}
return "", false
},
SignIn: func(email, password string) error { *signedIn = true; return nil },
SignUp: func(email, password string) error { *signedIn = true; return nil },
SignIn: func(email, password string) error { *signedIn = true; return nil },
SignUp: func(username, email, password string) error {
*signedIn = true
signedUpAs = username
return nil
},
SignOut: func() error { *signedIn = false; return nil },
Reload: func() []engine.Registration {
g := &fakeGame{}
Expand All @@ -377,6 +381,10 @@ func fakeMarket(signedIn *bool, installed *[]engine.Registration) *Marketplace {
}
}

// signedUpAs records the handle the signup form submitted, so a test can
// prove the field is wired rather than merely present.
var signedUpAs string

func newMarketShell(t *testing.T) (Model, *bool, *[]engine.Registration) {
t.Helper()
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
Expand All @@ -385,6 +393,7 @@ func newMarketShell(t *testing.T) (Model, *bool, *[]engine.Registration) {
t.Fatal(err)
}
signedIn := false
signedUpAs = ""
var installed []engine.Registration
mp := fakeMarket(&signedIn, &installed)
m := New(mp.Reload(), st, sdk.Quadrant, mp)
Expand Down Expand Up @@ -508,19 +517,31 @@ func TestMarketSignup(t *testing.T) {
mm, _ = step(t, mm, key("l"))
mm, _ = step(t, mm, key("j"))
mm, _ = step(t, mm, key("enter"))
for _, r := range "p@t.dev" {
mm, _ = step(t, mm, key(string(r)))

// Signing up asks for a handle first: it is what games are published
// under, so it is the decision being made rather than a field below the
// password.
typeIn := func(m Model, text string) Model {
for _, r := range text {
m, _ = step(t, m, key(string(r)))
}
return m
}
mm = typeIn(mm, "nicodes")
mm, _ = step(t, mm, key("tab"))
for _, r := range "password123" {
mm, _ = step(t, mm, key(string(r)))
}
mm = typeIn(mm, "p@t.dev")
mm, _ = step(t, mm, key("tab"))
mm = typeIn(mm, "password123")

mm, cmd = step(t, mm, key("enter"))
if cmd == nil {
t.Fatal("no signup command issued")
}
mm = drain(t, mm, cmd)

if signedUpAs != "nicodes" {
t.Errorf("signup submitted username %q, want nicodes", signedUpAs)
}
if !*signedIn {
t.Fatal("signup hook not called")
}
Expand Down
4 changes: 2 additions & 2 deletions market.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,8 @@ func newMarketplace(rt *plugin.Runtime) *shell.Marketplace {
return registry.SaveSession(session)
},

SignUp: func(email, password string) error {
session, err := registry.New(registry.URL(nil), "").Signup(email, password)
SignUp: func(username, email, password string) error {
session, err := registry.New(registry.URL(nil), "").Signup(email, password, username)
if err != nil {
return err
}
Expand Down