v0.35.2 80d5067

cli_web.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"context"
7	"errors"
8	"fmt"
9
10	"connectrpc.com/connect"
11	"github.com/spf13/cobra"
12
13	"bnema.dev/ermite-api/gen/ermite"
14	"bnema.dev/ermite/internal/adapters/client"
15)
16
17// jsonLoginLink is the stable JSON shape of `ermite web login --json`.
18type jsonLoginLink struct {
19	URL       string  `json:"url"`
20	ExpiresAt *string `json:"expires_at"`
21}
22
23func newWebCmd(env *cliEnv) *cobra.Command {
24	web := &cobra.Command{
25		Use:   "web",
26		Short: "Sign the owner in to the web interface of a remote server",
27		Long: "Manage the owner's browser sessions and passkeys on a remote server. They need the server's admin\n" +
28			"token, or a token that holds every scope and is limited neither to repositories nor to branches.",
29	}
30	web.AddCommand(newWebLoginCmd(env), newWebLogoutCmd(env), newWebPasskeyCmd(env))
31	return web
32}
33
34func newWebLoginCmd(env *cliEnv) *cobra.Command {
35	cmd := &cobra.Command{
36		Use:   "login",
37		Short: "Print a one-time link that signs your browser in",
38		Long: "Ask the server for a login link and print it on standard output. Open it in your browser:\n" +
39			"the browser is then signed in as the owner for 12 hours (private repositories, merge requests).\n\n" +
40			"The link works once, for about a minute, and only until the server restarts. It is a secret: it is\n" +
41			"printed and nowhere else (not stored, not logged); do not paste it into a chat or a ticket. The page\n" +
42			"signs the browser in by itself; link previews, which run no script, cannot use it.\n\n" +
43			"JSON fields (--json): url, expires_at (RFC 3339 UTC).",
44		Args: cobra.NoArgs,
45	}
46	asJSON := addJSONFlag(cmd)
47	cmd.RunE = func(cmd *cobra.Command, _ []string) error {
48		c, err := env.client(cmd)
49		if err != nil {
50			return err
51		}
52		res, err := c.Sessions().CreateLoginLink(env.ctx(cmd), connect.NewRequest(&ermite.CreateLoginLinkRequest{}))
53		if err != nil {
54			return c.Describe(err)
55		}
56		if *asJSON {
57			return writeJSON(cmd.OutOrStdout(), jsonLoginLink{URL: res.Msg.GetUrl(), ExpiresAt: jsonTime(res.Msg.GetExpiresAt())})
58		}
59		noticef(cmd, "open this link in your browser; it works once, until %s UTC", shortTime(res.Msg.GetExpiresAt(), "shortly"))
60		_, err = fmt.Fprintln(cmd.OutOrStdout(), res.Msg.GetUrl())
61		return err
62	}
63	return cmd
64}
65
66func newWebLogoutCmd(env *cliEnv) *cobra.Command {
67	var all bool
68	cmd := &cobra.Command{
69		Use:   "logout --all",
70		Short: "Sign every browser out",
71		Long: "End every web session at once, on every device. Pending login links and API tokens are not affected\n" +
72			"(revoke tokens with `ermite token revoke`). To sign out one browser, use the sign out button on the page.",
73		Args: cobra.NoArgs,
74	}
75	cmd.Flags().BoolVar(&all, "all", false, "sign out every browser (required)")
76	cmd.RunE = func(cmd *cobra.Command, _ []string) error {
77		if !all {
78			return errors.New("--all is required: this signs every browser out (one browser signs out from its page)")
79		}
80		c, err := env.client(cmd)
81		if err != nil {
82			return err
83		}
84		if _, err := c.Sessions().SignOutEverywhere(env.ctx(cmd), connect.NewRequest(&ermite.SignOutEverywhereRequest{})); err != nil {
85			return c.Describe(err)
86		}
87		noticef(cmd, "every web session is signed out")
88		return nil
89	}
90	return cmd
91}
92
93// jsonPasskey is the stable JSON shape of a passkey. It never holds key
94// material.
95type jsonPasskey struct {
96	ID         string  `json:"id"`
97	Name       string  `json:"name"`
98	CreatedAt  *string `json:"created_at"`
99	LastUsedAt *string `json:"last_used_at"`
100	Synced     bool    `json:"synced"`
101}
102
103const jsonPasskeyHelp = "\n\nJSON fields (--json): id (credential id, base64url), name, created_at (RFC 3339 UTC), last_used_at\n" +
104	"(null if never used), synced (the authenticator reports the passkey as backed up)."
105
106func newWebPasskeyCmd(env *cliEnv) *cobra.Command {
107	cmd := &cobra.Command{
108		Use:   "passkey",
109		Short: "List or remove the passkeys that sign the owner in",
110		Long: "List or remove the passkeys that sign the owner in to the web interface. Adding one needs a browser:\n" +
111			"sign in (`ermite web login`), then open /-/passkeys. Passkeys are only available when the server's\n" +
112			"public URL is https (or localhost) with a host name, not an IP address.",
113	}
114	cmd.AddCommand(newWebPasskeyListCmd(env), newWebPasskeyRemoveCmd(env))
115	return cmd
116}
117
118func listPasskeys(ctx context.Context, c *client.Client) ([]*ermite.Passkey, error) {
119	res, err := c.Sessions().ListPasskeys(ctx, connect.NewRequest(&ermite.ListPasskeysRequest{}))
120	if err != nil {
121		return nil, c.Describe(err)
122	}
123	return res.Msg.GetPasskeys(), nil
124}
125
126func newWebPasskeyListCmd(env *cliEnv) *cobra.Command {
127	cmd := &cobra.Command{
128		Use:   "list",
129		Short: "List the registered passkeys",
130		Long: "List the passkeys, oldest first, with the id to give to `ermite web passkey remove`." + jsonPasskeyHelp +
131			"\n--json prints an array of passkeys.",
132		Args: cobra.NoArgs,
133	}
134	asJSON := addJSONFlag(cmd)
135	cmd.RunE = func(cmd *cobra.Command, _ []string) error {
136		c, err := env.client(cmd)
137		if err != nil {
138			return err
139		}
140		passkeys, err := listPasskeys(env.ctx(cmd), c)
141		if err != nil {
142			return err
143		}
144		if *asJSON {
145			out := make([]jsonPasskey, 0, len(passkeys))
146			for _, p := range passkeys {
147				out = append(out, jsonPasskey{
148					ID: p.GetId(), Name: p.GetName(), CreatedAt: jsonTime(p.GetCreatedAt()),
149					LastUsedAt: jsonTime(p.GetLastUsedAt()), Synced: p.GetSynced(),
150				})
151			}
152			return writeJSON(cmd.OutOrStdout(), out)
153		}
154		if len(passkeys) == 0 {
155			noticef(cmd, "no passkeys")
156			return nil
157		}
158		rows := make([][]string, 0, len(passkeys))
159		for _, p := range passkeys {
160			synced := "no"
161			if p.GetSynced() {
162				synced = "yes"
163			}
164			rows = append(rows, []string{
165				p.GetId(), p.GetName(), shortTime(p.GetCreatedAt(), "-"), shortTime(p.GetLastUsedAt(), "never"), synced,
166			})
167		}
168		return table(cmd.OutOrStdout(), []string{"ID", "NAME", "CREATED", "LAST USED", "SYNCED"}, rows)
169	}
170	return cmd
171}
172
173func newWebPasskeyRemoveCmd(env *cliEnv) *cobra.Command {
174	return &cobra.Command{
175		Use:   "remove <id>",
176		Short: "Remove a passkey and sign every browser out",
177		Long: "Remove a passkey by id (see `ermite web passkey list`). It can no longer sign in. Removing one also\n" +
178			"signs every browser out, like `ermite web logout --all`: web sessions are not tied to the passkey that\n" +
179			"opened them. Sign in again with another passkey or `ermite web login`.",
180		Args: cobra.ExactArgs(1),
181		ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
182			if len(args) > 0 {
183				return nil, cobra.ShellCompDirectiveNoFileComp
184			}
185			ids := complete(env, cmd, toComplete, listPasskeys, func(p *ermite.Passkey) (string, string) {
186				return p.GetId(), clean(p.GetName())
187			})
188			return ids, cobra.ShellCompDirectiveNoFileComp
189		},
190		RunE: func(cmd *cobra.Command, args []string) error {
191			c, err := env.client(cmd)
192			if err != nil {
193				return err
194			}
195			if _, err := c.Sessions().RemovePasskey(env.ctx(cmd), connect.NewRequest(&ermite.RemovePasskeyRequest{Id: args[0]})); err != nil {
196				return c.Describe(err)
197			}
198			noticef(cmd, "passkey removed; every browser is signed out")
199			_, err = fmt.Fprintf(cmd.OutOrStdout(), "removed %s\n", args[0])
200			return err
201		},
202	}
203}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 45

↑↓ moveenter openesc close