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 | 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 | ||
| 254 | func emitProfile(c *Ctx, d ProfileOut) int { | 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 | 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 @@ | |||
| 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 | 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 | ||