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

Account Switching #644

Merged
merged 34 commits into from
Sep 12, 2024
Merged

Account Switching #644

merged 34 commits into from
Sep 12, 2024

Conversation

ekzyis
Copy link
Member

@ekzyis ekzyis commented Nov 19, 2023

Closes #489

This PR implements account switching:

2023-11-19-021317_1920x1080_scrot

Implementation details

If a user wants to add an account, they are redirected to /login with multiAuth param set:

components/switch-account.js:

const AddAccount = () => {
  const router = useRouter()
  return (
    <div className='d-flex flex-column me-2 my-1 text-center'>
      <Image
        width='135' height='135' src='https://imgs.search.brave.com/t8qv-83e1m_kaajLJoJ0GNID5ch0WvBGmy7Pkyr4kQY/rs:fit:860:0:0/g:ce/aHR0cHM6Ly91cGxv/YWQud2lraW1lZGlh/Lm9yZy93aWtpcGVk/aWEvY29tbW9ucy84/Lzg5L1BvcnRyYWl0/X1BsYWNlaG9sZGVy/LnBuZw' style={{ cursor: 'pointer' }} onClick={() => {
          router.push({
            pathname: '/login',
            query: { callbackUrl: window.location.origin + router.asPath, multiAuth: true }
          })
        }}
      />
      <div className='fst-italic'>+ add account</div>
    </div>
  )
}

With this param, a login flow within a session can be initiated. This param is checked in the backend to only set cookies without actually switching the user (return token instead of return user):

if (multiAuth) {
  // we want to add a new account to 'switch accounts'
  const secret = process.env.NEXTAUTH_SECRET
  // default expiration for next-auth JWTs is in 1 month
  const expiresAt = datePivot(new Date(), { months: 1 })
  const cookieOptions = {
    path: '/',
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    expires: expiresAt
  }
  const userJWT = await encodeJWT({
    token: {
      id: user.id,
      name: user.name,
      email: user.email
    },
    secret
  })
  const me = await prisma.user.findUnique({ where: { id: token.id } })
  const tokenJWT = await encodeJWT({ token, secret })
  // NOTE: why can't I put this in a function with a for loop?!
  res.appendHeader('Set-Cookie', cookie.serialize(`multi_auth.${user.id}`, userJWT, cookieOptions))
  res.appendHeader('Set-Cookie', cookie.serialize(`multi_auth.${me.id}`, tokenJWT, cookieOptions))
  res.appendHeader('Set-Cookie',
    cookie.serialize('multi_auth',
      JSON.stringify([
        { id: user.id, name: user.name, photoId: user.photoId },
        { id: me.id, name: me.name, photoId: me.photoId }
      ]),
      { ...cookieOptions, httpOnly: false }))
  // don't switch accounts, we only want to add. switching is done in client via "pointer cookie"
  return token
}
return null

With these multi_auth.* cookies set, we can now show accounts to the user to which they can switch. The multi_auth
cookie is not HTTP only since we want to access it from JS. This shouldn't be a problem since this is only for reading which accounts can be switched to and render this view:

2023-11-19-022224_499x506_scrot

On click, another cookie multi_auth.user-id is created via JS. This cookie is read inside a middleware in the backend to replace the session cookie that will be used to determine the user in the backend:

middleware.js:

const multiAuthMiddleware = (request) => {
  // switch next-auth session cookie with multi_auth cookie if cookie pointer present
  const userId = request.cookies?.get('multi_auth.user-id')?.value
  const sessionCookieName = '__Secure-next-auth.session-token'
  const hasSession = request.cookies?.has(sessionCookieName)
  if (userId && hasSession) {
    const userJWT = request.cookies.get(`multi_auth.${userId}`)?.value
    if (userJWT) request.cookies.set(sessionCookieName, userJWT)
  }
  const response = NextResponse.next({ request })
  return response
}

Showcase

2023-11-19.02-25-56.mp4

