C1. A membership of an organization may be admin as well as member and manager. An admin sees /org as a manager does and changes the organization from it: its teams and projects, who of the approved accounts is in them, its members' roles, and the keys spent in its projects. Every id a form names is checked against the organization; limits, accounts, the organization's name and the cluster stay with the cluster's staff, and an admin cannot take their own role away. A group from OIDC, SCIM or a directory no longer rewrites a membership it names as a plain member at every sign-in, which took a manager's role away (EnsureMembership). Pages draw a form only for someone who may send it: Nav.May asks the staff rule staffMay keeps, and a page whose forms a person may not send gets no form token. A viewer sees the people, limits and mirror pages without forms; an operator the mirror's. Forms are compact: labels close to their fields, fields as wide as what goes in them, smaller buttons, and the short forms on one line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
586 lines
19 KiB
Go
586 lines
19 KiB
Go
package cli
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
"text/tabwriter"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/account"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
|
)
|
|
|
|
func newUsersCmd() *cobra.Command {
|
|
var sf storeFlags
|
|
|
|
cmd := &cobra.Command{
|
|
Use: "users",
|
|
Short: "Approve and manage the people who sign up",
|
|
Long: "Signing up creates an account that cannot do anything. Approval is what\n" +
|
|
"makes it work, and it happens here rather than in a browser: approving is\n" +
|
|
"the step that decides who gets to spend the cluster, and it needs a shell\n" +
|
|
"on this machine rather than a session cookie.",
|
|
}
|
|
sf.bind(cmd)
|
|
open := func(cmd *cobra.Command) (*store.DB, error) {
|
|
return sf.open(cmd)
|
|
}
|
|
|
|
cmd.AddCommand(newUserListCmd(open), newUserApproveCmd(open),
|
|
newUserStateCmd(open, "suspend", store.UserSuspended,
|
|
"Stop an account working, keeping it and its history"),
|
|
newUserStateCmd(open, "reinstate", store.UserActive,
|
|
"Let a suspended account work again"),
|
|
newUserCreateCmd(open), newUserResetPasswordCmd(open), newUserResetLinkCmd(open, &sf),
|
|
newUserJoinCmd(open), newUserLeaveCmd(open), newUserDeleteCmd(open), newUserForgetCmd(open),
|
|
newUserAdminCmd(open, "promote", true, "Let an account see the cluster dashboard at /admin"),
|
|
newUserAdminCmd(open, "demote", false, "Take away access to the cluster dashboard"),
|
|
newUserRoleCmd(open))
|
|
return cmd
|
|
}
|
|
|
|
type userOpener func(*cobra.Command) (*store.DB, error)
|
|
|
|
func newUserListCmd(open userOpener) *cobra.Command {
|
|
var pending bool
|
|
cmd := &cobra.Command{
|
|
Use: "list",
|
|
Short: "List accounts",
|
|
Args: cobra.NoArgs,
|
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
s, err := open(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer s.Close()
|
|
|
|
state := store.UserState("")
|
|
if pending {
|
|
state = store.UserPending
|
|
}
|
|
users, err := s.Users(cmd.Context(), state)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(users) == 0 {
|
|
fmt.Fprintln(cmd.OutOrStdout(), "no accounts")
|
|
return nil
|
|
}
|
|
|
|
all, err := s.AllMemberships(cmd.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
where := map[string][]string{}
|
|
for _, m := range all {
|
|
where[m.UserID] = append(where[m.UserID], m.Path)
|
|
}
|
|
w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0)
|
|
fmt.Fprintln(w, "ID\tEMAIL\tSTATE\tADMIN\tMEMBER OF\tSIGNED UP\tLAST LOGIN")
|
|
now := time.Now()
|
|
for _, u := range users {
|
|
admin := "-"
|
|
if u.IsAdmin {
|
|
admin = "yes"
|
|
}
|
|
member := "on their own"
|
|
if ms := where[u.ID]; len(ms) > 0 {
|
|
member = strings.Join(ms, "; ")
|
|
}
|
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
|
|
u.ID, u.Email, u.State, admin, member,
|
|
ago(u.CreatedAt, now), ago(u.LastLoginAt, now))
|
|
}
|
|
return w.Flush()
|
|
},
|
|
}
|
|
cmd.Flags().BoolVar(&pending, "pending", false, "only accounts waiting for approval")
|
|
return cmd
|
|
}
|
|
|
|
func newUserApproveCmd(open userOpener) *cobra.Command {
|
|
var project, by string
|
|
cmd := &cobra.Command{
|
|
Use: "approve <user>",
|
|
Short: "Let an account sign in and issue its own keys",
|
|
Long: "A project is optional. Approved into nothing, a person uses the gateway on\n" +
|
|
"their own — personal keys and chat within their personal limits — and can\n" +
|
|
"be added to organizations, teams and projects with 'llmgw users join'.",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
s, err := open(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer s.Close()
|
|
|
|
u, err := resolveUser(cmd.Context(), s, args[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var p store.ProjectPath
|
|
if project != "" {
|
|
if p, err = resolveProject(cmd.Context(), s, project); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := s.ApproveUser(cmd.Context(), u.ID, p.ID, by, time.Now()); err != nil {
|
|
return fmt.Errorf("approving %s: %w", u.Email, err)
|
|
}
|
|
if p.ID == "" {
|
|
fmt.Fprintf(cmd.OutOrStdout(), "%s approved, on their own for now\n", u.Email)
|
|
} else {
|
|
fmt.Fprintf(cmd.OutOrStdout(), "%s approved into %s\n", u.Email, p.Path)
|
|
}
|
|
cmd.PrintErrln("They can sign in and create their own keys from /profile.")
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().StringVar(&project, "project", "",
|
|
"a project to put them in (optional): id, path (Acme/platform/gateway) or unique name")
|
|
cmd.Flags().StringVar(&by, "by", "", "who approved, for the record")
|
|
return cmd
|
|
}
|
|
|
|
func newUserStateCmd(open userOpener, verb string, state store.UserState, short string) *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: verb + " <user>",
|
|
Short: short,
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
s, err := open(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer s.Close()
|
|
|
|
u, err := resolveUser(cmd.Context(), s, args[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.SetUserState(cmd.Context(), u.ID, state); err != nil {
|
|
return err
|
|
}
|
|
if state == store.UserSuspended {
|
|
// Otherwise the person stays signed in and suspension is
|
|
// advisory until their session happens to expire.
|
|
if err := s.DeleteUserSessions(cmd.Context(), u.ID); err != nil {
|
|
return err
|
|
}
|
|
cmd.PrintErrln("Their sessions were closed. Their API keys still work — revoke those separately.")
|
|
}
|
|
fmt.Fprintf(cmd.OutOrStdout(), "%s is now %s\n", u.Email, state)
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
func newUserRoleCmd(open userOpener) *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "role <user> <viewer|operator|billing|admin|none>",
|
|
Short: "Give an account a part in running the gateway, from the pages under /admin",
|
|
Long: "viewer looks at every administration page and changes nothing. operator\n" +
|
|
"also runs the cluster from them: models through the mirror, a node's\n" +
|
|
"engines. billing sees usage and quotas. admin may do anything. none takes\n" +
|
|
"the role away. Every change made from those pages is in `llmgw ctl audit`.",
|
|
Args: cobra.ExactArgs(2),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
role := args[1]
|
|
if role == "none" {
|
|
role = ""
|
|
}
|
|
s, err := open(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer s.Close()
|
|
u, err := resolveUser(cmd.Context(), s, args[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.SetStaffRole(cmd.Context(), u.ID, role); err != nil {
|
|
return err
|
|
}
|
|
if role == "" {
|
|
fmt.Fprintf(cmd.OutOrStdout(), "%s has no part in running the gateway now\n", u.Email)
|
|
} else {
|
|
fmt.Fprintf(cmd.OutOrStdout(), "%s is now %s\n", u.Email, role)
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
func newUserAdminCmd(open userOpener, verb string, admin bool, short string) *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: verb + " <user>",
|
|
Short: short,
|
|
Long: "The dashboard is read-only, so this grants looking and nothing else.\n" +
|
|
"Everything that changes the cluster or issues a credential stays here on\n" +
|
|
"the CLI, where it needs a shell rather than a session — which keeps the\n" +
|
|
"worst case of a stolen admin cookie to \"saw the cluster\".",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
s, err := open(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer s.Close()
|
|
|
|
u, err := resolveUser(cmd.Context(), s, args[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.SetAdmin(cmd.Context(), u.ID, admin); err != nil {
|
|
return err
|
|
}
|
|
if admin {
|
|
fmt.Fprintf(cmd.OutOrStdout(), "%s can now see /admin\n", u.Email)
|
|
} else {
|
|
fmt.Fprintf(cmd.OutOrStdout(), "%s can no longer see /admin\n", u.Email)
|
|
cmd.PrintErrln("Their session stays open; the page is read-only. " +
|
|
"Suspend the account to close it.")
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
func newUserResetPasswordCmd(open userOpener) *cobra.Command {
|
|
var password string
|
|
cmd := &cobra.Command{
|
|
Use: "reset-password <user>",
|
|
Short: "Set a new password for someone who lost theirs",
|
|
Long: "People reset a forgotten password themselves at /forgot when the cluster\n" +
|
|
"has mail settings (mail.smtp); without them, 'llmgw users reset-link' makes\n" +
|
|
"a link to hand them. Or set one here, tell them out of band, and have them\n" +
|
|
"change it from their profile.\n\n" +
|
|
"Every session they have open is closed: if they lost the password, the\n" +
|
|
"reason might be that somebody else has it.",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
if password == "" {
|
|
return fmt.Errorf("--password is required")
|
|
}
|
|
s, err := open(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer s.Close()
|
|
|
|
hash, err := account.HashPassword(password)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
u, err := resolveUser(cmd.Context(), s, args[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.SetPassword(cmd.Context(), u.ID, hash); err != nil {
|
|
return err
|
|
}
|
|
if err := s.DeleteUserSessions(cmd.Context(), u.ID); err != nil {
|
|
return err
|
|
}
|
|
fmt.Fprintf(cmd.OutOrStdout(), "password set for %s\n", u.Email)
|
|
cmd.PrintErrln("Their sessions were closed. Their API keys still work.")
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().StringVar(&password, "password", "", "the new password")
|
|
return cmd
|
|
}
|
|
|
|
func newUserCreateCmd(open userOpener) *cobra.Command {
|
|
var project, password string
|
|
cmd := &cobra.Command{
|
|
Use: "create <email>",
|
|
Short: "Create an account directly, already approved",
|
|
Long: "For when signup is closed, or for the first account on a new deployment.\n" +
|
|
"The password is set here and should be changed by its owner.",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
if password == "" {
|
|
return fmt.Errorf("--password is required")
|
|
}
|
|
s, err := open(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer s.Close()
|
|
|
|
var p store.ProjectPath
|
|
if project != "" {
|
|
if p, err = resolveProject(cmd.Context(), s, project); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
hash, err := account.HashPassword(password)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
id := randomID("usr")
|
|
u := store.User{
|
|
ID: id, Email: args[0], Name: args[0],
|
|
PasswordHash: hash, State: store.UserPending, CreatedAt: time.Now(),
|
|
}
|
|
if err := s.CreateUser(cmd.Context(), u); err != nil {
|
|
return err
|
|
}
|
|
if err := s.ApproveUser(cmd.Context(), id, p.ID, "cli", time.Now()); err != nil {
|
|
return err
|
|
}
|
|
fmt.Fprintf(cmd.OutOrStdout(), "%s\n", id)
|
|
if p.ID == "" {
|
|
cmd.PrintErrf("%s created and approved, on their own for now\n", args[0])
|
|
} else {
|
|
cmd.PrintErrf("%s created and approved into %s\n", args[0], p.Path)
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().StringVar(&project, "project", "",
|
|
"a project to put them in (optional): id, path or unique name")
|
|
cmd.Flags().StringVar(&password, "password", "", "initial password")
|
|
return cmd
|
|
}
|
|
|
|
// scopeFlags names one org, team or project by whichever flag is given.
|
|
type scopeFlags struct{ org, team, project string }
|
|
|
|
func (f *scopeFlags) bind(cmd *cobra.Command) {
|
|
cmd.Flags().StringVar(&f.org, "org", "", "an organization: id or name")
|
|
cmd.Flags().StringVar(&f.team, "team", "", "a team: id, path (Acme/ml) or unique name")
|
|
cmd.Flags().StringVar(&f.project, "project", "", "a project: id, path (Acme/ml/training) or unique name")
|
|
}
|
|
|
|
func (f *scopeFlags) resolve(cmd *cobra.Command, s *store.DB) (store.Scope, string, string, error) {
|
|
n := 0
|
|
for _, v := range []string{f.org, f.team, f.project} {
|
|
if v != "" {
|
|
n++
|
|
}
|
|
}
|
|
if n != 1 {
|
|
return "", "", "", fmt.Errorf("give exactly one of --org, --team or --project")
|
|
}
|
|
ctx := cmd.Context()
|
|
switch {
|
|
case f.org != "":
|
|
o, err := resolveOrg(ctx, s, f.org)
|
|
return store.ScopeOrg, o.ID, o.Name, err
|
|
case f.team != "":
|
|
t, err := resolveTeam(ctx, s, f.team)
|
|
return store.ScopeTeam, t.ID, t.OrgName + " / " + t.Name, err
|
|
default:
|
|
p, err := resolveProject(ctx, s, f.project)
|
|
return store.ScopeProject, p.ID, p.Path, err
|
|
}
|
|
}
|
|
|
|
func newUserJoinCmd(open userOpener) *cobra.Command {
|
|
var where scopeFlags
|
|
var role, by string
|
|
cmd := &cobra.Command{
|
|
Use: "join <user> --org|--team|--project <which>",
|
|
Short: "Make a person a member of an organization, team or project",
|
|
Long: "A person can be a member of any number of them. A member of a team or\n" +
|
|
"organization may use every project in it.",
|
|
Example: " llmgw users join ayse@example.com --team Acme/ml\n" +
|
|
" llmgw users join ayse@example.com --project Scania/research/vision\n" +
|
|
" llmgw users join lars@example.com --org Scania --role admin",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
s, err := open(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer s.Close()
|
|
u, err := resolveUser(cmd.Context(), s, args[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
scope, id, label, err := where.resolve(cmd, s)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.AddMembership(cmd.Context(), u.ID, scope, id, role, by, time.Now()); err != nil {
|
|
return err
|
|
}
|
|
fmt.Fprintf(cmd.OutOrStdout(), "%s is a %s of %s\n", u.Email, role, label)
|
|
return nil
|
|
},
|
|
}
|
|
where.bind(cmd)
|
|
cmd.Flags().StringVar(&role, "role", store.RoleMember,
|
|
"member; with --org also manager, who sees the organization's page, or admin, who also changes its teams, projects, people and keys")
|
|
cmd.Flags().StringVar(&by, "by", "", "who added them, for the record")
|
|
return cmd
|
|
}
|
|
|
|
func newUserLeaveCmd(open userOpener) *cobra.Command {
|
|
var where scopeFlags
|
|
cmd := &cobra.Command{
|
|
Use: "leave <user> --org|--team|--project <which>",
|
|
Short: "Take a person out of an organization, team or project",
|
|
Long: "Keys they made in a project they can no longer use are revoked; a project\n" +
|
|
"they still reach through another membership keeps them. Their personal\n" +
|
|
"keys are not touched.",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
s, err := open(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer s.Close()
|
|
u, err := resolveUser(cmd.Context(), s, args[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
scope, id, label, err := where.resolve(cmd, s)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
revoked, err := s.RemoveMembership(cmd.Context(), u.ID, scope, id, time.Now())
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
return fmt.Errorf("%s is not a member of %s itself", u.Email, label)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Fprintf(cmd.OutOrStdout(), "%s is no longer in %s (%s revoked)\n",
|
|
u.Email, label, countOf(revoked, "key"))
|
|
return nil
|
|
},
|
|
}
|
|
where.bind(cmd)
|
|
return cmd
|
|
}
|
|
|
|
func newUserDeleteCmd(open userOpener) *cobra.Command {
|
|
var yes bool
|
|
cmd := &cobra.Command{
|
|
Use: "delete <user>",
|
|
Short: "Remove an account for good",
|
|
Long: "Signs them out, revokes every key they made, and removes the account. The\n" +
|
|
"keys are kept, revoked and ownerless, because usage history refers to them.\n" +
|
|
"To stop an account working and keep it, use suspend.",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
s, err := open(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer s.Close()
|
|
u, err := resolveUser(cmd.Context(), s, args[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !yes {
|
|
return fmt.Errorf("this removes %s and revokes their keys; run again with --yes", u.Email)
|
|
}
|
|
if err := s.DeleteUser(cmd.Context(), u.ID, time.Now()); err != nil {
|
|
return err
|
|
}
|
|
fmt.Fprintf(cmd.OutOrStdout(), "%s removed\n", u.Email)
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().BoolVar(&yes, "yes", false, "confirm")
|
|
return cmd
|
|
}
|
|
|
|
func newUserForgetCmd(open userOpener) *cobra.Command {
|
|
var yes bool
|
|
cmd := &cobra.Command{
|
|
Use: "forget <user>",
|
|
Short: "Erase a person: their account, what was kept of their requests, and their name",
|
|
Long: "For a request to be forgotten (GDPR article 17). Beyond delete:\n\n" +
|
|
" - their usage — every request record and every sum — is kept, so the\n" +
|
|
" totals stay true, under a random pseudonym that links to nothing;\n" +
|
|
" - their requests kept by `keys requests`, and their personal files and\n" +
|
|
" batches, are deleted;\n" +
|
|
" - their name and email are taken out of the audit log;\n" +
|
|
" - then the account goes, as delete does.\n\n" +
|
|
"Conversations and agent notes belong to projects, not people, and are\n" +
|
|
"left: a project forgets them by its retention. Backups taken before\n" +
|
|
"still hold everything until they age out.",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
s, err := open(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer s.Close()
|
|
u, err := resolveUser(cmd.Context(), s, args[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !yes {
|
|
return fmt.Errorf("this erases %s and cannot be undone; run again with --yes", u.Email)
|
|
}
|
|
f, err := s.ForgetUser(cmd.Context(), u.ID, time.Now())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Fprintf(cmd.OutOrStdout(), "%s forgotten. Their usage (%d requests, %d sums) is now %s; "+
|
|
"%d kept requests, %d files and %d batches deleted; %d audit lines no longer name them.\n",
|
|
u.Email, f.UsageRows, f.RollupRows, f.Pseudonym, f.Requests, f.Files, f.Batches, f.AuditEvents)
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().BoolVar(&yes, "yes", false, "confirm")
|
|
return cmd
|
|
}
|
|
|
|
func newUserResetLinkCmd(open userOpener, sf *storeFlags) *cobra.Command {
|
|
var base string
|
|
cmd := &cobra.Command{
|
|
Use: "reset-link <user>",
|
|
Short: "Make a one-time link for someone to set a forgotten password",
|
|
Long: "For a gateway that sends no mail, or a person whose mail does not arrive:\n" +
|
|
"the link is printed, to hand them however suits. It works once, for an\n" +
|
|
"hour, and a newer one replaces it. Setting the password closes every\n" +
|
|
"session they have open. The address it points at is mail.base_url from\n" +
|
|
"the cluster's settings, or --base-url.",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
if base == "" {
|
|
if cfg, err := loadEffectiveConfig(sf.config); err == nil {
|
|
base = cfg.Mail.BaseURL
|
|
}
|
|
}
|
|
if base == "" {
|
|
return errors.New("give --base-url, the address people reach this gateway at, or set mail.base_url " +
|
|
"(llmgw ctl config set mail.base_url https://…)")
|
|
}
|
|
s, err := open(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer s.Close()
|
|
u, err := resolveUser(cmd.Context(), s, args[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if u.PasswordHash == "" {
|
|
return fmt.Errorf("%s signs in through single sign-on: there is no password to set", u.Email)
|
|
}
|
|
token, err := s.CreatePasswordReset(cmd.Context(), u.ID, account.ResetLinkTTL)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Fprintf(cmd.OutOrStdout(), "%s/reset?token=%s\n", strings.TrimRight(base, "/"), url.QueryEscape(token))
|
|
cmd.PrintErrf("For %s: works once, for %s. Hand it to them alone; it opens their account.\n",
|
|
u.Email, account.ResetLinkTTL)
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().StringVar(&base, "base-url", "", "the address people reach this gateway at (default: mail.base_url)")
|
|
return cmd
|
|
}
|