Merge "Installer: Give unique names to OAuth2 clients" into main
diff --git a/charts/dodo-app/templates/install.yaml b/charts/dodo-app/templates/install.yaml
index 50ff7ab..d63326d 100644
--- a/charts/dodo-app/templates/install.yaml
+++ b/charts/dodo-app/templates/install.yaml
@@ -122,6 +122,7 @@
         - --api-port={{ .Values.apiPort }}
         - --self={{ .Values.self }}
         - --namespace={{ .Values.namespace }} # TODO(gio): maybe use .Release.Namespace ?
+        - --env-app-manager-addr={{ .Values.envAppManagerAddr }}
         - --env-config=/pcloud/env-config/config.json
         - --app-admin-key={{ .Values.appAdminKey }}
         - --git-repo-public-key={{ .Values.gitRepoPublicKey }}
diff --git a/charts/dodo-app/values.yaml b/charts/dodo-app/values.yaml
index 50aae4d..4c0b788 100644
--- a/charts/dodo-app/values.yaml
+++ b/charts/dodo-app/values.yaml
@@ -9,6 +9,7 @@
 sshPrivateKey: key
 self: ""
 namespace: ""
+envAppManagerAddr: ""
 envConfig: ""
 appAdminKey: ""
 gitRepoPublicKey: ""
diff --git a/core/installer/app.go b/core/installer/app.go
index 1922b48..dcd2ca2 100644
--- a/core/installer/app.go
+++ b/core/installer/app.go
@@ -186,7 +186,7 @@
 
 type EnvApp interface {
 	App
-	Render(release Release, env EnvConfig, values map[string]any, charts map[string]helmv2.HelmChartTemplateSpec) (EnvAppRendered, error)
+	Render(release Release, env EnvConfig, networks []Network, values map[string]any, charts map[string]helmv2.HelmChartTemplateSpec) (EnvAppRendered, error)
 }
 
 type cueApp struct {
@@ -435,8 +435,13 @@
 	return AppTypeEnv
 }
 
-func (a cueEnvApp) Render(release Release, env EnvConfig, values map[string]any, charts map[string]helmv2.HelmChartTemplateSpec) (EnvAppRendered, error) {
-	networks := CreateNetworks(env)
+func (a cueEnvApp) Render(
+	release Release,
+	env EnvConfig,
+	networks []Network,
+	values map[string]any,
+	charts map[string]helmv2.HelmChartTemplateSpec,
+) (EnvAppRendered, error) {
 	derived, err := deriveValues(values, a.Schema(), networks)
 	if err != nil {
 		return EnvAppRendered{}, err
diff --git a/core/installer/app_manager.go b/core/installer/app_manager.go
index 0930356..4fc0b1f 100644
--- a/core/installer/app_manager.go
+++ b/core/installer/app_manager.go
@@ -87,7 +87,11 @@
 func (m *AppManager) FindAllAppInstances(name string) ([]AppInstanceConfig, error) {
 	kust, err := soft.ReadKustomization(m.repoIO, filepath.Join(m.appDirRoot, "kustomization.yaml"))
 	if err != nil {
-		return nil, err
+		if errors.Is(err, fs.ErrNotExist) {
+			return nil, nil
+		} else {
+			return nil, err
+		}
 	}
 	ret := make([]AppInstanceConfig, 0)
 	for _, app := range kust.Resources {
@@ -404,6 +408,16 @@
 			return ReleaseResources{}, err
 		}
 	}
+	var networks []Network
+	if o.Networks != nil {
+		networks = o.Networks
+	} else {
+		var err error
+		networks, err = m.CreateNetworks(env)
+		if err != nil {
+			return ReleaseResources{}, err
+		}
+	}
 	var lg LocalChartGenerator
 	if o.LG != nil {
 		lg = o.LG
@@ -416,7 +430,7 @@
 		RepoAddr:      m.repoIO.FullAddress(),
 		AppDir:        appDir,
 	}
-	rendered, err := app.Render(release, env, values, nil)
+	rendered, err := app.Render(release, env, networks, values, nil)
 	if err != nil {
 		return ReleaseResources{}, err
 	}
@@ -447,7 +461,7 @@
 	if o.FetchContainerImages {
 		release.ImageRegistry = imageRegistry
 	}
-	rendered, err = app.Render(release, env, values, localCharts)
+	rendered, err = app.Render(release, env, networks, values, localCharts)
 	if err != nil {
 		return ReleaseResources{}, err
 	}
@@ -456,7 +470,6 @@
 	}
 	// TODO(gio): add ingress-nginx to release resources
 	if err := openPorts(rendered.Ports, portReservations, allocators); err != nil {
-		fmt.Println(err)
 		return ReleaseResources{}, err
 	}
 	return ReleaseResources{
@@ -531,7 +544,11 @@
 	if err != nil {
 		return ReleaseResources{}, err
 	}
-	rendered, err := app.Render(config.Release, env, values, renderedCfg.LocalCharts)
+	networks, err := m.CreateNetworks(env)
+	if err != nil {
+		return ReleaseResources{}, err
+	}
+	rendered, err := app.Render(config.Release, env, networks, values, renderedCfg.LocalCharts)
 	if err != nil {
 		return ReleaseResources{}, err
 	}
@@ -563,15 +580,14 @@
 		return err
 	}
 	if err := closePorts(portForward); err != nil {
-		fmt.Println(err)
 		return err
 	}
 	return nil
 }
 
 // TODO(gio): deduplicate with cue definition in app.go, this one should be removed.
-func CreateNetworks(env EnvConfig) []Network {
-	return []Network{
+func (m *AppManager) CreateNetworks(env EnvConfig) ([]Network, error) {
+	ret := []Network{
 		{
 			Name:               "Public",
 			IngressClass:       fmt.Sprintf("%s-ingress-public", env.InfraName),
@@ -590,11 +606,28 @@
 			DeallocatePortAddr: fmt.Sprintf("http://port-allocator.%s-ingress-private.svc.cluster.local/api/remove", env.Id),
 		},
 	}
+	n, err := m.FindAllAppInstances("network")
+	if err != nil {
+		return nil, err
+	}
+	for _, a := range n {
+		ret = append(ret, Network{
+			Name:               a.Input["name"].(string),
+			IngressClass:       fmt.Sprintf("%s-ingress-public", env.InfraName),
+			CertificateIssuer:  fmt.Sprintf("%s-public", env.Id),
+			Domain:             a.Input["domain"].(string),
+			AllocatePortAddr:   fmt.Sprintf("http://port-allocator.%s-ingress-public.svc.cluster.local/api/allocate", env.InfraName),
+			ReservePortAddr:    fmt.Sprintf("http://port-allocator.%s-ingress-public.svc.cluster.local/api/reserve", env.InfraName),
+			DeallocatePortAddr: fmt.Sprintf("http://port-allocator.%s-ingress-public.svc.cluster.local/api/remove", env.InfraName),
+		})
+	}
+	return ret, nil
 }
 
 type installOptions struct {
 	NoPublish            bool
 	Env                  *EnvConfig
+	Networks             []Network
 	Branch               string
 	LG                   LocalChartGenerator
 	FetchContainerImages bool
@@ -610,6 +643,12 @@
 	}
 }
 
+func WithNetworks(networks []Network) InstallOption {
+	return func(o *installOptions) {
+		o.Networks = networks
+	}
+}
+
 func WithBranch(branch string) InstallOption {
 	return func(o *installOptions) {
 		o.Branch = branch
diff --git a/core/installer/app_repository.go b/core/installer/app_repository.go
index 27b9b43..df35ae8 100644
--- a/core/installer/app_repository.go
+++ b/core/installer/app_repository.go
@@ -33,6 +33,7 @@
 	// "values-tmpl/qbittorrent.cue",
 	// "values-tmpl/jellyfin.cue",
 	"values-tmpl/rpuppy.cue",
+	"values-tmpl/certificate-issuer-custom.cue",
 }
 
 var envAppConfigs = []string{
diff --git a/core/installer/app_test.go b/core/installer/app_test.go
index 7935ec2..7e64d50 100644
--- a/core/installer/app_test.go
+++ b/core/installer/app_test.go
@@ -2,28 +2,51 @@
 
 import (
 	_ "embed"
+	"fmt"
 	"net"
 	"testing"
 )
 
-var env = EnvConfig{
-	InfraName:       "dodo",
-	Id:              "id",
-	ContactEmail:    "foo@bar.ge",
-	Domain:          "bar.ge",
-	PrivateDomain:   "p.bar.ge",
-	PublicIP:        []net.IP{net.ParseIP("1.2.3.4")},
-	NameserverIP:    []net.IP{net.ParseIP("1.2.3.4")},
-	NamespacePrefix: "id-",
-	Network: EnvNetwork{
-		DNS:            net.ParseIP("1.1.1.1"),
-		DNSInClusterIP: net.ParseIP("2.2.2.2"),
-		Ingress:        net.ParseIP("3.3.3.3"),
-		Headscale:      net.ParseIP("4.4.4.4"),
-		ServicesFrom:   net.ParseIP("5.5.5.5"),
-		ServicesTo:     net.ParseIP("6.6.6.6"),
-	},
-}
+var (
+	env = EnvConfig{
+		InfraName:       "dodo",
+		Id:              "id",
+		ContactEmail:    "foo@bar.ge",
+		Domain:          "bar.ge",
+		PrivateDomain:   "p.bar.ge",
+		PublicIP:        []net.IP{net.ParseIP("1.2.3.4")},
+		NameserverIP:    []net.IP{net.ParseIP("1.2.3.4")},
+		NamespacePrefix: "id-",
+		Network: EnvNetwork{
+			DNS:            net.ParseIP("1.1.1.1"),
+			DNSInClusterIP: net.ParseIP("2.2.2.2"),
+			Ingress:        net.ParseIP("3.3.3.3"),
+			Headscale:      net.ParseIP("4.4.4.4"),
+			ServicesFrom:   net.ParseIP("5.5.5.5"),
+			ServicesTo:     net.ParseIP("6.6.6.6"),
+		},
+	}
+
+	networks = []Network{
+		{
+			Name:               "Public",
+			IngressClass:       fmt.Sprintf("%s-ingress-public", env.InfraName),
+			CertificateIssuer:  fmt.Sprintf("%s-public", env.Id),
+			Domain:             env.Domain,
+			AllocatePortAddr:   fmt.Sprintf("http://port-allocator.%s-ingress-public.svc.cluster.local/api/allocate", env.InfraName),
+			ReservePortAddr:    fmt.Sprintf("http://port-allocator.%s-ingress-public.svc.cluster.local/api/reserve", env.InfraName),
+			DeallocatePortAddr: fmt.Sprintf("http://port-allocator.%s-ingress-public.svc.cluster.local/api/remove", env.InfraName),
+		},
+		{
+			Name:               "Private",
+			IngressClass:       fmt.Sprintf("%s-ingress-private", env.Id),
+			Domain:             env.PrivateDomain,
+			AllocatePortAddr:   fmt.Sprintf("http://port-allocator.%s-ingress-private.svc.cluster.local/api/allocate", env.Id),
+			ReservePortAddr:    fmt.Sprintf("http://port-allocator.%s-ingress-private.svc.cluster.local/api/reserve", env.Id),
+			DeallocatePortAddr: fmt.Sprintf("http://port-allocator.%s-ingress-private.svc.cluster.local/api/remove", env.Id),
+		},
+	}
+)
 
 func TestAuthProxyEnabled(t *testing.T) {
 	r := NewInMemoryAppRepository(CreateAllApps())
@@ -46,7 +69,7 @@
 				"groups":  "a,b",
 			},
 		}
-		rendered, err := a.Render(release, env, values, nil)
+		rendered, err := a.Render(release, env, networks, values, nil)
 		if err != nil {
 			t.Fatal(err)
 		}
@@ -76,7 +99,7 @@
 				"enabled": false,
 			},
 		}
-		rendered, err := a.Render(release, env, values, nil)
+		rendered, err := a.Render(release, env, networks, values, nil)
 		if err != nil {
 			t.Fatal(err)
 		}
@@ -101,7 +124,7 @@
 	values := map[string]any{
 		"authGroups": "foo,bar",
 	}
-	rendered, err := a.Render(release, env, values, nil)
+	rendered, err := a.Render(release, env, networks, values, nil)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -131,7 +154,7 @@
 		},
 		"sshPort": 22,
 	}
