From 08603af96145320849a1e88b285d6abcac1f09ac Mon Sep 17 00:00:00 2001 From: Matchu Date: Mon, 12 Sep 2022 15:25:22 -0700 Subject: [PATCH] Create account endpoint skeleton + validation It doesn't actually create the account, but it does some field validation and the form reacts to it! --- src/app/components/LoginModal.js | 174 ++++++++++++++++++++++++++----- src/server/auth-by-db.js | 39 +++++++ src/server/types/User.js | 38 ++++++- 3 files changed, 225 insertions(+), 26 deletions(-) diff --git a/src/app/components/LoginModal.js b/src/app/components/LoginModal.js index b557676..028244c 100644 --- a/src/app/components/LoginModal.js +++ b/src/app/components/LoginModal.js @@ -3,6 +3,7 @@ import { Box, Button, FormControl, + FormErrorMessage, FormHelperText, FormLabel, Input, @@ -61,7 +62,7 @@ function LoginForm({ onSuccess }) { { loading, error, data, called, reset }, ] = useMutation( gql` - mutation LoginForm_Login($username: String!, $password: String!) { + mutation LoginForm($username: String!, $password: String!) { login(username: $username, password: $password) { id } @@ -88,21 +89,21 @@ function LoginForm({ onSuccess }) { } ); + const onSubmit = (e) => { + e.preventDefault(); + sendLoginMutation({ + variables: { username, password }, + }) + .then(({ data }) => { + if (data?.login != null) { + onSuccess(); + } + }) + .catch((e) => console.error(e)); // plus the error UI + }; + return ( -
{ - e.preventDefault(); - sendLoginMutation({ - variables: { username, password }, - }) - .then(({ data }) => { - if (data?.login != null) { - onSuccess(); - } - }) - .catch((e) => console.error(e)); // plus the error UI - }} - > + DTI Username { + // If account creation succeeded, evict the `currentUser` from the + // cache, which will force all queries on the page that depend on it to + // update. (This includes the GlobalHeader that shows who you're logged + // in as!) + // + // I don't do any optimistic UI here, because auth is complex enough + // that I'd rather only show login success after validating it through + // an actual server round-trip. + if (data.createAccount?.user != null) { + cache.evict({ id: "ROOT_QUERY", fieldName: "currentUser" }); + cache.gc(); + } + }, + } + ); + const onSubmit = (e) => { e.preventDefault(); - alert("TODO: Create account!"); + sendCreateAccountMutation({ + variables: { username, password, email }, + }) + .then(({ data }) => { + if (data?.login != null) { + onSuccess(); + } + }) + .catch((e) => console.error(e)); // plus the error UI }; + const errorTypes = (data?.createAccount?.errors || []).map( + (error) => error.type + ); + + const passwordsDontMatch = + password !== "" && password !== passwordConfirmation; + return ( TODO: This form isn't wired up yet! - + DTI Username - + { + setUsername(e.target.value); + reset(); + }} + /> This will be separate from your Neopets.com account. + {errorTypes.includes("USERNAME_IS_REQUIRED") && ( + Username can't be blank + )} - + DTI Password - + { + setPassword(e.target.value); + reset(); + }} + /> Careful, never use your Neopets password for another site! + {errorTypes.includes("PASSWORD_IS_REQUIRED") && ( + Password can't be blank + )} - + Confirm DTI Password - + { + setPasswordConfirmation(e.target.value); + reset(); + }} + /> One more time, to make sure! - + Email address - + { + setEmail(e.target.value); + reset(); + }} + /> We'll use this in the future if you need to reset your password, or for us to contact you about your account. We won't sell this address, and we won't send marketing-y emails. + {errorTypes.includes("EMAIL_IS_REQUIRED") && ( + Email can't be blank + )} + {errorTypes.includes("EMAIL_MUST_BE_VALID") && ( + Email must be valid + )} + {error && ( + + Oops, account creation failed: "{getGraphQLErrorMessage(error)}". Try + again? + + )} - diff --git a/src/server/auth-by-db.js b/src/server/auth-by-db.js index 0e77829..df588df 100644 --- a/src/server/auth-by-db.js +++ b/src/server/auth-by-db.js @@ -1,6 +1,8 @@ import { createHmac } from "crypto"; import { normalizeRow } from "./util"; +// https://stackoverflow.com/a/201378/107415 +const EMAIL_PATTERN = /(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/; export async function getAuthToken({ username, password, ipAddress }, db) { // For legacy reasons (and I guess decent security reasons too!), auth info @@ -149,3 +151,40 @@ function computeSignatureForAuthToken(unsignedAuthToken) { authTokenHmac.update(JSON.stringify(unsignedAuthToken)); return authTokenHmac.digest("hex"); } + +export async function createAccount( + { username, password, email, _ /* ipAddress */ }, + __ /* db */ +) { + const errors = []; + if (!username) { + errors.push({ type: "USERNAME_IS_REQUIRED" }); + } + if (!password) { + errors.push({ type: "PASSWORD_IS_REQUIRED" }); + } + if (!email) { + errors.push({ type: "EMAIL_IS_REQUIRED" }); + } + if (email && !email?.match(EMAIL_PATTERN)) { + errors.push({ type: "EMAIL_MUST_BE_VALID" }); + } + + // TODO: Add an error for non-unique username. + + if (errors.length > 0) { + return { errors, authToken: null }; + } + + throw new Error(`TODO: Actually create the account!`); + + // await db.query(` + // INSERT INTO openneo_id.users + // (name, encrypted_password, email, password_salt, sign_in_count, + // current_sign_in_at, current_sign_in_ip, created_at, updated_at) + // VALUES (?, ?, ?, ?, 1, CURRENT_TIMESTAMP(), ?, + // CURRENT_TIMESTAMP(), CURRENT_TIMESTAMP()); + // `, [username, encryptedPassword, email, passwordSalt, ipAddress]); + + // return { errors: [], authToken: createAuthToken(6) }; +} diff --git a/src/server/types/User.js b/src/server/types/User.js index 12bc280..84009bf 100644 --- a/src/server/types/User.js +++ b/src/server/types/User.js @@ -1,7 +1,7 @@ import { gql } from "apollo-server"; import { assertSupportSecretOrThrow } from "./MutationsForSupport"; -import { getAuthToken } from "../auth-by-db"; +import { createAccount, getAuthToken } from "../auth-by-db"; const typeDefs = gql` type User { @@ -61,6 +61,26 @@ const typeDefs = gql` extend type Mutation { login(username: String!, password: String!): User logout: User + createAccount( + username: String! + password: String! + email: String! + ): CreateAccountMutationResult! + } + + type CreateAccountMutationResult { + errors: [CreateAccountError!]! + user: User + } + type CreateAccountError { + type: CreateAccountErrorType! + } + enum CreateAccountErrorType { + USERNAME_IS_REQUIRED + USERNAME_ALREADY_TAKEN + PASSWORD_IS_REQUIRED + EMAIL_IS_REQUIRED + EMAIL_MUST_BE_VALID } `; @@ -388,6 +408,22 @@ const resolvers = { } return { id: currentUserId }; }, + createAccount: async ( + _, + { username, password, email }, + { setAuthToken, db, ipAddress } + ) => { + const { errors, authToken } = await createAccount( + { username, password, email, ipAddress }, + db + ); + if (authToken == null) { + return { errors, user: null }; + } + + setAuthToken(authToken); + return { errors, user: { id: authToken.userId } }; + }, }, };