Pick up the single-page application as frontend

This commit is contained in:
2024-06-24 23:06:20 +08:00
parent 86b2cd8140
commit 1cf675b23a
65 changed files with 2257 additions and 1410 deletions

View File

@ -1,6 +0,0 @@
package pkg
import "embed"
//go:embed all:views/*
var FS embed.FS

View File

@ -17,27 +17,31 @@ type Server struct {
proto.UnimplementedFriendshipsServer
proto.UnimplementedRealmsServer
health.UnimplementedHealthServer
srv *grpc.Server
}
var S *grpc.Server
func NewServer() *Server {
server := &Server{
srv: grpc.NewServer(),
}
func NewGRPC() {
S = grpc.NewServer()
proto.RegisterAuthServer(server.srv, &Server{})
proto.RegisterNotifyServer(server.srv, &Server{})
proto.RegisterFriendshipsServer(server.srv, &Server{})
proto.RegisterRealmsServer(server.srv, &Server{})
health.RegisterHealthServer(server.srv, &Server{})
proto.RegisterAuthServer(S, &Server{})
proto.RegisterNotifyServer(S, &Server{})
proto.RegisterFriendshipsServer(S, &Server{})
proto.RegisterRealmsServer(S, &Server{})
health.RegisterHealthServer(S, &Server{})
reflection.Register(server.srv)
reflection.Register(S)
return server
}
func ListenGRPC() error {
func (v *Server) Listen() error {
listener, err := net.Listen("tcp", viper.GetString("grpc_bind"))
if err != nil {
return err
}
return S.Serve(listener)
return v.srv.Serve(listener)
}

View File

@ -85,5 +85,9 @@ func MapAPIs(app *fiber.App) {
}
return c.Next()
}).Get("/ws", websocket.New(listenWebsocket))
api.All("/*", func(c *fiber.Ctx) error {
return fiber.ErrNotFound
})
}
}

View File

