Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"type": "module",
"dependencies": {
"@apollo/server": "^5.5.1",
"bcryptjs": "^3.0.3",
"graphql": "^16.14.2",
"mongodb": "^7.7.0"
},
Expand Down
98 changes: 97 additions & 1 deletion backend/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,41 @@
import { ApolloServer } from "@apollo/server";
import { startStandaloneServer } from "@apollo/server/standalone";
import { connectToDatabase } from "./db.js";
import {
randomBytes,
scrypt as scryptCallback,
timingSafeEqual,
} from "node:crypto";
import { promisify } from "node:util";
import { GraphQLError } from "graphql/error/index.js";


// Hash a password using scrypt
const scrypt = promisify(scryptCallback);

async function hashPassword(password: string): Promise<string> {
const salt = randomBytes(16);
const hash = (await scrypt(password, salt, 64)) as Buffer;
return `${salt.toString("hex")}:${hash.toString("hex")}`;
}

async function verifyPassword(password: string, storedHash: string): Promise<boolean> {
const [saltHex, hashHex] = storedHash.split(":");
if (!saltHex || !hashHex) return false;

const salt = Buffer.from(saltHex, "hex");
const expectedHash = Buffer.from(hashHex, "hex");
const actualHash = (await scrypt(password, salt, expectedHash.length)) as Buffer;

return expectedHash.length === actualHash.length && timingSafeEqual(expectedHash, actualHash);
}

// Connect to MongoDB
const db = await connectToDatabase();

// Prevent duplicate usernames at the database level
await db.collection("users").createIndex({ username: 1 }, { unique: true });

