installer: generate and use invitations
diff --git a/core/installer/cmd/env_manager.go b/core/installer/cmd/env_manager.go
index 8e21c67..66bfb85 100644
--- a/core/installer/cmd/env_manager.go
+++ b/core/installer/cmd/env_manager.go
@@ -79,6 +79,7 @@
 		ss,
 		repoIO,
 		nsCreator,
+		installer.NewFixedLengthRandomNameGenerator(4),
 	)
 	log.Printf("Starting server\n")
 	s.Start()
diff --git a/core/installer/kube.go b/core/installer/kube.go
index 3fa253b..25a7103 100644
--- a/core/installer/kube.go
+++ b/core/installer/kube.go
@@ -2,7 +2,6 @@
 
 import (
 	"context"
-	"fmt"
 
 	corev1 "k8s.io/api/core/v1"
 	"k8s.io/apimachinery/pkg/api/errors"
diff --git a/core/installer/name.go b/core/installer/name.go
new file mode 100644
index 0000000..f0ba6a4
--- /dev/null
+++ b/core/installer/name.go
@@ -0,0 +1,29 @@
+package installer
+
+import (
+	"crypto/rand"
+)
+
+type NameGenerator interface {
+	Generate() (string, error)
+}
+
+type fixedLengthRandomNameGenerator struct {
+	len int
+}
+
+func NewFixedLengthRandomNameGenerator(len int) NameGenerator {
+	return &fixedLengthRandomNameGenerator{len}
+}
+
+func (g *fixedLengthRandomNameGenerator) Generate() (string, error) {
+	r := make([]byte, g.len)
+	if _, err := rand.Read(r); err != nil {
+		return "", err
+	}
+	ret := make([]rune, g.len)
+	for i, v := range r {
+		ret[i] += letters[v%26]
+	}
+	return string(ret), nil
+}
diff --git a/core/installer/welcome/create-env.html b/core/installer/welcome/create-env.html
index 6633d65..abaa197 100644
--- a/core/installer/welcome/create-env.html
+++ b/core/installer/welcome/create-env.html
@@ -12,12 +12,6 @@
                 <div>
                   <form action="/env" method="POST">
                     <input
-                      type="text"
-                      name="name"
-                      placeholder="Name"
-                      required
-                    />
-                    <input
                       type="test"
                       name="domain"
                       placeholder="Domain"
@@ -28,7 +22,12 @@
                       name="contact-email"
                       placeholder="Contact Email"
                       required
-                      />
+                    />
+                    <textarea
+                      name="secret-token"
+                      placeholder="Secret Token"
+                      required
+                    ></textarea>
                     <button type="submit" class="contrast">Create Environment</button>
                   </form>
                 </div>
diff --git a/core/installer/welcome/env-created.html b/core/installer/welcome/env-created.html
new file mode 100644
index 0000000..0a64116
--- /dev/null
+++ b/core/installer/welcome/env-created.html
@@ -0,0 +1,15 @@
+<!DOCTYPE html>
+<html lang="en" data-theme="light">
+	<head>
+        <link rel="stylesheet" href="/static/pico.min.css">
+		<meta charset="utf-8" />
+		<meta name="viewport" content="width=device-width, initial-scale=1" />
+	</head>
+	<body>
+		<div style="display: contents">
+            <main class="container">
+				Environment for {{ .Domain }} is being configured. In few minutes go to <a href="https://welcome.{{ .Domain }}">https://welcome.<b>{{ .Domain }}</b></a> to set up administrative account.
+            </main>
+        </div>
+	</body>
+</html>
diff --git a/core/installer/welcome/env.go b/core/installer/welcome/env.go
index 20b4354..029f6df 100644
--- a/core/installer/welcome/env.go
+++ b/core/installer/welcome/env.go
@@ -5,6 +5,8 @@
 	"encoding/base64"
 	"encoding/json"
 	"fmt"
+	htemplate "html/template"
+	"io"
 	"log"
 	"net/http"
 	"path"
@@ -23,19 +25,37 @@
 //go:embed create-env.html
 var createEnvFormHtml string
 
-type EnvServer struct {
-	port      int
-	ss        *soft.Client
-	repo      installer.RepoIO
-	nsCreator installer.NamespaceCreator
+//go:embed env-created.html
+var envCreatedHtml string
+
+type Status string
+
+const (
+	StatusActive   Status = "ACTIVE"
+	StatusAccepted Status = "ACCEPTED"
+)
+
+// TODO(giolekva): add CreatedAt and ValidUntil
+type invitation struct {
+	Token  string `json:"token"`
+	Status Status `json:"status"`
 }
 
-func NewEnvServer(port int, ss *soft.Client, repo installer.RepoIO, nsCreator installer.NamespaceCreator) *EnvServer {
+type EnvServer struct {
+	port          int
+	ss            *soft.Client
+	repo          installer.RepoIO
+	nsCreator     installer.NamespaceCreator
+	nameGenerator installer.NameGenerator
+}
+
+func NewEnvServer(port int, ss *soft.Client, repo installer.RepoIO, nsCreator installer.NamespaceCreator, nameGenerator installer.NameGenerator) *EnvServer {
 	return &EnvServer{
 		port,
 		ss,
 		repo,
 		nsCreator,
+		nameGenerator,
 	}
 }
 
@@ -44,21 +64,78 @@
 	r.PathPrefix("/static/").Handler(http.FileServer(http.FS(staticAssets)))
 	r.Path("/env").Methods("GET").HandlerFunc(s.createEnvForm)
 	r.Path("/env").Methods("POST").HandlerFunc(s.createEnv)
+	r.Path("/create-invitation").Methods("GET").HandlerFunc(s.createInvitation)
 	http.Handle("/", r)
 	log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", s.port), nil))
 }
 
 func (s *EnvServer) createEnvForm(w http.ResponseWriter, r *http.Request) {
-	log.Printf("asdasd\n")
 	if _, err := w.Write([]byte(createEnvFormHtml)); err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 	}
 }
 
+func (s *EnvServer) createInvitation(w http.ResponseWriter, r *http.Request) {
+	invitations, err := s.readInvitations()
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	token, err := installer.NewFixedLengthRandomNameGenerator(100).Generate() // TODO(giolekva): use cryptographic tokens
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+
+	}
+	invitations = append(invitations, invitation{token, StatusActive})
+	if err := s.writeInvitations(invitations); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	if _, err := w.Write([]byte("OK")); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+}
+
 type createEnvReq struct {
-	Name         string `json:"name"`
+	Name         string
 	ContactEmail string `json:"contactEmail"`
 	Domain       string `json:"domain"`
+	SecretToken  string `json:"secretToken"`
+}
+
+func (s *EnvServer) readInvitations() ([]invitation, error) {
+	r, err := s.repo.Reader("invitations")
+	if err != nil {
+		return nil, err
+	}
+	defer r.Close()
+	dec := json.NewDecoder(r)
+	invitations := make([]invitation, 0)
+	for {
+		var i invitation
+		if err := dec.Decode(&i); err == io.EOF {
+			break
+		}
+		invitations = append(invitations, i)
+	}
+	return invitations, nil
+}
+
+func (s *EnvServer) writeInvitations(invitations []invitation) error {
+	w, err := s.repo.Writer("invitations")
+	if err != nil {
+		return err
+	}
+	defer w.Close()
+	enc := json.NewEncoder(w)
+	for _, i := range invitations {
+		if err := enc.Encode(i); err != nil {
+			return err
+		}
+	}
+	return s.repo.CommitAndPush("Generated new invitation")
 }
 
 func (s *EnvServer) createEnv(w http.ResponseWriter, r *http.Request) {
@@ -68,7 +145,7 @@
 		if err = r.ParseForm(); err != nil {
 			return err
 		}
-		if req.Name, err = getFormValue(r.PostForm, "name"); err != nil {
+		if req.SecretToken, err = getFormValue(r.PostForm, "secret-token"); err != nil {
 			return err
 		}
 		if req.Domain, err = getFormValue(r.PostForm, "domain"); err != nil {
@@ -84,6 +161,33 @@
 			return
 		}
 	}
+	invitations, err := s.readInvitations()
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	found := false
+	for _, i := range invitations {
+		if i.Token == req.SecretToken && i.Status == StatusActive {
+			i.Status = StatusAccepted
+			found = true
+			break
+		}
+	}
+	if !found {
+		http.Error(w, "Invalid invitation", http.StatusNotFound)
+		return
+	}
+	if err := s.writeInvitations(invitations); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	if name, err := s.nameGenerator.Generate(); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	} else {
+		req.Name = name
+	}
 	fluxUserName := fmt.Sprintf("flux-%s", req.Name)
 	keys, err := installer.NewSSHKeyPair(fluxUserName)
 	if err != nil {
@@ -143,7 +247,14 @@
 			return
 		}
 	}
-	if _, err := w.Write([]byte("OK")); err != nil {
+	tmpl, err := htemplate.New("response").Parse(envCreatedHtml)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	if err := tmpl.Execute(w, map[string]any{
+		"Domain": req.Domain,
+	}); err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}