From 7450f7f6956fec190367b957ddcce03fd6d546f8 Mon Sep 17 00:00:00 2001 From: Grady Ward Date: Tue, 23 Jan 2024 10:56:33 -0700 Subject: [PATCH] Allows Anonymous Initiative Viewing --- cmd/server/main.go | 32 +++++- cmd/server/pactasrv/analysis.go | 2 +- cmd/server/pactasrv/authz.go | 12 ++ 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 | 4 +- frontend/lib/editor/initiative.ts | 4 + .../generated/pacta/models/Initiative.ts | 5 + .../pacta/services/DefaultService.ts | 39 ------- frontend/pages/admin/initiative/new.vue | 1 + 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 | 19 ++++ 24 files changed, 341 insertions(+), 332 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..6880cdd 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,24 @@ func run(args []string) error { return nil } +var publicEndpoints = []*regexp.Regexp{ + regexp.MustCompile(`^/initiative/[^/]*$`), +} + +func requireJWTIfNotPublicEndpoint(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + for _, re := range publicEndpoints { + if re.MatchString(path) { + 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 +400,17 @@ 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. + ip := r.Header.Get("X-Real-IP") + if ip == "" { + ip = r.Header.Get("X-Forwarded-For") + } + if ip == "" { + ip = r.RemoteAddr + } + return ip, 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..d54a5b1 100644 --- a/cmd/server/pactasrv/authz.go +++ b/cmd/server/pactasrv/authz.go @@ -79,15 +79,27 @@ func notFoundOrUnauthorized[T ~string](actorInfo actorInfo, action pacta.AuditLo } func (s *Server) auditLogIfAuthorizedOrFail(ctx context.Context, status *authzStatus) error { + zapFields := []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), + } if !status.isAuthorized { + zapFields = append(zapFields, zap.String("reason", "is_authorized_false")) + s.Logger.Warn("not authorized", zapFields...) return notFoundOrUnauthorized(status.actorInfo, status.action, status.primaryTargetType, status.primaryTargetID) } al, err := status.ToAuditLog() if err != nil { + zapFields = append(zapFields, zap.String("reason", "to_audit_log_failure"), zap.Error(err)) + s.Logger.Warn("not authorized", zapFields...) return err } _, err = s.DB.CreateAuditLog(s.DB.NoTxn(ctx), al) if err != nil { + zapFields = append(zapFields, zap.String("reason", "create_audit_log_failure"), zap.Error(err)) + s.Logger.Warn("not authorized", zapFields...) 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 cce1a68..ddb795e 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", 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/new.vue b/frontend/pages/admin/initiative/new.vue index 1141b1b..b0a5dec 100644 --- a/frontend/pages/admin/initiative/new.vue +++ b/frontend/pages/admin/initiative/new.vue @@ -23,6 +23,7 @@ const defaultInitiative = { pactaVersion: undefined, createdAt: '', portfolioInitiativeMemberships: [], + initiativeUserRelationships: [], } const { editorFields, diff --git a/frontend/pages/initiative/[id].vue b/frontend/pages/initiative/[id].vue index 8fd69b4..921f4c6 100644 --- a/frontend/pages/initiative/[id].vue +++ b/frontend/pages/initiative/[id].vue @@ -1,33 +1,82 @@ diff --git a/frontend/pages/initiative/[id]/edit.vue b/frontend/pages/initiative/[id]/edit.vue index d8d7dac..94c1674 100644 --- a/frontend/pages/initiative/[id]/edit.vue +++ b/frontend/pages/initiative/[id]/edit.vue @@ -12,15 +12,17 @@ const { t } = i18n const id = presentOrCheckURL(fromParams('id')) const prefix = `pages/initiative/${id}` -const { data } = await useSimpleAsyncData(`${prefix}.getInitiative`, () => pactaClient.findInitiativeById(id)) + +const { initiative } = await useInitiativeData(id) + const { editorValues, editorFields, changes, saveTooltip, canSave, -} = initiativeEditor(presentOrCheckURL(data.value, 'no initiative in response'), i18n) -const tt = (key: string) => t(`pages/initiative/id.${key}`) +} = initiativeEditor(presentOrCheckURL(initiative.value, 'no initiative in response'), i18n) +const tt = (key: string) => t(`pages/initiative/id/edit.${key}`) const deleteInitiative = () => withLoading( () => pactaClient.deleteInitiative(id) diff --git a/frontend/pages/initiative/[id]/index.vue b/frontend/pages/initiative/[id]/index.vue index 41953ce..b70817d 100644 --- a/frontend/pages/initiative/[id]/index.vue +++ b/frontend/pages/initiative/[id]/index.vue @@ -5,19 +5,14 @@ const pactaClient = usePACTA() const { fromParams } = useURLParams() const { humanReadableDateFromStandardString } = useTime() const { getMaybeMe } = useSession() +const { signIn } = useSignIn() const { loading: { withLoading } } = useModal() const { maybeMe } = await getMaybeMe() const id = presentOrCheckURL(fromParams('id')) -const prefix = `initiative/${id}` -const [ - { data: initiative }, - { data: relationships, refresh: refreshRelationships }, -] = await Promise.all([ - useSimpleAsyncData(`${prefix}.getInitiative`, () => pactaClient.findInitiativeById(id)), - useSimpleAsyncData(`${prefix}.getRelationships`, () => pactaClient.listInitiativeUserRelationshipsByInitiative(id)), -]) + +const { initiative, refreshInitiative, canDirectlyJoin, isMember, canJoinIfLoggedIn } = await useInitiativeData(id) const status = computed(() => { const i = initiative.value @@ -31,15 +26,6 @@ const status = computed(() => { } }) -const isAMember = computed(() => { - const mm = maybeMe.value - if (!mm) return false - return relationships.value.some((r) => r.userId === mm.id && r.member) -}) -const canJoin = computed(() => { - const i = initiative.value - return maybeMe.value && i.isAcceptingNewMembers && !i.requiresInvitationToJoin && !isAMember.value -}) const join = () => { void withLoading(async () => { await pactaClient.updateInitiativeUserRelationship( @@ -47,7 +33,7 @@ const join = () => { presentOrFileBug(maybeMe.value).id, { member: true }, ) - await refreshRelationships() + await refreshInitiative() }, 'initiative/join') } @@ -75,24 +61,31 @@ const join = () => { {{ initiative.publicDescription }} + -const pactaClient = usePACTA() const { fromParams } = useURLParams() const id = presentOrCheckURL(fromParams('id')) -const prefix = `initiative/${id}/invitations` -const [ - { data: initiative }, -] = await Promise.all([ - useSimpleAsyncData(`${prefix}.getInitiative`, () => pactaClient.findInitiativeById(id)), - useSimpleAsyncData(`${prefix}.getInvitations`, () => pactaClient.listInitiativeInvitations(id)), - useSimpleAsyncData(`${prefix}.getRelationships`, () => pactaClient.listInitiativeUserRelationshipsByInitiative(id)), -]) + +const { initiative } = await useInitiativeData(id)