forked from mirrors/pronouns.cc
feat: discord login works!
This commit is contained in:
parent
206feb21b8
commit
d2f4e09a01
11 changed files with 208 additions and 48 deletions
3
Makefile
3
Makefile
|
@ -1,5 +1,6 @@
|
||||||
migrate:
|
migrate:
|
||||||
go build -v ./scripts/migrate
|
go run -v ./scripts/migrate
|
||||||
|
|
||||||
|
.PHONY: api
|
||||||
api:
|
api:
|
||||||
go build -v -o api -ldflags="-buildid= -X gitlab.com/1f320/pronouns/backend/server.Revision=`git rev-parse --short HEAD`" ./backend
|
go build -v -o api -ldflags="-buildid= -X gitlab.com/1f320/pronouns/backend/server.Revision=`git rev-parse --short HEAD`" ./backend
|
||||||
|
|
|
@ -9,15 +9,10 @@ import (
|
||||||
"gitlab.com/1f320/pronouns/backend/log"
|
"gitlab.com/1f320/pronouns/backend/log"
|
||||||
"gitlab.com/1f320/pronouns/backend/server"
|
"gitlab.com/1f320/pronouns/backend/server"
|
||||||
|
|
||||||
"github.com/joho/godotenv"
|
_ "github.com/joho/godotenv/autoload"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
err := godotenv.Load()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Error loading .env file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
port := ":" + os.Getenv("PORT")
|
port := ":" + os.Getenv("PORT")
|
||||||
|
|
||||||
s, err := server.New()
|
s, err := server.New()
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
@ -24,8 +25,9 @@ var discordOAuthConfig = oauth2.Config{
|
||||||
}
|
}
|
||||||
|
|
||||||
type oauthCallbackRequest struct {
|
type oauthCallbackRequest struct {
|
||||||
Code string `json:"code"`
|
CallbackDomain string `json:"callback_domain"`
|
||||||
State string `json:"state"`
|
Code string `json:"code"`
|
||||||
|
State string `json:"state"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type discordCallbackResponse struct {
|
type discordCallbackResponse struct {
|
||||||
|
@ -55,7 +57,9 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
|
||||||
return server.APIError{Code: server.ErrInvalidState}
|
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 {
|
if err != nil {
|
||||||
log.Errorf("exchanging oauth code: %v", err)
|
log.Errorf("exchanging oauth code: %v", err)
|
||||||
|
|
||||||
|
@ -80,6 +84,8 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fmt.Println(token)
|
||||||
|
|
||||||
render.JSON(w, r, discordCallbackResponse{
|
render.JSON(w, r, discordCallbackResponse{
|
||||||
HasAccount: true,
|
HasAccount: true,
|
||||||
Token: token,
|
Token: token,
|
||||||
|
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"emperror.dev/errors"
|
"emperror.dev/errors"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/render"
|
"github.com/go-chi/render"
|
||||||
|
"gitlab.com/1f320/pronouns/backend/log"
|
||||||
"gitlab.com/1f320/pronouns/backend/server"
|
"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) {
|
r.Route("/auth", func(r chi.Router) {
|
||||||
// generate csrf token, returns all supported OAuth provider URLs
|
// 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) {
|
r.Route("/discord", func(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", nil)
|
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", nil)
|
||||||
})
|
})
|
||||||
|
@ -30,7 +31,7 @@ func Mount(srv *server.Server, r chi.Router) {
|
||||||
}
|
}
|
||||||
|
|
||||||
type oauthURLsRequest struct {
|
type oauthURLsRequest struct {
|
||||||
CallbackURL string `json:"callback_url"`
|
CallbackDomain string `json:"callback_domain"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type oauthURLsResponse struct {
|
type oauthURLsResponse struct {
|
||||||
|
@ -40,6 +41,8 @@ type oauthURLsResponse struct {
|
||||||
func (s *Server) oauthURLs(w http.ResponseWriter, r *http.Request) error {
|
func (s *Server) oauthURLs(w http.ResponseWriter, r *http.Request) error {
|
||||||
req, err := Decode[oauthURLsRequest](r)
|
req, err := Decode[oauthURLsRequest](r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Error(err)
|
||||||
|
|
||||||
return server.APIError{Code: server.ErrBadRequest}
|
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
|
// copy Discord config and set redirect url
|
||||||
discordCfg := discordOAuthConfig
|
discordCfg := discordOAuthConfig
|
||||||
discordCfg.RedirectURL = req.CallbackURL
|
discordCfg.RedirectURL = req.CallbackDomain + "/login/discord"
|
||||||
|
|
||||||
render.JSON(w, r, oauthURLsResponse{
|
render.JSON(w, r, oauthURLsResponse{
|
||||||
Discord: discordCfg.AuthCodeURL(state),
|
Discord: discordCfg.AuthCodeURL(state),
|
||||||
|
|
|
@ -3,6 +3,8 @@ import "./App.css";
|
||||||
import Container from "./lib/Container";
|
import Container from "./lib/Container";
|
||||||
import Navigation from "./lib/Navigation";
|
import Navigation from "./lib/Navigation";
|
||||||
import Home from "./pages/Home";
|
import Home from "./pages/Home";
|
||||||
|
import Discord from "./pages/login/Discord";
|
||||||
|
import Login from "./pages/login/Login";
|
||||||
import User from "./pages/User";
|
import User from "./pages/User";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
@ -13,6 +15,8 @@ function App() {
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Home />} />
|
<Route path="/" element={<Home />} />
|
||||||
<Route path="/u/:username" element={<User />} />
|
<Route path="/u/:username" element={<User />} />
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/login/discord" element={<Discord />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Container>
|
</Container>
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -1,15 +1,30 @@
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { MoonStars, Sun, List } from "react-bootstrap-icons";
|
import { MoonStars, Sun, List } from "react-bootstrap-icons";
|
||||||
|
|
||||||
import NavItem from "./NavItem";
|
import NavItem from "./NavItem";
|
||||||
import Logo from "./logo";
|
import Logo from "./logo";
|
||||||
|
import { useRecoilState } from "recoil";
|
||||||
|
import { userState } from "./store";
|
||||||
|
import fetchAPI from "./fetch";
|
||||||
|
import { MeUser } from "./types";
|
||||||
|
|
||||||
function Navigation() {
|
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>(
|
const [darkTheme, setDarkTheme] = useState<boolean>(
|
||||||
localStorage.theme === "dark" ||
|
localStorage.theme === "dark" ||
|
||||||
(!("theme" in localStorage) &&
|
(!("theme" in localStorage) &&
|
||||||
window.matchMedia("(prefers-color-scheme: dark)").matches)
|
window.matchMedia("(prefers-color-scheme: dark)").matches)
|
||||||
);
|
);
|
||||||
|
|
||||||
const [showMenu, setShowMenu] = useState(false);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="bg-white/75 dark:bg-slate-800/75 w-full backdrop-blur border-slate-200 dark:border-slate-700 border-b">
|
<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>
|
</Link>
|
||||||
<div className="ml-auto flex items-center">
|
<div className="ml-auto flex items-center">
|
||||||
<nav className="hidden lg:flex">
|
<nav className="hidden lg:flex">
|
||||||
<ul className="flex space-x-4 font-bold">
|
<ul className="flex space-x-4 font-bold">{nav}</ul>
|
||||||
<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>
|
</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 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
|
<div
|
||||||
|
@ -65,8 +85,15 @@ function Navigation() {
|
||||||
<MoonStars size={24} className="hover:text-sky-500" />
|
<MoonStars size={24} className="hover:text-sky-500" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div onClick={() => setShowMenu(!showMenu)} title="Show menu" className="cursor-pointer flex lg:hidden">
|
<div
|
||||||
<List className="dark:hover:text-sky-400 hover:text-sky-500" size={24} />
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -74,15 +101,12 @@ function Navigation() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<nav className={`lg:hidden p-4 border-slate-200 dark:border-slate-700 border-b ${showMenu ? "flex" : "hidden"}`}>
|
<nav
|
||||||
<ul className="flex flex-col space-y-4 font-bold">
|
className={`lg:hidden p-4 border-slate-200 dark:border-slate-700 border-b ${
|
||||||
<NavItem to="/">Home</NavItem>
|
showMenu ? "flex" : "hidden"
|
||||||
<NavItem to="/">Link 2</NavItem>
|
}`}
|
||||||
<NavItem to="/">Link 3</NavItem>
|
>
|
||||||
<NavItem to="/">Link 4</NavItem>
|
<ul className="flex flex-col space-y-4 font-bold">{nav}</ul>
|
||||||
<NavItem to="/">Link 5</NavItem>
|
|
||||||
<NavItem to="/login">Log in</NavItem>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
</nav>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,14 +1,21 @@
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import type { APIError } from "./types";
|
import type { APIError } from "./types";
|
||||||
|
|
||||||
export default async function fetchAPI<T>(path: string) {
|
export default async function fetchAPI<T>(
|
||||||
const resp = await axios.get<T | APIError>(`/api/v1${path}`, {
|
path: string,
|
||||||
|
method = "GET",
|
||||||
|
body = null
|
||||||
|
) {
|
||||||
|
const resp = await fetch(`/api/v1${path}`, {
|
||||||
|
method,
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: localStorage.getItem("pronouns-token"),
|
Authorization: localStorage.getItem("pronouns-token"),
|
||||||
"Content-Type": "application/json",
|
"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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
export interface MeUser {
|
export interface MeUser extends User {
|
||||||
avatar_source: string | null;
|
avatar_source: string | null;
|
||||||
discord: string | null;
|
discord: string | null;
|
||||||
discord_username: string | null;
|
discord_username: string | null;
|
||||||
|
|
|
@ -18,11 +18,9 @@ if (import.meta.env.VITE_SENTRY_DSN) {
|
||||||
}
|
}
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<RecoilRoot>
|
||||||
<RecoilRoot>
|
<BrowserRouter>
|
||||||
<BrowserRouter>
|
<App />
|
||||||
<App />
|
</BrowserRouter>
|
||||||
</BrowserRouter>
|
</RecoilRoot>
|
||||||
</RecoilRoot>
|
|
||||||
</React.StrictMode>
|
|
||||||
);
|
);
|
||||||
|
|
71
frontend/src/pages/login/Discord.tsx
Normal file
71
frontend/src/pages/login/Discord.tsx
Normal 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</>;
|
||||||
|
}
|
51
frontend/src/pages/login/Login.tsx
Normal file
51
frontend/src/pages/login/Login.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
Loading…
Reference in a new issue