1package admin
2
3import (
4 "context"
5 "errors"
6 "slices"
7 "strings"
8 "time"
9
10 "tug/internal/storage"
11)
12
13const (
14 InvitationBatchMax = 50
15 InvitationGroupChoicesShown = 100
16 InvitationExpiryDefault = 7 * 24 * time.Hour
17)
18
19var InvitationExpiries = []time.Duration{24 * time.Hour, InvitationExpiryDefault, 30 * 24 * time.Hour}
20
21type NewInvitations struct {
22 Labels []string
23 GroupIDs []string
24 GroupNames []string
25 Expiry time.Duration
26}
27
28type IssuedInvitation struct {
29 Label string
30 URL string
31}
32
33type CreatedInvitations struct {
34 Invitations []IssuedInvitation
35 ExpiresAt time.Time
36 Groups []string
37}
38
39type GroupChoice struct {
40 ID string
41 Name string
42}
43
44type InvitationGroupChoices struct {
45 Groups []GroupChoice
46 More bool
47 Defaults []string
48}
49
50func (s *Service) CreateInvitations(ctx context.Context, actor string, n NewInvitations) (CreatedInvitations, error) {
51 var labels []string
52 for _, label := range n.Labels {
53 if label = strings.TrimSpace(label); label != "" {
54 labels = append(labels, label)
55 }
56 }
57 if len(labels) == 0 || len(labels) > InvitationBatchMax {
58 return CreatedInvitations{}, ProblemBatchSize
59 }
60 for _, label := range labels {
61 if storage.CheckInvitationLabel(label) != nil {
62 return CreatedInvitations{}, ProblemLabelInvalid
63 }
64 }
65 if !slices.Contains(InvitationExpiries, n.Expiry) {
66 return CreatedInvitations{}, ProblemExpiryInvalid
67 }
68 var ids []string
69 defaults, _ := s.configuredGroups()
70 for i, name := range append(slices.Clone(defaults), n.GroupNames...) {
71 group, err := s.store.GroupByName(ctx, name)
72 if errors.Is(err, storage.ErrNotFound) && i < len(defaults) {
73 continue
74 }
75 if errors.Is(err, storage.ErrNotFound) {
76 return CreatedInvitations{}, ProblemUnknownGroup
77 }
78 if err != nil {
79 return CreatedInvitations{}, failed("read a group by name", err)
80 }
81 ids = append(ids, group.ID)
82 }
83 ids = append(ids, n.GroupIDs...)
84 issued, err := s.invites.CreateMany(ctx, actor, labels, ids, n.Expiry)
85 switch {
86 case errors.Is(err, storage.ErrAdminGroupGrant):
87 return CreatedInvitations{}, ProblemAdminGroupGrant
88 case errors.Is(err, storage.ErrNotFound):
89 return CreatedInvitations{}, ProblemUnknownGroup
90 case err != nil:
91 return CreatedInvitations{}, failed("create invitations", err)
92 }
93 out := CreatedInvitations{ExpiresAt: issued[0].ExpiresAt}
94 for _, i := range issued {
95 out.Invitations = append(out.Invitations, IssuedInvitation{Label: i.Label, URL: i.URL})
96 }
97 if out.Groups, err = s.invitationGroupNames(ctx, ids); err != nil {
98 return CreatedInvitations{}, err
99 }
100 return out, nil
101}
102
103func (s *Service) invitationGroupNames(ctx context.Context, ids []string) ([]string, error) {
104 configured, _ := s.configuredGroups()
105 var defaults, chosen []string
106 for _, id := range ids {
107 group, err := s.store.GroupByID(ctx, id)
108 if err != nil {
109 return nil, failed("read a granted group", err)
110 }
111 switch {
112 case slices.Contains(configured, group.Name):
113 if !slices.Contains(defaults, group.Name) {
114 defaults = append(defaults, group.Name)
115 }
116 case !slices.Contains(chosen, group.Name):
117 chosen = append(chosen, group.Name)
118 }
119 }
120 slices.Sort(chosen)
121 return append(defaults, chosen...), nil
122}
123
124func (s *Service) InvitationGroupChoices(ctx context.Context) (InvitationGroupChoices, error) {
125 all, err := s.store.ListGroups(ctx)
126 if err != nil {
127 return InvitationGroupChoices{}, failed("list groups", err)
128 }
129 defaults, _ := s.configuredGroups()
130 out := InvitationGroupChoices{Defaults: slices.Clone(defaults)}
131 for _, g := range all {
132 if g.Name == s.adminGroup || g.Name == s.primaryGroup || slices.Contains(defaults, g.Name) {
133 continue
134 }
135 if len(out.Groups) == InvitationGroupChoicesShown {
136 out.More = true
137 break
138 }
139 out.Groups = append(out.Groups, GroupChoice{ID: g.ID, Name: g.Name})
140 }
141 return out, nil
142}