diff --git a/Makefile b/Makefile index 20b7ce10..e22ac57d 100644 --- a/Makefile +++ b/Makefile @@ -19,7 +19,7 @@ GOARCH=$(shell go env GOARCH) build: bin/dex bin/example-app -bin/dex: FORCE +bin/dex: FORCE server/templates_default.go @go install -ldflags $(LD_FLAGS) $(REPO_PATH)/cmd/dex bin/example-app: FORCE @@ -42,6 +42,9 @@ lint: golint $$package; \ done +server/templates_default.go: $(wildcard web/templates/**) + @go run server/templates_default_gen.go + .PHONY: docker-build docker-build: bin/dex @docker build -t $(DOCKER_IMAGE) . diff --git a/cmd/dex/config.go b/cmd/dex/config.go index ea764156..2b08885d 100644 --- a/cmd/dex/config.go +++ b/cmd/dex/config.go @@ -8,6 +8,7 @@ import ( "github.com/coreos/dex/connector/ldap" "github.com/coreos/dex/connector/mock" "github.com/coreos/dex/connector/oidc" + "github.com/coreos/dex/server" "github.com/coreos/dex/storage" "github.com/coreos/dex/storage/kubernetes" "github.com/coreos/dex/storage/memory" @@ -21,6 +22,8 @@ type Config struct { Web Web `yaml:"web"` OAuth2 OAuth2 `yaml:"oauth2"` + Templates server.TemplateConfig `yaml:"templates"` + StaticClients []storage.Client `yaml:"staticClients"` } @@ -111,9 +114,15 @@ func (c *Connector) UnmarshalYAML(unmarshal func(interface{}) error) error { var err error switch c.Type { - case "mock": + case "mockCallback": var config struct { - Config mock.Config `yaml:"config"` + Config mock.CallbackConfig `yaml:"config"` + } + err = unmarshal(&config) + c.Config = &config.Config + case "mockPassword": + var config struct { + Config mock.PasswordConfig `yaml:"config"` } err = unmarshal(&config) c.Config = &config.Config diff --git a/cmd/dex/serve.go b/cmd/dex/serve.go index be27f12b..24beb4b6 100644 --- a/cmd/dex/serve.go +++ b/cmd/dex/serve.go @@ -89,11 +89,11 @@ func serve(cmd *cobra.Command, args []string) error { } serverConfig := server.Config{ - Issuer: c.Issuer, - Connectors: connectors, - Storage: s, - SupportedResponseTypes: c.OAuth2.ResponseTypes, + Issuer: c.Issuer, + Connectors: connectors, + Storage: s, + TemplateConfig: c.Templates, } serv, err := server.New(serverConfig) diff --git a/connector/mock/connectortest.go b/connector/mock/connectortest.go index 71c97d92..0d4b87ba 100644 --- a/connector/mock/connectortest.go +++ b/connector/mock/connectortest.go @@ -1,4 +1,4 @@ -// Package mock implements a mock connector which requires no user interaction. +// Package mock implements connectors which help test various server components. package mock import ( @@ -11,22 +11,24 @@ import ( "github.com/coreos/dex/connector" ) -// New returns a mock connector which requires no user interaction. It always returns +// NewCallbackConnector returns a mock connector which requires no user interaction. It always returns // the same (fake) identity. -func New() connector.Connector { - return mockConnector{} +func NewCallbackConnector() connector.Connector { + return callbackConnector{} } var ( - _ connector.CallbackConnector = mockConnector{} - _ connector.GroupsConnector = mockConnector{} + _ connector.CallbackConnector = callbackConnector{} + _ connector.GroupsConnector = callbackConnector{} + + _ connector.PasswordConnector = passwordConnector{} ) -type mockConnector struct{} +type callbackConnector struct{} -func (m mockConnector) Close() error { return nil } +func (m callbackConnector) Close() error { return nil } -func (m mockConnector) LoginURL(callbackURL, state string) (string, error) { +func (m callbackConnector) LoginURL(callbackURL, state string) (string, error) { u, err := url.Parse(callbackURL) if err != nil { return "", fmt.Errorf("failed to parse callbackURL %q: %v", callbackURL, err) @@ -39,7 +41,7 @@ func (m mockConnector) LoginURL(callbackURL, state string) (string, error) { var connectorData = []byte("foobar") -func (m mockConnector) HandleCallback(r *http.Request) (connector.Identity, string, error) { +func (m callbackConnector) HandleCallback(r *http.Request) (connector.Identity, string, error) { return connector.Identity{ UserID: "0-385-28089-0", Username: "Kilgore Trout", @@ -49,17 +51,54 @@ func (m mockConnector) HandleCallback(r *http.Request) (connector.Identity, stri }, r.URL.Query().Get("state"), nil } -func (m mockConnector) Groups(identity connector.Identity) ([]string, error) { +func (m callbackConnector) Groups(identity connector.Identity) ([]string, error) { if !bytes.Equal(identity.ConnectorData, connectorData) { return nil, errors.New("connector data mismatch") } return []string{"authors"}, nil } -// Config holds the configuration parameters for the mock connector. -type Config struct{} +// CallbackConfig holds the configuration parameters for a connector which requires no interaction. +type CallbackConfig struct{} // Open returns an authentication strategy which requires no user interaction. -func (c *Config) Open() (connector.Connector, error) { - return New(), nil +func (c *CallbackConfig) Open() (connector.Connector, error) { + return NewCallbackConnector(), nil +} + +// PasswordConfig holds the configuration for a mock connector which prompts for the supplied +// username and password. +type PasswordConfig struct { + Username string `yaml:"username"` + Password string `yaml:"password"` +} + +// Open returns an authentication strategy which prompts for a predefined username and password. +func (c *PasswordConfig) Open() (connector.Connector, error) { + if c.Username == "" { + return nil, errors.New("no username supplied") + } + if c.Password == "" { + return nil, errors.New("no password supplied") + } + return &passwordConnector{c.Username, c.Password}, nil +} + +type passwordConnector struct { + username string + password string +} + +func (p passwordConnector) Close() error { return nil } + +func (p passwordConnector) Login(username, password string) (identity connector.Identity, validPassword bool, err error) { + if username == p.username && password == p.password { + return connector.Identity{ + UserID: "0-385-28089-0", + Username: "Kilgore Trout", + Email: "kilgore@kilgore.trout", + EmailVerified: true, + }, true, nil + } + return identity, false, nil } diff --git a/examples/config-dev.yaml b/examples/config-dev.yaml index 65267d31..fa61cc5e 100644 --- a/examples/config-dev.yaml +++ b/examples/config-dev.yaml @@ -7,25 +7,15 @@ web: http: 127.0.0.1:5556 connectors: -- type: mock - id: mock +- type: mockCallback + id: mock-callback name: Mock -- type: github - id: github - name: GitHub +- type: mockPassword + id: mock-password + name: Password config: - clientID: "$GITHUB_CLIENT_ID" - clientSecret: "$GITHUB_CLIENT_SECRET" - redirectURI: http://127.0.0.1:5556/callback/github - org: kubernetes -- type: oidc - id: google - name: Google Account - config: - issuer: https://accounts.google.com - clientID: "$GOOGLE_OAUTH2_CLIENT_ID" - clientSecret: "$GOOGLE_OAUTH2_CLIENT_SECRET" - redirectURI: http://127.0.0.1:5556/callback/google + username: "admin" + password: "PASSWORD" staticClients: - id: example-app diff --git a/server/handlers.go b/server/handlers.go index 3aa0b368..ebbc8740 100644 --- a/server/handlers.go +++ b/server/handlers.go @@ -129,15 +129,16 @@ func (s *Server) handleAuthorization(w http.ResponseWriter, r *http.Request) { connectorInfos := make([]connectorInfo, len(s.connectors)) i := 0 - for id := range s.connectors { + for id, conn := range s.connectors { connectorInfos[i] = connectorInfo{ - DisplayName: id, - URL: s.absPath("/auth", id), + ID: id, + Name: conn.DisplayName, + URL: s.absPath("/auth", id), } i++ } - renderLoginOptions(w, connectorInfos, state) + s.templates.login(w, connectorInfos, state) } func (s *Server) handleConnectorLogin(w http.ResponseWriter, r *http.Request) { @@ -163,7 +164,7 @@ func (s *Server) handleConnectorLogin(w http.ResponseWriter, r *http.Request) { } http.Redirect(w, r, callbackURL, http.StatusFound) case connector.PasswordConnector: - renderPasswordTmpl(w, state, r.URL.String(), "") + s.templates.password(w, state, r.URL.String(), "", false) default: s.notFound(w, r) } @@ -174,7 +175,7 @@ func (s *Server) handleConnectorLogin(w http.ResponseWriter, r *http.Request) { return } - username := r.FormValue("username") + username := r.FormValue("login") password := r.FormValue("password") identity, ok, err := passwordConnector.Login(username, password) @@ -184,7 +185,7 @@ func (s *Server) handleConnectorLogin(w http.ResponseWriter, r *http.Request) { return } if !ok { - renderPasswordTmpl(w, state, r.URL.String(), "Invalid credentials") + s.templates.password(w, state, r.URL.String(), username, true) return } redirectURL, err := s.finalizeLogin(identity, state, connID, conn.Connector) @@ -299,7 +300,7 @@ func (s *Server) handleApproval(w http.ResponseWriter, r *http.Request) { s.renderError(w, http.StatusInternalServerError, errServerError, "") return } - renderApprovalTmpl(w, authReq.ID, *authReq.Claims, client, authReq.Scopes) + s.templates.approval(w, authReq.ID, authReq.Claims.Username, client.Name, authReq.Scopes) case "POST": if r.FormValue("approval") != "approve" { s.renderError(w, http.StatusInternalServerError, "approval rejected", "") diff --git a/server/server.go b/server/server.go index ef2697db..2e3c00af 100644 --- a/server/server.go +++ b/server/server.go @@ -43,6 +43,8 @@ type Config struct { // If specified, the server will use this function for determining time. Now func() time.Time + + TemplateConfig TemplateConfig } func value(val, defaultValue time.Duration) time.Duration { @@ -63,6 +65,8 @@ type Server struct { mux http.Handler + templates *templates + // If enabled, don't prompt user for approval after logging in through connector. // No package level API to set this, only used in tests. skipApproval bool @@ -107,6 +111,11 @@ func newServer(c Config, rotationStrategy rotationStrategy) (*Server, error) { supported[respType] = true } + tmpls, err := loadTemplates(c.TemplateConfig) + if err != nil { + return nil, fmt.Errorf("server: failed to load templates: %v", err) + } + now := c.Now if now == nil { now = time.Now @@ -124,6 +133,7 @@ func newServer(c Config, rotationStrategy rotationStrategy) (*Server, error) { supportedResponseTypes: supported, idTokensValidFor: value(c.IDTokensValidFor, 24*time.Hour), now: now, + templates: tmpls, } for _, conn := range c.Connectors { diff --git a/server/server_test.go b/server/server_test.go index 4865ab1f..296f22cc 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -64,7 +64,7 @@ FDWV28nTP9sqbtsmU8Tem2jzMvZ7C/Q0AuDoKELFUpux8shm8wfIhyaPnXUGZoAZ Np4vUwMSYV5mopESLWOg3loBxKyLGFtgGKVCjGiQvy6zISQ4fQo= -----END RSA PRIVATE KEY-----`) -func newTestServer(updateConfig func(c *Config)) (*httptest.Server, *Server) { +func newTestServer(t *testing.T, updateConfig func(c *Config)) (*httptest.Server, *Server) { var server *Server s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server.ServeHTTP(w, r) @@ -76,7 +76,7 @@ func newTestServer(updateConfig func(c *Config)) (*httptest.Server, *Server) { { ID: "mock", DisplayName: "Mock", - Connector: mock.New(), + Connector: mock.NewCallbackConnector(), }, }, } @@ -87,21 +87,21 @@ func newTestServer(updateConfig func(c *Config)) (*httptest.Server, *Server) { var err error if server, err = newServer(config, staticRotationStrategy(testKey)); err != nil { - panic(err) + t.Fatal(err) } server.skipApproval = true // Don't prompt for approval, just immediately redirect with code. return s, server } func TestNewTestServer(t *testing.T) { - newTestServer(nil) + newTestServer(t, nil) } func TestDiscovery(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - httpServer, _ := newTestServer(func(c *Config) { + httpServer, _ := newTestServer(t, func(c *Config) { c.Issuer = c.Issuer + "/non-root-path" }) defer httpServer.Close() @@ -129,7 +129,7 @@ func TestOAuth2CodeFlow(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - httpServer, s := newTestServer(func(c *Config) { + httpServer, s := newTestServer(t, func(c *Config) { c.Issuer = c.Issuer + "/non-root-path" }) defer httpServer.Close() @@ -255,7 +255,7 @@ func TestOAuth2ImplicitFlow(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - httpServer, s := newTestServer(func(c *Config) { + httpServer, s := newTestServer(t, func(c *Config) { // Enable support for the implicit flow. c.SupportedResponseTypes = []string{"code", "token"} }) diff --git a/server/templates.go b/server/templates.go index 7bc64144..a0d1ce17 100644 --- a/server/templates.go +++ b/server/templates.go @@ -1,101 +1,196 @@ package server import ( + "fmt" + "io" + "io/ioutil" "log" "net/http" + "path/filepath" + "sort" "text/template" - - "github.com/coreos/dex/storage" ) -type connectorInfo struct { - DisplayName string - URL string +const ( + tmplApproval = "approval.html" + tmplLogin = "login.html" + tmplPassword = "password.html" +) + +const coreOSLogoURL = "https://coreos.com/assets/images/brand/coreos-wordmark-135x40px.png" + +var requiredTmpls = []string{ + tmplApproval, + tmplLogin, + tmplPassword, } -var loginTmpl = template.Must(template.New("login-template").Parse(` - - -

Login options

-{{ range $i, $connector := .Connectors }} -{{ $connector.DisplayName }} -{{ end }} - -`)) +// TemplateConfig describes. +type TemplateConfig struct { + // Directory of the templates. If empty, these will be loaded from memory. + Dir string `yaml:"dir"` + + // Defaults to the CoreOS logo and "dex". + LogoURL string `yaml:"logoURL"` + Issuer string `yaml:"issuerName"` +} + +type globalData struct { + LogoURL string + Issuer string +} + +func loadTemplates(config TemplateConfig) (*templates, error) { + var tmpls *template.Template + if config.Dir != "" { + files, err := ioutil.ReadDir(config.Dir) + if err != nil { + return nil, fmt.Errorf("read dir: %v", err) + } + filenames := []string{} + for _, file := range files { + if file.IsDir() { + continue + } + filenames = append(filenames, filepath.Join(config.Dir, file.Name())) + } + if len(filenames) == 0 { + return nil, fmt.Errorf("no files in template dir %s", config.Dir) + } + if tmpls, err = template.ParseFiles(filenames...); err != nil { + return nil, fmt.Errorf("parse files: %v", err) + } + } else { + // Load templates from memory. This code is largely copied from the standard library's + // ParseFiles source code. + // See: https://goo.gl/6Wm4mN + for name, data := range defaultTemplates { + var t *template.Template + if tmpls == nil { + tmpls = template.New(name) + } + if name == tmpls.Name() { + t = tmpls + } else { + t = tmpls.New(name) + } + if _, err := t.Parse(data); err != nil { + return nil, fmt.Errorf("parsing %s: %v", name, err) + } + } + } + + missingTmpls := []string{} + for _, tmplName := range requiredTmpls { + if tmpls.Lookup(tmplName) == nil { + missingTmpls = append(missingTmpls, tmplName) + } + } + if len(missingTmpls) > 0 { + return nil, fmt.Errorf("missing template(s): %s", missingTmpls) + } + + if config.LogoURL == "" { + config.LogoURL = coreOSLogoURL + } + if config.Issuer == "" { + config.Issuer = "dex" + } + + return &templates{ + globalData: config, + loginTmpl: tmpls.Lookup(tmplLogin), + approvalTmpl: tmpls.Lookup(tmplApproval), + passwordTmpl: tmpls.Lookup(tmplPassword), + }, nil +} + +var scopeDescriptions = map[string]string{ + "offline_access": "Have offline access", + "profile": "View basic profile information", + "email": "View your email", +} + +type templates struct { + globalData TemplateConfig + loginTmpl *template.Template + approvalTmpl *template.Template + passwordTmpl *template.Template +} + +type connectorInfo struct { + ID string + Name string + URL string +} + +type byName []connectorInfo + +func (n byName) Len() int { return len(n) } +func (n byName) Less(i, j int) bool { return n[i].Name < n[j].Name } +func (n byName) Swap(i, j int) { n[i], n[j] = n[j], n[i] } + +func (t *templates) login(w http.ResponseWriter, connectors []connectorInfo, state string) { + sort.Sort(byName(connectors)) -func renderLoginOptions(w http.ResponseWriter, connectors []connectorInfo, state string) { data := struct { + TemplateConfig Connectors []connectorInfo State string - }{connectors, state} - renderTemplate(w, loginTmpl, data) + }{t.globalData, connectors, state} + renderTemplate(w, t.loginTmpl, data) } -var passwordTmpl = template.Must(template.New("password-template").Parse(` - -

Login

-
-Login:
-Password:
- - -{{ if .Message }} -

Error: {{ .Message }}

-{{ end }} -
- -`)) - -func renderPasswordTmpl(w http.ResponseWriter, state, callback, message string) { +func (t *templates) password(w http.ResponseWriter, state, callback, lastUsername string, lastWasInvalid bool) { data := struct { + TemplateConfig State string - Callback string - Message string - }{state, callback, message} - renderTemplate(w, passwordTmpl, data) + PostURL string + Username string + Invalid bool + }{t.globalData, state, callback, lastUsername, lastWasInvalid} + renderTemplate(w, t.passwordTmpl, data) } -var approvalTmpl = template.Must(template.New("approval-template").Parse(` - -

User: {{ .User }}

-

Client: {{ .ClientName }}

-
- - - -
-
- - - -
- -`)) - -func renderApprovalTmpl(w http.ResponseWriter, state string, identity storage.Claims, client storage.Client, scopes []string) { +func (t *templates) approval(w http.ResponseWriter, state, username, clientName string, scopes []string) { + accesses := []string{} + for _, scope := range scopes { + access, ok := scopeDescriptions[scope] + if ok { + accesses = append(accesses, access) + } + } + sort.Strings(accesses) data := struct { - User string - ClientName string - State string - }{identity.Email, client.Name, state} - renderTemplate(w, approvalTmpl, data) + TemplateConfig + User string + Client string + State string + Scopes []string + }{t.globalData, username, clientName, state, accesses} + renderTemplate(w, t.approvalTmpl, data) +} + +// small io.Writer utilitiy to determine if executing the template wrote to the underlying response writer. +type writeRecorder struct { + wrote bool + w io.Writer +} + +func (w *writeRecorder) Write(p []byte) (n int, err error) { + w.wrote = true + return w.w.Write(p) } func renderTemplate(w http.ResponseWriter, tmpl *template.Template, data interface{}) { - err := tmpl.Execute(w, data) - if err == nil { - return - } - - switch err := err.(type) { - case template.ExecError: - // An ExecError guarentees that Execute has not written to the underlying reader. + wr := &writeRecorder{w: w} + if err := tmpl.Execute(wr, data); err != nil { log.Printf("Error rendering template %s: %s", tmpl.Name(), err) - // TODO(ericchiang): replace with better internal server error. - http.Error(w, "Internal server error", http.StatusInternalServerError) - default: - // An error with the underlying write, such as the connection being - // dropped. Ignore for now. + if !wr.wrote { + // TODO(ericchiang): replace with better internal server error. + http.Error(w, "Internal server error", http.StatusInternalServerError) + } } + return } diff --git a/server/templates_default.go b/server/templates_default.go new file mode 100644 index 00000000..fc272675 --- /dev/null +++ b/server/templates_default.go @@ -0,0 +1,12 @@ +// This file was generated by the makefile. Do not edit. + +package server + +// defaultTemplates is a key for file name to file data of the files in web/templates. +var defaultTemplates = map[string]string{ + "approval.html": "{{ template \"header.html\" . }}\n\n
\n

Grant Access

\n\n
\n
\n
{{ .Client }} would like to:
\n {{ range $scope := .Scopes }}\n
  • \n
    \n {{ $scope }}\n
    \n
  • \n {{ end }}\n
    \n
    \n\n
    \n
    \n
    \n \n \n \n
    \n
    \n
    \n
    \n \n \n \n
    \n
    \n
    \n\n
    \n\n{{ template \"footer.html\" . }}\n", + "footer.html": " \n \n\n", + "header.html": "\n\n \n \n \n {{ .Issuer }}\n \n \n \n\n \n
    \n
    \n \n
    \n
    \n\n
    \n\n", + "login.html": "{{ template \"header.html\" . }}\n\n
    \n

    Log in to {{ .Issuer }}

    \n\n
    \n {{ range $c := .Connectors }}\n \n {{ end }}\n
    \n\n
    \n\n\n{{ template \"footer.html\" . }}\n", + "password.html": "{{ template \"header.html\" . }}\n\n
    \n

    Log in to Your Account

    \n
    \n
    \n
    \n \n
    \n\t \n
    \n
    \n
    \n \n
    \n\t \n
    \n \n\n {{ if .Invalid }}\n
    \n Invalid username and password.\n
    \n {{ end }}\n\n \n\n
    \n
    \n\n{{ template \"footer.html\" . }}\n", +} diff --git a/server/templates_default_gen.go b/server/templates_default_gen.go new file mode 100644 index 00000000..0a5ab78a --- /dev/null +++ b/server/templates_default_gen.go @@ -0,0 +1,79 @@ +// +build ignore + +package main + +import ( + "bytes" + "fmt" + "io/ioutil" + "log" + "os/exec" + "path/filepath" +) + +// ignoreFile uses "git check-ignore" to determine if we should ignore a file. +func ignoreFile(p string) (ok bool, err error) { + err = exec.Command("git", "check-ignore", p).Run() + if err == nil { + return true, nil + } + exitErr, ok := err.(*exec.ExitError) + if ok { + if sys := exitErr.Sys(); sys != nil { + e, ok := sys.(interface { + // Is the returned value something that returns an exit status? + ExitStatus() int + }) + if ok && e.ExitStatus() == 1 { + return false, nil + } + } + } + return false, err +} + +type fileData struct { + name string + data string +} + +func main() { + dir, err := ioutil.ReadDir("web/templates") + if err != nil { + log.Fatal(err) + } + files := []fileData{} + for _, file := range dir { + p := filepath.Join("web/templates", file.Name()) + ignore, err := ignoreFile(p) + if err != nil { + log.Fatal(err) + } + if ignore { + continue + } + + data, err := ioutil.ReadFile(p) + if err != nil { + log.Fatal(err) + } + files = append(files, fileData{file.Name(), string(data)}) + } + + f := new(bytes.Buffer) + + fmt.Fprintln(f, "// This file was generated by the makefile. Do not edit.") + fmt.Fprintln(f) + fmt.Fprintln(f, "package server") + fmt.Fprintln(f) + fmt.Fprintln(f, "// defaultTemplates is a key for file name to file data of the files in web/templates.") + fmt.Fprintln(f, "var defaultTemplates = map[string]string{") + for _, file := range files { + fmt.Fprintf(f, "\t%q: %q,\n", file.name, file.data) + } + fmt.Fprintln(f, "}") + + if err := ioutil.WriteFile("server/templates_default.go", f.Bytes(), 0644); err != nil { + log.Fatal(err) + } +} diff --git a/server/templates_test.go b/server/templates_test.go index abb4e431..efbb29ed 100644 --- a/server/templates_test.go +++ b/server/templates_test.go @@ -1 +1,16 @@ package server + +import "testing" + +func TestNewTemplates(t *testing.T) { + var config TemplateConfig + if _, err := loadTemplates(config); err != nil { + t.Fatal(err) + } +} + +func TestLoadTemplates(t *testing.T) { + var config TemplateConfig + + config.Dir = "../web/templates" +} diff --git a/web/templates/approval.html b/web/templates/approval.html new file mode 100644 index 00000000..c73a522e --- /dev/null +++ b/web/templates/approval.html @@ -0,0 +1,42 @@ +{{ template "header.html" . }} + +
    +

    Grant Access

    + +
    +
    +
    {{ .Client }} would like to:
    + {{ range $scope := .Scopes }} +
  • +
    + {{ $scope }} +
    +
  • + {{ end }} +
    +
    + +
    +
    +
    + + + +
    +
    +
    +
    + + + +
    +
    +
    + +
    + +{{ template "footer.html" . }} diff --git a/web/templates/footer.html b/web/templates/footer.html new file mode 100644 index 00000000..5b6e2d65 --- /dev/null +++ b/web/templates/footer.html @@ -0,0 +1,3 @@ +
    + + diff --git a/web/templates/header.html b/web/templates/header.html new file mode 100644 index 00000000..cadb078d --- /dev/null +++ b/web/templates/header.html @@ -0,0 +1,240 @@ + + + + + + {{ .Issuer }} + + + + + + + +
    + diff --git a/web/templates/login.html b/web/templates/login.html new file mode 100644 index 00000000..d43b5142 --- /dev/null +++ b/web/templates/login.html @@ -0,0 +1,22 @@ +{{ template "header.html" . }} + +
    +

    Log in to {{ .Issuer }}

    + +
    + {{ range $c := .Connectors }} + + {{ end }} +
    + +
    + + +{{ template "footer.html" . }} diff --git a/web/templates/password.html b/web/templates/password.html new file mode 100644 index 00000000..89f833fc --- /dev/null +++ b/web/templates/password.html @@ -0,0 +1,31 @@ +{{ template "header.html" . }} + +
    +

    Log in to Your Account

    +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    + + + {{ if .Invalid }} +
    + Invalid username and password. +
    + {{ end }} + + + +
    +
    + +{{ template "footer.html" . }}