// Define the GraphQL schema
const typeDefs = `#graphql
type Artwork {
Expand All @@ -20,10 +51,20 @@ const typeDefs = `#graphql
totalCount: Int!
}
type User {
id: ID!
username: String!
}
type Query {
hello: String!
artworks(limit: Int!, offset: Int!): ArtworkPage!
}
type Mutation {
register(username: String!, password: String!): User!
login(username: String!, password: String!): User!
}
`;

// Define how queries are resolved
Expand All @@ -42,7 +83,6 @@ const resolvers = {

const [artworks, totalCount] = await Promise.all([
collection.find({}).sort({ id: 1 }).skip(offset).limit(limit).toArray(),

collection.countDocuments({}),
]);

Expand All @@ -52,6 +92,62 @@ const resolvers = {
};
},
},
Mutation: {
login: async (
_parent: unknown,
args: { username: string; password: string },
) => {
const username = args.username.trim().toLowerCase();
const user = await db.collection("users").findOne({ username });

if (!user || typeof user.passwordHash !== "string") {
throw new GraphQLError("Incorrect username or password");
}

const passwordIsCorrect = await verifyPassword(args.password, user.passwordHash);
if (!passwordIsCorrect) {
throw new GraphQLError("Incorrect username or password");
}

return { id: user._id.toString(), username: user.username };
},
register: async (
_parent: unknown,
args: { username: string; password: string },
) => {
const username = args.username.trim().toLowerCase();

if (username.length < 3 || username.length > 20) {
throw new GraphQLError("Username must be between 3 and 20 characters");
}
if (args.password.length < 8 || args.password.length > 100) {
throw new GraphQLError("Password must be between 8 and 100 characters");
}

const users = db.collection("users");

const existing = await users.findOne({ username });
if (existing) {
throw new GraphQLError("Username is already taken");
}

const passwordHash = await hashPassword(args.password);

try {
const result = await users.insertOne({
username,
passwordHash,
createdAt: new Date(),
});
return { id: result.insertedId.toString(), username };
} catch (err: any) {
if (err?.code === 11000) {
throw new GraphQLError("Username is already taken");
}
throw err;
}
},
},
};

// Create the Apollo Server
Expand Down
2 changes: 1 addition & 1 deletion frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ import { Routes, Route } from "react-router-dom";
import "./index.css";
import HomePage from "./pages/HomePage";
import Login from "./pages/login";
import CreateAccount from "./pages/CreateAccount";

function App() {
return (
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/login" element={<Login />} />
<Route path="/create-account" element={<CreateAccount />} />
</Routes>
);
}
Expand Down
148 changes: 148 additions & 0 deletions frontend/src/pages/CreateAccount.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { useState, type SubmitEvent as ReactSubmitEvent } from "react";
import { Link } from "react-router-dom";

type RegisterResponse = {
data?: { register?: { id: string; username: string } };
errors?: Array<{ message: string }>;
};

export default function CreateAccount() {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [message, setMessage] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);

async function handleRegister(event: ReactSubmitEvent<HTMLFormElement>) {
event.preventDefault();
setMessage("");
setIsSubmitting(true);

const query = `
mutation Register($username: String!, $password: String!) {
register(username: $username, password: $password) {
id
username
}
}
`;

try {
const response = await fetch("/graphql", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query, variables: { username, password } }),
});

if (!response.ok) {
throw new Error(`Server error (${response.status}).`);
}

const result = (await response.json()) as RegisterResponse;
if (result.errors?.length) {
throw new Error(result.errors[0].message);
}
if (!result.data?.register) {
throw new Error("The server did not return the new account.");
}

setMessage(`Account created for ${result.data.register.username}.`);
setPassword("");
} catch (error) {
setMessage(
error instanceof Error ? error.message : "Something went wrong.",
);
} finally {
setIsSubmitting(false);
}
}

return (
<main className="flex min-h-dvh">
<section
aria-labelledby="create-account-heading"
className="flex w-full items-center justify-center px-6 pb-10 pt-20 md:w-2/5 md:p-8"
>
<div className="w-full max-w-sm">
<Link
to="/"
className="absolute left-4 top-4 z-10 p-2 hover:text-gray-500"
>
← Back
</Link>

<h1 id="create-account-heading" className="mb-8 text-4xl md:text-5xl">
Create account
</h1>

<form onSubmit={handleRegister} className="flex flex-col gap-5">
<div className="flex flex-col gap-1.5">
<label htmlFor="username" className="text-sm">Username</label>
<input
id="username"
name="username"
type="text"
autoComplete="username"
placeholder="Choose a username..."
value={username}
onChange={(event) => setUsername(event.target.value)}
minLength={3}
maxLength={20}
required
className="w-full rounded-lg bg-gray-100 px-4 py-3 text-base text-black outline-none transition placeholder:text-gray-400 hover:bg-gray-200 focus:bg-white focus:ring-2 focus:ring-gray-400"
/>
</div>

<div className="flex flex-col gap-1.5">
<label htmlFor="password" className="text-sm">Password</label>
<input
id="password"
name="password"
type="password"
autoComplete="new-password"
placeholder="Choose a password..."
value={password}
onChange={(event) => setPassword(event.target.value)}
minLength={8}
maxLength={100}
required
className="w-full rounded-lg bg-gray-100 px-4 py-3 text-base text-black outline-none transition placeholder:text-gray-400 hover:bg-gray-200 focus:bg-white focus:ring-2 focus:ring-gray-400"
/>
</div>

<button
type="submit"
disabled={isSubmitting}
className="w-full rounded-lg bg-gray-900 py-3 text-white transition hover:bg-gray-700 active:scale-[0.98] disabled:cursor-wait disabled:opacity-60"
>
{isSubmitting ? "Creating account..." : "Create account"}
</button>

{message && (
<p role="status" aria-live="polite" className="text-sm text-gray-700">
{message}
</p>
)}
</form>

<p className="mt-6 text-gray-500">
Already have an account?{" "}
<Link to="/login" className="text-gray-900 underline underline-offset-4 hover:text-gray-600">
Login
</Link>
</p>
</div>
</section>

<aside
aria-label="Gallery artwork"
className="relative hidden md:block md:w-3/5"
>
<img
src="https://encrypted-tbn1.gstatic.com/images?q=tbn:ANd9GcQUfteD2ooluFe28h35IMSc9Tv0SW3_8sLHwrpZqRSS-Ihl_Pp2"
alt="Artwork displayed on a gallery wall"
className="absolute inset-0 h-full w-full object-cover"
/>
</aside>
</main>
);
}
Loading