Commit 5e9585315f

5e9585315f57b8e057948bb3cfc4f1b39a4e353c

parent: 1040228a52

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-02 04:19 UTC

control: account commands as screens

keys, email, pgp, token and web sessions lists, whoami, and the
profile emitter behind profile show, profile set and org profile. The
session's own key leads with ● and says "this session"; pgp list
shows the key's user IDs rather than their JSON.

Ref #319

Layout: unified · split

internal/control/identity.go +35 −21
@@ -71,15 +71,15 @@ func runWhoami(c *Ctx, args []string) int {
71 KeyScope string `json:"key_scope"` 71 KeyScope string `json:"key_scope"`
72 } 72 }
73 d := out{Username: c.User.Username, Admin: c.User.IsAdmin, KeyScope: c.Scope} 73 d := out{Username: c.User.Username, Admin: c.User.IsAdmin, KeyScope: c.Scope}
74 return c.emit(d, func(w io.Writer) { 74 return c.emitView(d, func(w io.Writer) {
75 if c.Term.Cols == 0 { 75 fmt.Fprintln(w, d.Username)
76 fmt.Fprintln(w, d.Username) 76 }, func() screen {
77 return 77 s := screen{fields: []field{{"User", []cell{cText(d.Username)}}}}
78 }
79 // At a terminal: where, and with what.
80 role := ""
81 if d.Admin { 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 // The key's label, or the start of its fingerprint: keys list 84 // The key's label, or the start of its fingerprint: keys list
85 // has the whole of it. 85 // has the whole of it.
@@ -89,13 +89,18 @@ func runWhoami(c *Ctx, args []string) int {
89 } else if len(via) > 20 { 89 } else if len(via) > 20 {
90 via = via[:19] + "…" 90 via = via[:19] + "…"
91 } 91 }
92 v := c.view(w) 92 if via != "" {
93 v.title(d.Username, "", role) 93 s.fields = append(s.fields, field{"Key", []cell{cText(via)}})
94 v.fields( 94 }
95 "instance", c.Cfg.Server.SiteURL, 95 if d.KeyScope != "" {
96 "key", via, 96 s.fields = append(s.fields, field{"Scope", []cell{cState(d.KeyScope)}})
97 "scope", d.KeyScope, 97 }
98 ) 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 ds = append(ds, out{k.Fingerprint, k.Algo, k.Scope, k.Label, k.CreatedBy, k.LastUsedAt, k.ExpiresAt}) 126 ds = append(ds, out{k.Fingerprint, k.Algo, k.Scope, k.Label, k.CreatedBy, k.LastUsedAt, k.ExpiresAt})
122 } 127 }
123 now := time.Now() 128 now := time.Now()
124 return c.emit(ds, func(w io.Writer) { 129 return c.emitView(ds, func(w io.Writer) {
125 tb := c.table(w, "FINGERPRINT", "ALGO", "SCOPE", "LABEL", "USED", "EXPIRES") 130 tb := c.table(w, "FINGERPRINT", "ALGO", "SCOPE", "LABEL", "USED", "EXPIRES")
126 for _, d := range ds { 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 tb.row(cFlex(d.Fingerprint), cText(d.Algo), cState(d.Scope), cText(d.Label), 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 tb.flush() 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
254func emitProfile(c *Ctx, d ProfileOut) int { 254func 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 activity := "" 256 activity := ""
257 if d.ActivityTotal > 0 { 257 if d.ActivityTotal > 0 {
258 activity = fmt.Sprintf("%d in the last year", d.ActivityTotal) 258 activity = fmt.Sprintf("%d in the last year", d.ActivityTotal)
@@ -297,6 +297,40 @@ func emitProfile(c *Ctx, d ProfileOut) int {
297 tb.flush() 297 tb.flush()
298 } 298 }
299 v.body(d.About, d.AboutFormat) 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 for _, e := range emails { 73 for _, e := range emails {
74 ds = append(ds, out{e.Address, e.Verified, e.VerifiedBy, e.Primary}) 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 tb := c.table(w, "ADDRESS", "STATE") 77 tb := c.table(w, "ADDRESS", "STATE")
78 for _, d := range ds { 78 for _, d := range ds {
79 state := "unverified" 79 state := "unverified"
@@ -87,6 +87,23 @@ func runEmailList(c *Ctx, args []string) int {
87 tb.row(cells...) 87 tb.row(cells...)
88 } 88 }
89 tb.flush() 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 for _, k := range keys { 92 for _, k := range keys {
93 ds = append(ds, out{k.Fingerprint, k.UIDsJSON, k.ExpiresAt, k.RevokedAt}) 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 tb := c.table(w, "FINGERPRINT", "EMAILS") 96 tb := c.table(w, "FINGERPRINT", "EMAILS")
97 for _, d := range ds { 97 for _, d := range ds {
98 tb.row(cRef(d.Fingerprint), cText(d.Emails)) 98 tb.row(cRef(d.Fingerprint), cText(d.Emails))
99 } 99 }
100 tb.flush() 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 @@
1package control
2
3import (
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.
13func 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
41func 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
58func 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 @@
1alice@example.test verified primary
2old@example.test unverified
internal/control/testdata/plain/keys-list.txt added +1
@@ -0,0 +1 @@
1SHA256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ssh-ed25519 full laptop never never
internal/control/testdata/plain/org-profile.txt added +6
@@ -0,0 +1,6 @@
1acme org
2
3 url /acme
4
5members:
6alice admin
internal/control/testdata/plain/pgp-list.txt added +1
@@ -0,0 +1 @@
1ABCDEF0123456789ABCDEF0123456789ABCDEF01 ["Alice <alice@example.test>"]
internal/control/testdata/plain/profile-set.txt added +4
@@ -0,0 +1,4 @@
1alice 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 @@
1alice Builds forges user
2
3 website https://alice.example.test
4 url /alice
5
6orgs:
7acme admin
8
9repos:
10alice/app public
internal/control/testdata/plain/token-list.txt added +1
@@ -0,0 +1 @@
1ci read <time>
internal/control/testdata/plain/web-sessions-list.txt added +1
@@ -0,0 +1 @@
1cdcdcdcdcdcd since <time> until <time> <time>
internal/control/testdata/plain/whoami.txt added +1
@@ -0,0 +1 @@
1alice
internal/control/token.go +17 −1
@@ -118,12 +118,28 @@ func runTokenList(c *Ctx, args []string) int {
118 ds = append(ds, out{t.Name, t.Scope, t.CreatedAt, t.ExpiresAt, t.LastUsedAt, t.CreatedBy}) 118 ds = append(ds, out{t.Name, t.Scope, t.CreatedAt, t.ExpiresAt, t.LastUsedAt, t.CreatedBy})
119 } 119 }
120 now := time.Now() 120 now := time.Now()
121 return c.emit(ds, func(w io.Writer) { 121 return c.emitView(ds, func(w io.Writer) {
122 tb := c.table(w, "NAME", "SCOPE", "EXPIRES") 122 tb := c.table(w, "NAME", "SCOPE", "EXPIRES")
123 for _, d := range ds { 123 for _, d := range ds {
124 tb.row(cRef(d.Name), cState(d.Scope), cText(c.expiresText(d.ExpiresAt, now))) 124 tb.row(cRef(d.Name), cState(d.Scope), cText(c.expiresText(d.ExpiresAt, now)))
125 } 125 }
126 tb.flush() 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 if err != nil { 39 if err != nil {
40 return c.fail(protocol.ExitFailure, "%v", err) 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 tb := c.table(w, "ID", "SINCE", "UNTIL", "USED") 43 tb := c.table(w, "ID", "SINCE", "UNTIL", "USED")
44 for _, s := range sessions { 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 tb.row(cRef(s.ID), cText("since "+stamp(s.CreatedAt)), cText("until "+stamp(s.ExpiresAt)), cText(c.usedText(s.LastUsedAt))) 45 tb.row(cRef(s.ID), cText("since "+stamp(s.CreatedAt)), cText("until "+stamp(s.ExpiresAt)), cText(c.usedText(s.LastUsedAt)))
50 } 46 }
51 tb.flush() 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