Skip to content

Commit

Permalink
Invitations (#1122)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
ukutaht authored Jun 16, 2021
1 parent 5c86b0d commit e71de6d
Show file tree
Hide file tree
Showing 73 changed files with 2,261 additions and 287 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion assets/js/dashboard/historical.js
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
4 changes: 2 additions & 2 deletions assets/js/dashboard/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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} />
}
}
}
Expand Down
3 changes: 2 additions & 1 deletion assets/js/dashboard/mount.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,15 @@ if (container) {
}

const loggedIn = container.dataset.loggedIn === 'true'
const currentUserRole = container.dataset.currentUserRole
const sharedLinkAuth = container.dataset.sharedLinkAuth
if (sharedLinkAuth) {
api.setSharedLinkAuth(sharedLinkAuth)
}

const app = (
<ErrorBoundary>
<Router site={site} loggedIn={loggedIn} />
<Router site={site} loggedIn={loggedIn} currentUserRole={currentUserRole} />
</ErrorBoundary>
)

Expand Down
4 changes: 2 additions & 2 deletions assets/js/dashboard/router.js
Original file line number Diff line number Diff line change
Expand Up @@ -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} />
Expand Down
26 changes: 18 additions & 8 deletions assets/js/dashboard/site-switcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand All @@ -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>
Expand Down
4 changes: 2 additions & 2 deletions assets/js/dashboard/stats/sources/search-terms.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}))
}

Expand Down Expand Up @@ -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) {
Expand Down
3 changes: 2 additions & 1 deletion assets/tailwind.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ module.exports = {
backgroundOpacity: ['dark'],
display: ['dark'],
cursor: ['hover'],
justifyContent: ['responsive']
justifyContent: ['responsive'],
backgroundColor: ['odd', 'even'],
}
},
plugins: [
Expand Down
16 changes: 11 additions & 5 deletions config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -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 = [
Expand All @@ -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
Expand Down
17 changes: 14 additions & 3 deletions lib/plausible/auth/auth.ex
Original file line number Diff line number Diff line change
Expand Up @@ -59,27 +59,38 @@ 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

def find_user_by(opts) 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
)

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
23 changes: 23 additions & 0 deletions lib/plausible/auth/invitation.ex
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions lib/plausible/auth/user.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
31 changes: 21 additions & 10 deletions lib/plausible/billing/billing.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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",
Expand All @@ -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

Expand All @@ -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),
Expand Down Expand Up @@ -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
29 changes: 29 additions & 0 deletions lib/plausible/billing/site_locker.ex
Original file line number Diff line number Diff line change
@@ -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
7 changes: 4 additions & 3 deletions lib/plausible/site/membership.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions lib/plausible/site/schema.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading

0 comments on commit e71de6d

Please sign in to comment.