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)