✨ Email notification
This commit is contained in:
		| @@ -1,22 +1,11 @@ | |||||||
| package security | package security | ||||||
|  |  | ||||||
| import ( | import ( | ||||||
| 	"fmt" |  | ||||||
|  |  | ||||||
| 	"code.smartsheep.studio/hydrogen/passport/pkg/models" | 	"code.smartsheep.studio/hydrogen/passport/pkg/models" | ||||||
|  | 	"fmt" | ||||||
| 	"github.com/samber/lo" | 	"github.com/samber/lo" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| func GetFactorCode(factor models.AuthFactor) (bool, error) { |  | ||||||
| 	switch factor.Type { |  | ||||||
| 	case models.EmailPasswordFactor: |  | ||||||
| 		// TODO |  | ||||||
| 		return true, nil |  | ||||||
| 	default: |  | ||||||
| 		return false, nil |  | ||||||
| 	} |  | ||||||
| } |  | ||||||
|  |  | ||||||
| func VerifyFactor(factor models.AuthFactor, code string) error { | func VerifyFactor(factor models.AuthFactor, code string) error { | ||||||
| 	switch factor.Type { | 	switch factor.Type { | ||||||
| 	case models.PasswordAuthFactor: | 	case models.PasswordAuthFactor: | ||||||
| @@ -25,6 +14,12 @@ func VerifyFactor(factor models.AuthFactor, code string) error { | |||||||
| 			nil, | 			nil, | ||||||
| 			fmt.Errorf("invalid password"), | 			fmt.Errorf("invalid password"), | ||||||
| 		) | 		) | ||||||
|  | 	case models.EmailPasswordFactor: | ||||||
|  | 		return lo.Ternary( | ||||||
|  | 			code == factor.Secret, | ||||||
|  | 			nil, | ||||||
|  | 			fmt.Errorf("invalid verification code"), | ||||||
|  | 		) | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	return nil | 	return nil | ||||||
|   | |||||||
| @@ -4,7 +4,9 @@ import ( | |||||||
| 	"code.smartsheep.studio/hydrogen/passport/pkg/database" | 	"code.smartsheep.studio/hydrogen/passport/pkg/database" | ||||||
| 	"code.smartsheep.studio/hydrogen/passport/pkg/models" | 	"code.smartsheep.studio/hydrogen/passport/pkg/models" | ||||||
| 	"code.smartsheep.studio/hydrogen/passport/pkg/services" | 	"code.smartsheep.studio/hydrogen/passport/pkg/services" | ||||||
|  | 	"fmt" | ||||||
| 	"github.com/gofiber/fiber/v2" | 	"github.com/gofiber/fiber/v2" | ||||||
|  | 	"github.com/spf13/viper" | ||||||
| 	"gorm.io/gorm/clause" | 	"gorm.io/gorm/clause" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| @@ -23,14 +25,23 @@ func getPrincipal(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"` | ||||||
| 		Nick     string `json:"nick"` | 		Nick       string `json:"nick"` | ||||||
| 		Email    string `json:"email"` | 		Email      string `json:"email"` | ||||||
| 		Password string `json:"password"` | 		Password   string `json:"password"` | ||||||
|  | 		MagicToken string `json:"magic_token"` | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	if err := BindAndValidate(c, &data); err != nil { | 	if err := BindAndValidate(c, &data); err != nil { | ||||||
| 		return err | 		return err | ||||||
|  | 	} else if viper.GetBool("use_registration_magic_token") && len(data.MagicToken) <= 0 { | ||||||
|  | 		return fmt.Errorf("missing magic token in request") | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if tk, err := services.ValidateMagicToken(data.MagicToken, models.RegistrationMagicToken); err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} else { | ||||||
|  | 		database.C.Delete(&tk) | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	if user, err := services.CreateAccount( | 	if user, err := services.CreateAccount( | ||||||
|   | |||||||
| @@ -1,7 +1,6 @@ | |||||||
| package server | package server | ||||||
|  |  | ||||||
| import ( | import ( | ||||||
| 	"code.smartsheep.studio/hydrogen/passport/pkg/security" |  | ||||||
| 	"code.smartsheep.studio/hydrogen/passport/pkg/services" | 	"code.smartsheep.studio/hydrogen/passport/pkg/services" | ||||||
| 	"github.com/gofiber/fiber/v2" | 	"github.com/gofiber/fiber/v2" | ||||||
| ) | ) | ||||||
| @@ -14,7 +13,7 @@ func requestFactorToken(c *fiber.Ctx) error { | |||||||
| 		return fiber.NewError(fiber.StatusNotFound, err.Error()) | 		return fiber.NewError(fiber.StatusNotFound, err.Error()) | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	if sent, err := security.GetFactorCode(factor); err != nil { | 	if sent, err := services.GetFactorCode(factor); err != nil { | ||||||
| 		return fiber.NewError(fiber.StatusNotFound, err.Error()) | 		return fiber.NewError(fiber.StatusNotFound, err.Error()) | ||||||
| 	} else if !sent { | 	} else if !sent { | ||||||
| 		return c.SendStatus(fiber.StatusNoContent) | 		return c.SendStatus(fiber.StatusNoContent) | ||||||
|   | |||||||
| @@ -7,7 +7,8 @@ import ( | |||||||
|  |  | ||||||
| func getMetadata(c *fiber.Ctx) error { | func getMetadata(c *fiber.Ctx) error { | ||||||
| 	return c.JSON(fiber.Map{ | 	return c.JSON(fiber.Map{ | ||||||
| 		"name":   viper.GetString("name"), | 		"name":              viper.GetString("name"), | ||||||
| 		"domain": viper.GetString("domain"), | 		"domain":            viper.GetString("domain"), | ||||||
|  | 		"open_registration": !viper.GetBool("use_registration_magic_token"), | ||||||
| 	}) | 	}) | ||||||
| } | } | ||||||
|   | |||||||
| @@ -5,6 +5,7 @@ import ( | |||||||
| 	"code.smartsheep.studio/hydrogen/passport/pkg/models" | 	"code.smartsheep.studio/hydrogen/passport/pkg/models" | ||||||
| 	"code.smartsheep.studio/hydrogen/passport/pkg/security" | 	"code.smartsheep.studio/hydrogen/passport/pkg/security" | ||||||
| 	"fmt" | 	"fmt" | ||||||
|  | 	"github.com/google/uuid" | ||||||
| 	"github.com/samber/lo" | 	"github.com/samber/lo" | ||||||
| 	"gorm.io/datatypes" | 	"gorm.io/datatypes" | ||||||
| 	"gorm.io/gorm" | 	"gorm.io/gorm" | ||||||
| @@ -54,11 +55,16 @@ func CreateAccount(name, nick, email, password string) (models.Account, error) { | |||||||
| 				Type:   models.PasswordAuthFactor, | 				Type:   models.PasswordAuthFactor, | ||||||
| 				Secret: security.HashPassword(password), | 				Secret: security.HashPassword(password), | ||||||
| 			}, | 			}, | ||||||
|  | 			{ | ||||||
|  | 				Type:   models.EmailPasswordFactor, | ||||||
|  | 				Secret: uuid.NewString()[:8], | ||||||
|  | 			}, | ||||||
| 		}, | 		}, | ||||||
| 		Contacts: []models.AccountContact{ | 		Contacts: []models.AccountContact{ | ||||||
| 			{ | 			{ | ||||||
| 				Type:       models.EmailAccountContact, | 				Type:       models.EmailAccountContact, | ||||||
| 				Content:    email, | 				Content:    email, | ||||||
|  | 				IsPrimary:  true, | ||||||
| 				VerifiedAt: nil, | 				VerifiedAt: nil, | ||||||
| 			}, | 			}, | ||||||
| 		}, | 		}, | ||||||
| @@ -80,14 +86,9 @@ func CreateAccount(name, nick, email, password string) (models.Account, error) { | |||||||
| } | } | ||||||
|  |  | ||||||
| func ConfirmAccount(code string) error { | func ConfirmAccount(code string) error { | ||||||
| 	var token models.MagicToken | 	token, err := ValidateMagicToken(code, models.ConfirmMagicToken) | ||||||
| 	if err := database.C.Where(&models.MagicToken{ | 	if err != nil { | ||||||
| 		Code: code, |  | ||||||
| 		Type: models.ConfirmMagicToken, |  | ||||||
| 	}).First(&token).Error; err != nil { |  | ||||||
| 		return err | 		return err | ||||||
| 	} else if token.AssignTo == nil { |  | ||||||
| 		return fmt.Errorf("account was not found") |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	var user models.Account | 	var user models.Account | ||||||
|   | |||||||
| @@ -3,8 +3,26 @@ package services | |||||||
| import ( | import ( | ||||||
| 	"code.smartsheep.studio/hydrogen/passport/pkg/database" | 	"code.smartsheep.studio/hydrogen/passport/pkg/database" | ||||||
| 	"code.smartsheep.studio/hydrogen/passport/pkg/models" | 	"code.smartsheep.studio/hydrogen/passport/pkg/models" | ||||||
|  | 	"fmt" | ||||||
|  | 	"github.com/google/uuid" | ||||||
|  | 	"github.com/spf13/viper" | ||||||
| ) | ) | ||||||
|  |  | ||||||
|  | const EmailPasswordTemplate = `Dear %s, | ||||||
|  |  | ||||||
|  | We hope this message finds you well. | ||||||
|  | As part of our ongoing commitment to ensuring the security of your account, we require you to complete the login process by entering the verification code below: | ||||||
|  |  | ||||||
|  | Your Login Verification Code: %s | ||||||
|  |  | ||||||
|  | Please use the provided code within the next 2 hours to complete your login.  | ||||||
|  | If you did not request this code, please update your information, maybe your username or email has been leak. | ||||||
|  |  | ||||||
|  | Thank you for your cooperation in helping us maintain the security of your account. | ||||||
|  |  | ||||||
|  | Best regards, | ||||||
|  | %s` | ||||||
|  |  | ||||||
| func LookupFactor(id uint) (models.AuthFactor, error) { | func LookupFactor(id uint) (models.AuthFactor, error) { | ||||||
| 	var factor models.AuthFactor | 	var factor models.AuthFactor | ||||||
| 	err := database.C.Where(models.AuthFactor{ | 	err := database.C.Where(models.AuthFactor{ | ||||||
| @@ -22,3 +40,30 @@ func LookupFactorsByUser(uid uint) ([]models.AuthFactor, error) { | |||||||
|  |  | ||||||
| 	return factors, err | 	return factors, err | ||||||
| } | } | ||||||
|  |  | ||||||
|  | func GetFactorCode(factor models.AuthFactor) (bool, error) { | ||||||
|  | 	switch factor.Type { | ||||||
|  | 	case models.EmailPasswordFactor: | ||||||
|  | 		var user models.Account | ||||||
|  | 		if err := database.C.Where(&models.Account{ | ||||||
|  | 			BaseModel: models.BaseModel{ID: factor.AccountID}, | ||||||
|  | 		}).Preload("Contacts").First(&user).Error; err != nil { | ||||||
|  | 			return true, err | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		factor.Secret = uuid.NewString()[:8] | ||||||
|  | 		if err := database.C.Save(&factor).Error; err != nil { | ||||||
|  | 			return true, err | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		subject := fmt.Sprintf("[%s] Login verification code", viper.GetString("name")) | ||||||
|  | 		content := fmt.Sprintf(EmailPasswordTemplate, user.Name, factor.Secret, viper.GetString("maintainer")) | ||||||
|  | 		if err := SendMail(user.GetPrimaryEmail().Content, subject, content); err != nil { | ||||||
|  | 			return true, err | ||||||
|  | 		} | ||||||
|  | 		return true, nil | ||||||
|  |  | ||||||
|  | 	default: | ||||||
|  | 		return false, nil | ||||||
|  | 	} | ||||||
|  | } | ||||||
|   | |||||||
| @@ -10,6 +10,33 @@ import ( | |||||||
| 	"time" | 	"time" | ||||||
| ) | ) | ||||||
|  |  | ||||||
|  | const ConfirmRegistrationTemplate = `Dear %s, | ||||||
|  |  | ||||||
|  | Thank you for choosing to register with %s. We are excited to welcome you to our community and appreciate your trust in us. | ||||||
|  |  | ||||||
|  | Your registration details have been successfully received, and you are now a valued member of %s. Here are the confirm link of your registration: | ||||||
|  |  | ||||||
|  | 	%s | ||||||
|  |  | ||||||
|  | As a confirmed registered member, you will have access to all our services. | ||||||
|  | We encourage you to explore our services and take full advantage of the resources available to you. | ||||||
|  |  | ||||||
|  | Once again, thank you for choosing us. We look forward to serving you and hope you have a positive experience with us. | ||||||
|  |  | ||||||
|  | Best regards, | ||||||
|  | %s` | ||||||
|  |  | ||||||
|  | func ValidateMagicToken(code string, mode models.MagicTokenType) (models.MagicToken, error) { | ||||||
|  | 	var tk models.MagicToken | ||||||
|  | 	if err := database.C.Where(models.MagicToken{Code: code, Type: mode}).First(&tk).Error; err != nil { | ||||||
|  | 		return tk, err | ||||||
|  | 	} else if tk.ExpiredAt != nil && time.Now().Unix() >= tk.ExpiredAt.Unix() { | ||||||
|  | 		return tk, fmt.Errorf("token has been expired") | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return tk, nil | ||||||
|  | } | ||||||
|  |  | ||||||
| func NewMagicToken(mode models.MagicTokenType, assignTo *models.Account, expiredAt *time.Time) (models.MagicToken, error) { | func NewMagicToken(mode models.MagicTokenType, assignTo *models.Account, expiredAt *time.Time) (models.MagicToken, error) { | ||||||
| 	var uid uint | 	var uid uint | ||||||
| 	if assignTo != nil { | 	if assignTo != nil { | ||||||
| @@ -36,8 +63,8 @@ func NotifyMagicToken(token models.MagicToken) error { | |||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	var user models.Account | 	var user models.Account | ||||||
| 	if err := database.C.Where(&models.MagicToken{ | 	if err := database.C.Where(&models.Account{ | ||||||
| 		AssignTo: token.AssignTo, | 		BaseModel: models.BaseModel{ID: *token.AssignTo}, | ||||||
| 	}).Preload("Contacts").First(&user).Error; err != nil { | 	}).Preload("Contacts").First(&user).Error; err != nil { | ||||||
| 		return err | 		return err | ||||||
| 	} | 	} | ||||||
| @@ -46,15 +73,16 @@ func NotifyMagicToken(token models.MagicToken) error { | |||||||
| 	var content string | 	var content string | ||||||
| 	switch token.Type { | 	switch token.Type { | ||||||
| 	case models.ConfirmMagicToken: | 	case models.ConfirmMagicToken: | ||||||
| 		link := fmt.Sprintf("%s/users/me/confirm?tk=%s", viper.GetString("domain"), token.Code) | 		link := fmt.Sprintf("https://%s/users/me/confirm?tk=%s", viper.GetString("domain"), token.Code) | ||||||
| 		subject = fmt.Sprintf("[%s] Confirm your registration", viper.GetString("name")) | 		subject = fmt.Sprintf("[%s] Confirm your registration", viper.GetString("name")) | ||||||
| 		content = fmt.Sprintf("We got a create account request with this email recently.\n"+ | 		content = fmt.Sprintf( | ||||||
| 			"So we need you to click the link below to confirm your registeration.\n"+ | 			ConfirmRegistrationTemplate, | ||||||
| 			"Confirmnation Link: %s\n"+ | 			user.Name, | ||||||
| 			"If you didn't do that, you can ignore this email.\n\n"+ | 			viper.GetString("name"), | ||||||
| 			"%s\n"+ | 			viper.GetString("maintainer"), | ||||||
| 			"Best wishes", | 			link, | ||||||
| 			link, viper.GetString("maintainer")) | 			viper.GetString("maintainer"), | ||||||
|  | 		) | ||||||
| 	default: | 	default: | ||||||
| 		return fmt.Errorf("unsupported magic token type to notify") | 		return fmt.Errorf("unsupported magic token type to notify") | ||||||
| 	} | 	} | ||||||
|   | |||||||
| @@ -7,6 +7,15 @@ bind = "0.0.0.0:8444" | |||||||
| domain = "id.smartsheep.studio" | domain = "id.smartsheep.studio" | ||||||
| secret = "LtTjzAGFLshwXhN4ZD4nG5KlMv1MWcsvfv03TSZYnT1VhiAnLIZFTnHUwR0XhGgi" | secret = "LtTjzAGFLshwXhN4ZD4nG5KlMv1MWcsvfv03TSZYnT1VhiAnLIZFTnHUwR0XhGgi" | ||||||
|  |  | ||||||
|  | use_registration_magic_token = true | ||||||
|  |  | ||||||
|  | [mailer] | ||||||
|  | name = "Alphabot <alphabot@smartsheep.studio>" | ||||||
|  | smtp_host = "smtp.exmail.qq.com" | ||||||
|  | smtp_port = 465 | ||||||
|  | username = "alphabot@smartsheep.studio" | ||||||
|  | password = "gz937Zxxzfcd9SeH" | ||||||
|  |  | ||||||
| [database] | [database] | ||||||
| dsn = "host=localhost dbname=hy_passport port=5432 sslmode=disable" | dsn = "host=localhost dbname=hy_passport port=5432 sslmode=disable" | ||||||
| prefix = "passport_" | prefix = "passport_" | ||||||
|   | |||||||
| @@ -5,5 +5,4 @@ | |||||||
| html, body { | html, body { | ||||||
|     padding: 0; |     padding: 0; | ||||||
|     margin: 0; |     margin: 0; | ||||||
|     height: 100vh; |  | ||||||
| } | } | ||||||
| @@ -9,14 +9,20 @@ import { lazy } from "solid-js"; | |||||||
| import { Route, Router } from "@solidjs/router"; | import { Route, Router } from "@solidjs/router"; | ||||||
|  |  | ||||||
| import RootLayout from "./layouts/RootLayout.tsx"; | import RootLayout from "./layouts/RootLayout.tsx"; | ||||||
|  | import { UserinfoProvider } from "./stores/userinfo.tsx"; | ||||||
|  | import { WellKnownProvider } from "./stores/wellKnown.tsx"; | ||||||
|  |  | ||||||
| const root = document.getElementById("root"); | const root = document.getElementById("root"); | ||||||
|  |  | ||||||
| render(() => ( | render(() => ( | ||||||
|   <Router root={RootLayout}> |   <WellKnownProvider> | ||||||
|     <Route path="/" component={lazy(() => import("./pages/dashboard.tsx"))} /> |     <UserinfoProvider> | ||||||
|     <Route path="/auth/login" component={lazy(() => import("./pages/auth/login.tsx"))} /> |       <Router root={RootLayout}> | ||||||
|     <Route path="/auth/register" component={lazy(() => import("./pages/auth/register.tsx"))} /> |         <Route path="/" component={lazy(() => import("./pages/dashboard.tsx"))} /> | ||||||
|     <Route path="/users/me/confirm" component={lazy(() => import("./pages/users/confirm.tsx"))} /> |         <Route path="/auth/login" component={lazy(() => import("./pages/auth/login.tsx"))} /> | ||||||
|   </Router> |         <Route path="/auth/register" component={lazy(() => import("./pages/auth/register.tsx"))} /> | ||||||
|  |         <Route path="/users/me/confirm" component={lazy(() => import("./pages/users/confirm.tsx"))} /> | ||||||
|  |       </Router> | ||||||
|  |     </UserinfoProvider> | ||||||
|  |   </WellKnownProvider> | ||||||
| ), root!); | ), root!); | ||||||
|   | |||||||
| @@ -1,11 +1,12 @@ | |||||||
| import Navbar from "./shared/Navbar.tsx"; | import Navbar from "./shared/Navbar.tsx"; | ||||||
| import { readProfiles, UserinfoProvider } from "../stores/userinfo.tsx"; | import { readProfiles } from "../stores/userinfo.tsx"; | ||||||
| import { createSignal, Show } from "solid-js"; | import { createSignal, Show } from "solid-js"; | ||||||
|  | import { readWellKnown } from "../stores/wellKnown.tsx"; | ||||||
|  |  | ||||||
| export default function RootLayout(props: any) { | export default function RootLayout(props: any) { | ||||||
|   const [ready, setReady] = createSignal(false); |   const [ready, setReady] = createSignal(false); | ||||||
|  |  | ||||||
|   readProfiles().then(() => setReady(true)); |   Promise.all([readWellKnown(), readProfiles()]).then(() => setReady(true)); | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <Show when={ready()} fallback={ |     <Show when={ready()} fallback={ | ||||||
| @@ -15,11 +16,8 @@ export default function RootLayout(props: any) { | |||||||
|         </div> |         </div> | ||||||
|       </div> |       </div> | ||||||
|     }> |     }> | ||||||
|       <UserinfoProvider> |       <Navbar /> | ||||||
|         <Navbar /> |       <main class="h-[calc(100vh-68px)] mt-[68px]">{props.children}</main> | ||||||
|  |  | ||||||
|         <main class="h-[calc(100vh-68px)]">{props.children}</main> |  | ||||||
|       </UserinfoProvider> |  | ||||||
|     </Show> |     </Show> | ||||||
|   ); |   ); | ||||||
| } | } | ||||||
| @@ -1,6 +1,7 @@ | |||||||
| import { For, Match, Switch } from "solid-js"; | import { For, Match, Switch } from "solid-js"; | ||||||
| import { clearUserinfo, useUserinfo } from "../../stores/userinfo.tsx"; | import { clearUserinfo, useUserinfo } from "../../stores/userinfo.tsx"; | ||||||
| import { useNavigate } from "@solidjs/router"; | import { useNavigate } from "@solidjs/router"; | ||||||
|  | import { useWellKnown } from "../../stores/wellKnown.tsx"; | ||||||
|  |  | ||||||
| interface MenuItem { | interface MenuItem { | ||||||
|   label: string; |   label: string; | ||||||
| @@ -10,6 +11,7 @@ interface MenuItem { | |||||||
| export default function Navbar() { | export default function Navbar() { | ||||||
|   const nav: MenuItem[] = [{ label: "Dashboard", href: "/" }]; |   const nav: MenuItem[] = [{ label: "Dashboard", href: "/" }]; | ||||||
|  |  | ||||||
|  |   const wellKnown = useWellKnown(); | ||||||
|   const userinfo = useUserinfo(); |   const userinfo = useUserinfo(); | ||||||
|   const navigate = useNavigate(); |   const navigate = useNavigate(); | ||||||
|  |  | ||||||
| @@ -19,7 +21,7 @@ export default function Navbar() { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <div class="navbar bg-base-100 shadow-md px-5"> |     <div class="navbar bg-base-100 shadow-md px-5 z-10 fixed top-0"> | ||||||
|       <div class="navbar-start"> |       <div class="navbar-start"> | ||||||
|         <div class="dropdown"> |         <div class="dropdown"> | ||||||
|           <div tabIndex={0} role="button" class="btn btn-ghost lg:hidden"> |           <div tabIndex={0} role="button" class="btn btn-ghost lg:hidden"> | ||||||
| @@ -52,7 +54,7 @@ export default function Navbar() { | |||||||
|           </ul> |           </ul> | ||||||
|         </div> |         </div> | ||||||
|         <a href="/" class="btn btn-ghost text-xl"> |         <a href="/" class="btn btn-ghost text-xl"> | ||||||
|           Goatpass |           {wellKnown?.name ?? "Goatpass"} | ||||||
|         </a> |         </a> | ||||||
|       </div> |       </div> | ||||||
|       <div class="navbar-center hidden lg:flex"> |       <div class="navbar-center hidden lg:flex"> | ||||||
|   | |||||||
| @@ -50,7 +50,7 @@ export default function LoginPage() { | |||||||
|       if (!data.factor) return; |       if (!data.factor) return; | ||||||
|  |  | ||||||
|       setLoading(true); |       setLoading(true); | ||||||
|       const res = await fetch(`/api/auth/factors/${data.id}`, { |       const res = await fetch(`/api/auth/factors/${data.factor}`, { | ||||||
|         method: "POST" |         method: "POST" | ||||||
|       }); |       }); | ||||||
|       if (res.status !== 200 && res.status !== 204) { |       if (res.status !== 200 && res.status !== 204) { | ||||||
| @@ -93,6 +93,7 @@ export default function LoginPage() { | |||||||
|           setStage("choosing"); |           setStage("choosing"); | ||||||
|           setTitle("Continue verifying"); |           setTitle("Continue verifying"); | ||||||
|           setSubtitle("You passed one check, but that's not enough."); |           setSubtitle("You passed one check, but that's not enough."); | ||||||
|  |           setChallenge(data["challenge"]); | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
|       setLoading(false); |       setLoading(false); | ||||||
| @@ -120,10 +121,17 @@ export default function LoginPage() { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   function getFactorAvailable(factor: any) { | ||||||
|  |     const blacklist: number[] = challenge()?.blacklist_factors ?? []; | ||||||
|  |     return blacklist.includes(factor.id); | ||||||
|  |   } | ||||||
|  |  | ||||||
|   function getFactorName(factor: any) { |   function getFactorName(factor: any) { | ||||||
|     switch (factor.type) { |     switch (factor.type) { | ||||||
|       case 0: |       case 0: | ||||||
|         return "Password Verification"; |         return "Password Verification"; | ||||||
|  |       case 1: | ||||||
|  |         return "Email Verification Code"; | ||||||
|       default: |       default: | ||||||
|         return "Unknown"; |         return "Unknown"; | ||||||
|     } |     } | ||||||
| @@ -171,6 +179,7 @@ export default function LoginPage() { | |||||||
|                       {item => |                       {item => | ||||||
|                         <input class="join-item btn" type="radio" name="factor" |                         <input class="join-item btn" type="radio" name="factor" | ||||||
|                                value={item.id} |                                value={item.id} | ||||||
|  |                                disabled={getFactorAvailable(item)} | ||||||
|                                aria-label={getFactorName(item)} |                                aria-label={getFactorName(item)} | ||||||
|                         /> |                         /> | ||||||
|                       } |                       } | ||||||
|   | |||||||
| @@ -1,4 +1,5 @@ | |||||||
| import { createSignal, Show } from "solid-js"; | import { createSignal, Show } from "solid-js"; | ||||||
|  | import { useWellKnown } from "../../stores/wellKnown.tsx"; | ||||||
|  |  | ||||||
| export default function RegisterPage() { | export default function RegisterPage() { | ||||||
|   const [title, setTitle] = createSignal("Create an account"); |   const [title, setTitle] = createSignal("Create an account"); | ||||||
| @@ -8,6 +9,8 @@ export default function RegisterPage() { | |||||||
|   const [loading, setLoading] = createSignal(false); |   const [loading, setLoading] = createSignal(false); | ||||||
|   const [done, setDone] = createSignal(false); |   const [done, setDone] = createSignal(false); | ||||||
|  |  | ||||||
|  |   const metadata = useWellKnown(); | ||||||
|  |  | ||||||
|   async function submit(evt: SubmitEvent) { |   async function submit(evt: SubmitEvent) { | ||||||
|     evt.preventDefault(); |     evt.preventDefault(); | ||||||
|  |  | ||||||
| @@ -23,6 +26,7 @@ export default function RegisterPage() { | |||||||
|     if (res.status !== 200) { |     if (res.status !== 200) { | ||||||
|       setError(await res.text()); |       setError(await res.text()); | ||||||
|     } else { |     } else { | ||||||
|  |       setError(null); | ||||||
|       setTitle("Congratulations!"); |       setTitle("Congratulations!"); | ||||||
|       setSubtitle("Your account has been created and activation email has sent to your inbox!"); |       setSubtitle("Your account has been created and activation email has sent to your inbox!"); | ||||||
|       setDone(true); |       setDone(true); | ||||||
| @@ -32,83 +36,100 @@ export default function RegisterPage() { | |||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <div class="w-full h-full flex justify-center items-center"> |     <div class="w-full h-full flex justify-center items-center"> | ||||||
|       <div class="card w-[480px] max-w-screen shadow-xl"> |       <div> | ||||||
|         <div class="card-body"> |         <div class="card w-[480px] max-w-screen shadow-xl"> | ||||||
|           <div id="header" class="text-center mb-5"> |           <div class="card-body"> | ||||||
|             <h1 class="text-xl font-bold">{title()}</h1> |             <div id="header" class="text-center mb-5"> | ||||||
|             <p>{subtitle()}</p> |               <h1 class="text-xl font-bold">{title()}</h1> | ||||||
|           </div> |               <p>{subtitle()}</p> | ||||||
|  |             </div> | ||||||
|  |  | ||||||
|           <Show when={error()}> |             <Show when={error()}> | ||||||
|             <div id="alerts" class="mt-1"> |               <div id="alerts" class="mt-1"> | ||||||
|               <div role="alert" class="alert alert-error"> |                 <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" |                   <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" | ||||||
|                      viewBox="0 0 24 24"> |                        viewBox="0 0 24 24"> | ||||||
|                   <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" |                     <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" /> |                           d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /> | ||||||
|                 </svg> |                   </svg> | ||||||
|                 <span class="capitalize">{error()}</span> |                   <span class="capitalize">{error()}</span> | ||||||
|  |                 </div> | ||||||
|               </div> |               </div> | ||||||
|             </div> |             </Show> | ||||||
|           </Show> |  | ||||||
|  |  | ||||||
|           <Show when={!done()}> |             <Show when={!done()}> | ||||||
|             <form id="form" onSubmit={submit}> |               <form id="form" onSubmit={submit}> | ||||||
|               <label class="form-control w-full"> |                 <label class="form-control w-full"> | ||||||
|                 <div class="label"> |                   <div class="label"> | ||||||
|                   <span class="label-text">Username</span> |                     <span class="label-text">Username</span> | ||||||
|                   <span class="label-text-alt font-bold">Cannot be modify</span> |                     <span class="label-text-alt font-bold">Cannot be modify</span> | ||||||
|                 </div> |                   </div> | ||||||
|                 <input name="name" type="text" placeholder="Type here" class="input input-bordered w-full" /> |                   <input name="name" type="text" placeholder="Type here" class="input input-bordered w-full" /> | ||||||
|                 <div class="label"> |                   <div class="label"> | ||||||
|                   <span class="label-text-alt">Lowercase alphabet and numbers only, maximum 16 characters</span> |                     <span class="label-text-alt">Lowercase alphabet and numbers only, maximum 16 characters</span> | ||||||
|                 </div> |                   </div> | ||||||
|               </label> |                 </label> | ||||||
|               <label class="form-control w-full"> |                 <label class="form-control w-full"> | ||||||
|                 <div class="label"> |                   <div class="label"> | ||||||
|                   <span class="label-text">Nickname</span> |                     <span class="label-text">Nickname</span> | ||||||
|                 </div> |                   </div> | ||||||
|                 <input name="nick" type="text" placeholder="Type here" class="input input-bordered w-full" /> |                   <input name="nick" type="text" placeholder="Type here" class="input input-bordered w-full" /> | ||||||
|                 <div class="label"> |                   <div class="label"> | ||||||
|                   <span class="label-text-alt">Maximum length is 24 characters</span> |                     <span class="label-text-alt">Maximum length is 24 characters</span> | ||||||
|                 </div> |                   </div> | ||||||
|               </label> |                 </label> | ||||||
|               <label class="form-control w-full"> |                 <label class="form-control w-full"> | ||||||
|                 <div class="label"> |                   <div class="label"> | ||||||
|                   <span class="label-text">Email Address</span> |                     <span class="label-text">Email Address</span> | ||||||
|                 </div> |                   </div> | ||||||
|                 <input name="email" type="email" placeholder="Type here" class="input input-bordered w-full" /> |                   <input name="email" type="email" placeholder="Type here" class="input input-bordered w-full" /> | ||||||
|                 <div class="label"> |                   <div class="label"> | ||||||
|                   <span class="label-text-alt">Do not accept address with plus sign</span> |                     <span class="label-text-alt">Do not accept address with plus sign</span> | ||||||
|                 </div> |                   </div> | ||||||
|               </label> |                 </label> | ||||||
|               <label class="form-control w-full"> |                 <label class="form-control w-full"> | ||||||
|                 <div class="label"> |                   <div class="label"> | ||||||
|                   <span class="label-text">Password</span> |                     <span class="label-text">Password</span> | ||||||
|                 </div> |                   </div> | ||||||
|                 <input name="password" type="password" placeholder="Type here" class="input input-bordered w-full" /> |                   <input name="password" type="password" placeholder="Type here" | ||||||
|                 <div class="label"> |                          class="input input-bordered w-full" /> | ||||||
|                   <span class="label-text-alt">Must be secure</span> |                   <div class="label"> | ||||||
|                 </div> |                     <span class="label-text-alt">Must be secure</span> | ||||||
|               </label> |                   </div> | ||||||
|  |                 </label> | ||||||
|               <button type="submit" class="btn btn-primary btn-block mt-3" disabled={loading()}> |                 <Show when={!metadata?.open_registration}> | ||||||
|                 <Show when={loading()} fallback={"Next"}> |                   <label class="form-control w-full"> | ||||||
|                   <span class="loading loading-spinner"></span> |                     <div class="label"> | ||||||
|  |                       <span class="label-text">Magic Token</span> | ||||||
|  |                     </div> | ||||||
|  |                     <input name="magic_token" type="password" placeholder="Type here" | ||||||
|  |                            class="input input-bordered w-full" /> | ||||||
|  |                     <div class="label"> | ||||||
|  |                       <span class="label-text-alt"> | ||||||
|  |                         This server enabled invitation only, so you need a magic token. | ||||||
|  |                       </span> | ||||||
|  |                     </div> | ||||||
|  |                   </label> | ||||||
|                 </Show> |                 </Show> | ||||||
|               </button> |  | ||||||
|             </form> |  | ||||||
|           </Show> |  | ||||||
|  |  | ||||||
|           <Show when={done()}> |                 <button type="submit" class="btn btn-primary btn-block mt-3" disabled={loading()}> | ||||||
|             <div class="py-12 text-center"> |                   <Show when={loading()} fallback={"Next"}> | ||||||
|               <h2 class="text-lg font-bold">What's next?</h2> |                     <span class="loading loading-spinner"></span> | ||||||
|               <span> |                   </Show> | ||||||
|  |                 </button> | ||||||
|  |               </form> | ||||||
|  |             </Show> | ||||||
|  |  | ||||||
|  |             <Show when={done()}> | ||||||
|  |               <div class="py-12 text-center"> | ||||||
|  |                 <h2 class="text-lg font-bold">What's next?</h2> | ||||||
|  |                 <span> | ||||||
|                 <a href="/auth/login" class="link">Go login</a>{" "} |                 <a href="/auth/login" class="link">Go login</a>{" "} | ||||||
|                 then you can take part in the entire smartsheep community. |                   then you can take part in the entire smartsheep community. | ||||||
|               </span> |               </span> | ||||||
|             </div> |               </div> | ||||||
|           </Show> |             </Show> | ||||||
|  |           </div> | ||||||
|         </div> |         </div> | ||||||
|  |  | ||||||
|         <div class="text-sm text-center mt-3"> |         <div class="text-sm text-center mt-3"> | ||||||
|   | |||||||
| @@ -1,5 +1,6 @@ | |||||||
| import { createSignal, Show } from "solid-js"; | import { createSignal, Show } from "solid-js"; | ||||||
| import { useSearchParams } from "@solidjs/router"; | import { useNavigate, useSearchParams } from "@solidjs/router"; | ||||||
|  | import { readProfiles } from "../../stores/userinfo.tsx"; | ||||||
|  |  | ||||||
| export default function ConfirmRegistrationPage() { | export default function ConfirmRegistrationPage() { | ||||||
|   const [error, setError] = createSignal<string | null>(null); |   const [error, setError] = createSignal<string | null>(null); | ||||||
| @@ -7,6 +8,8 @@ export default function ConfirmRegistrationPage() { | |||||||
|  |  | ||||||
|   const [searchParams] = useSearchParams(); |   const [searchParams] = useSearchParams(); | ||||||
|  |  | ||||||
|  |   const navigate = useNavigate(); | ||||||
|  |  | ||||||
|   async function doConfirm() { |   async function doConfirm() { | ||||||
|     if (!searchParams["tk"]) { |     if (!searchParams["tk"]) { | ||||||
|       setError("Bad Request: Code was not exists"); |       setError("Bad Request: Code was not exists"); | ||||||
| @@ -23,6 +26,8 @@ export default function ConfirmRegistrationPage() { | |||||||
|       setError(await res.text()); |       setError(await res.text()); | ||||||
|     } else { |     } else { | ||||||
|       setStatus("Confirmed. Redirecting to dashboard..."); |       setStatus("Confirmed. Redirecting to dashboard..."); | ||||||
|  |       await readProfiles(); | ||||||
|  |       navigate("/"); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										23
									
								
								view/src/stores/wellKnown.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								view/src/stores/wellKnown.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | |||||||
|  | import { createContext, useContext } from "solid-js"; | ||||||
|  | import { createStore } from "solid-js/store"; | ||||||
|  |  | ||||||
|  | const WellKnownContext = createContext<any>(); | ||||||
|  |  | ||||||
|  | const [wellKnown, setWellKnown] = createStore<any>(null); | ||||||
|  |  | ||||||
|  | export async function readWellKnown() { | ||||||
|  |   const res = await fetch("/.well-known") | ||||||
|  |   setWellKnown(await res.json()) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export function WellKnownProvider(props: any) { | ||||||
|  |   return ( | ||||||
|  |     <WellKnownContext.Provider value={wellKnown}> | ||||||
|  |       {props.children} | ||||||
|  |     </WellKnownContext.Provider> | ||||||
|  |   ); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export function useWellKnown() { | ||||||
|  |   return useContext(WellKnownContext); | ||||||
|  | } | ||||||
| @@ -6,7 +6,8 @@ export default defineConfig({ | |||||||
|   plugins: [devtools({ autoname: true }), solid()], |   plugins: [devtools({ autoname: true }), solid()], | ||||||
|   server: { |   server: { | ||||||
|     proxy: { |     proxy: { | ||||||
|       "/api": "http://localhost:8444" |       "/api": "http://localhost:8444", | ||||||
|  |       "/.well-known": "http://localhost:8444" | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| }); | }); | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user