Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Frontend Build Out #41

Merged
merged 9 commits into from
Nov 16, 2023
Merged

Frontend Build Out #41

merged 9 commits into from
Nov 16, 2023

Conversation

gbdubs
Copy link
Contributor

@gbdubs gbdubs commented Oct 16, 2023

NOTE: This PR is a poorly structured amalgomation of a bunch of small changes. In the future I'll try to do better to structure changes like these as smaller, more focused PRs - just during this initial build out, so much is changing so quickly, I'm struggling to find the "right" boundaries for increments of work.

Contentious

  • Starts Populating UserID in context, and in doing so, moves the logic for extracting a PACTA User ID from a Authn User ID and moves it into a server handler in the main.go method. I don't love this so if you have better Ideas on how this can be done, please let me know.
  • Creates a Fake Users Modal (since MSAL doesn't have a direct authn mechanism that I can find, I think this kind of "shared test account" strategy may be the best we can do.
  • merges useSession.signedIn and useMSAL.isAuthenticated to fix a few issues with hydration mismtaches/SSR 500s.

Non-Contentious

  • Creates OpenAPI methods for
    • User Editing + Retrieval
    • Initiative Invitation Editing + Claiming
    • Initiative Membership Listing
  • Creates rudimentary frontends for initiatives, users, relationships, etc. The goal of these isn't to be a polished final result, but demonstrate basic functionality. I've added i18n everywhere I expect to be stable/permanent, but have not bothered with it elsewhere.

@gbdubs gbdubs requested a review from bcspragu October 16, 2023 16:08
Copy link
Collaborator

@bcspragu bcspragu left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Main takeaways:

  • Lots of progress, great work!
  • Need to refactor UserID populating middleware (i.e. dump into a session package)
  • Seems like we should talk about API design and stuff
  • No idea why invitations are the way they are
  • No composable loops or calling outside of setup for general FE stability

(Approved, but maybe let's chat and do one more round of review before this goes in?)

cmd/server/main.go Outdated Show resolved Hide resolved

return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, err := fn(r.Context())
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I think this will go away with the above comment, but just in case it doesn't: normally, one only returns a new context from functions that are dedicated to contexts, e.g. WithX or context.WithX functions. So I'd change it to something like:

fn := func(c context.Context) (pacta.UserID, error) { ... }
[...]
return func(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		uID, err := fn(r.Context())
		ctx := session.WithUserID(ctx, uID)
		next.ServeHTTP(w, r.WithContext(ctx))
	}
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep - Done.

}

// Claims this initiative invitation, if it exists
// (POST /initiative-invitation/{id})
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I think of "claiming an invitation" as a non-CRUD action that should get a custom verb, like POST /initiative-invitation/{id}:claim. Benefits of this:

  • Unambigious about what it's for, makes reading logs and stuff easier
  • If you have another "updating an initiative invitation" endpoint you need to add, you won't have a collision on POST /initiative-invitation/{id}

Some Google Cloud guide that discusses this at length

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

return fmt.Errorf("looking up initiative invite: %w", err)
}
if !ii.UsedAt.IsZero() || ii.UsedBy != nil {
if ii.UsedBy != nil && ii.UsedBy.ID == userID {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My usual grumbling about new paradigms I don't like: the whole "We use objects for everything and only populate the IDs" makes all of this logic one conditional comparision more than it otherwise would need to be. E.g. as compared to

if ii.UsedByID == userID { ... }

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I think in most cases you'll come to like this pattern. I've found it increasingly lovely for side projects.

// We may want to log this, though.
return nil
} else {
return fmt.Errorf("initiative is already used: %+v", ii)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should be using oapierr.BadRequest (or similar) for this. For this case in particular, we likely want a custom error code so we can show a specific error to the end user. Then you'd just need to update the error logic below to:

if errors.Is(err, oapierr.Error{}) { // or something like that, could also make oapierr.Is(err) or similar
	return err
} else if err != nil {
	return oapierr.Internal(...)
}

You could even make a lil helper, like return oapierr.OrInternal(err)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, but through different means, as an external var.

Comment on lines +7 to +11
const [
{ data: user },
] = await Promise.all([
useSimpleAsyncData(`${prefix}.findUserById`, () => pactaClient.findUserById(id)),
])
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same note here, don't need Promise.all if you only got one promise.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Next PR adds more :)

frontend/pages/user/[id]/index.vue Outdated Show resolved Hide resolved
Comment on lines -52 to 62
console.info(message)
// console.info(message)
return
case LogLevel.Verbose:
console.debug(message)
// console.debug(message)
return
case LogLevel.Warning:
console.warn(message)
return
case LogLevel.Trace:
console.trace(message)
// console.trace(message)
return
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

undo pls

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've found this logging makes the console semi-unusable - sorting through hundreds of extraneous logs in order to find anything other than authn information. I think our default should be to a clean log. Keeping warning and error messages makes a ton of sense, but the default happy path of console logging should not create noise.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

^^

openapi/pacta.yaml Show resolved Hide resolved
openapi/pacta.yaml Outdated Show resolved Hide resolved
@gbdubs gbdubs requested a review from bcspragu November 15, 2023 15:18
Copy link
Collaborator

@bcspragu bcspragu left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Boosted a few previous comments that should be addressed/we should discuss, but otherwise LGTM

Comment on lines +38 to +69
fn := func(c context.Context) (context.Context, error) {
token, _, err := jwtauth.FromContext(c)
if err != nil {
return nil, fmt.Errorf("error getting authorization token: %w", err)
}
if token == nil {
return nil, fmt.Errorf("nil authorization token")
}
authnID := token.Subject()
if authnID == "" {
return nil, fmt.Errorf("couldn't find authn id in jwt")
}
user, err := d.UserByAuthn(d.NoTxn(c), pacta.AuthnMechanism_EmailAndPass, authnID)
if err != nil {
return nil, fmt.Errorf("failed to get user by authn: %w", err)
}
return WithUserID(c, user.ID), nil
}

return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, err := fn(r.Context())
if err != nil {
// Optionally log errors here when debugging authentication access.
// logger.Warn("couldn't authenticate", zap.Error(err))
// http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
next.ServeHTTP(w, r)
return
}
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few random tweaks, just to abstract some concerns better. E.g. it'd be easier to pull loadUserID into a function and test it independently now:

Suggested change
fn := func(c context.Context) (context.Context, error) {
token, _, err := jwtauth.FromContext(c)
if err != nil {
return nil, fmt.Errorf("error getting authorization token: %w", err)
}
if token == nil {
return nil, fmt.Errorf("nil authorization token")
}
authnID := token.Subject()
if authnID == "" {
return nil, fmt.Errorf("couldn't find authn id in jwt")
}
user, err := d.UserByAuthn(d.NoTxn(c), pacta.AuthnMechanism_EmailAndPass, authnID)
if err != nil {
return nil, fmt.Errorf("failed to get user by authn: %w", err)
}
return WithUserID(c, user.ID), nil
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, err := fn(r.Context())
if err != nil {
// Optionally log errors here when debugging authentication access.
// logger.Warn("couldn't authenticate", zap.Error(err))
// http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
next.ServeHTTP(w, r)
return
}
next.ServeHTTP(w, r.WithContext(ctx))
})
}
loadUserID := func(ctx context.Context) (pacta.UserID, error) {
token, _, err := jwtauth.FromContext(ctx)
if err != nil {
return "", fmt.Errorf("error getting jwt from context: %w", err)
}
if token == nil {
return "", errors.New("nil authorization token")
}
authnID := token.Subject()
if authnID == "" {
return "", errors.New("no 'sub' claim in JWT")
}
user, err := d.UserByAuthn(d.NoTxn(ctx), pacta.AuthnMechanism_EmailAndPass, authnID)
if err != nil {
return "", fmt.Errorf("failed to load user %q by authn: %w", authnID, err)
}
return user.ID, nil
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
userID, err := fn(ctx)
if err != nil {
// Optionally log errors here when debugging authentication access.
// logger.Warn("couldn't authenticate", zap.Error(err))
// http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
next.ServeHTTP(w, r)
return
}
next.ServeHTTP(w, r.WithContext(WithUserID(ctx, userID)))
})
}

