Commit 5e9585315f
Verified · cmc
Layout: unified · split
internal/control/identity.go +35 −21
| @@ -71,15 +71,15 @@ func runWhoami(c *Ctx, args []string) int { | ||
| 71 | 71 | KeyScope string `json:"key_scope"` |
| 72 | 72 | } |
| 73 | 73 | d := out{Username: c.User.Username, Admin: c.User.IsAdmin, KeyScope: c.Scope} |
| 74 | return c.emit(d, func(w io.Writer) { | |
| 75 | if c.Term.Cols == 0 { | |
| 76 | fmt.Fprintln(w, d.Username) | |
| 77 | return | |
| 78 | } | |
| 79 | // At a terminal: where, and with what. | |
| 80 | role := "" | |
| 74 | return c.emitView(d, func(w io.Writer) { | |
| 75 | fmt.Fprintln(w, d.Username) | |
| 76 | }, func() screen { | |
| 77 | s := screen{fields: []field{{"User", []cell{cText(d.Username)}}}} | |
| 81 | 78 | if d.Admin { |
| 82 | role = "admin" | |
| 79 | s.fields = append(s.fields, field{"Role", []cell{cState("admin")}}) | |
| 80 | } | |
| 81 | if c.Cfg.Server.SiteURL != "" { | |
| 82 | s.fields = append(s.fields, field{"Instance", []cell{cText(c.Cfg.Server.SiteURL)}}) | |
| 83 | 83 | } |
| 84 | 84 | // The key's label, or the start of its fingerprint: keys list |
| 85 | 85 | // has the whole of it. |
| @@ -89,13 +89,18 @@ func runWhoami(c *Ctx, args []string) int { | ||
| 89 | 89 | } else if len(via) > 20 { |
| 90 | 90 | via = via[:19] + "…" |
| 91 | 91 | } |
| 92 | v := c.view(w) | |
| 93 | v.title(d.Username, "", role) | |
| 94 | v.fields( | |
| 95 | "instance", c.Cfg.Server.SiteURL, | |
| 96 | "key", via, | |
| 97 | "scope", d.KeyScope, | |
| 98 | ) | |
| 92 | if via != "" { | |
| 93 | s.fields = append(s.fields, field{"Key", []cell{cText(via)}}) | |
| 94 | } | |
| 95 | if d.KeyScope != "" { | |
| 96 | s.fields = append(s.fields, field{"Scope", []cell{cState(d.KeyScope)}}) | |
| 97 | } | |
| 98 | s.actions = []action{ | |
| 99 | {"Account", []string{"keys", "list"}}, | |
| 100 | {"Account", []string{"token", "list"}}, | |
| 101 | {"Account", []string{"email", "list"}}, | |
| 102 | } | |
| 103 | return s | |
| 99 | 104 | }) |
| 100 | 105 | } |
| 101 | 106 | |
| @@ -121,17 +126,26 @@ func runKeysList(c *Ctx, args []string) int { | ||
| 121 | 126 | ds = append(ds, out{k.Fingerprint, k.Algo, k.Scope, k.Label, k.CreatedBy, k.LastUsedAt, k.ExpiresAt}) |
| 122 | 127 | } |
| 123 | 128 | now := time.Now() |
| 124 | return c.emit(ds, func(w io.Writer) { | |
| 129 | return c.emitView(ds, func(w io.Writer) { | |
| 125 | 130 | tb := c.table(w, "FINGERPRINT", "ALGO", "SCOPE", "LABEL", "USED", "EXPIRES") |
| 126 | 131 | for _, d := range ds { |
| 127 | used := cText(c.usedText(d.LastUsedAt)) | |
| 128 | if c.Term.Cols > 0 && d.Fingerprint == c.Source { | |
| 129 | used = cMark("this session", sgrGreen) | |
| 130 | } | |
| 131 | 132 | tb.row(cFlex(d.Fingerprint), cText(d.Algo), cState(d.Scope), cText(d.Label), |
| 132 | used, cText(c.expiresText(d.ExpiresAt, now))) | |
| 133 | cText(c.usedText(d.LastUsedAt)), cText(c.expiresText(d.ExpiresAt, now))) | |
| 133 | 134 | } |
| 134 | 135 | tb.flush() |
| 136 | }, func() screen { | |
| 137 | rows := make([]row, len(ds)) | |
| 138 | for i, d := range ds { | |
| 139 | lead, used := cGlyph(""), "used "+c.usedText(d.LastUsedAt) | |
| 140 | if d.Fingerprint == c.Source { | |
| 141 | lead, used = cYou(), "this session" | |
| 142 | } | |
| 143 | rows[i] = rowOf(cRef(d.Fingerprint), lead, cState(d.Scope), cText(d.Label), cMeta(d.Algo, used, c.expiresText(d.ExpiresAt, now))) | |
| 144 | } | |
| 145 | return listScreen("SSH keys", rows, | |
| 146 | action{"Keys", []string{"keys", "label", "<fingerprint>", "<text>"}}, | |
| 147 | action{"Keys", []string{"keys", "remove", "<fingerprint>"}}, | |
| 148 | ) | |
| 135 | 149 | }) |
| 136 | 150 | } |
| 137 | 151 | |
internal/control/profile.go +35 −1
| @@ -252,7 +252,7 @@ func ActivityWindow() string { | ||
| 252 | 252 | } |
| 253 | 253 | |
| 254 | 254 | func emitProfile(c *Ctx, d ProfileOut) int { |
| 255 | return c.emit(d, func(w io.Writer) { | |
| 255 | return c.emitView(d, func(w io.Writer) { | |
| 256 | 256 | activity := "" |
| 257 | 257 | if d.ActivityTotal > 0 { |
| 258 | 258 | activity = fmt.Sprintf("%d in the last year", d.ActivityTotal) |
| @@ -297,6 +297,40 @@ func emitProfile(c *Ctx, d ProfileOut) int { | ||
| 297 | 297 | tb.flush() |
| 298 | 298 | } |
| 299 | 299 | v.body(d.About, d.AboutFormat) |
| 300 | }, func() screen { | |
| 301 | s := screen{body: d.About, format: d.AboutFormat, fields: []field{ | |
| 302 | {"Profile", []cell{cLink(d.Name, c.siteURL(d.Name)), cMeta(d.Kind, d.Description)}}, | |
| 303 | }} | |
| 304 | if d.Website != "" { | |
| 305 | s.fields = append(s.fields, field{"Website", []cell{cText(d.Website)}}) | |
| 306 | } | |
| 307 | if d.ActivityTotal > 0 { | |
| 308 | s.fields = append(s.fields, field{"Activity", []cell{cText(fmt.Sprintf("%d in the last year", d.ActivityTotal))}}) | |
| 309 | } | |
| 310 | links := section{title: "Links", n: len(d.Links)} | |
| 311 | for _, l := range d.Links { | |
| 312 | links.rows = append(links.rows, rowOf(cText(l.Label), cFlex(l.URL))) | |
| 313 | } | |
| 314 | orgs := section{title: "Orgs", n: len(d.Orgs)} | |
| 315 | for _, m := range d.Orgs { | |
| 316 | orgs.rows = append(orgs.rows, rowOf(cLink(m.Name, c.siteURL(m.Name)), cState(m.Role))) | |
| 317 | } | |
| 318 | members := section{title: "Members", n: len(d.Members)} | |
| 319 | for _, m := range d.Members { | |
| 320 | members.rows = append(members.rows, rowOf(cRef(m.Name), cState(m.Role))) | |
| 321 | } | |
| 322 | repos := section{title: "Repos", n: len(d.Repos)} | |
| 323 | for _, r := range d.Repos { | |
| 324 | repos.rows = append(repos.rows, rowOf(cLink(r.Path, c.siteURL(r.Path)), cState(r.Visibility), cFlex(r.Description))) | |
| 325 | } | |
| 326 | s.sections = []section{links, orgs, members, repos} | |
| 327 | switch { | |
| 328 | case d.Kind == "org": | |
| 329 | s.actions = []action{{"Edit", []string{"org", "profile", d.Name, "--description", "<text>"}}} | |
| 330 | case d.Name == c.User.Username: | |
| 331 | s.actions = []action{{"Edit", []string{"profile", "set", "--description", "<text>"}}} | |
| 332 | } | |
| 333 | return s | |
| 300 | 334 | }) |
| 301 | 335 | } |
| 302 | 336 | |
internal/control/register.go +18 −1
| @@ -73,7 +73,7 @@ func runEmailList(c *Ctx, args []string) int { | ||
| 73 | 73 | for _, e := range emails { |
| 74 | 74 | ds = append(ds, out{e.Address, e.Verified, e.VerifiedBy, e.Primary}) |
| 75 | 75 | } |
| 76 | return c.emit(ds, func(w io.Writer) { | |
| 76 | return c.emitView(ds, func(w io.Writer) { | |
| 77 | 77 | tb := c.table(w, "ADDRESS", "STATE") |
| 78 | 78 | for _, d := range ds { |
| 79 | 79 | state := "unverified" |
| @@ -87,6 +87,23 @@ func runEmailList(c *Ctx, args []string) int { | ||
| 87 | 87 | tb.row(cells...) |
| 88 | 88 | } |
| 89 | 89 | tb.flush() |
| 90 | }, func() screen { | |
| 91 | rows := make([]row, len(ds)) | |
| 92 | for i, d := range ds { | |
| 93 | lead, state := cYou(), "unverified" | |
| 94 | if d.Verified { | |
| 95 | lead, state = cGlyph(""), "verified" | |
| 96 | } | |
| 97 | primary := "" | |
| 98 | if d.Primary { | |
| 99 | primary = "primary" | |
| 100 | } | |
| 101 | rows[i] = rowOf(cRef(d.Address), lead, cState(state), cMeta(primary, d.VerifiedBy)) | |
| 102 | } | |
| 103 | return listScreen("Emails", rows, | |
| 104 | action{"Email", []string{"email", "add", "<address>"}}, | |
| 105 | action{"Email", []string{"email", "primary", "<address>"}}, | |
| 106 | ) | |
| 90 | 107 | }) |
| 91 | 108 | } |
| 92 | 109 | |
internal/control/sig.go +23 −1
| @@ -92,12 +92,34 @@ func runPGPList(c *Ctx, args []string) int { | ||
| 92 | 92 | for _, k := range keys { |
| 93 | 93 | ds = append(ds, out{k.Fingerprint, k.UIDsJSON, k.ExpiresAt, k.RevokedAt}) |
| 94 | 94 | } |
| 95 | return c.emit(ds, func(w io.Writer) { | |
| 95 | return c.emitView(ds, func(w io.Writer) { | |
| 96 | 96 | tb := c.table(w, "FINGERPRINT", "EMAILS") |
| 97 | 97 | for _, d := range ds { |
| 98 | 98 | tb.row(cRef(d.Fingerprint), cText(d.Emails)) |
| 99 | 99 | } |
| 100 | 100 | tb.flush() |
| 101 | }, func() screen { | |
| 102 | rows := make([]row, len(ds)) | |
| 103 | for i, d := range ds { | |
| 104 | emails := d.Emails | |
| 105 | var uids []string | |
| 106 | if json.Unmarshal([]byte(d.Emails), &uids) == nil { | |
| 107 | emails = strings.Join(uids, ", ") | |
| 108 | } | |
| 109 | lead, note := cGlyph(""), "" | |
| 110 | switch { | |
| 111 | case d.RevokedAt != nil: | |
| 112 | lead, note = cGlyph("failed"), "revoked" | |
| 113 | case d.ExpiresAt != nil && !d.ExpiresAt.After(time.Now()): | |
| 114 | lead, note = cGlyph("failed"), "expired" | |
| 115 | case d.ExpiresAt != nil: | |
| 116 | note = "expires " + d.ExpiresAt.Format("2006-01-02") | |
| 117 | } | |
| 118 | rows[i] = rowOf(cRef(d.Fingerprint), lead, cFlex(emails), cMeta(note)) | |
| 119 | } | |
| 120 | return listScreen("OpenPGP keys", rows, | |
| 121 | action{"Keys", []string{"pgp", "remove", "<fingerprint>"}}, | |
| 122 | ) | |
| 101 | 123 | }) |
| 102 | 124 | } |
| 103 | 125 | |
internal/control/stage3account_test.go added +85
| @@ -0,0 +1,85 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 4 | "strings" | |
| 5 | "testing" | |
| 6 | "time" | |
| 7 | ||
| 8 | "gitbay.org/gitbay/internal/store" | |
| 9 | ) | |
| 10 | ||
| 11 | // accountFixture is a user with something in every account list task 4 | |
| 12 | // migrates, an org they admin, and a profile. | |
| 13 | func accountFixture(t *testing.T) (*store.Store, store.User) { | |
| 14 | t.Helper() | |
| 15 | st, _, uid := newQueueTestRepo(t) | |
| 16 | u := store.User{ID: uid, Username: "alice", IsAdmin: true} | |
| 17 | if err := st.AddSSHKey(uid, "SHA256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "ssh-ed25519", []byte("blob"), "full", "laptop"); err != nil { | |
| 18 | t.Fatal(err) | |
| 19 | } | |
| 20 | if err := st.AddEmail(uid, "alice@example.test", "smtp", true); err != nil { | |
| 21 | t.Fatal(err) | |
| 22 | } | |
| 23 | if err := st.AddEmail(uid, "old@example.test", "", false); err != nil { | |
| 24 | t.Fatal(err) | |
| 25 | } | |
| 26 | if err := st.AddPGPKey(uid, "ABCDEF0123456789ABCDEF0123456789ABCDEF01", "armored", `["Alice <alice@example.test>"]`, nil, nil); err != nil { | |
| 27 | t.Fatal(err) | |
| 28 | } | |
| 29 | expires := time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC) | |
| 30 | if err := st.CreateAPIToken(uid, "ci", strings.Repeat("ab", 32), "read", &expires, 0); err != nil { | |
| 31 | t.Fatal(err) | |
| 32 | } | |
| 33 | if err := st.CreateWebSession(strings.Repeat("cd", 32), uid, time.Hour); err != nil { | |
| 34 | t.Fatal(err) | |
| 35 | } | |
| 36 | dispatchAs(t, st, u, "", "org", "create", "acme") | |
| 37 | dispatchAs(t, st, u, "", "profile", "set", "--description", "Builds forges", "--website", "https://alice.example.test") | |
| 38 | return st, u | |
| 39 | } | |
| 40 | ||
| 41 | func TestAccountPlainPinned(t *testing.T) { | |
| 42 | st, u := accountFixture(t) | |
| 43 | for name, argv := range map[string][]string{ | |
| 44 | "keys-list": {"keys", "list"}, | |
| 45 | "email-list": {"email", "list"}, | |
| 46 | "pgp-list": {"pgp", "list"}, | |
| 47 | "token-list": {"token", "list"}, | |
| 48 | "web-sessions-list": {"web", "sessions", "list"}, | |
| 49 | "whoami": {"whoami"}, | |
| 50 | "profile-show": {"profile", "show"}, | |
| 51 | "org-profile": {"org", "profile", "acme"}, | |
| 52 | } { | |
| 53 | pinPlain(t, name, dispatchAs(t, st, u, "", argv...)) | |
| 54 | } | |
| 55 | pinPlain(t, "profile-set", dispatchAs(t, st, u, "", "profile", "set", "--description", "Builds forges")) | |
| 56 | } | |
| 57 | ||
| 58 | func TestAccountScreens(t *testing.T) { | |
| 59 | st, u := accountFixture(t) | |
| 60 | for _, tc := range []struct { | |
| 61 | argv []string | |
| 62 | want []string | |
| 63 | }{ | |
| 64 | {[]string{"keys", "list"}, []string{"SSH keys (1)\n", "full laptop"}}, | |
| 65 | {[]string{"email", "list"}, []string{"Emails (2)\n", "alice@example.test verified primary · smtp", "old@example.test ● unverified"}}, | |
| 66 | {[]string{"pgp", "list"}, []string{"OpenPGP keys (1)\n", "Alice <alice@example.test>"}}, | |
| 67 | {[]string{"token", "list"}, []string{"API tokens (1)\n", "ci read"}}, | |
| 68 | {[]string{"web", "sessions", "list"}, []string{"Browser sessions (1)\n", "cdcdcdcdcdcd"}}, | |
| 69 | {[]string{"whoami"}, []string{"User:", "alice", "Role:", "admin"}}, | |
| 70 | {[]string{"profile", "show"}, []string{"Profile:", "alice user · Builds forges", "Website:", "https://alice.example.test", "Repos (1)\n"}}, | |
| 71 | {[]string{"profile", "set", "--description", "Builds forges"}, []string{"Profile:", "alice"}}, | |
| 72 | {[]string{"org", "profile", "acme"}, []string{"Profile:", "acme org"}}, | |
| 73 | } { | |
| 74 | out := atTerminal(t, st, u, tc.argv...) | |
| 75 | for _, w := range tc.want { | |
| 76 | if !strings.Contains(out, w) { | |
| 77 | t.Errorf("%v: missing %q in:\n%s", tc.argv, w, out) | |
| 78 | } | |
| 79 | } | |
| 80 | if strings.Contains(out, strings.Repeat("ab", 32)) { | |
| 81 | t.Errorf("%v: token hash on screen", tc.argv) | |
| 82 | } | |
| 83 | checkLegend(t, out) | |
| 84 | } | |
| 85 | } | |
internal/control/testdata/plain/email-list.txt added +2
| @@ -0,0 +1,2 @@ | ||
| 1 | alice@example.test verified primary | |
| 2 | old@example.test unverified | |
internal/control/testdata/plain/keys-list.txt added +1
| @@ -0,0 +1 @@ | ||
| 1 | SHA256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ssh-ed25519 full laptop never never | |
internal/control/testdata/plain/org-profile.txt added +6
| @@ -0,0 +1,6 @@ | ||
| 1 | acme org | |
| 2 | ||
| 3 | url /acme | |
| 4 | ||
| 5 | members: | |
| 6 | alice admin | |
internal/control/testdata/plain/pgp-list.txt added +1
| @@ -0,0 +1 @@ | ||
| 1 | ABCDEF0123456789ABCDEF0123456789ABCDEF01 ["Alice <alice@example.test>"] | |
internal/control/testdata/plain/profile-set.txt added +4
| @@ -0,0 +1,4 @@ | ||
| 1 | alice Builds forges user | |
| 2 | ||
| 3 | website https://alice.example.test | |
| 4 | url /alice | |
internal/control/testdata/plain/profile-show.txt added +10
| @@ -0,0 +1,10 @@ | ||
| 1 | alice Builds forges user | |
| 2 | ||
| 3 | website https://alice.example.test | |
| 4 | url /alice | |
| 5 | ||
| 6 | orgs: | |
| 7 | acme admin | |
| 8 | ||
| 9 | repos: | |
| 10 | alice/app public | |
internal/control/testdata/plain/token-list.txt added +1
| @@ -0,0 +1 @@ | ||
| 1 | ci read <time> | |
internal/control/testdata/plain/web-sessions-list.txt added +1
| @@ -0,0 +1 @@ | ||
| 1 | cdcdcdcdcdcd since <time> until <time> <time> | |
internal/control/testdata/plain/whoami.txt added +1
| @@ -0,0 +1 @@ | ||
| 1 | alice | |
internal/control/token.go +17 −1
| @@ -118,12 +118,28 @@ func runTokenList(c *Ctx, args []string) int { | ||
| 118 | 118 | ds = append(ds, out{t.Name, t.Scope, t.CreatedAt, t.ExpiresAt, t.LastUsedAt, t.CreatedBy}) |
| 119 | 119 | } |
| 120 | 120 | now := time.Now() |
| 121 | return c.emit(ds, func(w io.Writer) { | |
| 121 | return c.emitView(ds, func(w io.Writer) { | |
| 122 | 122 | tb := c.table(w, "NAME", "SCOPE", "EXPIRES") |
| 123 | 123 | for _, d := range ds { |
| 124 | 124 | tb.row(cRef(d.Name), cState(d.Scope), cText(c.expiresText(d.ExpiresAt, now))) |
| 125 | 125 | } |
| 126 | 126 | tb.flush() |
| 127 | }, func() screen { | |
| 128 | rows := make([]row, len(ds)) | |
| 129 | for i, d := range ds { | |
| 130 | lead := cGlyph("") | |
| 131 | if d.ExpiresAt != nil && !d.ExpiresAt.After(now) { | |
| 132 | lead = cGlyph("failed") | |
| 133 | } | |
| 134 | by := "" | |
| 135 | if d.CreatedBy != "" { | |
| 136 | by = "by " + d.CreatedBy | |
| 137 | } | |
| 138 | rows[i] = rowOf(cRef(d.Name), lead, cState(d.Scope), cMeta(by, c.expiresText(d.ExpiresAt, now))) | |
| 139 | } | |
| 140 | return listScreen("API tokens", rows, | |
| 141 | action{"Tokens", []string{"token", "revoke", "<name>"}}, | |
| 142 | ) | |
| 127 | 143 | }) |
| 128 | 144 | } |
| 129 | 145 | |
internal/control/web.go +9 −5
| @@ -39,16 +39,20 @@ func runWebSessionsList(c *Ctx, args []string) int { | ||
| 39 | 39 | if err != nil { |
| 40 | 40 | return c.fail(protocol.ExitFailure, "%v", err) |
| 41 | 41 | } |
| 42 | return c.emit(sessions, func(w io.Writer) { | |
| 42 | return c.emitView(sessions, func(w io.Writer) { | |
| 43 | 43 | tb := c.table(w, "ID", "SINCE", "UNTIL", "USED") |
| 44 | 44 | for _, s := range sessions { |
| 45 | if c.Term.Cols > 0 { | |
| 46 | tb.row(cRef(s.ID), cAge(s.CreatedAt), cAge(s.ExpiresAt), cText(c.usedText(s.LastUsedAt))) | |
| 47 | continue | |
| 48 | } | |
| 49 | 45 | tb.row(cRef(s.ID), cText("since "+stamp(s.CreatedAt)), cText("until "+stamp(s.ExpiresAt)), cText(c.usedText(s.LastUsedAt))) |
| 50 | 46 | } |
| 51 | 47 | tb.flush() |
| 48 | }, func() screen { | |
| 49 | rows := make([]row, len(sessions)) | |
| 50 | for i, s := range sessions { | |
| 51 | rows[i] = rowOf(cRef(s.ID), cMeta("since "+relAge(s.CreatedAt, termNow()), "until "+relAge(s.ExpiresAt, termNow()), "used "+c.usedText(s.LastUsedAt))) | |
| 52 | } | |
| 53 | return listScreen("Browser sessions", rows, | |
| 54 | action{"Sessions", []string{"web", "sessions", "revoke", "<id>"}}, | |
| 55 | ) | |
| 52 | 56 | }) |
| 53 | 57 | } |
| 54 | 58 | |