~vpzom/bracketmonster

a40c6812ceb09c676233f9b5788c8b00777d6aed — Colin Reeder 6 months ago 98628e7 logins
Improve registration
M brackend/openapi/openapi.json => brackend/openapi/openapi.json +23 -3
@@ 17,7 17,20 @@
	"paths": {
		"/v1/users": {
			"post": {
				"summary": "Create an anonymous user",
				"summary": "Create a user. Can either be created anonymously or with a username and password.",
				"requestBody": {
					"content": {
						"application/json": {
							"schema": {
								"type": "object",
								"properties": {
									"username": {"type": "string", "example": "paul"},
									"password": {"type": "string", "example": "hunter2"}
								}
							}
						}
					}
				},
				"responses": {
					"200": {
						"description": "Successfully created user. Includes new authentication token.",


@@ 25,10 38,17 @@
							"application/json": {
								"schema": {
									"type": "object",
									"required": ["token", "user_id"],
									"required": ["token", "user"],
									"properties": {
										"token": {"type": "string", "example": "e9a86a0f-6f70-4694-bc3c-6b4215f1a8eb"},
										"user_id": {"type": "integer", "example": 45}
										"user": {
											"type": "object",
											"required": ["id", "username"],
											"properties": {
												"id": {"type": "integer", "example": "45"},
												"username": {"nullable": true, "type": "string", "example": "paul"}
											}
										}
									}
								}
							}

M brackend/src/routes/v1/users.rs => brackend/src/routes/v1/users.rs +53 -7
@@ 34,7 34,9 @@ pub async fn route_users(
) -> Result<hyper::Response<hyper::Body>, Error> {
    if path.is_empty() {
        match *req.method() {
            hyper::Method::POST => route_users_create_fn(db_pool).await.map(|v| to_200(&v)),
            hyper::Method::POST => route_users_create_fn(db_pool, req)
                .await
                .map(|v| to_200(&v)),
            _ => Err(Error::MethodNotAllowed),
        }
    } else if let Some((id_str, path)) = consume_path_segment(path) {


@@ 58,16 60,60 @@ pub async fn route_users(
    }
}

async fn route_users_create_fn(db_pool: DbPool) -> Result<serde_json::Value, Error> {
async fn route_users_create_fn(
    db_pool: DbPool,
    req: hyper::Request<hyper::Body>,
) -> Result<serde_json::Value, Error> {
    #[derive(serde_derive::Deserialize, Debug, Default)]
    struct UserCreateBody {
        username: Option<String>,
        password: Option<String>,
    }

    let body = hyper::body::to_bytes(req.into_body()).await?;

    let body: UserCreateBody = if body.is_empty() {
        Default::default()
    } else {
        serde_json::from_slice(&body)?
    };

    let mut client = db_pool.get().await?;

    let user_id = {
    let (user_id, username): (i32, _) = match if let Some(username) = body.username {
        if let Some(password) = body.password {
            let password_hash =
                tokio::task::spawn_blocking(|| bcrypt::hash(password, bcrypt::DEFAULT_COST))
                    .await??;
            let stmt = client
                .prepare("INSERT INTO users (username, password_hash) VALUES ($1, $2) RETURNING id")
                .await?;
            Some((
                client
                    .query_one(&stmt, &[&username, &password_hash])
                    .await?,
                Some(username),
            ))
        } else {
            None
        }
    } else if body.password.is_some() {
        None
    } else {
        let stmt = client
            .prepare("INSERT INTO users DEFAULT VALUES RETURNING id")
            .await?;
        let row = client.query_one(&stmt, &[]).await?;
        let id: i32 = row.get(0);
        id
        Some((client.query_one(&stmt, &[]).await?, None))
    } {
        Some((row, username)) => (row.get(0), username),
        None => {
            return Err(Error::UserError({
                let mut res =
                    hyper::Response::new("username and password cannot be used separately".into());
                *res.status_mut() = hyper::StatusCode::BAD_REQUEST;
                res
            }));
        }
    };

    let token = uuid::Uuid::new_v4();


@@ 82,7 128,7 @@ async fn route_users_create_fn(db_pool: DbPool) -> Result<serde_json::Value, Err
        "token": token.to_string(),
        "user": {
            "id": user_id,
            "username": null,
            "username": username,
        }
    }))
}

A rosebush/src/client/constants.ts => rosebush/src/client/constants.ts +1 -0
@@ 0,0 1,1 @@
export const COOKIE_AGE = 60 * 60 * 24 * 365;

M rosebush/src/client/index.tsx => rosebush/src/client/index.tsx +1 -1
@@ 64,7 64,7 @@ class App extends Component<{}, State> {
	}

	public render(props: {}, state: State) {
		const hasLogin = state.userInfo.state === "done" && state.userInfo !== null;
		const hasLogin = state.userInfo.state === "done" && state.userInfo.value !== null;

		return <AppContext.Provider value={{
			userInfo: state.userInfo,

M rosebush/src/client/login.tsx => rosebush/src/client/login.tsx +1 -2
@@ 3,11 3,10 @@ import { useContext } from "preact/hooks";
import { route } from "preact-router";

import { AppContext } from ".";
import { COOKIE_AGE } from "./constants";

import Helmet from "./Helmet";

const COOKIE_AGE = 60 * 60 * 24 * 365;

interface Props {
	continue?: string;
}

M rosebush/src/client/newBracket.tsx => rosebush/src/client/newBracket.tsx +2 -2
@@ 4,12 4,11 @@ import { useContext } from "preact/hooks";
import { route } from "preact-router";

import { AppContext, AppContextContent } from ".";
import { COOKIE_AGE } from "./constants";

import Helmet from "./Helmet";
import Loading from "./Loading";

const COOKIE_AGE = 60 * 60 * 24 * 365;

interface OuterProps {
	anonymous?: unknown;
}


@@ 47,6 46,7 @@ class PageContent extends Component<Props, State> {
						</div>
						<div class="options">
							<a class="button" href="/newBracket?anonymous">Continue anonymously</a>
							<a class="button" href="/register?continue=/newBracket">Register</a>
							<a class="button" href="/login?continue=/newBracket">Login</a>
						</div>
					</div>

M rosebush/src/client/register.tsx => rosebush/src/client/register.tsx +49 -9
@@ 1,14 1,25 @@
import { Component, JSX, h } from "preact";
import { useContext } from "preact/hooks";
import { route } from "preact-router";

import { AppContext } from ".";
import { COOKIE_AGE } from "./constants";
import Helmet from "./Helmet";
import Loading from "./Loading";

interface Props {
	continue?: string;
}

interface State {
	submitting: boolean;
	success: boolean;
}

export default class RegisterPage extends Component<{}, State> {
	public render(props: {}, state: State) {
export default class RegisterPage extends Component<Props, State> {
	public render(props: Props, state: State) {
		const app = useContext(AppContext)!;

		if(state.success) {
			return <div>
				<Helmet title="Register" />


@@ 18,10 29,21 @@ export default class RegisterPage extends Component<{}, State> {
			</div>;
		}

		if(app.userInfo.state === "loading") return <Loading />;
		else if(app.userInfo.state === "failed") return <div>Failed to load login state</div>;
		else if(app.userInfo.state === "done") {}
		else {
			const _: never = app.userInfo;
			throw new Error("This should never happen.");
		}

		const userInfo = app.userInfo.value;

		return <div>
			<Helmet title="Register" />

			<h1>Register</h1>
			{userInfo !== null && <p>Upgrade your anonymous account by adding a username and password.</p>}
			<form onSubmit={this.submit.bind(this)}>
				<p>
					<label>


@@ 46,13 68,31 @@ export default class RegisterPage extends Component<{}, State> {
		const username = form.username.value;

		this.setState({submitting: true}, () => {
			fetch("/api/v1/users/~me", {method: "PATCH", body: JSON.stringify({
				username,
				password: form.password.value,
			})})
			.then(() => this.setState({success: true}))
			.catch(console.error)
			.then(() => this.setState({submitting: false}));
			const app = useContext(AppContext)!;

			if(app.userInfo.state !== "done") throw new Error("UserInfo is not available. This shouldn't be possible.");

			const data = {username, password: form.password.value};

			(
				app.userInfo.value === null ?
					fetch("/api/v1/users", {method: "POST", body: JSON.stringify(data)})
						.then(res => res.json())
						.then(({token, user}) => {
							document.cookie = "rosebushToken=" + token + ";path=/;max-age=" + COOKIE_AGE;

							app.setUserInfo(user);

							if(this.props.continue) {
								route(this.props.continue);
							}
						}) :
					fetch("/api/v1/users/~me", {method: "PATCH", body: JSON.stringify(data)})
						.then(() => undefined)
			)
				.then(() => this.setState({success: true}))
				.catch(console.error)
				.then(() => this.setState({submitting: false}));
		});
	}
}