basic login / logout lichess user

This commit is contained in:
2026-06-08 12:23:20 +02:00
parent ba05fbedd3
commit d1cdc7764b
7 changed files with 186 additions and 64 deletions
+12 -4
View File
@@ -1,12 +1,18 @@
import { useLichess } from "#/hooks/useLichess.ts"; import { useLichessUser } from "#/context/LichessUserContext.tsx";
import { useLichessOAuth } from "#/hooks/useLichessOAuth.ts";
const LichessButton = () => { const LichessButton = () => {
const { lichessLogin, lichessLogout } = useLichess(); const { lichessLogin, lichessLogout } = useLichessOAuth();
const { user, setUser, logout } = useLichessUser();
const onClickHandler = () => {
return user?.isLoggedIn ? lichessLogout() : lichessLogin();
};
return ( return (
<button <button
className="text-(--accent) border rounded-xl px-6 py-3 cursor-pointer flex items-center gap-2 p-2 hover:bg-(--accent) hover:text-(--header-bg) transition-colors duration-300 ease-in-out" className="text-(--accent) border rounded-xl px-6 py-3 cursor-pointer flex items-center gap-2 p-2 hover:bg-(--accent) hover:text-(--header-bg) transition-colors duration-300 ease-in-out"
onClick={lichessLogin} onClick={onClickHandler}
type="button" type="button"
> >
<div className="w-6 h-6"> <div className="w-6 h-6">
@@ -20,7 +26,9 @@ const LichessButton = () => {
</svg> </svg>
</div> </div>
<p>Log into Lichess.org</p> <p>
{user?.isLoggedIn ? `Logout ${user.username}` : "Log into Lichess.org"}
</p>
</button> </button>
); );
}; };
+3 -7
View File
@@ -9,13 +9,13 @@ import {
interface ILichessUser { interface ILichessUser {
username: string; username: string;
id: string;
isLoggedIn: boolean; isLoggedIn: boolean;
} }
interface ILichessUserContextType { interface ILichessUserContextType {
user: ILichessUser | null; user: ILichessUser | null;
setUser: Dispatch<SetStateAction<ILichessUser | null>>; setUser: Dispatch<SetStateAction<ILichessUser | null>>
logout: () => void;
} }
const LichessUserContext = createContext<ILichessUserContextType | undefined>( const LichessUserContext = createContext<ILichessUserContextType | undefined>(
@@ -25,14 +25,10 @@ const LichessUserContext = createContext<ILichessUserContextType | undefined>(
export const LichessUserProvider = ({ children }: { children: ReactNode }) => { export const LichessUserProvider = ({ children }: { children: ReactNode }) => {
const [user, setUser] = useState<ILichessUser | null>(null); const [user, setUser] = useState<ILichessUser | null>(null);
const logout = () => {
setUser(null);
};
// TODO useEffect to check and login user on mount // TODO useEffect to check and login user on mount
return ( return (
<LichessUserContext.Provider value={{ user, setUser, logout }}> <LichessUserContext.Provider value={{ user, setUser }}>
{children} {children}
</LichessUserContext.Provider> </LichessUserContext.Provider>
); );
-33
View File
@@ -1,33 +0,0 @@
import { useServerFn } from "@tanstack/react-start";
import { useLichessUser } from "#/context/LichessUserContext.tsx";
import { getToken, login } from "#/server/lichess.ts";
export const useLichess = () => {
const { user, setUser, logout } = useLichessUser();
const triggerLogin = useServerFn(login);
const trigggerGetToken = useServerFn(getToken);
console.log("Lichess User:", user);
const lichessTokenVerification = async ({ code }) => {
await trigggerGetToken();
};
const lichessLogin = async () => {
// TODO add try/catch with toast
await triggerLogin();
};
const lichessLogout = () => {
console.log("Lichess Logout");
// TODO remove cookies
logout();
};
return {
lichessLogin,
lichessLogout,
lichessTokenVerification,
};
};
+14 -5
View File
@@ -1,17 +1,26 @@
import { useNavigate } from "@tanstack/react-router";
import { useEffect } from "react"; import { useEffect } from "react";
import { useLichess } from "#/hooks/useLichess.ts"; import { useLichessOAuth } from "#/hooks/useLichessOAuth.ts";
interface IProps { interface IProps {
code: string; code: string;
} }
export const useLichessCallback = ({ code }: IProps) => { export const useLichessCallback = ({ code }: IProps) => {
const { lichessTokenVerification } = useLichess(); const navigate = useNavigate();
const { lichessAccessToken, getLichessUser, setLichessUser } =
useLichessOAuth();
useEffect(() => { useEffect(() => {
if (!code) return; if (!code) return;
const response = lichessTokenVerification({ code }); (async () => {
console.log("Response:", response); const tokenData = await lichessAccessToken({ code });
}, [code, lichessTokenVerification]); const userData = await getLichessUser({ tokenData });
await setLichessUser({ username: userData.username, id: userData.id });
await navigate({ to: "/chessboard" });
// TODO set logging in state then redirect
})();
}, [code, lichessAccessToken, getLichessUser, setLichessUser, navigate]);
}; };
+60
View File
@@ -0,0 +1,60 @@
import { useServerFn } from "@tanstack/react-start";
import { useLichessUser } from "#/context/LichessUserContext.tsx";
import { getToken, getUser, login, setSession, logout } from "#/server/lichess.ts";
interface ITokenData {
access_token: string;
expires_in: number;
token_type: string;
}
export const useLichessOAuth = () => {
const { setUser } = useLichessUser();
const loginFn = useServerFn(login);
const logoutFn = useServerFn(logout)
const accessTokenFn = useServerFn(getToken);
const getUserFn = useServerFn(getUser);
const setSessionFn = useServerFn(setSession);
const lichessAccessToken = async ({ code }: { code: string }) => {
return accessTokenFn({ data: { code } });
};
const setLichessUser = async ({
username,
id,
}: {
username: string;
id: string;
}) => {
await setSessionFn({ data: { username, id } });
setUser({ username, id, isLoggedIn: true });
};
const getLichessUser = async ({ tokenData }: { tokenData: ITokenData }) => {
return getUserFn({
data: { access_token: tokenData.access_token },
});
};
const lichessLogin = async () => {
// TODO add try/catch with toast
await loginFn();
};
const lichessLogout = async () => {
console.log("Lichess Logout");
// TODO remove cookies
await logoutFn();
setUser(null);
};
return {
lichessLogin,
lichessLogout,
lichessAccessToken,
getLichessUser,
setLichessUser,
};
};
-1
View File
@@ -4,7 +4,6 @@ import Callback from "#/pages/Callback.tsx";
const callbackSchema = z.object({ const callbackSchema = z.object({
code: z.string(), code: z.string(),
state: z.string(),
}); });
// type CallbackProps = z.infer<typeof callbackSchema>; // type CallbackProps = z.infer<typeof callbackSchema>;
+97 -14
View File
@@ -1,31 +1,118 @@
import { randomBytes } from "node:crypto"; import { randomBytes } from "node:crypto";
import { redirect } from "@tanstack/react-router"; import { redirect } from "@tanstack/react-router";
import { createServerFn } from "@tanstack/react-start"; import { createServerFn } from "@tanstack/react-start";
import { getCookie, setCookie, deleteCookie } from "@tanstack/react-start/server";
import { env } from "#/env.ts"; import { env } from "#/env.ts";
import { base64UrlEncode, sha256 } from "#/lib/encodeDecode.ts"; import { base64UrlEncode, sha256 } from "#/lib/encodeDecode.ts";
export const getToken = createServerFn({ method: "POST" }).handler(async () => { const createVerifier = () => base64UrlEncode(randomBytes(32));
console.log("getTokens"); const createChallenge = (verifier: string) => base64UrlEncode(sha256(verifier));
});
export const verifyToken = createServerFn({ method: "POST" }).handler( const getRedirectUri = () =>
process.env.NODE_ENV === "production"
? `https://${env.LICHESS_CLIENT_ID}/callback`
: "http://localhost:3000/callback";
// TODO fix and check
export const getSession = createServerFn({ method: "GET" }).handler(
async () => { async () => {
console.log("verifyTokens"); const session = getCookie("lichess-session");
if (!session) return null;
try {
return JSON.parse(session) as { username: string };
} catch {
return null;
}
}, },
); );
export const login = createServerFn({ method: "POST" }).handler(async () => { export const setSession = createServerFn({ method: "POST" })
const createVerifier = () => base64UrlEncode(randomBytes(32)); .inputValidator((data: { username: string; id: string }) => data)
const createChallenge = (verifier: string) => .handler(async ({ data }) => {
base64UrlEncode(sha256(verifier)); setCookie(
"lichess-session",
JSON.stringify({ username: data.username, id: data.id }),
{
path: "/",
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 7,
},
);
setCookie("lichess-oauth-verifier", "", {
path: "/",
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 0,
});
return { username: data.username, id: data.id };
});
export const getToken = createServerFn({ method: "POST" })
.inputValidator((data: { code: string }) => data)
.handler(async ({ data }) => {
const verifier = getCookie("lichess-oauth-verifier");
if (!verifier)
throw new Error("OAuth session expired. Please login again.");
const response = await fetch("https://lichess.org/api/token", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
grant_type: "authorization_code",
client_id: env.LICHESS_CLIENT_ID || "",
code: data.code,
redirect_uri: getRedirectUri(),
code_verifier: verifier,
}),
});
if (!response.ok)
throw new Error(`Token exchange failed: ${await response.text()}`);
return response.json();
});
export const getUser = createServerFn({ method: "GET" })
.inputValidator((data: { access_token: string }) => data)
.handler(async ({ data }) => {
const response = await fetch("https://lichess.org/api/account", {
headers: {
Authorization: `Bearer ${data.access_token}`,
},
});
return response.json();
});
export const logout = createServerFn().handler(async () => {
deleteCookie("lichess-session", { path: "/" });
deleteCookie("lichess-oauth-verifier", { path: "/" });
});
export const login = createServerFn({ method: "GET" }).handler(async () => {
const verifier = createVerifier(); const verifier = createVerifier();
const challenge = createChallenge(verifier); const challenge = createChallenge(verifier);
setCookie("lichess-oauth-verifier", verifier, {
path: "/",
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 10,
});
const params = new URLSearchParams({ const params = new URLSearchParams({
response_type: "code", response_type: "code",
client_id: env.LICHESS_CLIENT_ID || "", client_id: env.LICHESS_CLIENT_ID || "",
redirect_uri: `${process.env.NODE_ENV === "production" ? `https://${env.LICHESS_CLIENT_ID}/callback` : "http://localhost:3000/callback"}`, redirect_uri: getRedirectUri(),
scope: "study:read", scope: "study:read",
code_challenge_method: "S256", code_challenge_method: "S256",
code_challenge: challenge, code_challenge: challenge,
@@ -33,9 +120,5 @@ export const login = createServerFn({ method: "POST" }).handler(async () => {
throw redirect({ throw redirect({
href: `https://lichess.org/oauth?${params.toString()}`, href: `https://lichess.org/oauth?${params.toString()}`,
statusCode: 302,
headers: {
"Set-Cookie": `lichess_oauth_verifier=${verifier}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=600`, // Expires in 10 mins
},
}); });
}); });