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

Update AuthUser to offer a better DX #1915

Merged
merged 18 commits into from
Apr 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions waspc/ChangeLog.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,47 @@
# Changelog


## 0.14.0 (2024-04-22)

### 🎉 New Features

- Simplified Auth User API: Introduced a simpler API for accessing user auth fields (for example `username`, `email`, `isEmailVerified`) directly on the `user` object, eliminating the need for helper functions.

### ⚠️ Breaking Changes & Migration Guide

We had to make a couple of breaking changes to reach the new simpler API:

1. You don't need to use `getUsername` to access the username:

- Before: Used `getUsername` to access the username.
- After: Directly use `user.identities.username?.id`.

2. You don't need to use `getEmail` to access the email:

- Before: Used `getEmail` to access the email.
- After: Directly use `user.identities.email?.id`.

3. Better API for accessing `providerData`:

- Before: Required complex logic to access typed provider data.
- After: Directly use `user.identities.<provider>.<value>` for typed access.

4. Better API for accessing `getFirstProviderUserId`:

- Before: Used `getFirstProviderUserId(user)` to get the ID.
- After: Use `user.getFirstProviderUserId()` directly on the user object.

5. You don't need to use `findUserIdentity` any more:

- Before: Relied on `findUserIdentity` to check which user identity exists.
- After: Directly check `user.identities.<provider>` existence.

These changes improve code readability and lower the complexity of accessing user's auth fields. Follow the [detailed migration steps to update your project to 0.14.0](https://wasp-lang.dev/docs/migrate-from-0-13-to-0-14).

### Note on Auth Helper Functions (`getUsername`, `getEmail` etc.)

