From e71de6dc1f0c9a70a743d633dc7b254a7f4f1958 Mon Sep 17 00:00:00 2001
From: Uku Taht <Uku.taht@gmail.com>
Date: Wed, 16 Jun 2021 15:00:07 +0300
Subject: [PATCH] Invitations (#1122)

* Invite existing user to a site

* Add invitation flow for non-existing users

* Accept and reject invitations

* Use invitation flow for existing users

* Locking mechanism for sites

* Authorization for site settings

* Show usage based on site ownership

* Add ability to remove members from a site

* Do not show settings link to viewer roles

* Ability to remove invitations

* Remove `Plausible.Sites.count_for/1`

* Fix tests

* Do not show the trial banner after the trial

* Correct trial emails

* Transfer ownership

* Send invitation email to existing user

* Add invitation email flows

* Add plug for role-based authorization

* Rename AuthorizeStatsPlug -> AuthorizeSiteAccess

* Add email flow for ownership transfer

* Fix URLs in emails

* Fix small copy issues

* Make 'People' its own section in site settings

* Notify user via email if their access has been removed

* Check site lock status when invitation is accepted

* Check lock status when user subscribes

* Make sure only admins and owners can create shared links

* Changelog

* Add LockSites to daily cron

* Clean invitations after 48 hours

* Add notices about expiry

* Add invitation expired page

* Add doc link
---
 CHANGELOG.md                                  |   1 +
 assets/js/dashboard/historical.js             |   2 +-
 assets/js/dashboard/index.js                  |   4 +-
 assets/js/dashboard/mount.js                  |   3 +-
 assets/js/dashboard/router.js                 |   4 +-
 assets/js/dashboard/site-switcher.js          |  26 ++-
 .../dashboard/stats/sources/search-terms.js   |   4 +-
 assets/tailwind.config.js                     |   3 +-
 config/runtime.exs                            |  16 +-
 lib/plausible/auth/auth.ex                    |  17 +-
 lib/plausible/auth/invitation.ex              |  23 ++
 lib/plausible/auth/user.ex                    |   4 +-
 lib/plausible/billing/billing.ex              |  31 ++-
 lib/plausible/billing/site_locker.ex          |  29 +++
 lib/plausible/site/membership.ex              |   7 +-
 lib/plausible/site/schema.ex                  |   3 +
 lib/plausible/sites.ex                        |  56 +++--
 .../api/external_sites_controller.ex          |   3 +-
 .../controllers/api/stats_controller.ex       |   4 +-
 .../controllers/auth_controller.ex            |  75 +++++-
 .../controllers/invitation_controller.ex      | 108 +++++++++
 .../controllers/site/membership_controller.ex | 149 ++++++++++++
 .../controllers/site_controller.ex            | 219 ++++++++++--------
 .../controllers/stats_controller.ex           |  52 +++--
 lib/plausible_web/email.ex                    |  97 ++++++++
 .../plugs/authorize_site_access.ex            |  43 ++++
 .../plugs/authorize_stats_api.ex              |   4 +-
 .../plugs/authorize_stats_plug.ex             |  33 ---
 .../plugs/upgrade_billing_plug.ex             |  21 --
 lib/plausible_web/router.ex                   |  18 +-
 .../templates/auth/activate.html.eex          |   8 +-
 .../auth/invitation_expired.html.eex          |  12 +
 .../register_from_invitation_form.html.eex    |  61 +++++
 .../email/existing_user_invitation.html.eex   |  10 +
 .../email/invitation_accepted.html.eex        |   9 +
 .../email/invitation_rejected.html.eex        |   9 +
 .../email/new_user_invitation.html.eex        |  11 +
 .../ownership_transfer_accepted.html.eex      |  10 +
 .../ownership_transfer_rejected.html.eex      |   9 +
 .../email/ownership_transfer_request.html.eex |  15 ++
 .../email/site_member_removed.html.eex        |  10 +
 .../templates/layout/_header.html.eex         |   7 +-
 .../templates/layout/site_settings.html.eex   |   4 +-
 .../templates/site/index.html.eex             | 131 ++++++++++-
 .../membership/invite_member_form.html.eex    |  63 +++++
 .../transfer_ownership_form.html.eex          |  30 +++
 .../templates/site/settings_general.html.eex  |   7 +-
 .../templates/site/settings_people.html.eex   | 149 ++++++++++++
 .../templates/stats/site_locked.html.eex      |  32 +++
 .../templates/stats/stats.html.eex            |   2 +-
 lib/plausible_web/views/layout_view.ex        |  12 +-
 .../views/site/membership_view.ex             |   3 +
 lib/plausible_web/views/site_view.ex          |  20 ++
 lib/workers/clean_invitations.ex              |  14 ++
 lib/workers/lock_sites.ex                     |  15 ++
 lib/workers/send_check_stats_emails.ex        |   2 +-
 lib/workers/send_email_report.ex              |   2 +-
 lib/workers/send_site_setup_emails.ex         |  14 +-
 lib/workers/send_trial_notifications.ex       |   8 +-
 ...531080158_add_role_to_site_memberships.exs |  13 ++
 .../20210601090924_add_invitations.exs        |  18 ++
 .../20210604085943_add_locked_to_sites.exs    |   9 +
 test/plausible/auth/auth_test.exs             |  19 +-
 test/plausible/billing/billing_test.exs       |  76 +++++-
 test/plausible/billing/site_locker_test.exs   | 110 +++++++++
 test/plausible/sites_test.exs                 |  10 +-
 .../api/external_sites_controller_test.exs    |  23 ++
 .../invitation_controller_test.exs            | 158 +++++++++++++
 .../site/membership_controller_test.exs       | 204 ++++++++++++++++
 test/support/factory.ex                       |  12 +
 test/workers/clean_invitations_test.exs       |  28 +++
 test/workers/lock_sites_test.exs              |  96 ++++++++
 .../workers/send_trial_notifications_test.exs |  34 ++-
 73 files changed, 2261 insertions(+), 287 deletions(-)
 create mode 100644 lib/plausible/auth/invitation.ex
 create mode 100644 lib/plausible/billing/site_locker.ex
 create mode 100644 lib/plausible_web/controllers/invitation_controller.ex
 create mode 100644 lib/plausible_web/controllers/site/membership_controller.ex
 create mode 100644 lib/plausible_web/plugs/authorize_site_access.ex
 delete mode 100644 lib/plausible_web/plugs/authorize_stats_plug.ex
 delete mode 100644 lib/plausible_web/plugs/upgrade_billing_plug.ex
 create mode 100644 lib/plausible_web/templates/auth/invitation_expired.html.eex
 create mode 100644 lib/plausible_web/templates/auth/register_from_invitation_form.html.eex
 create mode 100644 lib/plausible_web/templates/email/existing_user_invitation.html.eex
 create mode 100644 lib/plausible_web/templates/email/invitation_accepted.html.eex
 create mode 100644 lib/plausible_web/templates/email/invitation_rejected.html.eex
 create mode 100644 lib/plausible_web/templates/email/new_user_invitation.html.eex
 create mode 100644 lib/plausible_web/templates/email/ownership_transfer_accepted.html.eex
 create mode 100644 lib/plausible_web/templates/email/ownership_transfer_rejected.html.eex
 create mode 100644 lib/plausible_web/templates/email/ownership_transfer_request.html.eex
 create mode 100644 lib/plausible_web/templates/email/site_member_removed.html.eex
 create mode 100644 lib/plausible_web/templates/site/membership/invite_member_form.html.eex
 create mode 100644 lib/plausible_web/templates/site/membership/transfer_ownership_form.html.eex
 create mode 100644 lib/plausible_web/templates/site/settings_people.html.eex
 create mode 100644 lib/plausible_web/templates/stats/site_locked.html.eex
 create mode 100644 lib/plausible_web/views/site/membership_view.ex
 create mode 100644 lib/workers/clean_invitations.ex
 create mode 100644 lib/workers/lock_sites.ex
 create mode 100644 priv/repo/migrations/20210531080158_add_role_to_site_memberships.exs
 create mode 100644 priv/repo/migrations/20210601090924_add_invitations.exs
 create mode 100644 priv/repo/migrations/20210604085943_add_locked_to_sites.exs
 create mode 100644 test/plausible/billing/site_locker_test.exs
 create mode 100644 test/plausible_web/controllers/invitation_controller_test.exs
 create mode 100644 test/plausible_web/controllers/site/membership_controller_test.exs
 create mode 100644 test/workers/clean_invitations_test.exs
 create mode 100644 test/workers/lock_sites_test.exs

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 19315b57..bf92042c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,7 @@ All notable changes to this project will be documented in this file.
   `DATABASE_SOCKET_DIR` & `DATABASE_NAME` were added.
 - Time on Page metric available in detailed Top Pages report plausible/analytics#1007
 - Added `CLICKHOUSE_FLUSH_INTERVAL_MS` and `CLICKHOUSE_MAX_BUFFER_SIZE` configuration parameters
+- Ability to invite users to sites with different roles plausible/analytics#1122
 
 ### Fixed
 - Fix weekly report time range plausible/analytics#951
diff --git a/assets/js/dashboard/historical.js b/assets/js/dashboard/historical.js
index f2380ac5..9266cefb 100644
--- a/assets/js/dashboard/historical.js
+++ b/assets/js/dashboard/historical.js
@@ -32,7 +32,7 @@ class Historical extends React.Component {
         <div className={`${navClass} top-0 sm:py-3 py-1 z-9 ${this.props.stuck && !this.props.site.embedded ? 'z-10 fullwidth-shadow bg-gray-50 dark:bg-gray-850' : ''}`}>
           <div className="items-center w-full sm:flex">
             <div className="flex items-center w-full mb-2 sm:mb-0">
-              <SiteSwitcher site={this.props.site} loggedIn={this.props.loggedIn} />
+              <SiteSwitcher site={this.props.site} loggedIn={this.props.loggedIn} currentUserRole={this.props.currentUserRole} />
               <CurrentVisitors timer={this.props.timer} site={this.props.site} query={this.props.query} />
               <Filters query={this.props.query} history={this.props.history} />
             </div>
diff --git a/assets/js/dashboard/index.js b/assets/js/dashboard/index.js
index d5143d90..42fa42d1 100644
--- a/assets/js/dashboard/index.js
+++ b/assets/js/dashboard/index.js
@@ -44,9 +44,9 @@ class Dashboard extends React.Component {
 
   render() {
     if (this.state.query.period === 'realtime') {
-      return <Realtime timer={this.state.timer} site={this.props.site} loggedIn={this.props.loggedIn} query={this.state.query} />
+      return <Realtime timer={this.state.timer} site={this.props.site} loggedIn={this.props.loggedIn} currentUserRole={this.props.currentUserRole} query={this.state.query} />
     } else {
-      return <Historical timer={this.state.timer} site={this.props.site} loggedIn={this.props.loggedIn} query={this.state.query} />
+      return <Historical timer={this.state.timer} site={this.props.site} loggedIn={this.props.loggedIn} currentUserRole={this.props.currentUserRole} query={this.state.query} />
     }
   }
 }
diff --git a/assets/js/dashboard/mount.js b/assets/js/dashboard/mount.js
index db86a5b1..635d2e5c 100644
--- a/assets/js/dashboard/mount.js
+++ b/assets/js/dashboard/mount.js
@@ -20,6 +20,7 @@ if (container) {
   }
 
   const loggedIn = container.dataset.loggedIn === 'true'
+  const currentUserRole = container.dataset.currentUserRole
   const sharedLinkAuth = container.dataset.sharedLinkAuth
   if (sharedLinkAuth) {
     api.setSharedLinkAuth(sharedLinkAuth)
@@ -27,7 +28,7 @@ if (container) {
 
   const app = (
     <ErrorBoundary>
-      <Router site={site} loggedIn={loggedIn} />
+      <Router site={site} loggedIn={loggedIn} currentUserRole={currentUserRole} />
     </ErrorBoundary>
   )
 
diff --git a/assets/js/dashboard/router.js b/assets/js/dashboard/router.js
index 23d223ba..9183513c 100644
--- a/assets/js/dashboard/router.js
+++ b/assets/js/dashboard/router.js
@@ -22,12 +22,12 @@ function ScrollToTop() {
   return null;
 }
 
-export default function Router({site, loggedIn}) {
+export default function Router({site, loggedIn, currentUserRole}) {
   return (
     <BrowserRouter>
       <Route path="/:domain">
         <ScrollToTop />
-        <Dash site={site} loggedIn={loggedIn} />
+        <Dash site={site} loggedIn={loggedIn} currentUserRole={currentUserRole} />
         <Switch>
           <Route exact path={["/:domain/sources", "/:domain/utm_mediums", "/:domain/utm_sources", "/:domain/utm_campaigns"]}>
             <SourcesModal site={site} />
diff --git a/assets/js/dashboard/site-switcher.js b/assets/js/dashboard/site-switcher.js
index 3269f2f6..33beb26a 100644
--- a/assets/js/dashboard/site-switcher.js
+++ b/assets/js/dashboard/site-switcher.js
@@ -76,13 +76,29 @@ export default class SiteSwitcher extends React.Component {
       <a href={domain === this.props.site.domain ? null : `/${encodeURIComponent(domain)}`} key={domain} className={`flex items-center justify-between truncate px-4 py-2 md:text-sm leading-5 text-gray-700 dark:text-gray-300 ${extraClass}`}>
         <span>
           <img src={`https://icons.duckduckgo.com/ip3/${domain}.ico`} referrerPolicy="no-referrer" onError={(e)=>{e.target.onerror = null; e.target.src="https://icons.duckduckgo.com/ip3/placeholder.ico"}} className="inline w-4 mr-2 align-middle" />
-          <span class="truncate inline-block align-middle max-w-3xs pr-2">{domain}</span>
+          <span className="truncate inline-block align-middle max-w-3xs pr-2">{domain}</span>
         </span>
         {index < 9 && <span>{index+1}</span>}
       </a>
     )
   }
 
+  renderSettingsLink() {
+    if (['owner', 'admin'].includes(this.props.currentUserRole)) {
+      return (
+        <React.Fragment>
+          <div className="py-1">
+            <a href={`/${encodeURIComponent(this.props.site.domain)}/settings`} className="group flex items-center px-4 py-2 md:text-sm leading-5 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-900 hover:text-gray-900 dark:hover:text-gray-100 focus:outline-none focus:bg-gray-100 dark:focus:bg-gray-900 focus:text-gray-900 dark:focus:text-gray-100" role="menuitem">
+              <svg className="mr-2 h-4 w-4 text-gray-500 dark:text-gray-200 group-hover:text-gray-600 dark:group-hover:text-gray-400 group-focus:text-gray-500 dark:group-focus:text-gray-200" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fillRule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clipRule="evenodd"></path></svg>
+              Site settings
+            </a>
+          </div>
+          <div className="border-t border-gray-200 dark:border-gray-500"></div>
+        </React.Fragment>
+      )
+    }
+  }
+
   renderDropdown() {
     if (this.state.loading) {
       return <div className="px-4 py-6"><div className="loading sm mx-auto"><div></div></div></div>
@@ -91,13 +107,7 @@ export default class SiteSwitcher extends React.Component {
     } else {
       return (
         <React.Fragment>
-          <div className="py-1">
-            <a href={`/${encodeURIComponent(this.props.site.domain)}/settings`} className="group flex items-center px-4 py-2 md:text-sm leading-5 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-900 hover:text-gray-900 dark:hover:text-gray-100 focus:outline-none focus:bg-gray-100 dark:focus:bg-gray-900 focus:text-gray-900 dark:focus:text-gray-100" role="menuitem">
-              <svg class="mr-2 h-4 w-4 text-gray-500 dark:text-gray-200 group-hover:text-gray-600 dark:group-hover:text-gray-400 group-focus:text-gray-500 dark:group-focus:text-gray-200" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd"></path></svg>
-  Site settings
-            </a>
-          </div>
-          <div className="border-t border-gray-200 dark:border-gray-500"></div>
+          { this.renderSettingsLink() }
           <div className="py-1">
             { this.state.sites.map(this.renderSiteLink.bind(this)) }
           </div>
diff --git a/assets/js/dashboard/stats/sources/search-terms.js b/assets/js/dashboard/stats/sources/search-terms.js
index 65018f8c..77252269 100644
--- a/assets/js/dashboard/stats/sources/search-terms.js
+++ b/assets/js/dashboard/stats/sources/search-terms.js
@@ -29,7 +29,7 @@ export default class SearchTerms extends React.Component {
         loading: false,
         searchTerms: res.search_terms || [],
         notConfigured: res.not_configured,
-        isOwner: res.is_owner
+        isAdmin: res.is_admin
       }))
   }
 
@@ -65,7 +65,7 @@ export default class SearchTerms extends React.Component {
           <RocketIcon />
           <div>The site is not connected to Google Search Keywords</div>
           <div>Cannot show search terms</div>
-          {this.state.isOwner && <a href={`/${encodeURIComponent(this.props.site.domain)}/settings/search-console`} className="button mt-4">Connect with Google</a> }
+          {this.state.isAdmin && <a href={`/${encodeURIComponent(this.props.site.domain)}/settings/search-console`} className="button mt-4">Connect with Google</a> }
         </div>
       )
     } else if (this.state.searchTerms.length > 0) {
diff --git a/assets/tailwind.config.js b/assets/tailwind.config.js
index a5655244..1e781ce1 100644
--- a/assets/tailwind.config.js
+++ b/assets/tailwind.config.js
@@ -44,7 +44,8 @@ module.exports = {
       backgroundOpacity: ['dark'],
       display: ['dark'],
       cursor: ['hover'],
-      justifyContent: ['responsive']
+      justifyContent: ['responsive'],
+      backgroundColor: ['odd', 'even'],
     }
   },
   plugins: [
diff --git a/config/runtime.exs b/config/runtime.exs
index b0360824..01d80252 100644
--- a/config/runtime.exs
+++ b/config/runtime.exs
@@ -272,7 +272,9 @@ if config_env() == :prod && !disable_cron do
     # Every 15 minutes
     {"*/15 * * * *", Plausible.Workers.SpikeNotifier},
     # Every day at midnight
-    {"0 0 * * *", Plausible.Workers.CleanEmailVerificationCodes}
+    {"0 0 * * *", Plausible.Workers.CleanEmailVerificationCodes},
+    # Every day at 1am
+    {"0 1 * * *", Plausible.Workers.CleanInvitations}
   ]
 
   extra_cron = [
@@ -283,7 +285,9 @@ if config_env() == :prod && !disable_cron do
     # Daily at 15
     {"0 15 * * *", Plausible.Workers.NotifyAnnualRenewal},
     # Every 10 minutes
-    {"*/10 * * * *", Plausible.Workers.ProvisionSslCertificates}
+    {"*/10 * * * *", Plausible.Workers.ProvisionSslCertificates},
+    # Every midnight
+    {"0 0 * * *", Plausible.Workers.LockSites}
   ]
 
   base_queues = [
@@ -292,16 +296,18 @@ if config_env() == :prod && !disable_cron do
     send_email_reports: 1,
     spike_notifications: 1,
     fetch_tweets: 1,
-    clean_email_verification_codes: 1,
     check_stats_emails: 1,
-    site_setup_emails: 1
+    site_setup_emails: 1,
+    clean_email_verification_codes: 1,
+    clean_invitations: 1
   ]
 
   extra_queues = [
     provision_ssl_certificates: 1,
     trial_notification_emails: 1,
     check_usage: 1,
-    notify_annual_renewal: 1
+    notify_annual_renewal: 1,
+    lock_sites: 1
   ]
 
   # Keep 30 days history
diff --git a/lib/plausible/auth/auth.ex b/lib/plausible/auth/auth.ex
index ecd4f535..ac3c7864 100644
--- a/lib/plausible/auth/auth.ex
+++ b/lib/plausible/auth/auth.ex
@@ -59,8 +59,7 @@ defmodule Plausible.Auth do
   end
 
   def create_user(name, email, pwd) do
-    %Auth.User{}
-    |> Auth.User.new(%{name: name, email: email, password: pwd, password_confirmation: pwd})
+    Auth.User.new(%{name: name, email: email, password: pwd, password_confirmation: pwd})
     |> Repo.insert()
   end
 
@@ -68,13 +67,14 @@ defmodule Plausible.Auth do
     Repo.get_by(Auth.User, opts)
   end
 
-  def user_completed_setup?(user) do
+  def has_active_sites?(user, roles \\ [:owner, :admin, :viewer]) do
     domains =
       Repo.all(
         from u in Plausible.Auth.User,
           where: u.id == ^user.id,
           join: sm in Plausible.Site.Membership,
           on: sm.user_id == u.id,
+          where: sm.role in ^roles,
           join: s in Plausible.Site,
           on: s.id == sm.site_id,
           select: s.domain
@@ -82,4 +82,15 @@ defmodule Plausible.Auth do
 
     Stats.has_pageviews?(domains)
   end
+
+  def user_owns_sites?(user) do
+    Repo.exists?(
+      from(s in Plausible.Site,
+        join: sm in Plausible.Site.Membership,
+        on: sm.site_id == s.id,
+        where: sm.user_id == ^user.id,
+        where: sm.role == :owner
+      )
+    )
+  end
 end
diff --git a/lib/plausible/auth/invitation.ex b/lib/plausible/auth/invitation.ex
new file mode 100644
index 00000000..18925573
--- /dev/null
+++ b/lib/plausible/auth/invitation.ex
@@ -0,0 +1,23 @@
+defmodule Plausible.Auth.Invitation do
+  use Ecto.Schema
+  import Ecto.Changeset
+
+  @derive {Jason.Encoder, only: [:invitation_id, :role, :site]}
+  @required [:email, :role, :site_id, :inviter_id]
+  schema "invitations" do
+    field :invitation_id, :string
+    field :email, :string
+    field :role, Ecto.Enum, values: [:owner, :admin, :viewer]
+
+    belongs_to :inviter, Plausible.Auth.User
+    belongs_to :site, Plausible.Site
+
+    timestamps()
+  end
+
+  def new(attrs \\ %{}) do
+    %__MODULE__{invitation_id: Nanoid.generate()}
+    |> cast(attrs, @required)
+    |> validate_required(@required)
+  end
+end
diff --git a/lib/plausible/auth/user.ex b/lib/plausible/auth/user.ex
index aff7c1b3..1c163fe5 100644
--- a/lib/plausible/auth/user.ex
+++ b/lib/plausible/auth/user.ex
@@ -29,8 +29,8 @@ defmodule Plausible.Auth.User do
     timestamps()
   end
 
-  def new(user, attrs \\ %{}) do
-    user
+  def new(attrs \\ %{}) do
+    %Plausible.Auth.User{}
     |> cast(attrs, @required)
     |> validate_required(@required)
     |> validate_length(:password, min: 6, message: "has to be at least 6 characters")
diff --git a/lib/plausible/billing/billing.ex b/lib/plausible/billing/billing.ex
index 4e076864..af6b927a 100644
--- a/lib/plausible/billing/billing.ex
+++ b/lib/plausible/billing/billing.ex
@@ -18,7 +18,7 @@ defmodule Plausible.Billing do
 
     changeset = Subscription.changeset(%Subscription{}, format_subscription(params))
 
-    Repo.insert(changeset)
+    Repo.insert(changeset) |> check_lock_status
   end
 
   def subscription_updated(params) do
@@ -119,10 +119,10 @@ defmodule Plausible.Billing do
     subscription.next_bill_date && !Timex.before?(subscription.next_bill_date, Timex.today())
   end
 
-  defp subscription_is_active?(_), do: false
+  defp subscription_is_active?(%Subscription{}), do: false
+  defp subscription_is_active?(nil), do: false
 
   def on_trial?(user) do
-    user = Repo.preload(user, :subscription)
     !subscription_is_active?(user.subscription) && trial_days_left(user) >= 0
   end
 
@@ -135,8 +135,8 @@ defmodule Plausible.Billing do
     pageviews + custom_events
   end
 
-  defp get_usage_for_billing_cycle(user, cycle) do
-    domains = Enum.map(user.sites, & &1.domain)
+  defp get_usage_for_billing_cycle(sites, cycle) do
+    domains = Enum.map(sites, & &1.domain)
 
     ClickhouseRepo.one(
       from e in "events",
@@ -149,11 +149,11 @@ defmodule Plausible.Billing do
 
   def last_two_billing_months_usage(user, today \\ Timex.today()) do
     {first, second} = last_two_billing_cycles(user, today)
-    user = Repo.preload(user, :sites)
+    sites = Plausible.Sites.owned_by(user)
 
     {
-      get_usage_for_billing_cycle(user, first),
-      get_usage_for_billing_cycle(user, second)
+      get_usage_for_billing_cycle(sites, first),
+      get_usage_for_billing_cycle(sites, second)
     }
   end
 
@@ -178,9 +178,9 @@ defmodule Plausible.Billing do
   end
 
   def usage_breakdown(user) do
-    user = Repo.preload(user, :sites)
+    sites = Plausible.Sites.owned_by(user)
 
-    Enum.reduce(user.sites, {0, 0}, fn site, {pageviews, custom_events} ->
+    Enum.reduce(sites, {0, 0}, fn site, {pageviews, custom_events} ->
       usage = Plausible.Stats.Clickhouse.usage(site)
 
       {pageviews + Map.get(usage, "pageviews", 0),
@@ -221,5 +221,16 @@ defmodule Plausible.Billing do
   defp present?(nil), do: false
   defp present?(_), do: true
 
+  defp check_lock_status({:ok, subscription}) do
+    user =
+      Repo.get(Plausible.Auth.User, subscription.user_id)
+      |> Map.put(:subscription, subscription)
+
+    Plausible.Billing.SiteLocker.check_sites_for(user)
+    {:ok, subscription}
+  end
+
+  defp check_lock_status(err), do: err
+
   defp paddle_api(), do: Application.fetch_env!(:plausible, :paddle_api)
 end
diff --git a/lib/plausible/billing/site_locker.ex b/lib/plausible/billing/site_locker.ex
new file mode 100644
index 00000000..e7cbedb6
--- /dev/null
+++ b/lib/plausible/billing/site_locker.ex
@@ -0,0 +1,29 @@
+defmodule Plausible.Billing.SiteLocker do
+  use Plausible.Repo
+
+  def check_sites_for(user) do
+    if Plausible.Billing.needs_to_upgrade?(user) do
+      set_lock_status_for(user, true)
+    else
+      set_lock_status_for(user, false)
+    end
+  end
+
+  defp set_lock_status_for(user, status) do
+    site_ids =
+      Repo.all(
+        from s in Plausible.Site.Membership,
+          where: s.user_id == ^user.id,
+          where: s.role == :owner,
+          select: s.site_id
+      )
+
+    site_q =
+      from(
+        s in Plausible.Site,
+        where: s.id in ^site_ids
+      )
+
+    Repo.update_all(site_q, set: [locked: status])
+  end
+end
diff --git a/lib/plausible/site/membership.ex b/lib/plausible/site/membership.ex
index 2732dd8c..52e7c94e 100644
--- a/lib/plausible/site/membership.ex
+++ b/lib/plausible/site/membership.ex
@@ -3,15 +3,16 @@ defmodule Plausible.Site.Membership do
   import Ecto.Changeset
 
   schema "site_memberships" do
+    field :role, Ecto.Enum, values: [:owner, :admin, :viewer]
     belongs_to :site, Plausible.Site
     belongs_to :user, Plausible.Auth.User
 
     timestamps()
   end
 
-  def changeset(user, attrs) do
-    user
-    |> cast(attrs, [:user_id, :site_id])
+  def changeset(schema, attrs) do
+    schema
+    |> cast(attrs, [:user_id, :site_id, :role])
     |> validate_required([:user_id, :site_id])
   end
 end
diff --git a/lib/plausible/site/schema.ex b/lib/plausible/site/schema.ex
index b0f90716..ade0ffc2 100644
--- a/lib/plausible/site/schema.ex
+++ b/lib/plausible/site/schema.ex
@@ -9,8 +9,11 @@ defmodule Plausible.Site do
     field :domain, :string
     field :timezone, :string, default: "Etc/UTC"
     field :public, :boolean
+    field :locked, :boolean
 
     many_to_many :members, User, join_through: Plausible.Site.Membership
+    has_many :memberships, Plausible.Site.Membership
+    has_many :invitations, Plausible.Auth.Invitation
     has_one :google_auth, GoogleAuth
     has_one :weekly_report, Plausible.Site.WeeklyReport
     has_one :monthly_report, Plausible.Site.MonthlyReport
diff --git a/lib/plausible/sites.ex b/lib/plausible/sites.ex
index 353d8e33..f50f034d 100644
--- a/lib/plausible/sites.ex
+++ b/lib/plausible/sites.ex
@@ -3,7 +3,7 @@ defmodule Plausible.Sites do
   alias Plausible.Site.{CustomDomain, SharedLink}
 
   def create(user, params) do
-    count = count_for(user)
+    count = Enum.count(owned_by(user))
     limit = Plausible.Billing.sites_limit(user)
 
     if count >= limit do
@@ -45,28 +45,23 @@ defmodule Plausible.Sites do
     base <> domain <> "?auth=" <> link.slug
   end
 
-  def get_for_user!(user_id, domain), do: Repo.one!(get_for_user_q(user_id, domain))
-  def get_for_user(user_id, domain), do: Repo.one(get_for_user_q(user_id, domain))
+  def get_for_user!(user_id, domain, roles \\ [:owner, :admin, :viewer]),
+    do: Repo.one!(get_for_user_q(user_id, domain, roles))
 
-  def get_for_user_q(user_id, domain) do
+  def get_for_user(user_id, domain, roles \\ [:owner, :admin, :viewer]),
+    do: Repo.one(get_for_user_q(user_id, domain, roles))
+
+  defp get_for_user_q(user_id, domain, roles) do
     from(s in Plausible.Site,
       join: sm in Plausible.Site.Membership,
       on: sm.site_id == s.id,
       where: sm.user_id == ^user_id,
+      where: sm.role in ^roles,
       where: s.domain == ^domain,
       select: s
     )
   end
 
-  def count_for(user) do
-    Repo.aggregate(
-      from(sm in Plausible.Site.Membership,
-        where: sm.user_id == ^user.id
-      ),
-      :count
-    )
-  end
-
   def has_goals?(site) do
     Repo.exists?(
       from g in Plausible.Goal,
@@ -74,10 +69,39 @@ defmodule Plausible.Sites do
     )
   end
 
-  def is_owner?(user_id, site) do
-    Repo.exists?(
+  def is_member?(user_id, site) do
+    role(user_id, site) !== nil
+  end
+
+  def has_admin_access?(user_id, site) do
+    role(user_id, site) in [:admin, :owner]
+  end
+
+  def role(user_id, site) do
+    Repo.one(
       from sm in Plausible.Site.Membership,
-        where: sm.user_id == ^user_id and sm.site_id == ^site.id
+        where: sm.user_id == ^user_id and sm.site_id == ^site.id,
+        select: sm.role
+    )
+  end
+
+  def owned_by(user) do
+    Repo.all(
+      from s in Plausible.Site,
+        join: sm in Plausible.Site.Membership,
+        on: sm.site_id == s.id,
+        where: sm.role == :owner,
+        where: sm.user_id == ^user.id
+    )
+  end
+
+  def owner_for(site) do
+    Repo.one(
+      from u in Plausible.Auth.User,
+        join: sm in Plausible.Site.Membership,
+        on: sm.user_id == u.id,
+        where: sm.site_id == ^site.id,
+        where: sm.role == :owner
     )
   end
 
diff --git a/lib/plausible_web/controllers/api/external_sites_controller.ex b/lib/plausible_web/controllers/api/external_sites_controller.ex
index 44447770..bfff8271 100644
--- a/lib/plausible_web/controllers/api/external_sites_controller.ex
+++ b/lib/plausible_web/controllers/api/external_sites_controller.ex
@@ -37,7 +37,8 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
   def find_or_create_shared_link(conn, params) do
     with {:ok, site_id} <- expect_param_key(params, "site_id"),
          {:ok, link_name} <- expect_param_key(params, "name"),
-         site when not is_nil(site) <- Sites.get_for_user(conn.assigns[:current_user].id, site_id) do
+         site when not is_nil(site) <-
+           Sites.get_for_user(conn.assigns[:current_user].id, site_id, [:owner, :admin]) do
       shared_link = Repo.get_by(Plausible.Site.SharedLink, site_id: site.id, name: link_name)
 
       shared_link =
diff --git a/lib/plausible_web/controllers/api/stats_controller.ex b/lib/plausible_web/controllers/api/stats_controller.ex
index 33d7f510..6f3806fd 100644
--- a/lib/plausible_web/controllers/api/stats_controller.ex
+++ b/lib/plausible_web/controllers/api/stats_controller.ex
@@ -233,8 +233,8 @@ defmodule PlausibleWeb.Api.StatsController do
       nil ->
         {_, total_visitors} = Stats.pageviews_and_visitors(site, query)
         user_id = get_session(conn, :current_user_id)
-        is_owner = user_id && Plausible.Sites.is_owner?(user_id, site)
-        json(conn, %{not_configured: true, is_owner: is_owner, total_visitors: total_visitors})
+        is_admin = user_id && Plausible.Sites.has_admin_access?(user_id, site)
+        json(conn, %{not_configured: true, is_admin: is_admin, total_visitors: total_visitors})
 
       {:ok, terms} ->
         {_, total_visitors} = Stats.pageviews_and_visitors(site, query)
diff --git a/lib/plausible_web/controllers/auth_controller.ex b/lib/plausible_web/controllers/auth_controller.ex
index be554007..dfb87870 100644
--- a/lib/plausible_web/controllers/auth_controller.ex
+++ b/lib/plausible_web/controllers/auth_controller.ex
@@ -36,7 +36,7 @@ defmodule PlausibleWeb.AuthController do
       conn
       |> redirect(to: "/login")
     else
-      user = Plausible.Auth.User.new(%Plausible.Auth.User{}, params["user"])
+      user = Plausible.Auth.User.new(params["user"])
 
       if PlausibleWeb.Captcha.verify(params["h-captcha-response"]) do
         case Repo.insert(user) do
@@ -70,8 +70,71 @@ defmodule PlausibleWeb.AuthController do
     end
   end
 
+  def register_from_invitation_form(conn, %{"invitation_id" => invitation_id}) do
+    if Keyword.fetch!(Application.get_env(:plausible, :selfhost), :disable_registration) do
+      conn
+      |> redirect(to: "/login")
+    else
+      invitation = Repo.get_by(Plausible.Auth.Invitation, invitation_id: invitation_id)
+      changeset = Plausible.Auth.User.changeset(%Plausible.Auth.User{})
+
+      if invitation do
+        render(conn, "register_from_invitation_form.html",
+          changeset: changeset,
+          invitation: invitation,
+          layout: {PlausibleWeb.LayoutView, "focus.html"}
+        )
+      else
+        render(conn, "invitation_expired.html", layout: {PlausibleWeb.LayoutView, "focus.html"})
+      end
+    end
+  end
+
+  def register_from_invitation(conn, %{"invitation_id" => invitation_id} = params) do
+    if Keyword.fetch!(Application.get_env(:plausible, :selfhost), :disable_registration) do
+      conn
+      |> redirect(to: "/login")
+    else
+      invitation = Repo.get_by(Plausible.Auth.Invitation, invitation_id: invitation_id)
+      user = Plausible.Auth.User.new(params["user"])
+
+      if PlausibleWeb.Captcha.verify(params["h-captcha-response"]) do
+        case Repo.insert(user) do
+          {:ok, user} ->
+            code = Auth.issue_email_verification(user)
+            Logger.info("VERIFICATION CODE: #{code}")
+            email_template = PlausibleWeb.Email.activation_email(user, code)
+            Plausible.Mailer.send_email(email_template)
+
+            conn
+            |> put_session(:current_user_id, user.id)
+            |> put_resp_cookie("logged_in", "true",
+              http_only: false,
+              max_age: 60 * 60 * 24 * 365 * 5000
+            )
+            |> redirect(to: "/activate")
+
+          {:error, changeset} ->
+            render(conn, "register_from_invitation_form.html",
+              invitation: invitation,
+              changeset: changeset,
+              layout: {PlausibleWeb.LayoutView, "focus.html"}
+            )
+        end
+      else
+        render(conn, "register_from_invitation_form.html",
+          invitation: invitation,
+          changeset: user,
+          captcha_error: "Please complete the captcha to register",
+          layout: {PlausibleWeb.LayoutView, "focus.html"}
+        )
+      end
+    end
+  end
+
   def activate_form(conn, _params) do
     user = conn.assigns[:current_user]
+    invitation = Repo.get_by(Plausible.Auth.Invitation, email: user.email)
 
     has_code =
       Repo.exists?(
@@ -81,22 +144,29 @@ defmodule PlausibleWeb.AuthController do
 
     render(conn, "activate.html",
       has_pin: has_code,
+      invitation: invitation,
       layout: {PlausibleWeb.LayoutView, "focus.html"}
     )
   end
 
   def activate(conn, %{"code" => code}) do
     user = conn.assigns[:current_user]
+    invitation = Repo.get_by(Plausible.Auth.Invitation, email: user.email)
     {code, ""} = Integer.parse(code)
 
     case Auth.verify_email(user, code) do
       :ok ->
-        redirect(conn, to: "/sites/new")
+        if invitation do
+          redirect(conn, to: "/sites")
+        else
+          redirect(conn, to: "/sites/new")
+        end
 
       {:error, :incorrect} ->
         render(conn, "activate.html",
           error: "Incorrect activation code",
           has_pin: true,
+          invitation: invitation,
           layout: {PlausibleWeb.LayoutView, "focus.html"}
         )
 
@@ -104,6 +174,7 @@ defmodule PlausibleWeb.AuthController do
         render(conn, "activate.html",
           error: "Code is expired, please request another one",
           has_pin: false,
+          invitation: invitation,
           layout: {PlausibleWeb.LayoutView, "focus.html"}
         )
     end
diff --git a/lib/plausible_web/controllers/invitation_controller.ex b/lib/plausible_web/controllers/invitation_controller.ex
new file mode 100644
index 00000000..db1020ab
--- /dev/null
+++ b/lib/plausible_web/controllers/invitation_controller.ex
@@ -0,0 +1,108 @@
+defmodule PlausibleWeb.InvitationController do
+  use PlausibleWeb, :controller
+  use Plausible.Repo
+  alias Ecto.Multi
+  alias Plausible.Auth.Invitation
+  alias Plausible.Site.Membership
+
+  plug PlausibleWeb.RequireAccountPlug
+
+  def accept_invitation(conn, %{"invitation_id" => invitation_id}) do
+    invitation =
+      Repo.get_by!(Invitation, invitation_id: invitation_id)
+      |> Repo.preload([:site, :inviter])
+
+    user = conn.assigns[:current_user]
+    existing_membership = Repo.get_by(Membership, user_id: user.id, site_id: invitation.site.id)
+
+    multi =
+      if invitation.role == :owner do
+        downgrade_previous_owner(Multi.new(), invitation.site)
+      else
+        Multi.new()
+      end
+
+    membership_changeset =
+      Membership.changeset(existing_membership || %Membership{}, %{
+        user_id: user.id,
+        site_id: invitation.site.id,
+        role: invitation.role
+      })
+
+    multi =
+      multi
+      |> Multi.insert_or_update(:membership, membership_changeset)
+      |> Multi.delete(:invitation, invitation)
+
+    case Repo.transaction(multi) do
+      {:ok, _} ->
+        notify_invitation_accepted(invitation)
+        Plausible.Billing.SiteLocker.check_sites_for(user)
+
+        conn
+        |> put_flash(:success, "You now have access to #{invitation.site.domain}")
+        |> redirect(to: "/#{URI.encode_www_form(invitation.site.domain)}")
+
+      {:error, _} ->
+        conn
+        |> put_flash(:error, "Something went wrong, please try again")
+        |> redirect(to: "/sites")
+    end
+  end
+
+  defp downgrade_previous_owner(multi, site) do
+    prev_owner =
+      from(
+        sm in Plausible.Site.Membership,
+        where: sm.site_id == ^site.id,
+        where: sm.role == :owner
+      )
+
+    Multi.update_all(multi, :prev_owner, prev_owner, set: [role: :admin])
+  end
+
+  def reject_invitation(conn, %{"invitation_id" => invitation_id}) do
+    invitation =
+      Repo.get_by!(Invitation, invitation_id: invitation_id)
+      |> Repo.preload([:site, :inviter])
+
+    Repo.delete!(invitation)
+    notify_invitation_rejected(invitation)
+
+    conn
+    |> put_flash(:success, "You have rejected the invitation to #{invitation.site.domain}")
+    |> redirect(to: "/sites")
+  end
+
+  defp notify_invitation_accepted(%Invitation{role: :owner} = invitation) do
+    PlausibleWeb.Email.ownership_transfer_accepted(invitation)
+    |> Plausible.Mailer.send_email()
+  end
+
+  defp notify_invitation_accepted(invitation) do
+    PlausibleWeb.Email.invitation_accepted(invitation)
+    |> Plausible.Mailer.send_email()
+  end
+
+  defp notify_invitation_rejected(%Invitation{role: :owner} = invitation) do
+    PlausibleWeb.Email.ownership_transfer_rejected(invitation)
+    |> Plausible.Mailer.send_email()
+  end
+
+  defp notify_invitation_rejected(invitation) do
+    PlausibleWeb.Email.invitation_rejected(invitation)
+    |> Plausible.Mailer.send_email()
+  end
+
+  def remove_invitation(conn, %{"invitation_id" => invitation_id}) do
+    invitation =
+      Repo.get_by!(Invitation, invitation_id: invitation_id)
+      |> Repo.preload(:site)
+
+    Repo.delete!(invitation)
+
+    conn
+    |> put_flash(:success, "You have removed the invitation for #{invitation.email}")
+    |> redirect(to: Routes.site_path(conn, :settings_general, invitation.site.domain))
+  end
+end
diff --git a/lib/plausible_web/controllers/site/membership_controller.ex b/lib/plausible_web/controllers/site/membership_controller.ex
new file mode 100644
index 00000000..c8bb3f67
--- /dev/null
+++ b/lib/plausible_web/controllers/site/membership_controller.ex
@@ -0,0 +1,149 @@
+defmodule PlausibleWeb.Site.MembershipController do
+  use PlausibleWeb, :controller
+  use Plausible.Repo
+  alias Plausible.Sites
+  alias Plausible.Site.Membership
+  alias Plausible.Auth.Invitation
+
+  @only_owner_is_allowed_to [:transfer_ownership_form, :transfer_ownership]
+
+  plug PlausibleWeb.RequireAccountPlug
+  plug PlausibleWeb.AuthorizeSiteAccess, [:owner] when action in @only_owner_is_allowed_to
+
+  plug PlausibleWeb.AuthorizeSiteAccess,
+       [:owner, :admin] when action not in @only_owner_is_allowed_to
+
+  def invite_member_form(conn, %{"website" => site_domain}) do
+    site = Sites.get_for_user!(conn.assigns[:current_user].id, site_domain)
+
+    render(
+      conn,
+      "invite_member_form.html",
+      site: site,
+      layout: {PlausibleWeb.LayoutView, "focus.html"}
+    )
+  end
+
+  def invite_member(conn, %{"website" => site_domain, "email" => email, "role" => role}) do
+    site = Sites.get_for_user!(conn.assigns[:current_user].id, site_domain)
+    user = Plausible.Auth.find_user_by(email: email)
+
+    if user && Sites.is_member?(user.id, site) do
+      msg = "Cannot send invite because #{user.email} is already a member of #{site.domain}"
+
+      render(conn, "invite_member_form.html",
+        error: msg,
+        site: site,
+        layout: {PlausibleWeb.LayoutView, "focus.html"}
+      )
+    else
+      invitation =
+        Invitation.new(%{
+          email: email,
+          role: role,
+          site_id: site.id,
+          inviter_id: conn.assigns[:current_user].id
+        })
+        |> Repo.insert!()
+        |> Repo.preload([:site, :inviter])
+
+      email_template =
+        if user do
+          PlausibleWeb.Email.existing_user_invitation(invitation)
+        else
+          PlausibleWeb.Email.new_user_invitation(invitation)
+        end
+
+      Plausible.Mailer.send_email(email_template)
+
+      conn
+      |> put_flash(
+        :success,
+        "#{email} has been invited to #{site_domain} as #{
+          PlausibleWeb.SiteView.with_indefinite_article(role)
+        }"
+      )
+      |> redirect(to: Routes.site_path(conn, :settings_people, site.domain))
+    end
+  end
+
+  def transfer_ownership_form(conn, %{"website" => site_domain}) do
+    site = Sites.get_for_user!(conn.assigns[:current_user].id, site_domain)
+
+    render(
+      conn,
+      "transfer_ownership_form.html",
+      site: site,
+      layout: {PlausibleWeb.LayoutView, "focus.html"}
+    )
+  end
+
+  def transfer_ownership(conn, %{"website" => site_domain, "email" => email}) do
+    site = Sites.get_for_user!(conn.assigns[:current_user].id, site_domain)
+    user = Plausible.Auth.find_user_by(email: email)
+
+    invitation =
+      Invitation.new(%{
+        email: email,
+        role: :owner,
+        site_id: site.id,
+        inviter_id: conn.assigns[:current_user].id
+      })
+      |> Repo.insert!()
+      |> Repo.preload([:site, :inviter])
+
+    PlausibleWeb.Email.ownership_transfer_request(invitation, user)
+    |> Plausible.Mailer.send_email()
+
+    conn
+    |> put_flash(:success, "Site transfer request has been sent to #{email}")
+    |> redirect(to: Routes.site_path(conn, :settings_people, site.domain))
+  end
+
+  def update_role(conn, %{"id" => id, "new_role" => new_role}) do
+    membership =
+      Repo.get!(Membership, id)
+      |> Repo.preload([:site, :user])
+      |> Membership.changeset(%{role: new_role})
+      |> Repo.update!()
+
+    redirect_target =
+      if membership.user.id == conn.assigns[:current_user].id && new_role == "viewer" do
+        "/#{URI.encode_www_form(membership.site.domain)}"
+      else
+        Routes.site_path(conn, :settings_people, membership.site.domain)
+      end
+
+    conn
+    |> put_flash(
+      :success,
+      "#{membership.user.name} is now #{PlausibleWeb.SiteView.with_indefinite_article(new_role)}"
+    )
+    |> redirect(to: redirect_target)
+  end
+
+  def remove_member(conn, %{"id" => id}) do
+    membership =
+      Repo.get!(Membership, id)
+      |> Repo.preload([:user, :site])
+
+    Repo.delete!(membership)
+
+    PlausibleWeb.Email.site_member_removed(membership)
+    |> Plausible.Mailer.send_email()
+
+    redirect_target =
+      if membership.user.id == conn.assigns[:current_user].id do
+        "/#{URI.encode_www_form(membership.site.domain)}"
+      else
+        Routes.site_path(conn, :settings_people, membership.site.domain)
+      end
+
+    conn
+    |> put_flash(
+      :success,
+      "#{membership.user.name} has been removed from #{membership.site.domain}"
+    )
+    |> redirect(to: redirect_target)
+  end
+end
diff --git a/lib/plausible_web/controllers/site_controller.ex b/lib/plausible_web/controllers/site_controller.ex
index 0599ed92..4f1b572c 100644
--- a/lib/plausible_web/controllers/site_controller.ex
+++ b/lib/plausible_web/controllers/site_controller.ex
@@ -5,27 +5,53 @@ defmodule PlausibleWeb.SiteController do
 
   plug PlausibleWeb.RequireAccountPlug
 
+  plug PlausibleWeb.AuthorizeSiteAccess,
+       [:owner, :admin] when action not in [:index, :new, :create_site]
+
   def index(conn, params) do
     user = conn.assigns[:current_user]
 
+    invitations =
+      Repo.all(
+        from i in Plausible.Auth.Invitation,
+          where: i.email == ^user.email
+      )
+      |> Repo.preload(:site)
+
+    invitation_site_ids = Enum.map(invitations, & &1.site.id)
+
     {sites, pagination} =
       Repo.paginate(
         from(s in Plausible.Site,
           join: sm in Plausible.Site.Membership,
           on: sm.site_id == s.id,
           where: sm.user_id == ^user.id,
-          order_by: s.domain
+          where: s.id not in ^invitation_site_ids,
+          order_by: s.domain,
+          preload: [memberships: sm]
         ),
         params
       )
 
-    visitors = Plausible.Stats.Clickhouse.last_24h_visitors(sites)
-    render(conn, "index.html", sites: sites, visitors: visitors, pagination: pagination)
+    user_owns_sites =
+      Enum.any?(sites, fn site -> List.first(site.memberships).role == :owner end) ||
+        Plausible.Auth.user_owns_sites?(user)
+
+    visitors =
+      Plausible.Stats.Clickhouse.last_24h_visitors(sites ++ Enum.map(invitations, & &1.site))
+
+    render(conn, "index.html",
+      invitations: invitations,
+      sites: sites,
+      visitors: visitors,
+      pagination: pagination,
+      needs_to_upgrade: user_owns_sites && Plausible.Billing.needs_to_upgrade?(user)
+    )
   end
 
   def new(conn, _params) do
     current_user = conn.assigns[:current_user]
-    site_count = Plausible.Sites.count_for(current_user)
+    site_count = Enum.count(Plausible.Sites.owned_by(current_user))
     site_limit = Plausible.Billing.sites_limit(current_user)
     is_at_limit = site_limit && site_count >= site_limit
     is_first_site = site_count == 0
@@ -43,7 +69,7 @@ defmodule PlausibleWeb.SiteController do
 
   def create_site(conn, %{"site" => site_params}) do
     user = conn.assigns[:current_user]
-    site_count = Plausible.Sites.count_for(user)
+    site_count = Enum.count(Plausible.Sites.owned_by(user))
     is_first_site = site_count == 0
 
     case Sites.create(user, site_params) do
@@ -72,12 +98,9 @@ defmodule PlausibleWeb.SiteController do
     end
   end
 
-  def add_snippet(conn, %{"website" => website}) do
+  def add_snippet(conn, _params) do
     user = conn.assigns[:current_user]
-
-    site =
-      Sites.get_for_user!(conn.assigns[:current_user].id, website)
-      |> Repo.preload(:custom_domain)
+    site = conn.assigns[:site] |> Repo.preload(:custom_domain)
 
     is_first_site =
       !Repo.exists?(
@@ -96,8 +119,8 @@ defmodule PlausibleWeb.SiteController do
     )
   end
 
-  def new_goal(conn, %{"website" => website}) do
-    site = Sites.get_for_user!(conn.assigns[:current_user].id, website)
+  def new_goal(conn, _params) do
+    site = conn.assigns[:site]
     changeset = Plausible.Goal.changeset(%Plausible.Goal{})
 
     conn
@@ -109,8 +132,8 @@ defmodule PlausibleWeb.SiteController do
     )
   end
 
-  def create_goal(conn, %{"website" => website, "goal" => goal}) do
-    site = Sites.get_for_user!(conn.assigns[:current_user].id, website)
+  def create_goal(conn, %{"goal" => goal}) do
+    site = conn.assigns[:site]
 
     case Plausible.Goals.create(site, goal) do
       {:ok, _} ->
@@ -141,9 +164,9 @@ defmodule PlausibleWeb.SiteController do
     redirect(conn, to: "/#{URI.encode_www_form(website)}/settings/general")
   end
 
-  def settings_general(conn, %{"website" => website}) do
+  def settings_general(conn, _params) do
     site =
-      Sites.get_for_user!(conn.assigns[:current_user].id, website)
+      conn.assigns[:site]
       |> Repo.preload(:custom_domain)
 
     conn
@@ -155,8 +178,22 @@ defmodule PlausibleWeb.SiteController do
     )
   end
 
-  def settings_visibility(conn, %{"website" => website}) do
-    site = Sites.get_for_user!(conn.assigns[:current_user].id, website)
+  def settings_people(conn, _params) do
+    site =
+      conn.assigns[:site]
+      |> Repo.preload(memberships: :user)
+      |> Repo.preload(:invitations)
+
+    conn
+    |> assign(:skip_plausible_tracking, true)
+    |> render("settings_people.html",
+      site: site,
+      layout: {PlausibleWeb.LayoutView, "site_settings.html"}
+    )
+  end
+
+  def settings_visibility(conn, _params) do
+    site = conn.assigns[:site]
     shared_links = Repo.all(from l in Plausible.Site.SharedLink, where: l.site_id == ^site.id)
 
     conn
@@ -168,8 +205,8 @@ defmodule PlausibleWeb.SiteController do
     )
   end
 
-  def settings_goals(conn, %{"website" => website}) do
-    site = Sites.get_for_user!(conn.assigns[:current_user].id, website)
+  def settings_goals(conn, _params) do
+    site = conn.assigns[:site]
     goals = Goals.for_site(site.domain)
 
     conn
@@ -181,9 +218,9 @@ defmodule PlausibleWeb.SiteController do
     )
   end
 
-  def settings_search_console(conn, %{"website" => website}) do
+  def settings_search_console(conn, _params) do
     site =
-      Sites.get_for_user!(conn.assigns[:current_user].id, website)
+      conn.assigns[:site]
       |> Repo.preload(:google_auth)
 
     search_console_domains =
@@ -200,8 +237,8 @@ defmodule PlausibleWeb.SiteController do
     )
   end
 
-  def settings_email_reports(conn, %{"website" => website}) do
-    site = Sites.get_for_user!(conn.assigns[:current_user].id, website)
+  def settings_email_reports(conn, _params) do
+    site = conn.assigns[:site]
 
     conn
     |> assign(:skip_plausible_tracking, true)
@@ -214,9 +251,9 @@ defmodule PlausibleWeb.SiteController do
     )
   end
 
-  def settings_custom_domain(conn, %{"website" => website}) do
+  def settings_custom_domain(conn, _params) do
     site =
-      Sites.get_for_user!(conn.assigns[:current_user].id, website)
+      conn.assigns[:site]
       |> Repo.preload(:custom_domain)
 
     conn
@@ -227,21 +264,17 @@ defmodule PlausibleWeb.SiteController do
     )
   end
 
-  def settings_danger_zone(conn, %{"website" => website}) do
-    site = Sites.get_for_user!(conn.assigns[:current_user].id, website)
-
+  def settings_danger_zone(conn, _params) do
     conn
     |> assign(:skip_plausible_tracking, true)
     |> render("settings_danger_zone.html",
-      site: site,
+      site: conn.assigns[:site],
       layout: {PlausibleWeb.LayoutView, "site_settings.html"}
     )
   end
 
-  def update_google_auth(conn, %{"website" => website, "google_auth" => attrs}) do
-    site =
-      Sites.get_for_user!(conn.assigns[:current_user].id, website)
-      |> Repo.preload(:google_auth)
+  def update_google_auth(conn, %{"google_auth" => attrs}) do
+    site = conn.assigns[:site] |> Repo.preload(:google_auth)
 
     Plausible.Site.GoogleAuth.set_property(site.google_auth, attrs)
     |> Repo.update!()
@@ -251,9 +284,9 @@ defmodule PlausibleWeb.SiteController do
     |> redirect(to: "/#{URI.encode_www_form(site.domain)}/settings/search-console")
   end
 
-  def delete_google_auth(conn, %{"website" => website}) do
+  def delete_google_auth(conn, _params) do
     site =
-      Sites.get_for_user!(conn.assigns[:current_user].id, website)
+      conn.assigns[:site]
       |> Repo.preload(:google_auth)
 
     Repo.delete!(site.google_auth)
@@ -263,8 +296,8 @@ defmodule PlausibleWeb.SiteController do
     |> redirect(to: "/#{URI.encode_www_form(site.domain)}/settings/search-console")
   end
 
-  def update_settings(conn, %{"website" => website, "site" => site_params}) do
-    site = Sites.get_for_user!(conn.assigns[:current_user].id, website)
+  def update_settings(conn, %{"site" => site_params}) do
+    site = conn.assigns[:site]
     changeset = site |> Plausible.Site.changeset(site_params)
     res = changeset |> Repo.update()
 
@@ -282,8 +315,8 @@ defmodule PlausibleWeb.SiteController do
     end
   end
 
-  def reset_stats(conn, %{"website" => website}) do
-    site = Sites.get_for_user!(conn.assigns[:current_user].id, website)
+  def reset_stats(conn, _params) do
+    site = conn.assigns[:site]
     Plausible.ClickhouseRepo.clear_stats_for(site.domain)
 
     conn
@@ -291,8 +324,8 @@ defmodule PlausibleWeb.SiteController do
     |> redirect(to: "/#{URI.encode_www_form(site.domain)}/settings/danger-zone")
   end
 
-  def delete_site(conn, %{"website" => website}) do
-    site = Sites.get_for_user!(conn.assigns[:current_user].id, website)
+  def delete_site(conn, _params) do
+    site = conn.assigns[:site]
 
     Repo.delete!(site)
     Plausible.ClickhouseRepo.clear_stats_for(site.domain)
@@ -302,9 +335,9 @@ defmodule PlausibleWeb.SiteController do
     |> redirect(to: "/sites")
   end
 
-  def make_public(conn, %{"website" => website}) do
+  def make_public(conn, _params) do
     site =
-      Sites.get_for_user!(conn.assigns[:current_user].id, website)
+      conn.assigns[:site]
       |> Plausible.Site.make_public()
       |> Repo.update!()
 
@@ -313,9 +346,9 @@ defmodule PlausibleWeb.SiteController do
     |> redirect(to: "/#{URI.encode_www_form(site.domain)}/settings/visibility")
   end
 
-  def make_private(conn, %{"website" => website}) do
+  def make_private(conn, _params) do
     site =
-      Sites.get_for_user!(conn.assigns[:current_user].id, website)
+      conn.assigns[:site]
       |> Plausible.Site.make_private()
       |> Repo.update!()
 
@@ -324,8 +357,8 @@ defmodule PlausibleWeb.SiteController do
     |> redirect(to: "/#{URI.encode_www_form(site.domain)}/settings/visibility")
   end
 
-  def enable_weekly_report(conn, %{"website" => website}) do
-    site = Sites.get_for_user!(conn.assigns[:current_user].id, website)
+  def enable_weekly_report(conn, _params) do
+    site = conn.assigns[:site]
 
     Plausible.Site.WeeklyReport.changeset(%Plausible.Site.WeeklyReport{}, %{
       site_id: site.id,
@@ -338,8 +371,8 @@ defmodule PlausibleWeb.SiteController do
     |> redirect(to: "/#{URI.encode_www_form(site.domain)}/settings/email-reports")
   end
 
-  def disable_weekly_report(conn, %{"website" => website}) do
-    site = Sites.get_for_user!(conn.assigns[:current_user].id, website)
+  def disable_weekly_report(conn, _params) do
+    site = conn.assigns[:site]
     Repo.delete_all(from wr in Plausible.Site.WeeklyReport, where: wr.site_id == ^site.id)
 
     conn
@@ -347,8 +380,8 @@ defmodule PlausibleWeb.SiteController do
     |> redirect(to: "/#{URI.encode_www_form(site.domain)}/settings/email-reports")
   end
 
-  def add_weekly_report_recipient(conn, %{"website" => website, "recipient" => recipient}) do
-    site = Sites.get_for_user!(conn.assigns[:current_user].id, website)
+  def add_weekly_report_recipient(conn, %{"recipient" => recipient}) do
+    site = conn.assigns[:site]
 
     Repo.get_by(Plausible.Site.WeeklyReport, site_id: site.id)
     |> Plausible.Site.WeeklyReport.add_recipient(recipient)
@@ -359,8 +392,8 @@ defmodule PlausibleWeb.SiteController do
     |> redirect(to: "/#{URI.encode_www_form(site.domain)}/settings/email-reports")
   end
 
-  def remove_weekly_report_recipient(conn, %{"website" => website, "recipient" => recipient}) do
-    site = Sites.get_for_user!(conn.assigns[:current_user].id, website)
+  def remove_weekly_report_recipient(conn, %{"recipient" => recipient}) do
+    site = conn.assigns[:site]
 
     Repo.get_by(Plausible.Site.WeeklyReport, site_id: site.id)
     |> Plausible.Site.WeeklyReport.remove_recipient(recipient)
@@ -374,8 +407,8 @@ defmodule PlausibleWeb.SiteController do
     |> redirect(to: "/#{URI.encode_www_form(site.domain)}/settings/email-reports")
   end
 
-  def enable_monthly_report(conn, %{"website" => website}) do
-    site = Sites.get_for_user!(conn.assigns[:current_user].id, website)
+  def enable_monthly_report(conn, _params) do
+    site = conn.assigns[:site]
 
     Plausible.Site.MonthlyReport.changeset(%Plausible.Site.MonthlyReport{}, %{
       site_id: site.id,
@@ -388,8 +421,8 @@ defmodule PlausibleWeb.SiteController do
     |> redirect(to: "/#{URI.encode_www_form(site.domain)}/settings/email-reports")
   end
 
-  def disable_monthly_report(conn, %{"website" => website}) do
-    site = Sites.get_for_user!(conn.assigns[:current_user].id, website)
+  def disable_monthly_report(conn, _params) do
+    site = conn.assigns[:site]
     Repo.delete_all(from mr in Plausible.Site.MonthlyReport, where: mr.site_id == ^site.id)
 
     conn
@@ -397,8 +430,8 @@ defmodule PlausibleWeb.SiteController do
     |> redirect(to: "/#{URI.encode_www_form(site.domain)}/settings/email-reports")
   end
 
-  def add_monthly_report_recipient(conn, %{"website" => website, "recipient" => recipient}) do
-    site = Sites.get_for_user!(conn.assigns[:current_user].id, website)
+  def add_monthly_report_recipient(conn, %{"recipient" => recipient}) do
+    site = conn.assigns[:site]
 
     Repo.get_by(Plausible.Site.MonthlyReport, site_id: site.id)
     |> Plausible.Site.MonthlyReport.add_recipient(recipient)
@@ -409,8 +442,8 @@ defmodule PlausibleWeb.SiteController do
     |> redirect(to: "/#{URI.encode_www_form(site.domain)}/settings/email-reports")
   end
 
-  def remove_monthly_report_recipient(conn, %{"website" => website, "recipient" => recipient}) do
-    site = Sites.get_for_user!(conn.assigns[:current_user].id, website)
+  def remove_monthly_report_recipient(conn, %{"recipient" => recipient}) do
+    site = conn.assigns[:site]
 
     Repo.get_by(Plausible.Site.MonthlyReport, site_id: site.id)
     |> Plausible.Site.MonthlyReport.remove_recipient(recipient)
@@ -424,8 +457,8 @@ defmodule PlausibleWeb.SiteController do
     |> redirect(to: "/#{URI.encode_www_form(site.domain)}/settings/email-reports")
   end
 
-  def enable_spike_notification(conn, %{"website" => website}) do
-    site = Sites.get_for_user!(conn.assigns[:current_user].id, website)
+  def enable_spike_notification(conn, _params) do
+    site = conn.assigns[:site]
 
     res =
       Plausible.Site.SpikeNotification.changeset(%Plausible.Site.SpikeNotification{}, %{
@@ -448,8 +481,8 @@ defmodule PlausibleWeb.SiteController do
     end
   end
 
-  def disable_spike_notification(conn, %{"website" => website}) do
-    site = Sites.get_for_user!(conn.assigns[:current_user].id, website)
+  def disable_spike_notification(conn, _params) do
+    site = conn.assigns[:site]
     Repo.delete_all(from mr in Plausible.Site.SpikeNotification, where: mr.site_id == ^site.id)
 
     conn
@@ -457,8 +490,8 @@ defmodule PlausibleWeb.SiteController do
     |> redirect(to: "/#{URI.encode_www_form(site.domain)}/settings/email-reports")
   end
 
-  def update_spike_notification(conn, %{"website" => website, "spike_notification" => params}) do
-    site = Sites.get_for_user!(conn.assigns[:current_user].id, website)
+  def update_spike_notification(conn, %{"spike_notification" => params}) do
+    site = conn.assigns[:site]
     notification = Repo.get_by(Plausible.Site.SpikeNotification, site_id: site.id)
 
     Plausible.Site.SpikeNotification.changeset(notification, params)
@@ -469,8 +502,8 @@ defmodule PlausibleWeb.SiteController do
     |> redirect(to: "/#{URI.encode_www_form(site.domain)}/settings/email-reports")
   end
 
-  def add_spike_notification_recipient(conn, %{"website" => website, "recipient" => recipient}) do
-    site = Sites.get_for_user!(conn.assigns[:current_user].id, website)
+  def add_spike_notification_recipient(conn, %{"recipient" => recipient}) do
+    site = conn.assigns[:site]
 
     Repo.get_by(Plausible.Site.SpikeNotification, site_id: site.id)
     |> Plausible.Site.SpikeNotification.add_recipient(recipient)
@@ -481,8 +514,8 @@ defmodule PlausibleWeb.SiteController do
     |> redirect(to: "/#{URI.encode_www_form(site.domain)}/settings/email-reports")
   end
 
-  def remove_spike_notification_recipient(conn, %{"website" => website, "recipient" => recipient}) do
-    site = Sites.get_for_user!(conn.assigns[:current_user].id, website)
+  def remove_spike_notification_recipient(conn, %{"recipient" => recipient}) do
+    site = conn.assigns[:site]
 
     Repo.get_by(Plausible.Site.SpikeNotification, site_id: site.id)
     |> Plausible.Site.SpikeNotification.remove_recipient(recipient)
@@ -496,8 +529,8 @@ defmodule PlausibleWeb.SiteController do
     |> redirect(to: "/#{URI.encode_www_form(site.domain)}/settings/email-reports")
   end
 
-  def new_shared_link(conn, %{"website" => website}) do
-    site = Sites.get_for_user!(conn.assigns[:current_user].id, website)
+  def new_shared_link(conn, _params) do
+    site = conn.assigns[:site]
     changeset = Plausible.Site.SharedLink.changeset(%Plausible.Site.SharedLink{}, %{})
 
     conn
@@ -509,8 +542,8 @@ defmodule PlausibleWeb.SiteController do
     )
   end
 
-  def create_shared_link(conn, %{"website" => website, "shared_link" => link}) do
-    site = Sites.get_for_user!(conn.assigns[:current_user].id, website)
+  def create_shared_link(conn, %{"shared_link" => link}) do
+    site = conn.assigns[:site]
 
     case Sites.create_shared_link(site, link["name"], link["password"]) do
       {:ok, _created} ->
@@ -527,8 +560,8 @@ defmodule PlausibleWeb.SiteController do
     end
   end
 
-  def edit_shared_link(conn, %{"website" => website, "slug" => slug}) do
-    site = Sites.get_for_user!(conn.assigns[:current_user].id, website)
+  def edit_shared_link(conn, %{"slug" => slug}) do
+    site = conn.assigns[:site]
     shared_link = Repo.get_by(Plausible.Site.SharedLink, slug: slug)
     changeset = Plausible.Site.SharedLink.changeset(shared_link, %{})
 
@@ -541,8 +574,8 @@ defmodule PlausibleWeb.SiteController do
     )
   end
 
-  def update_shared_link(conn, %{"website" => website, "slug" => slug, "shared_link" => params}) do
-    site = Sites.get_for_user!(conn.assigns[:current_user].id, website)
+  def update_shared_link(conn, %{"slug" => slug, "shared_link" => params}) do
+    site = conn.assigns[:site]
     shared_link = Repo.get_by(Plausible.Site.SharedLink, slug: slug)
     changeset = Plausible.Site.SharedLink.changeset(shared_link, params)
 
@@ -561,8 +594,8 @@ defmodule PlausibleWeb.SiteController do
     end
   end
 
-  def delete_shared_link(conn, %{"website" => website, "slug" => slug}) do
-    site = Sites.get_for_user!(conn.assigns[:current_user].id, website)
+  def delete_shared_link(conn, %{"slug" => slug}) do
+    site = conn.assigns[:site]
 
     Repo.get_by(Plausible.Site.SharedLink, slug: slug)
     |> Repo.delete!()
@@ -570,8 +603,8 @@ defmodule PlausibleWeb.SiteController do
     redirect(conn, to: "/#{URI.encode_www_form(site.domain)}/settings/visibility")
   end
 
-  def new_custom_domain(conn, %{"website" => website}) do
-    site = Sites.get_for_user!(conn.assigns[:current_user].id, website)
+  def new_custom_domain(conn, _params) do
+    site = conn.assigns[:site]
     changeset = Plausible.Site.CustomDomain.changeset(%Plausible.Site.CustomDomain{}, %{})
 
     conn
@@ -583,10 +616,8 @@ defmodule PlausibleWeb.SiteController do
     )
   end
 
-  def custom_domain_dns_setup(conn, %{"website" => website}) do
-    site =
-      Sites.get_for_user!(conn.assigns[:current_user].id, website)
-      |> Repo.preload(:custom_domain)
+  def custom_domain_dns_setup(conn, _params) do
+    site = conn.assigns[:site] |> Repo.preload(:custom_domain)
 
     conn
     |> assign(:skip_plausible_tracking, true)
@@ -596,9 +627,9 @@ defmodule PlausibleWeb.SiteController do
     )
   end
 
-  def custom_domain_snippet(conn, %{"website" => website}) do
+  def custom_domain_snippet(conn, _params) do
     site =
-      Sites.get_for_user!(conn.assigns[:current_user].id, website)
+      conn.assigns[:site]
       |> Repo.preload(:custom_domain)
 
     conn
@@ -609,8 +640,8 @@ defmodule PlausibleWeb.SiteController do
     )
   end
 
-  def add_custom_domain(conn, %{"website" => website, "custom_domain" => domain}) do
-    site = Sites.get_for_user!(conn.assigns[:current_user].id, website)
+  def add_custom_domain(conn, %{"custom_domain" => domain}) do
+    site = conn.assigns[:site]
 
     case Sites.add_custom_domain(site, domain["domain"]) do
       {:ok, _custom_domain} ->
@@ -627,9 +658,9 @@ defmodule PlausibleWeb.SiteController do
     end
   end
 
-  def delete_custom_domain(conn, %{"website" => website}) do
+  def delete_custom_domain(conn, _params) do
     site =
-      Sites.get_for_user!(conn.assigns[:current_user].id, website)
+      conn.assigns[:site]
       |> Repo.preload(:custom_domain)
 
     Repo.delete!(site.custom_domain)
diff --git a/lib/plausible_web/controllers/stats_controller.ex b/lib/plausible_web/controllers/stats_controller.ex
index 7becf8aa..cdd2023f 100644
--- a/lib/plausible_web/controllers/stats_controller.ex
+++ b/lib/plausible_web/controllers/stats_controller.ex
@@ -4,33 +4,37 @@ defmodule PlausibleWeb.StatsController do
   alias Plausible.Stats.Clickhouse, as: Stats
   alias Plausible.Stats.Query
 
-  plug PlausibleWeb.AuthorizeStatsPlug when action in [:stats, :csv_export]
-  plug PlausibleWeb.UpgradeBillingPlug when action in [:stats]
-
-  def base_domain() do
-    PlausibleWeb.Endpoint.host()
-  end
+  plug PlausibleWeb.AuthorizeSiteAccess when action in [:stats, :csv_export]
 
   def stats(%{assigns: %{site: site}} = conn, _params) do
-    if Stats.has_pageviews?(site) do
-      demo = site.domain == base_domain()
-      offer_email_report = get_session(conn, site.domain <> "_offer_email_report")
+    has_pageviews = Stats.has_pageviews?(site)
 
-      conn
-      |> assign(:skip_plausible_tracking, !demo)
-      |> remove_email_report_banner(site)
-      |> put_resp_header("x-robots-tag", "noindex")
-      |> render("stats.html",
-        site: site,
-        has_goals: Plausible.Sites.has_goals?(site),
-        title: "Plausible · " <> site.domain,
-        offer_email_report: offer_email_report,
-        demo: demo
-      )
-    else
-      conn
-      |> assign(:skip_plausible_tracking, true)
-      |> render("waiting_first_pageview.html", site: site)
+    cond do
+      !site.locked && has_pageviews ->
+        demo = site.domain == PlausibleWeb.Endpoint.host()
+        offer_email_report = get_session(conn, site.domain <> "_offer_email_report")
+
+        conn
+        |> assign(:skip_plausible_tracking, !demo)
+        |> remove_email_report_banner(site)
+        |> put_resp_header("x-robots-tag", "noindex")
+        |> render("stats.html",
+          site: site,
+          has_goals: Plausible.Sites.has_goals?(site),
+          title: "Plausible · " <> site.domain,
+          offer_email_report: offer_email_report,
+          demo: demo
+        )
+
+      !site.locked && !has_pageviews ->
+        conn
+        |> assign(:skip_plausible_tracking, true)
+        |> render("waiting_first_pageview.html", site: site)
+
+      site.locked ->
+        conn
+        |> assign(:skip_plausible_tracking, true)
+        |> render("site_locked.html", site: site)
     end
   end
 
diff --git a/lib/plausible_web/email.ex b/lib/plausible_web/email.ex
index ab2eef03..c63782f8 100644
--- a/lib/plausible_web/email.ex
+++ b/lib/plausible_web/email.ex
@@ -164,6 +164,103 @@ defmodule PlausibleWeb.Email do
     |> render("cancellation_email.html", name: user.name)
   end
 
+  def new_user_invitation(invitation) do
+    base_email()
+    |> to(invitation.email)
+    |> tag("new-user-invitation")
+    |> subject("[Plausible Analytics] You've been invited to #{invitation.site.domain}")
+    |> render("new_user_invitation.html",
+      invitation: invitation
+    )
+  end
+
+  def existing_user_invitation(invitation) do
+    base_email()
+    |> to(invitation.email)
+    |> tag("existing-user-invitation")
+    |> subject("[Plausible Analytics] You've been invited to #{invitation.site.domain}")
+    |> render("existing_user_invitation.html",
+      invitation: invitation
+    )
+  end
+
+  def ownership_transfer_request(invitation, new_owner_account) do
+    base_email()
+    |> to(invitation.email)
+    |> tag("ownership-transfer-request")
+    |> subject("[Plausible Analytics] Request to transfer ownership of #{invitation.site.domain}")
+    |> render("ownership_transfer_request.html",
+      invitation: invitation,
+      new_owner_account: new_owner_account
+    )
+  end
+
+  def invitation_accepted(invitation) do
+    base_email()
+    |> to(invitation.inviter.email)
+    |> tag("invitation-accepted")
+    |> subject(
+      "[Plausible Analytics] #{invitation.email} accepted your invitation to #{
+        invitation.site.domain
+      }"
+    )
+    |> render("invitation_accepted.html",
+      invitation: invitation
+    )
+  end
+
+  def invitation_rejected(invitation) do
+    base_email()
+    |> to(invitation.inviter.email)
+    |> tag("invitation-rejected")
+    |> subject(
+      "[Plausible Analytics] #{invitation.email} rejected your invitation to #{
+        invitation.site.domain
+      }"
+    )
+    |> render("invitation_rejected.html",
+      invitation: invitation
+    )
+  end
+
+  def ownership_transfer_accepted(invitation) do
+    base_email()
+    |> to(invitation.inviter.email)
+    |> tag("ownership-transfer-accepted")
+    |> subject(
+      "[Plausible Analytics] #{invitation.email} accepted the ownership transfer of #{
+        invitation.site.domain
+      }"
+    )
+    |> render("ownership_transfer_accepted.html",
+      invitation: invitation
+    )
+  end
+
+  def ownership_transfer_rejected(invitation) do
+    base_email()
+    |> to(invitation.inviter.email)
+    |> tag("ownership-transfer-rejected")
+    |> subject(
+      "[Plausible Analytics] #{invitation.email} rejected the ownership transfer of #{
+        invitation.site.domain
+      }"
+    )
+    |> render("ownership_transfer_rejected.html",
+      invitation: invitation
+    )
+  end
+
+  def site_member_removed(membership) do
+    base_email()
+    |> to(membership.user.email)
+    |> tag("site-member-removed")
+    |> subject("[Plausible Analytics] Your access to #{membership.site.domain} has been revoked")
+    |> render("site_member_removed.html",
+      membership: membership
+    )
+  end
+
   defp base_email() do
     mailer_from = Application.get_env(:plausible, :mailer_email)
 
diff --git a/lib/plausible_web/plugs/authorize_site_access.ex b/lib/plausible_web/plugs/authorize_site_access.ex
new file mode 100644
index 00000000..754820b9
--- /dev/null
+++ b/lib/plausible_web/plugs/authorize_site_access.ex
@@ -0,0 +1,43 @@
+defmodule PlausibleWeb.AuthorizeSiteAccess do
+  import Plug.Conn
+  use Plausible.Repo
+
+  def init([]), do: [:public, :viewer, :admin, :owner]
+  def init(allowed_roles), do: allowed_roles
+
+  def call(conn, allowed_roles) do
+    site = Repo.get_by(Plausible.Site, domain: conn.params["domain"] || conn.params["website"])
+    shared_link_auth = conn.params["auth"]
+
+    shared_link_record =
+      shared_link_auth && Repo.get_by(Plausible.Site.SharedLink, slug: shared_link_auth)
+
+    if !site do
+      PlausibleWeb.ControllerHelpers.render_error(conn, 404) |> halt
+    else
+      user_id = get_session(conn, :current_user_id)
+      membership_role = user_id && Plausible.Sites.role(user_id, site)
+
+      role =
+        cond do
+          user_id && membership_role ->
+            membership_role
+
+          site.public ->
+            :public
+
+          shared_link_record && shared_link_record.site_id == site.id ->
+            :public
+
+          true ->
+            nil
+        end
+
+      if role in allowed_roles do
+        merge_assigns(conn, site: site, current_user_role: role)
+      else
+        PlausibleWeb.ControllerHelpers.render_error(conn, 404) |> halt
+      end
+    end
+  end
+end
diff --git a/lib/plausible_web/plugs/authorize_stats_api.ex b/lib/plausible_web/plugs/authorize_stats_api.ex
index 6b76ec2e..e16ce643 100644
--- a/lib/plausible_web/plugs/authorize_stats_api.ex
+++ b/lib/plausible_web/plugs/authorize_stats_api.ex
@@ -45,10 +45,10 @@ defmodule PlausibleWeb.AuthorizeStatsApiPlug do
 
   defp verify_access(api_key, site_id) do
     site = Repo.get_by(Plausible.Site, domain: site_id)
-    is_owner = site && Plausible.Sites.is_owner?(api_key.user_id, site)
+    is_member = site && Plausible.Sites.is_member?(api_key.user_id, site)
 
     cond do
-      site && is_owner -> {:ok, site}
+      site && is_member -> {:ok, site}
       true -> {:error, :invalid_api_key}
     end
   end
diff --git a/lib/plausible_web/plugs/authorize_stats_plug.ex b/lib/plausible_web/plugs/authorize_stats_plug.ex
deleted file mode 100644
index 37076c09..00000000
--- a/lib/plausible_web/plugs/authorize_stats_plug.ex
+++ /dev/null
@@ -1,33 +0,0 @@
-defmodule PlausibleWeb.AuthorizeStatsPlug do
-  import Plug.Conn
-  use Plausible.Repo
-
-  def init(options) do
-    options
-  end
-
-  def call(conn, _opts) do
-    site = Repo.get_by(Plausible.Site, domain: conn.params["domain"])
-    shared_link_auth = conn.params["auth"]
-
-    shared_link_record =
-      shared_link_auth && Repo.get_by(Plausible.Site.SharedLink, slug: shared_link_auth)
-
-    if !site do
-      PlausibleWeb.ControllerHelpers.render_error(conn, 404) |> halt
-    else
-      user_id = get_session(conn, :current_user_id)
-
-      can_access =
-        site.public ||
-          (user_id && Plausible.Sites.is_owner?(user_id, site)) ||
-          (shared_link_auth && shared_link_record && shared_link_record.site_id == site.id)
-
-      if !can_access do
-        PlausibleWeb.ControllerHelpers.render_error(conn, 404) |> halt
-      else
-        assign(conn, :site, site)
-      end
-    end
-  end
-end
diff --git a/lib/plausible_web/plugs/upgrade_billing_plug.ex b/lib/plausible_web/plugs/upgrade_billing_plug.ex
deleted file mode 100644
index 7cef0b9f..00000000
--- a/lib/plausible_web/plugs/upgrade_billing_plug.ex
+++ /dev/null
@@ -1,21 +0,0 @@
-defmodule PlausibleWeb.UpgradeBillingPlug do
-  import Phoenix.Controller
-  import Plug.Conn
-  use Plausible.Repo
-
-  def init(options) do
-    options
-  end
-
-  def call(conn, _opts) do
-    user = conn.assigns[:current_user]
-
-    if user && Plausible.Billing.needs_to_upgrade?(conn.assigns[:current_user]) do
-      conn
-      |> redirect(to: "/settings")
-      |> halt
-    else
-      conn
-    end
-  end
-end
diff --git a/lib/plausible_web/router.ex b/lib/plausible_web/router.ex
index c7cbf68f..b8778693 100644
--- a/lib/plausible_web/router.ex
+++ b/lib/plausible_web/router.ex
@@ -33,7 +33,7 @@ defmodule PlausibleWeb.Router do
     plug :accepts, ["json"]
     plug PlausibleWeb.Firewall
     plug :fetch_session
-    plug PlausibleWeb.AuthorizeStatsPlug
+    plug PlausibleWeb.AuthorizeSiteAccess
   end
 
   pipeline :public_api do
@@ -105,6 +105,8 @@ defmodule PlausibleWeb.Router do
 
     get "/register", AuthController, :register_form
     post "/register", AuthController, :register
+    get "/register/invitation/:invitation_id", AuthController, :register_from_invitation_form
+    post "/register/invitation/:invitation_id", AuthController, :register_from_invitation
     get "/activate", AuthController, :activate_form
     post "/activate/request-code", AuthController, :request_activation_code
     post "/activate", AuthController, :activate
@@ -195,12 +197,26 @@ defmodule PlausibleWeb.Router do
     post "/sites/:website/custom-domains", SiteController, :add_custom_domain
     delete "/sites/:website/custom-domains/:id", SiteController, :delete_custom_domain
 
+    get "/sites/:website/memberships/invite", Site.MembershipController, :invite_member_form
+    post "/sites/:website/memberships/invite", Site.MembershipController, :invite_member
+
+    post "/sites//invitations/:invitation_id/accept", InvitationController, :accept_invitation
+    post "/sites//invitations/:invitation_id/reject", InvitationController, :reject_invitation
+    delete "/sites//invitations/:invitation_id", InvitationController, :remove_invitation
+
+    get "/sites/:website/transfer-ownership", Site.MembershipController, :transfer_ownership_form
+    post "/sites/:website/transfer-ownership", Site.MembershipController, :transfer_ownership
+
+    put "/sites/:website/memberships/:id/role/:new_role", Site.MembershipController, :update_role
+    delete "/sites/:website/memberships/:id", Site.MembershipController, :remove_member
+
     get "/sites/:website/weekly-report/unsubscribe", UnsubscribeController, :weekly_report
     get "/sites/:website/monthly-report/unsubscribe", UnsubscribeController, :monthly_report
 
     get "/:website/snippet", SiteController, :add_snippet
     get "/:website/settings", SiteController, :settings
     get "/:website/settings/general", SiteController, :settings_general
+    get "/:website/settings/people", SiteController, :settings_people
     get "/:website/settings/visibility", SiteController, :settings_visibility
     get "/:website/settings/goals", SiteController, :settings_goals
     get "/:website/settings/search-console", SiteController, :settings_search_console
diff --git a/lib/plausible_web/templates/auth/activate.html.eex b/lib/plausible_web/templates/auth/activate.html.eex
index 34e7a924..d009f67f 100644
--- a/lib/plausible_web/templates/auth/activate.html.eex
+++ b/lib/plausible_web/templates/auth/activate.html.eex
@@ -47,7 +47,9 @@
     </div>
   <% end %>
 
-  <div class="pt-12 pl-8 hidden md:block">
-    <%= render(PlausibleWeb.AuthView, "_onboarding_steps.html", current_step: 1) %>
-  </div>
+  <%= if !@invitation do %>
+    <div class="pt-12 pl-8 hidden md:block">
+      <%= render(PlausibleWeb.AuthView, "_onboarding_steps.html", current_step: 1) %>
+    </div>
+  <% end %>
 </div>
diff --git a/lib/plausible_web/templates/auth/invitation_expired.html.eex b/lib/plausible_web/templates/auth/invitation_expired.html.eex
new file mode 100644
index 00000000..2158a4c2
--- /dev/null
+++ b/lib/plausible_web/templates/auth/invitation_expired.html.eex
@@ -0,0 +1,12 @@
+<div class="mx-auto mt-6 text-center dark:text-gray-300">
+  <h1 class="text-3xl font-black">Plausible Analytics</h1>
+  <div class="text-xl font-medium">Lightweight and privacy-friendly web analytics</div>
+</div>
+
+<div class="w-full max-w-md mx-auto bg-white dark:bg-gray-800 shadow-md rounded px-8 py-6 mb-4 mt-8">
+  <h2 class="text-xl font-black dark:text-gray-100">Invitation expired</h2>
+
+  <p class="mt-4 text-sm">
+  Your invitation has expired. Please request fresh one or you can <%= link("sign up", class: "text-indigo-600 hover:text-indigo-900", to: Routes.auth_path(@conn, :register)) %> for a 30-day unlimited free trial without an invitation.
+  </p>
+</div>
diff --git a/lib/plausible_web/templates/auth/register_from_invitation_form.html.eex b/lib/plausible_web/templates/auth/register_from_invitation_form.html.eex
new file mode 100644
index 00000000..1cc9e94c
--- /dev/null
+++ b/lib/plausible_web/templates/auth/register_from_invitation_form.html.eex
@@ -0,0 +1,61 @@
+<div class="mx-auto mt-6 text-center dark:text-gray-300">
+  <h1 class="text-3xl font-black">Register your Plausible Analytics account</h1>
+  <div class="text-xl font-medium">Set up privacy-friendly analytics with just a few clicks</div>
+</div>
+
+<%= form_for @changeset, Routes.auth_path(@conn, :register_from_invitation_form, @invitation.invitation_id), [class: "w-full max-w-md mx-auto bg-white dark:bg-gray-800 shadow-md rounded px-8 py-6 mb-4 mt-8", id: "register-form"], fn f -> %>
+  <h2 class="text-xl font-black dark:text-gray-100">Enter your details</h2>
+  <div class="my-4">
+    <div class="flex justify-between">
+    <%= label f, :email, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
+    <p class="text-xs text-gray-500 mt-1">No spam, guaranteed.</p>
+    </div>
+    <div class="mt-1">
+      <%= email_input f, :email, class: "dark:bg-gray-900 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 dark:border-gray-500 rounded-md dark:text-gray-300", placeholder: "example@email.com", value: @invitation.email, readonly: "readonly" %>
+    </div>
+    <%= error_tag f, :email %>
+  </div>
+
+  <div class="my-4">
+    <%= label f, :name, "Full name", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
+    <div class="mt-1">
+      <%= text_input f, :name, class: "dark:bg-gray-900 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 dark:border-gray-500 rounded-md dark:text-gray-300", placeholder: "Jane Doe" %>
+    </div>
+    <%= error_tag f, :name %>
+  </div>
+
+  <div class="my-4">
+    <div class="flex justify-between">
+    <%= label f, :password, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
+    <p class="text-xs text-gray-500 mt-1">Min 6 characters</p>
+    </div>
+    <div class="mt-1">
+      <%= password_input f, :password, class: "dark:bg-gray-900 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 dark:border-gray-500 rounded-md dark:text-gray-300" %>
+    </div>
+    <%= error_tag f, :password %>
+  </div>
+
+  <div class="my-4">
+    <%= label f, :password_confirmation, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
+    <div class="mt-1">
+      <%= password_input f, :password_confirmation, class: "dark:bg-gray-900 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 dark:border-gray-500 rounded-md dark:text-gray-300" %>
+    </div>
+    <%= error_tag f, :password_confirmation %>
+  </div>
+
+  <%= if PlausibleWeb.Captcha.enabled?() do %>
+    <div class="mt-4">
+      <div class="h-captcha" data-sitekey="<%= PlausibleWeb.Captcha.sitekey() %>"></div>
+      <%= if assigns[:captcha_error] do %>
+        <div class="text-red-500 text-xs italic mt-3"><%= @captcha_error %></div>
+      <% end %>
+      <script src="https://hcaptcha.com/1/api.js" async defer></script>
+    </div>
+  <% end %>
+
+  <%= submit "Create my account →", class: "button mt-4 w-full" %>
+
+  <p class="text-center text-gray-600 dark:text-gray-500  text-xs mt-4">
+    Already have an account? <%= link("Log in", to: "/login", class: "underline text-gray-800 dark:text-gray-50") %> instead.
+  </p>
+<% end %>
diff --git a/lib/plausible_web/templates/email/existing_user_invitation.html.eex b/lib/plausible_web/templates/email/existing_user_invitation.html.eex
new file mode 100644
index 00000000..c1bc0354
--- /dev/null
+++ b/lib/plausible_web/templates/email/existing_user_invitation.html.eex
@@ -0,0 +1,10 @@
+Hey,
+<br /><br />
+<%= @invitation.inviter.email %> has invited you to the <%= @invitation.site.domain %> site on Plausible Analytics.
+<%= link("Click here", to: Routes.site_url(PlausibleWeb.Endpoint, :index)) %> to view and respond to the invitation. The invitation
+will expire 48 hours after this email is sent.
+<br /><br />
+Thanks,<br />
+Uku and Marko<br />
+--<br />
+<%= plausible_url() %><br />
diff --git a/lib/plausible_web/templates/email/invitation_accepted.html.eex b/lib/plausible_web/templates/email/invitation_accepted.html.eex
new file mode 100644
index 00000000..f437183d
--- /dev/null
+++ b/lib/plausible_web/templates/email/invitation_accepted.html.eex
@@ -0,0 +1,9 @@
+Hey <%= user_salutation(@invitation.inviter) %>,
+<br /><br />
+<%= @invitation.email %> has accepted your invitation to <%= @invitation.site.domain %>.
+<%= link("Click here", to: Routes.site_url(PlausibleWeb.Endpoint, :settings_general, @invitation.site.domain)) %> to view site settings.
+<br /><br />
+Thanks,<br />
+Uku and Marko<br />
+--<br />
+<%= plausible_url() %><br />
diff --git a/lib/plausible_web/templates/email/invitation_rejected.html.eex b/lib/plausible_web/templates/email/invitation_rejected.html.eex
new file mode 100644
index 00000000..ef40174b
--- /dev/null
+++ b/lib/plausible_web/templates/email/invitation_rejected.html.eex
@@ -0,0 +1,9 @@
+Hey <%= user_salutation(@invitation.inviter) %>,
+<br /><br />
+<%= @invitation.email %> has rejected your invitation to <%= @invitation.site.domain %>.
+<%= link("Click here", to: Routes.site_url(PlausibleWeb.Endpoint, :settings_general, @invitation.site.domain)) %> to view site settings.
+<br /><br />
+Thanks,<br />
+Uku and Marko<br />
+--<br />
+<%= plausible_url() %><br />
diff --git a/lib/plausible_web/templates/email/new_user_invitation.html.eex b/lib/plausible_web/templates/email/new_user_invitation.html.eex
new file mode 100644
index 00000000..8e8ab755
--- /dev/null
+++ b/lib/plausible_web/templates/email/new_user_invitation.html.eex
@@ -0,0 +1,11 @@
+Hey,
+<br /><br />
+<%= @invitation.inviter.email %> has invited you to join the <%= @invitation.site.domain %> site on Plausible Analytics.
+<%= link("Click here", to: Routes.auth_url(PlausibleWeb.Endpoint, :register_form), invitation: @invitation.invitation_id) %> to create your account. The link is valid for 48 hours after this email is sent.
+<br /><br />
+Plausible is a lightweight and open-source website analytics tool. We hope you like our simple and ethical approach to tracking website visitors.
+<br /><br />
+Thanks,<br />
+Uku and Marko<br />
+--<br />
+<%= plausible_url() %><br />
diff --git a/lib/plausible_web/templates/email/ownership_transfer_accepted.html.eex b/lib/plausible_web/templates/email/ownership_transfer_accepted.html.eex
new file mode 100644
index 00000000..1d8dcf53
--- /dev/null
+++ b/lib/plausible_web/templates/email/ownership_transfer_accepted.html.eex
@@ -0,0 +1,10 @@
+Hey <%= user_salutation(@invitation.inviter) %>,
+<br /><br />
+<%= @invitation.email %> has accepted the ownership transfer of <%= @invitation.site.domain %>. They will be responsible for billing of it going
+forward and your role has been changed to <b>admin</b>.
+<%= link("Click here", to: Routes.site_url(PlausibleWeb.Endpoint, :settings_general, @invitation.site.domain)) %> to view site settings.
+<br /><br />
+Thanks,<br />
+Uku and Marko<br />
+--<br />
+<%= plausible_url() %><br />
diff --git a/lib/plausible_web/templates/email/ownership_transfer_rejected.html.eex b/lib/plausible_web/templates/email/ownership_transfer_rejected.html.eex
new file mode 100644
index 00000000..b67ca63e
--- /dev/null
+++ b/lib/plausible_web/templates/email/ownership_transfer_rejected.html.eex
@@ -0,0 +1,9 @@
+Hey <%= user_salutation(@invitation.inviter) %>,
+<br /><br />
+<%= @invitation.email %> has rejected the ownership transfer of <%= @invitation.site.domain %>.
+<%= link("Click here", to: Routes.site_url(PlausibleWeb.Endpoint, :settings_general, @invitation.site.domain)) %> to view site settings.
+<br /><br />
+Thanks,<br />
+Uku and Marko<br />
+--<br />
+<%= plausible_url() %><br />
diff --git a/lib/plausible_web/templates/email/ownership_transfer_request.html.eex b/lib/plausible_web/templates/email/ownership_transfer_request.html.eex
new file mode 100644
index 00000000..4d48468f
--- /dev/null
+++ b/lib/plausible_web/templates/email/ownership_transfer_request.html.eex
@@ -0,0 +1,15 @@
+Hey,
+<br /><br />
+<%= @invitation.inviter.email %> has request to transfer the ownership of <%= @invitation.site.domain %> site on Plausible Analytics to you.
+<%= if @new_owner_account do %>
+  <%= link("Click here", to: Routes.site_url(PlausibleWeb.Endpoint, :index)) %> to view and respond to the invitation.
+<% else %>
+  <%= link("Click here", to: Routes.auth_url(PlausibleWeb.Endpoint, :register_form), invitation: @invitation.invitation_id) %> to create your account.
+  <br /><br />
+  Plausible is a lightweight and open-source website analytics tool. We hope you like our simple and ethical approach to tracking website visitors.
+<% end %>
+<br /><br />
+Thanks,<br />
+Uku and Marko<br />
+--<br />
+<%= plausible_url() %><br />
diff --git a/lib/plausible_web/templates/email/site_member_removed.html.eex b/lib/plausible_web/templates/email/site_member_removed.html.eex
new file mode 100644
index 00000000..1634b7dc
--- /dev/null
+++ b/lib/plausible_web/templates/email/site_member_removed.html.eex
@@ -0,0 +1,10 @@
+Hey <%= user_salutation(@membership.user) %>,
+<br /><br />
+An administrator of <%= @membership.site.domain %> has removed you as a member. You won't be able to see the stats anymore.
+<br /><br />
+<%= link("Click here", to: Routes.site_url(PlausibleWeb.Endpoint, :index)) %> to view your sites.
+<br /><br />
+Thanks,<br />
+Uku and Marko<br />
+--<br />
+<%= plausible_url() %><br />
diff --git a/lib/plausible_web/templates/layout/_header.html.eex b/lib/plausible_web/templates/layout/_header.html.eex
index 6a989a77..f694df45 100644
--- a/lib/plausible_web/templates/layout/_header.html.eex
+++ b/lib/plausible_web/templates/layout/_header.html.eex
@@ -21,11 +21,12 @@
                   <% end %>
                 </li>
               <% end %>
-              <%= if !Application.get_env(:plausible, :is_selfhost) && @conn.assigns[:current_user].subscription == nil  do %>
+              <%= if Plausible.Billing.on_trial?(@conn.assigns[:current_user]) do %>
                 <li class="hidden mr-6 sm:block">
-                  <%= link(trial_notificaton(@conn.assigns[:current_user]), to: "/settings", class: "font-bold text-orange-900 dark:text-yellow-900 rounded p-2 bg-orange-200 dark:bg-yellow-100", style: "line-height: 40px;") %>
+                  <%= link(trial_notificaton(@conn.assigns[:current_user]), to: "/settings", class: "font-bold text-sm text-yellow-900 dark:text-yellow-900 rounded p-2 bg-yellow-100 dark:bg-yellow-100", style: "line-height: 40px;") %>
                 </li>
-              <% else %>
+              <% end %>
+              <%= if !Application.get_env(:plausible, :is_selfhost) do %>
                 <li class="hidden mr-6 sm:block">
                   <%= link("Docs", to: "https://docs.plausible.io", class: "font-bold rounded m-1 p-1 hover:bg-gray-200 dark:hover:bg-gray-900 dark:text-gray-100", style: "line-height: 40px;", target: "_blank") %>
                 </li>
diff --git a/lib/plausible_web/templates/layout/site_settings.html.eex b/lib/plausible_web/templates/layout/site_settings.html.eex
index 1199fb50..91e78cc6 100644
--- a/lib/plausible_web/templates/layout/site_settings.html.eex
+++ b/lib/plausible_web/templates/layout/site_settings.html.eex
@@ -9,10 +9,10 @@
     <div class="lg:grid lg:grid-cols-12 lg:gap-x-5 lg:mt-4">
       <div class="py-4 g:py-0 lg:col-span-3">
         <%= form_for @conn, "/sites/#{URI.encode_www_form(@site.domain)}/monthly-report/recipients", [class: "lg:hidden"], fn f -> %>
-          <%= select f, :tab, settings_tabs(), class: "dark:bg-gray-800 mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 dark:border-gray-500 outline-none focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md dark:text-gray-100", onchange: "location.href = location.href.replace(/[^\/\/]*$/, event.target.value)", selected: List.last(@conn.path_info)  %>
+          <%= select f, :tab, settings_tabs(@conn), class: "dark:bg-gray-800 mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 dark:border-gray-500 outline-none focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md dark:text-gray-100", onchange: "location.href = location.href.replace(/[^\/\/]*$/, event.target.value)", selected: List.last(@conn.path_info)  %>
         <% end %>
         <div class="hidden lg:block">
-          <%= for [key: key, value: val] <- settings_tabs() do %>
+          <%= for [key: key, value: val] <- settings_tabs(@conn) do %>
             <%= render("_settings_tab.html", this_tab: val, text: key, site: @site, conn: @conn) %>
           <% end %>
         </div>
diff --git a/lib/plausible_web/templates/site/index.html.eex b/lib/plausible_web/templates/site/index.html.eex
index dc5e9367..abb29e6d 100644
--- a/lib/plausible_web/templates/site/index.html.eex
+++ b/lib/plausible_web/templates/site/index.html.eex
@@ -1,5 +1,29 @@
-<div class="container pt-6">
-  <div class="pb-5 border-b border-gray-200 dark:border-gray-500 flex items-center justify-between">
+<div x-data="{selectedInvitation: null, invitationOpen: false, invitations: <%= Enum.map(@invitations, &({&1.invitation_id, &1})) |> Enum.into(%{}) |> Jason.encode! %>}" @keydown.escape.window="invitationOpen = false" class="container pt-6">
+
+  <%= if @needs_to_upgrade do %>
+    <div class="rounded-md bg-yellow-50 p-4">
+      <div class="flex">
+        <div class="flex-shrink-0">
+          <svg class="h-5 w-5 text-yellow-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
+            <path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
+          </svg>
+        </div>
+        <div class="ml-3">
+          <h3 class="text-sm font-medium text-yellow-800">
+            Payment required
+          </h3>
+          <div class="mt-2 text-sm text-yellow-700">
+            <p>
+            To access the sites you own, you need to subscribe to a monthly or yearly payment plan.
+            <%= link("Upgrade now →", to: "/settings", class: "text-sm font-medium text-yellow-800") %>
+            </p>
+          </div>
+        </div>
+      </div>
+    </div>
+  <% end %>
+
+  <div class="mt-6 pb-5 border-b border-gray-200 dark:border-gray-500 flex items-center justify-between">
     <h2 class="text-2xl font-bold leading-7 text-gray-900 dark:text-gray-100 sm:text-3xl sm:leading-9 sm:truncate flex-shrink-0">
       My sites
     </h2>
@@ -7,10 +31,34 @@
   </div>
 
   <ul class="my-6 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
-    <%= if Enum.empty?(@sites) do %>
+    <%= if Enum.empty?(@sites ++ @invitations) do %>
       <p class="dark:text-gray-100">You don't have any sites yet</p>
     <% end %>
 
+    <%= for invitation <- @invitations do %>
+      <div class="group cursor-pointer" @click="invitationOpen = true; selectedInvitation = invitations['<%= invitation.invitation_id %>']">
+        <li class="col-span-1 bg-white dark:bg-gray-800 rounded-lg shadow p-4 group-hover:shadow-lg cursor-pointer">
+          <div class="w-full flex items-center justify-between space-x-4">
+            <img src="https://icons.duckduckgo.com/ip3/<%= invitation.site.domain %>.ico" referrerpolicy="no-referrer" onerror="this.onerror=null; this.src='https://icons.duckduckgo.com/ip3/placeholder.ico';" class="w-4 h-4 flex-shrink-0 mt-px">
+            <div class="flex-1 truncate -mt-px">
+              <h3 class="text-gray-900 font-medium text-lg truncate dark:text-gray-100"><%= invitation.site.domain %></h3>
+            </div>
+
+            <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
+              Pending invitation
+            </span>
+          </div>
+          <div class="pl-8 mt-2 flex items-center justify-between">
+            <span class="text-gray-600 dark:text-gray-400 text-sm truncate">
+              <span class="text-gray-800 dark:text-gray-200">
+                <b><%= PlausibleWeb.StatsView.large_number_format(Map.get(@visitors, invitation.site.domain, 0)) %></b> visitor<%= if Map.get(@visitors, invitation.site.domain, 0) != 1 do %>s<% end %> in last 24h
+              </span>
+            </span>
+          </div>
+        </li>
+      </div>
+    <% end %>
+
     <%= for site <- @sites do %>
       <div class="relative group">
         <%= link(to: "/" <> URI.encode_www_form(site.domain)) do %>
@@ -30,9 +78,10 @@
             </div>
           </li>
         <% end %>
-
-        <%= link(to: "/" <> URI.encode_www_form(site.domain) <> "/settings", class: "absolute top-0 right-0 p-4 mt-1") do %>
-          <svg class="w-5 h-5 text-gray-600 dark:text-gray-400 opacity-100 md:opacity-0 group-hover:opacity-100 transition hover:text-gray-900 dark:hover:text-gray-100" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd"></path></svg>
+        <%= if List.first(site.memberships).role != :viewer do %>
+          <%= link(to: "/" <> URI.encode_www_form(site.domain) <> "/settings", class: "absolute top-0 right-0 p-4 mt-1") do %>
+            <svg class="w-5 h-5 text-gray-600 dark:text-gray-400 opacity-100 md:opacity-0 group-hover:opacity-100 transition hover:text-gray-900 dark:hover:text-gray-100" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd"></path></svg>
+          <% end %>
         <% end %>
       </div>
     <% end %>
@@ -58,4 +107,74 @@
     <% end %>
   <% end %>
 
+  <%= if Enum.any?(@invitations) do %>
+    <div x-cloak x-show="invitationOpen"  class="fixed z-10 inset-0 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
+      <div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
+        <div
+          x-show="invitationOpen"
+          x-transition:enter="transition ease-out duration-300"
+          x-transition:enter-start="opacity-0"
+          x-transition:enter-end="opacity-100"
+          x-transition:leave="transition ease-in duration-200"
+          x-transition:leave-start="opacity-100"
+          x-transition:leave-end="opacity-0"
+          class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
+          aria-hidden="true"
+          @click="invitationOpen = false"
+          ></div>
+
+        <!-- This element is to trick the browser into centering the modal contents. -->
+        <span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
+
+        <div
+          x-show="invitationOpen"
+          x-transition:enter="transition ease-out duration-300"
+          x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
+          x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
+          x-transition:leave="transition ease-in duration-200"
+          x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
+          x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
+          class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full"
+          >
+          <div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
+            <div class="hidden sm:block absolute top-0 right-0 pt-4 pr-4">
+              <button @click="invitationOpen = false" class="bg-white rounded-md text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
+                <span class="sr-only">Close</span>
+                <!-- Heroicon name: outline/x -->
+                <svg class="h-6 w-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
+                  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
+                </svg>
+              </button>
+            </div>
+            <div class="sm:flex sm:items-start">
+              <div class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-green-100 sm:mx-0 sm:h-10 sm:w-10">
+                <svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"></path></svg>
+              </div>
+              <div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
+                <h3 class="text-lg leading-6 font-medium text-gray-900" id="modal-title">
+                  Invitation for <span x-text="selectedInvitation && selectedInvitation.site.domain"></span>
+                </h3>
+                <div class="mt-2">
+                  <p class="text-sm text-gray-500">
+                    You've been invited to the <span x-text="selectedInvitation && selectedInvitation.site.domain"></span> analytics dashboard as <b class="capitalize" x-text="selectedInvitation && selectedInvitation.role">Admin</b>.
+                  </p>
+                  <p x-show="selectedInvitation && selectedInvitation.role === 'owner'" class="mt-2 text-sm text-gray-500">
+                    If you accept the ownership transfer, you will be responsible for billing.
+                  </p>
+                </div>
+              </div>
+            </div>
+          </div>
+          <div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
+            <button class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-700 sm:ml-3 sm:w-auto sm:text-sm" data-method="post" data-csrf="<%= Plug.CSRFProtection.get_csrf_token() %>" x-bind:data-to="selectedInvitation && ('/sites/invitations/' + selectedInvitation.invitation_id + '/accept')">
+              Accept &amp; Continue
+            </button>
+            <button type="button" class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm" data-method="post" data-csrf="<%= Plug.CSRFProtection.get_csrf_token() %>" x-bind:data-to="selectedInvitation && ('/sites/invitations/' + selectedInvitation.invitation_id + '/reject')">
+              Reject
+            </button>
+          </div>
+        </div>
+      </div>
+    </div>
+  <% end %>
 </div>
diff --git a/lib/plausible_web/templates/site/membership/invite_member_form.html.eex b/lib/plausible_web/templates/site/membership/invite_member_form.html.eex
new file mode 100644
index 00000000..c358b141
--- /dev/null
+++ b/lib/plausible_web/templates/site/membership/invite_member_form.html.eex
@@ -0,0 +1,63 @@
+<%= form_for @conn, Routes.membership_path(@conn, :invite_member, @site.domain), [class: "max-w-lg w-full mx-auto bg-white dark:bg-gray-800 shadow-md rounded px-8 pt-6 pb-8 mb-4 mt-8"], fn f -> %>
+  <h2 class="text-xl font-black dark:text-gray-100">Invite member to <%= @site.domain %></h2>
+
+  <p class="mt-4 max-w-2xl text-sm text-gray-500">
+    Enter the email address and role of the person you want to invite. We will contact them over email to offer them access to
+    <%= @site.domain %> analytics.
+  </p>
+
+  <p class="mt-4 max-w-2xl text-sm text-gray-500">
+    The invitation will expire in 48 hours
+  </p>
+
+  <%= if @conn.assigns[:error] do %>
+    <div class="text-red-500 text-xs italic mt-4"><%= @conn.assigns[:error] %></div>
+  <% end %>
+
+  <div class="my-6">
+    <%= label f, :email, "Email address", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
+    <div class="mt-1 relative rounded-md shadow-sm">
+      <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
+        <svg class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z" /><path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z" /></svg>
+      </div>
+      <%= email_input(f, :email, class: "focus:ring-indigo-500 focus:border-indigo-500 block w-full rounded-md pl-10 sm:text-sm border-gray-300", placeholder: "john.doe@example.com", required: "true") %>
+    </div>
+    <%= error_tag f, :email %>
+  </div>
+
+  <fieldset x-data="{selectedOption: null}">
+    <%= label f, :role, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
+    <div class="mt-1 bg-white rounded-md -space-y-px">
+      <label class="border-gray-200 rounded-tl-md rounded-tr-md relative border p-4 flex cursor-pointer" :class="{'bg-indigo-50 border-indigo-200 z-10': selectedOption === 'admin', 'border-gray-200': selectedOption !== 'admin'}">
+        <%= radio_button(f, :role, "admin", class: "h-4 w-4 mt-0.5 cursor-pointer text-indigo-600 border-gray-300 focus:ring-indigo-500", "x-model": "selectedOption", required: "true") %>
+        <div class="ml-3 flex flex-col">
+          <span class="text-gray-900 block text-sm font-medium" :class="{'text-indigo-900': selectedOption === 'admin', 'text-gray-900': selectedOption !== 'admin'}">
+            Admin
+          </span>
+          <span class="text-gray-500 block text-sm"  :class="{'text-indigo-700': selectedOption === 'admin', 'text-gray-500': selectedOption !== 'admin'}">
+            Can view stats, change site settings and invite other members
+          </span>
+        </div>
+      </label>
+
+      <label class="border-gray-200 relative border p-4 flex cursor-pointer" :class="{'bg-indigo-50 border-indigo-200 z-10': selectedOption === 'viewer', 'border-gray-200': selectedOption !== 'viewer'}">
+        <%= radio_button(f, :role, "viewer", class: "h-4 w-4 mt-0.5 cursor-pointer text-indigo-600 border-gray-300 focus:ring-indigo-500", "x-model": "selectedOption", required: "true") %>
+        <div class="ml-3 flex flex-col">
+          <span class="text-gray-900 block text-sm font-medium" :class="{'text-indigo-900': selectedOption === 'viewer', 'text-gray-900': selectedOption !== 'viewer'}">
+            Viewer
+          </span>
+          <span class="text-gray-500 block text-sm" :class="{'text-indigo-700': selectedOption === 'viewer', 'text-gray-500': selectedOption !== 'viewer'}">
+            Can view stats but cannot access settings or invite members
+          </span>
+        </div>
+      </label>
+    </div>
+  </fieldset>
+
+  <div class="mt-6">
+    <%= submit(class: "button w-full") do %>
+      <svg class="w-5 h-5 mr-1" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M8 9a3 3 0 100-6 3 3 0 000 6zM8 11a6 6 0 016 6H2a6 6 0 016-6zM16 7a1 1 0 10-2 0v1h-1a1 1 0 100 2h1v1a1 1 0 102 0v-1h1a1 1 0 100-2h-1V7z"></path></svg>
+      <span>Invite</span>
+    <% end %>
+  </div>
+<% end %>
diff --git a/lib/plausible_web/templates/site/membership/transfer_ownership_form.html.eex b/lib/plausible_web/templates/site/membership/transfer_ownership_form.html.eex
new file mode 100644
index 00000000..0c8499a1
--- /dev/null
+++ b/lib/plausible_web/templates/site/membership/transfer_ownership_form.html.eex
@@ -0,0 +1,30 @@
+<%= form_for @conn, Routes.membership_path(@conn, :transfer_ownership, @site.domain), [class: "max-w-lg w-full mx-auto bg-white dark:bg-gray-800 shadow-md rounded px-8 pt-6 pb-8 mb-4 mt-8"], fn f -> %>
+  <h2 class="text-xl font-black dark:text-gray-100">Transfer ownership of <%= @site.domain %></h2>
+
+  <p class="mt-4 max-w-2xl text-sm text-gray-500">
+    Enter the email address of the new owner. We will contact them over email to offer them the ownership of <%= @site.domain %>.
+  </p>
+  <p class="mt-4 max-w-2xl text-sm text-gray-500">
+    If they accept the transfer request, the new owner will be responsible for billing. Your access will be downgraded to <b>admin</b> and
+    any other member roles will stay the same.
+  </p>
+
+  <%= if @conn.assigns[:error] do %>
+    <div class="text-red-500 text-xs italic mt-4"><%= @conn.assigns[:error] %></div>
+  <% end %>
+
+  <div class="my-6">
+    <%= label f, :email, "Email address", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
+    <div class="mt-1 relative rounded-md shadow-sm">
+      <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
+        <svg class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z" /><path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z" /></svg>
+      </div>
+      <%= email_input(f, :email, class: "focus:ring-indigo-500 focus:border-indigo-500 block w-full rounded-md pl-10 sm:text-sm border-gray-300", placeholder: "john.doe@example.com", required: "true") %>
+    </div>
+    <%= error_tag f, :email %>
+  </div>
+
+  <div class="mt-6">
+    <%= submit("Request transfer", class: "button w-full") %>
+  </div>
+<% end %>
diff --git a/lib/plausible_web/templates/site/settings_general.html.eex b/lib/plausible_web/templates/site/settings_general.html.eex
index 758ab8b7..5f3ee4a0 100644
--- a/lib/plausible_web/templates/site/settings_general.html.eex
+++ b/lib/plausible_web/templates/site/settings_general.html.eex
@@ -4,14 +4,13 @@
       <header class="relative">
         <h2 class="text-lg leading-6 font-medium text-gray-900 dark:text-gray-100">General information</h2>
         <p class="mt-1 text-sm leading-5 text-gray-500 dark:text-gray-200">Update your reporting timezone.</p>
-        <%= link(to: "https://docs.plausible.io/general/", target: "_blank") do %>
+        <%= link(to: "https://plausible.io/docs/general/", target: "_blank") do %>
           <svg class="w-6 h-6 absolute top-0 right-0 text-gray-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path></svg>
         <% end %>
       </header>
 
       <div class="grid grid-cols-4 gap-6">
-        <div class="col-span-4 sm:col-span-2"> <%= label f, :domain, class: "block text-sm font-medium leading-5 text-gray-700 dark:text-gray-300" %>
-          <%= text_input f, :domain, class: "dark:bg-gray-900 mt-1 block w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:max-w-xs sm:text-sm border-gray-300 dark:border-gray-500 rounded-md dark:text-gray-100", disabled: "disabled" %>
+        <div class="col-span-4 sm:col-span-2"> <%= label f, :domain, class: "block text-sm font-medium leading-5 text-gray-700 dark:text-gray-300" %> <%= text_input f, :domain, class: "dark:bg-gray-900 mt-1 block w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:max-w-xs sm:text-sm border-gray-300 dark:border-gray-500 rounded-md dark:text-gray-100", disabled: "disabled" %>
         </div>
 
         <div class="col-span-4 sm:col-span-2">
@@ -33,7 +32,7 @@
     <h2 class="text-lg leading-6 font-medium text-gray-900 dark:text-gray-100">Javascript snippet</h2>
     <p class="mt-1 text-sm leading-5 text-gray-500 dark:text-gray-200">Include this snippet in the <code>&lt;head&gt;</code> of your website.</p>
 
-    <%= link(to: "https://docs.plausible.io/plausible-script", target: "_blank") do %>
+    <%= link(to: "https://plausible.io/docs/plausible-script", target: "_blank") do %>
       <svg class="w-6 h-6 absolute top-0 right-0 text-gray-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path></svg>
     <% end %>
   </header>
diff --git a/lib/plausible_web/templates/site/settings_people.html.eex b/lib/plausible_web/templates/site/settings_people.html.eex
new file mode 100644
index 00000000..5918bd42
--- /dev/null
+++ b/lib/plausible_web/templates/site/settings_people.html.eex
@@ -0,0 +1,149 @@
+<div class="shadow bg-white dark:bg-gray-800 sm:rounded-md py-6 px-4 sm:p-6">
+  <header class="relative">
+    <h2 class="text-lg leading-6 font-medium text-gray-900 dark:text-gray-100">People</h2>
+    <p class="mt-1 text-sm leading-5 text-gray-500 dark:text-gray-200">Invite your friend or coworkers</p>
+    <%= link(to: "https://plausible.io/docs/users-roles", target: "_blank") do %>
+      <svg class="w-6 h-6 absolute top-0 right-0 text-gray-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path></svg>
+    <% end %>
+  </header>
+  <div class="flow-root mt-6">
+    <ul class="-my-5 divide-y divide-gray-200">
+      <%= for membership <- @site.memberships do %>
+        <li class="py-4">
+          <div class="flex items-center space-x-4">
+            <div class="flex-shrink-0">
+              <%= gravatar(membership.user.email, class: "h-8 w-8 rounded-full") %>
+            </div>
+            <div class="flex-1 min-w-0">
+              <p class="text-sm font-medium text-gray-900 truncate">
+                <%= membership.user.name %>
+              </p>
+              <p class="text-sm text-gray-500 truncate">
+                <%= membership.user.email %>
+              </p>
+            </div>
+
+            <div x-data="{open: false}" @click.away="open = false" x-cloak class="relative">
+              <button @click="open = !open" class="inline-flex items-center shadow-sm px-2.5 py-0.5 border border-gray-300 text-sm leading-5 font-medium rounded-full text-gray-700 bg-white hover:bg-gray-50">
+                <%= membership.role |> Atom.to_string() |> String.capitalize() %>
+                <svg class="w-4 h-4 pt-px ml-1" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"></path></svg>
+              </button>
+              <ul
+                x-show="open"
+                x-transition:leave="transition ease-in duration-100"
+                x-transition:leave-start="opacity-100"
+                x-transition:leave-end="opacity-0"
+                class="origin-top-right absolute z-10 right-0 mt-2 w-72 rounded-md shadow-lg overflow-hidden bg-white divide-y divide-gray-200 ring-1 ring-black ring-opacity-5 focus:outline-none" tabindex="-1" role="listbox" aria-labelledby="listbox-label" aria-activedescendant="listbox-option-0"
+                >
+                <%= if membership.role == :owner do %>
+                  <li class="p-4 text-sm cursor-default group flex justify-between" role="option">
+                    <div>
+                      <p class="text-base font-medium text-gray-900">Owner</p>
+                      <p class="mt-1 text-sm text-gray-500">Site owner cannot be assigned to any other role</p>
+                    </div>
+
+                    <span class="text-indigo-500">
+                      <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
+                        <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
+                      </svg>
+                    </span>
+                  </li>
+                  <%= if @conn.assigns[:current_user_role] == :owner do %>
+                    <li class="select-none hover:bg-gray-100 text-red-600" role="option">
+                      <%= link("Transfer ownership →", to: Routes.membership_path(@conn, :transfer_ownership_form, @site.domain), class: "inline-block w-full p-4 text-sm text-red-600 font-medium") %>
+                    </li>
+                  <% end %>
+                <% else %>
+                  <%= link(to: Routes.membership_path(@conn, :update_role, @site.domain, membership.id, "admin"), method: :put, class: "p-4 flex justify-between text-sm group hover:bg-indigo-500") do %>
+                    <div>
+                      <p class="text-base font-medium text-gray-900 group-hover:text-white">Admin</p>
+                      <p class="mt-1 text-sm text-gray-500 group-hover:text-gray-100">View stats and edit site settings</p>
+                    </div>
+
+                    <%= if membership.role == :admin do %>
+                      <span class="text-indigo-500 group-hover:text-white">
+                        <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
+                          <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
+                        </svg>
+                      </span>
+                    <% end %>
+                  <% end %>
+                  <%= link(to: Routes.membership_path(@conn, :update_role, @site.domain, membership.id, "viewer"), method: :put, class: "p-4 flex justify-between text-sm group hover:bg-indigo-500") do %>
+                    <div>
+                      <p class="text-base font-medium text-gray-900 group-hover:text-white">Viewer</p>
+                      <p class="mt-1 text-sm text-gray-500 group-hover:text-white">View stats only</p>
+                    </div>
+
+                    <%= if membership.role == :viewer do %>
+                      <span class="text-indigo-500 group-hover:text-white">
+                        <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
+                          <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
+                        </svg>
+                      </span>
+                    <% end %>
+                  <% end %>
+
+                  <%= link(to: Routes.membership_path(@conn, :remove_member, @site.domain, membership.id), method: :delete, class: "p-4 flex hover:bg-gray-100 text-red-600") do %>
+                    <p class="text-sm text-red-600 font-medium">Remove member</p>
+                  <% end %>
+                <% end %>
+              </ul>
+            </div>
+          </div>
+        </li>
+      <% end %>
+    </ul>
+
+    <%= if Enum.count(@site.invitations) > 0 do %>
+      <header class="mt-12">
+        <h2 class="text-lg leading-6 font-medium text-gray-900 dark:text-gray-100">Pending invitations</h2>
+      </header>
+      <div class="flex flex-col mt-4">
+        <div class="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
+          <div class="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
+            <div class="shadow overflow-hidden border-b border-gray-200 sm:rounded-lg">
+              <table class="min-w-full divide-y divide-gray-200">
+                <thead class="bg-gray-50">
+                  <tr>
+                    <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
+                      Email
+                    </th>
+                    <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
+                      Role
+                    </th>
+                    <th scope="col" class="relative px-6 py-3">
+                      <span class="sr-only">Edit</span>
+                    </th>
+                  </tr>
+                </thead>
+                <tbody>
+                  <%= for invitation <- @site.invitations do %>
+                    <tr class="odd:bg-white even:bg-gray-50">
+                      <td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
+                        <%= invitation.email %>
+                      </td>
+                      <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
+                        <%= invitation.role |> Atom.to_string |> String.capitalize %>
+                      </td>
+                      <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
+                        <%= link("Remove", to: "/sites/invitations/#{invitation.invitation_id}", method: :delete, class: "text-red-600 hover:text-red-900") %>
+                      </td>
+                    </tr>
+                  <% end %>
+                </tbody>
+              </table>
+            </div>
+          </div>
+        </div>
+      </div>
+    <% end %>
+  </div>
+
+  <div class="mt-8">
+    <%= link(to: Routes.membership_path(@conn, :invite_member_form, @site.domain), class: "button") do %>
+      <svg class="w-5 h-5 mr-1" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M8 9a3 3 0 100-6 3 3 0 000 6zM8 11a6 6 0 016 6H2a6 6 0 016-6zM16 7a1 1 0 10-2 0v1h-1a1 1 0 100 2h1v1a1 1 0 102 0v-1h1a1 1 0 100-2h-1V7z"></path></svg>
+      Invite
+    <% end %>
+  </div>
+</div>
+
diff --git a/lib/plausible_web/templates/stats/site_locked.html.eex b/lib/plausible_web/templates/stats/site_locked.html.eex
new file mode 100644
index 00000000..bf70cb98
--- /dev/null
+++ b/lib/plausible_web/templates/stats/site_locked.html.eex
@@ -0,0 +1,32 @@
+<div class="w-full max-w-lg mx-auto mt-8">
+  <div class="bg-white shadow sm:rounded-lg">
+    <div class="px-4 py-5 sm:px-8 sm:py-6">
+      <div class="mx-auto flex items-center justify-center rounded-full bg-green-100 h-12 w-12">
+        <svg class="w-6 h-6 text-green-600" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd"></path></svg>
+      </div>
+      <h3 class="mt-6 text-center text-2xl leading-6 font-medium text-gray-900">
+        Site locked
+      </h3>
+
+      <%= if @conn.assigns[:current_user_role] == :owner do %>
+        <div class="mt-3 text-gray-500 text-center">
+          <p>
+            This site is locked because you don't have an active subscription. We are still counting stats in the background but your access to the dashboard is restricted. Subscribe with the link below to access your stats again.
+          </p>
+        </div>
+        <div class="mt-6 w-full text-center">
+          <%= link("Manage my subscription", to: "/settings", class: "inline-flex items-center px-4 py-2 border border-transparent shadow-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:text-sm") %>
+        </div>
+      <% else %>
+        <div class="mt-3 text-gray-500 text-center">
+          <p>
+            This site is currently locked and cannot be accessed. You can check back later or contact the site owner to unlock it.
+          </p>
+        </div>
+        <div class="mt-6 w-full text-center">
+          <%= link("Back to my sites", to: "/sites", class: "inline-flex items-center px-4 py-2 border border-transparent shadow-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:text-sm") %>
+        </div>
+      <% end %>
+    </div>
+  </div>
+</div>
diff --git a/lib/plausible_web/templates/stats/stats.html.eex b/lib/plausible_web/templates/stats/stats.html.eex
index f6a9212f..40d71e6d 100644
--- a/lib/plausible_web/templates/stats/stats.html.eex
+++ b/lib/plausible_web/templates/stats/stats.html.eex
@@ -5,7 +5,7 @@
     </div>
   <% end %>
   <div class="pt-6"></div>
-  <div id="stats-react-container" data-domain="<%= @site.domain %>" data-offset="<%= Timex.Timezone.total_offset(Timex.Timezone.get(@site.timezone)) %>" data-has-goals="<%= @has_goals %>" data-logged-in="<%= !!@conn.assigns[:current_user] %>" data-inserted-at="<%= @site.inserted_at %>" data-shared-link-auth="<%= assigns[:shared_link_auth] %>" data-embedded="<%= @conn.assigns[:embedded] %>" data-background="<%= @conn.assigns[:background] %>" data-selfhosted="<%= Application.get_env(:plausible, :is_selfhost) %>"></div>
+  <div id="stats-react-container" data-domain="<%= @site.domain %>" data-offset="<%= Timex.Timezone.total_offset(Timex.Timezone.get(@site.timezone)) %>" data-has-goals="<%= @has_goals %>" data-logged-in="<%= !!@conn.assigns[:current_user] %>" data-inserted-at="<%= @site.inserted_at %>" data-shared-link-auth="<%= assigns[:shared_link_auth] %>" data-embedded="<%= @conn.assigns[:embedded] %>" data-background="<%= @conn.assigns[:background] %>" data-selfhosted="<%= Application.get_env(:plausible, :is_selfhost) %>" data-current-user-role="<%= @conn.assigns[:current_user_role] %>"></div>
   <div id="modal_root"></div>
   <%= if !@conn.assigns[:current_user] && @conn.assigns[:demo] do %>
     <div class="bg-gray-50 dark:bg-gray-850">
diff --git a/lib/plausible_web/views/layout_view.ex b/lib/plausible_web/views/layout_view.ex
index 1ecfca2f..c1a8bf40 100644
--- a/lib/plausible_web/views/layout_view.ex
+++ b/lib/plausible_web/views/layout_view.ex
@@ -21,9 +21,10 @@ defmodule PlausibleWeb.LayoutView do
     end
   end
 
-  def settings_tabs() do
+  def settings_tabs(conn) do
     [
       [key: "General", value: "general"],
+      [key: "People", value: "people"],
       [key: "Visibility", value: "visibility"],
       [key: "Goals", value: "goals"],
       [key: "Search Console", value: "search-console"],
@@ -33,7 +34,11 @@ defmodule PlausibleWeb.LayoutView do
       else
         nil
       end,
-      [key: "Danger zone", value: "danger-zone"]
+      if conn.assigns[:current_user_role] == :owner do
+        [key: "Danger zone", value: "danger-zone"]
+      else
+        nil
+      end
     ]
   end
 
@@ -47,9 +52,6 @@ defmodule PlausibleWeb.LayoutView do
 
       days when days == 0 ->
         "Trial ends today"
-
-      days when days < 0 ->
-        "Trial over, upgrade now"
     end
   end
 
diff --git a/lib/plausible_web/views/site/membership_view.ex b/lib/plausible_web/views/site/membership_view.ex
new file mode 100644
index 00000000..20af528c
--- /dev/null
+++ b/lib/plausible_web/views/site/membership_view.ex
@@ -0,0 +1,3 @@
+defmodule PlausibleWeb.Site.MembershipView do
+  use PlausibleWeb, :view
+end
diff --git a/lib/plausible_web/views/site_view.ex b/lib/plausible_web/views/site_view.ex
index 458ba08a..91e817dd 100644
--- a/lib/plausible_web/views/site_view.ex
+++ b/lib/plausible_web/views/site_view.ex
@@ -26,6 +26,18 @@ defmodule PlausibleWeb.SiteView do
     Plausible.Sites.shared_link_url(site, link)
   end
 
+  def gravatar(email, opts) do
+    hash =
+      email
+      |> String.trim()
+      |> String.downcase()
+      |> :erlang.md5()
+      |> Base.encode16(case: :lower)
+
+    img = "https://www.gravatar.com/avatar/#{hash}?s=150&d=identicon"
+    img_tag(img, opts)
+  end
+
   def snippet(site) do
     tracker =
       if site.custom_domain do
@@ -38,4 +50,12 @@ defmodule PlausibleWeb.SiteView do
     <script defer data-domain="#{site.domain}" src="#{tracker}"></script>
     """
   end
+
+  def with_indefinite_article(word) do
+    if String.starts_with?(word, ["a", "e", "i", "o", "u"]) do
+      "an " <> word
+    else
+      "a " <> word
+    end
+  end
 end
diff --git a/lib/workers/clean_invitations.ex b/lib/workers/clean_invitations.ex
new file mode 100644
index 00000000..17cf841d
--- /dev/null
+++ b/lib/workers/clean_invitations.ex
@@ -0,0 +1,14 @@
+defmodule Plausible.Workers.CleanInvitations do
+  use Plausible.Repo
+  use Oban.Worker, queue: :clean_invitations
+
+  @impl Oban.Worker
+  def perform(_job) do
+    Repo.delete_all(
+      from i in Plausible.Auth.Invitation,
+        where: i.inserted_at < fragment("now() - INTERVAL '48 hours'")
+    )
+
+    :ok
+  end
+end
diff --git a/lib/workers/lock_sites.ex b/lib/workers/lock_sites.ex
new file mode 100644
index 00000000..2c85da14
--- /dev/null
+++ b/lib/workers/lock_sites.ex
@@ -0,0 +1,15 @@
+defmodule Plausible.Workers.LockSites do
+  use Plausible.Repo
+  use Oban.Worker, queue: :lock_sites
+
+  @impl Oban.Worker
+  def perform(_job) do
+    users = Repo.all(from u in Plausible.Auth.User, preload: :subscription)
+
+    for user <- users do
+      Plausible.Billing.SiteLocker.check_sites_for(user)
+    end
+
+    :ok
+  end
+end
diff --git a/lib/workers/send_check_stats_emails.ex b/lib/workers/send_check_stats_emails.ex
index e01ef45e..ee158580 100644
--- a/lib/workers/send_check_stats_emails.ex
+++ b/lib/workers/send_check_stats_emails.ex
@@ -19,7 +19,7 @@ defmodule Plausible.Workers.SendCheckStatsEmails do
     for user <- Repo.all(q) do
       enabled_report = Enum.any?(user.sites, fn site -> site.weekly_report end)
 
-      if Plausible.Auth.user_completed_setup?(user) && !enabled_report do
+      if Plausible.Auth.has_active_sites?(user) && !enabled_report do
         send_check_stats_email(user)
       end
     end
diff --git a/lib/workers/send_email_report.ex b/lib/workers/send_email_report.ex
index 8b63ab0c..ca4f15cb 100644
--- a/lib/workers/send_email_report.ex
+++ b/lib/workers/send_email_report.ex
@@ -60,7 +60,7 @@ defmodule Plausible.Workers.SendEmailReport do
     referrers = Stats.top_sources(site, query, 5, 1, [])
     pages = Stats.top_pages(site, query, 5, 1, [])
     user = Plausible.Auth.find_user_by(email: email)
-    login_link = user && Plausible.Sites.is_owner?(user.id, site)
+    login_link = user && Plausible.Sites.is_member?(user.id, site)
 
     template =
       PlausibleWeb.Email.weekly_report(email, site,
diff --git a/lib/workers/send_site_setup_emails.ex b/lib/workers/send_site_setup_emails.ex
index 92e5f00c..74c95546 100644
--- a/lib/workers/send_site_setup_emails.ex
+++ b/lib/workers/send_site_setup_emails.ex
@@ -38,12 +38,13 @@ defmodule Plausible.Workers.SendSiteSetupEmails do
         left_join: se in "setup_help_emails",
         on: se.site_id == s.id,
         where: is_nil(se.id),
-        where: s.inserted_at > fragment("(now() at time zone 'utc') - '72 hours'::interval"),
-        preload: :members
+        where: s.inserted_at > fragment("(now() at time zone 'utc') - '72 hours'::interval")
       )
 
     for site <- Repo.all(q) do
-      owner = List.first(site.members)
+      owner =
+        Plausible.Sites.owner_for(site)
+        |> Repo.preload(:subscription)
 
       setup_completed = Stats.has_pageviews?(site)
       hours_passed = Timex.diff(Timex.now(), site.inserted_at, :hours)
@@ -60,12 +61,13 @@ defmodule Plausible.Workers.SendSiteSetupEmails do
         left_join: se in "setup_success_emails",
         on: se.site_id == s.id,
         where: is_nil(se.id),
-        where: s.inserted_at > fragment("(now() at time zone 'utc') - '72 hours'::interval"),
-        preload: :members
+        where: s.inserted_at > fragment("(now() at time zone 'utc') - '72 hours'::interval")
       )
 
     for site <- Repo.all(q) do
-      owner = List.first(site.members)
+      owner =
+        Plausible.Sites.owner_for(site)
+        |> Repo.preload(:subscription)
 
       if Stats.has_pageviews?(site) do
         send_setup_success_email(owner, site)
diff --git a/lib/workers/send_trial_notifications.ex b/lib/workers/send_trial_notifications.ex
index ab2e140f..8eb318ad 100644
--- a/lib/workers/send_trial_notifications.ex
+++ b/lib/workers/send_trial_notifications.ex
@@ -21,22 +21,22 @@ defmodule Plausible.Workers.SendTrialNotifications do
     for user <- users do
       case Timex.diff(user.trial_expiry_date, Timex.today(), :days) do
         7 ->
-          if Plausible.Auth.user_completed_setup?(user) do
+          if Plausible.Auth.has_active_sites?(user, [:owner]) do
             send_one_week_reminder(user)
           end
 
         1 ->
-          if Plausible.Auth.user_completed_setup?(user) do
+          if Plausible.Auth.has_active_sites?(user, [:owner]) do
             send_tomorrow_reminder(user)
           end
 
         0 ->
-          if Plausible.Auth.user_completed_setup?(user) do
+          if Plausible.Auth.has_active_sites?(user, [:owner]) do
             send_today_reminder(user)
           end
 
         -1 ->
-          if Plausible.Auth.user_completed_setup?(user) do
+          if Plausible.Auth.has_active_sites?(user, [:owner]) do
             send_over_reminder(user)
           end
 
diff --git a/priv/repo/migrations/20210531080158_add_role_to_site_memberships.exs b/priv/repo/migrations/20210531080158_add_role_to_site_memberships.exs
new file mode 100644
index 00000000..a5b8c3b7
--- /dev/null
+++ b/priv/repo/migrations/20210531080158_add_role_to_site_memberships.exs
@@ -0,0 +1,13 @@
+defmodule Plausible.Repo.Migrations.AddRoleToSiteMemberships do
+  use Ecto.Migration
+
+  def change do
+    create_query = "CREATE TYPE site_membership_role AS ENUM ('owner', 'admin', 'viewer')"
+    drop_query = "DROP TYPE site_membership_role"
+    execute(create_query, drop_query)
+
+    alter table(:site_memberships) do
+      add :role, :site_membership_role, null: false, default: "owner"
+    end
+  end
+end
diff --git a/priv/repo/migrations/20210601090924_add_invitations.exs b/priv/repo/migrations/20210601090924_add_invitations.exs
new file mode 100644
index 00000000..b13cc4b8
--- /dev/null
+++ b/priv/repo/migrations/20210601090924_add_invitations.exs
@@ -0,0 +1,18 @@
+defmodule Plausible.Repo.Migrations.AddInvitations do
+  use Ecto.Migration
+
+  def change do
+    create table(:invitations) do
+      add :email, :string, null: false
+      add :site_id, references(:sites), null: false
+      add :inviter_id, references(:users), null: false
+      add :role, :site_membership_role, null: false
+      add :invitation_id, :string
+
+      timestamps()
+    end
+
+    create unique_index(:invitations, [:site_id, :email])
+    create unique_index(:invitations, :invitation_id)
+  end
+end
diff --git a/priv/repo/migrations/20210604085943_add_locked_to_sites.exs b/priv/repo/migrations/20210604085943_add_locked_to_sites.exs
new file mode 100644
index 00000000..7764d966
--- /dev/null
+++ b/priv/repo/migrations/20210604085943_add_locked_to_sites.exs
@@ -0,0 +1,9 @@
+defmodule Plausible.Repo.Migrations.AddLockedToSites do
+  use Ecto.Migration
+
+  def change do
+    alter table(:sites) do
+      add :locked, :boolean, null: false, default: false
+    end
+  end
+end
diff --git a/test/plausible/auth/auth_test.exs b/test/plausible/auth/auth_test.exs
index 365bf5fb..d76417bb 100644
--- a/test/plausible/auth/auth_test.exs
+++ b/test/plausible/auth/auth_test.exs
@@ -6,21 +6,34 @@ defmodule Plausible.AuthTest do
     test "is false if user does not have any sites" do
       user = insert(:user)
 
-      refute Auth.user_completed_setup?(user)
+      refute Auth.has_active_sites?(user)
     end
 
     test "is false if user does not have any events" do
       user = insert(:user)
       insert(:site, members: [user])
 
-      refute Auth.user_completed_setup?(user)
+      refute Auth.has_active_sites?(user)
     end
 
     test "is true if user does have events" do
       user = insert(:user)
       insert(:site, members: [user], domain: "test-site.com")
 
-      assert Auth.user_completed_setup?(user)
+      assert Auth.has_active_sites?(user)
+    end
+
+    test "can specify which roles we're looking for" do
+      user = insert(:user)
+
+      insert(:site,
+        domain: "test-site.com",
+        memberships: [
+          build(:site_membership, user: user, role: :admin)
+        ]
+      )
+
+      refute Auth.has_active_sites?(user, [:owner])
     end
   end
 end
diff --git a/test/plausible/billing/billing_test.exs b/test/plausible/billing/billing_test.exs
index f20a6e79..c0e082b3 100644
--- a/test/plausible/billing/billing_test.exs
+++ b/test/plausible/billing/billing_test.exs
@@ -17,6 +17,26 @@ defmodule Plausible.BillingTest do
 
       assert Billing.usage(user) == 3
     end
+
+    test "only counts usage from sites where the user is the owner" do
+      user = insert(:user)
+
+      insert(:site,
+        domain: "site-with-no-views.com",
+        memberships: [
+          build(:site_membership, user: user, role: :owner)
+        ]
+      )
+
+      insert(:site,
+        domain: "test-site.com",
+        memberships: [
+          build(:site_membership, user: user, role: :admin)
+        ]
+      )
+
+      assert Billing.usage(user) == 0
+    end
   end
 
   describe "last_two_billing_cycles" do
@@ -66,6 +86,36 @@ defmodule Plausible.BillingTest do
       assert Billing.last_two_billing_months_usage(user, today) == {1, 1}
     end
 
+    test "only considers sites that the user owns" do
+      last_bill_date = ~D[2021-01-01]
+      today = ~D[2021-01-02]
+
+      user = insert(:user, subscription: build(:subscription, last_bill_date: last_bill_date))
+
+      owner_site =
+        insert(:site,
+          memberships: [
+            build(:site_membership, user: user, role: :owner)
+          ]
+        )
+
+      admin_site =
+        insert(:site,
+          memberships: [
+            build(:site_membership, user: user, role: :admin)
+          ]
+        )
+
+      create_pageviews([
+        %{domain: owner_site.domain, timestamp: ~N[2020-12-31 00:00:00]},
+        %{domain: admin_site.domain, timestamp: ~N[2020-12-31 00:00:00]},
+        %{domain: owner_site.domain, timestamp: ~N[2020-11-01 00:00:00]},
+        %{domain: admin_site.domain, timestamp: ~N[2020-11-01 00:00:00]}
+      ])
+
+      assert Billing.last_two_billing_months_usage(user, today) == {1, 1}
+    end
+
     test "gets event count from last month and this one" do
       user =
         insert(:user,
@@ -93,13 +143,15 @@ defmodule Plausible.BillingTest do
 
   describe "on_trial?" do
     test "is true with >= 0 trial days left" do
-      user = insert(:user)
+      user = insert(:user) |> Repo.preload(:subscription)
 
       assert Billing.on_trial?(user)
     end
 
     test "is false with < 0 trial days left" do
-      user = insert(:user, trial_expiry_date: Timex.shift(Timex.now(), days: -1))
+      user =
+        insert(:user, trial_expiry_date: Timex.shift(Timex.now(), days: -1))
+        |> Repo.preload(:subscription)
 
       refute Billing.on_trial?(user)
     end
@@ -214,6 +266,26 @@ defmodule Plausible.BillingTest do
       assert subscription.next_bill_date == ~D[2019-06-01]
       assert subscription.next_bill_amount == "6.00"
     end
+
+    test "unlocks sites if user has any locked sites" do
+      user = insert(:user)
+      site = insert(:site, locked: true, members: [user])
+
+      Billing.subscription_created(%{
+        "alert_name" => "subscription_created",
+        "subscription_id" => @subscription_id,
+        "subscription_plan_id" => @plan_id,
+        "update_url" => "update_url.com",
+        "cancel_url" => "cancel_url.com",
+        "passthrough" => user.id,
+        "status" => "active",
+        "next_bill_date" => "2019-06-01",
+        "unit_price" => "6.00",
+        "currency" => "EUR"
+      })
+
+      refute Repo.reload!(site).locked
+    end
   end
 
   describe "subscription_updated" do
diff --git a/test/plausible/billing/site_locker_test.exs b/test/plausible/billing/site_locker_test.exs
new file mode 100644
index 00000000..b61812f4
--- /dev/null
+++ b/test/plausible/billing/site_locker_test.exs
@@ -0,0 +1,110 @@
+defmodule Plausible.Billing.SiteLockerTest do
+  use Plausible.DataCase
+  use Bamboo.Test, shared: true
+  alias Plausible.Billing.SiteLocker
+
+  describe "check_sites_for/1" do
+    test "does not lock sites if user is on trial" do
+      user =
+        insert(:user, trial_expiry_date: Timex.today())
+        |> Repo.preload(:subscription)
+
+      site = insert(:site, locked: true, members: [user])
+
+      SiteLocker.check_sites_for(user)
+
+      refute Repo.reload!(site).locked
+    end
+
+    test "does not lock if user has an active subscription" do
+      user = insert(:user)
+      insert(:subscription, status: "active", user: user)
+      user = Repo.preload(user, :subscription)
+      site = insert(:site, locked: true, members: [user])
+
+      SiteLocker.check_sites_for(user)
+
+      refute Repo.reload!(site).locked
+    end
+
+    test "does not lock user who is past due" do
+      user = insert(:user)
+      insert(:subscription, status: "past_due", user: user)
+      user = Repo.preload(user, :subscription)
+      site = insert(:site, members: [user])
+
+      SiteLocker.check_sites_for(user)
+
+      refute Repo.reload!(site).locked
+    end
+
+    test "does not lock user who cancelled subscription but it hasn't expired yet" do
+      user = insert(:user)
+      insert(:subscription, status: "deleted", user: user)
+      user = Repo.preload(user, :subscription)
+      site = insert(:site, members: [user])
+
+      SiteLocker.check_sites_for(user)
+
+      refute Repo.reload!(site).locked
+    end
+
+    test "locks user who cancelled subscription and the cancelled subscription has expired" do
+      user = insert(:user)
+
+      insert(:subscription,
+        status: "deleted",
+        next_bill_date: Timex.today() |> Timex.shift(days: -1),
+        user: user
+      )
+
+      site = insert(:site, members: [user])
+
+      user = Repo.preload(user, :subscription)
+
+      SiteLocker.check_sites_for(user)
+
+      refute Repo.reload!(site).locked
+    end
+
+    test "locks all sites if user has no trial or active subscription" do
+      user =
+        insert(:user, trial_expiry_date: Timex.today() |> Timex.shift(days: -1))
+        |> Repo.preload(:subscription)
+
+      site = insert(:site, locked: true, members: [user])
+
+      SiteLocker.check_sites_for(user)
+
+      assert Repo.reload!(site).locked
+    end
+
+    test "only locks sites that the user owns" do
+      user =
+        insert(:user, trial_expiry_date: Timex.today() |> Timex.shift(days: -1))
+        |> Repo.preload(:subscription)
+
+      owner_site =
+        insert(:site,
+          memberships: [
+            build(:site_membership, user: user, role: :owner)
+          ]
+        )
+
+      viewer_site =
+        insert(:site,
+          memberships: [
+            build(:site_membership, user: user, role: :viewer)
+          ]
+        )
+
+      SiteLocker.check_sites_for(user)
+
+      owner_site = Repo.reload!(owner_site)
+      viewer_site = Repo.reload!(viewer_site)
+
+      assert owner_site.locked
+      refute viewer_site.locked
+    end
+  end
+end
diff --git a/test/plausible/sites_test.exs b/test/plausible/sites_test.exs
index b95a0e1f..b4f4be59 100644
--- a/test/plausible/sites_test.exs
+++ b/test/plausible/sites_test.exs
@@ -2,19 +2,19 @@ defmodule Plausible.SitesTest do
   use Plausible.DataCase
   alias Plausible.Sites
 
-  describe "is_owner?" do
-    test "is true if user is the owner of the site" do
+  describe "is_member?" do
+    test "is true if user is a member of the site" do
       user = insert(:user)
       site = insert(:site, members: [user])
 
-      assert Sites.is_owner?(user.id, site)
+      assert Sites.is_member?(user.id, site)
     end
 
-    test "is false if user is not the owner" do
+    test "is false if user is not a member" do
       user = insert(:user)
       site = insert(:site)
 
-      refute Sites.is_owner?(user.id, site)
+      refute Sites.is_member?(user.id, site)
     end
   end
 end
diff --git a/test/plausible_web/controllers/api/external_sites_controller_test.exs b/test/plausible_web/controllers/api/external_sites_controller_test.exs
index 9dc48d5c..2f05fceb 100644
--- a/test/plausible_web/controllers/api/external_sites_controller_test.exs
+++ b/test/plausible_web/controllers/api/external_sites_controller_test.exs
@@ -1,5 +1,6 @@
 defmodule PlausibleWeb.Api.ExternalSitesControllerTest do
   use PlausibleWeb.ConnCase
+  use Plausible.Repo
   import Plausible.TestUtils
 
   setup %{conn: conn} do
@@ -129,5 +130,27 @@ defmodule PlausibleWeb.Api.ExternalSitesControllerTest do
       res = json_response(conn, 404)
       assert res["error"] == "Site could not be found"
     end
+
+    test "returns 404 when api key owner does not have permissions to create a shared link", %{
+      conn: conn,
+      site: site,
+      user: user
+    } do
+      Repo.update_all(
+        from(sm in Plausible.Site.Membership,
+          where: sm.site_id == ^site.id and sm.user_id == ^user.id
+        ),
+        set: [role: :viewer]
+      )
+
+      conn =
+        put(conn, "/api/v1/sites/shared-links", %{
+          site_id: site.domain,
+          name: "Wordpress"
+        })
+
+      res = json_response(conn, 404)
+      assert res["error"] == "Site could not be found"
+    end
   end
 end
diff --git a/test/plausible_web/controllers/invitation_controller_test.exs b/test/plausible_web/controllers/invitation_controller_test.exs
new file mode 100644
index 00000000..4e9671a5
--- /dev/null
+++ b/test/plausible_web/controllers/invitation_controller_test.exs
@@ -0,0 +1,158 @@
+defmodule PlausibleWeb.Site.InvitationControllerTest do
+  use PlausibleWeb.ConnCase
+  use Plausible.Repo
+  use Bamboo.Test
+  import Plausible.TestUtils
+
+  setup [:create_user, :log_in]
+
+  describe "POST /sites/invitations/:invitation_id/accept" do
+    test "converts the invitation into a membership", %{conn: conn, user: user} do
+      site = insert(:site)
+
+      invitation =
+        insert(:invitation,
+          site_id: site.id,
+          inviter: build(:user),
+          email: user.email,
+          role: :admin
+        )
+
+      post(conn, "/sites/invitations/#{invitation.invitation_id}/accept")
+
+      refute Repo.exists?(from(i in Plausible.Auth.Invitation, where: i.email == ^user.email))
+
+      membership = Repo.get_by(Plausible.Site.Membership, user_id: user.id, site_id: site.id)
+      assert membership.role == :admin
+    end
+
+    test "notifies the original inviter", %{conn: conn, user: user} do
+      inviter = insert(:user)
+      site = insert(:site)
+
+      invitation =
+        insert(:invitation, site_id: site.id, inviter: inviter, email: user.email, role: :admin)
+
+      post(conn, "/sites/invitations/#{invitation.invitation_id}/accept")
+
+      assert_email_delivered_with(
+        to: [nil: inviter.email],
+        subject: "[Plausible Analytics] #{user.email} accepted your invitation to #{site.domain}"
+      )
+    end
+
+    test "ownership transfer - notifies the original inviter with a different email", %{
+      conn: conn,
+      user: user
+    } do
+      inviter = insert(:user)
+      site = insert(:site)
+
+      invitation =
+        insert(:invitation, site_id: site.id, inviter: inviter, email: user.email, role: :owner)
+
+      post(conn, "/sites/invitations/#{invitation.invitation_id}/accept")
+
+      assert_email_delivered_with(
+        to: [nil: inviter.email],
+        subject:
+          "[Plausible Analytics] #{user.email} accepted the ownership transfer of #{site.domain}"
+      )
+    end
+
+    test "ownership transfer - downgrades previous owner to admin", %{conn: conn, user: user} do
+      old_owner = insert(:user)
+      site = insert(:site, members: [old_owner])
+
+      invitation =
+        insert(:invitation, site_id: site.id, inviter: old_owner, email: user.email, role: :owner)
+
+      post(conn, "/sites/invitations/#{invitation.invitation_id}/accept")
+
+      refute Repo.exists?(from(i in Plausible.Auth.Invitation, where: i.email == ^user.email))
+
+      old_owner_membership =
+        Repo.get_by(Plausible.Site.Membership, user_id: old_owner.id, site_id: site.id)
+
+      assert old_owner_membership.role == :admin
+
+      new_owner_membership =
+        Repo.get_by(Plausible.Site.Membership, user_id: user.id, site_id: site.id)
+
+      assert new_owner_membership.role == :owner
+    end
+
+    test "ownership transfer - will lock the site if new owner does not have an active subscription or trial",
+         %{
+           conn: conn,
+           user: user
+         } do
+      Repo.update_all(from(u in Plausible.Auth.User, where: u.id == ^user.id),
+        set: [trial_expiry_date: Timex.today() |> Timex.shift(days: -1)]
+      )
+
+      inviter = insert(:user)
+      site = insert(:site, locked: false)
+
+      invitation =
+        insert(:invitation, site_id: site.id, inviter: inviter, email: user.email, role: :owner)
+
+      post(conn, "/sites/invitations/#{invitation.invitation_id}/accept")
+
+      assert Repo.reload!(site).locked
+    end
+  end
+
+  describe "POST /sites/invitations/:invitation_id/reject" do
+    test "deletes the invitation", %{conn: conn, user: user} do
+      site = insert(:site)
+
+      invitation =
+        insert(:invitation,
+          site_id: site.id,
+          inviter: build(:user),
+          email: user.email,
+          role: :admin
+        )
+
+      post(conn, "/sites/invitations/#{invitation.invitation_id}/reject")
+
+      refute Repo.exists?(from(i in Plausible.Auth.Invitation, where: i.email == ^user.email))
+    end
+
+    test "notifies the original inviter", %{conn: conn, user: user} do
+      inviter = insert(:user)
+      site = insert(:site)
+
+      invitation =
+        insert(:invitation, site_id: site.id, inviter: inviter, email: user.email, role: :admin)
+
+      post(conn, "/sites/invitations/#{invitation.invitation_id}/reject")
+
+      assert_email_delivered_with(
+        to: [nil: inviter.email],
+        subject: "[Plausible Analytics] #{user.email} rejected your invitation to #{site.domain}"
+      )
+    end
+  end
+
+  describe "DELETE /sites/invitations/:invitation_id" do
+    test "removes the invitation", %{conn: conn} do
+      site = insert(:site)
+
+      invitation =
+        insert(:invitation,
+          site_id: site.id,
+          inviter: build(:user),
+          email: "jane@example.com",
+          role: :admin
+        )
+
+      delete(conn, "/sites/invitations/#{invitation.invitation_id}")
+
+      refute Repo.exists?(
+               from i in Plausible.Auth.Invitation, where: i.email == "jane@example.com"
+             )
+    end
+  end
+end
diff --git a/test/plausible_web/controllers/site/membership_controller_test.exs b/test/plausible_web/controllers/site/membership_controller_test.exs
new file mode 100644
index 00000000..81c7587c
--- /dev/null
+++ b/test/plausible_web/controllers/site/membership_controller_test.exs
@@ -0,0 +1,204 @@
+defmodule PlausibleWeb.Site.MembershipControllerTest do
+  use PlausibleWeb.ConnCase
+  use Plausible.Repo
+  use Bamboo.Test
+  import Plausible.TestUtils
+
+  setup [:create_user, :log_in]
+
+  describe "GET /sites/:website/memberships/invite" do
+    test "shows invite form", %{conn: conn, user: user} do
+      site = insert(:site, members: [user])
+
+      conn = get(conn, "/sites/#{site.domain}/memberships/invite")
+
+      assert html_response(conn, 200) =~ "Invite member to"
+    end
+  end
+
+  describe "POST /sites/:website/memberships/invite" do
+    test "creates invitation", %{conn: conn, user: user} do
+      site = insert(:site, members: [user])
+
+      conn =
+        post(conn, "/sites/#{site.domain}/memberships/invite", %{
+          email: "john.doe@example.com",
+          role: "admin"
+        })
+
+      invitation = Repo.get_by(Plausible.Auth.Invitation, email: "john.doe@example.com")
+
+      assert invitation.role == :admin
+      assert redirected_to(conn) == "/#{site.domain}/settings/people"
+    end
+
+    test "sends invitation email for new user", %{conn: conn, user: user} do
+      site = insert(:site, members: [user])
+
+      post(conn, "/sites/#{site.domain}/memberships/invite", %{
+        email: "john.doe@example.com",
+        role: "admin"
+      })
+
+      assert_email_delivered_with(
+        to: [nil: "john.doe@example.com"],
+        subject: "[Plausible Analytics] You've been invited to #{site.domain}"
+      )
+    end
+
+    test "sends invitation email for existing user", %{conn: conn, user: user} do
+      existing_user = insert(:user)
+      site = insert(:site, members: [user])
+
+      post(conn, "/sites/#{site.domain}/memberships/invite", %{
+        email: existing_user.email,
+        role: "admin"
+      })
+
+      assert_email_delivered_with(
+        to: [nil: existing_user.email],
+        subject: "[Plausible Analytics] You've been invited to #{site.domain}"
+      )
+    end
+
+    test "renders form with error if the invitee is already a member", %{conn: conn, user: user} do
+      second_member = insert(:user)
+      site = insert(:site, members: [user, second_member])
+
+      conn =
+        post(conn, "/sites/#{site.domain}/memberships/invite", %{
+          email: second_member.email,
+          role: "admin"
+        })
+
+      assert html_response(conn, 200) =~
+               "#{second_member.email} is already a member of #{site.domain}"
+    end
+  end
+
+  describe "GET /sites/:website/transfer-ownership" do
+    test "shows ownership transfer form", %{conn: conn, user: user} do
+      site = insert(:site, members: [user])
+
+      conn = get(conn, "/sites/#{site.domain}/transfer-ownership")
+
+      assert html_response(conn, 200) =~ "Transfer ownership of"
+    end
+  end
+
+  describe "POST /sites/:website/transfer-ownership" do
+    test "creates invitation with :owner role", %{conn: conn, user: user} do
+      site = insert(:site, members: [user])
+
+      conn =
+        post(conn, "/sites/#{site.domain}/transfer-ownership", %{email: "john.doe@example.com"})
+
+      invitation = Repo.get_by(Plausible.Auth.Invitation, email: "john.doe@example.com")
+
+      assert invitation.role == :owner
+      assert redirected_to(conn) == "/#{site.domain}/settings/people"
+    end
+
+    test "sends ownership transfer email for new user", %{conn: conn, user: user} do
+      site = insert(:site, members: [user])
+
+      post(conn, "/sites/#{site.domain}/transfer-ownership", %{email: "john.doe@example.com"})
+
+      assert_email_delivered_with(
+        to: [nil: "john.doe@example.com"],
+        subject: "[Plausible Analytics] Request to transfer ownership of #{site.domain}"
+      )
+    end
+
+    test "sends invitation email for existing user", %{conn: conn, user: user} do
+      existing_user = insert(:user)
+      site = insert(:site, members: [user])
+
+      post(conn, "/sites/#{site.domain}/transfer-ownership", %{email: existing_user.email})
+
+      assert_email_delivered_with(
+        to: [nil: existing_user.email],
+        subject: "[Plausible Analytics] Request to transfer ownership of #{site.domain}"
+      )
+    end
+  end
+
+  describe "PUT /sites/memberships/:id/role/:new_role" do
+    test "updates a site member's role", %{conn: conn, user: user} do
+      admin = insert(:user)
+
+      site =
+        insert(:site,
+          memberships: [
+            build(:site_membership, user: user, role: :owner),
+            build(:site_membership, user: admin, role: :admin)
+          ]
+        )
+
+      membership = Repo.get_by(Plausible.Site.Membership, user_id: admin.id)
+
+      put(conn, "/sites/#{site.domain}/memberships/#{membership.id}/role/viewer")
+
+      membership = Repo.reload!(membership)
+
+      assert membership.role == :viewer
+    end
+
+    test "can downgrade yourself from admin to viewer, redirects to stats instead", %{
+      conn: conn,
+      user: user
+    } do
+      site = insert(:site, memberships: [build(:site_membership, user: user, role: :admin)])
+
+      membership = Repo.get_by(Plausible.Site.Membership, user_id: user.id)
+
+      conn = put(conn, "/sites/#{site.domain}/memberships/#{membership.id}/role/viewer")
+
+      membership = Repo.reload!(membership)
+
+      assert membership.role == :viewer
+      assert redirected_to(conn) == "/#{site.domain}"
+    end
+  end
+
+  describe "DELETE /sites/memberships/:id" do
+    test "removes a member from a site", %{conn: conn, user: user} do
+      admin = insert(:user)
+
+      site =
+        insert(:site,
+          memberships: [
+            build(:site_membership, user: user, role: :owner),
+            build(:site_membership, user: admin, role: :admin)
+          ]
+        )
+
+      membership = Enum.find(site.memberships, &(&1.role == :admin))
+
+      delete(conn, "/sites/#{site.domain}/memberships/#{membership.id}")
+
+      refute Repo.exists?(from sm in Plausible.Site.Membership, where: sm.user_id == ^admin.id)
+    end
+
+    test "notifies the user who has been removed via email", %{conn: conn, user: user} do
+      admin = insert(:user)
+
+      site =
+        insert(:site,
+          memberships: [
+            build(:site_membership, user: user, role: :owner),
+            build(:site_membership, user: admin, role: :admin)
+          ]
+        )
+
+      membership = Enum.find(site.memberships, &(&1.role == :admin))
+
+      delete(conn, "/sites/#{site.domain}/memberships/#{membership.id}")
+
+      assert_email_delivered_with(
+        to: [nil: admin.email],
+        subject: "[Plausible Analytics] Your access to #{site.domain} has been revoked"
+      )
+    end
+  end
+end
diff --git a/test/support/factory.ex b/test/support/factory.ex
index 34407d33..05fa273a 100644
--- a/test/support/factory.ex
+++ b/test/support/factory.ex
@@ -30,6 +30,10 @@ defmodule Plausible.Factory do
     }
   end
 
+  def site_membership_factory do
+    %Plausible.Site.Membership{}
+  end
+
   def ch_session_factory do
     hostname = sequence(:domain, &"example-#{&1}.com")
 
@@ -153,6 +157,14 @@ defmodule Plausible.Factory do
     }
   end
 
+  def invitation_factory do
+    %Plausible.Auth.Invitation{
+      invitation_id: Nanoid.generate(),
+      email: sequence(:email, &"email-#{&1}@example.com"),
+      role: :admin
+    }
+  end
+
   def api_key_factory do
     key = :crypto.strong_rand_bytes(64) |> Base.url_encode64() |> binary_part(0, 64)
 
diff --git a/test/workers/clean_invitations_test.exs b/test/workers/clean_invitations_test.exs
new file mode 100644
index 00000000..5d6d872f
--- /dev/null
+++ b/test/workers/clean_invitations_test.exs
@@ -0,0 +1,28 @@
+defmodule Plausible.Workers.CleanInvitationsTest do
+  use Plausible.DataCase
+  alias Plausible.Workers.CleanInvitations
+
+  test "cleans invitation that is more than 48h old" do
+    insert(:invitation,
+      inserted_at: Timex.shift(Timex.now(), hours: -49),
+      site: build(:site),
+      inviter: build(:user)
+    )
+
+    CleanInvitations.perform(nil)
+
+    refute Repo.exists?(Plausible.Auth.Invitation)
+  end
+
+  test "does not clean invitation that is less than 48h old" do
+    insert(:invitation,
+      inserted_at: Timex.shift(Timex.now(), hours: -47),
+      site: build(:site),
+      inviter: build(:user)
+    )
+
+    CleanInvitations.perform(nil)
+
+    assert Repo.exists?(Plausible.Auth.Invitation)
+  end
+end
diff --git a/test/workers/lock_sites_test.exs b/test/workers/lock_sites_test.exs
new file mode 100644
index 00000000..27ecc7fd
--- /dev/null
+++ b/test/workers/lock_sites_test.exs
@@ -0,0 +1,96 @@
+defmodule Plausible.Workers.LockSitesTest do
+  use Plausible.DataCase
+  alias Plausible.Workers.LockSites
+
+  test "does not lock trial user's site" do
+    user = insert(:user, trial_expiry_date: Timex.today() |> Timex.shift(days: 1))
+    site = insert(:site, members: [user])
+
+    LockSites.perform(nil)
+
+    refute Repo.reload!(site).locked
+  end
+
+  test "locks site for user whose trial has expired" do
+    user = insert(:user, trial_expiry_date: Timex.today() |> Timex.shift(days: -1))
+    site = insert(:site, members: [user])
+
+    LockSites.perform(nil)
+
+    assert Repo.reload!(site).locked
+  end
+
+  test "does not lock active subsriber's sites" do
+    user = insert(:user)
+    insert(:subscription, status: "active", user: user)
+    site = insert(:site, members: [user])
+
+    LockSites.perform(nil)
+
+    refute Repo.reload!(site).locked
+  end
+
+  test "does not lock user who is past due" do
+    user = insert(:user)
+    insert(:subscription, status: "past_due", user: user)
+    site = insert(:site, members: [user])
+
+    LockSites.perform(nil)
+
+    refute Repo.reload!(site).locked
+  end
+
+  test "does not lock user who cancelled subscription but it hasn't expired yet" do
+    user = insert(:user)
+    insert(:subscription, status: "deleted", user: user)
+    site = insert(:site, members: [user])
+
+    LockSites.perform(nil)
+
+    refute Repo.reload!(site).locked
+  end
+
+  test "locks user who cancelled subscription and the cancelled subscription has expired" do
+    user = insert(:user)
+
+    insert(:subscription,
+      status: "deleted",
+      next_bill_date: Timex.today() |> Timex.shift(days: -1),
+      user: user
+    )
+
+    site = insert(:site, members: [user])
+
+    LockSites.perform(nil)
+
+    refute Repo.reload!(site).locked
+  end
+
+  describe "locking" do
+    test "only locks sites that the user owns" do
+      user = insert(:user, trial_expiry_date: Timex.today() |> Timex.shift(days: -1))
+
+      owner_site =
+        insert(:site,
+          memberships: [
+            build(:site_membership, user: user, role: :owner)
+          ]
+        )
+
+      viewer_site =
+        insert(:site,
+          memberships: [
+            build(:site_membership, user: user, role: :viewer)
+          ]
+        )
+
+      LockSites.perform(nil)
+
+      owner_site = Repo.reload!(owner_site)
+      viewer_site = Repo.reload!(viewer_site)
+
+      assert owner_site.locked
+      refute viewer_site.locked
+    end
+  end
+end
diff --git a/test/workers/send_trial_notifications_test.exs b/test/workers/send_trial_notifications_test.exs
index b014264a..aaef8403 100644
--- a/test/workers/send_trial_notifications_test.exs
+++ b/test/workers/send_trial_notifications_test.exs
@@ -4,11 +4,35 @@ defmodule Plausible.Workers.SendTrialNotificationsTest do
   use Oban.Testing, repo: Plausible.Repo
   alias Plausible.Workers.SendTrialNotifications
 
-  test "does not send a notification if user didn't set up their site" do
-    insert(:user, inserted_at: Timex.now() |> Timex.shift(days: -14))
-    insert(:user, inserted_at: Timex.now() |> Timex.shift(days: -29))
-    insert(:user, inserted_at: Timex.now() |> Timex.shift(days: -30))
-    insert(:user, inserted_at: Timex.now() |> Timex.shift(days: -31))
+  test "does not send a notification if user didn't create a site" do
+    insert(:user, trial_expiry_date: Timex.now() |> Timex.shift(days: 7))
+    insert(:user, trial_expiry_date: Timex.now() |> Timex.shift(days: 1))
+    insert(:user, trial_expiry_date: Timex.now() |> Timex.shift(days: 0))
+    insert(:user, trial_expiry_date: Timex.now() |> Timex.shift(days: -1))
+
+    perform_job(SendTrialNotifications, %{})
+
+    assert_no_emails_delivered()
+  end
+
+  test "does not send a notification if user created a site but there are no pageviews" do
+    user = insert(:user, trial_expiry_date: Timex.now() |> Timex.shift(days: 7))
+    insert(:site, domain: "some-nonexistent-site.com", members: [user])
+
+    perform_job(SendTrialNotifications, %{})
+
+    assert_no_emails_delivered()
+  end
+
+  test "does not send a notification if user is a collaborator on sites but not an owner" do
+    user = insert(:user, trial_expiry_date: Timex.now())
+
+    insert(:site,
+      domain: "test-site.com",
+      memberships: [
+        build(:site_membership, user: user, role: :admin)
+      ]
+    )
 
     perform_job(SendTrialNotifications, %{})
 
-- 
GitLab