@ -1,32 +1,31 @@
package server
import (
"git.solsynth.dev/hydrogen/passport/pkg/internal/server/admin"
"git.solsynth.dev/hydrogen/passport/pkg/internal/server/api"
"git.solsynth.dev/hydrogen/passport/pkg/internal/server/exts"
"github.com/gofiber/fiber/v2/middleware/filesystem"
"net/http"
"path/filepath"
"strings"
"git.solsynth.dev/hydrogen/passport/pkg/internal"
"git.solsynth.dev/hydrogen/passport/pkg/internal/i18n"
"git.solsynth.dev/hydrogen/passport/pkg/internal/server/admin"
"git.solsynth.dev/hydrogen/passport/pkg/internal/server/ui"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/gofiber/fiber/v2/middleware/favicon"
"github.com/gofiber/fiber/v2/middleware/idempotency"
"github.com/gofiber/fiber/v2/middleware/logger"
"github.com/gofiber/template/html/v2"
jsoniter "github.com/json-iterator/go"
"github.com/rs/zerolog/log"
"github.com/spf13/viper"
)
var A *fiber.App
type HTTPApp struct {
app *fiber.App
}
func NewServer() {
templates := html.NewFileSystem(http.FS(pkg.FS), ".gohtml")
A = fiber.New(fiber.Config{
func NewServer() *HTTPApp {
app := fiber.New(fiber.Config{
DisableStartupMessage: true,
EnableIPValidation: true,
ServerHeader: "Hydrogen.Passport",
@ -35,12 +34,10 @@ func NewServer() {
JSONEncoder: jsoniter.ConfigCompatibleWithStandardLibrary.Marshal,
JSONDecoder: jsoniter.ConfigCompatibleWithStandardLibrary.Unmarshal,
EnablePrintRoutes: viper.GetBool("debug.print_routes"),
Views: templates,
ViewsLayout: "views/index",
})
A.Use(idempotency.New())
A.Use(cors.New(cors.Config{
app.Use(idempotency.New())
app.Use(cors.New(cors.Config{
AllowCredentials: true,
AllowMethods: strings.Join([]string{
fiber.MethodGet,
@ -56,27 +53,34 @@ func NewServer() {
},
}))
A.Use(logger.New(logger.Config{
app.Use(logger.New(logger.Config{
Format: "${status} | ${latency} | ${method} ${path}\n",
Output: log.Logger,
}))
A.Use(exts.AuthMiddleware)
A.Use(i18n.I18nMiddleware)
app.Use(exts.AuthMiddleware)
app.Use(i18n.I18nMiddleware)
A.Use(favicon.New(favicon.Config{
FileSystem: http.FS(pkg.FS),
File: "views/favicon.png",
URL: "/favicon.png",
api.MapAPIs(app)
admin.MapAdminEndpoints(app)
app.Use(filesystem.New(filesystem.Config{
Root: http.Dir(viper.GetString("frontend_app")),
Index: "index.html",
NotFoundFile: "index.html",
MaxAge: 3600,
}))
api.MapAPIs(A)
admin.MapAdminEndpoints(A)
ui.MapUserInterface(A)
app.Use(favicon.New(favicon.Config{
File: filepath.Join(viper.GetString("frontend_app"), "favicon.png"),
URL: "/favicon.png",
}))
return &HTTPApp{app}
}
func Listen() {
if err := A.Listen(viper.GetString("bind")); err != nil {
func (v *HTTPApp) Listen() {
if err := v.app.Listen(viper.GetString("bind")); err != nil {
log.Fatal().Err(err).Msg("An error occurred when starting server...")
}
}

View File

@ -1,55 +0,0 @@
package ui
import (
"fmt"
"git.solsynth.dev/hydrogen/passport/pkg/internal/server/exts"
"html/template"
"time"
"git.solsynth.dev/hydrogen/passport/pkg/internal/database"
"git.solsynth.dev/hydrogen/passport/pkg/internal/models"
"github.com/gofiber/fiber/v2"
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/html"
"github.com/gomarkdown/markdown/parser"
"github.com/sujit-baniya/flash"
)
func selfUserinfoPage(c *fiber.Ctx) error {
if err := exts.EnsureAuthenticated(c); err != nil {
return DoAuthRedirect(c)
}
user := c.Locals("user").(models.Account)
var data models.Account
if err := database.C.
Where(&models.Account{BaseModel: models.BaseModel{ID: user.ID}}).
Preload("Profile").
Preload("PersonalPage").
Preload("Contacts").
First(&data).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
birthday := "Unknown"
if data.Profile.Birthday != nil {
birthday = data.Profile.Birthday.Format(time.RFC822)
}
doc := parser.
NewWithExtensions(parser.CommonExtensions | parser.AutoHeadingIDs | parser.NoEmptyLineBeforeBlock).
Parse([]byte(data.PersonalPage.Content))
renderer := html.NewRenderer(html.RendererOptions{Flags: html.CommonFlags | html.HrefTargetBlank})
return c.Render("views/users/me", fiber.Map{
"info": flash.Get(c)["message"],
"uid": fmt.Sprintf("%08d", data.ID),
"joined_at": data.CreatedAt.Format(time.RFC822),
"birthday_at": birthday,
"personal_page": template.HTML(markdown.Render(doc, renderer)),
"userinfo": data,
"avatar": data.GetAvatar(),
"banner": data.GetBanner(),
}, "views/layouts/user-center")
}

View File

@ -1,34 +0,0 @@
package ui
import (
"fmt"
"github.com/gofiber/fiber/v2"
)
func DoAuthRedirect(c *fiber.Ctx) error {
uri := c.Request().URI().FullURI()
return c.Redirect(fmt.Sprintf("/sign-in?redirect_uri=%s", string(uri)))
}
func MapUserInterface(app *fiber.App) {
pages := app.Group("/").Name("Pages")
pages.Get("/", func(c *fiber.Ctx) error {
return c.Redirect("/users/me")
})
pages.Get("/sign-up", signupPage)
pages.Get("/sign-in", signinPage)
pages.Get("/mfa", mfaRequestPage)
pages.Get("/mfa/apply", mfaApplyPage)
pages.Get("/authorize", authorizePage)
pages.Post("/sign-up", signupAction)
pages.Post("/sign-in", signinAction)
pages.Post("/mfa", mfaRequestAction)
pages.Post("/mfa/apply", mfaApplyAction)
pages.Post("/authorize", authorizeAction)
pages.Get("/users/me", selfUserinfoPage)
}

View File

@ -1,194 +0,0 @@
package ui
import (
"fmt"
"git.solsynth.dev/hydrogen/passport/pkg/internal/models"
"git.solsynth.dev/hydrogen/passport/pkg/internal/server/exts"
"git.solsynth.dev/hydrogen/passport/pkg/internal/services"
"github.com/gofiber/fiber/v2"
"github.com/nicksnyder/go-i18n/v2/i18n"
"github.com/samber/lo"
"github.com/sujit-baniya/flash"
)
func mfaRequestPage(c *fiber.Ctx) error {
ticketId := c.QueryInt("ticket", 0)
ticket, err := services.GetTicket(uint(ticketId))
if err != nil {
return flash.WithInfo(c, fiber.Map{
"message": "you must provide ticket id to perform multi-factor authenticate",
}).Redirect("/sign-in")
}
user, err := services.GetAccount(ticket.AccountID)
if err != nil {
return flash.WithInfo(c, fiber.Map{
"message": "ticket related user just weirdly disappear",
}).Redirect("/sign-in")
}
factors, err := services.ListUserFactor(user.ID)
if err != nil {
return flash.WithInfo(c, fiber.Map{
"message": fmt.Sprintf("unable to get your factors: %v", err.Error()),
}).Redirect("/sign-in")
}
factors = lo.Filter(factors, func(item models.AuthFactor, index int) bool {
return item.Type != models.PasswordAuthFactor
})
localizer := c.Locals("localizer").(*i18n.Localizer)
next, _ := localizer.LocalizeMessage(&i18n.Message{ID: "next"})
title, _ := localizer.LocalizeMessage(&i18n.Message{ID: "mfaTitle"})
caption, _ := localizer.LocalizeMessage(&i18n.Message{ID: "mfaCaption"})
return c.Render("views/mfa", fiber.Map{
"info": flash.Get(c)["message"],
"redirect_uri": flash.Get(c)["redirect_uri"],
"ticket_id": ticket.ID,
"factors": lo.Map(factors, func(item models.AuthFactor, index int) fiber.Map {
return fiber.Map{
"name": services.GetFactorName(item.Type, localizer),
"id": item.ID,
}
}),
"i18n": fiber.Map{
"next": next,
"title": title,
"caption": caption,
},
}, "views/layouts/auth")
}
func mfaRequestAction(c *fiber.Ctx) error {
var data struct {
TicketID uint `form:"ticket_id" validate:"required"`
FactorID uint `form:"factor_id" validate:"required"`
}
redirectBackUri := "/sign-in"
err := exts.BindAndValidate(c, &data)
if data.TicketID > 0 {
redirectBackUri = fmt.Sprintf("/mfa?ticket=%d", data.TicketID)
}
if err != nil {
return flash.WithInfo(c, fiber.Map{
"message": err.Error(),
}).Redirect(redirectBackUri)
}
factor, err := services.GetFactor(data.FactorID)
if err != nil {
return flash.WithInfo(c, fiber.Map{
"message": fmt.Sprintf("factor was not found: %v", err.Error()),
}).Redirect(redirectBackUri)
}
_, err = services.GetFactorCode(factor)
if err != nil {
return flash.WithInfo(c, fiber.Map{
"message": fmt.Sprintf("unable to get factor code: %v", err.Error()),
}).Redirect(redirectBackUri)
}
return flash.WithData(c, fiber.Map{
"redirect_uri": exts.GetRedirectUri(c),
}).Redirect(fmt.Sprintf("/mfa/apply?ticket=%d&factor=%d", data.TicketID, factor.ID))
}
func mfaApplyPage(c *fiber.Ctx) error {
ticketId := c.QueryInt("ticket", 0)
factorId := c.QueryInt("factor", 0)
ticket, err := services.GetTicket(uint(ticketId))
if err != nil {
return flash.WithInfo(c, fiber.Map{
"message": fmt.Sprintf("unable to find your ticket: %v", err.Error()),
}).Redirect("/sign-in")
}
factor, err := services.GetFactor(uint(factorId))
if err != nil {
return flash.WithInfo(c, fiber.Map{
"message": fmt.Sprintf("unable to find your factors: %v", err.Error()),
}).Redirect("/sign-in")
}
localizer := c.Locals("localizer").(*i18n.Localizer)
next, _ := localizer.LocalizeMessage(&i18n.Message{ID: "next"})
password, _ := localizer.LocalizeMessage(&i18n.Message{ID: "password"})
title, _ := localizer.LocalizeMessage(&i18n.Message{ID: "mfaTitle"})
caption, _ := localizer.LocalizeMessage(&i18n.Message{ID: "mfaCaption"})
return c.Render("views/mfa-apply", fiber.Map{
"info": flash.Get(c)["message"],
"label": services.GetFactorName(factor.Type, localizer),
"ticket_id": ticket.ID,
"factor_id": factor.ID,
"i18n": fiber.Map{
"next": next,
"password": password,
"title": title,
"caption": caption,
},
}, "views/layouts/auth")
}
func mfaApplyAction(c *fiber.Ctx) error {
var data struct {
TicketID uint `form:"ticket_id" validate:"required"`
FactorID uint `form:"factor_id" validate:"required"`
Code string `form:"code" validate:"required"`
}
redirectBackUri := "/sign-in"
err := exts.BindAndValidate(c, &data)
if data.TicketID > 0 {
redirectBackUri = fmt.Sprintf("/mfa/apply?ticket=%d&factor=%d", data.TicketID, data.FactorID)
}
if err != nil {
return flash.WithInfo(c, fiber.Map{
"message": err.Error(),
}).Redirect(redirectBackUri)
}
ticket, err := services.GetTicket(data.TicketID)
if err != nil {
return flash.WithInfo(c, fiber.Map{
"message": fmt.Sprintf("unable to find your ticket: %v", err.Error()),
}).Redirect("/sign-in")
}
factor, err := services.GetFactor(data.FactorID)
if err != nil {
return flash.WithInfo(c, fiber.Map{
"message": fmt.Sprintf("factor was not found: %v", err.Error()),
}).Redirect(redirectBackUri)
}
ticket, err = services.ActiveTicketWithMFA(ticket, factor, data.Code)
if err != nil {
return flash.WithInfo(c, fiber.Map{
"message": fmt.Sprintf("invalid multi-factor authenticate code: %v", err.Error()),
}).Redirect(redirectBackUri)
} else if ticket.IsAvailable() != nil {
return flash.WithInfo(c, fiber.Map{
"message": "ticket weirdly still unavailable after multi-factor authenticate",
}).Redirect("/sign-in")
}
access, refresh, err := services.ExchangeToken(*ticket.GrantToken)
if err != nil {
return flash.WithInfo(c, fiber.Map{
"message": fmt.Sprintf("failed to exchange token: %v", err.Error()),
}).Redirect("/sign-in")
} else {
exts.SetAuthCookies(c, access, refresh)
}
return c.Redirect(lo.FromPtr(exts.GetRedirectUri(c, "/users/me")))
}

View File

@ -1,166 +0,0 @@
package ui
import (
"fmt"
"git.solsynth.dev/hydrogen/passport/pkg/internal/database"
"git.solsynth.dev/hydrogen/passport/pkg/internal/models"
"git.solsynth.dev/hydrogen/passport/pkg/internal/server/exts"
"git.solsynth.dev/hydrogen/passport/pkg/internal/services"
"github.com/gofiber/fiber/v2"
"github.com/nicksnyder/go-i18n/v2/i18n"
"github.com/samber/lo"
"github.com/sujit-baniya/flash"
"html/template"
"strings"
"time"
)
func authorizePage(c *fiber.Ctx) error {
localizer := c.Locals("localizer").(*i18n.Localizer)
if err := exts.EnsureAuthenticated(c); err != nil {
return DoAuthRedirect(c)
}
user := c.Locals("user").(models.Account)
id := c.Query("client_id")
redirect := c.Query("redirect_uri")
var message string
if len(id) <= 0 || len(redirect) <= 0 {
message = "invalid request, missing query parameters"
}
var client models.ThirdClient
if err := database.C.Where(&models.ThirdClient{Alias: id}).First(&client).Error; err != nil {
message = fmt.Sprintf("unable to find client: %v", err)
} else if !client.IsDraft && !lo.Contains(client.Callbacks, strings.Split(redirect, "?")[0]) {
message = "invalid callback url"
}
var ticket models.AuthTicket
if err := database.C.Where(&models.AuthTicket{
AccountID: user.ID,
ClientID: &client.ID,
}).Where("last_grant_at IS NULL").First(&ticket).Error; err == nil {
if !(ticket.ExpiredAt != nil && ticket.ExpiredAt.Unix() < time.Now().Unix()) {
ticket, err = services.RegenSession(ticket)
if c.Query("response_type") == "code" {
return c.Redirect(fmt.Sprintf(
"%s?code=%s&state=%s",
redirect,
*ticket.GrantToken,
c.Query("state"),
))
} else if c.Query("response_type") == "token" {
if access, refresh, err := services.GetToken(ticket); err == nil {
return c.Redirect(fmt.Sprintf("%s?access_token=%s&refresh_token=%s&state=%s",
redirect,
access,
refresh, c.Query("state"),
))
}
}
}
}
decline, _ := localizer.LocalizeMessage(&i18n.Message{ID: "decline"})
approve, _ := localizer.LocalizeMessage(&i18n.Message{ID: "approve"})
title, _ := localizer.LocalizeMessage(&i18n.Message{ID: "authorizeTitle"})
caption, _ := localizer.LocalizeMessage(&i18n.Message{ID: "authorizeCaption"})
qs := "/authorize?" + string(c.Request().URI().QueryString())
return c.Render("views/authorize", fiber.Map{
"info": lo.Ternary[any](len(message) > 0, message, flash.Get(c)["message"]),
"client": client,
"scopes": strings.Split(c.Query("scope"), " "),
"action_url": template.URL(qs),
"i18n": fiber.Map{
"approve": approve,
"decline": decline,
"title": title,
"caption": caption,
},
}, "views/layouts/auth")
}
func authorizeAction(c *fiber.Ctx) error {
if err := exts.EnsureAuthenticated(c); err != nil {
return err
}
user := c.Locals("user").(models.Account)
id := c.Query("client_id")
response := c.Query("response_type")
redirect := c.Query("redirect_uri")
scope := c.Query("scope")
if err := exts.EnsureAuthenticated(c); err != nil {
return DoAuthRedirect(c)
}
redirectBackUri := "/authorize?" + string(c.Request().URI().QueryString())
if len(scope) <= 0 {
return flash.WithInfo(c, fiber.Map{
"message": "invalid request parameters",
}).Redirect(redirectBackUri)
}
var client models.ThirdClient
if err := database.C.Where(&models.ThirdClient{Alias: id}).First(&client).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
switch response {
case "code":
// OAuth Authorization Mode
ticket, err := services.NewOauthTicket(
user,
client,
strings.Split(scope, " "),
[]string{"passport", client.Alias},
c.IP(),
c.Get(fiber.HeaderUserAgent),
)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
} else {
services.AddEvent(user, "oauth.connect", client.Alias, c.IP(), c.Get(fiber.HeaderUserAgent))
return c.Redirect(fmt.Sprintf(
"%s?code=%s&state=%s",
redirect,
*ticket.GrantToken,
c.Query("state"),
))
}
case "token":
// OAuth Implicit Mode
ticket, err := services.NewOauthTicket(
user,
client,
strings.Split(scope, " "),
[]string{"passport", client.Alias},
c.IP(),
c.Get(fiber.HeaderUserAgent),
)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
} else if access, refresh, err := services.GetToken(ticket); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
} else {
services.AddEvent(user, "oauth.connect", client.Alias, c.IP(), c.Get(fiber.HeaderUserAgent))
return c.Redirect(fmt.Sprintf("%s?access_token=%s&refresh_token=%s&state=%s",
redirect,
access,
refresh, c.Query("state"),
))
}
default:
return flash.WithInfo(c, fiber.Map{
"message": "unsupported response type",
}).Redirect(redirectBackUri)
}
}

View File

@ -1,93 +0,0 @@
package ui
import (
"fmt"
"git.solsynth.dev/hydrogen/passport/pkg/internal/server/exts"
"git.solsynth.dev/hydrogen/passport/pkg/internal/services"
"github.com/gofiber/fiber/v2"
"github.com/nicksnyder/go-i18n/v2/i18n"
"github.com/samber/lo"
"github.com/sujit-baniya/flash"
)
func signinPage(c *fiber.Ctx) error {
localizer := c.Locals("localizer").(*i18n.Localizer)
next, _ := localizer.LocalizeMessage(&i18n.Message{ID: "next"})
username, _ := localizer.LocalizeMessage(&i18n.Message{ID: "username"})
password, _ := localizer.LocalizeMessage(&i18n.Message{ID: "password"})
signup, _ := localizer.LocalizeMessage(&i18n.Message{ID: "signupTitle"})
title, _ := localizer.LocalizeMessage(&i18n.Message{ID: "signinTitle"})
caption, _ := localizer.LocalizeMessage(&i18n.Message{ID: "signinCaption"})
requiredNotify, _ := localizer.LocalizeMessage(&i18n.Message{ID: "signinRequired"})
var info any
if flash.Get(c)["message"] != nil {
info = flash.Get(c)["message"]
} else {
info = requiredNotify
}
return c.Render("views/signin", fiber.Map{
"info": info,
"i18n": fiber.Map{
"next": next,
"username": username,
"password": password,
"signup": signup,
"title": title,
"caption": caption,
},
}, "views/layouts/auth")
}
func signinAction(c *fiber.Ctx) error {
var data struct {
Username string `form:"username" validate:"required"`
Password string `form:"password" validate:"required"`
}
if err := exts.BindAndValidate(c, &data); err != nil {
return flash.WithInfo(c, fiber.Map{
"message": err.Error(),
}).Redirect("/sign-in")
}
user, err := services.LookupAccount(data.Username)
if err != nil {
return flash.WithInfo(c, fiber.Map{
"message": fmt.Sprintf("account was not found: %v", err.Error()),
}).Redirect("/sign-in")
}
ticket, err := services.NewTicket(user, c.IP(), c.Get(fiber.HeaderUserAgent))
if err != nil {
return flash.WithInfo(c, fiber.Map{
"message": fmt.Sprintf("unable setup ticket: %v", err.Error()),
}).Redirect("/sign-in")
}
ticket, err = services.ActiveTicketWithPassword(ticket, data.Password)
if err != nil {
return flash.WithInfo(c, fiber.Map{
"message": fmt.Sprintf("invalid password: %v", err.Error()),
}).Redirect("/sign-in")
}
if ticket.IsAvailable() != nil {
return flash.WithData(c, fiber.Map{
"redirect_uri": exts.GetRedirectUri(c),
}).Redirect(fmt.Sprintf("/mfa?ticket=%d", ticket.ID))
}
access, refresh, err := services.ExchangeToken(*ticket.GrantToken)
if err != nil {
return flash.WithInfo(c, fiber.Map{
"message": fmt.Sprintf("failed to exchange token: %v", err.Error()),
}).Redirect("/sign-in")
} else {
exts.SetAuthCookies(c, access, refresh)
}
return c.Redirect(lo.FromPtr(exts.GetRedirectUri(c, "/users/me")))
}

View File

@ -1,87 +0,0 @@
package ui
import (
"fmt"
"git.solsynth.dev/hydrogen/passport/pkg/internal/database"
"git.solsynth.dev/hydrogen/passport/pkg/internal/models"
"git.solsynth.dev/hydrogen/passport/pkg/internal/server/exts"
"git.solsynth.dev/hydrogen/passport/pkg/internal/services"
"github.com/gofiber/fiber/v2"
"github.com/nicksnyder/go-i18n/v2/i18n"
"github.com/samber/lo"
"github.com/spf13/viper"
"github.com/sujit-baniya/flash"
)
func signupPage(c *fiber.Ctx) error {
localizer := c.Locals("localizer").(*i18n.Localizer)
next, _ := localizer.LocalizeMessage(&i18n.Message{ID: "next"})
email, _ := localizer.LocalizeMessage(&i18n.Message{ID: "email"})
nickname, _ := localizer.LocalizeMessage(&i18n.Message{ID: "nickname"})
username, _ := localizer.LocalizeMessage(&i18n.Message{ID: "username"})
password, _ := localizer.LocalizeMessage(&i18n.Message{ID: "password"})
magicToken, _ := localizer.LocalizeMessage(&i18n.Message{ID: "magicToken"})
signin, _ := localizer.LocalizeMessage(&i18n.Message{ID: "signinTitle"})
title, _ := localizer.LocalizeMessage(&i18n.Message{ID: "signupTitle"})
caption, _ := localizer.LocalizeMessage(&i18n.Message{ID: "signupCaption"})
return c.Render("views/signup", fiber.Map{
"info": flash.Get(c)["message"],
"use_magic_token": viper.GetBool("use_registration_magic_token"),
"i18n": fiber.Map{
"next": next,
"email": email,
"username": username,
"nickname": nickname,
"password": password,
"magic_token": magicToken,
"signin": signin,
"title": title,
"caption": caption,
},
}, "views/layouts/auth")
}
func signupAction(c *fiber.Ctx) error {
var data struct {
Name string `form:"name" validate:"required,lowercase,alphanum,min=4,max=16"`
Nick string `form:"nick" validate:"required,min=4,max=24"`
Email string `form:"email" validate:"required,email"`
Password string `form:"password" validate:"required,min=4,max=32"`
MagicToken string `form:"magic_token"`
}
if err := exts.BindAndValidate(c, &data); err != nil {
return flash.WithInfo(c, fiber.Map{
"message": err.Error(),
}).Redirect("/sign-up")
} else if viper.GetBool("use_registration_magic_token") && len(data.MagicToken) <= 0 {
return flash.WithInfo(c, fiber.Map{
"message": "magic token was required",
}).Redirect("/sign-up")
} else if viper.GetBool("use_registration_magic_token") {
if tk, err := services.ValidateMagicToken(data.MagicToken, models.RegistrationMagicToken); err != nil {
return flash.WithInfo(c, fiber.Map{
"message": fmt.Sprintf("magic token was invalid: %v", err.Error()),
}).Redirect("/sign-up")
} else {
database.C.Delete(&tk)
}
}
if _, err := services.CreateAccount(
data.Name,
data.Nick,
data.Email,
data.Password,
); err != nil {
return flash.WithInfo(c, fiber.Map{
"message": err.Error(),
}).Redirect("/sign-up")
} else {
return flash.WithInfo(c, fiber.Map{
"message": "account has been created. now you can sign in!",
}).Redirect(lo.FromPtr(exts.GetRedirectUri(c, "/sign-in")))
}
}

View File

@ -1,56 +0,0 @@
<div class="left-part">
<img class="logo" alt="Logo" src="/favicon.png" width="64" height="64" />
<h1 class="title">{{.i18n.title}} {{.client.Name}}</h1>
<p class="caption">{{.i18n.caption}}</p>
</div>
<div class="right-part">
<div class="responsive-title-gap "></div>
<form class="action-form" action="{{.action_url}}" method="POST">
<div>
<div class="section-title">Description</div>
<div class="section-body">{{.client.Description}}</div>
</div>
<div>
<div class="section-title">Requested scopes</div>
<ul class="section-scope list-group">
{{range $_, $element := .scopes}}
<li class="monospace list-group-item">
{{$element}}
</li>
{{end}}
</ul>
</div>
<div class="action-form-buttons">
<button class="btn btn-secondary" type="button" id="decline-button">{{.i18n.decline}}</button>
<button class="btn btn-primary" type="submit">{{.i18n.approve}}</button>
</div>
</form>
</div>
<style>
.section-title {
font-weight: bold;
}
.section-scope {
margin-top: 4px;
margin-left: -8px;
margin-right: -8px;
}
.monospace {
font-family: "Roboto Mono", monospace;
}
</style>
<script>
$("#decline-button").on("click", () => {
history.back()
window.close()
})
</script>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

View File

@ -1,10 +0,0 @@
<!doctype html>
<html lang="en">
{{template "views/partials/header"}}
<body>
{{embed}}
</body>
</html>

View File

@ -1,115 +0,0 @@
<!doctype html>
<html lang="en">
{{template "views/partials/header"}}
<body>
<div class="outer-container">
<div class="inner-container">
{{if ne .info nil}}
<div class="alert alert-primary" role="alert">
<svg class="bi me-2" role="img" aria-label="Info:">
<use xlink:href="#info-fill" />
</svg>
<div class="content">{{.info}}</div>
</div>
{{end}}
<div class="card card-container">
{{embed}}
</div>
</div>
</div>
</body>
<style>
.outer-container {
width: 100dvw;
height: 100dvh;
display: flex;
justify-content: center;
align-items: center;
}
.inner-container {
width: 100%;
min-width: 0;
max-width: min(800px, 100dvw);
margin: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.card-container {
transition: all .3s;
height: auto;
overflow: auto;
display: grid;
grid-template-columns: 1fr;
justify-content: center;
padding: 48px;
gap: 0 2rem;
}
.logo {
margin-left: -8px;
margin-bottom: -8px;
display: block;
}
.title {
margin-block-start: 0.33em;
margin-block-end: 0.33em;
font-size: 2.5rem;
}
.caption {
font-size: 1rem;
}
.action-form {
display: flex;
flex-direction: column;
gap: 0.8rem 0;
}
.action-form-buttons {
display: flex;
gap: 4px;
margin-top: 10px;
}
.action-form-buttons * {
flex: 1;
}
.block-field {
width: 100%;
}
.responsive-hidden {
display: unset;
}
.columns-two {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
@media (min-width: 768px) {
.card-container {
grid-template-columns: 1fr 1fr;
}
.responsive-title-gap {
height: calc(56px + 0.44rem);
display: block;
}
}
</style>
</html>

View File

@ -1,128 +0,0 @@
<!doctype html>
<html lang="en">
{{template "views/partials/header"}}
<body>
<div class="outer-container">
<div class="inner-container">
{{if ne .info nil}}
<div class="alert alert-primary" role="alert">
<svg class="bi me-2" role="img" aria-label="Info:">
<use xlink:href="#info-fill" />
</svg>
<div class="content">{{.info}}</div>
</div>
{{end}}
<div class="card card-container">
{{embed}}
</div>
</div>
</div>
</body>
<style>
body,
.outer-container {
scrollbar-width: none;
overflow-x: hidden;
}
.outer-container {
width: 100dvw;
min-height: 100dvh;
display: flex;
justify-content: center;
align-items: center;
}
.outer-container::-webkit-scrollbar,
body::-webkit-scrollbar {
display: none;
width: 0;
}
.inner-container {
width: 100%;
min-width: 0;
max-width: min(800px, 100dvw);
margin: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
padding-top: 1rem;
padding-bottom: 1rem;
}
.card-container {
transition: all .3s;
height: auto;
overflow: auto;
display: grid;
grid-template-columns: 1fr;
justify-content: center;
padding: 48px;
gap: 0 2rem;
}
.logo {
margin-left: -8px;
margin-bottom: -8px;
display: block;
}
.title {
margin-block-start: 0.33em;
margin-block-end: 0.33em;
font-size: 2.5rem;
}
.caption {
font-size: 1rem;
}
.action-form {
display: flex;
flex-direction: column;
gap: 0.8rem 0;
}
.action-form-buttons {
display: flex;
justify-content: end;
margin-top: 8px;
gap: 4px;
}
.block-field {
width: 100%;
}
.responsive-hidden {
display: unset;
}
.columns-two {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
@media (min-width: 768px) {
.card-container {
grid-template-columns: 1fr 1fr;
}
.responsive-title-gap {
height: calc(56px + 0.44rem);
display: block;
}
}
</style>
</html>

View File

@ -1,43 +0,0 @@
<div class="left-part">
<img class="logo" alt="Logo" src="/favicon.png" width="64" height="64" />
<h1 class="title">{{.i18n.title}}</h1>
<p class="caption">{{.i18n.caption}}</p>
</div>
<div class="right-part">
<div class="responsive-title-gap"></div>
<form class="action-form" action="/mfa/apply" method="POST">
<label>
<input name="ticket_id" value="{{.ticket_id}}" hidden>
</label>
<label>
<input name="factor_id" value="{{.factor_id}}" hidden>
</label>
<div class="factor-label">{{.label}}</div>
<div class="mb-1 block-field">
<label for="code" class="form-label">{{.i18n.password}}</label>
<input type="password" class="form-control" id="code" name="password" autocomplete="off">
</div>
<div class="action-form-buttons">
<button class="btn btn-primary" type="submit">{{.i18n.next}}</button>
</div>
</form>
</div>
<style>
.factor-label {
font-size: 14px;
text-align: left;
}
@media (min-width: 768px) {
.factor-label {
text-align: center;
}
}
</style>

View File

@ -1,59 +0,0 @@
<div class="left-part">
<img class="logo" alt="Logo" src="/favicon.png" width="64" height="64" />
<h1 class="title">{{.i18n.title}}</h1>
<p class="caption">{{.i18n.caption}}</p>
</div>
<div class="right-part">
<div class="responsive-title-gap"></div>
<form class="action-form" action="/mfa" method="POST">
<label>
<input name="ticket_id" value="{{.ticket_id}}" hidden>
</label>
{{if ne .redirect_uri nil}}
<label>
<input name="redirect_uri" value="{{.redirect_uri}}" hidden>
</label>
{{end}}
<div class="block-field factor-list" role="radiogroup">
{{range $_, $element := .factors}}
<div class="factor-label">
<div class="form-check">
<input class="form-check-input" type="radio" name="factor_id" id="factor-{{$element.id}}"
value="{{$element.id}}">
<label class="form-check-label" for="factor-{{$element.id}}">
{{$element.name}}
</label>
</div>
</div>
{{end}}
</div>
<div class="action-form-buttons">
<button class="btn btn-primary" type="submit">{{.i18n.next}}</button>
</div>
</form>
</div>
<style>
.factor-list {
display: flex;
flex-direction: column;
}
.factor-label {
display: flex;
align-items: center;
}
.factor-label label {
display: inline-flex;
place-items: center;
gap: 8px;
font-family: Roboto, system-ui;
color: var(--md-sys-color-on-background);
}
</style>

View File

@ -1,56 +0,0 @@
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<link rel="icon" type="image/png" href="/favicon.png">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/dist/umd/popper.min.js"
integrity="sha384-I7E8VVD/ismYTF4hNIPjVp/Zjvgyol6VFvRkX/vR+Vc4jQkC+hVqc2pM8ODewa9r"
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.min.js"
integrity="sha384-0pUGZvbkm6XF6gxjEnlmuGrJXVbNuzT9qBBavbLwCsOGabYfZo0T0to5eqruptLy"
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js"
integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo=" crossorigin="anonymous"></script>
<svg xmlns="http://www.w3.org/2000/svg" class="d-none">
<symbol id="info-fill" viewBox="0 0 16 16">
<path
d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm.93-9.412-1 4.705c-.07.34.029.533.304.533.194 0 .487-.07.686-.246l-.088.416c-.287.346-.92.598-1.465.598-.703 0-1.002-.422-.808-1.319l.738-3.468c.064-.293.006-.399-.287-.47l-.451-.081.082-.381 2.29-.287zM8 5.5a1 1 0 1 1 0-2 1 1 0 0 1 0 2z" />
</symbol>
</svg>
<title>Solarpass</title>
<style>
html,
body {
padding: 0;
margin: 0;
}
.alert {
padding: 16px 48px;
display: flex;
align-items: center;
gap: 8px;
}
.alert .bi {
aspect-ratio: 1;
width: 16px;
fill: var(--bs-alert-color);
}
.alert .content {
flex-grow: 1;
text-transform: capitalize;
}
</style>
</head>

View File

@ -1,27 +0,0 @@
<div class="left-part">
<img class="logo" alt="Logo" src="/favicon.png" width="64" height="64" />
<h1 class="title">{{.i18n.title}}</h1>
<p class="caption">{{.i18n.caption}}</p>
</div>
<div class="right-part">
<div class="responsive-title-gap"></div>
<form class="action-form" action="/sign-in" method="POST">
<div class="mb-1 block-field">
<label for="username" class="form-label">{{.i18n.username}}</label>
<input type="text" class="form-control" id="username" name="username">
</div>
<div class="mb-1 block-field">
<label for="password" class="form-label">{{.i18n.password}}</label>
<input type="password" class="form-control" id="password" name="password">
</div>
<div class="action-form-buttons">
<a class="btn btn-secondary" href="/sign-up">{{.i18n.signup}}</a>
<button class="btn btn-primary" type="submit">{{.i18n.next}}</button>
</div>
</form>
</div>

View File

@ -1,47 +0,0 @@
<div class="left-part">
<img class="logo" alt="Logo" src="/favicon.png" width="64" height="64" />
<h1 class="title">{{.i18n.title}}</h1>
<p class="caption">{{.i18n.caption}}</p>
</div>
<div class="right-part">
<div class="responsive-title-gap"></div>
<form class="action-form" action="/sign-up" method="POST">
<div class="columns-two">
<div class="mb-1">
<label for="name" class="form-label">{{.i18n.username}}</label>
<input type="text" class="form-control" id="name" name="name">
</div>
<div class="mb-1">
<label for="nick" class="form-label">{{.i18n.nickname}}</label>
<input type="text" class="form-control" id="nick" name="nick">
</div>
</div>
<div class="mb-1 block-field">
<label for="email" class="form-label">{{.i18n.email}}</label>
<input type="email" class="form-control" id="email" name="email">
</div>
<div class="mb-1">
<label for="password" class="form-label">{{.i18n.password}}</label>
<input type="password" class="form-control" id="password" name="password" autocomplete="new-password">
</div>
{{if eq .use_magic_token true}}
<div class="mb-1">
<label for="token" class="form-label">{{.i18n.password}}</label>
<input type="password" class="form-control" id="token" name="magic_token" autocomplete="new-password">
</div>
{{end}}
<div class="action-form-buttons">
<a class="btn btn-secondary" href="/sign-in">{{.i18n.signin}}</a>
<button class="btn btn-primary" type="submit">{{.i18n.next}}</button>
</div>
</form>
</div>

View File

@ -1,153 +0,0 @@
<link rel="stylesheet" href="https://unpkg.com/tailwindcss@1.4.6/dist/base.min.css">
<link rel="stylesheet" href="https://unpkg.com/tailwindcss@1.4.6/dist/components.min.css">
<link rel="stylesheet" href="https://unpkg.com/@tailwindcss/typography@0.1.2/dist/typography.min.css">
<link rel="stylesheet" href="https://unpkg.com/tailwindcss@1.4.6/dist/utilities.min.css">
<div class="banner-container">
{{if ne .userinfo.Banner nil}}
<img src="{{.banner}}" alt="Banner" class="banner">
{{end}}
</div>
<div class="left-part name-card">
{{if ne .userinfo.Avatar nil}}
<img src="{{.avatar}}" alt="Avatar" class="avatar">
{{else}}
<div class="avatar empty">
<span class="material-symbols-outlined">account_circle</span>
</div>
{{end}}
<div class="name">
<h2 class="username">{{.userinfo.Nick}}</h2>
<h6 class="nickname">@{{.userinfo.Name}}</h6>
</div>
{{if gt (len .userinfo.Description) 0}}
<div class="description">{{.userinfo.Description}}</div>
{{else}}
<div class="description empty">No description yet.</div>
{{end}}
<div class="uid">#{{.uid}}</div>
</div>
<div class="right-part">
<article class="personal-page prose">
{{.personal_page}}
</article>
</div>
<style>
.avatar {
display: block;
width: 64px;
height: 64px;
object-fit: cover;
clip-path: circle();
}
.avatar.empty {
display: flex;
justify-content: center;
align-items: center;
background-color: var(--md-sys-color-secondary);
color: var(--md-sys-color-on-secondary);
}
@media (min-width: 768px) {
.banner-container {
grid-column: span 2;
}
}
.banner {
display: block;
object-fit: cover;
border-radius: 28px;
aspect-ratio: 3 / 1;
width: 100%;
}
.name-card {
display: flex;
flex-direction: column;
gap: 1rem;
}
.name-card .name {
display: flex;
align-items: baseline;
gap: 0.3rem;
}
.name-card .username {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
}
.name-card .nickname {
margin: 0;
font-size: 0.75rem;
font-weight: 500;
}
.name-card .uid {
margin-top: -0.8rem;
font-size: 0.7rem;
font-weight: 400;
font-family: Roboto Mono, monospace;
}
.name-card .description {
margin-top: -1.25rem;
}
.description.empty {
font-style: italic;
}
.name-card .metadata {
font-size: 0.85rem;
font-weight: 500;
display: flex;
flex-direction: column;
}
.metadata>div {
display: flex;
align-items: center;
gap: 0.25rem;
}
.metadata .material-symbols-outlined {
font-size: 1rem;
display: block;
}
.actions {
display: flex;
gap: 0.5rem;
margin: 0 -0.5rem;
}
@media (min-width: 768px) {
.actions {
flex-direction: column;
}
}
.actions .action {
width: fit-content;
}
.actions .material-symbols-outlined {
font-size: 20px;
margin-bottom: 4px;
}
.left-part .prose {
min-width: 0;
max-width: unset;
}
</style>