✨ Login
This commit is contained in:
		| @@ -19,13 +19,8 @@ render(() => ( | ||||
|     <UserinfoProvider> | ||||
|       <Router root={RootLayout}> | ||||
|         <Route path="/" component={lazy(() => import("./pages/dashboard.tsx"))} /> | ||||
|         <Route path="/security" component={lazy(() => import("./pages/security.tsx"))} /> | ||||
|         <Route path="/personalise" component={lazy(() => import("./pages/personalise.tsx"))} /> | ||||
|         <Route path="/auth/login" component={lazy(() => import("./pages/auth/login.tsx"))} /> | ||||
|         <Route path="/auth/register" component={lazy(() => import("./pages/auth/register.tsx"))} /> | ||||
|         <Route path="/auth/oauth/connect" component={lazy(() => import("./pages/auth/connect.tsx"))} /> | ||||
|         <Route path="/auth/oauth/callback" component={lazy(() => import("./pages/auth/callback.tsx"))} /> | ||||
|         <Route path="/users/me/confirm" component={lazy(() => import("./pages/users/confirm.tsx"))} /> | ||||
|         <Route path="/auth" component={lazy(() => import("./pages/auth/callout.tsx"))} /> | ||||
|         <Route path="/auth/callback" component={lazy(() => import("./pages/auth/callback.tsx"))} /> | ||||
|       </Router> | ||||
|     </UserinfoProvider> | ||||
|   </WellKnownProvider> | ||||
|   | ||||
| @@ -21,7 +21,7 @@ export default function RootLayout(props: any) { | ||||
|   }, [ready, userinfo]); | ||||
|  | ||||
|   function keepGate(path: string, e?: BeforeLeaveEventArgs) { | ||||
|     const whitelist = ["/auth/login", "/auth/register", "/users/me/confirm"]; | ||||
|     const whitelist = ["/auth", "/auth/callback"]; | ||||
|  | ||||
|     if (!userinfo?.isLoggedIn && !whitelist.includes(path)) { | ||||
|       if (!e?.defaultPrevented) e?.preventDefault(); | ||||
|   | ||||
| @@ -74,7 +74,7 @@ export default function Navbar() { | ||||
|           </ul> | ||||
|         </div> | ||||
|         <a href="/" class="btn btn-ghost text-xl"> | ||||
|           {wellKnown?.name ?? "Goatpass"} | ||||
|           {wellKnown?.name ?? "Interactive"} | ||||
|         </a> | ||||
|       </div> | ||||
|       <div class="navbar-center hidden lg:flex"> | ||||
| @@ -109,7 +109,7 @@ export default function Navbar() { | ||||
|             <button type="button" class="btn btn-sm btn-ghost" onClick={() => logout()}>Logout</button> | ||||
|           </Match> | ||||
|           <Match when={!userinfo?.isLoggedIn}> | ||||
|             <a href="/auth/login" class="btn btn-sm btn-primary">Login</a> | ||||
|             <a href="/auth" class="btn btn-sm btn-primary">Login</a> | ||||
|           </Match> | ||||
|         </Switch> | ||||
|       </div> | ||||
|   | ||||
							
								
								
									
										64
									
								
								pkg/view/src/pages/auth/callback.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								pkg/view/src/pages/auth/callback.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,64 @@ | ||||
| import { createSignal, Show } from "solid-js"; | ||||
| import { readProfiles } from "../../stores/userinfo.tsx"; | ||||
| import { useNavigate } from "@solidjs/router"; | ||||
| import Cookie from "universal-cookie"; | ||||
|  | ||||
| export default function AuthCallback() { | ||||
|   const [error, setError] = createSignal<string | null>(null); | ||||
|   const [status, setStatus] = createSignal("Communicating with Goatpass..."); | ||||
|  | ||||
|   const navigate = useNavigate(); | ||||
|  | ||||
|   async function callback() { | ||||
|     const res = await fetch(`/api/auth/callback${location.search}`); | ||||
|     if (res.status !== 200) { | ||||
|       setError(await res.text()); | ||||
|     } else { | ||||
|       const data = await res.json(); | ||||
|       new Cookie().set("access_token", data["access_token"], { path: "/", maxAge: undefined }); | ||||
|       new Cookie().set("refresh_token", data["refresh_token"], { path: "/", maxAge: undefined }); | ||||
|       setStatus("Pulling your personal data..."); | ||||
|       await readProfiles(); | ||||
|       setStatus("Redirecting...") | ||||
|       setTimeout(() => navigate("/"), 1850) | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   callback(); | ||||
|  | ||||
|   return ( | ||||
|     <div class="w-full h-full flex justify-center items-center"> | ||||
|       <div class="card w-[480px] max-w-screen shadow-xl"> | ||||
|         <div class="card-body"> | ||||
|           <div id="header" class="text-center mb-5"> | ||||
|             <h1 class="text-xl font-bold">Authenticate</h1> | ||||
|             <p>Via your Goatpass account</p> | ||||
|           </div> | ||||
|  | ||||
|           <div class="pt-16 text-center"> | ||||
|             <div class="text-center"> | ||||
|               <div> | ||||
|                 <span class="loading loading-lg loading-bars"></span> | ||||
|               </div> | ||||
|               <span>{status()}</span> | ||||
|             </div> | ||||
|           </div> | ||||
|  | ||||
|           <Show when={error()} fallback={<div class="mt-16"></div>}> | ||||
|             <div id="alerts" class="mt-16"> | ||||
|               <div role="alert" class="alert alert-error"> | ||||
|                 <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" | ||||
|                      fill="none" | ||||
|                      viewBox="0 0 24 24"> | ||||
|                   <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" | ||||
|                         d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /> | ||||
|                 </svg> | ||||
|                 <span class="capitalize">{error()}</span> | ||||
|               </div> | ||||
|             </div> | ||||
|           </Show> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										55
									
								
								pkg/view/src/pages/auth/callout.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								pkg/view/src/pages/auth/callout.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,55 @@ | ||||
| import { createSignal, Show } from "solid-js"; | ||||
|  | ||||
| export default function AuthCallout() { | ||||
|   const [error, setError] = createSignal<string | null>(null); | ||||
|   const [status, setStatus] = createSignal("Communicating with Goatpass..."); | ||||
|  | ||||
|   async function communicate() { | ||||
|     const res = await fetch(`/api/auth${location.search}`); | ||||
|     if (res.status !== 200) { | ||||
|       setError(await res.text()); | ||||
|     } else { | ||||
|       const data = await res.json(); | ||||
|       setStatus("Got you! Now redirecting..."); | ||||
|       window.open(data["target"], "_self"); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   communicate(); | ||||
|  | ||||
|   return ( | ||||
|     <div class="w-full h-full flex justify-center items-center"> | ||||
|       <div class="card w-[480px] max-w-screen shadow-xl"> | ||||
|         <div class="card-body"> | ||||
|           <div id="header" class="text-center mb-5"> | ||||
|             <h1 class="text-xl font-bold">Authenticate</h1> | ||||
|             <p>Via your Goatpass account</p> | ||||
|           </div> | ||||
|  | ||||
|           <div class="pt-16 text-center"> | ||||
|             <div class="text-center"> | ||||
|               <div> | ||||
|                 <span class="loading loading-lg loading-bars"></span> | ||||
|               </div> | ||||
|               <span>{status()}</span> | ||||
|             </div> | ||||
|           </div> | ||||
|  | ||||
|           <Show when={error()} fallback={<div class="mt-16"></div>}> | ||||
|             <div id="alerts" class="mt-16"> | ||||
|               <div role="alert" class="alert alert-error"> | ||||
|                 <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" | ||||
|                      fill="none" | ||||
|                      viewBox="0 0 24 24"> | ||||
|                   <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" | ||||
|                         d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /> | ||||
|                 </svg> | ||||
|                 <span class="capitalize">{error()}</span> | ||||
|               </div> | ||||
|             </div> | ||||
|           </Show> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
| @@ -1,5 +1,5 @@ | ||||
| import { getAtk, readProfiles, useUserinfo } from "../stores/userinfo.tsx"; | ||||
| import { createSignal, For, Show } from "solid-js"; | ||||
| import { useUserinfo } from "../stores/userinfo.tsx"; | ||||
| import { createSignal, Show } from "solid-js"; | ||||
|  | ||||
| export default function DashboardPage() { | ||||
|   const userinfo = useUserinfo(); | ||||
| @@ -18,19 +18,6 @@ export default function DashboardPage() { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   async function readNotification(item: any) { | ||||
|     const res = await fetch(`/api/notifications/${item.id}/read`, { | ||||
|       method: "PUT", | ||||
|       headers: { Authorization: `Bearer ${getAtk()}` } | ||||
|     }); | ||||
|     if (res.status !== 200) { | ||||
|       setError(await res.text()); | ||||
|     } else { | ||||
|       await readProfiles(); | ||||
|       setError(null); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <div class="max-w-[720px] mx-auto px-5 pt-12"> | ||||
|       <div id="greeting" class="px-5"> | ||||
| @@ -39,19 +26,6 @@ export default function DashboardPage() { | ||||
|       </div> | ||||
|  | ||||
|       <div id="alerts"> | ||||
|         <Show when={!userinfo?.meta?.confirmed_at}> | ||||
|           <div role="alert" class="alert alert-warning mt-5"> | ||||
|             <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" | ||||
|                  viewBox="0 0 24 24"> | ||||
|               <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" | ||||
|                     d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /> | ||||
|             </svg> | ||||
|             <div> | ||||
|               <span>Your account isn't confirmed yet. Please check your inbox and confirm your account.</span> <br /> | ||||
|               <span>Otherwise your account will be deactivate after 48 hours.</span> | ||||
|             </div> | ||||
|           </div> | ||||
|         </Show> | ||||
|         <Show when={error()}> | ||||
|           <div role="alert" class="alert alert-error mt-5"> | ||||
|             <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" | ||||
| @@ -64,45 +38,6 @@ export default function DashboardPage() { | ||||
|         </Show> | ||||
|       </div> | ||||
|  | ||||
|       <div class="card shadow-xl mt-5"> | ||||
|         <div class="card-body"> | ||||
|           <h2 class="card-title">Notifications</h2> | ||||
|           <div class="bg-base-200 mt-3 mx-[-32px]"> | ||||
|             <Show when={userinfo?.meta?.notifications?.length <= 0}> | ||||
|               <table class="table"> | ||||
|                 <tbody> | ||||
|                 <tr> | ||||
|                   <td class="px-[32px]">You're done! There are no notifications unread for you.</td> | ||||
|                 </tr> | ||||
|                 </tbody> | ||||
|               </table> | ||||
|             </Show> | ||||
|             <Show when={userinfo?.meta?.notifications?.length > 0}> | ||||
|               <table class="table"> | ||||
|                 <tbody> | ||||
|                 <For each={userinfo?.meta?.notifications}> | ||||
|                   {item => | ||||
|                     <tr> | ||||
|                       <td class="px-[32px]"> | ||||
|                         <h2 class="font-bold">{item.subject}</h2> | ||||
|                         <p>{item.content}</p> | ||||
|                         <div class="flex gap-2"> | ||||
|                           <Show when={item.is_important}> | ||||
|                             <span class="font-bold">Important</span> | ||||
|                           </Show> | ||||
|                           <a class="link" onClick={() => readNotification(item)}>Mark as read</a> | ||||
|                         </div> | ||||
|                       </td> | ||||
|                     </tr> | ||||
|                   } | ||||
|                 </For> | ||||
|                 </tbody> | ||||
|               </table> | ||||
|             </Show> | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|  | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
| @@ -1,95 +1,91 @@ | ||||
| import Cookie from "universal-cookie"; | ||||
| import { createContext, useContext } from "solid-js"; | ||||
| import { createStore } from "solid-js/store"; | ||||
| import {createContext, useContext} from "solid-js"; | ||||
| import {createStore} from "solid-js/store"; | ||||
|  | ||||
| export interface Userinfo { | ||||
|   isLoggedIn: boolean, | ||||
|   displayName: string, | ||||
|   profiles: any, | ||||
|   meta: any | ||||
|     isLoggedIn: boolean, | ||||
|     displayName: string, | ||||
|     profiles: any, | ||||
| } | ||||
|  | ||||
| const UserinfoContext = createContext<Userinfo>(); | ||||
|  | ||||
| const defaultUserinfo: Userinfo = { | ||||
|   isLoggedIn: false, | ||||
|   displayName: "Citizen", | ||||
|   profiles: null, | ||||
|   meta: null | ||||
|     isLoggedIn: false, | ||||
|     displayName: "Citizen", | ||||
|     profiles: null, | ||||
| }; | ||||
|  | ||||
| const [userinfo, setUserinfo] = createStore<Userinfo>(structuredClone(defaultUserinfo)); | ||||
|  | ||||
| export function getAtk(): string { | ||||
|   return new Cookie().get("access_token"); | ||||
|     return new Cookie().get("access_token"); | ||||
| } | ||||
|  | ||||
| export async function refreshAtk() { | ||||
|   const rtk = new Cookie().get("refresh_token"); | ||||
|     const rtk = new Cookie().get("refresh_token"); | ||||
|  | ||||
|   const res = await fetch("/api/auth/token", { | ||||
|     method: "POST", | ||||
|     headers: { "Content-Type": "application/json" }, | ||||
|     body: JSON.stringify({ | ||||
|       refresh_token: rtk, | ||||
|       grant_type: "refresh_token" | ||||
|     }) | ||||
|   }); | ||||
|   if (res.status !== 200) { | ||||
|     console.error(await res.text()) | ||||
|   } else { | ||||
|     const data = await res.json(); | ||||
|     new Cookie().set("access_token", data["access_token"], { path: "/", maxAge: undefined }); | ||||
|     new Cookie().set("refresh_token", data["refresh_token"], { path: "/", maxAge: undefined }); | ||||
|   } | ||||
|     const res = await fetch("/api/auth/refresh", { | ||||
|         method: "POST", | ||||
|         headers: {"Content-Type": "application/json"}, | ||||
|         body: JSON.stringify({ | ||||
|             refresh_token: rtk, | ||||
|         }) | ||||
|     }); | ||||
|     if (res.status !== 200) { | ||||
|         console.error(await res.text()) | ||||
|     } else { | ||||
|         const data = await res.json(); | ||||
|         new Cookie().set("access_token", data["access_token"], {path: "/", maxAge: undefined}); | ||||
|         new Cookie().set("refresh_token", data["refresh_token"], {path: "/", maxAge: undefined}); | ||||
|     } | ||||
| } | ||||
|  | ||||
| function checkLoggedIn(): boolean { | ||||
|   return new Cookie().get("access_token"); | ||||
|     return new Cookie().get("access_token"); | ||||
| } | ||||
|  | ||||
| export async function readProfiles(recovering = true) { | ||||
|   if (!checkLoggedIn()) return; | ||||
|     if (!checkLoggedIn()) return; | ||||
|  | ||||
|   const res = await fetch("/api/users/me", { | ||||
|     headers: { "Authorization": `Bearer ${getAtk()}` } | ||||
|   }); | ||||
|     const res = await fetch("/api/users/me", { | ||||
|         headers: {"Authorization": `Bearer ${getAtk()}`} | ||||
|     }); | ||||
|  | ||||
|   if (res.status !== 200) { | ||||
|     if (recovering) { | ||||
|       // Auto retry after refresh access token | ||||
|       await refreshAtk(); | ||||
|       return await readProfiles(false); | ||||
|     } else { | ||||
|       clearUserinfo(); | ||||
|       window.location.reload(); | ||||
|     if (res.status !== 200) { | ||||
|         if (recovering) { | ||||
|             // Auto retry after refresh access token | ||||
|             await refreshAtk(); | ||||
|             return await readProfiles(false); | ||||
|         } else { | ||||
|             clearUserinfo(); | ||||
|             window.location.reload(); | ||||
|         } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   const data = await res.json(); | ||||
|     const data = await res.json(); | ||||
|  | ||||
|   setUserinfo({ | ||||
|     isLoggedIn: true, | ||||
|     displayName: data["nick"], | ||||
|     profiles: null, | ||||
|     meta: data | ||||
|   }); | ||||
|     setUserinfo({ | ||||
|         isLoggedIn: true, | ||||
|         displayName: data["name"], | ||||
|         profiles: data, | ||||
|     }); | ||||
| } | ||||
|  | ||||
| export function clearUserinfo() { | ||||
|   new Cookie().remove("access_token", { path: "/", maxAge: undefined }); | ||||
|   new Cookie().remove("refresh_token", { path: "/", maxAge: undefined }); | ||||
|   setUserinfo(defaultUserinfo); | ||||
|     new Cookie().remove("access_token", {path: "/", maxAge: undefined}); | ||||
|     new Cookie().remove("refresh_token", {path: "/", maxAge: undefined}); | ||||
|     setUserinfo(defaultUserinfo); | ||||
| } | ||||
|  | ||||
| export function UserinfoProvider(props: any) { | ||||
|   return ( | ||||
|     <UserinfoContext.Provider value={userinfo}> | ||||
|       {props.children} | ||||
|     </UserinfoContext.Provider> | ||||
|   ); | ||||
|     return ( | ||||
|         <UserinfoContext.Provider value={userinfo}> | ||||
|             {props.children} | ||||
|         </UserinfoContext.Provider> | ||||
|     ); | ||||
| } | ||||
|  | ||||
| export function useUserinfo() { | ||||
|   return useContext(UserinfoContext); | ||||
|     return useContext(UserinfoContext); | ||||
| } | ||||
		Reference in New Issue
	
	Block a user