diff --git a/example/admin/auth.go b/example/admin/auth.go
index 5bd04fd1..e938b8c7 100644
--- a/example/admin/auth.go
+++ b/example/admin/auth.go
@@ -55,19 +55,19 @@ func initLoginSessionBuilder(db *gorm.DB, pb *presets.Builder, ab *activity.Buil
Goth: google.New(loginGoogleKey, loginGoogleSecret, baseURL+"/auth/callback?provider="+models.OAuthProviderGoogle),
Key: models.OAuthProviderGoogle,
Text: "LoginProviderGoogleText",
- Logo: RawHTML(``),
+ Logo: RawHTML(``),
},
&login.Provider{
Goth: microsoftonline.New(loginMicrosoftOnlineKey, loginMicrosoftOnlineSecret, baseURL+"/auth/callback"),
Key: models.OAuthProviderMicrosoftOnline,
Text: "LoginProviderMicrosoftText",
- Logo: RawHTML(``),
+ Logo: RawHTML(``),
},
&login.Provider{
Goth: github.New(loginGithubKey, loginGithubSecret, baseURL+"/auth/callback?provider="+models.OAuthProviderGithub),
Key: models.OAuthProviderGithub,
Text: "LoginProviderGithubText",
- Logo: RawHTML(``),
+ Logo: RawHTML(``),
},
).
HomeURLFunc(func(r *http.Request, user interface{}) string {
@@ -144,8 +144,7 @@ func initLoginSessionBuilder(db *gorm.DB, pb *presets.Builder, ab *activity.Buil
return nil
}
}).TOTP(false).MaxRetryCount(0)
-
- loginBuilder.LoginPageFunc(loginPage(loginBuilder.ViewHelper(), pb))
+ loginBuilder.LoginPageFunc(plogin.NewAdvancedLoginPage(nil)(loginBuilder.ViewHelper(), pb))
genInitialUser(db)
diff --git a/example/integration/login_test.go b/example/integration/login_test.go
new file mode 100644
index 00000000..4533995c
--- /dev/null
+++ b/example/integration/login_test.go
@@ -0,0 +1,62 @@
+package integration_test
+
+import (
+ "net/http"
+ "testing"
+
+ "github.com/qor5/web/v3/multipartestutils"
+
+ "github.com/qor5/admin/v3/example/admin"
+)
+
+func TestLogin(t *testing.T) {
+ h := admin.TestL18nHandler(TestDB)
+
+ dbr, _ := TestDB.DB()
+ profileData.TruncatePut(dbr)
+
+ cases := []multipartestutils.TestCase{
+ {
+ Name: "view by en",
+ Debug: true,
+ ReqFunc: func() *http.Request {
+ req := multipartestutils.NewMultipartBuilder().
+ PageURL("/auth/login").
+ BuildEventFuncRequest()
+ req.Header.Add("accept-language", "en")
+ return req
+ },
+ ExpectPageBodyContainsInOrder: []string{`Welcome`, `Qor Admin System`, `Email`, `Password`, `Sign in`, `Forget your password?`},
+ },
+ {
+ Name: "view by zh",
+ Debug: true,
+ ReqFunc: func() *http.Request {
+ req := multipartestutils.NewMultipartBuilder().
+ PageURL("/auth/login").
+ BuildEventFuncRequest()
+ req.Header.Add("accept-language", "zh")
+ return req
+ },
+ ExpectPageBodyContainsInOrder: []string{`欢迎`, `Qor 管理系统`, `邮箱`, `密码`, `登录`, `忘记密码?`},
+ },
+ {
+ Name: "view by ja",
+ Debug: true,
+ ReqFunc: func() *http.Request {
+ req := multipartestutils.NewMultipartBuilder().
+ PageURL("/auth/login").
+ BuildEventFuncRequest()
+ req.Header.Add("accept-language", "ja")
+ return req
+ },
+ ExpectPageBodyContainsInOrder: []string{`ようこそ`, `Qor 管理システム`, `メールアドレス`, `パスワード`, `サインイン`, `パスワードをお忘れですか?`},
+ },
+ }
+
+ for _, c := range cases {
+ t.Run(c.Name, func(t *testing.T) {
+ multipartestutils.RunCase(t, c, h)
+ })
+ }
+}
diff --git a/login/messages.go b/login/messages.go
index 8342a13a..68beeab4 100644
--- a/login/messages.go
+++ b/login/messages.go
@@ -23,6 +23,20 @@ type Messages struct {
Available string
Unavailable string
SuccessfullyRename string
+
+ LoginWelcomeLabel string
+ LoginTitleLabel string
+
+ LoginAccountLabel string
+ LoginAccountPlaceholder string
+ LoginPasswordLabel string
+ LoginPasswordPlaceholder string
+ LoginSignInButtonLabel string
+ LoginForgetPasswordLabel string
+
+ LoginProviderGoogleText string
+ LoginProviderMicrosoftText string
+ LoginProviderGithubText string
}
func (m *Messages) UnreadMessages(n int) string {
@@ -48,6 +62,20 @@ var Messages_en_US = &Messages{
Available: "Available",
Unavailable: "Unavailable",
SuccessfullyRename: "Successfully renamed",
+
+ LoginWelcomeLabel: "Welcome",
+ LoginTitleLabel: "Qor Admin System",
+
+ LoginAccountLabel: "Email",
+ LoginAccountPlaceholder: "Please enter your email",
+ LoginPasswordLabel: "Password",
+ LoginPasswordPlaceholder: "Please enter your password",
+ LoginSignInButtonLabel: "Sign in",
+ LoginForgetPasswordLabel: "Forget your password?",
+
+ LoginProviderGoogleText: "Sign in with Google",
+ LoginProviderMicrosoftText: "Sign in with Microsoft",
+ LoginProviderGithubText: "Sign in with Github",
}
var Messages_zh_CN = &Messages{
@@ -68,6 +96,20 @@ var Messages_zh_CN = &Messages{
Available: "可用",
Unavailable: "不可用",
SuccessfullyRename: "成功重命名",
+
+ LoginWelcomeLabel: "欢迎",
+ LoginTitleLabel: "Qor 管理系统",
+
+ LoginAccountLabel: "邮箱",
+ LoginAccountPlaceholder: "请输入您的邮箱",
+ LoginPasswordLabel: "密码",
+ LoginPasswordPlaceholder: "请输入您的密码",
+ LoginSignInButtonLabel: "登录",
+ LoginForgetPasswordLabel: "忘记密码?",
+
+ LoginProviderGoogleText: "使用 Google 登录",
+ LoginProviderMicrosoftText: "使用 Microsoft 登录",
+ LoginProviderGithubText: "使用 Github 登录",
}
var Messages_ja_JP = &Messages{
@@ -88,4 +130,18 @@ var Messages_ja_JP = &Messages{
Available: "利用可能",
Unavailable: "利用不可",
SuccessfullyRename: "名前が変更されました",
+
+ LoginWelcomeLabel: "ようこそ",
+ LoginTitleLabel: "Qor 管理システム",
+
+ LoginAccountLabel: "メールアドレス",
+ LoginAccountPlaceholder: "メールアドレスを入力してください",
+ LoginPasswordLabel: "パスワード",
+ LoginPasswordPlaceholder: "パスワードを入力してください",
+ LoginSignInButtonLabel: "サインイン",
+ LoginForgetPasswordLabel: "パスワードをお忘れですか?",
+
+ LoginProviderGoogleText: "Google でログイン",
+ LoginProviderMicrosoftText: "Microsoft でログイン",
+ LoginProviderGithubText: "Github でログイン",
}
diff --git a/login/view_common.go b/login/view_common.go
index 4e67dad2..aaa3f58d 100644
--- a/login/view_common.go
+++ b/login/view_common.go
@@ -135,6 +135,7 @@ func (vc *ViewCommon) FormSubmitBtn(
label string,
) *VBtnBuilder {
return VBtn(label).
+ Variant(VariantFlat).
Color("primary").
Block(true).
Size(SizeLarge).
diff --git a/login/views.go b/login/views.go
index 1ac269c5..db739bc7 100644
--- a/login/views.go
+++ b/login/views.go
@@ -143,6 +143,212 @@ func defaultLoginPage(vh *login.ViewHelper, pb *presets.Builder) web.PageFunc {
})
}
+type OAuthProviderDisplay struct {
+ Logo HTMLComponent
+ Text string
+}
+
+type AdvancedLoginPageConfig struct {
+ WelcomeLabel string
+ TitleLabel string
+ AccountLabel string
+ AccountPlaceholder string
+ PasswordLabel string
+ PasswordPlaceholder string
+ SignInButtonLabel string
+ ForgetPasswordLabel string
+ OAuthProviderDisplay func(provider *login.Provider) OAuthProviderDisplay
+ BrandLogo HTMLComponent
+ LeftImage HTMLComponent
+}
+
+func NewAdvancedLoginPage(customize func(ctx *web.EventContext, config *AdvancedLoginPageConfig) (*AdvancedLoginPageConfig, error)) func(vh *login.ViewHelper, pb *presets.Builder) web.PageFunc {
+ return func(vh *login.ViewHelper, pb *presets.Builder) web.PageFunc {
+ return pb.PlainLayout(func(ctx *web.EventContext) (r web.PageResponse, err error) {
+ msgr := i18n.MustGetModuleMessages(ctx.R, I18nAdminLoginKey, Messages_en_US).(*Messages)
+
+ config := &AdvancedLoginPageConfig{
+ WelcomeLabel: msgr.LoginWelcomeLabel,
+ TitleLabel: msgr.LoginTitleLabel,
+ AccountLabel: msgr.LoginAccountLabel,
+ AccountPlaceholder: msgr.LoginAccountPlaceholder,
+ PasswordLabel: msgr.LoginPasswordLabel,
+ PasswordPlaceholder: msgr.LoginPasswordPlaceholder,
+ SignInButtonLabel: msgr.LoginSignInButtonLabel,
+ ForgetPasswordLabel: msgr.LoginForgetPasswordLabel,
+ OAuthProviderDisplay: func(provider *login.Provider) OAuthProviderDisplay {
+ return OAuthProviderDisplay{
+ Logo: provider.Logo,
+ Text: i18n.T(ctx.R, I18nAdminLoginKey, provider.Text),
+ }
+ },
+ BrandLogo: nil,
+ LeftImage: VImg().Class("fill-height").Cover(true).Src("https://cdn.vuetifyjs.com/images/parallax/material2.jpg"),
+ }
+ if customize != nil {
+ config, err = customize(ctx, config)
+ if err != nil {
+ return r, err
+ }
+ }
+
+ // i18n start
+ i18nBuilder := vh.I18n()
+ var langs []languageItem
+ var currLangVal string
+ qn := i18nBuilder.GetQueryName()
+ if ls := i18nBuilder.GetSupportLanguages(); len(ls) > 1 {
+ lang := ctx.R.FormValue(qn)
+ if lang == "" {
+ lang = i18nBuilder.GetCurrentLangFromCookie(ctx.R)
+ }
+ accept := ctx.R.Header.Get("Accept-Language")
+ _, mi := language.MatchStrings(language.NewMatcher(ls), lang, accept)
+ for i, l := range ls {
+ if i == mi {
+ currLangVal = l.String()
+ }
+ langs = append(langs, languageItem{
+ Label: display.Self.Name(l),
+ Value: l.String(),
+ })
+ }
+ }
+ // i18n end
+
+ logoCompo := func() *HTMLTagBuilder {
+ return Div().Class("d-flex flex-row ga-6 px-6 py-2 rounded").Style("background-color: white;").Children(
+ RawHTML(``),
+ If(config.BrandLogo != nil, Components(
+ VDivider().Color(ColorGreyDarken3).Vertical(true),
+ config.BrandLogo,
+ )),
+ )
+ }
+
+ leftCompo := VCol().Cols(0).Md(6).Class("hidden-md-and-down").Children(
+ config.LeftImage,
+ Div().Class("position-absolute").Style("top: 32px; left: 32px;").Children(
+ logoCompo(),
+ ),
+ )
+
+ var oauthCompo HTMLComponent
+ if vh.OAuthEnabled() {
+ buttons := []HTMLComponent{}
+ for _, provider := range vh.OAuthProviders() {
+ display := config.OAuthProviderDisplay(provider)
+ buttons = append(buttons,
+ VBtn("").Class("bg-grey-lighten-4").
+ Block(true).
+ Size(SizeLarge).
+ Variant(VariantFlat).
+ Href(fmt.Sprintf("%s?provider=%s", vh.OAuthBeginURL(), provider.Key)).
+ Children(
+ Div().Class("d-flex flex-row ga-2 text-body-1").Children(
+ display.Logo,
+ Div().Class("text-body1").Text(display.Text),
+ ),
+ ),
+ )
+ }
+ if len(buttons) > 0 {
+ oauthCompo = Div().Class("d-flex flex-column ga-6").Children(buttons...)
+ }
+ }
+
+ wIn := vh.GetWrongLoginInputFlash(ctx.W, ctx.R)
+ isRecaptchaEnabled := vh.RecaptchaEnabled()
+ if isRecaptchaEnabled {
+ DefaultViewCommon.InjectRecaptchaAssets(ctx, "login-form", "token")
+ }
+
+ var userPassCompo HTMLComponent
+ if vh.UserPassEnabled() {
+ compo := Form().Class("d-flex flex-column").Id("login-form").Method(http.MethodPost).Action(vh.PasswordLoginURL())
+ compo.AppendChildren(
+ DefaultViewCommon.Input("account", config.AccountPlaceholder, wIn.Account).Class("mb-5").Label(config.AccountLabel),
+ DefaultViewCommon.PasswordInput("password", config.PasswordPlaceholder, wIn.Password, true).Class("mb-5").Label(config.PasswordLabel),
+ If(isRecaptchaEnabled,
+ // recaptcha response token
+ Input("token").Id("token").Type("hidden"),
+ ),
+ DefaultViewCommon.FormSubmitBtn(config.SignInButtonLabel).
+ ClassIf("g-recaptcha", isRecaptchaEnabled).
+ AttrIf("data-sitekey", vh.RecaptchaSiteKey(), isRecaptchaEnabled).
+ AttrIf("data-callback", "onSubmit", isRecaptchaEnabled),
+ )
+
+ userPassCompo = Div().Class("d-flex flex-column").Children(
+ compo,
+ If(!vh.NoForgetPasswordLink(),
+ A(Text(config.ForgetPasswordLabel)).Href(vh.ForgetPasswordPageURL()).Class("align-self-end mt-2 mb-7 grey--text text-subtitle-2 text--darken-1"),
+ ),
+ VDivider().Color(ColorGreyDarken3).Class("mt-2 mb-10"),
+ )
+ }
+
+ var langCompo HTMLComponent
+ if len(langs) > 0 {
+ ctx.Injector.HeadHTML(`
+
+ `)
+ langCompo = web.Scope().VSlot(" { locals : selectLocals } ").Init(fmt.Sprintf(`{currLangVal: '%s'}`, currLangVal)).Children(
+ vx.VXSelect().Class("transparent-language-select").
+ Items(langs).
+ ItemTitle("Label").
+ ItemValue("Value").
+ Attr("v-model", `selectLocals.currLangVal`).
+ Attr("@update:model-value", web.Plaid().MergeQuery(true).Query(qn, web.Var("selectLocals.currLangVal")).PushState(true).Go()),
+ )
+ }
+
+ rightCompo := VCol().Cols(12).Md(6).Class("d-flex flex-column justify-center align-center").Children(
+ Div().Class("d-flex flex-column pa-4").Style("max-width: 455px; width: 100%").Children(
+ Div().Class("d-flex flex-row align-center ga-2 mb-5").Children(
+ Div().Class("hidden-lg-and-up mb-4").Children(
+ logoCompo(),
+ ),
+ VSpacer(),
+ langCompo,
+ ),
+ Div().Text(config.WelcomeLabel).Class("mb-4 text-h4"),
+ Div().Text(config.TitleLabel).Class("mb-16").Style("font-size: 42px; font-weight: 510;"),
+ userPassCompo,
+ oauthCompo,
+ ),
+ )
+
+ r.PageTitle = config.TitleLabel
+ r.Body = Components(
+ DefaultViewCommon.Notice(vh, i18n.MustGetModuleMessages(ctx.R, login.I18nLoginKey, login.Messages_en_US).(*login.Messages), ctx.W, ctx.R),
+ VRow().NoGutters(true).
+ Attr(":class", "$vuetify.display.mdAndDown ? 'fill-height justify-center bg-grey-lighten-5':'fill-height justify-center'").Children(
+ leftCompo,
+ rightCompo,
+ ),
+ )
+
+ return
+ })
+ }
+}
+
func defaultForgetPasswordPage(vh *login.ViewHelper, pb *presets.Builder) web.PageFunc {
return pb.PlainLayout(func(ctx *web.EventContext) (r web.PageResponse, err error) {
msgr := i18n.MustGetModuleMessages(ctx.R, login.I18nLoginKey, login.Messages_en_US).(*login.Messages)