TODO

  • switching to anon
  • UI to go from anon back to session
  • add multiAuth param to all login methods
  • testing
  • replace hardcoded link for add account image
  • handle case where account is added which doesn't exist yet (this will simply create a new account now but current account will never be updated if multiAuth is used)
  • also refresh JWTs in multi_auth.* (?) (how?)
  • think more about if using this "cookie pointer" approach is secure (found an article which also mentions "cookie pointers" - and I thought I invented that term, lol)
  • middleware is now applied to all routes ... performance impact? (nah, i think it's okay)
  • fix bug when looking at item and switching between anon and user ("fixed" in 0c8893c)
  • on logout, only delete session token for current logged in account and move user to next available account

@ekzyis ekzyis marked this pull request as draft November 19, 2023 01:28
@ekzyis ekzyis added the feature new product features that weren't there before label Nov 19, 2023
@ekzyis ekzyis force-pushed the 489-account-switching branch 2 times, most recently from 3de1b7e to 58ac860 Compare November 20, 2023 22:54
@ekzyis
Copy link
Member Author

ekzyis commented Nov 21, 2023

There is a bug where you can't switch between anon and a user if you're currently looking at an item:

https://files.ekzyis.com/public/sn/account_switching_item_bug.mp4

I'll probably need to rewrite the corresponding code which calls hooks conditionally (depending on me)

edit: "fixed" in 0c8893c

@ekzyis ekzyis force-pushed the 489-account-switching branch from 6b8c702 to b7c59a1 Compare November 21, 2023 04:26
@ekzyis
Copy link
Member Author

ekzyis commented Nov 22, 2023

Current state: https://files.ekzyis.com/public/sn/account_switching_002.mp4

Missing (see PR description):

  • JWT refresh: Figuring out if I can hook into whatever NextAuth calls to refresh the session token.
  • Multi auth support for Github, Email, Twitter: Going to be hard since I can't really test (maybe github) and their flows are very different. But let's see
  • some more testing
  • replace hardcoded link for add account image

@ekzyis ekzyis force-pushed the 489-account-switching branch from ee772b1 to 9bbc93d Compare November 22, 2023 01:39
@ekzyis
Copy link
Member Author

ekzyis commented Nov 22, 2023

Mhh, okay, can't find how to access a response to set the required multi_auth.* cookies on successful authentication for the Github, Twitter and Email provider. Looking at the source also didn't help. I decided to just remove these providers from the available auth methods if multiAuth is set in 77887ae.

Same problem with refreshing JWTs. For some reason, the jwt callback only has access to request and response on login/signup.

Putting this out of draft since everything else works, so I think we can ship as is.


Update: Unfortunately, this doesn't improve anon UX a lot since you can't pay from your account balance yet. And the cookies are shared between tabs so if you switch in one tab, you also switch in the other tab (obviously). Switching to anon in one tab also currently breaks the other tab since the state is not properly updated in the other tab (educated guess). The other tab then thinks you're completely signed out.

Keeping this as ready for review since I think this is still ready for review (lol)

One upside is that with this feature, I can easily keep track of replies to @hn.

@ekzyis ekzyis marked this pull request as ready for review November 22, 2023 04:43
@@ -36,8 +36,7 @@ export function postCommentBaseLineItems ({ baseCost = 1, comment = false, me })
}
}

