feat: sign up/log in with mastodon
This commit is contained in:
		
							parent
							
								
									f087e9a29f
								
							
						
					
					
						commit
						cf424d3ae4
					
				
					 8 changed files with 509 additions and 8 deletions
				
			
		|  | @ -30,7 +30,7 @@ func (f FediverseApp) ClientConfig() *oauth2.Config { | |||
| 			AuthStyle: oauth2.AuthStyleInParams, | ||||
| 		}, | ||||
| 		Scopes:      []string{"read:accounts"}, | ||||
| 		RedirectURL: os.Getenv("BASE_URL") + "/auth/login/mastodon", | ||||
| 		RedirectURL: os.Getenv("BASE_URL") + "/auth/login/mastodon/" + f.Instance, | ||||
| 	} | ||||
| 	// } | ||||
| 
 | ||||
|  |  | |||
|  | @ -98,6 +98,47 @@ func (db *DB) CreateUser(ctx context.Context, tx pgx.Tx, username string) (u Use | |||
| 	return u, nil | ||||
| } | ||||
| 
 | ||||
| func (db *DB) FediverseUser(ctx context.Context, userID string, instanceAppID int64) (u User, err error) { | ||||
| 	sql, args, err := sq.Select("*").From("users"). | ||||
| 		Where("fediverse = ?", userID).Where("fediverse_app_id = ?", instanceAppID). | ||||
| 		ToSql() | ||||
| 	if err != nil { | ||||
| 		return u, errors.Wrap(err, "building sql") | ||||
| 	} | ||||
| 
 | ||||
| 	err = pgxscan.Get(ctx, db, &u, sql, args...) | ||||
| 	if err != nil { | ||||
| 		if errors.Cause(err) == pgx.ErrNoRows { | ||||
| 			return u, ErrUserNotFound | ||||
| 		} | ||||
| 
 | ||||
| 		return u, errors.Wrap(err, "executing query") | ||||
| 	} | ||||
| 	return u, nil | ||||
| } | ||||
| 
 | ||||
