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,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
FormControl,
|
FormControl,
|
||||||
|
FormErrorMessage,
|
||||||
FormHelperText,
|
FormHelperText,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
Input,
|
Input,
|
||||||
|
@ -61,7 +62,7 @@ function LoginForm({ onSuccess }) {
|
||||||
{ loading, error, data, called, reset },
|
{ loading, error, data, called, reset },
|
||||||
] = useMutation(
|
] = useMutation(
|
||||||
gql`
|
gql`
|
||||||
mutation LoginForm_Login($username: String!, $password: String!) {
|
mutation LoginForm($username: String!, $password: String!) {
|
||||||
login(username: $username, password: $password) {
|
login(username: $username, password: $password) {
|
||||||
id
|
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 (
|
return (
|
||||||
<form
|
<form onSubmit={onSubmit}>
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
sendLoginMutation({
|
|
||||||
variables: { username, password },
|
|
||||||
})
|
|
||||||
.then(({ data }) => {
|
|
||||||
if (data?.login != null) {
|
|
||||||
onSuccess();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((e) => console.error(e)); // plus the error UI
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<FormLabel>DTI Username</FormLabel>
|
<FormLabel>DTI Username</FormLabel>
|
||||||
<Input
|
<Input
|
||||||
|
@ -159,50 +160,173 @@ function LoginForm({ onSuccess }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function CreateAccountForm() {
|
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) => {
|
const onSubmit = (e) => {
|
||||||
e.preventDefault();
|
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 (
|
return (
|
||||||
<form onSubmit={onSubmit}>
|
<form onSubmit={onSubmit}>
|
||||||
<Box display="flex" justifyContent="center" marginBottom="3">
|
<Box display="flex" justifyContent="center" marginBottom="3">
|
||||||
<WIPCallout>TODO: This form isn't wired up yet!</WIPCallout>
|
<WIPCallout>TODO: This form isn't wired up yet!</WIPCallout>
|
||||||
</Box>
|
</Box>
|
||||||
<FormControl>
|
<FormControl isInvalid={errorTypes.includes("USERNAME_IS_REQUIRED")}>
|
||||||
<FormLabel>DTI Username</FormLabel>
|
<FormLabel>DTI Username</FormLabel>
|
||||||
<Input type="text" />
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => {
|
||||||
|
setUsername(e.target.value);
|
||||||
|
reset();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<FormHelperText>
|
<FormHelperText>
|
||||||
This will be separate from your Neopets.com account.
|
This will be separate from your Neopets.com account.
|
||||||
</FormHelperText>
|
</FormHelperText>
|
||||||
|
{errorTypes.includes("USERNAME_IS_REQUIRED") && (
|
||||||
|
<FormErrorMessage>Username can't be blank</FormErrorMessage>
|
||||||
|
)}
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<Box height="4" />
|
<Box height="4" />
|
||||||
<FormControl>
|
<FormControl
|
||||||
|
isInvalid={
|
||||||
|
passwordsDontMatch || errorTypes.includes("PASSWORD_IS_REQUIRED")
|
||||||
|
}
|
||||||
|
>
|
||||||
<FormLabel>DTI Password</FormLabel>
|
<FormLabel>DTI Password</FormLabel>
|
||||||
<Input type="password" />
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPassword(e.target.value);
|
||||||
|
reset();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<FormHelperText>
|
<FormHelperText>
|
||||||
Careful, never use your Neopets password for another site!
|
Careful, never use your Neopets password for another site!
|
||||||
</FormHelperText>
|
</FormHelperText>
|
||||||
|
{errorTypes.includes("PASSWORD_IS_REQUIRED") && (
|
||||||
|
<FormErrorMessage>Password can't be blank</FormErrorMessage>
|
||||||
|
)}
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<Box height="4" />
|
<Box height="4" />
|
||||||
<FormControl>
|
<FormControl isInvalid={passwordsDontMatch}>
|
||||||
<FormLabel>Confirm DTI Password</FormLabel>
|
<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>
|
<FormHelperText>One more time, to make sure!</FormHelperText>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<Box height="4" />
|
<Box height="4" />
|
||||||
<FormControl>
|
<FormControl
|
||||||
|
isInvalid={
|
||||||
|
errorTypes.includes("EMAIL_IS_REQUIRED") ||
|
||||||
|
errorTypes.includes("EMAIL_MUST_BE_VALID")
|
||||||
|
}
|
||||||
|
>
|
||||||
<FormLabel>Email address</FormLabel>
|
<FormLabel>Email address</FormLabel>
|
||||||
<Input type="password" />
|
<Input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => {
|
||||||
|
setEmail(e.target.value);
|
||||||
|
reset();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<FormHelperText>
|
<FormHelperText>
|
||||||
We'll use this in the future if you need to reset your password, or
|
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,
|
for us to contact you about your account. We won't sell this address,
|
||||||
and we won't send marketing-y emails.
|
and we won't send marketing-y emails.
|
||||||
</FormHelperText>
|
</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>
|
</FormControl>
|
||||||
|
{error && (
|
||||||
|
<ErrorMessage marginTop="4">
|
||||||
|
Oops, account creation failed: "{getGraphQLErrorMessage(error)}". Try
|
||||||
|
again?
|
||||||
|
</ErrorMessage>
|
||||||
|
)}
|
||||||
<Box height="6" />
|
<Box height="6" />
|
||||||
<Box display="flex" justifyContent="flex-end">
|
<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
|
Create account
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
import { createHmac } from "crypto";
|
import { createHmac } from "crypto";
|
||||||
import { normalizeRow } from "./util";
|
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) {
|
export async function getAuthToken({ username, password, ipAddress }, db) {
|
||||||
// For legacy reasons (and I guess decent security reasons too!), auth info
|
// For legacy reasons (and I guess decent security reasons too!), auth info
|
||||||
|
@ -149,3 +151,40 @@ function computeSignatureForAuthToken(unsignedAuthToken) {
|
||||||
authTokenHmac.update(JSON.stringify(unsignedAuthToken));
|
authTokenHmac.update(JSON.stringify(unsignedAuthToken));
|
||||||
return authTokenHmac.digest("hex");
|
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 { gql } from "apollo-server";
|
||||||
|
|
||||||
import { assertSupportSecretOrThrow } from "./MutationsForSupport";
|
import { assertSupportSecretOrThrow } from "./MutationsForSupport";
|
||||||
import { getAuthToken } from "../auth-by-db";
|
import { createAccount, getAuthToken } from "../auth-by-db";
|
||||||
|
|
||||||
const typeDefs = gql`
|
const typeDefs = gql`
|
||||||
type User {
|
type User {
|
||||||
|
@ -61,6 +61,26 @@ const typeDefs = gql`
|
||||||
extend type Mutation {
|
extend type Mutation {
|
||||||
login(username: String!, password: String!): User
|
login(username: String!, password: String!): User
|
||||||
logout: 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 };
|
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