feat: get signup via discord working
This commit is contained in:
		
							parent
							
								
									77dea0c5ed
								
							
						
					
					
						commit
						9a3c51459b
					
				
					 6 changed files with 183 additions and 18 deletions
				
			
		|  | @ -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 | ||||
|  |  | |||
|  | @ -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) | ||||
| 
 | ||||
|  |  | |||
|  | @ -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)) | ||||
| 		}) | ||||
| 	}) | ||||
| } | ||||
|  |  | |||
|  | @ -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", | ||||
| 
 | ||||
|  |  | |||
|  | @ -84,6 +84,11 @@ export interface SignupRequest { | |||
|   invite_code?: string; | ||||
| } | ||||
| 
 | ||||
| export interface SignupResponse { | ||||
|   user: MeUser; | ||||
|   token: string; | ||||
| } | ||||
| 
 | ||||
| export interface PartialUser { | ||||
|   id: string; | ||||
|   username: string; | ||||
|  |  | |||
|  | @ -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<SignupResponse>("/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 <> | ||||
|     <h1 className="font-bold text-lg">Get started</h1> | ||||
|     <p>You{"'"}ve logged in with Discord as <strong className="font-bold">{state.discord}</strong>.</p> | ||||
| 
 | ||||
|     {state.error && ( | ||||
|       <div className="bg-red-600 dark:bg-red-700 p-2 rounded-md"> | ||||
|         <p>Error: {state.error.message ?? state.error}</p> | ||||
|         <p>Try again?</p> | ||||
|       </div> | ||||
|     )} | ||||
| 
 | ||||
|     <label> | ||||
|       <span className="font-bold">Username</span> | ||||
|       <TextInput value={formData.username} onChange={(e) => setFormData({ ...formData, username: e.target.value })} /> | ||||
|     </label> | ||||
|     {state.requireInvite && ( | ||||
|       <label> | ||||
|         <span className="font-bold">Invite code</span> | ||||
|         <TextInput value={formData.invite} onChange={(e) => setFormData({ ...formData, invite: e.target.value })} /> | ||||
|       </label> | ||||
|     )} | ||||
|     <button | ||||
|       type="button" | ||||
|       onClick={() => signup()} | ||||
|       className="bg-green-600 dark:bg-green-700 hover:bg-green-700 hover:dark:bg-green-800 p-2 rounded-md" | ||||
|     > | ||||
|       <span className="font-bold">Create account</span> | ||||
|     </button> | ||||
|   </>; | ||||
| } | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue