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:
|
||||
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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
export interface MeUser {
|
||||
export interface MeUser extends User {
|
||||
avatar_source: string | null;
|
||||
discord: string | null;
|
||||
discord_username: string | null;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
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