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:
parent
c7ba61a0f1
commit
08603af961
3 changed files with 225 additions and 26 deletions
|
@ -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,9 +89,7 @@ function LoginForm({ onSuccess }) {
|
|||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
const onSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
sendLoginMutation({
|
||||
variables: { username, password },
|
||||
|
@ -101,8 +100,10 @@ function LoginForm({ onSuccess }) {
|
|||
}
|
||||
})
|
||||
.catch((e) => console.error(e)); // plus the error UI
|
||||
}}
|
||||
>
|
||||
};
|
||||
|
||||
return (
|
||||
<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>
|
||||
|
|
|
@ -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) };
|
||||
}
|
||||
|
|
|
@ -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 } };
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
|
Loading…
Reference in a new issue