feat: discord login works!

This commit is contained in:
Sam 2022-05-12 16:41:32 +02:00
parent 206feb21b8
commit d2f4e09a01
11 changed files with 208 additions and 48 deletions

View File

@ -1,5 +1,6 @@
migrate:
go build -v ./scripts/migrate
go run -v ./scripts/migrate
.PHONY: api
api:
go build -v -o api -ldflags="-buildid= -X gitlab.com/1f320/pronouns/backend/server.Revision=`git rev-parse --short HEAD`" ./backend

View File

@ -9,15 +9,10 @@ import (
"gitlab.com/1f320/pronouns/backend/log"
"gitlab.com/1f320/pronouns/backend/server"
"github.com/joho/godotenv"
_ "github.com/joho/godotenv/autoload"
)
func main() {
err := godotenv.Load()
if err != nil {
log.Fatalf("Error loading .env file: %v", err)
}
port := ":" + os.Getenv("PORT")
s, err := server.New()

View File

@ -1,6 +1,7 @@
package auth
import (
"fmt"
"net/http"
"os"
@ -24,8 +25,9 @@ var discordOAuthConfig = oauth2.Config{
}
type oauthCallbackRequest struct {
Code string `json:"code"`
State string `json:"state"`
CallbackDomain string `json:"callback_domain"`
Code string `json:"code"`
State string `json:"state"`
}
type discordCallbackResponse struct {
@ -55,7 +57,9 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
return server.APIError{Code: server.ErrInvalidState}
}
token, err := discordOAuthConfig.Exchange(r.Context(), decoded.Code)
cfg := discordOAuthConfig
cfg.RedirectURL = decoded.CallbackDomain + "/login/discord"
token, err := cfg.Exchange(r.Context(), decoded.Code)
if err != nil {
log.Errorf("exchanging oauth code: %v", err)
@ -80,6 +84,8 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
return err
}
fmt.Println(token)
render.JSON(w, r, discordCallbackResponse{
HasAccount: true,
Token: token,

View File

@ -6,6 +6,7 @@ import (
"emperror.dev/errors"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"gitlab.com/1f320/pronouns/backend/log"
"gitlab.com/1f320/pronouns/backend/server"
)
@ -18,11 +19,11 @@ func Mount(srv *server.Server, r chi.Router) {
r.Route("/auth", func(r chi.Router) {
// generate csrf token, returns all supported OAuth provider URLs
r.Get("/urls", server.WrapHandler(s.oauthURLs))
r.Post("/urls", server.WrapHandler(s.oauthURLs))
r.Route("/discord", func(r chi.Router) {
// takes code + state, validates it, returns token OR discord signup ticket
r.Post("/callback", nil)
r.Post("/callback", server.WrapHandler(s.discordCallback))
// takes discord signup ticket to register account
r.Post("/signup", nil)
})
@ -30,7 +31,7 @@ func Mount(srv *server.Server, r chi.Router) {
}
type oauthURLsRequest struct {
CallbackURL string `json:"callback_url"`
CallbackDomain string `json:"callback_domain"`
}
type oauthURLsResponse struct {
@ -40,6 +41,8 @@ type oauthURLsResponse struct {
func (s *Server) oauthURLs(w http.ResponseWriter, r *http.Request) error {
req, err := Decode[oauthURLsRequest](r)
if err != nil {
log.Error(err)
return server.APIError{Code: server.ErrBadRequest}
}
@ -51,7 +54,7 @@ func (s *Server) oauthURLs(w http.ResponseWriter, r *http.Request) error {
// copy Discord config and set redirect url
discordCfg := discordOAuthConfig
discordCfg.RedirectURL = req.CallbackURL
discordCfg.RedirectURL = req.CallbackDomain + "/login/discord"
render.JSON(w, r, oauthURLsResponse{
Discord: discordCfg.AuthCodeURL(state),

View File

@ -3,6 +3,8 @@ import "./App.css";
import Container from "./lib/Container";
import Navigation from "./lib/Navigation";
import Home from "./pages/Home";
import Discord from "./pages/login/Discord";
import Login from "./pages/login/Login";
import User from "./pages/User";
function App() {
@ -13,6 +15,8 @@ function App() {
<Routes>
<Route path="/" element={<Home />} />
<Route path="/u/:username" element={<User />} />
<Route path="/login" element={<Login />} />
<Route path="/login/discord" element={<Discord />} />
</Routes>
</Container>
</>

View File

@ -1,15 +1,30 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { MoonStars, Sun, List } from "react-bootstrap-icons";
import NavItem from "./NavItem";
import Logo from "./logo";
import { useRecoilState } from "recoil";
import { userState } from "./store";
import fetchAPI from "./fetch";
import { MeUser } from "./types";
function Navigation() {
const [user, setUser] = useRecoilState(userState);
useEffect(() => {
if (user) return;
fetchAPI<MeUser>("/users/@me").then(
(res) => setUser(res),
(err) => console.log("fetching /users/@me", err)
);
}, []);
const [darkTheme, setDarkTheme] = useState<boolean>(
localStorage.theme === "dark" ||
(!("theme" in localStorage) &&
window.matchMedia("(prefers-color-scheme: dark)").matches)
(!("theme" in localStorage) &&
window.matchMedia("(prefers-color-scheme: dark)").matches)
);
const [showMenu, setShowMenu] = useState(false);
@ -28,6 +43,18 @@ function Navigation() {
}
};
const nav = user ? (
<>
<NavItem to="/me">@{user.username}</NavItem>
<NavItem to="/settings">Settings</NavItem>
<NavItem to="/logout">Log out</NavItem>
</>
) : (
<>
<NavItem to="/login">Log in</NavItem>
</>
);
return (
<>
<div className="bg-white/75 dark:bg-slate-800/75 w-full backdrop-blur border-slate-200 dark:border-slate-700 border-b">
@ -39,14 +66,7 @@ function Navigation() {
</Link>
<div className="ml-auto flex items-center">
<nav className="hidden lg:flex">
<ul className="flex space-x-4 font-bold">
<NavItem to="/">Home</NavItem>
<NavItem to="/">Link 2</NavItem>
<NavItem to="/">Link 3</NavItem>
<NavItem to="/">Link 4</NavItem>
<NavItem to="/">Link 5</NavItem>
<NavItem to="/login">Log in</NavItem>
</ul>
<ul className="flex space-x-4 font-bold">{nav}</ul>
</nav>
<div className="flex border-l border-slate-200 ml-4 pl-4 lg:ml-6 lg:pl-6 lg:mr-2 dark:border-slate-700 space-x-2 lg:space-x-4">
<div
@ -65,8 +85,15 @@ function Navigation() {
<MoonStars size={24} className="hover:text-sky-500" />
)}
</div>
<div onClick={() => setShowMenu(!showMenu)} title="Show menu" className="cursor-pointer flex lg:hidden">
<List className="dark:hover:text-sky-400 hover:text-sky-500" size={24} />
<div
onClick={() => setShowMenu(!showMenu)}
title="Show menu"
className="cursor-pointer flex lg:hidden"
>
<List
className="dark:hover:text-sky-400 hover:text-sky-500"
size={24}
/>
</div>
</div>
</div>
@ -74,15 +101,12 @@ function Navigation() {
</div>
</div>
</div>
<nav className={`lg:hidden p-4 border-slate-200 dark:border-slate-700 border-b ${showMenu ? "flex" : "hidden"}`}>
<ul className="flex flex-col space-y-4 font-bold">
<NavItem to="/">Home</NavItem>
<NavItem to="/">Link 2</NavItem>
<NavItem to="/">Link 3</NavItem>
<NavItem to="/">Link 4</NavItem>
<NavItem to="/">Link 5</NavItem>
<NavItem to="/login">Log in</NavItem>
</ul>
<nav
className={`lg:hidden p-4 border-slate-200 dark:border-slate-700 border-b ${
showMenu ? "flex" : "hidden"
}`}
>
<ul className="flex flex-col space-y-4 font-bold">{nav}</ul>
</nav>
</>
);

View File

@ -1,14 +1,21 @@
import axios from "axios";
import type { APIError } from "./types";
export default async function fetchAPI<T>(path: string) {
const resp = await axios.get<T | APIError>(`/api/v1${path}`, {
export default async function fetchAPI<T>(
path: string,
method = "GET",
body = null
) {
const resp = await fetch(`/api/v1${path}`, {
method,
headers: {
Authorization: localStorage.getItem("pronouns-token"),
"Content-Type": "application/json",
},
body: body ? JSON.stringify(body) : null,
});
if (resp.status !== 200) throw resp.data as APIError;
return resp.data as T;
const data = await resp.json();
if (resp.status !== 200) throw data as APIError;
return data as T;
}

View File

@ -1,4 +1,4 @@
export interface MeUser {
export interface MeUser extends User {
avatar_source: string | null;
discord: string | null;
discord_username: string | null;

View File

@ -18,11 +18,9 @@ if (import.meta.env.VITE_SENTRY_DSN) {
}
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<RecoilRoot>
<BrowserRouter>
<App />
</BrowserRouter>
</RecoilRoot>
</React.StrictMode>
<RecoilRoot>
<BrowserRouter>
<App />
</BrowserRouter>
</RecoilRoot>
);

View File

@ -0,0 +1,71 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useRecoilState } from "recoil";
import fetchAPI from "../../lib/fetch";
import { userState } from "../../lib/store";
import { MeUser } from "../../lib/types";
interface CallbackResponse {
has_account: boolean;
token?: string;
user?: MeUser;
discord?: string;
ticket?: string;
}
export default function Discord() {
const navigate = useNavigate();
const params = new URLSearchParams(window.location.search);
const [state, setState] = useState({
hasAccount: false,
isLoading: false,
token: null,
user: null,
discord: null,
ticket: null,
error: null,
});
const [user, setUser] = useRecoilState(userState);
useEffect(() => {
if (state.isLoading) return;
setState({ ...state, isLoading: true });
fetchAPI<CallbackResponse>("/auth/discord/callback", "POST", {
callback_domain: window.location.origin,
code: params.get("code"),
state: params.get("state"),
}).then(
(resp) => {
setState({
hasAccount: resp.has_account,
isLoading: false,
token: resp.token,
user: resp.user,
discord: resp.discord,
ticket: resp.ticket,
error: null,
});
console.log("token:", resp.token);
localStorage.setItem("pronouns-token", resp.token);
if (resp.user) setUser(resp.user as MeUser);
},
(err) => {
console.log(err);
setState({ ...state, error: err, isLoading: false });
}
);
}, []);
if (user) {
// we got a token + user, save it and return to the home page
navigate("/");
}
return <>wow such login</>;
}

View File

@ -0,0 +1,51 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useRecoilValue } from "recoil";
import fetchAPI from "../../lib/fetch";
import { userState } from "../../lib/store";
interface URLsResponse {
discord: string;
}
export default function Login() {
const [state, setState] = useState({
loading: false,
error: null,
discord: "",
});
if (useRecoilValue(userState) !== null) {
const nav = useNavigate();
nav("/");
}
useEffect(() => {
if (state.loading) return;
setState({ ...state, loading: true });
fetchAPI<URLsResponse>("/auth/urls", "POST", {
callback_domain: window.location.origin,
}).then(
(resp) => {
setState({ loading: false, error: null, discord: resp.discord });
},
(err) => {
console.log(err);
setState({ ...state, loading: false, error: err });
}
);
}, []);
if (state.loading) {
return <>Loading...</>;
} else if (state.error) {
return <>Error: {`${state.error}`}</>;
}
return (
<>
<a href={state.discord}>Login with Discord</a>
</>
);
}