-	rendered, err := a.Render(release, env, values, nil)
+	rendered, err := a.Render(release, env, networks, values, nil)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -156,7 +179,7 @@
 		"subdomain": "jenkins",
 		"network":   "Private",
 	}
-	rendered, err := a.Render(release, env, values, nil)
+	rendered, err := a.Render(release, env, networks, values, nil)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -215,7 +238,7 @@
 		},
 		"sshPrivateKey": "private",
 	}
-	rendered, err := a.Render(release, env, values, nil)
+	rendered, err := a.Render(release, env, networks, values, nil)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -248,7 +271,7 @@
 			"groups":  "a,b",
 		},
 	}
-	rendered, err := app.Render(release, env, values, nil)
+	rendered, err := app.Render(release, env, networks, values, nil)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -314,7 +337,7 @@
 		RepoAddr:      "ssh://192.168.100.210:22/config",
 		AppDir:        "/foo/bar",
 	}
-	_, err = app.Render(release, env, map[string]any{
+	_, err = app.Render(release, env, networks, map[string]any{
 		"repoAddr":      "",
 		"managerAddr":   "",
 		"appId":         "",
@@ -342,7 +365,7 @@
 		"repoHost":         "",
 		"gitRepoPublicKey": "",
 	}
-	rendered, err := a.Render(release, env, values, nil)
+	rendered, err := a.Render(release, env, networks, values, nil)
 	if err != nil {
 		t.Fatal(err)
 	}
diff --git a/core/installer/cmd/dodo_app.go b/core/installer/cmd/dodo_app.go
index 4b34f01..2eb5b8f 100644
--- a/core/installer/cmd/dodo_app.go
+++ b/core/installer/cmd/dodo_app.go
@@ -17,16 +17,17 @@
 )
 
 var dodoAppFlags struct {
-	port             int
-	apiPort          int
-	sshKey           string
-	repoAddr         string
-	self             string
-	namespace        string
-	envConfig        string
-	appAdminKey      string
-	gitRepoPublicKey string
-	db               string
+	port              int
+	apiPort           int
+	sshKey            string
+	repoAddr          string
+	self              string
+	namespace         string
+	envAppManagerAddr string
+	envConfig         string
+	appAdminKey       string
+	gitRepoPublicKey  string
+	db                string
 }
 
 func dodoAppCmd() *cobra.Command {
@@ -77,6 +78,12 @@
 		"",
 	)
 	cmd.Flags().StringVar(
+		&dodoAppFlags.envAppManagerAddr,
+		"env-app-manager-addr",
+		"",
+		"",
+	)
+	cmd.Flags().StringVar(
 		&dodoAppFlags.envConfig,
 		"env-config",
 		"",
@@ -152,6 +159,7 @@
 		dodoAppFlags.gitRepoPublicKey,
 		softClient,
 		dodoAppFlags.namespace,
+		dodoAppFlags.envAppManagerAddr,
 		nsc,
 		jc,
 		env,
@@ -160,7 +168,7 @@
 		return err
 	}
 	if dodoAppFlags.appAdminKey != "" {
-		if err := s.CreateApp("app", dodoAppFlags.appAdminKey); err != nil {
+		if _, err := s.CreateApp("app", dodoAppFlags.appAdminKey); err != nil {
 			return err
 		}
 	}
diff --git a/core/installer/go.mod b/core/installer/go.mod
index 0e389fa..c58ffaf 100644
--- a/core/installer/go.mod
+++ b/core/installer/go.mod
@@ -14,6 +14,7 @@
 	github.com/go-git/go-git/v5 v5.12.0
 	github.com/gomarkdown/markdown v0.0.0-20240328165702-4d01890c35c0
 	github.com/gorilla/mux v1.8.1
+	github.com/gorilla/securecookie v1.1.2
 	github.com/libdns/gandi v1.0.3
 	github.com/libdns/libdns v0.2.2
 	github.com/miekg/dns v1.1.58
diff --git a/core/installer/go.sum b/core/installer/go.sum
index 8356115..fc743fe 100644
--- a/core/installer/go.sum
+++ b/core/installer/go.sum
@@ -208,6 +208,8 @@
 github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q=
 github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
 github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
+github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
+github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
 github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
 github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
 github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
diff --git a/core/installer/values-tmpl/certificate-issuer-custom.cue b/core/installer/values-tmpl/certificate-issuer-custom.cue
new file mode 100644
index 0000000..382e8fa
--- /dev/null
+++ b/core/installer/values-tmpl/certificate-issuer-custom.cue
@@ -0,0 +1,41 @@
+input: {
+	name: string
+	domain: string
+}
+
+images: {}
+
+name: "Network"
+namespace: "ingress-custom"
+readme: "Configure custom public domain"
+description: readme
+icon: "<svg xmlns='http://www.w3.org/2000/svg' width='50' height='50' viewBox='0 0 48 48'><g fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='4'><path d='M4 34h8v8H4zM8 6h32v12H8zm16 28V18'/><path d='M8 34v-8h32v8m-4 0h8v8h-8zm-16 0h8v8h-8zm-6-22h2'/></g></svg>"
+
+charts: {
+	"certificate-issuer-public": {
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/certificate-issuer-public"
+	}
+}
+
+helm: {
+	"certificate-issuer-public": {
+		chart: charts["certificate-issuer-public"]
+		dependsOn: [{
+			name: "ingress-nginx"
+			namespace: "\(global.namespacePrefix)ingress-private"
+		}]
+		values: {
+			issuer: {
+				name: input.name
+				server: "https://acme-v02.api.letsencrypt.org/directory"
+				// server: "https://acme-staging-v02.api.letsencrypt.org/directory"
+				domain: input.domain
+				contactEmail: global.contactEmail
+				ingressClass: ingressPublic
+			}
+		}
+	}
+}
diff --git a/core/installer/values-tmpl/dodo-app.cue b/core/installer/values-tmpl/dodo-app.cue
index ddd3b17..99d4418 100644
--- a/core/installer/values-tmpl/dodo-app.cue
+++ b/core/installer/values-tmpl/dodo-app.cue
@@ -112,6 +112,7 @@
 			sshPrivateKey: base64.Encode(null, input.dAppKeys.private)
 			self: "api.\(release.namespace).svc.cluster.local"
 			namespace: release.namespace
+			envAppManagerAddr: "http://appmanager.\(global.namespacePrefix)appmanager.svc.cluster.local"
 			envConfig: base64.Encode(null, json.Marshal(global))
 			appAdminKey: input.adminKey
 			gitRepoPublicKey: input.ssKeys.public
@@ -172,8 +173,7 @@
 
 help: [{
 	title: "How to use"
-	contents: """
-	Clone: git clone ssh://\(_domain):\(input.sshPort)/app  <button onClick='copyToClipboard(this, "git clone ssh://\(_domain):\(input.sshPort)/app")'><svg width='24px' height='24px' viewBox='-2.4 -2.4 28.80 28.80' fill='none' xmlns='http://www.w3.org/2000/svg'><g id='SVGRepo_bgCarrier' stroke-width='0'></g><g id='SVGRepo_tracerCarrier' stroke-linecap='round' stroke-linejoin='round'></g><g id='SVGRepo_iconCarrier'> <path fill-rule='evenodd' clip-rule='evenodd' d='M19.5 16.5L19.5 4.5L18.75 3.75H9L8.25 4.5L8.25 7.5L5.25 7.5L4.5 8.25V20.25L5.25 21H15L15.75 20.25V17.25H18.75L19.5 16.5ZM15.75 15.75L15.75 8.25L15 7.5L9.75 7.5V5.25L18 5.25V15.75H15.75ZM6 9L14.25 9L14.25 19.5L6 19.5L6 9Z' fill='#080341'></path> </g></svg></button>  
-	Server public key: \(input.ssKeys.public)
+	"contents": """
+	Clone: git clone ssh://\(_domain):\(input.sshPort)/app <div onClick='copyToClipboard(this, "git clone ssh://\(_domain):\(input.sshPort)/app")' style='display: inline-block; cursor: pointer;'> <svg width='26px' height='26px' viewBox='-0 -0 28.80 28.80' fill='#7f9f7f' xmlns='http://www.w3.org/2000/svg' style='outline: none;'> <g id='SVGRepo_bgCarrier' stroke-width='0'></g> <g id='SVGRepo_tracerCarrier' stroke-linecap='round' stroke-linejoin='round'></g> <g id='SVGRepo_iconCarrier'> <path fill-rule='evenodd' clip-rule='evenodd' d='M19.5 16.5L19.5 4.5L18.75 3.75H9L8.25 4.5L8.25 7.5L5.25 7.5L4.5 8.25V20.25L5.25 21H15L15.75 20.25V17.25H18.75L19.5 16.5ZM15.75 15.75L15.75 8.25L15 7.5L9.75 7.5V5.25L18 5.25V15.75H15.75ZM6 9L14.25 9L14.25 19.5L6 19.5L6 9Z' fill='#7f9f7f'></path> </g> </svg> </div>  Server public key: \(input.ssKeys.public)
 	"""
 }]
diff --git a/core/installer/values-tmpl/headscale.cue b/core/installer/values-tmpl/headscale.cue
index 18b0d6e..726acd8 100644
--- a/core/installer/values-tmpl/headscale.cue
+++ b/core/installer/values-tmpl/headscale.cue
@@ -5,7 +5,7 @@
 
 name: "headscale"
 namespace: "app-headscale"
-icon: "<svg xmlns='http://www.w3.org/2000/svg' width='50' height='50' viewBox='0 0 48 48'><circle cx='24' cy='24' r='4.5' fill='none' stroke='black' stroke-linecap='round' stroke-linejoin='round'/><circle cx='38' cy='24' r='4.5' fill='none' stroke='black' stroke-linecap='round' stroke-linejoin='round'/><circle cx='38' cy='10' r='4.5' fill='none' stroke='black' stroke-linecap='round' stroke-linejoin='round'/><circle cx='24' cy='10' r='4.5' fill='none' stroke='black' stroke-linecap='round' stroke-linejoin='round'/><circle cx='10' cy='10' r='4.5' fill='none' stroke='black' stroke-linecap='round' stroke-linejoin='round'/><circle cx='10' cy='24' r='4.5' fill='none' stroke='black' stroke-linecap='round' stroke-linejoin='round'/><circle cx='10' cy='38' r='4.5' fill='none' stroke='black' stroke-linecap='round' stroke-linejoin='round'/><circle cx='24' cy='38' r='4.5' fill='none' stroke='black' stroke-linecap='round' stroke-linejoin='round'/><circle cx='38' cy='38' r='4.5' fill='none' stroke='black' stroke-linecap='round' stroke-linejoin='round'/><circle cx='24' cy='38' r='2' fill='none' stroke='black' stroke-linecap='round' stroke-linejoin='round'/><circle cx='24' cy='24' r='2' fill='none' stroke='black' stroke-linecap='round' stroke-linejoin='round'/><circle cx='10' cy='24' r='2' fill='none' stroke='black' stroke-linecap='round' stroke-linejoin='round'/><circle cx='38' cy='24' r='2' fill='none' stroke='black' stroke-linecap='round' stroke-linejoin='round'/></svg>"
+icon: "<svg xmlns='http://www.w3.org/2000/svg' width='50' height='50' viewBox='0 0 48 48'><circle cx='24' cy='24' r='4.5' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round'/><circle cx='38' cy='24' r='4.5' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round'/><circle cx='38' cy='10' r='4.5' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round'/><circle cx='24' cy='10' r='4.5' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round'/><circle cx='10' cy='10' r='4.5' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round'/><circle cx='10' cy='24' r='4.5' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round'/><circle cx='10' cy='38' r='4.5' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round'/><circle cx='24' cy='38' r='4.5' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round'/><circle cx='38' cy='38' r='4.5' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round'/><circle cx='24' cy='38' r='2' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round'/><circle cx='24' cy='24' r='2' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round'/><circle cx='10' cy='24' r='2' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round'/><circle cx='38' cy='24' r='2' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round'/></svg>"
 
 images: {
 	headscale: {
diff --git a/core/installer/welcome/appmanager.go b/core/installer/welcome/appmanager.go
index a4430b8..2c434c6 100644
--- a/core/installer/welcome/appmanager.go
+++ b/core/installer/welcome/appmanager.go
@@ -93,6 +93,7 @@
 func (s *AppManagerServer) Start() error {
 	r := mux.NewRouter()
 	r.PathPrefix("/static/").Handler(cachingHandler{http.FileServer(http.FS(staticAssets))})
+	r.HandleFunc("/api/networks", s.handleNetworks).Methods(http.MethodGet)
 	r.HandleFunc("/api/app-repo", s.handleAppRepo)
 	r.HandleFunc("/api/app/{slug}/install", s.handleAppInstall).Methods(http.MethodPost)
 	r.HandleFunc("/api/app/{slug}", s.handleApp).Methods(http.MethodGet)
@@ -116,6 +117,23 @@
 	Instances        []installer.AppInstanceConfig `json:"instances,omitempty"`
 }
 
+func (s *AppManagerServer) handleNetworks(w http.ResponseWriter, r *http.Request) {
+	env, err := s.m.Config()
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	networks, err := s.m.CreateNetworks(env)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	if err := json.NewEncoder(w).Encode(networks); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+}
+
 func (s *AppManagerServer) handleAppRepo(w http.ResponseWriter, r *http.Request) {
 	all, err := s.r.GetAll()
 	if err != nil {
@@ -414,10 +432,15 @@
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
+	networks, err := s.m.CreateNetworks(global)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
 	data := appPageData{
 		App:               a,
 		Instances:         instances,
-		AvailableNetworks: installer.CreateNetworks(global),
+		AvailableNetworks: networks,
 		CurrentPage:       a.Name(),
 	}
 	if err := s.tmpl.app.Execute(w, data); err != nil {
@@ -452,12 +475,17 @@
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
+	networks, err := s.m.CreateNetworks(global)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
 	t := s.tasks[slug]
 	data := appPageData{
 		App:               a,
 		Instance:          instance,
 		Instances:         instances,
-		AvailableNetworks: installer.CreateNetworks(global),
+		AvailableNetworks: networks,
 		Task:              t,
 		CurrentPage:       instance.Id,
 	}
diff --git a/core/installer/welcome/dodo_app.go b/core/installer/welcome/dodo_app.go
index 89b7ad2..20e881d 100644
--- a/core/installer/welcome/dodo_app.go
+++ b/core/installer/welcome/dodo_app.go
@@ -1,9 +1,11 @@
 package welcome
 
 import (
+	"context"
 	"encoding/json"
 	"errors"
 	"fmt"
+	"golang.org/x/crypto/bcrypt"
 	"io"
 	"io/fs"
 	"net/http"
@@ -14,28 +16,35 @@
 	"github.com/giolekva/pcloud/core/installer/soft"
 
 	"github.com/gorilla/mux"
+	"github.com/gorilla/securecookie"
 )
 
 const (
 	ConfigRepoName = "config"
 	namespacesFile = "/namespaces.json"
+	loginPath      = "/login"
+	logoutPath     = "/logout"
+	sessionCookie  = "dodo-app-session"
+	userCtx        = "user"
 )
 
 type DodoAppServer struct {
-	l                sync.Locker
-	st               Store
-	port             int
-	apiPort          int
-	self             string
-	sshKey           string
-	gitRepoPublicKey string
-	client           soft.Client
-	namespace        string
-	env              installer.EnvConfig
-	nsc              installer.NamespaceCreator
-	jc               installer.JobCreator
-	workers          map[string]map[string]struct{}
-	appNs            map[string]string
+	l                 sync.Locker
+	st                Store
+	port              int
+	apiPort           int
+	self              string
+	sshKey            string
+	gitRepoPublicKey  string
+	client            soft.Client
+	namespace         string
+	envAppManagerAddr string
+	env               installer.EnvConfig
+	nsc               installer.NamespaceCreator
+	jc                installer.JobCreator
+	workers           map[string]map[string]struct{}
+	appNs             map[string]string
+	sc                *securecookie.SecureCookie
 }
 
 // TODO(gio): Initialize appNs on startup
@@ -48,10 +57,15 @@
 	gitRepoPublicKey string,
 	client soft.Client,
 	namespace string,
+	envAppManagerAddr string,
 	nsc installer.NamespaceCreator,
 	jc installer.JobCreator,
 	env installer.EnvConfig,
 ) (*DodoAppServer, error) {
+	sc := securecookie.New(
+		securecookie.GenerateRandomKey(64),
+		securecookie.GenerateRandomKey(32),
+	)
 	s := &DodoAppServer{
 		&sync.Mutex{},
 		st,
@@ -62,11 +76,13 @@
 		gitRepoPublicKey,
 		client,
 		namespace,
+		envAppManagerAddr,
 		env,
 		nsc,
 		jc,
 		map[string]map[string]struct{}{},
 		map[string]string{},
+		sc,
 	}
 	config, err := client.GetRepo(ConfigRepoName)
 	if err != nil {
@@ -88,23 +104,132 @@
 	e := make(chan error)
 	go func() {
 		r := mux.NewRouter()
-		r.HandleFunc("/status/{app-name}", s.handleAppStatus).Methods(http.MethodGet)
-		r.HandleFunc("/status", s.handleStatus).Methods(http.MethodGet)
+		r.Use(s.mwAuth)
+		r.HandleFunc(logoutPath, s.handleLogout).Methods(http.MethodGet)
+		r.HandleFunc("/{app-name}"+loginPath, s.handleLoginForm).Methods(http.MethodGet)
+		r.HandleFunc("/{app-name}"+loginPath, s.handleLogin).Methods(http.MethodPost)
+		r.HandleFunc("/{app-name}", s.handleAppStatus).Methods(http.MethodGet)
+		r.HandleFunc("/", s.handleStatus).Methods(http.MethodGet)
 		e <- http.ListenAndServe(fmt.Sprintf(":%d", s.port), r)
 	}()
 	go func() {
 		r := mux.NewRouter()
-		r.HandleFunc("/update", s.handleUpdate)
-		r.HandleFunc("/api/apps/{app-name}/workers", s.handleRegisterWorker).Methods(http.MethodPost)
-		r.HandleFunc("/api/apps", s.handleCreateApp).Methods(http.MethodPost)
-		r.HandleFunc("/api/add-admin-key", s.handleAddAdminKey).Methods(http.MethodPost)
+		r.HandleFunc("/update", s.handleApiUpdate)
+		r.HandleFunc("/api/apps/{app-name}/workers", s.handleApiRegisterWorker).Methods(http.MethodPost)
+		r.HandleFunc("/api/apps", s.handleApiCreateApp).Methods(http.MethodPost)
+		r.HandleFunc("/api/add-admin-key", s.handleApiAddAdminKey).Methods(http.MethodPost)
 		e <- http.ListenAndServe(fmt.Sprintf(":%d", s.apiPort), r)
 	}()
 	return <-e
 }
 
+func (s *DodoAppServer) mwAuth(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if strings.HasSuffix(r.URL.Path, loginPath) || strings.HasPrefix(r.URL.Path, logoutPath) {
+			next.ServeHTTP(w, r)
+			return
+		}
+		cookie, err := r.Cookie(sessionCookie)
+		if err != nil {
+			vars := mux.Vars(r)
+			appName, ok := vars["app-name"]
+			if !ok || appName == "" {
+				http.Error(w, "missing app-name", http.StatusBadRequest)
+				return
+			}
+			http.Redirect(w, r, fmt.Sprintf("/%s%s", appName, loginPath), http.StatusSeeOther)
+			return
+		}
+		var user string
+		if err := s.sc.Decode(sessionCookie, cookie.Value, &user); err != nil {
+			http.Error(w, "unauthorized", http.StatusUnauthorized)
+			return
+		}
+		next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), userCtx, user)))
+	})
+}
+
+func (s *DodoAppServer) handleLogout(w http.ResponseWriter, r *http.Request) {
+	http.SetCookie(w, &http.Cookie{
+		Name:     sessionCookie,
+		Value:    "",
+		Path:     "/",
+		HttpOnly: true,
+		Secure:   true,
+	})
+	http.Redirect(w, r, "/", http.StatusSeeOther)
+}
+
+func (s *DodoAppServer) handleLoginForm(w http.ResponseWriter, r *http.Request) {
+	vars := mux.Vars(r)
+	appName, ok := vars["app-name"]
+	if !ok || appName == "" {
+		http.Error(w, "missing app-name", http.StatusBadRequest)
+		return
+	}
+	fmt.Fprint(w, `
+<!DOCTYPE html>
+<html lang='en'>
+	<head>
+		<title>dodo: app - login</title>
+		<meta charset='utf-8'>
+	</head>
+	<body>
+        <form action="" method="POST">
+          <input type="password" placeholder="Password" name="password" required />
+          <button type="submit">Login</button>
+        </form>
+	</body>
+</html>
+`)
+}
+
+func (s *DodoAppServer) handleLogin(w http.ResponseWriter, r *http.Request) {
+	vars := mux.Vars(r)
+	appName, ok := vars["app-name"]
+	if !ok || appName == "" {
+		http.Error(w, "missing app-name", http.StatusBadRequest)
+		return
+	}
+	password := r.FormValue("password")
+	if password == "" {
+		http.Error(w, "missing password", http.StatusBadRequest)
+		return
+	}
+	user, err := s.st.GetAppOwner(appName)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	hashed, err := s.st.GetUserPassword(user)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	if err := bcrypt.CompareHashAndPassword(hashed, []byte(password)); err != nil {
+		http.Redirect(w, r, r.URL.Path, http.StatusSeeOther)
+		return
+	}
+	if encoded, err := s.sc.Encode(sessionCookie, user); err == nil {
+		cookie := &http.Cookie{
+			Name:     sessionCookie,
+			Value:    encoded,
+			Path:     "/",
+			Secure:   true,
+			HttpOnly: true,
+		}
+		http.SetCookie(w, cookie)
+	}
+	http.Redirect(w, r, fmt.Sprintf("/%s", appName), http.StatusSeeOther)
+}
+
 func (s *DodoAppServer) handleStatus(w http.ResponseWriter, r *http.Request) {
-	apps, err := s.st.GetApps()
+	user := r.Context().Value(userCtx)
+	if user == nil {
+		http.Error(w, "unauthorized", http.StatusUnauthorized)
+		return
+	}
+	apps, err := s.st.GetUserApps(user.(string))
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
@@ -131,7 +256,7 @@
 	}
 }
 
-type updateReq struct {
+type apiUpdateReq struct {
 	Ref        string `json:"ref"`
 	Repository struct {
 		Name string `json:"name"`
@@ -139,9 +264,9 @@
 	After string `json:"after"`
 }
 
-func (s *DodoAppServer) handleUpdate(w http.ResponseWriter, r *http.Request) {
+func (s *DodoAppServer) handleApiUpdate(w http.ResponseWriter, r *http.Request) {
 	fmt.Println("update")
-	var req updateReq
+	var req apiUpdateReq
 	var contents strings.Builder
 	io.Copy(&contents, r.Body)
 	c := contents.String()
@@ -155,7 +280,11 @@
 	}
 	// TODO(gio): Create commit record on app init as well
 	go func() {
-		if err := s.updateDodoApp(req.Repository.Name, s.appNs[req.Repository.Name]); err != nil {
+		networks, err := getNetworks(fmt.Sprintf("%s/api/networks", s.envAppManagerAddr))
+		if err != nil {
+			return
+		}
+		if err := s.updateDodoApp(req.Repository.Name, s.appNs[req.Repository.Name], networks); err != nil {
 			if err := s.st.CreateCommit(req.Repository.Name, req.After, err.Error()); err != nil {
 				fmt.Printf("Error: %s\n", err.Error())
 				return
@@ -173,18 +302,18 @@
 	}()
 }
 
-type registerWorkerReq struct {
+type apiRegisterWorkerReq struct {
 	Address string `json:"address"`
 }
 
-func (s *DodoAppServer) handleRegisterWorker(w http.ResponseWriter, r *http.Request) {
+func (s *DodoAppServer) handleApiRegisterWorker(w http.ResponseWriter, r *http.Request) {
 	vars := mux.Vars(r)
 	appName, ok := vars["app-name"]
 	if !ok || appName == "" {
 		http.Error(w, "missing app-name", http.StatusBadRequest)
 		return
 	}
-	var req registerWorkerReq
+	var req apiRegisterWorkerReq
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
@@ -195,16 +324,17 @@
 	s.workers[appName][req.Address] = struct{}{}
 }
 
-type createAppReq struct {
+type apiCreateAppReq struct {
 	AdminPublicKey string `json:"adminPublicKey"`
 }
 
-type createAppResp struct {
-	AppName string `json:"appName"`
+type apiCreateAppResp struct {
+	AppName  string `json:"appName"`
+	Password string `json:"password"`
 }
 
-func (s *DodoAppServer) handleCreateApp(w http.ResponseWriter, r *http.Request) {
-	var req createAppReq
+func (s *DodoAppServer) handleApiCreateApp(w http.ResponseWriter, r *http.Request) {
+	var req apiCreateAppReq
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
 		http.Error(w, err.Error(), http.StatusBadRequest)
 		return
@@ -215,62 +345,100 @@
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
-	if err := s.CreateApp(appName, req.AdminPublicKey); err != nil {
+	password, err := s.CreateApp(appName, req.AdminPublicKey)
+	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
-	resp := createAppResp{appName}
+	resp := apiCreateAppResp{
+		AppName:  appName,
+		Password: password,
+	}
 	if err := json.NewEncoder(w).Encode(resp); err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
 }
 
-func (s *DodoAppServer) CreateApp(appName, adminPublicKey string) error {
+func (s *DodoAppServer) CreateApp(appName, adminPublicKey string) (string, error) {
 	s.l.Lock()
 	defer s.l.Unlock()
 	fmt.Printf("Creating app: %s\n", appName)
 	if ok, err := s.client.RepoExists(appName); err != nil {
-		return err
+		return "", err
 	} else if ok {
-		return nil
+		return "", nil
 	}
-	if err := s.st.CreateApp(appName); err != nil {
-		return err
+	user, err := s.client.FindUser(adminPublicKey)
+	if err != nil {
+		return "", err
+	}
+	if user != "" {
+		if err := s.client.AddPublicKey(user, adminPublicKey); err != nil {
+			return "", err
+		}
+	} else {
+		user = appName
+		if err := s.client.AddUser(user, adminPublicKey); err != nil {
+			return "", err
+		}
+	}
+	password := generatePassword()
+	// TODO(gio): take admin password for initial application as input
+	if appName == "app" {
+		password = "app"
+	}
+	hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
+	if err != nil {
+		return "", err
+	}
+	if err := s.st.CreateUser(user, hashed); err != nil {
+		if !errors.Is(err, ErrorAlreadyExists) {
+			return "", err
+		} else {
+			password = ""
+		}
+	}
+	if err := s.st.CreateApp(appName, user); err != nil {
+		return "", err
 	}
 	if err := s.client.AddRepository(appName); err != nil {
-		return err
+		return "", err
 	}
 	appRepo, err := s.client.GetRepo(appName)
 	if err != nil {
-		return err
+		return "", err
 	}
 	if err := InitRepo(appRepo); err != nil {
-		return err
+		return "", err
 	}
 	apps := installer.NewInMemoryAppRepository(installer.CreateAllApps())
 	app, err := installer.FindEnvApp(apps, "dodo-app-instance")
 	if err != nil {
-		return err
+		return "", err
 	}
 	suffixGen := installer.NewFixedLengthRandomSuffixGenerator(3)
 	suffix, err := suffixGen.Generate()
 	if err != nil {
-		return err
+		return "", err
 	}
 	namespace := fmt.Sprintf("%s%s%s", s.env.NamespacePrefix, app.Namespace(), suffix)
 	s.appNs[appName] = namespace
-	if err := s.updateDodoApp(appName, namespace); err != nil {
-		return err
+	networks, err := getNetworks(fmt.Sprintf("%s/api/networks", s.envAppManagerAddr))
+	if err != nil {
+		return "", err
+	}
+	if err := s.updateDodoApp(appName, namespace, networks); err != nil {
+		return "", err
 	}
 	repo, err := s.client.GetRepo(ConfigRepoName)
 	if err != nil {
-		return err
+		return "", err
 	}
 	hf := installer.NewGitHelmFetcher()
 	m, err := installer.NewAppManager(repo, s.nsc, s.jc, hf, "/")
 	if err != nil {
-		return err
+		return "", err
 	}
 	if err := repo.Do(func(fs soft.RepoFS) (string, error) {
 		w, err := fs.Writer(namespacesFile)
@@ -292,6 +460,7 @@
 				"gitRepoPublicKey": s.gitRepoPublicKey,
 			},
 			installer.WithConfig(&s.env),
+			installer.WithNetworks(networks),
 			installer.WithNoPublish(),
 			installer.WithNoLock(),
 		); err != nil {
@@ -299,60 +468,49 @@
 		}
 		return fmt.Sprintf("Installed app: %s", appName), nil
 	}); err != nil {
-		return err
+		return "", err
 	}
 	cfg, err := m.FindInstance(appName)
 	if err != nil {
-		return err
+		return "", err
 	}
 	fluxKeys, ok := cfg.Input["fluxKeys"]
 	if !ok {
-		return fmt.Errorf("Fluxcd keys not found")
+		return "", fmt.Errorf("Fluxcd keys not found")
 	}
 	fluxPublicKey, ok := fluxKeys.(map[string]any)["public"]
 	if !ok {
-		return fmt.Errorf("Fluxcd keys not found")
+		return "", fmt.Errorf("Fluxcd keys not found")
 	}
 	if ok, err := s.client.UserExists("fluxcd"); err != nil {
-		return err
+		return "", err
 	} else if ok {
 		if err := s.client.AddPublicKey("fluxcd", fluxPublicKey.(string)); err != nil {
-			return err
+			return "", err
 		}
 	} else {
 		if err := s.client.AddUser("fluxcd", fluxPublicKey.(string)); err != nil {
-			return err
+			return "", err
 		}
 	}
 	if err := s.client.AddReadOnlyCollaborator(appName, "fluxcd"); err != nil {
-		return err
+		return "", err
 	}
 	if err := s.client.AddWebhook(appName, fmt.Sprintf("http://%s/update", s.self), "--active=true", "--events=push", "--content-type=json"); err != nil {
-		return err
+		return "", err
 	}
-	if user, err := s.client.FindUser(adminPublicKey); err != nil {
-		return err
-	} else if user != "" {
-		if err := s.client.AddReadWriteCollaborator(appName, user); err != nil {
-			return err
-		}
-	} else {
-		if err := s.client.AddUser(appName, adminPublicKey); err != nil {
-			return err
-		}
-		if err := s.client.AddReadWriteCollaborator(appName, appName); err != nil {
-			return err
-		}
+	if err := s.client.AddReadWriteCollaborator(appName, user); err != nil {
+		return "", err
 	}
-	return nil
+	return password, nil
 }
 
-type addAdminKeyReq struct {
+type apiAddAdminKeyReq struct {
 	Public string `json:"public"`
 }
 
-func (s *DodoAppServer) handleAddAdminKey(w http.ResponseWriter, r *http.Request) {
-	var req addAdminKeyReq
+func (s *DodoAppServer) handleApiAddAdminKey(w http.ResponseWriter, r *http.Request) {
+	var req apiAddAdminKeyReq
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
 		http.Error(w, err.Error(), http.StatusBadRequest)
 		return
@@ -363,7 +521,7 @@
 	}
 }
 
-func (s *DodoAppServer) updateDodoApp(name, namespace string) error {
+func (s *DodoAppServer) updateDodoApp(name, namespace string, networks []installer.Network) error {
 	repo, err := s.client.GetRepo(name)
 	if err != nil {
 		return err
@@ -394,6 +552,7 @@
 			"sshPrivateKey": s.sshKey,
 		},
 		installer.WithConfig(&s.env),
+		installer.WithNetworks(networks),
 		installer.WithLocalChartGenerator(lg),
 		installer.WithBranch("dodo"),
 		installer.WithForce(),
@@ -470,3 +629,19 @@
 		return "go web app template", nil
 	})
 }
+
+func generatePassword() string {
+	return "foo"
+}
+
+func getNetworks(addr string) ([]installer.Network, error) {
+	resp, err := http.Get(addr)
+	if err != nil {
+		return nil, err
+	}
+	ret := []installer.Network{}
+	if json.NewDecoder(resp.Body).Decode(&ret); err != nil {
+		return nil, err
+	}
+	return ret, nil
+}
diff --git a/core/installer/welcome/launcher-tmpl/launcher.html b/core/installer/welcome/launcher-tmpl/launcher.html
index 91562f5..6d404ad 100644
--- a/core/installer/welcome/launcher-tmpl/launcher.html
+++ b/core/installer/welcome/launcher-tmpl/launcher.html
@@ -5,13 +5,14 @@
     <meta name="viewport" content="width=device-width, initial-scale=1.0">
     <title>dodo: Launcher</title>
     <link rel="stylesheet" type="text/css" href="/static/pico.2.0.6.min.css">
-    <link rel="stylesheet" type="text/css" href="/static/launcher.css?v=0.0.2">
+    <link rel="stylesheet" type="text/css" href="/static/launcher.css?v=0.0.13">
+    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/hack-font/3.3.0/web/hack.min.css">
 </head>
 <body class="container-fluid">
     <div id="left-panel">
         <div class="user-circle">
             <div class="circle">
-                <p>{{ GetUserInitials .LoggedInUsername }}</p>
+                <p id="user-initial">{{ GetUserInitials .LoggedInUsername }}</p>
                 <div class="tooltip-user" id="tooltip-user">
                     <p>{{ .LoggedInUsername }}</p>
                     <a href="{{ .LogoutURL }}" role="button" id="logout-button">Log Out</a>
@@ -19,28 +20,26 @@
             </div>
         </div>
         <hr class="separator">
-        <div class="app-list">
+        <div class="app-list scrollbar-custom">
             {{range .AllAppsInfo}}
-                <div class="app-icon-tooltip" data-app-id="{{ .Id }}" data-app-url="{{ .URL }}">
-                    <div class="icon">
-                        {{.Icon}}
-                    </div>
-                    <div class="tooltip">
-                        <p>{{ .Name }}</p>
-                        {{ if .DisplayURL }}
-                            <p>{{ .DisplayURL }}</p>
-                        {{ end }}
-                        {{ if .Help }}
-                            <button class="help-button" id="help-button-{{ CleanAppName .Id }}">Help</button>
-                        {{ end }}
-                    </div>
-                </div>
+            <div class="app-icon" data-app-id="{{ .Id }}" data-app-url="{{ .URL }}" {{ if not .URL }}data-modal-id="modal-{{ CleanAppName .Id }}"{{ end }}>
+                {{.Icon}}
+            </div>
+            <div class="tooltip">
+                <p>{{ .Name }}</p>
+                {{ if .DisplayURL }}
+                    <p>{{ .DisplayURL }}</p>
+                {{ end }}
+                {{ if .Help }}
+                    <button class="help-button" id="help-button-{{ CleanAppName .Id }}">Help</button>
+                {{ end }}
+            </div>
                 <dialog class="app-help-modal" id="modal-{{ CleanAppName .Id }}" close>
                     <article class="modal-article">
                         <header>
                             <h4>{{ .Name }}</h4>
                             <button class="close-button" id="close-help-{{ CleanAppName .Id }}">
-                                <svg xmlns="http://www.w3.org/2000/svg" width="1.5em" height="1.5em" viewBox="0 0 32 32"><path fill="black" d="M16 2C8.2 2 2 8.2 2 16s6.2 14 14 14s14-6.2 14-14S23.8 2 16 2m5.4 21L16 17.6L10.6 23L9 21.4l5.4-5.4L9 10.6L10.6 9l5.4 5.4L21.4 9l1.6 1.6l-5.4 5.4l5.4 5.4z"/></svg>
+                                <svg xmlns="http://www.w3.org/2000/svg" width="1.5em" height="1.5em" viewBox="0 0 32 32"><path fill="#d4888d" d="M16 2C8.2 2 2 8.2 2 16s6.2 14 14 14s14-6.2 14-14S23.8 2 16 2m5.4 21L16 17.6L10.6 23L9 21.4l5.4-5.4L9 10.6L10.6 9l5.4 5.4L21.4 9l1.6 1.6l-5.4 5.4l5.4 5.4z"/></svg>
                             </button>
                         </header>
                         <div class="app-help-modal-article">
@@ -81,6 +80,6 @@
             {{ template "help-content-template" (dict "Help" $h.Children "First" false) }}
         {{ end }}
     {{ end }}
-    <script src="/static/launcher.js?v=0.0.3"></script>
+    <script src="/static/launcher.js?v=0.0.13"></script>
 </body>
 </html>
diff --git a/core/installer/welcome/static/launcher.css b/core/installer/welcome/static/launcher.css
index bc716c8..d6038f9 100644
--- a/core/installer/welcome/static/launcher.css
+++ b/core/installer/welcome/static/launcher.css
@@ -3,23 +3,32 @@
   --pico-color: unset;
 }
 
+:root {
+  --bg: #d6d6d6;
+  --bodyBg: #3a3a3a;
+  --text: #3a3a3a;
+  --formText: #d6d6d6;
+  --button: #7f9f7f;
+  --logo: #d4888d;
+  --fontSize: 14px;
+}
+
 body {
   margin: 0;
   padding: 0;
-  font-family: Arial, sans-serif;
+  font-family: Hack, monospace;
   display: flex;
   height: 100vh;
-  padding-left: 10px !important;
-  padding-right: 10px !important;
-  background-color: black;
+  padding-left: 5px !important;
+  padding-right: 5px !important;
+  background-color: var(--bodyBg);
+  overflow-x: hidden;
+  overflow-y: hidden;
 }
 
 #left-panel {
   width: 80px;
-  background-color: #f0f0f0;
-  border-radius: 10px;
-  border-width: 1px;
-  border-color: black;
+  background-color: var(--bg);
   display: flex;
   flex-direction: column;
   align-items: center;
@@ -31,15 +40,50 @@
   display: flex;
   flex-direction: column;
   align-items: center;
+  overflow-y: auto;
+  overflow-x: hidden;
+  padding-top: 3px;
+  width: 95% !important;
+  /* scrollbar-width: thin;
+  scrollbar-color: var(--bodyBg) var(--bg); */
 }
 
+.scrollbar-custom {
+  scrollbar-width: thin;
+  scrollbar-color: var(--bodyBg) var(--bg);
+}
+
+/* .app-list:hover::-webkit-scrollbar {
+  width: 6px;
+  scrollbar-color: var(--bodyBg) var(--bg);
+} */
+
+.scrollbar-custom::-webkit-scrollbar {
+  width: 6px;
+}
+
+.scrollbar-custom::-webkit-scrollbar-track {
+  background-color: var(--bg) !important;
+}
+
+.scrollbar-custom::-webkit-scrollbar-thumb {
+  background-color: var(--bodyBg) !important;
+  border-radius: 4px !important;
+}
+
+.scrollbar-custom::-webkit-scrollbar-thumb:hover {
+  background-color: var(--bodyBg);
+}
+
+/* .layout-scrollbar::-webkit-scrollbar-thumb:active {
+  background-color: var(--bodyBg);
+} */
+
 #right-panel {
   flex: 1;
-  background-color: #f0f0f0;
-  margin: 5px;
+  background-color: none !important;
+  margin: 5px 0 5px 5px;
   padding: 2px;
-  border-radius: 10px;
-  border-color: black;
 }
 
 .appFrame {
@@ -49,30 +93,28 @@
   border: 0;
 }
 
-.app-icon-tooltip {
-  position: relative;
-  display: inline-block;
-  align-items: flex-start;
+.app-icon {
+  /* position: relative; */
+  /* display: inline-block; */
+  display: flex;
+  flex-direction: column;
+  align-items: center;
   justify-content: center;
-  cursor: initial;
+  /* cursor: initial; */
   width: 80px !important;
   height: 50px !important;
   margin-bottom: 10px !important;
   cursor: pointer !important;
-  --pico-background-color: unset !important;
-  --pico-color: unset !important;
+  /* --pico-background-color: unset !important;
+  --pico-color: unset !important; */
 }
 
 .tooltip {
   position: absolute;
   width: 200px;
-  border-radius: 0 10px 10px 0;
-  top: 70%;
-  left: 98%;
+  left: 90px;
   transform: translateY(-50%);
-  background-color: black;
-  color: white;
-  box-shadow: 0 0 5px rgba(0, 0, 0, 0.2);
+  background-color: var(--bodyBg);
   padding: 5px;
   z-index: 1;
   display: flex;
@@ -88,56 +130,45 @@
   margin-top: 5px !important;
   padding: 0 !important;
   border: 0 !important;
-  margin-bottom: 5px !important;
+  margin-bottom: 1px !important;
   width: 100% !important;
-  color: white !important;
+  background-color: var(--button) !important;
+  color: var(--bodyBg) !important;
+  border-radius: 0 !important;
   cursor: pointer !important;
   font-size: 16px !important;
 }
 
-.icon {
-  display: flex;
-  justify-content: center;
-  align-items: center !important;
-}
-
 .tooltip p {
-  color: white;
+  color: var(--formText);
   margin: 0;
   cursor: auto;
+  font-size: var(--fontSize);
 }
 
-.app-icon-tooltip:hover {
+.app-icon:hover {
   transform: scale(1.15);
 }
 
-.app-icon-tooltip .background-glow {
-  position: absolute;
-  top: 0;
-  left: 4px;
-  right: 4px;
-  bottom: 0;
-  background: rgba(0, 0, 0, 0);
-  pointer-events: none;
-  border-radius: 5px;
-  box-shadow: 0px 0px 7px 7px black;
-}
-
 .modal-left {
-  width: 30%;
   overflow-y: auto;
   float: left;
   margin-left: 0px;
+  padding-right: 10px;
   background-color: #fbfcfc;
   border-radius: 2px;
 }
 
 .modal-right {
-  /* flex: 1; */
-  width: 70%;
+  flex: 1;
+  /* width: 70%; */
   overflow-y: auto;
   float: right;
   margin-left: 2px;
+  color: var(--bg);
+  padding-left: 10px;
+  padding-right: 10px;
+  font-size: 16px !important;
 }
 
 .app-help-modal {
@@ -173,6 +204,12 @@
   align-items: center;
   position: relative;
   margin-bottom: 2px !important;
+  background-color: var(--bodyBg) !important;
+}
+
+header h4 {
+  color: var(--formText) !important;
+  padding-left: 10px;
 }
 
 .close-button {
@@ -185,7 +222,7 @@
   height: 1.5em;
   position: absolute;
   top: 11px;
-  right: 5px;
+  right: 28px;
 }
 
 .modal-article {
@@ -194,6 +231,8 @@
   min-height: 90% !important;
   max-height: 90% !important;
   overflow: hidden;
+  padding-left: 5px !important;
+  padding-right: 5px !important;
 }
 
 .help-content {
@@ -204,19 +243,21 @@
   width: 50px;
   height: 50px;
   border-radius: 50%;
-  background-color: #ccc;
+  background-color: var(--bodyBg);
   display: flex;
   justify-content: center;
   align-items: center;
+  margin-top: 2px;
 }
 
-.circle p {
+#user-initial {
   font-size: 24px;
   text-align: center;
   line-height: 50px;
   margin: 0;
   position: relative;
   display: inline-block;
+  color: var(--logo);
 }
 
 .user-circle {
@@ -232,7 +273,7 @@
   margin-top: 2px !important;
   margin-bottom: 4px !important;
   border-width: 2px !important;
-  border-color: black !important;
+  border-color: var(--bodyBg) !important;
   width: 100% !important;
 }
 
@@ -245,7 +286,7 @@
 
 .modal-left ul li {
   list-style: none !important;
-  padding-inline-start: 19px !important;
+  padding-inline-start: 10px !important;
   margin-bottom: 0px;
   font-size: 16px !important;
 }
@@ -254,19 +295,17 @@
   --pico-text-decoration: none;
   cursor: pointer;
 }
-
 .modal-left ul li a[aria-current] {
   color: var(--pico-primary);
 }
 
 .tooltip-user {
   position: absolute;
-  top: 54px;
-  left: 90px;
+  top: 45px;
+  left: 85.5px;
   transform: translateY(-50%);
   width: 234px;
-  background-color: black;
-  box-shadow: 0 0 5px rgba(0, 0, 0, 0.2);
+  background-color: var(--bodyBg);
   padding: 5px;
   z-index: 1;
   display: flex;
@@ -274,7 +313,6 @@
   align-items: center;
   visibility: hidden;
   opacity: 0;
-  border-radius: 0 0 10px 0;
   cursor: auto;
 }
 
@@ -284,9 +322,11 @@
   border: 0 !important;
   margin-bottom: 5px !important;
   width: 100% !important;
-  color: white !important;
   cursor: pointer !important;
   font-size: 19px !important;
+  border-radius: 0;
+  background-color: var(--button);
+  color: var(--text) !important;
 }
 
 .tooltip-user p {
@@ -294,4 +334,5 @@
   margin: 0;
   cursor: auto;
   font-size: 19px;
+  color: var(--logo);
 }
diff --git a/core/installer/welcome/static/launcher.js b/core/installer/welcome/static/launcher.js
index cbd35fd..ed54a70 100644
--- a/core/installer/welcome/static/launcher.js
+++ b/core/installer/welcome/static/launcher.js
@@ -1,74 +1,92 @@
+function showTooltip(obj) {
+  obj.style.visibility = 'visible';
+  obj.style.opacity = '1';
+}
+function hideTooltip(obj) {
+  obj.style.visibility = 'hidden';
+  obj.style.opacity = '0';
+}
+
 document.addEventListener("DOMContentLoaded", function () {
   document.getElementById('appFrame-default').contentDocument.write("Welcome to the dodo: application launcher, think of it as your desktop environment. You can launch applications from left-hand side dock. You should setup VPN clients on your devices, so you can install applications from Application Manager and access your private network. Instructions on how to do that can be viewed by clicking <b>Help</b> button after hovering over <b>Headscale</b> icon in the dock.");
-
-  function showTooltip(obj) {
-    obj.style.visibility = 'visible';
-    obj.style.opacity = '1';
-  }
-  function hideTooltip(obj) {
-    obj.style.visibility = 'hidden';
-    obj.style.opacity = '0';
-  }
-
+  document.getElementById('appFrame-default').style.backgroundColor = '#d6d6d6';
+  const icons = document.querySelectorAll(".app-icon");
   const circle = document.querySelector(".user-circle");
   const tooltipUser = document.querySelector("#tooltip-user");
-  [
-    ['mouseenter', () => showTooltip(tooltipUser)],
-    ['mouseleave', () => hideTooltip(tooltipUser)],
-  ].forEach(([event, listener]) => {
-    circle.addEventListener(event, listener);
+  const initial = document.getElementById('user-initial');
+
+  circle.addEventListener('mouseenter', () => {
+    icons.forEach(icon => {
+      const tooltip = icon.nextElementSibling;
+      hideTooltip(tooltip);
+    });
+    showTooltip(tooltipUser);
+    initial.style.color = "#7f9f7f";
   });
 
-  const iframes = {};
-  const rightPanel = document.getElementById('right-panel');
+  circle.addEventListener('mouseleave', () => {
+    hideTooltip(tooltipUser);
+    initial.style.color = "#d4888d";
+  });
 
-  function showIframe(appId) {
-    document.querySelectorAll('.appFrame').forEach(iframe => {
-      iframe.style.display = iframe.id === `appFrame-${appId}` ? 'block' : 'none';
-    });
-  }
+  let hideTimeout;
+  let activeTooltip;
 
-  function createIframe(appId, appUrl) {
-    const iframe = document.createElement('iframe');
-    iframe.id = `appFrame-${appId}`;
-    iframe.className = 'appFrame';
-    iframe.src = appUrl;
-    iframe.style.display = 'none';
-    rightPanel.appendChild(iframe);
-    iframes[appId] = iframe;
-  }
-
-  const icons = document.querySelectorAll(".app-icon-tooltip");
   icons.forEach(function (icon) {
     icon.addEventListener("click", function (event) {
       event.stopPropagation();
       const appUrl = this.getAttribute("data-app-url");
       const appId = this.getAttribute("data-app-id");
-      if (!appUrl) {
-        const modalId = `modal-${this.querySelector('.help-button').id.replace('help-button-', '')}`;
+      const modalId = this.getAttribute("data-modal-id");
+
+      if (!appUrl && modalId) {
         openModal(document.getElementById(modalId));
       } else {
         if (!iframes[appId]) createIframe(appId, appUrl);
         showIframe(appId);
-      }
-      document.querySelectorAll(".app-icon-tooltip .background-glow").forEach((e) => e.remove());
-      const glow = document.createElement('div');
-      glow.classList.add("background-glow");
-      glow.setAttribute("style", "transform: none; transform-origin: 50% 50% 0px;")
-      this.appendChild(glow);
+        document.querySelectorAll(".app-icon").forEach((icon) => {
+          icon.style.color = "var(--bodyBg)";
+        });
+        this.style.color = "var(--button)";
+      };
     });
-    const tooltip = icon.querySelector('.tooltip');
-    tooltip.addEventListener("click", function (event) {
-      event.stopPropagation();
-    });
+
+    const tooltip = icon.nextElementSibling;
     [
-      ['mouseenter', () => showTooltip(tooltip)],
-      ['mouseleave', () => hideTooltip(tooltip)],
-      ['focus', () => showTooltip(tooltip)],
-      ['blur', () => hideTooltip(tooltip)],
+      ['mouseenter', () => {
+        clearTimeout(hideTimeout);
+        if (activeTooltip && activeTooltip !== tooltip) {
+          hideTooltip(activeTooltip);
+        };
+        const rect = icon.getBoundingClientRect();
+        tooltip.style.top = `${rect.top + 26}px`;
+        showTooltip(tooltip);
+        activeTooltip = tooltip;
+      }],
+      ['mouseleave', () => {
+        hideTimeout = setTimeout(() => {
+          hideTooltip(tooltip);
+          if (activeTooltip === tooltip) {
+            activeTooltip = null;
+          };
+        }, 200);
+      }],
     ].forEach(([event, listener]) => {
       icon.addEventListener(event, listener);
     });
+
+    tooltip.addEventListener('mouseenter', () => {
+      clearTimeout(hideTimeout);
+    });
+
+    tooltip.addEventListener('mouseleave', () => {
+      hideTimeout = setTimeout(() => {
+        hideTooltip(tooltip);
+        if (activeTooltip === tooltip) {
+          activeTooltip = null;
+        };
+      }, 200);
+    });
   });
 
   let visibleModal = undefined;
@@ -77,6 +95,7 @@
     modal.setAttribute("open", true);
     visibleModal = modal;
   };
+
   const closeModal = function (modal) {
     modal.removeAttribute("open");
     modal.setAttribute("close", true);
@@ -84,6 +103,7 @@
   };
 
   const helpButtons = document.querySelectorAll('.help-button');
+
   helpButtons.forEach(function (button) {
     button.addEventListener('click', function (event) {
       event.stopPropagation();
@@ -101,19 +121,33 @@
   });
 
   const modalHelpButtons = document.querySelectorAll('.title-menu');
+
   modalHelpButtons.forEach(function (button) {
     button.addEventListener('click', function (event) {
       event.stopPropagation();
       const helpTitle = button.getAttribute('id');
       const helpTitleId = helpTitle.substring('title-'.length);
       const helpContentId = 'help-content-' + helpTitleId;
-      const allContentElements = document.querySelectorAll('.help-content');
+      let clDiv = document.getElementById(helpContentId).parentNode;
+      const allContentElements = clDiv.querySelectorAll('.help-content');
+
       allContentElements.forEach(function (contentElement) {
         contentElement.style.display = "none";
       });
-      modalHelpButtons.forEach(function (button) {
+
+      let currentHelpTitle = button;
+      while (currentHelpTitle && !currentHelpTitle.classList.contains('modal-left')) {
+        currentHelpTitle = currentHelpTitle.parentNode;
+        if (currentHelpTitle === document.body) {
+          currentHelpTitle = null;
+          break;
+        }
+      }
+
+      currentHelpTitle.querySelectorAll('.title-menu').forEach(function (button) {
         button.removeAttribute("aria-current");
       });
+
       document.getElementById(helpContentId).style.display = 'block';
       button.setAttribute("aria-current", "page");
     });
@@ -126,13 +160,32 @@
   });
 
   document.addEventListener("click", (event) => {
-    if (visibleModal === null) return;
+    if (visibleModal === null || visibleModal === undefined) return;
     const modalContent = visibleModal.querySelector("article");
     const closeButton = visibleModal.querySelector(".close-button");
     if (!modalContent.contains(event.target) || closeButton.contains(event.target)) {
       closeModal(visibleModal);
     }
   });
+
+  const iframes = {};
+  const rightPanel = document.getElementById('right-panel');
+
+  function showIframe(appId) {
+    document.querySelectorAll('.appFrame').forEach(iframe => {
+      iframe.style.display = iframe.id === `appFrame-${appId}` ? 'block' : 'none';
+    });
+  };
+
+  function createIframe(appId, appUrl) {
+    const iframe = document.createElement('iframe');
+    iframe.id = `appFrame-${appId}`;
+    iframe.className = 'appFrame';
+    iframe.src = appUrl;
+    iframe.style.display = 'none';
+    rightPanel.appendChild(iframe);
+    iframes[appId] = iframe;
+  };
 });
 
 function copyToClipboard(elem, text) {
@@ -140,7 +193,7 @@
   elem.setAttribute("data-tooltip", "Copied");
   elem.setAttribute("data-placement", "bottom");
   setTimeout(() => {
-	elem.removeAttribute("data-tooltip");
-	elem.removeAttribute("data-placement");
+    elem.removeAttribute("data-tooltip");
+    elem.removeAttribute("data-placement");
   }, 500);
-}
+};
diff --git a/core/installer/welcome/store.go b/core/installer/welcome/store.go
index 0ae5f4e..857045c 100644
--- a/core/installer/welcome/store.go
+++ b/core/installer/welcome/store.go
@@ -2,18 +2,33 @@
 
 import (
 	"database/sql"
+	"errors"
+
+	"github.com/ncruces/go-sqlite3"
 
 	"github.com/giolekva/pcloud/core/installer/soft"
 )
 
+const (
+	errorUniqueConstraintViolation = 2067
+)
+
+var (
+	ErrorAlreadyExists = errors.New("already exists")
+)
+
 type Commit struct {
 	Hash    string
 	Message string
 }
 
 type Store interface {
+	CreateUser(username string, password []byte) error
+	GetUserPassword(username string) ([]byte, error)
 	GetApps() ([]string, error)
-	CreateApp(name string) error
+	GetUserApps(username string) ([]string, error)
+	CreateApp(name, username string) error
+	GetAppOwner(name string) (string, error)
 	CreateCommit(name, hash, message string) error
 	GetCommitHistory(name string) ([]Commit, error)
 }
@@ -33,8 +48,13 @@
 
 func (s *storeImpl) init() error {
 	_, err := s.db.Exec(`
+		CREATE TABLE IF NOT EXISTS users (
+			username TEXT PRIMARY KEY,
+            password BLOB
+		);
 		CREATE TABLE IF NOT EXISTS apps (
-			name TEXT PRIMARY KEY
+			name TEXT PRIMARY KEY,
+            username TEXT
 		);
 		CREATE TABLE IF NOT EXISTS commits (
 			app_name TEXT,
@@ -46,12 +66,50 @@
 
 }
 
-func (s *storeImpl) CreateApp(name string) error {
-	query := `INSERT INTO apps (name) VALUES (?)`
-	_, err := s.db.Exec(query, name)
+func (s *storeImpl) CreateUser(username string, password []byte) error {
+	query := `INSERT INTO users (username, password) VALUES (?, ?)`
+	_, err := s.db.Exec(query, username, password)
+	if err != nil {
+		sqliteErr, ok := err.(*sqlite3.Error)
+		if ok && sqliteErr.ExtendedCode() == errorUniqueConstraintViolation {
+			return ErrorAlreadyExists
+		}
+	}
 	return err
 }
 
+func (s *storeImpl) GetUserPassword(username string) ([]byte, error) {
+	query := `SELECT password FROM users WHERE username = ?`
+	row := s.db.QueryRow(query, username)
+	if err := row.Err(); err != nil {
+		return nil, err
+	}
+	ret := []byte{}
+	if err := row.Scan(&ret); err != nil {
+		return nil, err
+	}
+	return ret, nil
+}
+
+func (s *storeImpl) CreateApp(name, username string) error {
+	query := `INSERT INTO apps (name, username) VALUES (?, ?)`
+	_, err := s.db.Exec(query, name, username)
+	return err
+}
+
+func (s *storeImpl) GetAppOwner(name string) (string, error) {
+	query := `SELECT username FROM apps WHERE name = ?`
+	row := s.db.QueryRow(query, name)
+	if err := row.Err(); err != nil {
+		return "", err
+	}
+	var ret string
+	if err := row.Scan(&ret); err != nil {
+		return "", err
+	}
+	return ret, nil
+}
+
 func (s *storeImpl) GetApps() ([]string, error) {
 	query := `SELECT name FROM apps`
 	rows, err := s.db.Query(query)
@@ -74,6 +132,28 @@
 	return ret, nil
 }
 
+func (s *storeImpl) GetUserApps(username string) ([]string, error) {
+	query := `SELECT name FROM apps WHERE username = ?`
+	rows, err := s.db.Query(query, username)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	ret := []string{}
+	for rows.Next() {
+		if err := rows.Err(); err != nil {
+			return nil, err
+		}
+		var name string
+		if err := rows.Scan(&name); err != nil {
+			return nil, err
+		}
+		ret = append(ret, name)
+
+	}
+	return ret, nil
+}
+
 func (s *storeImpl) CreateCommit(name, hash, message string) error {
 	query := `INSERT INTO commits (app_name, hash, message) VALUES (?, ?, ?)`
 	_, err := s.db.Exec(query, name, hash, message)