mirror of
https://codeberg.org/pronounscc/pronouns.cc.git
synced 2024-11-20 16:29:51 +01:00
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.
|
// 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
|
// check if the username is valid
|
||||||
// if not, return an error depending on what failed
|
// if not, return an error depending on what failed
|
||||||
if !usernameRegex.MatchString(username) {
|
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")
|
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 err != nil {
|
||||||
if v, ok := errors.Cause(err).(*pgconn.PgError); ok {
|
if v, ok := errors.Cause(err).(*pgconn.PgError); ok {
|
||||||
if v.Code == "23505" { // unique constraint violation
|
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/db"
|
||||||
"codeberg.org/u1f320/pronouns.cc/backend/log"
|
"codeberg.org/u1f320/pronouns.cc/backend/log"
|
||||||
"codeberg.org/u1f320/pronouns.cc/backend/server"
|
"codeberg.org/u1f320/pronouns.cc/backend/server"
|
||||||
|
"emperror.dev/errors"
|
||||||
"github.com/bwmarrin/discordgo"
|
"github.com/bwmarrin/discordgo"
|
||||||
"github.com/go-chi/render"
|
"github.com/go-chi/render"
|
||||||
|
"github.com/mediocregopher/radix/v4"
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -37,7 +39,7 @@ type discordCallbackResponse struct {
|
||||||
|
|
||||||
Discord string `json:"discord,omitempty"` // username, for UI purposes
|
Discord string `json:"discord,omitempty"` // username, for UI purposes
|
||||||
Ticket string `json:"ticket,omitempty"`
|
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 {
|
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
|
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) {
|
func Decode[T any](r *http.Request) (T, error) {
|
||||||
decoded := *new(T)
|
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
|
// takes code + state, validates it, returns token OR discord signup ticket
|
||||||
r.Post("/callback", server.WrapHandler(s.discordCallback))
|
r.Post("/callback", server.WrapHandler(s.discordCallback))
|
||||||
// takes discord signup ticket to register account
|
// 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
|
ErrInvalidState = 1001
|
||||||
ErrInvalidOAuthCode = 1002
|
ErrInvalidOAuthCode = 1002
|
||||||
ErrInvalidToken = 1003 // a token was supplied, but it is invalid
|
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
|
// User-related error codes
|
||||||
ErrUserNotFound = 2001
|
ErrUserNotFound = 2001
|
||||||
|
@ -99,6 +103,10 @@ var errCodeMessages = map[int]string{
|
||||||
ErrInvalidState: "Invalid OAuth state",
|
ErrInvalidState: "Invalid OAuth state",
|
||||||
ErrInvalidOAuthCode: "Invalid OAuth code",
|
ErrInvalidOAuthCode: "Invalid OAuth code",
|
||||||
ErrInvalidToken: "Supplied token was invalid",
|
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",
|
ErrUserNotFound: "User not found",
|
||||||
|
|
||||||
|
|
|
@ -84,6 +84,11 @@ export interface SignupRequest {
|
||||||
invite_code?: string;
|
invite_code?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SignupResponse {
|
||||||
|
user: MeUser;
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PartialUser {
|
export interface PartialUser {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
|
|
|
@ -3,7 +3,8 @@ import { useRouter } from "next/router";
|
||||||
import { useRecoilState } from "recoil";
|
import { useRecoilState } from "recoil";
|
||||||
import fetchAPI from "../../lib/fetch";
|
import fetchAPI from "../../lib/fetch";
|
||||||
import { userState } from "../../lib/state";
|
import { userState } from "../../lib/state";
|
||||||
import { MeUser } from "../../lib/types";
|
import { APIError, MeUser, SignupResponse } from "../../lib/types";
|
||||||
|
import TextInput from "../../components/TextInput";
|
||||||
|
|
||||||
interface CallbackResponse {
|
interface CallbackResponse {
|
||||||
has_account: boolean;
|
has_account: boolean;
|
||||||
|
@ -12,7 +13,7 @@ interface CallbackResponse {
|
||||||
|
|
||||||
discord?: string;
|
discord?: string;
|
||||||
ticket?: string;
|
ticket?: string;
|
||||||
require_invite?: boolean;
|
require_invite: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
|
@ -23,6 +24,7 @@ interface State {
|
||||||
discord: string | null;
|
discord: string | null;
|
||||||
ticket: string | null;
|
ticket: string | null;
|
||||||
error?: any;
|
error?: any;
|
||||||
|
requireInvite: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Discord() {
|
export default function Discord() {
|
||||||
|
@ -37,7 +39,9 @@ export default function Discord() {
|
||||||
discord: null,
|
discord: null,
|
||||||
ticket: null,
|
ticket: null,
|
||||||
error: null,
|
error: null,
|
||||||
|
requireInvite: false,
|
||||||
});
|
});
|
||||||
|
const [formData, setFormData] = useState<{ username: string, invite: string }>({ username: "", invite: "" });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!router.query.code || !router.query.state) { return; }
|
if (!router.query.code || !router.query.state) { return; }
|
||||||
|
@ -58,10 +62,10 @@ export default function Discord() {
|
||||||
user: resp.user || null,
|
user: resp.user || null,
|
||||||
discord: resp.discord || null,
|
discord: resp.discord || null,
|
||||||
ticket: resp.ticket || null,
|
ticket: resp.ticket || null,
|
||||||
|
requireInvite: resp.require_invite,
|
||||||
})
|
})
|
||||||
}).catch(e => {
|
}).catch(e => {
|
||||||
return {
|
setState({
|
||||||
props: {
|
|
||||||
hasAccount: false,
|
hasAccount: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: e,
|
error: e,
|
||||||
|
@ -69,8 +73,8 @@ export default function Discord() {
|
||||||
user: null,
|
user: null,
|
||||||
discord: null,
|
discord: null,
|
||||||
ticket: null,
|
ticket: null,
|
||||||
},
|
requireInvite: false,
|
||||||
};
|
});
|
||||||
})
|
})
|
||||||
|
|
||||||
// we got a token + user, save it and return to the home page
|
// 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]);
|
}, [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…
Reference in a new issue