Comment on lines +61 to +62
// Optionally log errors here when debugging authentication access.
// logger.Warn("couldn't authenticate", zap.Error(err))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we don't have any endpoints that are public, and since this runs after the JWT verification flow, if this fails, we want to fail the request here, as this will be a programmer bug 10 times out of 10. Otherwise, it's just going to go onto the handler, which will then fail because it couldn't load the UserID. At least here, we catch it at the source. I'd log at ERROR-level and throw a http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI We will have non-public endpoints: initiatives are going to be non-public. Does that change your reasoning?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Discussed offline, there will be public endpoints. That said, our current middleware has no way to poke holes for public endpoints, so anything non-auth'd will fail at the moment

chimiddleware.Recoverer,

jwtauth.Verifier(jwtauth.New("EdDSA", nil, jwKey)),
jwtauth.Authenticator,
session.WithAuthn(logger, db),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nothing needs to change here, but because auth in RMI-land is so different than AS-land, we really don't need a whole package and middleware for this, it could just be a server helper like s.getUser or s.getUserID. It's no more or less code in the endpoints themselves, just the difference between session.UserID(ctx) and s.getUserID(ctx).

if i == nil {
return nil, oapierr.Internal("initiativeInvitationToOAPI: can't convert nil pointer")
}
if !initiativeIDRegex.MatchString(i.Id) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't remember if I asked this before, but why are we validating IDs, aren't we the ones generating them? And if we aren't the ones generating them, let's chat.

Comment on lines +66 to +68
if i.UsedBy != nil {
usedBy = ptr(string(i.UsedBy.ID))
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you expect to be doing this a lot, you could make a generic nullablePtr or similar helper, then inline this in the return

frontend/composables/useTime.ts Outdated Show resolved Hide resolved
Comment on lines +12 to +14
useSimpleAsyncData(`${prefix}.getInitiative`, () => pactaClient.findInitiativeById(id)),
useSimpleAsyncData(`${prefix}.getInvitations`, () => pactaClient.listInitiativeInvitations(id)),
useSimpleAsyncData(`${prefix}.getRelationships`, () => pactaClient.listInitiativeUserRelationshipsByInitiative(id)),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

^^

Comment on lines +7 to +13
const [
{ data: initiative },
] = await Promise.all([
useSimpleAsyncData(`${prefix}.getInitiative`, () => pactaClient.findInitiativeById(id)),
useSimpleAsyncData(`${prefix}.getInvitations`, () => pactaClient.listInitiativeInvitations(id)),
useSimpleAsyncData(`${prefix}.getRelationships`, () => pactaClient.listInitiativeUserRelationshipsByInitiative(id)),
])
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

^^

Comment on lines +13 to +17
const [
{ data: relationships },
] = await Promise.all([
useSimpleAsyncData(`${prefix}.getRelationships`, () => pactaClient.listInitiativeUserRelationshipsByInitiative(id)),
])
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(this was resolved, but it's still a Promise.all on a single promise)

Comment on lines -52 to 62
console.info(message)
// console.info(message)
return
case LogLevel.Verbose:
console.debug(message)
// console.debug(message)
return
case LogLevel.Warning:
console.warn(message)
return
case LogLevel.Trace:
console.trace(message)
// console.trace(message)
return
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

^^

@@ -0,0 +1,5 @@
export const useIsAuthenticated = () => {
const prefix = 'useIsAuthenticated'
const isAuthenticated = useState<boolean>(`${prefix}.isAuthenticated`, () => true)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, straggling comment: why does this default to true?

@gbdubs
Copy link
Contributor Author

gbdubs commented Nov 16, 2023

Discussed offline - hitting the timebox on this. Smaller, better PRs going forward.

@gbdubs gbdubs enabled auto-merge (squash) November 16, 2023 00:38
@gbdubs gbdubs merged commit 09a2315 into main Nov 16, 2023
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants