From 57400810d0d49ae9e6be261f71c88b9a286ffe3a Mon Sep 17 00:00:00 2001 From: Hebi Li Date: Fri, 28 Apr 2023 11:00:00 -0700 Subject: [PATCH 1/2] add anonymous user --- api/src/resolver_user.ts | 43 +++++++++++++++++++++- api/src/typedefs.ts | 2 + ui/package.json | 1 + ui/src/lib/auth.tsx | 79 ++++++++++++++++++++++++++++++++++++++-- ui/src/pages/login.tsx | 45 ++++++++++++++--------- ui/src/pages/repos.tsx | 46 ++++++++++++++++++++--- ui/src/pages/signup.tsx | 6 +++ ui/yarn.lock | 5 +++ 8 files changed, 199 insertions(+), 28 deletions(-) diff --git a/api/src/resolver_user.ts b/api/src/resolver_user.ts index 4d5e0902..e5ca555e 100644 --- a/api/src/resolver_user.ts +++ b/api/src/resolver_user.ts @@ -38,7 +38,46 @@ async function signup(_, { email, password, firstname, lastname }) { }); return { token: jwt.sign({ id: user.id }, process.env.JWT_SECRET as string, { - expiresIn: "7d", + expiresIn: "30d", + }), + }; +} + +/** + * Create a guest user and return a token. The guest user doesn't have a password or email. + */ +async function signupGuest(_, {}) { + const id = await nanoid(); + const user = await prisma.user.create({ + data: { + id: "guest_" + id, + email: "guest_" + id + "@example.com", + firstname: "Guest", + lastname: "Guest", + }, + }); + return { + // CAUTION the front-end should save the user ID so that we can login again after expiration. + token: jwt.sign({ id: user.id }, process.env.JWT_SECRET as string, { + expiresIn: "30d", + }), + }; +} + +/** + * Login a user with a guest ID and no password. + */ +async function loginGuest(_, {id}) { + const user = await prisma.user.findFirst({ + where: { + id,} + }); + if (!user) throw Error(`User does not exist`); + return { + id: user.id, + email: user.email, + token: jwt.sign({ id: user.id }, process.env.JWT_SECRET as string, { + expiresIn: "30d", }), }; } @@ -139,5 +178,7 @@ export default { loginWithGoogle, signup, updateUser, + signupGuest, + loginGuest, }, }; diff --git a/api/src/typedefs.ts b/api/src/typedefs.ts index 8c06305d..0d9a3085 100644 --- a/api/src/typedefs.ts +++ b/api/src/typedefs.ts @@ -113,6 +113,8 @@ export const typeDefs = gql` type Mutation { login(email: String!, password: String!): AuthData + loginGuest(id: String!): AuthData + signupGuest: AuthData signup( email: String! password: String! diff --git a/ui/package.json b/ui/package.json index 5f1ddad8..f8de6c31 100644 --- a/ui/package.json +++ b/ui/package.json @@ -28,6 +28,7 @@ "crypto-js": "^4.1.1", "formik": "^2.2.9", "graphql": "^16.6.0", + "jwt-decode": "^3.1.2", "kbar": "^0.1.0-beta.40", "monaco-editor": "^0.34.1", "monaco-editor-webpack-plugin": "^7.0.1", diff --git a/ui/src/lib/auth.tsx b/ui/src/lib/auth.tsx index 7c1c5a17..998611b3 100644 --- a/ui/src/lib/auth.tsx +++ b/ui/src/lib/auth.tsx @@ -6,6 +6,7 @@ import { HttpLink, gql, } from "@apollo/client"; +import jwt_decode from "jwt-decode"; type AuthContextType = ReturnType; @@ -32,7 +33,11 @@ function useProvideAuth() { useEffect(() => { // load initial state from local storage - setAuthToken(localStorage.getItem("token") || null); + setAuthToken( + localStorage.getItem("token") || + localStorage.getItem("guestToken") || + null + ); }, []); const getAuthHeaders = () => { @@ -57,10 +62,10 @@ function useProvideAuth() { const signOut = () => { console.log("sign out"); - setAuthToken(null); // HEBI CAUTION this must be removed. Otherwise, when getItem back, it is not null, but "null" // localStorage.setItem("token", null); localStorage.removeItem("token"); + setAuthToken(localStorage.getItem("guestToken") || null); }; const handleGoogle = async (response) => { @@ -86,6 +91,68 @@ function useProvideAuth() { } }; + let guestSigningUp = false; + + const loginGuest = async () => { + console.log("Loginning as guest."); + // If there is a guest token, decode the guest ID from it, and login with the guest ID + let token = localStorage.getItem("guestToken"); + if (token) { + console.log("Guest token found, logining in .."); + const { id } = jwt_decode(token) as { id: string }; + // login a guest user with the guest ID + const client = createApolloClient(); + const LoginGuestMutation = gql` + mutation LoginGuestMutation($id: String!) { + loginGuest(id: $id) { + token + } + } + `; + const result = await client.mutate({ + mutation: LoginGuestMutation, + variables: { id }, + }); + if (result?.data?.loginGuest?.token) { + const token = result.data.loginGuest.token; + setAuthToken(token); + localStorage.setItem("guestToken", token); + } + } else { + // Signup a guest user + console.log("Guest token not found, signing up .."); + // set a 5 seconds timeout so that no duplicate guest users are created + if (guestSigningUp) { + console.log("Guest signing up, waiting .."); + return; + } + guestSigningUp = true; + setTimeout(() => { + guestSigningUp = false; + }, 5000); + + // actually signup the user + const client = createApolloClient(); + const SignupGuestMutation = gql` + mutation SignupGuestMutation { + signupGuest { + token + } + } + `; + + const result = await client.mutate({ + mutation: SignupGuestMutation, + }); + + if (result?.data?.signupGuest?.token) { + const token = result.data.signupGuest.token; + setAuthToken(token); + localStorage.setItem("guestToken", token); + } + } + }; + const signIn = async ({ email, password }) => { const client = createApolloClient(); const LoginMutation = gql` @@ -153,7 +220,7 @@ function useProvideAuth() { * This is not immediately set onrefresh. */ const isSignedIn = () => { - if (authToken) { + if (authToken && localStorage.getItem("token") !== null) { return true; } else { return false; @@ -164,13 +231,17 @@ function useProvideAuth() { * This is set immediately on refresh. */ function hasToken() { - return localStorage.getItem("token") !== null; + return ( + localStorage.getItem("token") !== null || + localStorage.getItem("guestToken") !== null + ); } return { createApolloClient, signIn, signOut, + loginGuest, handleGoogle, signUp, isSignedIn, diff --git a/ui/src/pages/login.tsx b/ui/src/pages/login.tsx index b27db739..f6303ac8 100644 --- a/ui/src/pages/login.tsx +++ b/ui/src/pages/login.tsx @@ -20,6 +20,7 @@ import { useFormik } from "formik"; import { Link as ReactLink, useNavigate } from "react-router-dom"; import { useAuth } from "../lib/auth"; +import Divider from "@mui/material/Divider"; function Copyright(props: any) { return ( @@ -43,6 +44,28 @@ const theme = createTheme(); declare var google: any; +export function GoogleSignin() { + const { handleGoogle } = useAuth(); + + useEffect(() => { + console.log("nodeenv", process.env.NODE_ENV); + let client_id = + process.env.NODE_ENV === "development" + ? process.env.REACT_APP_GOOGLE_CLIENT_ID + : window.GOOGLE_CLIENT_ID || null; + console.log("google client_id", client_id); + google.accounts.id.initialize({ + client_id, + callback: handleGoogle, + }); + google.accounts.id.renderButton( + document.getElementById("googleLoginDiv"), + { theme: "outline", size: "large" } // customization attributes + ); + }, [handleGoogle]); + return ; +} + export default function SignIn() { /* eslint-disable no-unused-vars */ const { signIn, isSignedIn, handleGoogle } = useAuth(); @@ -73,23 +96,6 @@ export default function SignIn() { }, }); - useEffect(() => { - console.log("nodeenv", process.env.NODE_ENV); - let client_id = - process.env.NODE_ENV === "development" - ? process.env.REACT_APP_GOOGLE_CLIENT_ID - : window.GOOGLE_CLIENT_ID || null; - console.log("google client_id", client_id); - google.accounts.id.initialize({ - client_id, - callback: handleGoogle, - }); - google.accounts.id.renderButton( - document.getElementById("googleLoginDiv"), - { theme: "outline", size: "large" } // customization attributes - ); - }, [handleGoogle]); - return ( @@ -102,13 +108,16 @@ export default function SignIn() { alignItems: "center", }} > - Sign in + + + Or login with email + ); } -export default function Page() { - const { me } = useMe(); + +function RepoLists() { // peiredically re-render so that the "last active time" is updated const [counter, setCounter] = useState(0); useEffect(() => { @@ -416,8 +418,27 @@ export default function Page() { }, 1000); return () => clearInterval(interval); }, [counter]); + return ( + <> + + + + ); +} + +export default function Page() { + const { me } = useMe(); + const { hasToken, loginGuest, isSignedIn } = useAuth(); + + useEffect(() => { + if (!hasToken()) { + loginGuest(); + } + }, [hasToken]); + if (!me) { - return ; + // return ; + return Loading user ..; } return ( @@ -435,8 +456,23 @@ export default function Page() { 👋 Welcome, {me?.firstname}! Please open or create a repository to get started. - - + {!isSignedIn() && ( + + + Please note that you are an anonymous Guest user. Please{" "} + + Login + {" "} + or + + Signup + {" "} + to save your work. + + + + )} + ); } diff --git a/ui/src/pages/signup.tsx b/ui/src/pages/signup.tsx index fa163f08..5c15d072 100644 --- a/ui/src/pages/signup.tsx +++ b/ui/src/pages/signup.tsx @@ -20,6 +20,8 @@ import Alert from "@mui/material/Alert"; import { useFormik } from "formik"; import { useAuth } from "../lib/auth"; +import { GoogleSignin } from "./login"; +import Divider from "@mui/material/Divider"; function Copyright(props) { return ( @@ -90,6 +92,10 @@ export default function SignUp() { Sign up + + + Or sign up with email + Date: Fri, 28 Apr 2023 11:16:32 -0700 Subject: [PATCH 2/2] chore: adjust wording --- ui/src/pages/repos.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/ui/src/pages/repos.tsx b/ui/src/pages/repos.tsx index 32a93aba..80d17ae8 100644 --- a/ui/src/pages/repos.tsx +++ b/ui/src/pages/repos.tsx @@ -360,9 +360,8 @@ function SharedWithMe() { fontSize: "25px", }} > - My projects ({repos.length}) + Projects shared with me ({repos.length}) - {repos.length > 0 ? ( @@ -453,13 +452,17 @@ export default function Page() { position: "relative", }} > - 👋 Welcome, {me?.firstname}! Please open or create a repository to get + Welcome, {me?.firstname}! Please open or create a repository to get started. {!isSignedIn() && ( - Please note that you are an anonymous Guest user. Please{" "} + Please note that you are a{" "} + + Guest + {" "} + user. Please{" "} Login {" "}