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 {
7171 KeyScope string `json:"key_scope"`
7272 }
7373 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)}}}}
8178 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)}})
8383 }
8484 // The key's label, or the start of its fingerprint: keys list
8585 // has the whole of it.
@@ -89,13 +89,18 @@ func runWhoami(c *Ctx, args []string) int {
8989 } else if len(via) > 20 {
9090 via = via[:19] + "…"
9191 }
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
99104 })
100105}
101106
@@ -121,17 +126,26 @@ func runKeysList(c *Ctx, args []string) int {
121126 ds = append(ds, out{k.Fingerprint, k.Algo, k.Scope, k.Label, k.CreatedBy, k.LastUsedAt, k.ExpiresAt})
122127 }
123128 now := time.Now()
124 return c.emit(ds, func(w io.Writer) {
129 return c.emitView(ds, func(w io.Writer) {
125130 tb := c.table(w, "FINGERPRINT", "ALGO", "SCOPE", "LABEL", "USED", "EXPIRES")
126131 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 }
131132 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)))
133134 }
134135 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 )
135149 })
136150}
137151
internal/control/profile.go +35 −1
@@ -252,7 +252,7 @@ func ActivityWindow() string {
252252}
253253
254254func emitProfile(c *Ctx, d ProfileOut) int {
255 return c.emit(d, func(w io.Writer) {
255 return c.emitView(d, func(w io.Writer) {
256256 activity := ""
257257 if d.ActivityTotal > 0 {
258258 activity = fmt.Sprintf("%d in the last year", d.ActivityTotal)
@@ -297,6 +297,40 @@ func emitProfile(c *Ctx, d ProfileOut) int {
297297 tb.flush()
298298 }
299299 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
300334 })
301335}
302336
internal/control/register.go +18 −1
@@ -73,7 +73,7 @@ func runEmailList(c *Ctx, args []string) int {
7373 for _, e := range emails {
7474 ds = append(ds, out{e.Address, e.Verified, e.VerifiedBy, e.Primary})
7575 }
76 return c.emit(ds, func(w io.Writer) {
76 return c.emitView(ds, func(w io.Writer) {
7777 tb := c.table(w, "ADDRESS", "STATE")
7878 for _, d := range ds {
7979 state := "unverified"
@@ -87,6 +87,23 @@ func runEmailList(c *Ctx, args []string) int {
8787 tb.row(cells...)
8888 }
8989 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 )
90107 })
91108}
92109
internal/control/sig.go +23 −1
@@ -92,12 +92,34 @@ func runPGPList(c *Ctx, args []string) int {
9292 for _, k := range keys {
9393 ds = append(ds, out{k.Fingerprint, k.UIDsJSON, k.ExpiresAt, k.RevokedAt})
9494 }
95 return c.emit(ds, func(w io.Writer) {
95 return c.emitView(ds, func(w io.Writer) {
9696 tb := c.table(w, "FINGERPRINT", "EMAILS")
9797 for _, d := range ds {
9898 tb.row(cRef(d.Fingerprint), cText(d.Emails))
9999 }
100100 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 )
101123 })
102124}
103125
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 {
118118 ds = append(ds, out{t.Name, t.Scope, t.CreatedAt, t.ExpiresAt, t.LastUsedAt, t.CreatedBy})
119119 }
120120 now := time.Now()
121 return c.emit(ds, func(w io.Writer) {
121 return c.emitView(ds, func(w io.Writer) {
122122 tb := c.table(w, "NAME", "SCOPE", "EXPIRES")
123123 for _, d := range ds {
124124 tb.row(cRef(d.Name), cState(d.Scope), cText(c.expiresText(d.ExpiresAt, now)))
125125 }
126126 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 )
127143 })
128144}
129145
internal/control/web.go +9 −5
@@ -39,16 +39,20 @@ func runWebSessionsList(c *Ctx, args []string) int {
3939 if err != nil {
4040 return c.fail(protocol.ExitFailure, "%v", err)
4141 }
42 return c.emit(sessions, func(w io.Writer) {
42 return c.emitView(sessions, func(w io.Writer) {
4343 tb := c.table(w, "ID", "SINCE", "UNTIL", "USED")
4444 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 }
4945 tb.row(cRef(s.ID), cText("since "+stamp(s.CreatedAt)), cText("until "+stamp(s.ExpiresAt)), cText(c.usedText(s.LastUsedAt)))
5046 }
5147 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 )
5256 })
5357}
5458