Repository: google/exposure-notifications-server
Pull request #429 (2020-05-24 23:25:29)

Move admin-console to gin framework

google-prow-robot: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/google/exposure-notifications-server/pull/429#" title="Author self-approved">mikehelmick</a>*

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=google%2Fexposure-notifications-server).

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

<details >
Needs approval from an approver in each of these files:

- ~~[OWNERS](https://github.com/google/exposure-notifications-server/blob/master/OWNERS)~~ [mikehelmick]

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->
google-prow-robot: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/google/exposure-notifications-server/pull/429#" title="Author self-approved">mikehelmick</a>*, *<a href="https://github.com/google/exposure-notifications-server/pull/429#pullrequestreview-417444922" title="Approved">sethvargo</a>*

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=google%2Fexposure-notifications-server).

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

<details >
Needs approval from an approver in each of these files:

- ~~[OWNERS](https://github.com/google/exposure-notifications-server/blob/master/OWNERS)~~ [mikehelmick,sethvargo]

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->

Patch:
diff --git a/go.mod b/go.mod
index 52b37bb..d7f4599 100644
--- a/go.mod
+++ b/go.mod
@@ -16,6 +16,7 @@ require (
 	github.com/aws/aws-sdk-go v1.31.4 // indirect
 	github.com/circonus-labs/circonusllhist v0.1.4 // indirect
 	github.com/dgrijalva/jwt-go v3.2.0+incompatible
+	github.com/gin-gonic/gin v1.6.3
 	github.com/go-ole/go-ole v1.2.4 // indirect
 	github.com/go-test/deep v1.0.6 // indirect
 	github.com/golang-migrate/migrate/v4 v4.10.0
@@ -38,11 +39,15 @@ require (
 	github.com/jefferai/jsonx v1.0.1 // indirect
 	github.com/kelseyhightower/envconfig v1.4.0
 	github.com/keybase/go-crypto v0.0.0-20200123153347-de78d2cb44f4 // indirect
+	github.com/kr/text v0.2.0 // indirect
 	github.com/lib/pq v1.5.2 // indirect
 	github.com/mitchellh/cli v1.1.1 // indirect
 	github.com/mitchellh/go-testing-interface v1.14.1 // indirect
 	github.com/mitchellh/mapstructure v1.3.1 // indirect
+	github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
 	github.com/oklog/run v1.1.0 // indirect
+	github.com/onsi/ginkgo v1.12.0 // indirect
+	github.com/onsi/gomega v1.9.0 // indirect
 	github.com/oracle/oci-go-sdk v19.3.0+incompatible // indirect
 	github.com/ory/dockertest v3.3.5+incompatible
 	github.com/pierrec/lz4 v2.5.2+incompatible // indirect
@@ -52,16 +57,19 @@ require (
 	github.com/sethvargo/go-gcpkms v0.0.0-20200417004547-e50d0c7083d9
 	github.com/shirou/gopsutil v2.20.4+incompatible // indirect
 	github.com/shopspring/decimal v0.0.0-20200419222939-1884f454f8ea // indirect
+	github.com/smartystreets/goconvey v1.6.4 // indirect
+	github.com/spf13/pflag v1.0.5 // indirect
 	github.com/tv42/httpunix v0.0.0-20191220191345-2ba4b9c3382c // indirect
 	go.uber.org/zap v1.14.1
 	golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2 // indirect
 	golang.org/x/sys v0.0.0-20200519105757-fe76b779f299 // indirect
 	golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1 // indirect
-	golang.org/x/tools v0.0.0-20200501205727-542909fd9944 // indirect
+	golang.org/x/tools v0.0.0-20200502202811-ed308ab3e770 // indirect
 	google.golang.org/api v0.25.0
 	google.golang.org/genproto v0.0.0-20200521103424-e9a78aa275b7
 	google.golang.org/grpc v1.29.1
 	google.golang.org/protobuf v1.23.0
+	gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
 	gopkg.in/ini.v1 v1.56.0 // indirect
 	gopkg.in/square/go-jose.v2 v2.5.1 // indirect
 )
diff --git a/go.sum b/go.sum
index 19f4e74..0e08621 100644
--- a/go.sum
+++ b/go.sum
@@ -194,6 +194,7 @@ github.com/circonus-labs/circonusllhist v0.1.3 h1:TJH+oke8D16535+jHExHj4nQvzlZrj
 github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I=
 github.com/circonus-labs/circonusllhist v0.1.4 h1:G5qJPuD16akpIXMUR7KcfBvrQOVm95+qyqUm+SEAZks=
 github.com/circonus-labs/circonusllhist v0.1.4/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I=
+github.com/client9/misspell v0.3.4 h1:ta993UF76GwbvJcIo3Y68y/M3WxlpEHPWIGDkJYwzJI=
 github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
 github.com/cloudflare/golz4 v0.0.0-20150217214814-ef862a3cdc58/go.mod h1:EOBUe0h4xcZ5GoxqC5SDxFQ8gwyZPKQoEzownBlhI80=
 github.com/cloudfoundry-community/go-cfclient v0.0.0-20190201205600-f136f9222381 h1:rdRS5BT13Iae9ssvcslol66gfOOXjaLYwqerEn/cl9s=
@@ -228,6 +229,7 @@ github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f h1:lBNOc5arjvs8E5mO2tbp
 github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
 github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
 github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
 github.com/cznic/mathutil v0.0.0-20180504122225-ca4c9f2c1369/go.mod h1:e6NPNENfs9mPDVNRekM7lKScauxd5kXTr1Mfyig6TDM=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -292,6 +294,10 @@ github.com/gammazero/workerpool v0.0.0-20190406235159-88d534f22b56/go.mod h1:w9R
 github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
 github.com/ghodss/yaml v1.0.1-0.20190212211648-25d852aebe32 h1:Mn26/9ZMNWSw9C9ERFA1PUxfmGpolnw2v0bKOREu5ew=
 github.com/ghodss/yaml v1.0.1-0.20190212211648-25d852aebe32/go.mod h1:GIjDIg/heH5DOkXY3YJ/wNhfHsQHoXGjl8G8amsYQ1I=
+github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
+github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
+github.com/gin-gonic/gin v1.6.3 h1:ahKqKTFpO5KTPHxWZjEdPScmYaGtLo8Y4DMHoEsnp14=
+github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
 github.com/go-asn1-ber/asn1-ber v1.3.1 h1:gvPdv/Hr++TRFCl0UbPFHC54P9N9jgsRPnmnr419Uck=
 github.com/go-asn1-ber/asn1-ber v1.3.1/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
 github.com/go-errors/errors v1.0.1 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6w=
@@ -314,6 +320,14 @@ github.com/go-ole/go-ole v1.2.1 h1:2lOsA72HgjxAuMlKpFiCbHTvu44PIVkZ5hqm3RSdI/E=
 github.com/go-ole/go-ole v1.2.1/go.mod h1:7FAglXiTm7HKlQRDeOQ6ZNUHidzCWXuZWq/1dTyBNF8=
 github.com/go-ole/go-ole v1.2.4 h1:nNBDSCOigTSiarFpYE9J/KtEA1IOW4CNeqT9TQDqCxI=
 github.com/go-ole/go-ole v1.2.4/go.mod h1:XCwSNxSkXRo4vlyPy93sltvi/qJq0jqQhjqQNIwKuxM=
+github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
+github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
+github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q=
+github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
+github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no=
+github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
+github.com/go-playground/validator/v10 v10.2.0 h1:KgJ0snyC2R9VXYN2rneOtQcw5aHQB1Vv0sFl1UcHBOY=
+github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
 github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
 github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs=
 github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
@@ -351,6 +365,7 @@ github.com/golang/groupcache v0.0.0-20191027212112-611e8accdfc9/go.mod h1:cIg4er
 github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
 github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e h1:1r7pUrabqp18hOBcwBwiTsbnFeTZHV9eER/QT5JVZxY=
 github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/golang/lint v0.0.0-20180702182130-06c8688daad7 h1:2hRPrmiwPrp3fQX967rNJIhQPtiGXdlQWAxKbKw3VHA=
 github.com/golang/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E=
 github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
 github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
@@ -718,7 +733,9 @@ github.com/keybase/go-crypto v0.0.0-20190403132359-d65b6b94177f/go.mod h1:ghbZsc
 github.com/keybase/go-crypto v0.0.0-20200123153347-de78d2cb44f4 h1:cTxwSmnaqLoo+4tLukHoB9iqHOu3LmLhRmgUxZo6Vp4=
 github.com/keybase/go-crypto v0.0.0-20200123153347-de78d2cb44f4/go.mod h1:ghbZscTyKdM07+Fw3KSi0hcJm+AlEUWj8QLlPtijN/M=
 github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
+github.com/kisielk/errcheck v1.2.0 h1:reN85Pxc5larApoH1keMBiu2GWtPqXQ1nc9gx+jOU+E=
 github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00=
+github.com/kisielk/gotool v1.0.0 h1:AV2c/EiW3KqPNT9ZKl07ehoAGi4C5/01Cfbblndcapg=
 github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
 github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
 github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
@@ -733,6 +750,10 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
 github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
 github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y=
+github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
 github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
 github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
 github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
@@ -820,6 +841,8 @@ github.com/natefinch/atomic v0.0.0-20150920032501-a62ce929ffcc/go.mod h1:1rLVY/D
 github.com/ncw/swift v1.0.47/go.mod h1:23YIA4yWVnGwv2dQlN4bB7egfYX6YLn0Yo/S6zZO/ZM=
 github.com/neo4j-drivers/gobolt v1.7.4/go.mod h1:O9AUbip4Dgre+CD3p40dnMD4a4r52QBIfblg5k7CTbE=
 github.com/neo4j/neo4j-go-driver v1.7.4/go.mod h1:aPO0vVr+WnhEJne+FgFjfsjzAnssPFLucHgGZ76Zb/U=
+github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
+github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
 github.com/nwaples/rardecode v1.0.0/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
 github.com/oklog/run v1.0.0 h1:Ru7dDtJNOyC66gQ5dQmaCa0qIsAUFY3sFpK1Xk8igrw=
 github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA=
@@ -834,10 +857,15 @@ github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+W
 github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
 github.com/onsi/ginkgo v1.8.0 h1:VkHVNpR4iVnU8XQR6DBm8BqYjN7CRzw+xKUbVVbbW9w=
 github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
+github.com/onsi/ginkgo v1.12.0 h1:Iw5WCbBcaAAd0fpRb1c9r5YCylv4XDoCSigm1zLevwU=
+github.com/onsi/ginkgo v1.12.0/go.mod h1:oUhWkIvk5aDxtKvDDuw8gItl8pKl42LzjC9KZE0HfGg=
 github.com/onsi/gomega v0.0.0-20190113212917-5533ce8a0da3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
 github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
 github.com/onsi/gomega v1.5.0 h1:izbySO9zDPmjJ8rDjLvkA2zJHIo+HkYXHnf7eN7SSyo=
 github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
+github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
+github.com/onsi/gomega v1.9.0 h1:R1uwffexN6Pr340GtYRIdZmAiN4J+iw6WG4wog1DUXg=
+github.com/onsi/gomega v1.9.0/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoTdcA=
 github.com/opencontainers/go-digest v1.0.0-rc1 h1:WzifXhOVOEOuFYOJAW6aQqW0TooG2iki3E3Ii+WN7gQ=
 github.com/opencontainers/go-digest v1.0.0-rc1/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s=
 github.com/opencontainers/image-spec v1.0.1 h1:JMemWkRwHx4Zj+fVxWoMCFm/8sYGGrUVojFA6h/TRcI=
@@ -862,6 +890,7 @@ github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144T
 github.com/patrickmn/go-cache v0.0.0-20180815053127-5633e0862627/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
 github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
 github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
+github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc=
 github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
 github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 h1:q2e307iGHPdTGp0hoxKjt1H5pDo6utceo3dQVK3I5XQ=
 github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5/go.mod h1:jvVRKCrJTQWu0XVbaOlby/2lO20uSCHEMzzplHXte1o=
@@ -966,18 +995,25 @@ github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1
 github.com/smartystreets/goconvey v0.0.0-20180222194500-ef6db91d284a/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s=
 github.com/smartystreets/goconvey v0.0.0-20190330032615-68dc04aab96a h1:pa8hGb/2YqsZKovtsgrwcDH1RZhVbTKCjLp47XpqCDs=
 github.com/smartystreets/goconvey v0.0.0-20190330032615-68dc04aab96a/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
+github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
+github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
 github.com/soheilhy/cmux v0.1.4 h1:0HKaf1o97UwFjHH9o5XsHUOF+tqmdA7KEzXLpiyaw0E=
 github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM=
 github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
 github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
+github.com/spf13/afero v1.2.1 h1:qgMbHoJbPbw579P+1zVY+6n4nIFuIchaIjzZ/I/Yq8M=
 github.com/spf13/afero v1.2.1/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk=
+github.com/spf13/cast v1.3.0 h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8=
 github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
 github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
 github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
+github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk=
 github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
 github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
 github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg=
 github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
+github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
+github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
 github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
 github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE=
 github.com/streadway/amqp v0.0.0-20190404075320-75d898a42a94 h1:0ngsPmuP6XIjiFRNFYlvKwSr5zff2v+uPHaffZ6/M4k=
@@ -1004,8 +1040,12 @@ github.com/tv42/httpunix v0.0.0-20191220191345-2ba4b9c3382c h1:u6SKchux2yDvFQnDH
 github.com/tv42/httpunix v0.0.0-20191220191345-2ba4b9c3382c/go.mod h1:hzIxponao9Kjc7aWznkXaL4U4TWaDSs8zcsY4Ka08nM=
 github.com/ugorji/go v1.1.4 h1:j4s+tAvLfL3bZyefP2SEWmhBzmuIlH/eqNuPdFPgngw=
 github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
+github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo=
+github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
 github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8 h1:3SVOIvH7Ae1KRYyQWRjXWJEA9sS/c/pjvH++55Gr648=
 github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
+github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs=
+github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
 github.com/ulikunitz/xz v0.5.6/go.mod h1:2bypXElzHzzJZwzH67Y6wb67pO62Rzfn7BSiF4ABRW8=
 github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
 github.com/xanzy/go-gitlab v0.15.0/go.mod h1:8zdQa/ri1dfn8eS3Ir1SyfvOKlw7WBJ8DVThkpGiXrs=
@@ -1193,6 +1233,7 @@ golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20191128015809-6d18c012aee9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -1277,8 +1318,8 @@ golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWc
 golang.org/x/tools v0.0.0-20200409170454-77362c5149f0/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
 golang.org/x/tools v0.0.0-20200416214402-fc959738d646/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
 golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
-golang.org/x/tools v0.0.0-20200501205727-542909fd9944 h1:H2fcUfqnOlhuExePgcxfMRf98XwAWOF2pqkJTFTc2z0=
-golang.org/x/tools v0.0.0-20200501205727-542909fd9944/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20200502202811-ed308ab3e770 h1:M9Fif0OxNji8w+HvmhVQ8KJtiZOsjU9RgslJGhn95XE=
+golang.org/x/tools v0.0.0-20200502202811-ed308ab3e770/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
 golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -1390,6 +1431,8 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
+gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/cheggaaa/pb.v1 v1.0.25/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw=
 gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
 gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
diff --git a/internal/admin/authorizedapps/form.go b/internal/admin/authorizedapps/form.go
new file mode 100644
index 0000000..b01abab
--- /dev/null
+++ b/internal/admin/authorizedapps/form.go
@@ -0,0 +1,85 @@
+// Copyright 2020 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, softwar
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package authorizedapps
+
+import (
+	"encoding/base64"
+	"fmt"
+	"strings"
+	"time"
+
+	"github.com/google/exposure-notifications-server/internal/authorizedapp/model"
+)
+
+type formData struct {
+	// Top Level
+	FormKey string `form:"Key"`
+	Action  string `form:"TODO"`
+
+	// Authorized App Data
+	AppPackageName              string `form:"AppPackageName"`
+	Platform                    string `form:"Platform"`
+	AllowedRegions              string `form:"Regions"`
+	SafetyNetDisabled           bool   `form:"SafetyNetDisabled"`
+	SafetyNetApkDigestSHA256    string `form:"SafetyNetApkDigestSHA256"`
+	SafetyNetBasicIntegrity     bool   `form:"SafetyNetBasicIntegrity"`
+	SafetyNetCTSProfileMatch    bool   `form:"SafetyNetCTSProfileMatch"`
+	SafetyNetPastTime           string `form:"SafetyNetPastTime"`
+	SafetyNetFutureTime         string `form:"SafetyNetFutureTime"`
+	DeviceCheckDisabled         bool   `form:"DeviceCheckDisabled"`
+	DeviceCheckKeyID            string `form:"DeviceCheckKeyID"`
+	DeviceCheckTeamID           string `form:"DeviceCheckTeamID"`
+	DeviceCheckPrivateKeySecret string `form:"DeviceCheckPrivateKeySecret"`
+}
+
+func (f *formData) PriorKey() string {
+	if f.FormKey != "" {
+		bytes, err := base64.StdEncoding.DecodeString(f.FormKey)
+		if err != nil {
+			return ""
+		}
+		return string(bytes)
+	}
+	return ""
+}
+
+func (f *formData) PopulateAuthorizedApp(a *model.AuthorizedApp) error {
+	a.AppPackageName = f.AppPackageName
+	a.Platform = f.Platform
+	a.AllowedRegions = make(map[string]struct{})
+	for _, region := range strings.Split(f.AllowedRegions, "\n") {
+		a.AllowedRegions[region] = struct{}{}
+	}
+	// SafetyNet
+	a.SafetyNetDisabled = f.SafetyNetDisabled
+	a.SafetyNetApkDigestSHA256 = strings.Split(f.SafetyNetApkDigestSHA256, "\n")
+	a.SafetyNetBasicIntegrity = f.SafetyNetBasicIntegrity
+	a.SafetyNetCTSProfileMatch = f.SafetyNetCTSProfileMatch
+	var err error
+	a.SafetyNetPastTime, err = time.ParseDuration(f.SafetyNetPastTime)
+	if err != nil {
+		return fmt.Errorf("Failed to parse durection for SafetyNetPastTime: %w", err)
+	}
+	a.SafetyNetFutureTime, err = time.ParseDuration(f.SafetyNetFutureTime)
+	if err != nil {
+		return fmt.Errorf("Failed to parse durection for SafetyNetFutureTime: %w", err)
+	}
+	// DeviceCheck
+	a.DeviceCheckDisabled = f.DeviceCheckDisabled
+	a.DeviceCheckKeyID = f.DeviceCheckKeyID
+	a.DeviceCheckTeamID = f.DeviceCheckTeamID
+	a.DeviceCheckPrivateKeySecret = f.DeviceCheckPrivateKeySecret
+	return nil
+}
diff --git a/internal/admin/authorizedapps/save.go b/internal/admin/authorizedapps/save.go
new file mode 100644
index 0000000..a548906
--- /dev/null
+++ b/internal/admin/authorizedapps/save.go
@@ -0,0 +1,110 @@
+// Copyright 2020 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, softwar
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package authorizedapps
+
+import (
+	"encoding/base64"
+	"fmt"
+	"log"
+	"net/http"
+
+	"github.com/gin-gonic/gin"
+	"github.com/google/exposure-notifications-server/internal/admin"
+	"github.com/google/exposure-notifications-server/internal/authorizedapp/database"
+	"github.com/google/exposure-notifications-server/internal/authorizedapp/model"
+	"github.com/google/exposure-notifications-server/internal/serverenv"
+)
+
+type saveController struct {
+	config *admin.Config
+	env    *serverenv.ServerEnv
+}
+
+func NewSave(c *admin.Config, env *serverenv.ServerEnv) *saveController {
+	return &saveController{config: c, env: env}
+}
+
+func (h *saveController) Execute(c *gin.Context) {
+	var form formData
+	err := c.Bind(&form)
+	if err != nil {
+		admin.ErrorPage(c, err.Error())
+		return
+	}
+
+	ctx := c.Request.Context()
+	m := admin.TemplateMap{}
+
+	aadb := database.New(h.env.Database())
+	if form.Action == "save" {
+		// Create new, or load previous.
+		authApp := model.NewAuthorizedApp()
+		priorKey := form.PriorKey()
+		if priorKey != "" {
+			authApp, err = aadb.GetAuthorizedApp(ctx, h.env.SecretManager(), priorKey)
+			if err != nil {
+				admin.ErrorPage(c, "Invalid request, app to edit not found.")
+				return
+			}
+		}
+		if err := form.PopulateAuthorizedApp(authApp); err != nil {
+			admin.ErrorPage(c, err.Error())
+			return
+		}
+		errors := authApp.Validate()
+		if len(errors) > 0 {
+			m.AddErrors(errors...)
+			m["app"] = authApp
+			c.HTML(http.StatusOK, "authorizedapp", m)
+			return
+		}
+
+		if priorKey != "" {
+			if err := aadb.UpdateAuthorizedApp(ctx, priorKey, authApp); err != nil {
+				m.AddErrors(fmt.Sprintf("Error removing old version: %v", err))
+				m["app"] = authApp
+				c.HTML(http.StatusOK, "authorizedapp", m)
+				return
+			}
+			m.AddSuccess(fmt.Sprintf("Updated authorized app: %v", authApp.AppPackageName))
+		} else {
+			if err := aadb.InsertAuthorizedApp(ctx, authApp); err != nil {
+				m.AddErrors(fmt.Sprintf("Error inserting authorized app: %v", err))
+			} else {
+				m.AddSuccess(fmt.Sprintf("Saved authorized app: %v", authApp.AppPackageName))
+			}
+		}
+		m["app"] = authApp
+		m["previousKey"] = base64.StdEncoding.EncodeToString([]byte(authApp.AppPackageName))
+		c.HTML(http.StatusOK, "authorizedapp", m)
+		return
+
+	} else if form.Action == "delete" {
+		priorKey := form.PriorKey()
+
+		log.Printf("Deleting authorized app: %v", priorKey)
+		if err := aadb.DeleteAuthorizedApp(ctx, priorKey); err != nil {
+			admin.ErrorPage(c, fmt.Sprintf("Error deleting authorized app: %v", err))
+			return
+		}
+
+		m.AddSuccess(fmt.Sprintf("Successfully deleted app `%v`", priorKey))
+		m["app"] = model.NewAuthorizedApp()
+		c.HTML(http.StatusOK, "authorizedapp", m)
+		return
+	}
+
+	admin.ErrorPage(c, "Invalid request, app to edit not found.")
+}
diff --git a/internal/admin/authorizedapps/view.go b/internal/admin/authorizedapps/view.go
new file mode 100644
index 0000000..f98cbe4
--- /dev/null
+++ b/internal/admin/authorizedapps/view.go
@@ -0,0 +1,61 @@
+// Copyright 2020 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, softwar
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package authorizedapps
+
+import (
+	"encoding/base64"
+	"fmt"
+	"net/http"
+
+	"github.com/gin-gonic/gin"
+	"github.com/google/exposure-notifications-server/internal/admin"
+	"github.com/google/exposure-notifications-server/internal/authorizedapp/database"
+	"github.com/google/exposure-notifications-server/internal/authorizedapp/model"
+	"github.com/google/exposure-notifications-server/internal/serverenv"
+)
+
+type viewController struct {
+	config *admin.Config
+	env    *serverenv.ServerEnv
+}
+
+func NewView(c *admin.Config, env *serverenv.ServerEnv) *viewController {
+	return &viewController{config: c, env: env}
+}
+
+func (h *viewController) Execute(c *gin.Context) {
+	ctx := c.Request.Context()
+	m := admin.TemplateMap{}
+
+	appID, _ := c.GetQuery("apn")
+	authorizedApp := model.NewAuthorizedApp()
+
+	if appID == "" {
+		m.AddJumbotron("Authorized Applications", "Create New Authorized Application")
+		m["new"] = true
+	} else {
+		aadb := database.New(h.env.Database())
+		var err error
+		authorizedApp, err = aadb.GetAuthorizedApp(ctx, h.env.SecretManager(), appID)
+		if err != nil {
+			admin.ErrorPage(c, err.Error())
+			return
+		}
+		m.AddJumbotron("Authorized Applications", fmt.Sprintf("Edit: `%v`", authorizedApp.AppPackageName))
+	}
+	m["app"] = authorizedApp
+	m["previousKey"] = base64.StdEncoding.EncodeToString([]byte(authorizedApp.AppPackageName))
+	c.HTML(http.StatusOK, "authorizedapp", m)
+}
diff --git a/tools/admin-console/config.go b/internal/admin/config.go
similarity index 92%
rename from tools/admin-console/config.go
rename to internal/admin/config.go
index 3dc778c..6c4e8e9 100644
--- a/tools/admin-console/config.go
+++ b/internal/admin/config.go
@@ -14,7 +14,7 @@
 
 // This tool provides a small admin UI. Requires connection to the database
 // and permissions to access whatever else you might need to access.
-package main
+package admin
 
 import (
 	"fmt"
@@ -30,7 +30,7 @@ var _ setup.DBConfigProvider = (*Config)(nil)
 
 type Config struct {
 	Port         string `envconfig:"PORT" default:"8080"`
-	TemplatePath string `envconfig:"TEMPLATE_DIR" default:"tools/admin-console/templates"`
+	TemplatePath string `envconfig:"TEMPLATE_DIR" default:"./tools/admin-console/templates"`
 	Database     *database.Config
 	TopFile      string `envconfig:"TOP_FILE" default:"top"`
 	BotFile      string `envconfig:"BOTTOM_FILE" default:"bottom"`
@@ -55,7 +55,7 @@ func (c *Config) RenderTemplate(w http.ResponseWriter, tmpl string, p TemplateMa
 		log.Printf("ERROR: %v", err)
 		return err
 	}
-	if err := t.ExecuteTemplate(w, "view", p); err != nil {
+	if err := t.ExecuteTemplate(w, tmpl, p); err != nil {
 		message := fmt.Sprintf("error rendering template: %v", err)
 		w.WriteHeader(http.StatusInternalServerError)
 		w.Write([]byte(message))
diff --git a/internal/admin/controller.go b/internal/admin/controller.go
new file mode 100644
index 0000000..fb77392
--- /dev/null
+++ b/internal/admin/controller.go
@@ -0,0 +1,17 @@
+package admin
+
+import (
+	"net/http"
+
+	"github.com/gin-gonic/gin"
+)
+
+// Controller is the interfactor for controlers that can be pluggied into
+// Gin for the admin console portion of this project.
+type Controller interface {
+	Execute(g *gin.Context)
+}
+
+func ErrorPage(c *gin.Context, messages ...string) {
+	c.HTML(http.StatusOK, "error", gin.H{"error": messages})
+}
diff --git a/tools/admin-console/index.go b/internal/admin/index/index.go
similarity index 75%
rename from tools/admin-console/index.go
rename to internal/admin/index/index.go
index 5ec4c2b..f01f666 100644
--- a/tools/admin-console/index.go
+++ b/internal/admin/index/index.go
@@ -12,35 +12,37 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package main
+// Package index contains admin console handler for the main landing page.
+package index
 
 import (
 	"net/http"
 
+	"github.com/gin-gonic/gin"
+	"github.com/google/exposure-notifications-server/internal/admin"
 	aadb "github.com/google/exposure-notifications-server/internal/authorizedapp/database"
 	exdb "github.com/google/exposure-notifications-server/internal/export/database"
 	"github.com/google/exposure-notifications-server/internal/serverenv"
 )
 
 type indexHandler struct {
-	config *Config
+	config *admin.Config
 	env    *serverenv.ServerEnv
 }
 
-func NewIndexHandler(c *Config, env *serverenv.ServerEnv) *indexHandler {
+func New(c *admin.Config, env *serverenv.ServerEnv) *indexHandler {
 	return &indexHandler{config: c, env: env}
 }
 
-func (h *indexHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	ctx := r.Context()
-	m := TemplateMap{}
+func (h *indexHandler) Execute(c *gin.Context) {
+	ctx := c.Request.Context()
+	m := admin.TemplateMap{}
 
 	// Load authorized apps for index.
 	db := aadb.New(h.env.Database())
 	apps, err := db.GetAllAuthorizedApps(ctx, h.env.SecretManager())
 	if err != nil {
-		m["error"] = err
-		h.config.RenderTemplate(w, "error", m)
+		admin.ErrorPage(c, err.Error())
 		return
 	}
 	m["apps"] = apps
@@ -50,13 +52,12 @@ func (h *indexHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	exportDB := exdb.New(h.env.Database())
 	exports, err := exportDB.GetAllExportConfigs(ctx)
 	if err != nil {
-		m["error"] = err
-		h.config.RenderTemplate(w, "error", m)
+		admin.ErrorPage(c, err.Error())
 		return
 	}
 	m["exports"] = exports
 
 	m.AddTitle("Exposure Notifications Server - Admin Console")
 	m.AddJumbotron("Exposure Notification Server", "Admin Console")
-	h.config.RenderTemplate(w, "index", m)
+	c.HTML(http.StatusOK, "index", m)
 }
diff --git a/tools/admin-console/util.go b/internal/admin/util.go
similarity index 98%
rename from tools/admin-console/util.go
rename to internal/admin/util.go
index 98ec6e0..e65500c 100644
--- a/tools/admin-console/util.go
+++ b/internal/admin/util.go
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package main
+package admin
 
 type TemplateMap map[string]interface{}
 
diff --git a/internal/authorizedapp/database/authorized_app.go b/internal/authorizedapp/database/authorized_app.go
index df33991..755021e 100644
--- a/internal/authorizedapp/database/authorized_app.go
+++ b/internal/authorizedapp/database/authorized_app.go
@@ -66,6 +66,29 @@ func (aa *AuthorizedAppDB) InsertAuthorizedApp(ctx context.Context, m *model.Aut
 	})
 }
 
+func (aa *AuthorizedAppDB) UpdateAuthorizedApp(ctx context.Context, priorKey string, m *model.AuthorizedApp) error {
+	return aa.db.InTx(ctx, pgx.Serializable, func(tx pgx.Tx) error {
+		result, err := tx.Exec(ctx, `
+			UPDATE AuthorizedApp
+			SET
+				app_package_name = $1, platform = $2, allowed_regions = $3,
+				safetynet_disabled = $4, safetynet_apk_digest = $5, safetynet_cts_profile_match = $6, safetynet_basic_integrity = $7, safetynet_past_seconds = $8, safetynet_future_seconds = $9,
+				devicecheck_disabled = $10, devicecheck_team_id = $11, devicecheck_key_id = $12, devicecheck_private_key_secret = $13
+			WHERE
+				app_package_name = $14
+			`, m.AppPackageName, m.Platform, m.AllAllowedRegions(),
+			m.SafetyNetDisabled, m.SafetyNetApkDigestSHA256, m.SafetyNetCTSProfileMatch, m.SafetyNetBasicIntegrity, int64(m.SafetyNetPastTime.Seconds()), int64(m.SafetyNetFutureTime.Seconds()),
+			m.DeviceCheckDisabled, m.DeviceCheckTeamID, m.DeviceCheckKeyID, m.DeviceCheckPrivateKeySecret, priorKey)
+		if err != nil {
+			return fmt.Errorf("inserting authorizedapp: %w", err)
+		}
+		if result.RowsAffected() != 1 {
+			return fmt.Errorf("no rows inserted")
+		}
+		return nil
+	})
+}
+
 func (aa *AuthorizedAppDB) DeleteAuthorizedApp(ctx context.Context, appPackageName string) error {
 	var count int64
 	err := aa.db.InTx(ctx, pgx.Serializable, func(tx pgx.Tx) error {
diff --git a/tools/admin-console/authorizedapps.go b/tools/admin-console/authorizedapps.go
deleted file mode 100644
index 27bc6d2..0000000
--- a/tools/admin-console/authorizedapps.go
+++ /dev/null
@@ -1,233 +0,0 @@
-// Copyright 2020 Google LLC
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, softwar
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package main
-
-import (
-	"encoding/base64"
-	"fmt"
-	"net/http"
-	"strconv"
-	"strings"
-	"time"
-
-	"github.com/google/exposure-notifications-server/internal/authorizedapp/database"
-	"github.com/google/exposure-notifications-server/internal/authorizedapp/model"
-	"github.com/google/exposure-notifications-server/internal/serverenv"
-)
-
-type appHandler struct {
-	config *Config
-	env    *serverenv.ServerEnv
-}
-
-func NewAppHandler(c *Config, env *serverenv.ServerEnv) *appHandler {
-	return &appHandler{config: c, env: env}
-}
-
-func (h *appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	if r.Method == "GET" {
-		h.handleGet(w, r)
-		return
-	} else if r.Method == "POST" {
-		h.handlePost(w, r)
-		return
-	}
-
-	m := TemplateMap{}
-	m.AddErrors("Invalid request.")
-	h.config.RenderTemplate(w, "error", m)
-}
-
-func (h *appHandler) renderError(m TemplateMap, msg string, w http.ResponseWriter) {
-	m.AddErrors(msg)
-	h.config.RenderTemplate(w, "error", m)
-}
-
-func populateAuthorizedApp(a *model.AuthorizedApp, r *http.Request) []string {
-	a.AppPackageName = r.FormValue("AppPackageName")
-	a.Platform = r.FormValue("Platform")
-
-	a.AllowedRegions = make(map[string]struct{})
-	regions := strings.Split(r.FormValue("Regions"), "\n")
-	for _, region := range regions {
-		a.AllowedRegions[region] = struct{}{}
-	}
-
-	var err error
-	errors := []string{}
-	// SafetyNet pieces.
-	a.SafetyNetDisabled, err = strconv.ParseBool(r.FormValue("SafetyNetDisabled"))
-	if err != nil {
-		errors = append(errors, fmt.Sprintf("SafetyNetDisabled, invalid value: %v", err))
-	}
-	a.SafetyNetApkDigestSHA256 = strings.Split(r.FormValue("SafetyNetApkDigestSHA256"), "\n")
-	a.SafetyNetBasicIntegrity, err = strconv.ParseBool(r.FormValue("SafetyNetBasicIntegrity"))
-	if err != nil {
-		errors = append(errors, fmt.Sprintf("SafetyNetBasicIntegrity, invalid value: %v", err))
-	}
-	a.SafetyNetCTSProfileMatch, err = strconv.ParseBool(r.FormValue("SafetyNetCTSProfileMatch"))
-	if err != nil {
-		errors = append(errors, fmt.Sprintf("SafetyNetCTSProfileMatch, invalid value: %v", err))
-	}
-	a.SafetyNetPastTime, err = time.ParseDuration(r.FormValue("SafetyNetPastTime"))
-	if err != nil {
-		errors = append(errors, fmt.Sprintf("SafetyNetPastTime, invalid value: %v", err))
-	}
-	a.SafetyNetFutureTime, err = time.ParseDuration(r.FormValue("SafetyNetFutureTime"))
-	if err != nil {
-		errors = append(errors, fmt.Sprintf("SafetyNetFutureTime, invalid value: %v", err))
-	}
-
-	// DeviceCheck pieces
-	a.DeviceCheckDisabled, err = strconv.ParseBool(r.FormValue("DeviceCheckDisabled"))
-	if err != nil {
-		errors = append(errors, fmt.Sprintf("DeviceCheckDisabled, invalid value: %v", err))
-	}
-	a.DeviceCheckKeyID = r.FormValue("DeviceCheckKeyID")
-	a.DeviceCheckTeamID = r.FormValue("DeviceCheckTeamID")
-	a.DeviceCheckPrivateKeySecret = r.FormValue("DeviceCheckPrivateKeySecret")
-
-	return errors
-}
-
-func decodePriorKey(priorKey string) (string, error) {
-	if priorKey != "" {
-		bytes, err := base64.StdEncoding.DecodeString(priorKey)
-		if err != nil {
-			return "", err
-		}
-		priorKey = string(bytes)
-	}
-	return priorKey, nil
-}
-
-func (h *appHandler) handlePost(w http.ResponseWriter, r *http.Request) {
-	ctx := r.Context()
-	m := TemplateMap{}
-	err := r.ParseForm()
-	if err != nil {
-		defer h.renderError(m, "Invalid request", w)
-		return
-	}
-
-	aadb := database.New(h.env.Database())
-
-	if action := r.FormValue("TODO"); action == "save" {
-		priorKey, err := decodePriorKey(r.FormValue("Key"))
-		if err != nil {
-			defer h.renderError(m, "Invalid request", w)
-			return
-		}
-
-		// Create new, or load previous.
-		authApp := model.NewAuthorizedApp()
-		if priorKey != "" {
-			authApp, err = aadb.GetAuthorizedApp(ctx, h.env.SecretManager(), priorKey)
-			if err != nil {
-				defer h.renderError(m, "Invalid request, app to edit not found.", w)
-				return
-			}
-		}
-		errors := populateAuthorizedApp(authApp, r)
-		if len(errors) > 0 {
-			m.AddErrors(errors...)
-			m["app"] = authApp
-			h.config.RenderTemplate(w, "app_view", m)
-			return
-		}
-
-		errors = authApp.Validate()
-		if len(errors) > 0 {
-			m.AddErrors(errors...)
-			m["app"] = authApp
-			h.config.RenderTemplate(w, "app_view", m)
-			return
-		}
-
-		if priorKey != "" {
-			if err := aadb.DeleteAuthorizedApp(ctx, priorKey); err != nil {
-				m.AddErrors(fmt.Sprintf("Error removing old version: %v", err))
-				m["app"] = authApp
-				h.config.RenderTemplate(w, "app_view", m)
-				return
-			}
-		}
-
-		if err := aadb.InsertAuthorizedApp(ctx, authApp); err != nil {
-			m.AddErrors(fmt.Sprintf("Error inserting authorized app: %v", err))
-		} else {
-			m.AddSuccess(fmt.Sprintf("Saved authorized app: %v", authApp.AppPackageName))
-		}
-		m["app"] = authApp
-		h.config.RenderTemplate(w, "app_view", m)
-		return
-
-	} else if action == "delete" {
-		priorKey, err := decodePriorKey(r.FormValue("Key"))
-		if err != nil {
-			defer h.renderError(m, "Invalid request", w)
-			return
-		}
-
-		_, err = aadb.GetAuthorizedApp(ctx, h.env.SecretManager(), priorKey)
-		if err != nil {
-			defer h.renderError(m, "Couldn't find record to delete.", w)
-			return
-		}
-
-		if err := aadb.DeleteAuthorizedApp(ctx, priorKey); err != nil {
-			defer h.renderError(m, fmt.Sprintf("Error deleting authorized app: %v", err), w)
-			return
-		}
-
-		m.AddSuccess(fmt.Sprintf("Successfully deleted app `%v`", priorKey))
-		m["app"] = model.NewAuthorizedApp()
-		h.config.RenderTemplate(w, "app_view", m)
-		return
-	}
-
-	h.renderError(m, "Invalid action requested", w)
-}
-
-func (h *appHandler) handleGet(w http.ResponseWriter, r *http.Request) {
-	ctx := r.Context()
-	m := TemplateMap{}
-
-	appIds := r.URL.Query()["apn"]
-	appID := ""
-	if len(appIds) > 0 {
-		appID = appIds[0]
-	}
-
-	authorizedApp := model.NewAuthorizedApp()
-
-	if appID == "" {
-		m.AddJumbotron("Authorized Applications", "Create New Authorized Application")
-		m["new"] = true
-	} else {
-		aadb := database.New(h.env.Database())
-		var err error
-		authorizedApp, err = aadb.GetAuthorizedApp(ctx, h.env.SecretManager(), appID)
-		if err != nil {
-			m["error"] = err
-			h.config.RenderTemplate(w, "error", m)
-			return
-		}
-		m.AddJumbotron("Authorized Applications", fmt.Sprintf("Edit: `%v`", authorizedApp.AppPackageName))
-	}
-	m["app"] = authorizedApp
-	m["previousKey"] = base64.StdEncoding.EncodeToString([]byte(authorizedApp.AppPackageName))
-	h.config.RenderTemplate(w, "app_view", m)
-}
diff --git a/tools/admin-console/main.go b/tools/admin-console/main.go
index b9cdc7a..b9041fc 100644
--- a/tools/admin-console/main.go
+++ b/tools/admin-console/main.go
@@ -19,24 +19,37 @@ package main
 import (
 	"context"
 	"log"
-	"net/http"
 
+	"github.com/gin-gonic/gin"
+	"github.com/google/exposure-notifications-server/internal/admin"
+	"github.com/google/exposure-notifications-server/internal/admin/authorizedapps"
+	"github.com/google/exposure-notifications-server/internal/admin/index"
 	"github.com/google/exposure-notifications-server/internal/setup"
 )
 
 func main() {
 	ctx := context.Background()
 
-	var config Config
+	var config admin.Config
 	env, closer, err := setup.Setup(ctx, &config)
 	if err != nil {
 		log.Fatalf("setup.Setup: %v", err)
 	}
 	defer closer()
 
-	http.Handle("/", NewIndexHandler(&config, env))
-	http.Handle("/app", NewAppHandler(&config, env))
+	router := gin.Default()
+	router.LoadHTMLGlob(config.TemplatePath + "/*")
+
+	// Landing page.
+	indexController := index.New(&config, env)
+	router.GET("/", indexController.Execute)
+
+	// Authorized App Handling.
+	authAppController := authorizedapps.NewView(&config, env)
+	router.GET("/app", authAppController.Execute)
+	saveAppController := authorizedapps.NewSave(&config, env)
+	router.POST("/app", saveAppController.Execute)
 
 	log.Printf("listening on http://localhost:" + config.Port)
-	log.Fatal(http.ListenAndServe(":"+config.Port, nil))
+	router.Run()
 }
diff --git a/tools/admin-console/templates/app_view.html b/tools/admin-console/templates/authorizedapp.html
similarity index 81%
rename from tools/admin-console/templates/app_view.html
rename to tools/admin-console/templates/authorizedapp.html
index e0b624f..bdfca39 100644
--- a/tools/admin-console/templates/app_view.html
+++ b/tools/admin-console/templates/authorizedapp.html
@@ -1,4 +1,4 @@
-{{define "view"}}
+{{define "authorizedapp"}}
 {{template "top" .}}
 
 {{if .new}}
@@ -7,7 +7,7 @@
 <h2>Edit Application {{.app.AppPackageName}}</h2>
 {{end}}
 
-<form method="POST" action="/app?&apn={{.app.AppPackageName}}" class="form-horizontal">
+<form method="POST" action="/app" class="form-horizontal">
   <input type="hidden" name="Key" value="{{.previousKey}}" />
   <div class="alert alert-info" role="alert">Basic Application Authorization Information</div>
   <div class="form-group row">
@@ -19,10 +19,10 @@
   <div class="form-group row">
     <label class="control-label col-sm-3" for="Platform">Platform:</label>
     <div class="col-sm-6">
-    <select name="Platform" id="Platform" value="{{.app.Platform}}">
-      <option value="android">Android</option>
-      <option value="ios">iOS</option>
-      <option value="both">both</option>
+    <select name="Platform" id="Platform">
+      <option value="android" {{if eq "android" .app.Platform}}selected{{end}}>Android</option>
+      <option value="ios" {{if eq "ios" .app.Platform}}selected{{end}}>iOS</option>
+      <option value="both" {{if eq "both" .app.Platform}}selected{{end}}>both</option>
     </select>
     </div>
   </div>
@@ -39,9 +39,9 @@
   <div class="form-group row">
     <label class="control-label col-sm-3" for="SafetyNetDisabled">SafetyNet Verification Disabled:</label>
     <div class="col-sm-6">
-      <select name="SafetyNetDisabled" id="SafetyNetDisabled" value="{{.app.SafetyNetDisabled}}">
-        <option value="false">false</option>
-        <option value="true">true</option>
+      <select name="SafetyNetDisabled" id="SafetyNetDisabled">
+        <option value="false" {{if not .app.SafetyNetDisabled}}selected{{end}}>false</option>
+        <option value="true" {{if .app.SafetyNetDisabled}}selected{{end}}>true</option>
       </select>
     </div>
   </div>
@@ -55,18 +55,18 @@
   <div class="form-group row">
     <label class="control-label col-sm-3" for="SafetyNetBasicIntegrity">Require Basic Integrity:</label>
     <div class="col-sm-6">
-      <select name="SafetyNetBasicIntegrity" id="SafetyNetBasicIntegrity" value="{{.app.SafetyNetBasicIntegrity}}">
-        <option value="true">true</option>
-        <option value="false">false</option>
+      <select name="SafetyNetBasicIntegrity" id="SafetyNetBasicIntegrity">
+        <option value="true" {{if .app.SafetyNetBasicIntegrity}}selected{{end}}>true</option>
+        <option value="false" {{if not .app.SafetyNetBasicIntegrity}}selected{{end}}>false</option>
       </select>
     </div>
   </div>
   <div class="form-group row">
     <label class="control-label col-sm-3" for="SafetyNetCTSProfileMatch">Require CTS Profile Match:</label>
     <div class="col-sm-6">
-      <select name="SafetyNetCTSProfileMatch" id="SafetyNetCTSProfileMatch" value="{{.app.SafetyNetCTSProfileMatch}}">
-        <option value="true">true</option>
-        <option value="false">false</option>
+      <select name="SafetyNetCTSProfileMatch" id="SafetyNetCTSProfileMatch">
+        <option value="true" {{if .app.SafetyNetCTSProfileMatch}}selected{{end}}>true</option>
+        <option value="false" {{if not .app.SafetyNetCTSProfileMatch}}selected{{end}}>false</option>
       </select>
     </div>
   </div>
@@ -91,9 +91,9 @@
   <div class="form-group row">
     <label class="control-label col-sm-3" for="DeviceCheckDisabled">DeviceCheck Verification Disabled:</label>
     <div class="col-sm-6">
-      <select name="DeviceCheckDisabled" id="DeviceCheckDisabled" value="{{.app.DeviceCheckDisabled}}">
-        <option value="false">false</option>
-        <option value="true">true</option>
+      <select name="DeviceCheckDisabled" id="DeviceCheckDisabled">
+        <option value="false" {{if not .app.DeviceCheckDisabled}}selected{{end}}>false</option>
+        <option value="true" {{if .app.DeviceCheckDisabled}}selected{{end}}>true</option>
       </select>
     </div>
   </div>
diff --git a/tools/admin-console/templates/error.html b/tools/admin-console/templates/error.html
index 9a8e8a9..0f65f1c 100644
--- a/tools/admin-console/templates/error.html
+++ b/tools/admin-console/templates/error.html
@@ -1,1 +1,5 @@
+{{define "error"}}
+{{template "top" .}}
 <!-- this space left intentionally blank -->
+{{template "bottom" .}}
+{{end}}
diff --git a/tools/admin-console/templates/index.html b/tools/admin-console/templates/index.html
index bc5eaa0..afe9e26 100644
--- a/tools/admin-console/templates/index.html
+++ b/tools/admin-console/templates/index.html
@@ -1,4 +1,4 @@
-{{define "view"}}
+{{define "index"}}
 {{template "top" .}}
 
 <h2>Authorized Applications</h2>


Test patch:
diff --git a/tools/admin-console/authorizedapps_test.go b/internal/admin/authorizedapps/view_test.go
similarity index 79%
rename from tools/admin-console/authorizedapps_test.go
rename to internal/admin/authorizedapps/view_test.go
index 872943d..35d7417 100644
--- a/tools/admin-console/authorizedapps_test.go
+++ b/internal/admin/authorizedapps/view_test.go
@@ -12,12 +12,13 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package main
+package authorizedapps
 
 import (
 	"net/http/httptest"
 	"testing"
 
+	"github.com/google/exposure-notifications-server/internal/admin"
 	"github.com/google/exposure-notifications-server/internal/authorizedapp/model"
 )
 
@@ -28,13 +29,17 @@ func TestRenderAuthorizedApps(t *testing.T) {
 	// And whatever you changed is used in the
 	//  tools/admin-console/templates/app_view.html
 	// That is what caused the test failure.
-	m := TemplateMap{}
+	m := admin.TemplateMap{}
 	authorizedApp := model.NewAuthorizedApp()
 	m["app"] = authorizedApp
 
 	recorder := httptest.NewRecorder()
-	config := Config{TemplatePath: "templates", TopFile: "top", BotFile: "bottom"}
-	err := config.RenderTemplate(recorder, "app_view", m)
+	config := admin.Config{
+		TemplatePath: "../../../tools/admin-console/templates",
+		TopFile:      "top",
+		BotFile:      "bottom",
+	}
+	err := config.RenderTemplate(recorder, "authorizedapp", m)
 	if err != nil {
 		t.Fatalf("error rendering template: %v", err)
 	}