From 3528656670c84c6d92f28d352e4af907574f9fe7 Mon Sep 17 00:00:00 2001 From: Grady Berry Ward Date: Wed, 24 Jan 2024 07:01:08 -0700 Subject: [PATCH] Refactors Initiative FE to support Authz + Anon Access (#164) --- cmd/server/main.go | 35 +++++- cmd/server/pactasrv/analysis.go | 2 +- cmd/server/pactasrv/authz.go | 11 ++ cmd/server/pactasrv/conv/helpers.go | 18 ++- cmd/server/pactasrv/conv/pacta_to_oapi.go | 11 +- cmd/server/pactasrv/initiative.go | 107 ++++++++++++------ .../pactasrv/initiative_user_relationship.go | 34 ------ frontend/components/initiative/Toolbar.vue | 76 ------------- frontend/composables/useInitiativeData.ts | 53 +++++++++ frontend/composables/usePACTA.ts | 2 +- frontend/lang/en.json | 31 ++--- frontend/lib/editor/initiative.ts | 4 + .../generated/pacta/models/Initiative.ts | 5 + .../pacta/services/DefaultService.ts | 39 ------- frontend/pages/admin/initiative/index.vue | 6 +- frontend/pages/admin/initiative/new.vue | 3 +- frontend/pages/initiative/[id].vue | 85 +++++++++++--- frontend/pages/initiative/[id]/edit.vue | 8 +- frontend/pages/initiative/[id]/index.vue | 35 +++--- frontend/pages/initiative/[id]/internal.vue | 11 +- .../pages/initiative/[id]/invitations.vue | 12 +- .../pages/initiative/[id]/relationships.vue | 29 +---- frontend/plugins/msal.ts | 27 ++--- openapi/pacta.yaml | 47 +------- session/session.go | 25 +++- 25 files changed, 361 insertions(+), 355 deletions(-) delete mode 100644 frontend/components/initiative/Toolbar.vue create mode 100644 frontend/composables/useInitiativeData.ts diff --git a/cmd/server/main.go b/cmd/server/main.go index e71a355..728de4f 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -8,6 +8,7 @@ import ( "log" "net/http" "os" + "regexp" "strings" "time" @@ -314,7 +315,7 @@ func run(args []string) error { zaphttplog.NewMiddleware(logger, zaphttplog.WithConcise(false)), chimiddleware.Recoverer, jwtauth.Verifier(jwtauth.New("EdDSA", nil, jwKey)), - jwtauth.Authenticator, + requireJWTIfNotPublicEndpoint, session.WithAuthn(logger, db), }, addl...) } @@ -372,6 +373,34 @@ func run(args []string) error { return nil } +type allowFn func(r *http.Request) bool + +var publicEndpoints = []allowFn{ + allowPublicInitiativeLookups, +} + +var allowPublicInitiativeLookupsRegexp = regexp.MustCompile(`^/initiative/[^/]*$`) + +func allowPublicInitiativeLookups(r *http.Request) bool { + if r.Method != http.MethodGet { + return false + } + return allowPublicInitiativeLookupsRegexp.MatchString(r.URL.Path) +} + +func requireJWTIfNotPublicEndpoint(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + for _, fn := range publicEndpoints { + if fn(r) { + r = r.WithContext(session.WithAllowedAnonymous(r.Context())) + next.ServeHTTP(w, r) + return + } + } + jwtauth.Authenticator(next).ServeHTTP(w, r) + }) +} + func rateLimitMiddleware(maxReq int, windowLength time.Duration) func(http.Handler) http.Handler { // This example uses an in-memory rate limiter for simplicity, an application // that will be running multiple API instances should likely use something like @@ -381,6 +410,10 @@ func rateLimitMiddleware(maxReq int, windowLength time.Duration) func(http.Handl maxReq, windowLength, httprate.WithKeyFuncs(func(r *http.Request) (string, error) { + if session.IsAllowedAnonymous(r.Context()) { + // Rate limit by IP address if the user is anonymous. + return r.RemoteAddr, nil + } _, claims, err := jwtauth.FromContext(r.Context()) if err != nil { return "", fmt.Errorf("failed to get claims from context: %w", err) diff --git a/cmd/server/pactasrv/analysis.go b/cmd/server/pactasrv/analysis.go index 4316975..9edf454 100644 --- a/cmd/server/pactasrv/analysis.go +++ b/cmd/server/pactasrv/analysis.go @@ -388,7 +388,7 @@ type initiativeAnalysis struct { func (ia *initiativeAnalysis) checkAuth(ctx context.Context, tx db.Tx) error { // This crudely tests whether or not a user is a manager of the initiative. - if err := ia.s.initiativeDoAuthzAndAuditLog(ctx, ia.iID, pacta.AuditLogAction_Update); err != nil { + if _, err := ia.s.initiativeDoAuthzAndAuditLog(ctx, ia.iID, pacta.AuditLogAction_Update); err != nil { return err } i, err := ia.s.DB.Initiative(tx, ia.iID) diff --git a/cmd/server/pactasrv/authz.go b/cmd/server/pactasrv/authz.go index 8033407..52ec74b 100644 --- a/cmd/server/pactasrv/authz.go +++ b/cmd/server/pactasrv/authz.go @@ -79,15 +79,26 @@ func notFoundOrUnauthorized[T ~string](actorInfo actorInfo, action pacta.AuditLo } func (s *Server) auditLogIfAuthorizedOrFail(ctx context.Context, status *authzStatus) error { + zapFields := func(others ...zap.Field) []zap.Field { + return append([]zap.Field{ + zap.String("actor_id", status.actorUserID()), + zap.String("action", string(status.action)), + zap.String("target_type", string(status.primaryTargetType)), + zap.String("target_id", status.primaryTargetID), + }, others...) + } if !status.isAuthorized { + s.Logger.Warn("not authorized", zapFields(zap.String("reason", "is_authorized_false"))...) return notFoundOrUnauthorized(status.actorInfo, status.action, status.primaryTargetType, status.primaryTargetID) } al, err := status.ToAuditLog() if err != nil { + s.Logger.Warn("not authorized", zapFields(zap.String("reason", "to_audit_log_failure"), zap.Error(err))...) return err } _, err = s.DB.CreateAuditLog(s.DB.NoTxn(ctx), al) if err != nil { + s.Logger.Warn("not authorized", zapFields(zap.String("reason", "create_audit_log_failure"), zap.Error(err))...) return oapierr.Internal("creating audit log failed", zap.Error(err)) } return nil diff --git a/cmd/server/pactasrv/conv/helpers.go b/cmd/server/pactasrv/conv/helpers.go index 010350f..f616746 100644 --- a/cmd/server/pactasrv/conv/helpers.go +++ b/cmd/server/pactasrv/conv/helpers.go @@ -1,6 +1,12 @@ package conv -import "time" +import ( + "fmt" + "time" + + "github.com/RMI/pacta/oapierr" + "go.uber.org/zap" +) func ptr[T any](t T) *T { return &t @@ -52,10 +58,16 @@ func convAll[I any, O any](is []I, f func(I) (O, error)) ([]O, error) { return os, nil } -func dereferenceAll[T any](ts []*T) []T { +func dereference[T any](ts []*T, e error) ([]T, error) { + if e != nil { + return nil, e + } result := make([]T, len(ts)) for i, t := range ts { + if t == nil { + return nil, oapierr.Internal("nil pointer found in derference", zap.String("type", fmt.Sprintf("%T", t)), zap.Int("index", i)) + } result[i] = *t } - return result + return result, nil } diff --git a/cmd/server/pactasrv/conv/pacta_to_oapi.go b/cmd/server/pactasrv/conv/pacta_to_oapi.go index 7a93c88..7ea15fa 100644 --- a/cmd/server/pactasrv/conv/pacta_to_oapi.go +++ b/cmd/server/pactasrv/conv/pacta_to_oapi.go @@ -42,6 +42,10 @@ func InitiativeToOAPI(i *pacta.Initiative) (*api.Initiative, error) { if err != nil { return nil, oapierr.Internal("initiativeToOAPI: portfolioInitiativeMembershipToOAPIInitiative failed", zap.Error(err)) } + iurs, err := dereference(convAll(i.InitiativeUserRelationships, InitiativeUserRelationshipToOAPI)) + if err != nil { + return nil, oapierr.Internal("initiativeToOAPI: initiativeUserRelationshipToOAPI failed", zap.Error(err)) + } lang, err := LanguageToOAPI(i.Language) if err != nil { return nil, oapierr.Internal("initiativeToOAPI: languageToOAPI failed", zap.Error(err)) @@ -59,6 +63,7 @@ func InitiativeToOAPI(i *pacta.Initiative) (*api.Initiative, error) { PublicDescription: i.PublicDescription, RequiresInvitationToJoin: i.RequiresInvitationToJoin, PortfolioInitiativeMemberships: pims, + InitiativeUserRelationships: iurs, }, nil } @@ -250,7 +255,7 @@ func PortfolioToOAPI(p *pacta.Portfolio) (*api.Portfolio, error) { } pims, err := convAll(p.PortfolioInitiativeMemberships, portfolioInitiativeMembershipToOAPIInitiative) if err != nil { - return nil, oapierr.Internal("initiativeToOAPI: portfolioInitiativeMembershipToOAPIInitiative failed", zap.Error(err)) + return nil, oapierr.Internal("portfolioToOAPI: portfolioInitiativeMembershipToOAPIInitiative failed", zap.Error(err)) } hd, err := HoldingsDateToOAPI(p.Properties.HoldingsDate) if err != nil { @@ -408,7 +413,7 @@ func AnalysisToOAPI(a *pacta.Analysis) (*api.Analysis, error) { if a == nil { return nil, oapierr.Internal("analysisToOAPI: can't convert nil pointer") } - aas, err := convAll(a.Artifacts, AnalysisArtifactToOAPI) + aas, err := dereference(convAll(a.Artifacts, AnalysisArtifactToOAPI)) if err != nil { return nil, oapierr.Internal("analysisToOAPI: analysisArtifactsToOAPI failed", zap.Error(err)) } @@ -440,7 +445,7 @@ func AnalysisToOAPI(a *pacta.Analysis) (*api.Analysis, error) { CompletedAt: timeToNilable(a.CompletedAt), FailureCode: fc, FailureMessage: fm, - Artifacts: dereferenceAll(aas), + Artifacts: aas, OwnerId: string(a.Owner.ID), }, nil } diff --git a/cmd/server/pactasrv/initiative.go b/cmd/server/pactasrv/initiative.go index 2e4704e..4518c8e 100644 --- a/cmd/server/pactasrv/initiative.go +++ b/cmd/server/pactasrv/initiative.go @@ -50,7 +50,7 @@ func (s *Server) CreateInitiative(ctx context.Context, request api.CreateInitiat // (PATCH /initiative/{id}) func (s *Server) UpdateInitiative(ctx context.Context, request api.UpdateInitiativeRequestObject) (api.UpdateInitiativeResponseObject, error) { id := pacta.InitiativeID(request.Id) - if err := s.initiativeDoAuthzAndAuditLog(ctx, id, pacta.AuditLogAction_Update); err != nil { + if _, err := s.initiativeDoAuthzAndAuditLog(ctx, id, pacta.AuditLogAction_Update); err != nil { return nil, err } mutations := []db.UpdateInitiativeFn{} @@ -97,7 +97,7 @@ func (s *Server) UpdateInitiative(ctx context.Context, request api.UpdateInitiat // (DELETE /initiative/{id}) func (s *Server) DeleteInitiative(ctx context.Context, request api.DeleteInitiativeRequestObject) (api.DeleteInitiativeResponseObject, error) { id := pacta.InitiativeID(request.Id) - if err := s.initiativeDoAuthzAndAuditLog(ctx, id, pacta.AuditLogAction_Delete); err != nil { + if _, err := s.initiativeDoAuthzAndAuditLog(ctx, id, pacta.AuditLogAction_Delete); err != nil { return nil, err } buris, err := s.DB.DeleteInitiative(s.DB.NoTxn(ctx), id) @@ -113,9 +113,13 @@ func (s *Server) DeleteInitiative(ctx context.Context, request api.DeleteInitiat // Returns an initiative by ID // (GET /initiative/{id}) func (s *Server) FindInitiativeById(ctx context.Context, request api.FindInitiativeByIdRequestObject) (api.FindInitiativeByIdResponseObject, error) { - // TODO(#12) Allow Anonymous Access + actorInfo, err := s.getActorInfoOrAnon(ctx) + if err != nil { + return nil, err + } id := pacta.InitiativeID(request.Id) - if err := s.initiativeDoAuthzAndAuditLog(ctx, id, pacta.AuditLogAction_ReadMetadata); err != nil { + info, err := s.initiativeDoAuthzAndAuditLog(ctx, id, pacta.AuditLogAction_ReadMetadata) + if err != nil { return nil, err } i, err := s.DB.Initiative(s.DB.NoTxn(ctx), id) @@ -125,11 +129,27 @@ func (s *Server) FindInitiativeById(ctx context.Context, request api.FindInitiat } return nil, oapierr.Internal("failed to load initiative", zap.String("initiative_id", request.Id), zap.Error(err)) } - portfolios, err := s.DB.PortfolioInitiativeMembershipsByInitiative(s.DB.NoTxn(ctx), i.ID) - if err != nil { - return nil, oapierr.Internal("failed to load portfolios for initiative", zap.String("initiative_id", string(i.ID)), zap.Error(err)) + if info.CanManageUsersAndPortfolios { + portfolios, err := s.DB.PortfolioInitiativeMembershipsByInitiative(s.DB.NoTxn(ctx), i.ID) + if err != nil { + return nil, oapierr.Internal("failed to load portfolios for initiative", zap.String("initiative_id", string(i.ID)), zap.Error(err)) + } + i.PortfolioInitiativeMemberships = portfolios + relationships, err := s.DB.InitiativeUserRelationshipsByInitiative(s.DB.NoTxn(ctx), i.ID) + if err != nil { + return nil, oapierr.Internal("failed to load initiative user relationships for initiative", zap.String("initiative_id", string(i.ID)), zap.Error(err)) + } + i.InitiativeUserRelationships = relationships + } else if actorInfo.UserID != "" { + iur, err := s.DB.InitiativeUserRelationship(s.DB.NoTxn(ctx), i.ID, actorInfo.UserID) + if err != nil { + return nil, oapierr.Internal("failed to load singular initiative user relationship for initiative", zap.String("initiative_id", string(i.ID)), zap.Error(err)) + } + i.InitiativeUserRelationships = []*pacta.InitiativeUserRelationship{iur} + } + if !info.CanSeeInternalInfo { + i.InternalDescription = "" } - i.PortfolioInitiativeMemberships = portfolios resp, err := conv.InitiativeToOAPI(i) if err != nil { return nil, err @@ -144,6 +164,9 @@ func (s *Server) ListInitiatives(ctx context.Context, request api.ListInitiative if err != nil { return nil, oapierr.Internal("failed to load all initiatives", zap.Error(err)) } + for _, i := range is { + i.InternalDescription = "" + } result, err := dereference(mapAll(is, conv.InitiativeToOAPI)) if err != nil { return nil, err @@ -158,17 +181,14 @@ func (s *Server) AllInitiativeData(ctx context.Context, request api.AllInitiativ if err != nil { return nil, err } - // TODO(#12) Implement Authorization, along the lines of #121 - i, err := s.DB.Initiative(s.DB.NoTxn(ctx), pacta.InitiativeID(request.Id)) + id := pacta.InitiativeID(request.Id) + _, err = s.initiativeDoAuthzAndAuditLog(ctx, id, pacta.AuditLogAction_Download) if err != nil { - if db.IsNotFound(err) { - return nil, oapierr.NotFound("initiative not found", zap.String("initiative_id", request.Id)) - } - return nil, oapierr.Internal("failed to load initiative", zap.String("initiative_id", request.Id), zap.Error(err)) + return nil, err } - portfolioMembers, err := s.DB.PortfolioInitiativeMembershipsByInitiative(s.DB.NoTxn(ctx), i.ID) + portfolioMembers, err := s.DB.PortfolioInitiativeMembershipsByInitiative(s.DB.NoTxn(ctx), id) if err != nil { - return nil, oapierr.Internal("failed to load portfolio memberships for initiative", zap.String("initiative_id", string(i.ID)), zap.Error(err)) + return nil, oapierr.Internal("failed to load portfolio memberships for initiative", zap.String("initiative_id", string(id)), zap.Error(err)) } portfolioIDs := []pacta.PortfolioID{} for _, pm := range portfolioMembers { @@ -176,7 +196,7 @@ func (s *Server) AllInitiativeData(ctx context.Context, request api.AllInitiativ } portfolios, err := s.DB.Portfolios(s.DB.NoTxn(ctx), portfolioIDs) if err != nil { - return nil, oapierr.Internal("failed to load portfolios for initiative", zap.String("initiative_id", string(i.ID)), zap.Error(err)) + return nil, oapierr.Internal("failed to load portfolios for initiative", zap.String("initiative_id", string(id)), zap.Error(err)) } if err := s.populateBlobsInPortfolios(ctx, values(portfolios)...); err != nil { return nil, err @@ -192,7 +212,7 @@ func (s *Server) AllInitiativeData(ctx context.Context, request api.AllInitiativ PrimaryTargetID: string(p.ID), PrimaryTargetOwner: p.Owner, SecondaryTargetType: pacta.AuditLogTargetType_Initiative, - SecondaryTargetID: string(i.ID), + SecondaryTargetID: string(id), SecondaryTargetOwner: &pacta.Owner{ID: "SYSTEM"}, // TODO(#12) When merging with #121, use the const type. }) } @@ -216,24 +236,30 @@ func (s *Server) AllInitiativeData(ctx context.Context, request api.AllInitiativ ExpirationTime: expiryTime, }) } - return api.AllInitiativeData200JSONResponse(response), nil } -func (s *Server) initiativeDoAuthzAndAuditLog(ctx context.Context, iID pacta.InitiativeID, action pacta.AuditLogAction) error { - actorInfo, err := s.getActorInfoOrErrIfAnon(ctx) - if err != nil { - return err - } - iurs, err := s.DB.InitiativeUserRelationshipsByInitiative(s.DB.NoTxn(ctx), iID) +type initiativeAuthzVisibilityInfo struct { + CanManageUsersAndPortfolios bool + CanSeeInternalInfo bool +} + +func (s *Server) initiativeDoAuthzAndAuditLog(ctx context.Context, iID pacta.InitiativeID, action pacta.AuditLogAction) (*initiativeAuthzVisibilityInfo, error) { + actorInfo, err := s.getActorInfoOrAnon(ctx) if err != nil { - return oapierr.Internal("failed to list initiative user relationships", zap.Error(err)) + return nil, err } userIsInitiativeManager := false - for _, iur := range iurs { - if iur.User.ID == actorInfo.UserID && iur.Manager { - userIsInitiativeManager = true - break + userIsInitiativeMember := false + if actorInfo.UserID != "" { + iur, err := s.DB.InitiativeUserRelationship(s.DB.NoTxn(ctx), iID, actorInfo.UserID) + if err != nil { + if !db.IsNotFound(err) { + return nil, oapierr.Internal("failed to find initiative user relationships", zap.Error(err)) + } + } else { + userIsInitiativeMember = iur.Member + userIsInitiativeManager = iur.Manager } } as := &authzStatus{ @@ -244,7 +270,16 @@ func (s *Server) initiativeDoAuthzAndAuditLog(ctx context.Context, iID pacta.Ini action: action, } switch action { - case pacta.AuditLogAction_Delete, pacta.AuditLogAction_Create, pacta.AuditLogAction_ReadMetadata, pacta.AuditLogAction_Update: + case pacta.AuditLogAction_ReadMetadata: + as.isAuthorized = true + if userIsInitiativeManager { + as.authorizedAsActorType = ptr(pacta.AuditLogActorType_Owner) + } else if actorInfo.IsAdmin || actorInfo.IsSuperAdmin { + as.authorizedAsActorType = ptr(pacta.AuditLogActorType_Admin) + } else { + as.authorizedAsActorType = ptr(pacta.AuditLogActorType_Public) + } + case pacta.AuditLogAction_Delete, pacta.AuditLogAction_Create, pacta.AuditLogAction_Update: if userIsInitiativeManager { as.authorizedAsActorType = ptr(pacta.AuditLogActorType_Owner) as.isAuthorized = true @@ -252,7 +287,13 @@ func (s *Server) initiativeDoAuthzAndAuditLog(ctx context.Context, iID pacta.Ini as.isAuthorized, as.authorizedAsActorType = allowIfAdmin(actorInfo) } default: - return fmt.Errorf("unknown action %q for initiative authz", action) + return nil, fmt.Errorf("unknown action %q for initiative authz", action) + } + if err := s.auditLogIfAuthorizedOrFail(ctx, as); err != nil { + return nil, err } - return s.auditLogIfAuthorizedOrFail(ctx, as) + return &initiativeAuthzVisibilityInfo{ + CanSeeInternalInfo: userIsInitiativeMember || userIsInitiativeManager || actorInfo.IsAdmin || actorInfo.IsSuperAdmin, + CanManageUsersAndPortfolios: userIsInitiativeManager || actorInfo.IsAdmin || actorInfo.IsSuperAdmin, + }, nil } diff --git a/cmd/server/pactasrv/initiative_user_relationship.go b/cmd/server/pactasrv/initiative_user_relationship.go index 9cd64ec..c951125 100644 --- a/cmd/server/pactasrv/initiative_user_relationship.go +++ b/cmd/server/pactasrv/initiative_user_relationship.go @@ -30,43 +30,9 @@ func (s *Server) ListInitiativeUserRelationshipsByUser(ctx context.Context, requ return api.ListInitiativeUserRelationshipsByUser200JSONResponse(result), nil } -// Returns all initiative user relationships for the initiative that the user has access to view -// (GET /initiative/user-relationships/{id}) -func (s *Server) ListInitiativeUserRelationshipsByInitiative(ctx context.Context, request api.ListInitiativeUserRelationshipsByInitiativeRequestObject) (api.ListInitiativeUserRelationshipsByInitiativeResponseObject, error) { - id := pacta.InitiativeID(request.InitiativeId) - if err := s.initiativeDoAuthzAndAuditLog(ctx, id, pacta.AuditLogAction_ReadMetadata); err != nil { - return nil, err - } - iurs, err := s.DB.InitiativeUserRelationshipsByInitiative(s.DB.NoTxn(ctx), id) - if err != nil { - return nil, oapierr.Internal("failed to retrieve initiative user relationships by user", zap.Error(err)) - } - result, err := dereference(mapAll(iurs, conv.InitiativeUserRelationshipToOAPI)) - if err != nil { - return nil, err - } - return api.ListInitiativeUserRelationshipsByInitiative200JSONResponse(result), nil -} - -// Returns the initiative user relationship from this id, if it exists -// (GET /initiative/{initiativeId}/user-relationship/{userId}) -func (s *Server) GetInitiativeUserRelationship(ctx context.Context, request api.GetInitiativeUserRelationshipRequestObject) (api.GetInitiativeUserRelationshipResponseObject, error) { - // TODO(#12) Implement Authorization - iur, err := s.DB.InitiativeUserRelationship(s.DB.NoTxn(ctx), pacta.InitiativeID(request.InitiativeId), pacta.UserID(request.UserId)) - if err != nil { - return nil, oapierr.Internal("failed to retrieve initiative user relationship", zap.Error(err)) - } - result, err := conv.InitiativeUserRelationshipToOAPI(iur) - if err != nil { - return nil, err - } - return api.GetInitiativeUserRelationship200JSONResponse(*result), nil -} - // Updates initiative user relationship properties // (PATCH /initiative/{initiativeId}/user-relationship/{userId}) func (s *Server) UpdateInitiativeUserRelationship(ctx context.Context, request api.UpdateInitiativeUserRelationshipRequestObject) (api.UpdateInitiativeUserRelationshipResponseObject, error) { - // TODO(#12) Implement Authorization iID := pacta.InitiativeID(request.InitiativeId) uID := pacta.UserID(request.UserId) mutations := []db.UpdateInitiativeUserRelationshipFn{} diff --git a/frontend/components/initiative/Toolbar.vue b/frontend/components/initiative/Toolbar.vue deleted file mode 100644 index e1fe82d..0000000 --- a/frontend/components/initiative/Toolbar.vue +++ /dev/null @@ -1,76 +0,0 @@ - - - diff --git a/frontend/composables/useInitiativeData.ts b/frontend/composables/useInitiativeData.ts new file mode 100644 index 0000000..d9a68de --- /dev/null +++ b/frontend/composables/useInitiativeData.ts @@ -0,0 +1,53 @@ +import { type InitiativeUserRelationship, type InitiativeInvitation } from '@/openapi/generated/pacta' + +export const useInitiativeData = async (id: string) => { + const prefix = `useInitiativeData[${id}]` + + const pactaClient = usePACTA() + const { getMaybeMe } = useSession() + + const { maybeMe, isAdmin, isSuperAdmin } = await getMaybeMe() + + const meRelationships = useState(`${prefix}.meRelationships`, () => []) + if (maybeMe.value) { + meRelationships.value = await pactaClient.listInitiativeUserRelationshipsByUser(maybeMe.value.id) + } + + const isManagerByMe = computed(() => meRelationships.value.some((r) => r.initiativeId === id && r.manager)) + + const canManageByMe = computed(() => isAdmin.value || isSuperAdmin.value || isManagerByMe.value) + + const maybeLookUpInvitationsByInitiative = async (): Promise => { + if (!canManageByMe.value) { + return [] + } + return await pactaClient.listInitiativeInvitations(id) + } + + const [ + { data: initiative, refresh: refreshInitiative }, + { data: invitations, refresh: refreshInvitations }, + ] = await Promise.all([ + useSimpleAsyncData(`${prefix}.getInitiative`, () => pactaClient.findInitiativeById(id)), + useSimpleAsyncData(`${prefix}.getInvitations`, maybeLookUpInvitationsByInitiative), + ]) + + const isMember = computed(() => initiative.value.initiativeUserRelationships.some((r) => r.member)) + const isManager = computed(() => initiative.value.initiativeUserRelationships.some((r) => r.manager)) + const canManage = computed(() => canManageByMe.value || isManager.value) + + const canJoinIfLoggedIn = computed(() => !isMember.value && !isManager.value && initiative.value.isAcceptingNewMembers && !initiative.value.requiresInvitationToJoin) + const canDirectlyJoin = computed(() => canJoinIfLoggedIn.value && maybeMe.value) + + return { + initiative, + refreshInitiative, + invitations, + refreshInvitations, + canManage, + isMember, + isManager, + canDirectlyJoin, + canJoinIfLoggedIn, + } +} diff --git a/frontend/composables/usePACTA.ts b/frontend/composables/usePACTA.ts index e719274..03d0c8f 100644 --- a/frontend/composables/usePACTA.ts +++ b/frontend/composables/usePACTA.ts @@ -30,7 +30,7 @@ export const usePACTA = () => { // interface of our auto-generated code, which expects a class that extends // BaseHttpRequest. const httpReqClass = class extends BaseHttpRequest { - private readonly getToken: () => Promise + private readonly getToken: () => Promise constructor (config: OpenAPIConfig) { super(config) diff --git a/frontend/lang/en.json b/frontend/lang/en.json index 279ff32..aee2a2c 100644 --- a/frontend/lang/en.json +++ b/frontend/lang/en.json @@ -68,7 +68,9 @@ "AuditLogsHelpText": "Audit logs are a record of who has accessed or modified this analysis, and when. This is useful for debugging, for understanding who has seen this analysis, and for establishing peace of mind that your data's security is being upheld.", "View Audit Logs": "View Audit Logs" }, - "components/initiative/Toolbar": { + "pages/initiative": { + "Initiative": "Initiative", + "Portfolios": "Portfolios", "Edit": "Edit", "Initiative Home": "Initiative Home", "Internal Information": "Internal Information", @@ -313,19 +315,6 @@ "Edit": "Edit", "Profile": "Profile" }, - "initiative/invitations": { - "Create": "Create", - "Create Initiative Invitations": "Create Initiative Invitations", - "Heading": "Initaitive Invitations", - "Includes Illegal Characters": "Includes illegal characters", - "New Invitations": "New Invitations", - "New Invitations Help": "Create new invitations by clicking the button below. You can create as many invitations as you want, but each invitation can only be used once. Invitation codes must only contain alphanumeric characters (a-z, A-Z, 0-9), underscores and dashes. You can create multiple invitations at once by separating them with tabs, commas, newlines, or spaces.", - "No Invitations": "This initiative does not require an invitation to join - anyone who finds this initiative can join it.", - "Unused": "Unused", - "Yes Invitations": "This initiative requires an invitation (a unique, one-time code) to join. You can create these invitations below.", - "You can change": "You can change this behavior on the", - "edit initiative page": "edit initiative page" - }, "lib/editor/portfolio": { "Created At": "Created At", "ID": "ID", @@ -405,7 +394,19 @@ "New Invitations": "New Invitations", "New Invitations Help": "Create new invitations by entering new values in the box below. If you don't care what the invitation codes look like you can press the randomize button to generate random ones. You can create as many invitations as you want, but each invitation can only be used once. Invitation codes must only contain alphanumeric characters (a-z, A-Z, 0-9), underscores and dashes. You can create multiple invitations at once by separating them with tabs, commas, newlines, or spaces.", "Generate Random": "Generate Random", - "Cancel": "Cancel" + "Cancel": "Cancel", + "Create": "Create", + "Heading": "Initaitive Invitations", + "Includes Illegal Characters": "Includes illegal characters", + "No Invitations": "This initiative does not require an invitation to join - anyone who finds this initiative can join it.", + "You can change": "You can change this behavior on the", + "edit initiative page": "edit initiative page" + }, + "pages/admin/initiative/new": { + "New Initiative": "New Initiative", + "Paragraph1": "Initiatives are a way to organize portfolios for common analysis and reporting. They typicall correspond to a COP managed projects where investors are contributing data for analysis to a regulator.", + "Discard": "Discard", + "Save": "Save" }, "pages/initiative/relationships": { "Actions": "Actions", diff --git a/frontend/lib/editor/initiative.ts b/frontend/lib/editor/initiative.ts index cccd077..1bf254d 100644 --- a/frontend/lib/editor/initiative.ts +++ b/frontend/lib/editor/initiative.ts @@ -71,6 +71,10 @@ const createEditorInitiativeFields = (translation: Translation): EditorInitiativ name: 'portfolioInitiativeMemberships', label: tt('Portfolio Initiative Memberships'), }, + initiativeUserRelationships: { + name: 'initiativeUserRelationships', + label: tt('Initiative User Relationships'), + }, } } diff --git a/frontend/openapi/generated/pacta/models/Initiative.ts b/frontend/openapi/generated/pacta/models/Initiative.ts index a1cca61..47a82cf 100644 --- a/frontend/openapi/generated/pacta/models/Initiative.ts +++ b/frontend/openapi/generated/pacta/models/Initiative.ts @@ -3,6 +3,7 @@ /* tslint:disable */ /* eslint-disable */ +import type { InitiativeUserRelationship } from './InitiativeUserRelationship'; import type { Language } from './Language'; import type { PortfolioInitiativeMembershipPortfolio } from './PortfolioInitiativeMembershipPortfolio'; @@ -51,6 +52,10 @@ export type Initiative = { * the list of portfolios that are members of this initiative */ portfolioInitiativeMemberships: Array; + /** + * the list of users that are members of this initiative + */ + initiativeUserRelationships: Array; /** * The time at which this initiative was created. */ diff --git a/frontend/openapi/generated/pacta/services/DefaultService.ts b/frontend/openapi/generated/pacta/services/DefaultService.ts index 05c2297..4d1bbcb 100644 --- a/frontend/openapi/generated/pacta/services/DefaultService.ts +++ b/frontend/openapi/generated/pacta/services/DefaultService.ts @@ -305,24 +305,6 @@ export class DefaultService { }); } - /** - * Returns all initiative user relationships for this initiative that the caller has access to view - * @param initiativeId ID of the initiative to fetch relationships for - * @returns InitiativeUserRelationship - * @throws ApiError - */ - public listInitiativeUserRelationshipsByInitiative( - initiativeId: string, - ): CancelablePromise> { - return this.httpRequest.request({ - method: 'GET', - url: '/initiative/{initiativeId}/user-relationships', - path: { - 'initiativeId': initiativeId, - }, - }); - } - /** * Returns all initiative user relationships for this user that the caller has access to view * @param userId ID of the user to fetch relationships for @@ -435,27 +417,6 @@ export class DefaultService { }); } - /** - * Returns the initiative user relationship from this id, if it exists - * @param initiativeId ID of the initiative - * @param userId ID of the user - * @returns InitiativeUserRelationship - * @throws ApiError - */ - public getInitiativeUserRelationship( - initiativeId: string, - userId: string, - ): CancelablePromise { - return this.httpRequest.request({ - method: 'GET', - url: '/initiative/{initiativeId}/user-relationship/{userId}', - path: { - 'initiativeId': initiativeId, - 'userId': userId, - }, - }); - } - /** * Updates initiative user relationship properties * Updates a given user's relationship properties for a given initiative diff --git a/frontend/pages/admin/initiative/index.vue b/frontend/pages/admin/initiative/index.vue index 65f3cfd..b6840a3 100644 --- a/frontend/pages/admin/initiative/index.vue +++ b/frontend/pages/admin/initiative/index.vue @@ -19,10 +19,6 @@ const deleteInitiative = (id: string) => withLoading(