Skip to content

Commit

Permalink
[CLI-135] feat: add custom template edition w/preview (#267)
Browse files Browse the repository at this point in the history
* feat: add custom template edition w/preview [CLI-135]

* chore: apply PR feedback

* chore: add PR feedback

* chore: switch to fsnotify for fs events

* Update tests for auth, re-org

* Update to use AskBool

* Remove unused NotifyFileCreated

* Simplify: made the templates a const for now

* Nit: use URL / ID for names to comply with abbrev convention

* Nit: simplify naming within the context of the branding funcs

No need for full `templateData` since those functions all deal with
templates.

* Use structured URL

* Also close the listener

server.Close doesn't close it, best I can tell.

* Fix fsnotify and vim issues

TL;DR we need to watch an entire dir instead of just one file.  But also, when
doing that, just utilizing the entire tmpdir is overkill since that's got many
many files in there -- which in practice causes vim to segfault.

The fix is simple: make a tempdir, make the tempfile within that, and clean
those up in reverse order.

* Add docs for branding.Client struct

* Add TemplateData docs

* Re-jigger status

* Cleanup random log.Fatal

* Simplify parallel code with errgroup

* Apply suggestions from code review

* thread ctx; fix lint

* chore: return an empty brandinInfo

* chore: do not return err on get branding

* Added custom domain requirement to the help text

Co-authored-by: Cyril David <cyril.david@auth0.com>
Co-authored-by: Rita Zerrizuela <zeta@widcket.com>
  • Loading branch information
3 people committed Apr 30, 2021
1 parent 67e75d3 commit 60c02ca
Show file tree
Hide file tree
Showing 49 changed files with 1,450 additions and 6 deletions.
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ require (
github.com/briandowns/spinner v1.12.1-0.20210421154440-eac1b899fcf1
github.com/charmbracelet/glamour v0.2.0
github.com/fatih/color v1.10.0 // indirect
github.com/fsnotify/fsnotify v1.4.7
github.com/getsentry/sentry-go v0.10.0
github.com/golang/mock v1.5.0
github.com/golang/snappy v0.0.3 // indirect
github.com/google/go-cmp v0.5.5
github.com/guiguan/caster v0.0.0-20191104051807-3736c4464f38
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
github.com/klauspost/compress v1.11.9 // indirect
github.com/klauspost/pgzip v1.2.5 // indirect
Expand All @@ -34,6 +36,7 @@ require (
github.com/zalando/go-keyring v0.1.1
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83 // indirect
golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
golang.org/x/sys v0.0.0-20210426230700-d19ff857e887
golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d
golang.org/x/text v0.3.5 // indirect
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,8 @@ github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad
github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
github.com/guiguan/caster v0.0.0-20191104051807-3736c4464f38 h1:oWETJozNAt29o9b03jPJ8mjQTk8XklRXEZiXBECoNpg=
github.com/guiguan/caster v0.0.0-20191104051807-3736c4464f38/go.mod h1:giU/iWwQIOg/ND1ecR8raoyROxojrXL9osppnuI7MRY=
github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q=
github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
Expand Down Expand Up @@ -595,6 +597,8 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
Expand Down
4 changes: 3 additions & 1 deletion internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ var requiredScopes = []string{
"create:clients", "delete:clients", "read:clients", "update:clients",
"create:resource_servers", "delete:resource_servers", "read:resource_servers", "update:resource_servers",
"create:rules", "delete:rules", "read:rules", "update:rules",
"read:client_keys", "read:logs", "read:users", "update:users",
"read:users", "update:users",
"read:branding", "update:branding",
"read:client_keys", "read:logs", "read:tenant_settings",
}

// RequiredScopes returns the scopes used for login.
Expand Down
3 changes: 3 additions & 0 deletions internal/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ func TestRequiredScopes(t *testing.T) {
t.Run("verify special scopes", func(t *testing.T) {
list := []string{
"read:client_keys", "read:logs",
"read:users", "update:users",
"read:branding", "update:branding",
"read:client_keys", "read:logs", "read:tenant_settings",
}

for _, v := range list {
Expand Down
4 changes: 4 additions & 0 deletions internal/auth0/auth0.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ type API struct {
ActionVersion ActionVersionAPI
ActionExecution ActionExecutionAPI
ActionBinding ActionBindingAPI
Branding BrandingAPI
Client ClientAPI
Log LogAPI
ResourceServer ResourceServerAPI
Rule RuleAPI
Tenant TenantAPI
User UserAPI
}

Expand All @@ -25,10 +27,12 @@ func NewAPI(m *management.Management) *API {
ActionVersion: m.ActionVersion,
ActionExecution: m.ActionExecution,
ActionBinding: m.ActionBinding,
Branding: m.Branding,
Client: m.Client,
Log: m.Log,
ResourceServer: m.ResourceServer,
Rule: m.Rule,
Tenant: m.Tenant,
User: m.User,
}
}
Expand Down
11 changes: 11 additions & 0 deletions internal/auth0/branding.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
//go:generate mockgen -source=branding.go -destination=branding_mock.go -package=auth0
package auth0

import "gopkg.in/auth0.v5/management"

type BrandingAPI interface {
Read(opts ...management.RequestOption) (b *management.Branding, err error)
UniversalLogin(opts ...management.RequestOption) (ul *management.BrandingUniversalLogin, err error)
SetUniversalLogin(ul *management.BrandingUniversalLogin, opts ...management.RequestOption) (err error)
DeleteUniversalLogin(opts ...management.RequestOption) (err error)
}
8 changes: 8 additions & 0 deletions internal/auth0/tenant.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
//go:generate mockgen -source=tenant.go -destination=tenant_mock.go -package=auth0
package auth0

import "gopkg.in/auth0.v5/management"

type TenantAPI interface {
Read(opts ...management.RequestOption) (t *management.Tenant, err error)
}
174 changes: 174 additions & 0 deletions internal/branding/branding.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
package branding

import (
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"net/url"
"path/filepath"
"text/template"
"time"

"github.com/auth0/auth0-cli/internal/open"
"github.com/fsnotify/fsnotify"
"github.com/guiguan/caster"
)

// Client is a minimal representation of an auth0 Client as defined in the
// management API. This is used within the branding machinery to populate the
// tenant data.
type Client struct {
ID string `json:"id"`
Name string `json:"name"`
LogoURL string `json:"logo_url,omitempty"`
}

// TemplateData contains all the variables we project onto our embedded go
// template. These variables largely resemble the same ones in the auth0
// branding template.
type TemplateData struct {
Filename string
Clients []Client
PrimaryColor string
BackgroundColor string
LogoURL string
TenantName string
Body string
}

func PreviewCustomTemplate(ctx context.Context, data TemplateData) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()

listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return err
}
defer listener.Close()

// Long polling waiting for file changes
broadcaster, err := broadcastCustomTemplateChanges(ctx, data.Filename)
if err != nil {
return err
}

requestTimeout := 10 * time.Minute
server := &http.Server{
Handler: buildRoutes(ctx, requestTimeout, data, broadcaster),
ReadTimeout: requestTimeout + 1*time.Minute,
WriteTimeout: requestTimeout + 1*time.Minute,
}
defer server.Close()

go func() {
if err = server.Serve(listener); err != http.ErrServerClosed {
cancel()
}
}()

u := &url.URL{
Scheme: "http",
Host: listener.Addr().String(),
Path: "/data/storybook/",
RawQuery: (url.Values{
"path": []string{"/story/universal-login--prompts"},
}).Encode(),
}

if err := open.URL(u.String()); err != nil {
return err
}

// Wait until the file is closed or input is cancelled
<-ctx.Done()
return nil
}

func buildRoutes(ctx context.Context, requestTimeout time.Duration, data TemplateData, broadcaster *caster.Caster) *http.ServeMux {
router := http.NewServeMux()

router.HandleFunc("/dynamic/events", func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()

changes, _ := broadcaster.Sub(ctx, 1)
defer broadcaster.Unsub(changes)

writeStatus := func(w http.ResponseWriter, code int) {
msg := fmt.Sprintf("%d - %s", code, http.StatusText(http.StatusGone))
http.Error(w, msg, code)
}

select {
case <-ctx.Done():
writeStatus(w, http.StatusGone)
case <-time.After(requestTimeout):
writeStatus(w, http.StatusRequestTimeout)
case <-changes:
writeStatus(w, http.StatusOK)
}
})

// The template file
router.HandleFunc("/dynamic/template", func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, data.Filename)
})

jstmpl := template.Must(template.New("tenant-data.js").Funcs(template.FuncMap{
"asJS": func(v interface{}) string {
a, _ := json.Marshal(v)
return string(a)
},
}).Parse(tenantDataAsset))

router.HandleFunc("/dynamic/tenant-data", func(w http.ResponseWriter, r *http.Request) {
err := jstmpl.Execute(w, data)
if err != nil {
http.Error(w, err.Error(), 500)
}
})

// Storybook assets
router.Handle("/", http.FileServer(http.FS(templatePreviewAssets)))

return router
}

func broadcastCustomTemplateChanges(ctx context.Context, filename string) (*caster.Caster, error) {
publisher := caster.New(ctx)

watcher, err := fsnotify.NewWatcher()
if err != nil {
return nil, err
}

go func() {
for {
select {
case _, ok := <-watcher.Events:
if !ok {
return
}
publisher.Pub(true)

case _, ok := <-watcher.Errors:
if !ok {
return
}
}
}
}()

go func() {
<-ctx.Done()
watcher.Close()
publisher.Close()
}()

if err := watcher.Add(filepath.Dir(filename)); err != nil {
return nil, err
}

return publisher, nil
}
23 changes: 23 additions & 0 deletions internal/branding/branding_embed.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package branding

import (
"embed"
_ "embed"
)

var (
//go:embed data/storybook/*
templatePreviewAssets embed.FS

//go:embed data/tenant-data.js
tenantDataAsset string

//go:embed data/default-template.liquid
DefaultTemplate string

//go:embed data/footer-template.liquid
FooterTemplate string

//go:embed data/image-template.liquid
ImageTemplate string
)
9 changes: 9 additions & 0 deletions internal/branding/data/default-template.liquid
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<!DOCTYPE html>
<html>
<head>
{%- auth0:head -%}
</head>
<body>
{%- auth0:widget -%}
</body>
</html>
55 changes: 55 additions & 0 deletions internal/branding/data/footer-template.liquid
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="{{locale}}">
<head>
{%- auth0:head -%}
<style>
body {
background-image: radial-gradient(white, rgb(200, 200, 200));
}
.footer {
background-color: rgb(120, 120, 120);
position: absolute;
bottom: 0;
left: 0;
padding: 16px 0;
width: 100%;
color: white;
/* Use a high z-index for future-proofing */
z-index: 10;
}
.footer ul {
text-align: center;
}
.footer ul li {
display: inline-block;
margin: 0 4px;
}
.footer ul li:not(:first-of-type) {
margin-left: 0;
}
.footer ul li:not(:first-of-type)::before {
content: '';
display: inline-block;
vertical-align: middle;
width: 4px;
height: 4px;
margin-right: 4px;
background-color: white;
border-radius: 50%;
}
.footer a {
color: white;
}
</style>
<title>{{ prompt.screen.texts.pageTitle }}</title>
</head>
<body class="_widget-auto-layout">
{%- auth0:widget -%}
<footer class="footer">
<ul>
<li><a href="https://company.com/privacy">Privacy Policy</a></li>
<li><a href="https://company.com/terms">Terms of Service</a></li>
</ul>
</footer>
</body>
</html>
Loading

0 comments on commit 60c02ca

Please sign in to comment.