export function postCommentUseRemoteLineItems ({ parentId, me } = {}) {
if (!me) return () => {}
Copy link
Member Author

@ekzyis ekzyis Nov 22, 2023

Choose a reason for hiding this comment

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

I hope this doesn't break anything but afaict, it doesn't. It just does unnecessary requests since anon has fixed fees. But removing this line is required to not run hooks conditionally. When switching to anon, this line changed the hook order and thus broke this rule:

Don’t call Hooks inside loops, conditions, or nested functions. Instead, always use Hooks at the top level of your React function, before any early returns. By following this rule, you ensure that Hooks are called in the same order each time a component renders. That’s what allows React to correctly preserve the state of Hooks between multiple useState and useEffect calls. (If you’re curious, we’ll explain this in depth below.)

Copy link
Member

Choose a reason for hiding this comment

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

This isn't a hook though. It's a function that returns a hook

Copy link
Member Author

Choose a reason for hiding this comment

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

It's a function that returns a hook

Yes but conditionally

Copy link
Member

Choose a reason for hiding this comment

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

User reported this hook error in prod on telegram. I'm not sure if it's related but I believe this is already conditional

Copy link
Member Author

Choose a reason for hiding this comment

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

but I believe this is already conditional

Yes, it is. That's what my change is trying to fix since the bug would manifest when switching to anon. The user report shows that the bug already manifests in some cases.

Example:

This is the full function:

export function postCommentUseRemoteLineItems ({ parentId, me } = {}) {
  if (!me) return () => {}
  const query = parentId
    ? gql`{ itemRepetition(parentId: "${parentId}") }`
    : gql`{ itemRepetition }`

  return function useRemoteLineItems () {
    const [line, setLine] = useState({})

    const { data } = useQuery(query, SSR ? {} : { pollInterval: 1000, nextFetchPolicy: 'cache-and-network' })

    useEffect(() => {
      const repetition = data?.itemRepetition
      if (!repetition) return setLine({})
      setLine({
        itemRepetition: {
          term: <>x 10<sup>{repetition}</sup></>,
          label: <>{repetition} {parentId ? 'repeat or self replies' : 'posts'} in 10m</>,
          modifier: (cost) => cost * Math.pow(10, repetition)
        }
      })
    }, [data?.itemRepetition])

    return line
  }
}

return function useRemoteLineItems () is a hook which is only returned if we don't already return () => {}. That's what I meant with "but conditionally":

It's a function that returns a hook

Yes but conditionally

So yes, this is conditional. That the user reported it means that the bug actually manifests already, too. Not only with my changes, assuming I wouldn't remove this line: if (!me) return () => {} (which what this conversation here is about)

Copy link
Member Author

Choose a reason for hiding this comment

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

Sorry for the detailed and thus maybe snarky answer, I feel like we've been talking past each other for several replies already now, lol :)

@ekzyis
Copy link
Member Author

ekzyis commented Nov 23, 2023

Reminder for myself: check if there is still an error (there probably is) if you're on /settings and then you switch to anon

Copy link
Member

@huumn huumn left a comment

Choose a reason for hiding this comment

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

Looks great!

I added some high level remarks. After we talk/work through those, I'll do another more detailed pass.

components/me.js Outdated Show resolved Hide resolved
middleware.js Outdated Show resolved Hide resolved
pages/api/auth/[...nextauth].js Show resolved Hide resolved
components/switch-account.js Outdated Show resolved Hide resolved
@ekzyis ekzyis force-pushed the 489-account-switching branch 2 times, most recently from acb1c96 to 8aef88e Compare December 5, 2023 05:00
@ekzyis
Copy link
Member Author

ekzyis commented Dec 5, 2023

I'll fix the conflicts, give me a sec

@ekzyis ekzyis force-pushed the 489-account-switching branch from 902db6e to 20eb1ff Compare December 5, 2023 16:53
@huumn
Copy link
Member

huumn commented Dec 5, 2023

No rush. This won't until I ship territories first

@ekzyis
Copy link
Member Author

ekzyis commented Dec 8, 2023

This is going to be great for my new puppet account @oracle 👀

@huumn
Copy link
Member

huumn commented Dec 19, 2023

For some reason this doesn't work for me:

  1. can't switch to anon
  2. when I go to add account only some providers are listed

This might need migration logic to be added for stackers that are already logged in if that wasn't tested.

broken.mov

@ekzyis ekzyis force-pushed the 489-account-switching branch from 5e7eab0 to 20eb1ff Compare December 20, 2023 00:22
@ekzyis
Copy link
Member Author

ekzyis commented Dec 20, 2023

For some reason this doesn't work for me:

  1. can't switch to anon

Mhh, I had this error before. I think I fixed it by clearing all my session cookies since it's related to a (new) cookie being HTTP only when it should not have been set as HTTP only for some reason iirc. I think I also noticed that sometimes, cookies are not overridden but duplicated. This should only happen if the cookie path is different - it defaults to the current path if no path is specified (which should no longer be the case). I don't remember 100% though.

Can you show me your cookies when this bug happens to you?

  1. when I go to add account only some providers are listed

I wasn't able to hook into the OAuth login process and Email auth (and was hard to test locally). So I thought - at least for the initial version - we can provide only Login with Lightning and Nostr if people want to use account switching.

This might need migration logic to be added for stackers that are already logged in if that wasn't tested.

Mhh, I didn't think too much about this but I think this should be 100% backwards compatible. But I will test (again).

@ekzyis ekzyis force-pushed the 489-account-switching branch from 74771a4 to 20eb1ff Compare December 20, 2023 04:33
@ekzyis
Copy link
Member Author

ekzyis commented Dec 20, 2023

Did a full test (afaict) again.

https://files.ekzyis.com/public/sn/account_switching_full_test.mp4

  1. 00:00 - 00:15 -- Login sets multi_auth cookie which is a base64 encoded list of all available accounts and is accessible from JS (HTTP only set to false) and multi_auth.<userid> cookie with HTTP only set to true containing the JWT for account switching to this user.

  2. 00:27 - 00:32 -- Switching to anon set the pointer cookie multi_auth.user-id to anonymous. This tells the backend to not use any JWT.

  3. 00:33 - 00:56 -- adding a new account updates multi_auth cookie and adds a new multi_auth.<userid> cookie for that user.

  4. 00:59 - 01:06 -- switching between accounts (anon or not anon)

  5. 01:07 - 01:13 -- logging out switches to next available account and deletes multi_auth.<userid> cookie of completely logs you out (same as before)

  6. 01:26 - 02:28 -- testing backwards compatibility by simulating a user that logged in without account switching deployed and then uses account switching

  7. 02:28 -- ok there is indeed a bug lol. new account does not show up in the list even though cookies seem to be set correctly at first glance.

@ekzyis
Copy link
Member Author

ekzyis commented Dec 20, 2023

Putting in draft until bug is fixed

Edit: mhh, can't find how to put in draft in the Github app

@ekzyis ekzyis marked this pull request as draft December 20, 2023 10:59
@ekzyis
Copy link
Member Author

ekzyis commented Dec 20, 2023

Sessions that existed before we deployed account switching should now also be able to use account switching immediately. We now sync the cookie from their session if there is no multi_auth cookie. See 6839066

2023-12-20.16-21-43.mp4

@huumn
Copy link
Member

huumn commented Dec 20, 2023

So this is out of draft now?

@ekzyis
Copy link
Member Author

ekzyis commented Dec 20, 2023

So this is out of draft now?

Going to fix conflicts now, just had a call with someone from Alby

btw, regarding this:

can't switch to anon

I noticed that sometimes, it just takes some time (max 5 seconds) for the switch to anon to complete. Not sure if it's because of dev mode or network latency or something else (bug). But yes, in your video, it didn't look like anything is going to happen, no matter how long you wait. Would be interesting to see if multi_auth.user-id is set to anonymous in that case.

update: testing currently again after fixing conflicts

The previous commit broke the UI update after anon zaps because we actually updated item.meSats in the cache and not item.meAnonSats.

Updating item.meAnonSats was not possible because it's a local field. For that, one needs to use reactive variables.

We do this now and thus also don't need the useEffect hack in item-info.js anymore.
If we logged in but never switched to any other account, the 'multi_auth.user-id' cookie was not set.

This meant that during logout, the other 'multi_auth.*' cookies were not deleted.

This broke the account switch modal.

This is fixed by setting the 'multi_auth.user-id' cookie on login.

Additionally, we now cleanup if cookie pointer OR session is set (instead of only if both are set).
setState is stable and thus only noise in effect dependencies
@ekzyis ekzyis force-pushed the 489-account-switching branch from 4d725b6 to ff378fb Compare September 10, 2024 16:36
@ekzyis
Copy link
Member Author

ekzyis commented Sep 10, 2024

A lot of files have changed in this and afaict the pr description is out of date. Would you mind pointing me to files/functions/components I should be reviewing?

I've created #1386 to make the change set here a little smaller.

Another repetitive change is replacing const me = useMe() with const { me } = useMe(). That's why there are so many files with 2+-.

Here is the list of files sorted by their changes:

components/account.js               | 207 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
pages/api/auth/[...nextauth].js     |  99 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
components/nav/common.js            |  93 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
pages/api/signout.js                |  61 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
components/login.js                 |  55 +++++++++++++++++++++++++++++++++++-----------------
pages/api/graphql.js                |  39 +++++++++++++++++++++++++++++++++++++
pages/_app.js                       |  35 ++++++++++++++++++---------------
package-lock.json                   |  35 ++++++++++++++++++++++++++++++---
components/user-list.js             |  30 ++++++++++++++++++++++++++++-
pages/login.js                      |  24 ++++++++++++++++-------
lib/apollo.js                       |  23 ++++++++++++++++++----
pages/settings/index.js             |  14 ++++++++------
components/item-info.js             |  14 +++++---------
components/lightning-auth.js        |  12 ++++++------
components/item-act.js              |  11 ++++++++---
components/nostr-auth.js            |   9 +++++----
pages/wallet/index.js               |   8 ++++----
components/upvote.js                |   8 ++++----
components/me.js                    |   8 ++++++--
api/ssrApollo.js                    |   8 +++++++-
wallets/index.js                    |   6 +++---
fragments/users.js                  |   6 +++---
components/banners.js               |   6 +++---
api/resolvers/user.js               |   5 +++--
components/user-header.js           |   4 ++--
components/territory-payment-due.js |   4 ++--
components/share.js                 |   4 ++--
components/post.js                  |   4 ++--
components/nav/sticky-bar.js        |   4 ++--
components/nav/mobile/top-bar.js    |   4 ++--
components/nav/mobile/offcanvas.js  |   4 ++--
components/login-button.js          |   4 ++--
components/fee-button.js            |   4 ++--
pages/withdrawals/[id].js           |   2 +-
pages/referrals/[when].js           |   2 +-
pages/[name]/index.js               |   2 +-
components/wallet-logger.js         |   2 +-
components/territory-transfer.js    |   2 +-
components/territory-header.js      |   2 +-
components/territory-form.js        |   2 +-
components/search.js                |   2 +-
components/reply.js                 |   2 +-
components/price.js                 |   2 +-
components/poll.js                  |   2 +-
components/poll-form.js             |   2 +-
components/payment.js               |   2 +-
components/pay-bounty.js            |   2 +-
components/nav/mobile/second-bar.js |   2 +-
components/nav/mobile/footer.js     |   2 +-
components/nav/desktop/top-bar.js   |   2 +-
components/media-or-link.js         |   2 +-
components/logger.js                |   2 +-
components/link-form.js             |   2 +-
components/item.js                  |   2 +-
components/item-full.js             |   2 +-
components/hidden-wallet-summary.js |   2 +-
components/form.js                  |   2 +-
components/discussion-form.js       |   2 +-
components/comment.js               |   2 +-
components/bounty-form.js           |   2 +-
components/autowithdraw-shared.js   |   2 +-
components/adv-post-form.js         |   2 +-
api/typeDefs/user.js                |   2 +-
package.json                        |   1 +
64 files changed, 728 insertions(+), 185 deletions(-)

Most important changes are all the files above package-lock.json and pages/login.js + api/ssrApollo.js (related changes) and lib/apollo.js + components/item-act.js (related changes). You can basically ignore all the files with only 6 changes or less.

@huumn
Copy link
Member

huumn commented Sep 12, 2024

QA looks good. I'll do a line by line tomorrow.

  1. Is it intentional to have to close the modal once an account is picked? I thought it was a bug when I first switched.
  2. I can't tell why the last account is white in the list.

Screenshot 2024-09-11 at 8 24 55 PM

@ekzyis
Copy link
Member Author

ekzyis commented Sep 12, 2024

Is it intentional to have to close the modal once an account is picked? I thought it was a bug when I first switched.

Mhh, not really intentional, I just didn't think about closing or not.

I can't tell why the last account is white in the list.

I think it's related to if you clicked the link before or not

@huumn
Copy link
Member

huumn commented Sep 12, 2024

I think it's related to if you clicked the link before or not

It didn't change before or after I clicked it strangely.

Copy link
Member

@huumn huumn left a comment

Choose a reason for hiding this comment

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

I got this when switching while editing a post.

Keeping the page the same while switching is a pretty difficult requirement. There might be other such instances.

If it were me I'd probably just go nuclear do a refresh when switching ... so that we don't have to think of account switching anytime we add a personalized page.

Screenshot 2024-09-12 at 11 21 52 AM

@huumn
Copy link
Member

huumn commented Sep 12, 2024

For reference (not saying this exactly what we should do), when you switch on X it takes you to /home regardless of where you were in the other account. It sucks because you lose your place.

For us, I think it might be better to just refresh on switch.

@ekzyis
Copy link
Member Author

ekzyis commented Sep 12, 2024

Keeping the page the same while switching is a pretty difficult requirement. There might be other such instances.

Mhh yes, getting a hold of these cases turned out to be the most difficult part, not the actual switch, lol.

If it were me I'd probably just go nuclear do a refresh when switching ... so that we don't have to think of account switching anytime we add a personalized page.

Yeah, I think that's the right call after I explored that troublesome path of having a nice switch experience ("here be dragons"-like). I'll push that change in a bit.

when you switch on X it takes you to /home regardless of where you were in the other account. It sucks because you lose your place.

Ohh, so a refresh on the same page would still be better than X (took me a second to understand you mean Twitter)

@ekzyis
Copy link
Member Author

ekzyis commented Sep 12, 2024

For us, I think it might be better to just refresh on switch.

There you go, the modal is now also closed on switch, lol

@ekzyis ekzyis requested a review from huumn September 12, 2024 17:19
@ekzyis ekzyis force-pushed the 489-account-switching branch from a6555ff to 56b8431 Compare September 12, 2024 17:20
@ekzyis ekzyis force-pushed the 489-account-switching branch from 56b8431 to cc18fc1 Compare September 12, 2024 17:21
@huumn huumn merged commit a6713f9 into stackernews:master Sep 12, 2024
6 checks passed
@ekzyis ekzyis deleted the 489-account-switching branch September 13, 2024 21:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature new product features that weren't there before
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Account switching
2 participants