diff --git a/backend/db/user.go b/backend/db/user.go
index 8c9d00f..7e1911f 100644
--- a/backend/db/user.go
+++ b/backend/db/user.go
@@ -39,6 +39,9 @@ type User struct {
Tumblr *string
TumblrUsername *string
+ Google *string
+ GoogleUsername *string
+
MaxInvites int
IsAdmin bool
ListPrivate bool
@@ -58,6 +61,9 @@ func (u User) NumProviders() (numProviders int) {
if u.Tumblr != nil {
numProviders++
}
+ if u.Google != nil {
+ numProviders++
+ }
return numProviders
}
@@ -307,6 +313,67 @@ func (u *User) UnlinkTumblr(ctx context.Context, ex Execer) error {
return nil
}
+// GoogleUser fetches a user by Google user ID.
+func (db *DB) GoogleUser(ctx context.Context, googleID string) (u User, err error) {
+ sql, args, err := sq.Select("*", "(SELECT instance FROM fediverse_apps WHERE id = users.fediverse_app_id) AS fediverse_instance").
+ From("users").Where("google = ?", googleID).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) UpdateFromGoogle(ctx context.Context, ex Execer, googleID, googleUsername string) error {
+ sql, args, err := sq.Update("users").
+ Set("google", googleID).
+ Set("google_username", googleUsername).
+ 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.Google = &googleID
+ u.GoogleUsername = &googleUsername
+
+ return nil
+}
+
+func (u *User) UnlinkGoogle(ctx context.Context, ex Execer) error {
+ sql, args, err := sq.Update("users").
+ Set("google", nil).
+ Set("google_username", nil).
+ 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.Google = nil
+ u.GoogleUsername = nil
+
+ return nil
+}
+
// User gets a user by ID.
func (db *DB) User(ctx context.Context, id xid.ID) (u User, err error) {
sql, args, err := sq.Select("*", "(SELECT instance FROM fediverse_apps WHERE id = users.fediverse_app_id) AS fediverse_instance").
diff --git a/backend/exporter/types.go b/backend/exporter/types.go
index 48682d0..f554e5a 100644
--- a/backend/exporter/types.go
+++ b/backend/exporter/types.go
@@ -27,6 +27,9 @@ type userExport struct {
Tumblr *string `json:"tumblr"`
TumblrUsername *string `json:"tumblr_username"`
+ Google *string `json:"google"`
+ GoogleUsername *string `json:"google_username"`
+
MaxInvites int `json:"max_invites"`
Warnings []db.Warning `json:"warnings"`
@@ -46,6 +49,8 @@ func dbUserToExport(u db.User, fields []db.Field, warnings []db.Warning) userExp
DiscordUsername: u.DiscordUsername,
Tumblr: u.Tumblr,
TumblrUsername: u.TumblrUsername,
+ Google: u.Google,
+ GoogleUsername: u.GoogleUsername,
MaxInvites: u.MaxInvites,
Fediverse: u.Fediverse,
FediverseUsername: u.FediverseUsername,
diff --git a/backend/routes/auth/discord.go b/backend/routes/auth/discord.go
index 975accf..7ef3b04 100644
--- a/backend/routes/auth/discord.go
+++ b/backend/routes/auth/discord.go
@@ -27,7 +27,7 @@ var discordOAuthConfig = oauth2.Config{
Scopes: []string{"identify"},
}
-type discordOauthCallbackRequest struct {
+type oauthCallbackRequest struct {
CallbackDomain string `json:"callback_domain"`
Code string `json:"code"`
State string `json:"state"`
@@ -52,7 +52,7 @@ type discordCallbackResponse struct {
func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
- decoded, err := Decode[discordOauthCallbackRequest](r)
+ decoded, err := Decode[oauthCallbackRequest](r)
if err != nil {
return server.APIError{Code: server.ErrBadRequest}
}
@@ -245,7 +245,7 @@ func (s *Server) discordUnlink(w http.ResponseWriter, r *http.Request) error {
return nil
}
-type discordSignupRequest struct {
+type signupRequest struct {
Ticket string `json:"ticket"`
Username string `json:"username"`
InviteCode string `json:"invite_code"`
@@ -259,7 +259,7 @@ type signupResponse struct {
func (s *Server) discordSignup(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
- req, err := Decode[discordSignupRequest](r)
+ req, err := Decode[signupRequest](r)
if err != nil {
return server.APIError{Code: server.ErrBadRequest}
}
diff --git a/backend/routes/auth/google.go b/backend/routes/auth/google.go
new file mode 100644
index 0000000..d28b48b
--- /dev/null
+++ b/backend/routes/auth/google.go
@@ -0,0 +1,361 @@
+package auth
+
+import (
+ "net/http"
+ "os"
+ "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"
+ "golang.org/x/oauth2"
+ "google.golang.org/api/idtoken"
+)
+
+var googleOAuthConfig = oauth2.Config{
+ ClientID: os.Getenv("GOOGLE_CLIENT_ID"),
+ ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
+ Endpoint: oauth2.Endpoint{
+ AuthURL: "https://accounts.google.com/o/oauth2/auth",
+ TokenURL: "https://oauth2.googleapis.com/token",
+ AuthStyle: oauth2.AuthStyleInParams,
+ },
+ Scopes: []string{"openid", "https://www.googleapis.com/auth/userinfo.email"},
+}
+
+type googleCallbackResponse struct {
+ HasAccount bool `json:"has_account"` // if true, Token and User will be set. if false, Ticket and Google will be set
+
+ Token string `json:"token,omitempty"`
+ User *userResponse `json:"user,omitempty"`
+
+ Google string `json:"google,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"`
+ SelfDelete *bool `json:"self_delete,omitempty"`
+ DeleteReason *string `json:"delete_reason,omitempty"`
+}
+
+type partialGoogleUser struct {
+ ID string `json:"id"`
+ Email string `json:"email"`
+}
+
+func (s *Server) googleCallback(w http.ResponseWriter, r *http.Request) error {
+ ctx := r.Context()
+
+ decoded, err := Decode[oauthCallbackRequest](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}
+ }
+
+ cfg := googleOAuthConfig
+ cfg.RedirectURL = decoded.CallbackDomain + "/auth/login/google"
+ token, err := cfg.Exchange(r.Context(), decoded.Code)
+ if err != nil {
+ log.Errorf("exchanging oauth code: %v", err)
+ return server.APIError{Code: server.ErrInvalidOAuthCode}
+ }
+ rawToken := token.Extra("id_token")
+ if rawToken == nil {
+ log.Debug("id_token was nil")
+ return server.APIError{Code: server.ErrInternalServerError}
+ }
+
+ idToken, ok := rawToken.(string)
+ if !ok {
+ log.Debug("id_token was not a string")
+ return server.APIError{Code: server.ErrInternalServerError}
+ }
+ payload, err := idtoken.Validate(ctx, idToken, "")
+ if err != nil {
+ log.Errorf("getting id token payload: %v", err)
+ return server.APIError{Code: server.ErrInternalServerError}
+ }
+
+ googleID, ok := payload.Claims["sub"].(string)
+ if !ok {
+ log.Debug("id_token.claims.sub was not a string")
+ return server.APIError{Code: server.ErrInternalServerError}
+ }
+ googleUsername, ok := payload.Claims["email"].(string)
+ if !ok {
+ log.Debug("id_token.claims.email was not a string")
+ return server.APIError{Code: server.ErrInternalServerError}
+ }
+
+ u, err := s.DB.GoogleUser(ctx, googleID)
+ if err == nil {
+ if u.DeletedAt != nil {
+ // 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, googleCallbackResponse{
+ HasAccount: true,
+ Token: token,
+ User: dbUserToUserResponse(u, []db.Field{}),
+ IsDeleted: true,
+ DeletedAt: u.DeletedAt,
+ SelfDelete: u.SelfDelete,
+ DeleteReason: u.DeleteReason,
+ })
+ return nil
+ }
+
+ err = u.UpdateFromGoogle(ctx, s.DB, googleID, googleUsername)
+ if err != nil {
+ log.Errorf("updating user %v with Google info: %v", u.ID, err)
+ }
+
+ // TODO: implement user + token permissions
+ tokenID := xid.New()
+ token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true)
+ if err != nil {
+ return err
+ }
+
+ // save token to database
+ _, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false)
+ 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, googleCallbackResponse{
+ 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 Google info in Redis
+ ticket := RandBase64(32)
+ err = s.DB.SetJSON(ctx, "google:"+ticket, partialGoogleUser{ID: googleID, Email: googleUsername}, "EX", "600")
+ if err != nil {
+ log.Errorf("setting Google user for ticket %q: %v", ticket, err)
+ return err
+ }
+
+ render.JSON(w, r, googleCallbackResponse{
+ HasAccount: false,
+ Google: googleUsername,
+ Ticket: ticket,
+ RequireInvite: s.RequireInvite,
+ })
+
+ return nil
+}
+
+func (s *Server) googleLink(w http.ResponseWriter, r *http.Request) error {
+ ctx := r.Context()
+
+ claims, _ := server.ClaimsFromContext(ctx)
+
+ // only site tokens can be used for this endpoint
+ if claims.APIToken {
+ return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
+ }
+
+ req, err := Decode[linkRequest](r)
+ if err != nil {
+ return server.APIError{Code: server.ErrBadRequest}
+ }
+
+ u, err := s.DB.User(ctx, claims.UserID)
+ if err != nil {
+ return errors.Wrap(err, "getting user")
+ }
+
+ if u.Google != nil {
+ return server.APIError{Code: server.ErrAlreadyLinked}
+ }
+
+ gu := new(partialGoogleUser)
+ err = s.DB.GetJSON(ctx, "google:"+req.Ticket, &gu)
+ if err != nil {
+ log.Errorf("getting google user for ticket: %v", err)
+
+ return server.APIError{Code: server.ErrInvalidTicket}
+ }
+
+ err = u.UpdateFromGoogle(ctx, s.DB, gu.ID, gu.Email)
+ if err != nil {
+ return errors.Wrap(err, "updating user from google")
+ }
+
+ fields, err := s.DB.UserFields(ctx, u.ID)
+ if err != nil {
+ return errors.Wrap(err, "getting user fields")
+ }
+
+ render.JSON(w, r, dbUserToUserResponse(u, fields))
+ return nil
+}
+
+func (s *Server) googleUnlink(w http.ResponseWriter, r *http.Request) error {
+ ctx := r.Context()
+
+ claims, _ := server.ClaimsFromContext(ctx)
+
+ // only site tokens can be used for this endpoint
+ if claims.APIToken {
+ return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
+ }
+
+ u, err := s.DB.User(ctx, claims.UserID)
+ if err != nil {
+ return errors.Wrap(err, "getting user")
+ }
+
+ if u.Google == nil {
+ return server.APIError{Code: server.ErrNotLinked}
+ }
+
+ // cannot unlink last auth provider
+ if u.NumProviders() <= 1 {
+ return server.APIError{Code: server.ErrLastProvider}
+ }
+
+ err = u.UnlinkGoogle(ctx, s.DB)
+ if err != nil {
+ return errors.Wrap(err, "updating user in db")
+ }
+
+ fields, err := s.DB.UserFields(ctx, u.ID)
+ if err != nil {
+ return errors.Wrap(err, "getting user fields")
+ }
+
+ render.JSON(w, r, dbUserToUserResponse(u, fields))
+ return nil
+}
+
+func (s *Server) googleSignup(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)
+
+ gu := new(partialGoogleUser)
+ err = s.DB.GetJSON(ctx, "google:"+req.Ticket, &gu)
+ if err != nil {
+ log.Errorf("getting google user for ticket: %v", err)
+
+ return server.APIError{Code: server.ErrInvalidTicket}
+ }
+
+ u, err := s.DB.CreateUser(ctx, tx, req.Username)
+ if err != nil {
+ if errors.Cause(err) == db.ErrUsernameTaken {
+ return server.APIError{Code: server.ErrUsernameTaken}
+ }
+
+ return errors.Wrap(err, "creating user")
+ }
+
+ err = u.UpdateFromGoogle(ctx, tx, gu.ID, gu.Email)
+ if err != nil {
+ return errors.Wrap(err, "updating user from google")
+ }
+
+ 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", "google:"+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, false, false)
+ 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
+}
diff --git a/backend/routes/auth/routes.go b/backend/routes/auth/routes.go
index 18a06e5..7fec89c 100644
--- a/backend/routes/auth/routes.go
+++ b/backend/routes/auth/routes.go
@@ -37,6 +37,9 @@ type userResponse struct {
Tumblr *string `json:"tumblr"`
TumblrUsername *string `json:"tumblr_username"`
+ Google *string `json:"google"`
+ GoogleUsername *string `json:"google_username"`
+
Fediverse *string `json:"fediverse"`
FediverseUsername *string `json:"fediverse_username"`
FediverseInstance *string `json:"fediverse_instance"`
@@ -57,6 +60,8 @@ func dbUserToUserResponse(u db.User, fields []db.Field) *userResponse {
DiscordUsername: u.DiscordUsername,
Tumblr: u.Tumblr,
TumblrUsername: u.TumblrUsername,
+ Google: u.Google,
+ GoogleUsername: u.GoogleUsername,
Fediverse: u.Fediverse,
FediverseUsername: u.FediverseUsername,
FediverseInstance: u.FediverseInstance,
@@ -96,6 +101,13 @@ func Mount(srv *server.Server, r chi.Router) {
r.With(server.MustAuth).Post("/remove-provider", server.WrapHandler(s.tumblrUnlink))
})
+ r.Route("/google", func(r chi.Router) {
+ r.Post("/callback", server.WrapHandler(s.googleCallback))
+ r.Post("/signup", server.WrapHandler(s.googleSignup))
+ r.With(server.MustAuth).Post("/add-provider", server.WrapHandler(s.googleLink))
+ r.With(server.MustAuth).Post("/remove-provider", server.WrapHandler(s.googleUnlink))
+ })
+
r.Route("/mastodon", func(r chi.Router) {
r.Post("/callback", server.WrapHandler(s.mastodonCallback))
r.Post("/signup", server.WrapHandler(s.mastodonSignup))
@@ -134,6 +146,7 @@ type oauthURLsRequest struct {
type oauthURLsResponse struct {
Discord string `json:"discord"`
Tumblr string `json:"tumblr"`
+ Google string `json:"google"`
}
func (s *Server) oauthURLs(w http.ResponseWriter, r *http.Request) error {
@@ -156,10 +169,14 @@ func (s *Server) oauthURLs(w http.ResponseWriter, r *http.Request) error {
// copy tumblr config
tumblrCfg := tumblrOAuthConfig
tumblrCfg.RedirectURL = req.CallbackDomain + "/auth/login/tumblr"
+ // copy google config
+ googleCfg := googleOAuthConfig
+ googleCfg.RedirectURL = req.CallbackDomain + "/auth/login/google"
render.JSON(w, r, oauthURLsResponse{
Discord: discordCfg.AuthCodeURL(state) + "&prompt=none",
Tumblr: tumblrCfg.AuthCodeURL(state),
+ Google: googleCfg.AuthCodeURL(state),
})
return nil
}
diff --git a/backend/routes/auth/tumblr.go b/backend/routes/auth/tumblr.go
index d477b31..39dab61 100644
--- a/backend/routes/auth/tumblr.go
+++ b/backend/routes/auth/tumblr.go
@@ -68,7 +68,7 @@ type tumblrCallbackResponse struct {
func (s *Server) tumblrCallback(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
- decoded, err := Decode[discordOauthCallbackRequest](r)
+ decoded, err := Decode[oauthCallbackRequest](r)
if err != nil {
return server.APIError{Code: server.ErrBadRequest}
}
@@ -296,7 +296,7 @@ func (s *Server) tumblrUnlink(w http.ResponseWriter, r *http.Request) error {
func (s *Server) tumblrSignup(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
- req, err := Decode[discordSignupRequest](r)
+ req, err := Decode[signupRequest](r)
if err != nil {
return server.APIError{Code: server.ErrBadRequest}
}
diff --git a/backend/routes/user/get_user.go b/backend/routes/user/get_user.go
index 9265ce1..8e36015 100644
--- a/backend/routes/user/get_user.go
+++ b/backend/routes/user/get_user.go
@@ -38,6 +38,9 @@ type GetMeResponse struct {
Tumblr *string `json:"tumblr"`
TumblrUsername *string `json:"tumblr_username"`
+ Google *string `json:"google"`
+ GoogleUsername *string `json:"google_username"`
+
Fediverse *string `json:"fediverse"`
FediverseUsername *string `json:"fediverse_username"`
FediverseInstance *string `json:"fediverse_instance"`
@@ -196,6 +199,8 @@ func (s *Server) getMeUser(w http.ResponseWriter, r *http.Request) error {
DiscordUsername: u.DiscordUsername,
Tumblr: u.Tumblr,
TumblrUsername: u.TumblrUsername,
+ Google: u.Google,
+ GoogleUsername: u.GoogleUsername,
Fediverse: u.Fediverse,
FediverseUsername: u.FediverseUsername,
FediverseInstance: u.FediverseInstance,
diff --git a/backend/routes/user/patch_user.go b/backend/routes/user/patch_user.go
index bef275a..0b2b41e 100644
--- a/backend/routes/user/patch_user.go
+++ b/backend/routes/user/patch_user.go
@@ -253,6 +253,8 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
DiscordUsername: u.DiscordUsername,
Tumblr: u.Tumblr,
TumblrUsername: u.TumblrUsername,
+ Google: u.Google,
+ GoogleUsername: u.GoogleUsername,
Fediverse: u.Fediverse,
FediverseUsername: u.FediverseUsername,
FediverseInstance: fediInstance,
diff --git a/frontend/src/lib/api/entities.ts b/frontend/src/lib/api/entities.ts
index 18043e6..70dd2a6 100644
--- a/frontend/src/lib/api/entities.ts
+++ b/frontend/src/lib/api/entities.ts
@@ -24,6 +24,8 @@ export interface MeUser extends User {
discord_username: string | null;
tumblr: string | null;
tumblr_username: string | null;
+ google: string | null;
+ google_username: string | null;
fediverse: string | null;
fediverse_username: string | null;
fediverse_instance: string | null;
diff --git a/frontend/src/lib/api/responses.ts b/frontend/src/lib/api/responses.ts
index e3cc6cb..561deba 100644
--- a/frontend/src/lib/api/responses.ts
+++ b/frontend/src/lib/api/responses.ts
@@ -16,6 +16,7 @@ export interface MetaResponse {
export interface UrlsResponse {
discord: string;
tumblr: string;
+ google: string;
}
export interface ExportResponse {
diff --git a/frontend/src/routes/auth/login/+page.svelte b/frontend/src/routes/auth/login/+page.svelte
index 4150e4a..c7ee00b 100644
--- a/frontend/src/routes/auth/login/+page.svelte
+++ b/frontend/src/routes/auth/login/+page.svelte
@@ -17,7 +17,6 @@
ModalFooter,
} from "sveltestrap";
import type { PageData } from "./$types";
- import fediverse from "./fediverse.svg";
export let data: PageData;
@@ -63,6 +62,7 @@
Log in with the FediverseLog in with DiscordLog in with Tumblr
+ Log in with Google
diff --git a/frontend/src/routes/auth/login/google/+page.server.ts b/frontend/src/routes/auth/login/google/+page.server.ts
new file mode 100644
index 0000000..1e5cb14
--- /dev/null
+++ b/frontend/src/routes/auth/login/google/+page.server.ts
@@ -0,0 +1,38 @@
+import type { APIError, MeUser } from "$lib/api/entities";
+import { apiFetch } from "$lib/api/fetch";
+import type { PageServerLoad } from "./$types";
+import { PUBLIC_BASE_URL } from "$env/static/public";
+
+export const load = (async ({ url }) => {
+ try {
+ const resp = await apiFetch("/auth/google/callback", {
+ method: "POST",
+ body: {
+ callback_domain: PUBLIC_BASE_URL,
+ code: url.searchParams.get("code"),
+ state: url.searchParams.get("state"),
+ },
+ });
+
+ return {
+ ...resp,
+ };
+ } catch (e) {
+ return { error: e as APIError };
+ }
+}) satisfies PageServerLoad;
+
+interface CallbackResponse {
+ has_account: boolean;
+ token?: string;
+ user?: MeUser;
+
+ google?: string;
+ ticket?: string;
+ require_invite: boolean;
+
+ is_deleted: boolean;
+ deleted_at?: string;
+ self_delete?: boolean;
+ delete_reason?: string;
+}
diff --git a/frontend/src/routes/auth/login/google/+page.svelte b/frontend/src/routes/auth/login/google/+page.svelte
new file mode 100644
index 0000000..487917b
--- /dev/null
+++ b/frontend/src/routes/auth/login/google/+page.svelte
@@ -0,0 +1,64 @@
+
+
+
diff --git a/frontend/src/routes/settings/auth/+page.svelte b/frontend/src/routes/settings/auth/+page.svelte
index 0a7ff9c..59dfb03 100644
--- a/frontend/src/routes/settings/auth/+page.svelte
+++ b/frontend/src/routes/settings/auth/+page.svelte
@@ -21,7 +21,7 @@
let canUnlink = false;
$: canUnlink =
- [data.user.discord, data.user.fediverse, data.user.tumblr]
+ [data.user.discord, data.user.fediverse, data.user.tumblr, data.user.google]
.map((entry) => (entry === null ? 0 : 1))
.reduce((prev, current) => prev + current) >= 2;
@@ -41,6 +41,9 @@
let tumblrUnlinkModalOpen = false;
let toggleTumblrUnlinkModal = () => (tumblrUnlinkModalOpen = !tumblrUnlinkModalOpen);
+ let googleUnlinkModalOpen = false;
+ let toggleGoogleUnlinkModal = () => (googleUnlinkModalOpen = !googleUnlinkModalOpen);
+
const fediLogin = async () => {
fediDisabled = true;
try {
@@ -88,6 +91,17 @@
error = e as APIError;
}
};
+
+ const googleUnlink = async () => {
+ try {
+ const resp = await apiFetchClient("/auth/google/remove-provider", "POST");
+ data.user = resp;
+ addToast({ header: "Unlinked account", body: "Successfully unlinked Google account!" });
+ toggleGoogleUnlinkModal();
+ } catch (e) {
+ error = e as APIError;
+ }
+ };
@@ -162,6 +176,28 @@
+
+
+
+ Google
+
+ {#if data.user.google}
+ Your currently linked Google account is {data.user.google_username}
+ ({data.user.google}).
+ {:else}
+ You do not have a linked Google account.
+ {/if}
+
+ {#if data.user.google}
+
+ {:else}
+
+ {/if}
+
+
+
@@ -243,5 +279,27 @@
+
+
+
+
+ Are you sure you want to unlink your Google account? You will no longer be able to use it
+ to log in.
+