These changes only apply to getting auth fields from the `user` object you receive from Wasp, for example in the `authRequired` enabled pages or `context.user` on the server. If you are fetching the user and auth fields with your own queries, you _can_ keep using most of the helpers. Read more [about using the auth helpers](https://wasp-lang.dev/docs/auth/entities#including-the-user-with-other-entities).

## 0.13.2 (2024-04-11)

### 🐞 Bug fixes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,12 @@ import { SocialButton } from '../social/SocialButton'
{=# isAnyPasswordBasedAuthEnabled =}
import { useHistory } from 'react-router-dom'
{=/ isAnyPasswordBasedAuthEnabled =}
{=# isUsernameAndPasswordAuthEnabled =}
{=# enabledProviders.isUsernameAndPasswordAuthEnabled =}
import { useUsernameAndPassword } from '../usernameAndPassword/useUsernameAndPassword'
{=/ isUsernameAndPasswordAuthEnabled =}
{=# isEmailAuthEnabled =}
{=/ enabledProviders.isUsernameAndPasswordAuthEnabled =}
{=# enabledProviders.isEmailAuthEnabled =}
import { useEmail } from '../email/useEmail'
{=/ isEmailAuthEnabled =}
{=/ enabledProviders.isEmailAuthEnabled =}

{=# areBothSocialAndPasswordBasedAuthEnabled =}
const OrContinueWith = styled('div', {
Expand Down Expand Up @@ -105,15 +105,15 @@ const SocialAuthButtons = styled('div', {
}
})
{=/ isSocialAuthEnabled =}
{=# isGoogleAuthEnabled =}
{=# enabledProviders.isGoogleAuthEnabled =}
const googleSignInUrl = `${config.apiUrl}{= googleSignInPath =}`
{=/ isGoogleAuthEnabled =}
{=# isKeycloakAuthEnabled =}
{=/ enabledProviders.isGoogleAuthEnabled =}
{=# enabledProviders.isKeycloakAuthEnabled =}
const keycloakSignInUrl = `${config.apiUrl}{= keycloakSignInPath =}`
{=/ isKeycloakAuthEnabled =}
{=# isGitHubAuthEnabled =}
{=/ enabledProviders.isKeycloakAuthEnabled =}
{=# enabledProviders.isGitHubAuthEnabled =}
const gitHubSignInUrl = `${config.apiUrl}{= gitHubSignInPath =}`
{=/ isGitHubAuthEnabled =}
{=/ enabledProviders.isGitHubAuthEnabled =}

{=!
// Since we allow users to add additional fields to the signup form, we don't
Expand Down Expand Up @@ -151,16 +151,16 @@ export const LoginSignupForm = ({
{=/ isAnyPasswordBasedAuthEnabled =}
const hookForm = useForm<LoginSignupFormFields>()
const { register, formState: { errors }, handleSubmit: hookFormHandleSubmit } = hookForm
{=# isUsernameAndPasswordAuthEnabled =}
{=# enabledProviders.isUsernameAndPasswordAuthEnabled =}
const { handleSubmit } = useUsernameAndPassword({
isLogin,
onError: onErrorHandler,
onSuccess() {
history.push('{= onAuthSucceededRedirectTo =}')
},
});
{=/ isUsernameAndPasswordAuthEnabled =}
{=# isEmailAuthEnabled =}
{=/ enabledProviders.isUsernameAndPasswordAuthEnabled =}
{=# enabledProviders.isEmailAuthEnabled =}
const { handleSubmit } = useEmail({
isLogin,
onError: onErrorHandler,
Expand All @@ -172,7 +172,7 @@ export const LoginSignupForm = ({
history.push('{= onAuthSucceededRedirectTo =}')
},
});
{=/ isEmailAuthEnabled =}
{=/ enabledProviders.isEmailAuthEnabled =}
{=# isAnyPasswordBasedAuthEnabled =}
async function onSubmit (data) {
setIsLoading(true);
Expand All @@ -191,17 +191,17 @@ export const LoginSignupForm = ({
<SocialAuth>
<SocialAuthLabel>{cta} with</SocialAuthLabel>
<SocialAuthButtons gap='large' direction={socialButtonsDirection}>
{=# isGoogleAuthEnabled =}
{=# enabledProviders.isGoogleAuthEnabled =}
<SocialButton href={googleSignInUrl}><SocialIcons.Google/></SocialButton>
{=/ isGoogleAuthEnabled =}
{=/ enabledProviders.isGoogleAuthEnabled =}

{=# isKeycloakAuthEnabled =}
{=# enabledProviders.isKeycloakAuthEnabled =}
<SocialButton href={keycloakSignInUrl}><SocialIcons.Keycloak/></SocialButton>
{=/ isKeycloakAuthEnabled =}
{=/ enabledProviders.isKeycloakAuthEnabled =}

{=# isGitHubAuthEnabled =}
{=# enabledProviders.isGitHubAuthEnabled =}
<SocialButton href={gitHubSignInUrl}><SocialIcons.GitHub/></SocialButton>
{=/ isGitHubAuthEnabled =}
{=/ enabledProviders.isGitHubAuthEnabled =}
</SocialAuthButtons>
</SocialAuth>
{=/ isSocialAuthEnabled =}
Expand All @@ -217,7 +217,7 @@ export const LoginSignupForm = ({
{=/ areBothSocialAndPasswordBasedAuthEnabled =}
{=# isAnyPasswordBasedAuthEnabled =}
<Form onSubmit={hookFormHandleSubmit(onSubmit)}>
{=# isUsernameAndPasswordAuthEnabled =}
{=# enabledProviders.isUsernameAndPasswordAuthEnabled =}
<FormItemGroup>
<FormLabel>Username</FormLabel>
<FormInput
Expand All @@ -229,8 +229,8 @@ export const LoginSignupForm = ({
/>
{errors.username && <FormError>{errors.username.message}</FormError>}
</FormItemGroup>
{=/ isUsernameAndPasswordAuthEnabled =}
{=# isEmailAuthEnabled =}
{=/ enabledProviders.isUsernameAndPasswordAuthEnabled =}
{=# enabledProviders.isEmailAuthEnabled =}
<FormItemGroup>
<FormLabel>E-mail</FormLabel>
<FormInput
Expand All @@ -242,7 +242,7 @@ export const LoginSignupForm = ({
/>
{errors.email && <FormError>{errors.email.message}</FormError>}
</FormItemGroup>
{=/ isEmailAuthEnabled =}
{=/ enabledProviders.isEmailAuthEnabled =}
<FormItemGroup>
<FormLabel>Password</FormLabel>
<FormInput
Expand Down
13 changes: 10 additions & 3 deletions waspc/data/Generator/templates/sdk/wasp/auth/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
// PUBLIC
export type { AuthUser } from '../server/_types'
// PUBLIC API
export {
getEmail,
getUsername,
getFirstProviderUserId,
} from './user.js'

export { getEmail, getUsername, getFirstProviderUserId, findUserIdentity } from './user.js'
// PUBLIC API
export {
type AuthUser,
} from '../server/auth/user.js';
30 changes: 3 additions & 27 deletions waspc/data/Generator/templates/sdk/wasp/auth/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,10 @@ import { type AuthUser } from 'wasp/auth'

import { auth } from "./lucia.js";
import type { Session } from "lucia";
import {
throwInvalidCredentialsError,
deserializeAndSanitizeProviderData,
} from "./utils.js";
import { throwInvalidCredentialsError } from "./utils.js";

import { prisma } from 'wasp/server';
import { createAuthUser } from "../server/auth/user.js";

// PRIVATE API
// Creates a new session for the `authId` in the database
Expand Down Expand Up @@ -81,29 +79,7 @@ async function getUser(userId: {= userEntityUpper =}['id']): Promise<AuthUser> {
throwInvalidCredentialsError()
}

// TODO: This logic must match the type in _types/index.ts (if we remove the
// password field from the object here, we must to do the same there).
// Ideally, these two things would live in the same place:
// https://github.com/wasp-lang/wasp/issues/965
const deserializedIdentities = user.{= authFieldOnUserEntityName =}.{= identitiesFieldOnAuthEntityName =}.map((identity) => {
const deserializedProviderData = deserializeAndSanitizeProviderData(
identity.providerData,
{
shouldRemovePasswordField: true,
}
)
return {
...identity,
providerData: deserializedProviderData,
}
})
return {
...user,
auth: {
...user.auth,
identities: deserializedIdentities,
},
}
return createAuthUser(user);
}

// PRIVATE API
Expand Down
2 changes: 1 addition & 1 deletion waspc/data/Generator/templates/sdk/wasp/auth/types.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
// todo(filip): turn into a proper import/path
export type { AuthUser, ProviderName, DeserializedAuthIdentity } from 'wasp/server/_types'
export type { ProviderName } from 'wasp/server/_types'
2 changes: 1 addition & 1 deletion waspc/data/Generator/templates/sdk/wasp/auth/useAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { deserialize as superjsonDeserialize } from 'superjson'
import { useQuery, addMetadataToQuery } from 'wasp/client/operations'
import { api, handleApiError } from 'wasp/client/api'
import { HttpMethod } from 'wasp/client'
import type { AuthUser } from './types'
import type { AuthUser } from '../server/auth/user.js'
import { UseQueryResult } from '@tanstack/react-query'

// PUBLIC API
Expand Down
23 changes: 16 additions & 7 deletions waspc/data/Generator/templates/sdk/wasp/auth/user.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,36 @@
import type { AuthUser, ProviderName, DeserializedAuthIdentity } from './types'
{{={= =}=}}
import { type {= authIdentityEntityName =} } from '../entities/index.js'
import { type ProviderName } from '../server/_types/index.js'
/**
* We split the user.ts code into two files to avoid some server-only
* code (Oslo's hashing functions) being imported on the client.
*/
import { type UserEntityWithAuth } from '../server/auth/user.js'

// PUBLIC API
export function getEmail(user: AuthUser): string | null {
export function getEmail(user: UserEntityWithAuth): string | null {
return findUserIdentity(user, "email")?.providerUserId ?? null;
}

// PUBLIC API
export function getUsername(user: AuthUser): string | null {
export function getUsername(user: UserEntityWithAuth): string | null {
return findUserIdentity(user, "username")?.providerUserId ?? null;
}

// PUBLIC API
export function getFirstProviderUserId(user?: AuthUser): string | null {
export function getFirstProviderUserId(user?: UserEntityWithAuth): string | null {
if (!user || !user.auth || !user.auth.identities || user.auth.identities.length === 0) {
return null;
}

return user.auth.identities[0].providerUserId ?? null;
}

// PUBLIC API
export function findUserIdentity(user: AuthUser, providerName: ProviderName): DeserializedAuthIdentity | undefined {
function findUserIdentity(user: UserEntityWithAuth, providerName: ProviderName): {= authIdentityEntityName =} | null {
if (!user.auth) {
return null;
}
return user.auth.identities.find(
(identity) => identity.providerName === providerName
);
) ?? null;
}
18 changes: 1 addition & 17 deletions waspc/data/Generator/templates/sdk/wasp/server/_types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,14 @@ import { type ParamsDictionary as ExpressParams, type Query as ExpressQuery } fr
import { prisma } from 'wasp/server'
{=# isAuthEnabled =}
import {
type {= userEntityName =},
type {= authEntityName =},
type {= authIdentityEntityName =},
} from "wasp/entities"
import {
type EmailProviderData,
type UsernameProviderData,
type OAuthProviderData,
} from 'wasp/auth/utils'
import { type AuthUser } from 'wasp/auth'
{=/ isAuthEnabled =}
import { type _Entity } from "./taggedEntities"
import { type Payload } from "./serialization";
Expand Down Expand Up @@ -88,20 +87,5 @@ type Context<Entities extends _Entity[]> = Expand<{
{=# isAuthEnabled =}
type ContextWithUser<Entities extends _Entity[]> = Expand<Context<Entities> & { user?: AuthUser }>

// TODO: This type must match the logic in auth/session.js (if we remove the
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This TODO is now resolved yay! Since the type and the runtime value are based on the same logic.

// password field from the object there, we must do the same here). Ideally,
// these two things would live in the same place:
// https://github.com/wasp-lang/wasp/issues/965

export type DeserializedAuthIdentity = Expand<Omit<{= authIdentityEntityName =}, 'providerData'> & {
providerData: Omit<EmailProviderData, 'password'> | Omit<UsernameProviderData, 'password'> | OAuthProviderData
}>

export type AuthUser = {= userEntityName =} & {
{= authFieldOnUserEntityName =}: {= authEntityName =} & {
{= identitiesFieldOnAuthEntityName =}: DeserializedAuthIdentity[]
} | null
}

export type { ProviderName } from 'wasp/auth/utils'
{=/ isAuthEnabled =}
Loading
Loading