✨ User Center
This commit is contained in:
		| @@ -9,9 +9,10 @@ import ( | |||||||
| 	jsoniter "github.com/json-iterator/go" | 	jsoniter "github.com/json-iterator/go" | ||||||
| 	"github.com/spf13/viper" | 	"github.com/spf13/viper" | ||||||
| 	"strconv" | 	"strconv" | ||||||
|  | 	"time" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| func getPrincipal(c *fiber.Ctx) error { | func getUserinfo(c *fiber.Ctx) error { | ||||||
| 	user := c.Locals("principal").(models.Account) | 	user := c.Locals("principal").(models.Account) | ||||||
|  |  | ||||||
| 	var data models.Account | 	var data models.Account | ||||||
| @@ -68,6 +69,44 @@ func getEvents(c *fiber.Ctx) error { | |||||||
| 	}) | 	}) | ||||||
| } | } | ||||||
|  |  | ||||||
|  | func editUserinfo(c *fiber.Ctx) error { | ||||||
|  | 	user := c.Locals("principal").(models.Account) | ||||||
|  |  | ||||||
|  | 	var data struct { | ||||||
|  | 		Nick       string    `json:"nick" validate:"required,min=4,max=24"` | ||||||
|  | 		FirstName  string    `json:"first_name"` | ||||||
|  | 		MiddleName string    `json:"middle_name"` | ||||||
|  | 		LastName   string    `json:"last_name"` | ||||||
|  | 		Birthday   time.Time `json:"birthday"` | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if err := BindAndValidate(c, &data); err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	var account models.Account | ||||||
|  | 	if err := database.C. | ||||||
|  | 		Where(&models.Account{BaseModel: models.BaseModel{ID: user.ID}}). | ||||||
|  | 		Preload("Profile"). | ||||||
|  | 		First(&account).Error; err != nil { | ||||||
|  | 		return fiber.NewError(fiber.StatusInternalServerError, err.Error()) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	account.Nick = data.Nick | ||||||
|  | 	account.Profile.FirstName = data.FirstName | ||||||
|  | 	account.Profile.MiddleName = data.MiddleName | ||||||
|  | 	account.Profile.LastName = data.LastName | ||||||
|  | 	account.Profile.Birthday = &data.Birthday | ||||||
|  |  | ||||||
|  | 	if err := database.C.Save(&account).Error; err != nil { | ||||||
|  | 		return fiber.NewError(fiber.StatusInternalServerError, err.Error()) | ||||||
|  | 	} else if err := database.C.Save(&account.Profile).Error; err != nil { | ||||||
|  | 		return fiber.NewError(fiber.StatusInternalServerError, err.Error()) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return c.SendStatus(fiber.StatusOK) | ||||||
|  | } | ||||||
|  |  | ||||||
| func killSession(c *fiber.Ctx) error { | func killSession(c *fiber.Ctx) error { | ||||||
| 	user := c.Locals("principal").(models.Account) | 	user := c.Locals("principal").(models.Account) | ||||||
| 	id, _ := c.ParamsInt("sessionId", 0) | 	id, _ := c.ParamsInt("sessionId", 0) | ||||||
| @@ -84,10 +123,10 @@ func killSession(c *fiber.Ctx) error { | |||||||
|  |  | ||||||
| func doRegister(c *fiber.Ctx) error { | func doRegister(c *fiber.Ctx) error { | ||||||
| 	var data struct { | 	var data struct { | ||||||
| 		Name       string `json:"name"` | 		Name       string `json:"name" validate:"required,lowercase,alphanum,min=4,max=16"` | ||||||
| 		Nick       string `json:"nick"` | 		Nick       string `json:"nick" validate:"required,min=4,max=24"` | ||||||
| 		Email      string `json:"email"` | 		Email      string `json:"email" validate:"required,email"` | ||||||
| 		Password   string `json:"password"` | 		Password   string `json:"password" validate:"required,min=4,max=32"` | ||||||
| 		MagicToken string `json:"magic_token"` | 		MagicToken string `json:"magic_token"` | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| @@ -117,7 +156,7 @@ func doRegister(c *fiber.Ctx) error { | |||||||
|  |  | ||||||
| func doRegisterConfirm(c *fiber.Ctx) error { | func doRegisterConfirm(c *fiber.Ctx) error { | ||||||
| 	var data struct { | 	var data struct { | ||||||
| 		Code string `json:"code"` | 		Code string `json:"code" validate:"required"` | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	if err := BindAndValidate(c, &data); err != nil { | 	if err := BindAndValidate(c, &data); err != nil { | ||||||
|   | |||||||
| @@ -11,7 +11,7 @@ import ( | |||||||
|  |  | ||||||
| func startChallenge(c *fiber.Ctx) error { | func startChallenge(c *fiber.Ctx) error { | ||||||
| 	var data struct { | 	var data struct { | ||||||
| 		ID string `json:"id"` | 		ID string `json:"id" validate:"required"` | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	if err := BindAndValidate(c, &data); err != nil { | 	if err := BindAndValidate(c, &data); err != nil { | ||||||
| @@ -42,9 +42,9 @@ func startChallenge(c *fiber.Ctx) error { | |||||||
|  |  | ||||||
| func doChallenge(c *fiber.Ctx) error { | func doChallenge(c *fiber.Ctx) error { | ||||||
| 	var data struct { | 	var data struct { | ||||||
| 		ChallengeID uint   `json:"challenge_id"` | 		ChallengeID uint   `json:"challenge_id" validate:"required"` | ||||||
| 		FactorID    uint   `json:"factor_id"` | 		FactorID    uint   `json:"factor_id" validate:"required"` | ||||||
| 		Secret      string `json:"secret"` | 		Secret      string `json:"secret" validate:"required"` | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	if err := BindAndValidate(c, &data); err != nil { | 	if err := BindAndValidate(c, &data); err != nil { | ||||||
|   | |||||||
| @@ -22,8 +22,8 @@ func NewServer() { | |||||||
| 	A = fiber.New(fiber.Config{ | 	A = fiber.New(fiber.Config{ | ||||||
| 		DisableStartupMessage: true, | 		DisableStartupMessage: true, | ||||||
| 		EnableIPValidation:    true, | 		EnableIPValidation:    true, | ||||||
| 		ServerHeader:          "passport", | 		ServerHeader:          "Hydrogen.Passport", | ||||||
| 		AppName:               "passport", | 		AppName:               "Hydrogen.Passport", | ||||||
| 		ProxyHeader:           fiber.HeaderXForwardedFor, | 		ProxyHeader:           fiber.HeaderXForwardedFor, | ||||||
| 		JSONEncoder:           jsoniter.ConfigCompatibleWithStandardLibrary.Marshal, | 		JSONEncoder:           jsoniter.ConfigCompatibleWithStandardLibrary.Marshal, | ||||||
| 		JSONDecoder:           jsoniter.ConfigCompatibleWithStandardLibrary.Unmarshal, | 		JSONDecoder:           jsoniter.ConfigCompatibleWithStandardLibrary.Unmarshal, | ||||||
| @@ -57,7 +57,8 @@ func NewServer() { | |||||||
|  |  | ||||||
| 	api := A.Group("/api").Name("API") | 	api := A.Group("/api").Name("API") | ||||||
| 	{ | 	{ | ||||||
| 		api.Get("/users/me", auth, getPrincipal) | 		api.Get("/users/me", auth, getUserinfo) | ||||||
|  | 		api.Put("/users/me", auth, editUserinfo) | ||||||
| 		api.Get("/users/me/events", auth, getEvents) | 		api.Get("/users/me/events", auth, getEvents) | ||||||
| 		api.Delete("/users/me/sessions/:sessionId", auth, killSession) | 		api.Delete("/users/me/sessions/:sessionId", auth, killSession) | ||||||
|  |  | ||||||
|   | |||||||
| @@ -5,7 +5,7 @@ import ( | |||||||
| 	"github.com/gofiber/fiber/v2" | 	"github.com/gofiber/fiber/v2" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| var validation = validator.New() | var validation = validator.New(validator.WithRequiredStructEnabled()) | ||||||
|  |  | ||||||
| func BindAndValidate(c *fiber.Ctx, out any) error { | func BindAndValidate(c *fiber.Ctx, out any) error { | ||||||
| 	if err := c.BodyParser(out); err != nil { | 	if err := c.BodyParser(out); err != nil { | ||||||
|   | |||||||
| @@ -19,6 +19,7 @@ render(() => ( | |||||||
|     <UserinfoProvider> |     <UserinfoProvider> | ||||||
|       <Router root={RootLayout}> |       <Router root={RootLayout}> | ||||||
|         <Route path="/" component={lazy(() => import("./pages/dashboard.tsx"))} /> |         <Route path="/" component={lazy(() => import("./pages/dashboard.tsx"))} /> | ||||||
|  |         <Route path="/personalise" component={lazy(() => import("./pages/personalise.tsx"))} /> | ||||||
|         <Route path="/auth/login" component={lazy(() => import("./pages/auth/login.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/register" component={lazy(() => import("./pages/auth/register.tsx"))} /> | ||||||
|         <Route path="/auth/oauth/connect" component={lazy(() => import("./pages/auth/connect.tsx"))} /> |         <Route path="/auth/oauth/connect" component={lazy(() => import("./pages/auth/connect.tsx"))} /> | ||||||
|   | |||||||
| @@ -9,7 +9,10 @@ interface MenuItem { | |||||||
| } | } | ||||||
|  |  | ||||||
| export default function Navbar() { | export default function Navbar() { | ||||||
|   const nav: MenuItem[] = [{ label: "Dashboard", href: "/" }]; |   const nav: MenuItem[] = [ | ||||||
|  |     { label: "Dashboard", href: "/" }, | ||||||
|  |     { label: "Personalise", href: "/personalise" } | ||||||
|  |   ]; | ||||||
|  |  | ||||||
|   const wellKnown = useWellKnown(); |   const wellKnown = useWellKnown(); | ||||||
|   const userinfo = useUserinfo(); |   const userinfo = useUserinfo(); | ||||||
|   | |||||||
| @@ -117,8 +117,8 @@ export default function LoginPage() { | |||||||
|       throw new Error(err); |       throw new Error(err); | ||||||
|     } else { |     } else { | ||||||
|       const data = await res.json(); |       const data = await res.json(); | ||||||
|       new Cookie().set("access_token", data["access_token"], { path: "/" }); |       new Cookie().set("access_token", data["access_token"], { path: "/", maxAge: undefined }); | ||||||
|       new Cookie().set("refresh_token", data["refresh_token"], { path: "/" }); |       new Cookie().set("refresh_token", data["refresh_token"], { path: "/", maxAge: undefined }); | ||||||
|       setError(null); |       setError(null); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|   | |||||||
| @@ -128,7 +128,7 @@ export default function DashboardPage() { | |||||||
|       <div id="data-area" class="mt-5 shadow"> |       <div id="data-area" class="mt-5 shadow"> | ||||||
|         <div class="join join-vertical w-full"> |         <div class="join join-vertical w-full"> | ||||||
|  |  | ||||||
|           <details class="collapse collapse-plus join-item"> |           <details class="collapse collapse-plus join-item border-b border-base-200"> | ||||||
|             <summary class="collapse-title text-lg font-medium"> |             <summary class="collapse-title text-lg font-medium"> | ||||||
|               Challenges |               Challenges | ||||||
|             </summary> |             </summary> | ||||||
| @@ -164,7 +164,7 @@ export default function DashboardPage() { | |||||||
|             </div> |             </div> | ||||||
|           </details> |           </details> | ||||||
|  |  | ||||||
|           <details class="collapse collapse-plus join-item"> |           <details class="collapse collapse-plus join-item border-b border-base-200"> | ||||||
|             <summary class="collapse-title text-lg font-medium"> |             <summary class="collapse-title text-lg font-medium"> | ||||||
|               Sessions |               Sessions | ||||||
|             </summary> |             </summary> | ||||||
|   | |||||||
							
								
								
									
										121
									
								
								pkg/view/src/pages/personalise.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										121
									
								
								pkg/view/src/pages/personalise.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,121 @@ | |||||||
|  | import { getAtk, readProfiles, useUserinfo } from "../stores/userinfo.tsx"; | ||||||
|  | import { createSignal, Show } from "solid-js"; | ||||||
|  |  | ||||||
|  | export default function PersonalPage() { | ||||||
|  |   const userinfo = useUserinfo(); | ||||||
|  |  | ||||||
|  |   const [error, setError] = createSignal<null | string>(null); | ||||||
|  |   const [success, setSuccess] = createSignal<null | string>(null); | ||||||
|  |   const [loading, setLoading] = createSignal(false); | ||||||
|  |  | ||||||
|  |   async function update(evt: SubmitEvent) { | ||||||
|  |     evt.preventDefault(); | ||||||
|  |  | ||||||
|  |     const data = Object.fromEntries(new FormData(evt.target as HTMLFormElement)); | ||||||
|  |  | ||||||
|  |     setLoading(true); | ||||||
|  |     const res = await fetch("/api/users/me", { | ||||||
|  |       method: "PUT", | ||||||
|  |       headers: { | ||||||
|  |         "Content-Type": "application/json", | ||||||
|  |         "Authorization": `Bearer ${getAtk()}` | ||||||
|  |       }, | ||||||
|  |       body: JSON.stringify(data) | ||||||
|  |     }); | ||||||
|  |     if (res.status !== 200) { | ||||||
|  |       setSuccess(null); | ||||||
|  |       setError(await res.text()); | ||||||
|  |     } else { | ||||||
|  |       await readProfiles(); | ||||||
|  |       setSuccess("Your basic information has been update."); | ||||||
|  |       setError(null); | ||||||
|  |     } | ||||||
|  |     setLoading(false); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <div class="max-w-[720px] mx-auto px-5 pt-12"> | ||||||
|  |       <div class="px-5"> | ||||||
|  |         <h1 class="text-2xl font-bold">{userinfo?.displayName}</h1> | ||||||
|  |         <p>Joined at {new Date(userinfo?.meta?.created_at).toLocaleString()}</p> | ||||||
|  |       </div> | ||||||
|  |  | ||||||
|  |       <div class="card shadow mt-5"> | ||||||
|  |         <div class="card-body"> | ||||||
|  |  | ||||||
|  |           <Show when={error()}> | ||||||
|  |             <div id="alerts"> | ||||||
|  |               <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> | ||||||
|  |  | ||||||
|  |           <Show when={success()}> | ||||||
|  |             <div id="alerts"> | ||||||
|  |               <div role="alert" class="alert alert-success"> | ||||||
|  |                 <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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /> | ||||||
|  |                 </svg> | ||||||
|  |                 <span class="capitalize">{success()}</span> | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  |           </Show> | ||||||
|  |  | ||||||
|  |           <form class="grid grid-cols-1 gap-2" onSubmit={update}> | ||||||
|  |             <label class="form-control w-full"> | ||||||
|  |               <div class="label"> | ||||||
|  |                 <span class="label-text">Username</span> | ||||||
|  |               </div> | ||||||
|  |               <input value={userinfo?.meta?.name} name="name" type="text" placeholder="Type here" | ||||||
|  |                      class="input input-bordered w-full" disabled /> | ||||||
|  |             </label> | ||||||
|  |             <label class="form-control w-full"> | ||||||
|  |               <div class="label"> | ||||||
|  |                 <span class="label-text">Nickname</span> | ||||||
|  |               </div> | ||||||
|  |               <input value={userinfo?.meta?.nick} name="nick" type="text" placeholder="Type here" | ||||||
|  |                      class="input input-bordered w-full" /> | ||||||
|  |             </label> | ||||||
|  |             <div class="grid grid-cols-1 md:grid-cols-3 gap-x-4"> | ||||||
|  |               <label class="form-control w-full"> | ||||||
|  |                 <div class="label"> | ||||||
|  |                   <span class="label-text">First Name</span> | ||||||
|  |                 </div> | ||||||
|  |                 <input value={userinfo?.meta?.profile?.first_name} name="first_name" type="text" | ||||||
|  |                        placeholder="Type here" class="input input-bordered w-full" /> | ||||||
|  |               </label> | ||||||
|  |               <label class="form-control w-full"> | ||||||
|  |                 <div class="label"> | ||||||
|  |                   <span class="label-text">Middle Name</span> | ||||||
|  |                 </div> | ||||||
|  |                 <input value={userinfo?.meta?.profile?.middle_name} name="middle_name" type="text" | ||||||
|  |                        placeholder="Type here" class="input input-bordered w-full" /> | ||||||
|  |               </label> | ||||||
|  |               <label class="form-control w-full"> | ||||||
|  |                 <div class="label"> | ||||||
|  |                   <span class="label-text">Last Name</span> | ||||||
|  |                 </div> | ||||||
|  |                 <input value={userinfo?.meta?.profile?.last_name} name="last_name" type="text" | ||||||
|  |                        placeholder="Type here" class="input input-bordered w-full" /> | ||||||
|  |               </label> | ||||||
|  |             </div> | ||||||
|  |             <button type="submit" class="btn btn-primary btn-block mt-5" disabled={loading()}> | ||||||
|  |               <Show when={loading()} fallback={"Save changes"}> | ||||||
|  |                 <span class="loading loading-spinner"></span> | ||||||
|  |               </Show> | ||||||
|  |             </button> | ||||||
|  |           </form> | ||||||
|  |         </div> | ||||||
|  |  | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |   ); | ||||||
|  | } | ||||||
| @@ -39,8 +39,8 @@ export async function refreshAtk() { | |||||||
|     console.error(await res.text()) |     console.error(await res.text()) | ||||||
|   } else { |   } else { | ||||||
|     const data = await res.json(); |     const data = await res.json(); | ||||||
|     new Cookie().set("access_token", data["access_token"], { path: "/" }); |     new Cookie().set("access_token", data["access_token"], { path: "/", maxAge: undefined }); | ||||||
|     new Cookie().set("refresh_token", data["refresh_token"], { path: "/" }); |     new Cookie().set("refresh_token", data["refresh_token"], { path: "/", maxAge: undefined }); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -77,8 +77,8 @@ export async function readProfiles(recovering = true) { | |||||||
| } | } | ||||||
|  |  | ||||||
| export function clearUserinfo() { | export function clearUserinfo() { | ||||||
|   new Cookie().remove("access_token", { path: "/" }); |   new Cookie().remove("access_token", { path: "/", maxAge: undefined }); | ||||||
|   new Cookie().remove("refresh_token", { path: "/" }); |   new Cookie().remove("refresh_token", { path: "/", maxAge: undefined }); | ||||||
|   setUserinfo(defaultUserinfo); |   setUserinfo(defaultUserinfo); | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user