diff --git a/backend/db/user.go b/backend/db/user.go index 04131df..f5b0a6d 100644 --- a/backend/db/user.go +++ b/backend/db/user.go @@ -46,7 +46,7 @@ const ( ) // CreateUser creates a user with the given username. -func (db *DB) CreateUser(ctx context.Context, username string) (u User, err error) { +func (db *DB) CreateUser(ctx context.Context, tx pgx.Tx, username string) (u User, err error) { // check if the username is valid // if not, return an error depending on what failed if !usernameRegex.MatchString(username) { @@ -64,7 +64,7 @@ func (db *DB) CreateUser(ctx context.Context, username string) (u User, err erro return u, errors.Wrap(err, "building sql") } - err = pgxscan.Get(ctx, db, &u, sql, args...) + err = pgxscan.Get(ctx, tx, &u, sql, args...) if err != nil { if v, ok := errors.Cause(err).(*pgconn.PgError); ok { if v.Code == "23505" { // unique constraint violation diff --git a/backend/routes/auth/discord.go b/backend/routes/auth/discord.go index 7b510a8..bd6e33f 100644 --- a/backend/routes/auth/discord.go +++ b/backend/routes/auth/discord.go @@ -7,8 +7,10 @@ import ( "codeberg.org/u1f320/pronouns.cc/backend/db" "codeberg.org/u1f320/pronouns.cc/backend/log" "codeberg.org/u1f320/pronouns.cc/backend/server" + "emperror.dev/errors" "github.com/bwmarrin/discordgo" "github.com/go-chi/render" + "github.com/mediocregopher/radix/v4" "golang.org/x/oauth2" ) @@ -37,7 +39,7 @@ type discordCallbackResponse struct { Discord string `json:"discord,omitempty"` // username, for UI purposes Ticket string `json:"ticket,omitempty"` - RequireInvite bool `json:"require_invite,omitempty"` // require an invite for signing up + RequireInvite bool `json:"require_invite"` // require an invite for signing up } func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error { @@ -114,6 +116,108 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error { return nil } +type signupRequest struct { + Ticket string `json:"ticket"` + Username string `json:"username"` + InviteCode string `json:"invite_code"` +} + +type signupResponse struct { + User userResponse `json:"user"` + Token string `json:"token"` +} + +func (s *Server) discordSignup(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + + req, err := Decode[signupRequest](r) + if err != nil { + return server.APIError{Code: server.ErrBadRequest} + } + + if s.RequireInvite && req.InviteCode == "" { + return server.APIError{Code: server.ErrInviteRequired} + } + + valid, taken, err := s.DB.UsernameTaken(ctx, req.Username) + if err != nil { + return err + } + if !valid { + return server.APIError{Code: server.ErrInvalidUsername} + } + if taken { + return server.APIError{Code: server.ErrUsernameTaken} + } + + tx, err := s.DB.Begin(ctx) + if err != nil { + return errors.Wrap(err, "beginning transaction") + } + defer tx.Rollback(ctx) + + var du discordgo.User + err = s.DB.GetJSON(ctx, "discord:"+req.Ticket, &du) + if err != nil { + log.Errorf("getting discord user for ticket: %v", err) + + return server.APIError{Code: server.ErrInvalidTicket} + } + + u, err := s.DB.CreateUser(ctx, tx, req.Username) + if err != nil { + return errors.Wrap(err, "creating user") + } + + err = u.UpdateFromDiscord(ctx, tx, &du) + if err != nil { + if errors.Cause(err) == db.ErrUsernameTaken { + return server.APIError{Code: server.ErrUsernameTaken} + } + + return errors.Wrap(err, "updating user from discord") + } + + if s.RequireInvite { + // TODO: check invites, invalidate invite when done + inviteValid := true + + if !inviteValid { + err = tx.Rollback(ctx) + if err != nil { + return errors.Wrap(err, "rolling back transaction") + } + + return server.APIError{Code: server.ErrInviteRequired} + } + } + + // delete sign up ticket + err = s.DB.Redis.Do(ctx, radix.Cmd(nil, "DEL", "discord:"+req.Ticket)) + if err != nil { + return errors.Wrap(err, "deleting signup ticket") + } + + // commit transaction + err = tx.Commit(ctx) + if err != nil { + return errors.Wrap(err, "committing transaction") + } + + // create token + token, err := s.Auth.CreateToken(u.ID) + if err != nil { + return errors.Wrap(err, "creating token") + } + + // return user + render.JSON(w, r, signupResponse{ + User: *dbUserToUserResponse(u), + Token: token, + }) + return nil +} + func Decode[T any](r *http.Request) (T, error) { decoded := *new(T) diff --git a/backend/routes/auth/routes.go b/backend/routes/auth/routes.go index 3864d8b..e29d28f 100644 --- a/backend/routes/auth/routes.go +++ b/backend/routes/auth/routes.go @@ -61,7 +61,7 @@ func Mount(srv *server.Server, r chi.Router) { // takes code + state, validates it, returns token OR discord signup ticket r.Post("/callback", server.WrapHandler(s.discordCallback)) // takes discord signup ticket to register account - r.Post("/signup", nil) + r.Post("/signup", server.WrapHandler(s.discordSignup)) }) }) } diff --git a/backend/server/errors.go b/backend/server/errors.go index 5ba4578..47ac3e4 100644 --- a/backend/server/errors.go +++ b/backend/server/errors.go @@ -76,6 +76,10 @@ const ( ErrInvalidState = 1001 ErrInvalidOAuthCode = 1002 ErrInvalidToken = 1003 // a token was supplied, but it is invalid + ErrInviteRequired = 1004 + ErrInvalidTicket = 1005 // invalid signup ticket + ErrInvalidUsername = 1006 // invalid username (when signing up) + ErrUsernameTaken = 1007 // username taken (when signing up) // User-related error codes ErrUserNotFound = 2001 @@ -99,6 +103,10 @@ var errCodeMessages = map[int]string{ ErrInvalidState: "Invalid OAuth state", ErrInvalidOAuthCode: "Invalid OAuth code", ErrInvalidToken: "Supplied token was invalid", + ErrInviteRequired: "A valid invite code is required", + ErrInvalidTicket: "Invalid signup ticket", + ErrInvalidUsername: "Invalid username", + ErrUsernameTaken: "Username is already taken", ErrUserNotFound: "User not found", diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts index e5e6b8d..99b94a8 100644 --- a/frontend/lib/types.ts +++ b/frontend/lib/types.ts @@ -84,6 +84,11 @@ export interface SignupRequest { invite_code?: string; } +export interface SignupResponse { + user: MeUser; + token: string; +} + export interface PartialUser { id: string; username: string; diff --git a/frontend/pages/login/discord.tsx b/frontend/pages/login/discord.tsx index a55f647..caa5a79 100644 --- a/frontend/pages/login/discord.tsx +++ b/frontend/pages/login/discord.tsx @@ -3,7 +3,8 @@ import { useRouter } from "next/router"; import { useRecoilState } from "recoil"; import fetchAPI from "../../lib/fetch"; import { userState } from "../../lib/state"; -import { MeUser } from "../../lib/types"; +import { APIError, MeUser, SignupResponse } from "../../lib/types"; +import TextInput from "../../components/TextInput"; interface CallbackResponse { has_account: boolean; @@ -12,7 +13,7 @@ interface CallbackResponse { discord?: string; ticket?: string; - require_invite?: boolean; + require_invite: boolean; } interface State { @@ -23,6 +24,7 @@ interface State { discord: string | null; ticket: string | null; error?: any; + requireInvite: boolean; } export default function Discord() { @@ -37,7 +39,9 @@ export default function Discord() { discord: null, ticket: null, error: null, + requireInvite: false, }); + const [formData, setFormData] = useState<{ username: string, invite: string }>({ username: "", invite: "" }); useEffect(() => { if (!router.query.code || !router.query.state) { return; } @@ -58,19 +62,19 @@ export default function Discord() { user: resp.user || null, discord: resp.discord || null, ticket: resp.ticket || null, + requireInvite: resp.require_invite, }) }).catch(e => { - return { - props: { - hasAccount: false, - isLoading: false, - error: e, - token: null, - user: null, - discord: null, - ticket: null, - }, - }; + setState({ + hasAccount: false, + isLoading: false, + error: e, + token: null, + user: null, + discord: null, + ticket: null, + requireInvite: false, + }); }) // we got a token + user, save it and return to the home page @@ -82,5 +86,49 @@ export default function Discord() { } }, [state.token, state.user, setState, router]); - return <>wow such login; + // user needs to create an account + const signup = async () => { + try { + const resp = await fetchAPI("/auth/discord/signup", "POST", { + ticket: state.ticket, + username: formData.username, + invite_code: formData.invite, + }); + + setUser(resp.user); + localStorage.setItem("pronouns-token", resp.token); + } catch (e) { + setState({ ...state, error: e }); + } + }; + + return <> +

Get started

+

You{"'"}ve logged in with Discord as {state.discord}.

+ + {state.error && ( +
+

Error: {state.error.message ?? state.error}

+

Try again?

+
+ )} + + + {state.requireInvite && ( + + )} + + ; }