Create account endpoint skeleton + validation

It doesn't actually create the account, but it does some field validation and the form reacts to it!
This commit is contained in:
Emi Matchu 2022-09-12 15:25:22 -07:00
parent c7ba61a0f1
commit 08603af961
3 changed files with 225 additions and 26 deletions

View file

@ -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 (
<form
onSubmit={(e) => {
e.preventDefault();
sendLoginMutation({
variables: { username, password },
})
.then(({ data }) => {
if (data?.login != null) {
onSuccess();
}
})
.catch((e) => console.error(e)); // plus the error UI
}}
>
<form onSubmit={onSubmit}>
<FormControl>
<FormLabel>DTI Username</FormLabel>
<Input
@ -159,50 +160,173 @@ function LoginForm({ onSuccess }) {
}
function CreateAccountForm() {
const [username, setUsername] = React.useState("");
const [password, setPassword] = React.useState("");
const [passwordConfirmation, setPasswordConfirmation] = React.useState("");
const [email, setEmail] = React.useState("");
const [
sendCreateAccountMutation,
{ loading, error, data, reset },
] = useMutation(
gql`
mutation CreateAccountForm(
$username: String!
$password: String!
$email: String!
) {
createAccount(username: $username, password: $password, email: $email) {
errors {
type
}
user {
id
}
}
}
`,
{
update: (cache, { data }) => {
// 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 (
<form onSubmit={onSubmit}>
<Box display="flex" justifyContent="center" marginBottom="3">
<WIPCallout>TODO: This form isn't wired up yet!</WIPCallout>
</Box>
<FormControl>
<FormControl isInvalid={errorTypes.includes("USERNAME_IS_REQUIRED")}>
<FormLabel>DTI Username</FormLabel>
<Input type="text" />
<Input
type="text"
value={username}
onChange={(e) => {
setUsername(e.target.value);
reset();
}}
/>
<FormHelperText>
This will be separate from your Neopets.com account.
</FormHelperText>
{errorTypes.includes("USERNAME_IS_REQUIRED") && (
<FormErrorMessage>Username can't be blank</FormErrorMessage>
)}
</FormControl>
<Box height="4" />
<FormControl>
<FormControl
isInvalid={
passwordsDontMatch || errorTypes.includes("PASSWORD_IS_REQUIRED")
}
>
<FormLabel>DTI Password</FormLabel>
<Input type="password" />
<Input
type="password"
value={password}
onChange={(e) => {
setPassword(e.target.value);
reset();
}}
/>
<FormHelperText>
Careful, never use your Neopets password for another site!
</FormHelperText>
{errorTypes.includes("PASSWORD_IS_REQUIRED") && (
<FormErrorMessage>Password can't be blank</FormErrorMessage>
)}
</FormControl>
<Box height="4" />
<FormControl>
<FormControl isInvalid={passwordsDontMatch}>
<FormLabel>Confirm DTI Password</FormLabel>
<Input type="password" />
<Input
type="password"
value={passwordConfirmation}
onChange={(e) => {
setPasswordConfirmation(e.target.value);
reset();
}}
/>
<FormHelperText>One more time, to make sure!</FormHelperText>
</FormControl>
<Box height="4" />
<FormControl>
<FormControl
isInvalid={
errorTypes.includes("EMAIL_IS_REQUIRED") ||
errorTypes.includes("EMAIL_MUST_BE_VALID")
}
>
<FormLabel>Email address</FormLabel>
<Input type="password" />
<Input
type="email"
value={email}
onChange={(e) => {
setEmail(e.target.value);
reset();
}}
/>
<FormHelperText>
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.
</FormHelperText>
{errorTypes.includes("EMAIL_IS_REQUIRED") && (
<FormErrorMessage>Email can't be blank</FormErrorMessage>
)}
{errorTypes.includes("EMAIL_MUST_BE_VALID") && (
<FormErrorMessage>Email must be valid</FormErrorMessage>
)}
</FormControl>
{error && (
<ErrorMessage marginTop="4">
Oops, account creation failed: "{getGraphQLErrorMessage(error)}". Try
again?
</ErrorMessage>
)}
<Box height="6" />
<Box display="flex" justifyContent="flex-end">
<Button type="submit" colorScheme="green">
<Button
type="submit"
colorScheme="green"
isLoading={loading}
isDisabled={
username === "" ||
password === "" ||
email === "" ||
passwordsDontMatch
}
>
Create account
</Button>
</Box>

View file

@ -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) };
}

View file

@ -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 } };
},
},
};