Skip to content

Commit

Permalink
feat: add client-side validation to login (#1438)
Browse files Browse the repository at this point in the history
Fixes #139, at least in the sense that the infrastructure is now set-up.
However, some other forms still need to be endowed with validation.
  • Loading branch information
tobiasdiez committed Sep 23, 2022
1 parent 3ccf264 commit d60eb62
Show file tree
Hide file tree
Showing 7 changed files with 184 additions and 87 deletions.
2 changes: 2 additions & 0 deletions .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ module.exports = {
'import/named': 'warn',
// Import order is handled by prettier (which is incompatible with this rule: https://github.com/simonhaenisch/prettier-plugin-organize-imports/issues/65)
'import/order': 'off',
// Disable vue2-specific rules (until https://github.com/nuxt/eslint-config/issues/216 is fixed)
'vue/no-v-model-argument': 'off',
},
overrides: [
{
Expand Down
22 changes: 17 additions & 5 deletions apollo/validation.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,28 @@
import { z } from 'zod'
import { SignupInput } from './graphql'
import { SignupInputSchema as InternalSignupInputSchema } from './validation.internal'
import { LoginInput, SignupInput } from './graphql'
import {
LoginInputSchema as InternalLoginInputSchema,
SignupInputSchema as InternalSignupInputSchema,
} from './validation.internal'

type Properties<T> = Required<{
[K in keyof T]: z.ZodType<T[K], any, T[K]>
}>

const passwordSchema = z
.string()
.min(8, { message: 'The password must be at least 8 characters long' })

export function SignupInputSchema(): z.ZodObject<Properties<SignupInput>> {
return InternalSignupInputSchema().extend({
email: z.string().email(),
password: z
.string()
.min(8, { message: 'The password must be at least 8 characters long' }),
password: passwordSchema,
})
}

export function LoginInputSchema(): z.ZodObject<Properties<LoginInput>> {
return InternalLoginInputSchema().extend({
email: z.string().email(),
password: passwordSchema,
})
}
53 changes: 53 additions & 0 deletions composables/zodForm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { toFormValidator } from '@vee-validate/zod'
import { SubmissionContext, useField, useForm } from 'vee-validate'
import { Ref } from 'vue'
import * as zod from 'zod'

type MaybeRef<T> = Ref<T> | T

// I had to reimpliment this interface as it is not exported by vee-validate
interface FieldOptions<TValue = unknown> {
initialValue?: MaybeRef<TValue>
validateOnValueUpdate: boolean
validateOnMount?: boolean
bails?: boolean
type?: string
valueProp?: MaybeRef<TValue>
checkedValue?: MaybeRef<TValue>
uncheckedValue?: MaybeRef<TValue>
label?: MaybeRef<string | undefined>
standalone?: boolean
}

// I had to reimpliment this interface as it is not exported by vee-validate
interface FormOptions<TValues extends Record<string, any>> {
initialValues?: MaybeRef<TValues>
initialErrors?: Record<keyof TValues, string | undefined>
initialTouched?: Record<keyof TValues, boolean>
validateOnMount?: boolean
}

// Essentially taken from https://github.com/logaretm/vee-validate/issues/3375#issuecomment-909407701
// Only change: useField doesn't return a reactive
export function useZodForm<
Schema extends zod.ZodObject<any>,
Values extends zod.infer<Schema>
>(schema: Schema, options: Omit<FormOptions<Values>, 'validationSchema'> = {}) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const validationSchema = toFormValidator(schema)
const form = useForm<Values>({
...options,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
validationSchema,
})
return {
...form,
useField: <Field extends keyof Values, FieldType extends Values[Field]>(
field: Field,
opts?: FieldOptions<FieldType>
) => useField<FieldType>(field as string, undefined, opts),
handleSubmit: (
cb: (values: Values, ctx: SubmissionContext<Values>) => unknown
) => form.handleSubmit(cb),
}
}
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"@prisma/client": "^4.3.1",
"@variantjs/core": "^0.0.90",
"@variantjs/vue": "^0.0.22",
"@vee-validate/zod": "^4.6.9",
"@vue/apollo-composable": "4.0.0-alpha.19",
"@vue/apollo-util": "^4.0.0-alpha.18",
"@vueuse/core": "^9.2.0",
Expand All @@ -80,6 +81,7 @@
"ts-node": "^10.9.1",
"tsyringe": "^4.7.0",
"typescript": "^4.8.3",
"vee-validate": "^4.6.9",
"zod": "^3.19.1"
},
"devDependencies": {
Expand Down
166 changes: 85 additions & 81 deletions pages/user/login.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,41 +15,46 @@
Don't have an account?
<t-nuxtlink to="/user/register">Sign up</t-nuxtlink>
</p>
<t-alert
<n-alert
v-if="error"
variant="error"
class="mt-8"
:dismissible="false"
show
type="error"
class="mb-4"
>
{{ error }}
</t-alert>
<form @submit.prevent="loginUser()">
<div class="space-y-5">
<t-input-group
</n-alert>
<n-form
size="large"
@submit="onSubmit"
>
<div class="space-y-2">
<n-form-item
label="Email address"
variant="important"
label-style="font-weight: 600"
:feedback="errors.email"
:validation-status="errors.email ? 'error' : undefined"
>
<t-input
v-model="email"
<n-input
v-model:value="email"
v-focus
/>
</t-input-group>
<t-input-group
</n-form-item>
<n-form-item
label="Password"
variant="important"
label-style="font-weight: 600"
:feedback="errors.password"
:validation-status="errors.password ? 'error' : undefined"
>
<PasswordInput v-model="password" />
</t-input-group>
<n-input
v-model:value="password"
type="password"
show-password-on="mousedown"
/>
</n-form-item>
<div class="flex items-center justify-between">
<div class="flex items-center">
<t-checkbox id="remember_me" />
<label
for="remember_me"
class="ml-2 block text-sm text-gray-900"
>
<n-checkbox v-model:checked="rememberLogin">
Keep me logged in
</label>
</n-checkbox>
</div>

<div class="text-sm">
Expand Down Expand Up @@ -80,80 +85,79 @@
/>
</div>
</div>
</form>
</n-form>
</div>
</NuxtLayout>
</template>

<script lang="ts">
<script lang="ts" setup>
import { useMutation } from '@vue/apollo-composable'
import { gql } from '~/apollo'
import { cacheCurrentUser } from '~/apollo/cache'
import { LoginInputSchema } from '~/apollo/validation'
definePageMeta({ layout: false })
export default defineComponent({
name: 'UserLogin',
// TODO: Automatically go to home if already loggin in
// middleware: 'guest',
// TODO: Automatically go to home if already logged in
// middleware: 'guest',
const { handleSubmit, errors, useField } = useZodForm(LoginInputSchema())
setup() {
const email = ref('')
const password = ref('')
const otherError = ref('')
const { value: email } = useField('email')
const { value: password } = useField('password')
const {
mutate: loginUser,
onDone,
error: graphqlError,
} = useMutation(
gql(/* GraphQL */ `
mutation Login($input: LoginInput!) {
login(input: $input) {
... on UserReturned {
user {
id
}
}
... on InputValidationProblem {
problems {
path
message
}
}
const otherError = ref('')
const {
mutate: loginUser,
onDone,
error: graphqlError,
} = useMutation(
gql(/* GraphQL */ `
mutation Login($input: LoginInput!) {
login(input: $input) {
... on UserReturned {
user {
id
}
}
`),
() => ({
variables: {
input: {
email: email.value,
password: password.value,
},
},
update(cache, { data: login }) {
if (login?.login?.__typename === 'UserReturned') {
const { user } = login.login
cacheCurrentUser(cache, user)
} else {
cacheCurrentUser(cache, null)
... on InputValidationProblem {
problems {
path
message
}
},
})
)
onDone((result) => {
if (result.data?.login?.__typename === 'UserReturned') {
void navigateTo({ name: 'dashboard' })
}
}
}
`),
{
update(cache, { data: login }) {
if (login?.login?.__typename === 'UserReturned') {
const { user } = login.login
cacheCurrentUser(cache, user)
} else {
otherError.value =
result.data?.login?.__typename === 'InputValidationProblem' &&
result.data.login.problems[0]
? result.data.login.problems[0].message
: 'Unknown error'
cacheCurrentUser(cache, null)
}
})
const error = computed(() => graphqlError.value || otherError.value)
},
}
)
onDone((result) => {
if (result.data?.login?.__typename === 'UserReturned') {
void navigateTo({ name: 'dashboard' })
} else {
otherError.value =
result.data?.login?.__typename === 'InputValidationProblem' &&
result.data.login.problems[0]
? result.data.login.problems[0].message
: 'Unknown error'
}
})
const error = computed(() => graphqlError.value || otherError.value)
// TODO: Implement remember login
const rememberLogin = ref(false)
return { email, password, error, loginUser }
},
const onSubmit = handleSubmit(async (values) => {
await loginUser({ input: values })
})
</script>
3 changes: 2 additions & 1 deletion server/user/resolvers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { User } from '@prisma/client'
import { SignupInputSchema } from '~/apollo/validation'
import { LoginInputSchema, SignupInputSchema } from '~/apollo/validation'
import { Context } from '../context'
import {
UserDocumentService,
Expand Down Expand Up @@ -63,6 +63,7 @@ export class Mutation {
return newUserPayload
}

@validateInput(LoginInputSchema)
async login(
_root: Record<string, never>,
{ input: { email, password } }: MutationLoginArgs,
Expand Down
23 changes: 23 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -5886,6 +5886,16 @@ __metadata:
languageName: node
linkType: hard

"@vee-validate/zod@npm:^4.6.9":
version: 4.6.9
resolution: "@vee-validate/zod@npm:4.6.9"
peerDependencies:
vee-validate: ^4.4.0
zod: ^3.1.0
checksum: 97ed89e5cdd4d2b87c421c63d8220e407ce2dffce6f009cbbc18bd50284e7ab91d7fc4f4295ad3d4bdc3f09e7f7a7f825eb2551e7b9317f1c0f7ced461d7b44b
languageName: node
linkType: hard

"@vercel/nft@npm:^0.22.1":
version: 0.22.1
resolution: "@vercel/nft@npm:0.22.1"
Expand Down Expand Up @@ -15137,6 +15147,7 @@ __metadata:
"@typescript-eslint/parser": ^5.36.2
"@variantjs/core": ^0.0.90
"@variantjs/vue": ^0.0.22
"@vee-validate/zod": ^4.6.9
"@vitest/coverage-c8": ^0.23.2
"@volar/vue-typescript": ^0.40.6
"@vue/apollo-composable": 4.0.0-alpha.19
Expand Down Expand Up @@ -15188,6 +15199,7 @@ __metadata:
typescript: ^4.8.3
ufo: ^0.8.5
unplugin-vue-components: ^0.22.7
vee-validate: ^4.6.9
vitest: ^0.23.2
vitest-github-actions-reporter: ^0.8.3
vitest-mock-extended: ^0.1.15
Expand Down Expand Up @@ -24212,6 +24224,17 @@ __metadata:
languageName: node
linkType: hard

"vee-validate@npm:^4.6.9":
version: 4.6.9
resolution: "vee-validate@npm:4.6.9"
dependencies:
"@vue/devtools-api": ^6.1.4
peerDependencies:
vue: ^3.0.0
checksum: 8fd19fb1bbb23da6f68ebfde2d8a741ee5cbafd3b65e85830bc83b142823fe6e784fbca284887fd96f9d99c070166fe4d37343b2b331966bacd0fcdb5c78b201
languageName: node
linkType: hard

"verror@npm:1.10.0":
version: 1.10.0
resolution: "verror@npm:1.10.0"
Expand Down

0 comments on commit d60eb62

Please sign in to comment.