| func (u *User) UpdateFromFedi(ctx context.Context, ex Execer, userID, username string, appID int64) error { | ||||
| 	sql, args, err := sq.Update("users"). | ||||
| 		Set("fediverse", userID). | ||||
| 		Set("fediverse_username", username). | ||||
| 		Set("fediverse_app_id", appID). | ||||
| 		Where("id = ?", u.ID). | ||||
| 		ToSql() | ||||
| 	if err != nil { | ||||
| 		return errors.Wrap(err, "building sql") | ||||
| 	} | ||||
| 
 | ||||
| 	_, err = ex.Exec(ctx, sql, args...) | ||||
| 	if err != nil { | ||||
| 		return errors.Wrap(err, "executing query") | ||||
| 	} | ||||
| 
 | ||||
| 	u.Fediverse = &userID | ||||
| 	u.FediverseUsername = &username | ||||
| 	u.FediverseAppID = &appID | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| // DiscordUser fetches a user by Discord user ID. | ||||
| func (db *DB) DiscordUser(ctx context.Context, discordID string) (u User, err error) { | ||||
| 	sql, args, err := sq.Select("*").From("users").Where("discord = ?", discordID).ToSql() | ||||
|  |  | |||
|  | @ -27,7 +27,7 @@ var discordOAuthConfig = oauth2.Config{ | |||
| 	Scopes: []string{"identify"}, | ||||
| } | ||||
| 
 | ||||
| type oauthCallbackRequest struct { | ||||
| type discordOauthCallbackRequest struct { | ||||
| 	CallbackDomain string `json:"callback_domain"` | ||||
| 	Code           string `json:"code"` | ||||
| 	State          string `json:"state"` | ||||
|  | @ -50,7 +50,7 @@ type discordCallbackResponse struct { | |||
| func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error { | ||||
| 	ctx := r.Context() | ||||
| 
 | ||||
| 	decoded, err := Decode[oauthCallbackRequest](r) | ||||
| 	decoded, err := Decode[discordOauthCallbackRequest](r) | ||||
| 	if err != nil { | ||||
| 		return server.APIError{Code: server.ErrBadRequest} | ||||
| 	} | ||||
|  | @ -153,7 +153,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error { | |||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| type signupRequest struct { | ||||
| type discordSignupRequest struct { | ||||
| 	Ticket     string `json:"ticket"` | ||||
| 	Username   string `json:"username"` | ||||
| 	InviteCode string `json:"invite_code"` | ||||
|  | @ -167,7 +167,7 @@ type signupResponse struct { | |||
| func (s *Server) discordSignup(w http.ResponseWriter, r *http.Request) error { | ||||
| 	ctx := r.Context() | ||||
| 
 | ||||
| 	req, err := Decode[signupRequest](r) | ||||
| 	req, err := Decode[discordSignupRequest](r) | ||||
| 	if err != nil { | ||||
| 		return server.APIError{Code: server.ErrBadRequest} | ||||
| 	} | ||||
|  |  | |||
							
								
								
									
										287
									
								
								backend/routes/auth/fedi_mastodon.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										287
									
								
								backend/routes/auth/fedi_mastodon.go
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,287 @@ | |||
| package auth | ||||
| 
 | ||||
| import ( | ||||
| 	"encoding/json" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"time" | ||||
| 
 | ||||
| 	"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/go-chi/render" | ||||
| 	"github.com/mediocregopher/radix/v4" | ||||
| 	"github.com/rs/xid" | ||||
| ) | ||||
| 
 | ||||
| type fediOauthCallbackRequest struct { | ||||
| 	Instance string `json:"instance"` | ||||
| 	Code     string `json:"code"` | ||||
| 	State    string `json:"state"` | ||||
| } | ||||
| 
 | ||||
| type fediCallbackResponse struct { | ||||
| 	HasAccount bool `json:"has_account"` // if true, Token and User will be set. if false, Ticket and Fediverse will be set | ||||
| 
 | ||||
| 	Token string        `json:"token,omitempty"` | ||||
| 	User  *userResponse `json:"user,omitempty"` | ||||
| 
 | ||||
| 	Fediverse     string `json:"fediverse,omitempty"` // username, for UI purposes | ||||
| 	Ticket        string `json:"ticket,omitempty"` | ||||
| 	RequireInvite bool   `json:"require_invite"` // require an invite for signing up | ||||
| 
 | ||||
| 	IsDeleted bool       `json:"is_deleted"` | ||||
| 	DeletedAt *time.Time `json:"deleted_at,omitempty"` | ||||
| } | ||||
| 
 | ||||
| type partialMastodonAccount struct { | ||||
| 	ID       string `json:"id"` | ||||
| 	Username string `json:"username"` | ||||
| } | ||||
| 
 | ||||
| func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error { | ||||
| 	ctx := r.Context() | ||||
| 
 | ||||
| 	decoded, err := Decode[fediOauthCallbackRequest](r) | ||||
| 	if err != nil { | ||||
| 		return server.APIError{Code: server.ErrBadRequest} | ||||
| 	} | ||||
| 
 | ||||
| 	// if the state can't be validated, return | ||||
| 	if valid, err := s.validateCSRFState(ctx, decoded.State); !valid { | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 
 | ||||
| 		return server.APIError{Code: server.ErrInvalidState} | ||||
| 	} | ||||
| 
 | ||||
| 	app, err := s.DB.FediverseApp(ctx, decoded.Instance) | ||||
| 	if err != nil { | ||||
| 		log.Errorf("getting app for instance %q: %v", decoded.Instance, err) | ||||
| 
 | ||||
| 		if err == db.ErrNoInstanceApp { | ||||
| 			// can we get here? | ||||
| 			return server.APIError{Code: server.ErrNotFound} | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	token, err := app.ClientConfig().Exchange(ctx, decoded.Code) | ||||
| 	if err != nil { | ||||
| 		log.Errorf("exchanging oauth code: %v", err) | ||||
| 
 | ||||
| 		return server.APIError{Code: server.ErrInvalidOAuthCode} | ||||
| 	} | ||||
| 
 | ||||
| 	// make me user request | ||||
| 	req, err := http.NewRequestWithContext(ctx, "GET", "https://"+decoded.Instance+"/api/v1/accounts/verify_credentials", nil) | ||||
| 	if err != nil { | ||||
| 		return errors.Wrap(err, "creating verify_credentials request") | ||||
| 	} | ||||
| 	req.Header.Set("User-Agent", "pronouns.cc/"+server.Tag) | ||||
| 	req.Header.Set("Accept", "application/json") | ||||
| 	req.Header.Set("Authorization", token.Type()+" "+token.AccessToken) | ||||
| 
 | ||||
| 	resp, err := http.DefaultClient.Do(req) | ||||
| 	if err != nil { | ||||
| 		return errors.Wrap(err, "sending verify_credentials request") | ||||
| 	} | ||||
| 	defer resp.Body.Close() | ||||
| 
 | ||||
| 	jb, err := io.ReadAll(resp.Body) | ||||
| 	if err != nil { | ||||
| 		return errors.Wrap(err, "reading verify_credentials response") | ||||
| 	} | ||||
| 
 | ||||
| 	var mu partialMastodonAccount | ||||
| 	err = json.Unmarshal(jb, &mu) | ||||
| 	if err != nil { | ||||
| 		return errors.Wrap(err, "unmarshaling verify_credentials response") | ||||
| 	} | ||||
| 
 | ||||
| 	u, err := s.DB.FediverseUser(ctx, mu.ID, app.ID) | ||||
| 	if err == nil { | ||||
| 		if u.DeletedAt != nil && *u.SelfDelete { | ||||
| 			// store cancel delete token | ||||
| 			token := undeleteToken() | ||||
| 			err = s.saveUndeleteToken(ctx, u.ID, token) | ||||
| 			if err != nil { | ||||
| 				log.Errorf("saving undelete token: %v", err) | ||||
| 				return err | ||||
| 			} | ||||
| 
 | ||||
| 			render.JSON(w, r, fediCallbackResponse{ | ||||
| 				HasAccount: true, | ||||
| 				Token:      token, | ||||
| 				User:       dbUserToUserResponse(u, []db.Field{}), | ||||
| 				IsDeleted:  true, | ||||
| 				DeletedAt:  u.DeletedAt, | ||||
| 			}) | ||||
| 			return nil | ||||
| 		} | ||||
| 
 | ||||
| 		err = u.UpdateFromFedi(ctx, s.DB, mu.ID, mu.Username, app.ID) | ||||
| 		if err != nil { | ||||
| 			log.Errorf("updating user %v with mastoAPI info: %v", u.ID, err) | ||||
| 		} | ||||
| 
 | ||||
| 		// TODO: implement user + token permissions | ||||
| 		tokenID := xid.New() | ||||
| 		token, err := s.Auth.CreateToken(u.ID, tokenID, false, false, true) | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 
 | ||||
| 		// save token to database | ||||
| 		_, err = s.DB.SaveToken(ctx, u.ID, tokenID) | ||||
| 		if err != nil { | ||||
| 			return errors.Wrap(err, "saving token to database") | ||||
| 		} | ||||
| 
 | ||||
| 		fields, err := s.DB.UserFields(ctx, u.ID) | ||||
| 		if err != nil { | ||||
| 			return errors.Wrap(err, "querying fields") | ||||
| 		} | ||||
| 
 | ||||
| 		render.JSON(w, r, fediCallbackResponse{ | ||||
| 			HasAccount: true, | ||||
| 			Token:      token, | ||||
| 			User:       dbUserToUserResponse(u, fields), | ||||
| 		}) | ||||
| 
 | ||||
| 		return nil | ||||
| 
 | ||||
| 	} else if err != db.ErrUserNotFound { // internal error | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	// no user found, so save a ticket + save their Mastodon info in Redis | ||||
| 	ticket := RandBase64(32) | ||||
| 	err = s.DB.SetJSON(ctx, "mastodon:"+ticket, mu, "EX", "600") | ||||
| 	if err != nil { | ||||
| 		log.Errorf("setting mastoAPI user for ticket %q: %v", ticket, err) | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	render.JSON(w, r, fediCallbackResponse{ | ||||
| 		HasAccount:    false, | ||||
| 		Fediverse:     mu.Username, | ||||
| 		Ticket:        ticket, | ||||
| 		RequireInvite: s.RequireInvite, | ||||
| 	}) | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| type fediSignupRequest struct { | ||||
| 	Instance   string `json:"instance"` | ||||
| 	Ticket     string `json:"ticket"` | ||||
| 	Username   string `json:"username"` | ||||
| 	InviteCode string `json:"invite_code"` | ||||
| } | ||||
| 
 | ||||
| func (s *Server) mastodonSignup(w http.ResponseWriter, r *http.Request) error { | ||||
| 	ctx := r.Context() | ||||
| 
 | ||||
| 	req, err := Decode[fediSignupRequest](r) | ||||
| 	if err != nil { | ||||
| 		return server.APIError{Code: server.ErrBadRequest} | ||||
| 	} | ||||
| 
 | ||||
| 	if s.RequireInvite && req.InviteCode == "" { | ||||
| 		return server.APIError{Code: server.ErrInviteRequired} | ||||
| 	} | ||||
| 
 | ||||
| 	app, err := s.DB.FediverseApp(ctx, req.Instance) | ||||
| 	if err != nil { | ||||
| 		return errors.Wrap(err, "getting instance application") | ||||
| 	} | ||||
| 
 | ||||
| 	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) | ||||
| 
 | ||||
| 	mu := new(partialMastodonAccount) | ||||
| 	err = s.DB.GetJSON(ctx, "mastodon:"+req.Ticket, &mu) | ||||
| 	if err != nil { | ||||
| 		log.Errorf("getting mastoAPI 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.UpdateFromFedi(ctx, tx, mu.ID, mu.Username, app.ID) | ||||
| 	if err != nil { | ||||
| 		if errors.Cause(err) == db.ErrUsernameTaken { | ||||
| 			return server.APIError{Code: server.ErrUsernameTaken} | ||||
| 		} | ||||
| 
 | ||||
| 		return errors.Wrap(err, "updating user from mastoAPI") | ||||
| 	} | ||||
| 
 | ||||
| 	if s.RequireInvite { | ||||
| 		valid, used, err := s.DB.InvalidateInvite(ctx, tx, req.InviteCode) | ||||
| 		if err != nil { | ||||
| 			return errors.Wrap(err, "checking and invalidating invite") | ||||
| 		} | ||||
| 
 | ||||
| 		if !valid { | ||||
| 			return server.APIError{Code: server.ErrInviteRequired} | ||||
| 		} | ||||
| 
 | ||||
| 		if used { | ||||
| 			return server.APIError{Code: server.ErrInviteAlreadyUsed} | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	// delete sign up ticket | ||||
| 	err = s.DB.Redis.Do(ctx, radix.Cmd(nil, "DEL", "mastodon:"+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 | ||||
| 	// TODO: implement user + token permissions | ||||
| 	tokenID := xid.New() | ||||
| 	token, err := s.Auth.CreateToken(u.ID, tokenID, false, false, true) | ||||
| 	if err != nil { | ||||
| 		return errors.Wrap(err, "creating token") | ||||
| 	} | ||||
| 
 | ||||
| 	// save token to database | ||||
| 	_, err = s.DB.SaveToken(ctx, u.ID, tokenID) | ||||
| 	if err != nil { | ||||
| 		return errors.Wrap(err, "saving token to database") | ||||
| 	} | ||||
| 
 | ||||
| 	// return user | ||||
| 	render.JSON(w, r, signupResponse{ | ||||
| 		User:  *dbUserToUserResponse(u, nil), | ||||
| 		Token: token, | ||||
| 	}) | ||||
| 	return nil | ||||
| } | ||||
|  | @ -58,7 +58,7 @@ func (s *Server) noAppFediverseURL(ctx context.Context, w http.ResponseWriter, r | |||
| 
 | ||||
| 	formData := url.Values{ | ||||
| 		"client_name":   {"pronouns.cc (+" + s.BaseURL + ")"}, | ||||
| 		"redirect_uris": {s.BaseURL + "/auth/login/mastodon"}, | ||||
| 		"redirect_uris": {s.BaseURL + "/auth/login/mastodon/" + instance}, | ||||
| 		"scopes":        {"read:accounts"}, | ||||
| 		"website":       {s.BaseURL}, | ||||
| 	} | ||||
|  |  | |||
|  | @ -76,8 +76,8 @@ func Mount(srv *server.Server, r chi.Router) { | |||
| 		}) | ||||
| 
 | ||||
| 		r.Route("/mastodon", func(r chi.Router) { | ||||
| 			r.Post("/callback", server.WrapHandler(nil)) | ||||
| 			r.Post("/signup", server.WrapHandler(nil)) | ||||
| 			r.Post("/callback", server.WrapHandler(s.mastodonCallback)) | ||||
| 			r.Post("/signup", server.WrapHandler(s.mastodonSignup)) | ||||
| 			r.With(server.MustAuth).Post("/add-provider", server.WrapHandler(nil)) | ||||
| 		}) | ||||
| 
 | ||||
|  |  | |||
|  | @ -0,0 +1,36 @@ | |||
| import type { APIError, MeUser } from "$lib/api/entities"; | ||||
| import { apiFetch } from "$lib/api/fetch"; | ||||
| import type { PageServerLoad } from "./$types"; | ||||
| 
 | ||||
| export const load = (async ({ url, params }) => { | ||||
|   try { | ||||
|     const resp = await apiFetch<CallbackResponse>("/auth/mastodon/callback", { | ||||
|       method: "POST", | ||||
|       body: { | ||||
|         instance: params.instance, | ||||
|         code: url.searchParams.get("code"), | ||||
|         state: url.searchParams.get("state"), | ||||
|       }, | ||||
|     }); | ||||
| 
 | ||||
|     return { | ||||
|       ...resp, | ||||
|       instance: params.instance, | ||||
|     }; | ||||
|   } catch (e) { | ||||
|     return { error: e as APIError }; | ||||
|   } | ||||
| }) satisfies PageServerLoad; | ||||
| 
 | ||||
| interface CallbackResponse { | ||||
|   has_account: boolean; | ||||
|   token?: string; | ||||
|   user?: MeUser; | ||||
| 
 | ||||
|   fediverse?: string; | ||||
|   ticket?: string; | ||||
|   require_invite: boolean; | ||||
| 
 | ||||
|   is_deleted: boolean; | ||||
|   deleted_at?: string; | ||||
| } | ||||
							
								
								
									
										137
									
								
								frontend/src/routes/auth/login/mastodon/[instance]/+page.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										137
									
								
								frontend/src/routes/auth/login/mastodon/[instance]/+page.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,137 @@ | |||
| <script lang="ts"> | ||||
|   import { onMount } from "svelte"; | ||||
|   import { Alert, Button, Icon } from "sveltestrap"; | ||||
| 
 | ||||
|   import { goto } from "$app/navigation"; | ||||
|   import type { APIError, MeUser } from "$lib/api/entities"; | ||||
|   import { apiFetch } from "$lib/api/fetch"; | ||||
|   import { userStore } from "$lib/store"; | ||||
|   import type { PageData } from "./$types"; | ||||
|   import ErrorAlert from "$lib/components/ErrorAlert.svelte"; | ||||
| 
 | ||||
|   interface SignupResponse { | ||||
|     user: MeUser; | ||||
|     token: string; | ||||
|   } | ||||
| 
 | ||||
|   export let data: PageData; | ||||
| 
 | ||||
|   onMount(() => { | ||||
|     if (!data.is_deleted && data.token && data.user) { | ||||
|       localStorage.setItem("pronouns-token", data.token); | ||||
|       localStorage.setItem("pronouns-user", JSON.stringify(data.user)); | ||||
|       userStore.set(data.user); | ||||
|       goto("/"); | ||||
|     } | ||||
|   }); | ||||
| 
 | ||||
|   let username = ""; | ||||
|   let invite = ""; | ||||
| 
 | ||||
|   const signupForm = async () => { | ||||
|     try { | ||||
|       const resp = await apiFetch<SignupResponse>("/auth/mastodon/signup", { | ||||
|         method: "POST", | ||||
|         body: { | ||||
|           instance: data.instance, | ||||
|           ticket: data.ticket, | ||||
|           username: username, | ||||
|           invite_code: invite, | ||||
|         }, | ||||
|       }); | ||||
| 
 | ||||
|       localStorage.setItem("pronouns-token", resp.token); | ||||
|       localStorage.setItem("pronouns-user", JSON.stringify(resp.user)); | ||||
|       userStore.set(resp.user); | ||||
|       goto("/"); | ||||
|     } catch (e) { | ||||
|       data.error = e as APIError; | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   let deleteCancelled = false; | ||||
|   let deleteError: APIError | null = null; | ||||
|   const cancelDelete = async () => { | ||||
|     try { | ||||
|       await apiFetch<any>("/auth/cancel-delete", { | ||||
|         method: "GET", | ||||
|         headers: { | ||||
|           "X-Delete-Token": data.token!, | ||||
|         }, | ||||
|       }); | ||||
| 
 | ||||
|       deleteCancelled = true; | ||||
|       deleteError = null; | ||||
|     } catch (e) { | ||||
|       deleteCancelled = false; | ||||
|       deleteError = e as APIError; | ||||
|     } | ||||
|   }; | ||||
| </script> | ||||
| 
 | ||||
| <svelte:head> | ||||
|   <title>Log in with Mastodon - pronouns.cc</title> | ||||
| </svelte:head> | ||||
| 
 | ||||
| <h1>Log in with Mastodon</h1> | ||||
| 
 | ||||
| {#if data.error} | ||||
|   <ErrorAlert error={data.error} /> | ||||
| {/if} | ||||
| {#if data.ticket} | ||||
|   <form on:submit|preventDefault={signupForm}> | ||||
|     <div> | ||||
|       <label for="fediverse">Fediverse username</label> | ||||
|       <input | ||||
|         id="fediverse" | ||||
|         class="form-control" | ||||
|         name="fediverse" | ||||
|         disabled | ||||
|         value="{data.fediverse}@{data.instance}" | ||||
|       /> | ||||
|     </div> | ||||
|     <div> | ||||
|       <label for="username">Username</label> | ||||
|       <input id="username" class="form-control" name="username" bind:value={username} /> | ||||
|     </div> | ||||
|     {#if data.require_invite} | ||||
|       <div> | ||||
|         <label for="invite">Invite code</label> | ||||
|         <input | ||||
|           id="invite" | ||||
|           class="form-control" | ||||
|           name="invite" | ||||
|           bind:value={invite} | ||||
|           aria-describedby="invite-help" | ||||
|         /> | ||||
|         <div id="invite-help" class="form-text"> | ||||
|           <Icon name="info-circle-fill" /> You currently need an invite code to sign up. You can get | ||||
|           one from an existing user. | ||||
|         </div> | ||||
|       </div> | ||||
|     {/if} | ||||
|     <div class="form-text"> | ||||
|       By signing up, you agree to the <a href="/page/tos">terms of service</a> and the | ||||
|       <a href="/page/privacy">privacy policy</a>. | ||||
|     </div> | ||||
|     <Button type="submit" color="primary">Sign up</Button> | ||||
|   </form> | ||||
| {:else if data.is_deleted && data.token} | ||||
|   <p>Your account is pending deletion since {data.deleted_at}.</p> | ||||
|   <p>If you wish to cancel deletion, press the button below.</p> | ||||
|   <p> | ||||
|     <Button color="primary" on:click={cancelDelete} disabled={deleteCancelled} | ||||
|       >Cancel account deletion</Button | ||||
|     > | ||||
|   </p> | ||||
|   {#if deleteCancelled} | ||||
|     <Alert color="secondary" fade={false}> | ||||
|       Account deletion cancelled! You can now <a href="/auth/login">log in</a> again. | ||||
|     </Alert> | ||||
|   {/if} | ||||
|   {#if deleteError} | ||||
|     <ErrorAlert error={deleteError} /> | ||||
|   {/if} | ||||
| {:else} | ||||
|   Loading... | ||||
| {/if} | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue