Leon4gr45 commited on
Commit
4e65f2a
·
verified ·
1 Parent(s): d12d11b

Cleanup files batch 400

Browse files
Files changed (40) hide show
  1. extra/lib/plausible_web/live/customer_support/team/components/consolidated_views.ex +0 -112
  2. extra/lib/plausible_web/live/customer_support/team/components/members.ex +0 -94
  3. extra/lib/plausible_web/live/customer_support/team/components/overview.ex +0 -74
  4. extra/lib/plausible_web/live/customer_support/team/components/sites.ex +0 -245
  5. extra/lib/plausible_web/live/customer_support/team/components/sso.ex +0 -114
  6. extra/lib/plausible_web/live/customer_support/user.ex +0 -115
  7. extra/lib/plausible_web/live/customer_support/user/components/keys.ex +0 -52
  8. extra/lib/plausible_web/live/customer_support/user/components/overview.ex +0 -125
  9. extra/lib/plausible_web/live/funnel_settings.ex +0 -175
  10. extra/lib/plausible_web/live/funnel_settings/form.ex +0 -484
  11. extra/lib/plausible_web/live/funnel_settings/list.ex +0 -89
  12. extra/lib/plausible_web/live/sso_management.ex +0 -740
  13. extra/lib/plausible_web/live/verification.ex +0 -288
  14. extra/lib/plausible_web/plugins/api/controllers/funnels.ex +0 -120
  15. extra/lib/plausible_web/plugins/api/schemas/funnel.ex +0 -55
  16. extra/lib/plausible_web/plugins/api/schemas/funnel/create_request.ex +0 -56
  17. extra/lib/plausible_web/plugins/api/schemas/funnel/list_response.ex +0 -26
  18. extra/lib/plausible_web/plugins/api/views/funnel.ex +0 -53
  19. extra/lib/plausible_web/plugs/handle_expired_session.ex +0 -64
  20. extra/lib/plausible_web/plugs/secure_sso.ex +0 -39
  21. extra/lib/plausible_web/sso/fake_saml_adapter.ex +0 -77
  22. extra/lib/plausible_web/sso/real_saml_adapter.ex +0 -244
  23. extra/lib/plausible_web/templates/help_scout/layout.html.heex +0 -13
  24. extra/lib/plausible_web/templates/sso/cta.html.heex +0 -18
  25. extra/lib/plausible_web/templates/sso/login_form.html.heex +0 -54
  26. extra/lib/plausible_web/templates/sso/provision_issue.html.heex +0 -38
  27. extra/lib/plausible_web/templates/sso/provision_notice.html.heex +0 -17
  28. extra/lib/plausible_web/templates/sso/saml_signin.html.heex +0 -36
  29. extra/lib/plausible_web/templates/sso/sso_settings.html.heex +0 -3
  30. extra/lib/plausible_web/templates/sso/team_sessions.html.heex +0 -36
  31. extra/lib/plausible_web/views/help_scout_view.ex +0 -297
  32. extra/lib/plausible_web/views/sso_view.ex +0 -3
  33. fixture/ga4_api_disabled_error.json +0 -27
  34. fixture/ga4_end_date.json +0 -38
  35. fixture/ga4_get_property.json +0 -12
  36. fixture/ga4_list_properties.json +0 -41
  37. fixture/ga4_report_imported_browsers.json +0 -0
  38. fixture/ga4_report_imported_custom_events.json +0 -1212
  39. fixture/ga4_report_imported_devices.json +0 -2556
  40. fixture/ga4_report_imported_entry_pages.json +0 -0
extra/lib/plausible_web/live/customer_support/team/components/consolidated_views.ex DELETED
@@ -1,112 +0,0 @@
1
- defmodule PlausibleWeb.CustomerSupport.Team.Components.ConsolidatedViews do
2
- @moduledoc """
3
- Lists ConsolidatedViews of a team and allows creating one if none exist. Current
4
- limitation is one consolidated view per team, which always includes all sites of
5
- this team.
6
- """
7
- use PlausibleWeb, :live_component
8
- import PlausibleWeb.CustomerSupport.Live
9
- alias Plausible.ConsolidatedView
10
- alias Plausible.Stats
11
-
12
- def update(%{team: team}, socket) do
13
- consolidated_view = ConsolidatedView.get(team)
14
-
15
- sparkline_intervals =
16
- with true <- connected?(socket),
17
- {:ok, sparkline} <- Stats.Sparkline.safe_overview_24h(consolidated_view) do
18
- sparkline.intervals
19
- else
20
- _ ->
21
- Stats.Sparkline.empty_24h_intervals()
22
- end
23
-
24
- {:ok,
25
- assign(socket,
26
- team: team,
27
- consolidated_views: List.wrap(consolidated_view),
28
- sparkline_intervals: sparkline_intervals
29
- )}
30
- end
31
-
32
- def render(assigns) do
33
- ~H"""
34
- <div data-test-id="consolidated-views-tab-content" class="mt-2 mb-4">
35
- <%= if Enum.empty?(@consolidated_views) do %>
36
- <div class="mx-auto flex flex-col items-center">
37
- <p>This team does not have a consolidated view yet.</p>
38
- <.button class="mx-auto" phx-click="create-consolidated-view" phx-target={@myself}>
39
- Create one
40
- </.button>
41
- </div>
42
- <% else %>
43
- <.table rows={@consolidated_views}>
44
- <:thead>
45
- <.th>Domain</.th>
46
- <.th>Timezone</.th>
47
- <.th>Available?</.th>
48
- <.th invisible>Dashboard</.th>
49
- <.th invisible>24H</.th>
50
- <.th invisible>Delete</.th>
51
- </:thead>
52
-
53
- <:tbody :let={consolidated_view}>
54
- <.td>{consolidated_view.domain}</.td>
55
- <.td>{consolidated_view.timezone}</.td>
56
- <.td>{availability(@team)}</.td>
57
- <.td>
58
- <.styled_link
59
- new_tab={true}
60
- href={Routes.stats_path(PlausibleWeb.Endpoint, :stats, consolidated_view.domain, [])}
61
- >
62
- Dashboard
63
- </.styled_link>
64
- </.td>
65
-
66
- <.td>
67
- <span class="h-[24px] text-indigo-500">
68
- <PlausibleWeb.Live.Components.Visitors.chart
69
- intervals={@sparkline_intervals}
70
- height={20}
71
- />
72
- </span>
73
- </.td>
74
- <.td>
75
- <.delete_button
76
- phx-click="delete-consolidated-view"
77
- phx-target={@myself}
78
- data-confirm="Are you sure you want to delete this consolidated view? All existing consolidated view configuration will be lost. The view itself will be recreated whenever eligible subscription/trial accesses /sites for that team."
79
- />
80
- </.td>
81
- </:tbody>
82
- </.table>
83
- <% end %>
84
- </div>
85
- """
86
- end
87
-
88
- def handle_event("create-consolidated-view", _, socket) do
89
- case ConsolidatedView.enable(socket.assigns.team) do
90
- {:ok, consolidated_view} ->
91
- success("Consolidated view created")
92
- {:noreply, assign(socket, consolidated_views: [consolidated_view])}
93
-
94
- {:error, reason} ->
95
- failure("Could not create consolidated view. Reason: #{inspect(reason)}")
96
- {:noreply, socket}
97
- end
98
- end
99
-
100
- def handle_event("delete-consolidated-view", _, socket) do
101
- ConsolidatedView.disable(socket.assigns.team)
102
- success("Deleted consolidated view")
103
- {:noreply, assign(socket, consolidated_views: [])}
104
- end
105
-
106
- defp availability(team) do
107
- case Plausible.Billing.Feature.ConsolidatedView.check_availability(team) do
108
- :ok -> "Yes"
109
- {:error, :upgrade_required} -> "No - upgrade required"
110
- end
111
- end
112
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible_web/live/customer_support/team/components/members.ex DELETED
@@ -1,94 +0,0 @@
1
- defmodule PlausibleWeb.CustomerSupport.Team.Components.Members do
2
- @moduledoc """
3
- Team members component - handles team member management
4
- """
5
- use PlausibleWeb, :live_component
6
- import PlausibleWeb.CustomerSupport.Live
7
- alias Plausible.Teams
8
-
9
- def update(%{team: team}, socket) do
10
- {:ok, refresh_members(socket, team)}
11
- end
12
-
13
- def render(assigns) do
14
- ~H"""
15
- <div class="mt-2">
16
- <.table rows={Teams.Management.Layout.sorted_for_display(@team_layout)}>
17
- <:thead>
18
- <.th>User</.th>
19
- <.th>Sessions</.th>
20
- <.th>Type</.th>
21
- <.th>Role</.th>
22
- </:thead>
23
- <:tbody :let={{_, member}}>
24
- <.td truncate>
25
- <div :if={member.id != 0}>
26
- <.styled_link
27
- patch={Routes.customer_support_user_path(PlausibleWeb.Endpoint, :show, member.id)}
28
- class="cursor-pointer flex block items-center"
29
- >
30
- <img
31
- src={Plausible.Auth.User.profile_img_url(%Plausible.Auth.User{email: member.email})}
32
- class="mr-4 w-6 rounded-full bg-gray-300"
33
- />
34
- {member.name} &lt;{member.email}&gt;
35
- </.styled_link>
36
- </div>
37
- <div :if={member.id == 0} class="flex items-center">
38
- <img
39
- src={Plausible.Auth.User.profile_img_url(%Plausible.Auth.User{email: member.email})}
40
- class="mr-4 w-6 rounded-full bg-gray-300"
41
- />
42
- {member.name} &lt;{member.email}&gt;
43
- </div>
44
- </.td>
45
- <.td>
46
- {if member.type == :membership, do: @session_counts[member.meta.user.id] || 0, else: 0}
47
- </.td>
48
- <.td>
49
- <div class="flex items-center gap-x-1">
50
- <span :if={member.type == :membership && member.meta.user.type == :sso}>SSO </span>{member.type}
51
-
52
- <.delete_button
53
- :if={member.type == :membership && member.meta.user.type == :sso}
54
- id={"deprovision-sso-user-#{member.id}"}
55
- phx-click="deprovision-sso-user"
56
- phx-value-identifier={member.id}
57
- phx-target={@myself}
58
- class="text-sm"
59
- icon={:user_minus}
60
- data-confirm="Are you sure you want to deprovision SSO user and convert them to a standard user? This will sign them out and force to use regular e-mail/password combination to log in again."
61
- />
62
- </div>
63
- </.td>
64
- <.td>
65
- {member.role}
66
- </.td>
67
- </:tbody>
68
- </.table>
69
- </div>
70
- """
71
- end
72
-
73
- def handle_event("deprovision-sso-user", %{"identifier" => user_id}, socket) do
74
- [id: String.to_integer(user_id)]
75
- |> Plausible.Auth.find_user_by()
76
- |> Plausible.Auth.SSO.deprovision_user!()
77
-
78
- success("SSO user deprovisioned")
79
- {:noreply, refresh_members(socket, socket.assigns.team)}
80
- end
81
-
82
- defp refresh_members(socket, team) do
83
- team_layout = Teams.Management.Layout.init(team)
84
-
85
- session_counts =
86
- team_layout
87
- |> Enum.map(fn {_, entry} -> if entry.type == :membership, do: entry.meta.user end)
88
- |> Enum.reject(&is_nil/1)
89
- |> Plausible.Auth.UserSessions.count_for_users()
90
- |> Enum.into(%{})
91
-
92
- assign(socket, team: team, team_layout: team_layout, session_counts: session_counts)
93
- end
94
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible_web/live/customer_support/team/components/overview.ex DELETED
@@ -1,74 +0,0 @@
1
- defmodule PlausibleWeb.CustomerSupport.Team.Components.Overview do
2
- @moduledoc """
3
- Team overview component - handles team basic info, trial dates, notes
4
- """
5
- use PlausibleWeb, :live_component
6
- import PlausibleWeb.CustomerSupport.Live
7
-
8
- def update(%{team: team}, socket) do
9
- changeset = Plausible.Teams.Team.crm_changeset(team, %{})
10
- form = to_form(changeset)
11
-
12
- {:ok, assign(socket, team: team, form: form)}
13
- end
14
-
15
- def render(assigns) do
16
- ~H"""
17
- <div class="mt-8">
18
- <.form :let={f} for={@form} phx-submit="save-team" phx-target={@myself}>
19
- <.input field={f[:trial_expiry_date]} type="date" label="Trial Expiry Date" />
20
- <.input field={f[:accept_traffic_until]} type="date" label="Accept traffic Until" />
21
- <.input
22
- type="checkbox"
23
- field={f[:allow_next_upgrade_override]}
24
- label="Allow Next Upgrade Override"
25
- />
26
-
27
- <.input type="textarea" field={f[:notes]} label="Notes" />
28
-
29
- <div class="flex justify-between">
30
- <.button type="submit">
31
- Save
32
- </.button>
33
-
34
- <.button
35
- phx-target={@myself}
36
- phx-click="delete-team"
37
- data-confirm="Are you sure you want to delete this team?"
38
- theme="danger"
39
- >
40
- Delete Team
41
- </.button>
42
- </div>
43
- </.form>
44
- </div>
45
- """
46
- end
47
-
48
- def handle_event("save-team", %{"team" => params}, socket) do
49
- changeset = Plausible.Teams.Team.crm_changeset(socket.assigns.team, params)
50
-
51
- case Plausible.Repo.update(changeset) do
52
- {:ok, team} ->
53
- success("Team saved")
54
- {:noreply, assign(socket, team: team, form: to_form(changeset))}
55
-
56
- {:error, changeset} ->
57
- failure("Error saving team: #{inspect(changeset.errors)}")
58
- {:noreply, assign(socket, form: to_form(changeset))}
59
- end
60
- end
61
-
62
- def handle_event("delete-team", _params, socket) do
63
- case Plausible.Teams.delete(socket.assigns.team) do
64
- {:ok, :deleted} ->
65
- navigate_with_success(Routes.customer_support_path(socket, :index), "Team deleted")
66
- {:noreply, socket}
67
-
68
- {:error, :active_subscription} ->
69
- failure("The team has an active subscription which must be canceled first.")
70
-
71
- {:noreply, socket}
72
- end
73
- end
74
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible_web/live/customer_support/team/components/sites.ex DELETED
@@ -1,245 +0,0 @@
1
- defmodule PlausibleWeb.CustomerSupport.Team.Components.Sites do
2
- @moduledoc """
3
- Team sites component - handles team sites listing
4
- """
5
- use PlausibleWeb, :live_component
6
-
7
- import Ecto.Query, except: [update: 2, update: 3]
8
- import PlausibleWeb.Live.Components.Pagination
9
-
10
- alias Plausible.Repo
11
- alias Plausible.Site
12
- alias Plausible.Sites.Index
13
-
14
- @page_size 24
15
-
16
- def update(%{team: team, tab_params: tab_params}, socket) do
17
- team = Repo.preload(team, :owners)
18
- owner = List.first(team.owners)
19
-
20
- socket =
21
- assign_new(socket, :index_state, fn ->
22
- Index.build(owner, team: team, sort_by: :traffic, sort_direction: :desc)
23
- end)
24
-
25
- page =
26
- Index.paginate(socket.assigns.index_state, page: tab_params["page"], page_size: @page_size)
27
-
28
- sites = fetch_sites(page.entries)
29
-
30
- hourly_stats = build_hourly_stats(sites, socket)
31
-
32
- uri =
33
- Routes.customer_support_team_path(PlausibleWeb.Endpoint, :show, team.id, tab: "sites")
34
- |> URI.parse()
35
-
36
- {:ok,
37
- assign(socket,
38
- team: team,
39
- sites: sites,
40
- hourly_stats: hourly_stats,
41
- page_number: page.page_number,
42
- total_pages: page.total_pages,
43
- total_entries: page.total_entries,
44
- uri: uri
45
- )}
46
- end
47
-
48
- def update(%{team: team}, socket) do
49
- update(%{team: team, tab_params: %{}}, socket)
50
- end
51
-
52
- def handle_event("sort", %{"by" => by}, socket) do
53
- sort_by = parse_sort_by(by)
54
- current_state = socket.assigns.index_state
55
- current_sort_by = current_state.sort_by
56
-
57
- sort_direction =
58
- case sort_by do
59
- ^current_sort_by ->
60
- flip_direction(current_state.sort_direction)
61
-
62
- :traffic ->
63
- :desc
64
-
65
- :alnum ->
66
- :asc
67
- end
68
-
69
- new_state = Index.sort(current_state, sort_by: sort_by, sort_direction: sort_direction)
70
- page = Index.paginate(new_state, page: 1, page_size: @page_size)
71
- sites = fetch_sites(page.entries)
72
-
73
- hourly_stats = build_hourly_stats(sites, socket)
74
-
75
- {:noreply,
76
- assign(socket,
77
- index_state: new_state,
78
- sites: sites,
79
- page_number: page.page_number,
80
- total_pages: page.total_pages,
81
- total_entries: page.total_entries,
82
- hourly_stats: hourly_stats
83
- )}
84
- end
85
-
86
- def render(assigns) do
87
- assigns =
88
- assign(assigns,
89
- sort_by: assigns.index_state.sort_by,
90
- sort_direction: assigns.index_state.sort_direction
91
- )
92
-
93
- ~H"""
94
- <div class="mt-2">
95
- <.table rows={@sites}>
96
- <:thead>
97
- <th
98
- scope="col"
99
- class="px-6 first:pl-0 last:pr-0 py-3 text-left text-sm font-semibold cursor-pointer select-none"
100
- phx-click="sort"
101
- phx-value-by="alnum"
102
- phx-target={@myself}
103
- >
104
- Domain <.sort_arrow active={@sort_by == :alnum} direction={@sort_direction} />
105
- </th>
106
- <.th>Previous Domain</.th>
107
- <.th>Timezone</.th>
108
- <.th invisible>Settings</.th>
109
- <.th invisible>Dashboard</.th>
110
- <th
111
- scope="col"
112
- class="max-w-40 px-6 first:pl-0 last:pr-0 py-3 text-left text-sm font-semibold cursor-pointer select-none"
113
- phx-click="sort"
114
- phx-value-by="traffic"
115
- phx-target={@myself}
116
- >
117
- Traffic <.sort_arrow active={@sort_by == :traffic} direction={@sort_direction} />
118
- </th>
119
- </:thead>
120
- <:tbody :let={site}>
121
- <.td>
122
- <div class="flex items-center">
123
- <img
124
- src="/favicon/sources/{site.domain}"
125
- onerror="this.onerror=null; this.src='/favicon/sources/placeholder';"
126
- class="w-4 h-4 flex-shrink-0 mt-px mr-2"
127
- />
128
- <.styled_link
129
- patch={Routes.customer_support_site_path(PlausibleWeb.Endpoint, :show, site.id)}
130
- class="cursor-pointer flex block items-center"
131
- >
132
- {site.domain}
133
-
134
- <span :if={@index_state.pins[site.id]}>
135
- <PlausibleWeb.Components.Icons.pin_icon class="w-4 ml-2" filled={true} />
136
- </span>
137
- </.styled_link>
138
- </div>
139
- </.td>
140
- <.td>{site.domain_changed_from || "--"}</.td>
141
- <.td>{site.timezone}</.td>
142
- <.td>
143
- <.styled_link
144
- new_tab={true}
145
- href={Routes.stats_path(PlausibleWeb.Endpoint, :stats, site.domain, [])}
146
- >
147
- Dashboard
148
- </.styled_link>
149
- </.td>
150
- <.td>
151
- <.styled_link
152
- new_tab={true}
153
- href={Routes.site_path(PlausibleWeb.Endpoint, :settings_general, site.domain, [])}
154
- >
155
- Settings
156
- </.styled_link>
157
- </.td>
158
- <.td max_width="max-w-40">
159
- <span class="h-[24px] text-indigo-500">
160
- <PlausibleWeb.Live.Components.Visitors.chart
161
- :if={is_map(@hourly_stats[site.domain])}
162
- intervals={@hourly_stats[site.domain].intervals}
163
- height={20}
164
- />
165
- </span>
166
- <span class="text-[10px]">
167
- Unique visitors: {if is_map(@hourly_stats),
168
- do: @hourly_stats[site.domain][:visitors] || 0,
169
- else: 0}
170
- </span>
171
- </.td>
172
- </:tbody>
173
- </.table>
174
- <.pagination
175
- :if={@total_pages > 1}
176
- id="sites-pagination"
177
- uri={@uri}
178
- page_number={@page_number}
179
- total_pages={@total_pages}
180
- >
181
- Total of <span class="font-medium">{@total_entries}</span>
182
- sites. Page {@page_number} of {@total_pages}
183
- </.pagination>
184
- </div>
185
- """
186
- end
187
-
188
- defp fetch_sites([]), do: []
189
-
190
- defp fetch_sites(site_ids) do
191
- by_id =
192
- from(s in Site.regular(),
193
- where: s.id in ^site_ids,
194
- select: {s.id, s}
195
- )
196
- |> Repo.all()
197
- |> Map.new()
198
-
199
- Enum.map(site_ids, fn id -> Map.get(by_id, id) end)
200
- end
201
-
202
- defp build_hourly_stats(sites, socket) do
203
- if connected?(socket) do
204
- Plausible.Stats.Sparkline.parallel_overview(sites)
205
- else
206
- sites
207
- |> Enum.map(fn site ->
208
- {site.domain,
209
- %{
210
- intervals: Plausible.Stats.Sparkline.empty_24h_intervals(),
211
- visitors: 0,
212
- visitors_change: 0
213
- }}
214
- end)
215
- |> Map.new()
216
- end
217
- end
218
-
219
- defp parse_sort_by("alnum"), do: :alnum
220
- defp parse_sort_by(_), do: :traffic
221
-
222
- defp flip_direction(:asc), do: :desc
223
- defp flip_direction(:desc), do: :asc
224
-
225
- attr :active, :boolean, required: true
226
- attr :direction, :atom, required: true
227
-
228
- defp sort_arrow(%{active: false} = assigns) do
229
- ~H"""
230
- <span class="opacity-30">↕</span>
231
- """
232
- end
233
-
234
- defp sort_arrow(%{direction: :asc} = assigns) do
235
- ~H"""
236
- <span>↑</span>
237
- """
238
- end
239
-
240
- defp sort_arrow(assigns) do
241
- ~H"""
242
- <span>↓</span>
243
- """
244
- end
245
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible_web/live/customer_support/team/components/sso.ex DELETED
@@ -1,114 +0,0 @@
1
- defmodule PlausibleWeb.CustomerSupport.Team.Components.SSO do
2
- @moduledoc """
3
- Team SSO component - handles SSO integration management
4
- """
5
- use PlausibleWeb, :live_component
6
- import PlausibleWeb.CustomerSupport.Live
7
- alias Plausible.Auth.SSO
8
-
9
- def update(%{team: team}, socket) do
10
- sso_integration = get_sso_integration(team)
11
- {:ok, assign(socket, team: team, sso_integration: sso_integration)}
12
- end
13
-
14
- def render(assigns) do
15
- ~H"""
16
- <div>
17
- <div :if={@sso_integration}>
18
- <div class="mt-4 mb-4 text-gray-900 dark:text-gray-400">
19
- <.table rows={
20
- [
21
- {"configured?", SSO.Integration.configured?(@sso_integration)},
22
- {"IDP Sign-in URL", @sso_integration.config.idp_signin_url},
23
- {"IDP Entity ID", @sso_integration.config.idp_entity_id}
24
- ] ++ Enum.into(Map.from_struct(@team.policy), [])
25
- }>
26
- <:tbody :let={{k, v}}>
27
- <.td>{k}</.td>
28
- <.td>{v}</.td>
29
- </:tbody>
30
- </.table>
31
- </div>
32
-
33
- <.table rows={@sso_integration.sso_domains}>
34
- <:thead>
35
- <.th>Domain</.th>
36
- <.th>Status</.th>
37
- <.th></.th>
38
- </:thead>
39
- <:tbody :let={sso_domain}>
40
- <.td>
41
- {sso_domain.domain}
42
- </.td>
43
- <.td>
44
- {sso_domain.status}
45
- <span :if={sso_domain.verified_via}>
46
- (via {sso_domain.verified_via} at {Calendar.strftime(
47
- sso_domain.last_verified_at,
48
- "%b %-d, %Y"
49
- )})
50
- </span>
51
- </.td>
52
- <.td actions>
53
- <.delete_button
54
- id={"remove-sso-domain-#{sso_domain.identifier}"}
55
- phx-click="remove-sso-domain"
56
- phx-value-identifier={sso_domain.identifier}
57
- phx-target={@myself}
58
- class="text-sm text-red-600"
59
- data-confirm={"Are you sure you want to remove domain '#{sso_domain.domain}'? All SSO users will be deprovisioned and logged out."}
60
- />
61
- </.td>
62
- </:tbody>
63
- </.table>
64
-
65
- <div class="flex justify-end">
66
- <.button
67
- data-confirm="Are you sure you want to remove this SSO team integration, including all its domains and users?"
68
- id="remove-sso-integration"
69
- phx-click="remove-sso-integration"
70
- phx-target={@myself}
71
- theme="danger"
72
- >
73
- Remove Integration
74
- </.button>
75
- </div>
76
- </div>
77
- <div :if={!@sso_integration} class="text-center py-8 text-gray-500">
78
- <p>No SSO integration configured for this team.</p>
79
- </div>
80
- </div>
81
- """
82
- end
83
-
84
- def handle_event("remove-sso-integration", _, socket) do
85
- :ok = SSO.remove_integration(socket.assigns.sso_integration, force_deprovision?: true)
86
-
87
- socket =
88
- socket
89
- |> assign(sso_integration: nil)
90
- |> push_navigate(
91
- to: Routes.customer_support_team_path(socket, :show, socket.assigns.team.id)
92
- )
93
-
94
- success("SSO integration removed")
95
-
96
- {:noreply, socket}
97
- end
98
-
99
- def handle_event("remove-sso-domain", %{"identifier" => i}, socket) do
100
- domain = Enum.find(socket.assigns.sso_integration.sso_domains, &(&1.identifier == i))
101
- :ok = SSO.Domains.remove(domain, force_deprovision?: true)
102
-
103
- success("SSO domain removed")
104
-
105
- {:noreply, assign(socket, sso_integration: get_sso_integration(socket.assigns.team))}
106
- end
107
-
108
- defp get_sso_integration(team) do
109
- case SSO.get_integration_for(team) do
110
- {:error, :not_found} -> nil
111
- {:ok, integration} -> integration
112
- end
113
- end
114
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible_web/live/customer_support/user.ex DELETED
@@ -1,115 +0,0 @@
1
- defmodule PlausibleWeb.Live.CustomerSupport.User do
2
- @moduledoc """
3
- User coordinator LiveView for Customer Support interface.
4
-
5
- Manages tab-based navigation and delegates rendering to specialized
6
- components: Overview and API Keys.
7
- """
8
- use PlausibleWeb.CustomerSupport.Live
9
-
10
- import Ecto.Query
11
- alias Plausible.Repo
12
-
13
- alias PlausibleWeb.CustomerSupport.User.Components.{
14
- Overview,
15
- Keys
16
- }
17
-
18
- def handle_params(%{"id" => user_id} = params, _uri, socket) do
19
- tab = params["tab"] || "overview"
20
- user = Resource.User.get(user_id)
21
-
22
- if user do
23
- keys_count = count_keys(user)
24
-
25
- socket =
26
- socket
27
- |> assign(:user, user)
28
- |> assign(:tab, tab)
29
- |> assign(:keys_count, keys_count)
30
-
31
- {:noreply, go_to_tab(socket, tab, params, :user, tab_component(tab))}
32
- else
33
- {:noreply, redirect(socket, to: Routes.customer_support_path(socket, :index))}
34
- end
35
- end
36
-
37
- def render(assigns) do
38
- ~H"""
39
- <Layout.layout show_search={false} flash={@flash}>
40
- <div class="overflow-hidden rounded-lg">
41
- <.user_header user={@user} />
42
- <.user_tab_navigation user={@user} tab={@tab} keys_count={@keys_count} />
43
-
44
- <.live_component
45
- module={tab_component(@tab)}
46
- user={@user}
47
- tab={@tab}
48
- id={"user-#{@user.id}-#{@tab}"}
49
- />
50
- </div>
51
- </Layout.layout>
52
- """
53
- end
54
-
55
- defp user_header(assigns) do
56
- ~H"""
57
- <div class="sm:flex sm:items-center sm:justify-between">
58
- <div class="sm:flex sm:space-x-5">
59
- <div class="shrink-0">
60
- <div class="rounded-full p-1 flex items-center justify-center">
61
- <img
62
- src={Plausible.Auth.User.profile_img_url(@user)}
63
- class="w-14 rounded-full bg-gray-300"
64
- />
65
- </div>
66
- </div>
67
- <div class="mt-4 text-center sm:mt-0 sm:pt-1 sm:text-left">
68
- <p class="text-xl font-bold sm:text-2xl">
69
- <div class="flex items-center gap-x-2">
70
- {@user.name}
71
- <span :if={@user.type == :sso} class="bg-green-700 text-gray-100 text-xs p-1 rounded">
72
- SSO
73
- </span>
74
- </div>
75
- </p>
76
- <p class="text-sm font-medium">{@user.email}</p>
77
- </div>
78
- </div>
79
-
80
- <div class="mt-5 flex justify-center sm:mt-0">
81
- <.input_with_clipboard
82
- id="user-identifier"
83
- name="user-identifier"
84
- label="User Identifier"
85
- value={@user.id}
86
- />
87
- </div>
88
- </div>
89
- """
90
- end
91
-
92
- defp user_tab_navigation(assigns) do
93
- ~H"""
94
- <.tab_navigation tab={@tab}>
95
- <:tabs>
96
- <.tab to="overview" tab={@tab}>Overview</.tab>
97
- <.tab to="keys" tab={@tab}>
98
- API Keys ({@keys_count})
99
- </.tab>
100
- </:tabs>
101
- </.tab_navigation>
102
- """
103
- end
104
-
105
- defp tab_component("overview"), do: Overview
106
- defp tab_component("keys"), do: Keys
107
- defp tab_component(_), do: Overview
108
-
109
- defp count_keys(user) do
110
- from(api_key in Plausible.Auth.ApiKey,
111
- where: api_key.user_id == ^user.id
112
- )
113
- |> Repo.aggregate(:count)
114
- end
115
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible_web/live/customer_support/user/components/keys.ex DELETED
@@ -1,52 +0,0 @@
1
- defmodule PlausibleWeb.CustomerSupport.User.Components.Keys do
2
- @moduledoc """
3
- User API keys component - handles displaying user's API keys
4
- """
5
- use PlausibleWeb, :live_component
6
- import Ecto.Query
7
- alias Plausible.Repo
8
-
9
- def update(%{user: user}, socket) do
10
- keys = keys(user)
11
- {:ok, assign(socket, user: user, keys: keys)}
12
- end
13
-
14
- def render(assigns) do
15
- ~H"""
16
- <div class="mt-8">
17
- <.table rows={@keys}>
18
- <:thead>
19
- <.th>Team</.th>
20
- <.th>Name</.th>
21
- <.th>Scopes</.th>
22
- <.th>Prefix</.th>
23
- </:thead>
24
- <:tbody :let={api_key}>
25
- <.td :if={is_nil(api_key.team)}>N/A</.td>
26
- <.td :if={api_key.team}>
27
- <.styled_link patch={
28
- Routes.customer_support_team_path(PlausibleWeb.Endpoint, :show, api_key.team.id)
29
- }>
30
- {api_key.team.name}
31
- </.styled_link>
32
- </.td>
33
- <.td>{api_key.name}</.td>
34
- <.td>
35
- {api_key.scopes}
36
- </.td>
37
- <.td>{api_key.key_prefix}</.td>
38
- </:tbody>
39
- </.table>
40
- </div>
41
- """
42
- end
43
-
44
- defp keys(user) do
45
- from(
46
- key in Plausible.Auth.ApiKey,
47
- where: key.user_id == ^user.id,
48
- preload: :team
49
- )
50
- |> Repo.all()
51
- end
52
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible_web/live/customer_support/user/components/overview.ex DELETED
@@ -1,125 +0,0 @@
1
- defmodule PlausibleWeb.CustomerSupport.User.Components.Overview do
2
- @moduledoc """
3
- User overview component - handles user settings, team memberships, and user management
4
- """
5
- use PlausibleWeb, :live_component
6
- import PlausibleWeb.CustomerSupport.Live
7
-
8
- alias Plausible.Auth.TOTP
9
-
10
- def update(%{user: user}, socket) do
11
- form = user |> Plausible.Auth.User.changeset() |> to_form()
12
- {:ok, assign(socket, user: user, form: form)}
13
- end
14
-
15
- def render(assigns) do
16
- ~H"""
17
- <div class="mt-8">
18
- <div class="mb-8">
19
- <div class="mt-4 flex items-center justify-between">
20
- <div>
21
- <span class="text-sm">
22
- Two-Factor Authentication:
23
- <span class={[
24
- "text-sm font-medium",
25
- if(TOTP.enabled?(@user), do: "text-green-600", else: "text-gray-500")
26
- ]}>
27
- {if TOTP.enabled?(@user), do: "Enabled", else: "Disabled"}
28
- </span>
29
- </span>
30
- </div>
31
- </div>
32
- </div>
33
-
34
- <.table rows={@user.team_memberships}>
35
- <:thead>
36
- <.th>Team</.th>
37
- <.th>Role</.th>
38
- </:thead>
39
- <:tbody :let={membership}>
40
- <.td>
41
- <.styled_link patch={
42
- Routes.customer_support_team_path(PlausibleWeb.Endpoint, :show, membership.team.id)
43
- }>
44
- {membership.team.name}
45
- </.styled_link>
46
- </.td>
47
- <.td>{membership.role}</.td>
48
- </:tbody>
49
- </.table>
50
-
51
- <.form :let={f} for={@form} phx-target={@myself} phx-submit="save-user" class="mt-8">
52
- <.input type="textarea" field={f[:notes]} label="Notes" />
53
- <div class="flex justify-between">
54
- <div>
55
- <.button phx-target={@myself} type="submit">
56
- Save
57
- </.button>
58
- </div>
59
- <div class="flex gap-2">
60
- <.button
61
- :if={TOTP.enabled?(@user)}
62
- phx-target={@myself}
63
- phx-click="force-disable-2fa"
64
- data-confirm="Are you sure you want to force disable 2FA for this user? This action cannot be undone."
65
- theme="danger"
66
- >
67
- Force Disable 2FA
68
- </.button>
69
- <.button
70
- phx-target={@myself}
71
- phx-click="delete-user"
72
- data-confirm="Are you sure you want to delete this user?"
73
- theme="danger"
74
- >
75
- Delete User
76
- </.button>
77
- </div>
78
- </div>
79
- </.form>
80
- </div>
81
- """
82
- end
83
-
84
- def handle_event("delete-user", _params, socket) do
85
- user = socket.assigns.user
86
-
87
- case Plausible.Auth.delete_user(user) do
88
- {:ok, _} ->
89
- navigate_with_success(Routes.customer_support_path(socket, :index), "User deleted")
90
- {:noreply, socket}
91
-
92
- {:error, :active_subscription} ->
93
- failure("Cannot delete user with active subscription")
94
- {:noreply, socket}
95
-
96
- {:error, reason} ->
97
- failure("Failed to delete user: #{inspect(reason)}")
98
- {:noreply, socket}
99
- end
100
- end
101
-
102
- def handle_event("force-disable-2fa", _params, socket) do
103
- user = socket.assigns.user
104
-
105
- {:ok, updated_user} = TOTP.force_disable(user)
106
- send(self(), {:success, "2FA has been force disabled for this user"})
107
- {:noreply, assign(socket, user: updated_user)}
108
- end
109
-
110
- def handle_event("save-user", %{"user" => params}, socket) do
111
- user = socket.assigns.user
112
-
113
- case Plausible.Auth.User.changeset(user, params) |> Plausible.Repo.update() do
114
- {:ok, updated_user} ->
115
- form = updated_user |> Plausible.Auth.User.changeset() |> to_form()
116
- success("User updated successfully")
117
- {:noreply, assign(socket, user: updated_user, form: form)}
118
-
119
- {:error, changeset} ->
120
- form = changeset |> to_form()
121
- failure("Failed to update user")
122
- {:noreply, assign(socket, form: form)}
123
- end
124
- end
125
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible_web/live/funnel_settings.ex DELETED
@@ -1,175 +0,0 @@
1
- defmodule PlausibleWeb.Live.FunnelSettings do
2
- @moduledoc """
3
- LiveView allowing listing, creating and deleting funnels.
4
- """
5
- use PlausibleWeb, :live_view
6
-
7
- use Plausible.Funnel
8
-
9
- alias Plausible.{Goals, Funnels, Teams}
10
-
11
- def mount(
12
- _params,
13
- %{"site_id" => site_id, "domain" => domain},
14
- socket
15
- ) do
16
- socket =
17
- socket
18
- |> assign_new(:site, fn %{current_user: current_user} ->
19
- Plausible.Sites.get_for_user!(current_user, domain,
20
- roles: [
21
- :owner,
22
- :admin,
23
- :editor,
24
- :super_admin
25
- ]
26
- )
27
- end)
28
- |> assign_new(:all_funnels, fn %{site: %{id: ^site_id} = site} ->
29
- Funnels.list(site)
30
- end)
31
- |> assign_new(:goal_count, fn %{site: site} ->
32
- Goals.count(site)
33
- end)
34
-
35
- {:ok,
36
- assign(socket,
37
- site_team: Teams.with_subscription(socket.assigns.site.team),
38
- domain: domain,
39
- displayed_funnels: socket.assigns.all_funnels,
40
- setup_funnel?: false,
41
- filter_text: "",
42
- funnel_id: nil
43
- )}
44
- end
45
-
46
- # Flash sharing with live views within dead views can be done via re-rendering the flash partial.
47
- # Normally, we'd have to use live_patch which we can't do with views unmounted at the router it seems.
48
- def render(assigns) do
49
- ~H"""
50
- <div id="funnel-settings-main">
51
- <.flash_messages flash={@flash} />
52
-
53
- <.tile
54
- docs="funnel-analysis"
55
- feature_mod={Plausible.Billing.Feature.Funnels}
56
- feature_toggle?={true}
57
- show_content?={!Plausible.Billing.Feature.Funnels.opted_out?(@site)}
58
- site={@site}
59
- current_user={@current_user}
60
- current_team={@site_team}
61
- >
62
- <:title>
63
- Funnels
64
- </:title>
65
- <:subtitle :if={Enum.count(@all_funnels) > 0}>
66
- Compose goals into funnels to track user flows and conversion rates.
67
- </:subtitle>
68
- <%= if @setup_funnel? do %>
69
- {live_render(
70
- @socket,
71
- PlausibleWeb.Live.FunnelSettings.Form,
72
- id: "funnels-form",
73
- session: %{
74
- "domain" => @domain,
75
- "funnel_id" => @funnel_id
76
- }
77
- )}
78
- <% end %>
79
- <div :if={@goal_count >= Funnel.min_steps()}>
80
- <.live_component
81
- module={PlausibleWeb.Live.FunnelSettings.List}
82
- id="funnels-list"
83
- funnels={@displayed_funnels}
84
- filter_text={@filter_text}
85
- />
86
- </div>
87
-
88
- <div
89
- :if={@goal_count < Funnel.min_steps()}
90
- class="flex flex-col items-center justify-center pt-5 pb-6 max-w-md mx-auto"
91
- >
92
- <h3 class="text-center text-base font-medium text-gray-900 dark:text-gray-100 leading-7">
93
- Ready to dig into user flows?
94
- </h3>
95
- <p class="text-center text-sm mt-1 text-gray-500 dark:text-gray-400 leading-5 text-pretty">
96
- Set up a few goals like <.highlighted>Signup</.highlighted>, <.highlighted>Visit /</.highlighted>, or
97
- <.highlighted>Scroll 50% on /blog/*</.highlighted>
98
- first, then return here to build your first funnel.
99
- </p>
100
- <.button_link
101
- class="mt-4"
102
- href={PlausibleWeb.Router.Helpers.site_path(@socket, :settings_goals, @domain)}
103
- >
104
- Set up goals →
105
- </.button_link>
106
- </div>
107
- </.tile>
108
- </div>
109
- """
110
- end
111
-
112
- def handle_event("reset-filter-text", _params, socket) do
113
- {:noreply, assign(socket, filter_text: "", displayed_funnels: socket.assigns.all_funnels)}
114
- end
115
-
116
- def handle_event("filter", %{"filter-text" => filter_text}, socket) do
117
- new_list =
118
- PlausibleWeb.Live.Components.ComboBox.StaticSearch.suggest(
119
- filter_text,
120
- socket.assigns.all_funnels,
121
- to_string: & &1.name
122
- )
123
-
124
- {:noreply, assign(socket, displayed_funnels: new_list, filter_text: filter_text)}
125
- end
126
-
127
- def handle_event("add-funnel", _value, socket) do
128
- {:noreply, assign(socket, setup_funnel?: true)}
129
- end
130
-
131
- def handle_event("edit-funnel", %{"funnel-id" => id}, socket) do
132
- {:noreply, assign(socket, setup_funnel?: true, funnel_id: String.to_integer(id))}
133
- end
134
-
135
- def handle_event("delete-funnel", %{"funnel-id" => id}, socket) do
136
- site =
137
- Plausible.Sites.get_for_user!(
138
- socket.assigns.current_user,
139
- socket.assigns.domain,
140
- roles: [:owner, :admin, :editor]
141
- )
142
-
143
- id = String.to_integer(id)
144
- :ok = Funnels.delete(site, id)
145
- socket = put_live_flash(socket, :success, "Funnel deleted successfully")
146
-
147
- {:noreply,
148
- assign(socket,
149
- all_funnels: Enum.reject(socket.assigns.all_funnels, &(&1.id == id)),
150
- displayed_funnels: Enum.reject(socket.assigns.displayed_funnels, &(&1.id == id))
151
- )}
152
- end
153
-
154
- def handle_info({:funnel_saved, _funnel}, socket) do
155
- socket = put_live_flash(socket, :success, "Funnel saved successfully")
156
-
157
- funnels = Funnels.list(socket.assigns.site)
158
-
159
- {:noreply,
160
- assign(socket,
161
- setup_funnel?: false,
162
- all_funnels: funnels,
163
- funnel_id: nil,
164
- displayed_funnels: funnels
165
- )}
166
- end
167
-
168
- def handle_info(:cancel_setup_funnel, socket) do
169
- {:noreply, assign(socket, setup_funnel?: false, funnel_id: nil)}
170
- end
171
-
172
- def handle_info({:feature_toggled, flash_msg, updated_site}, socket) do
173
- {:noreply, assign(put_flash(socket, :success, flash_msg), site: updated_site)}
174
- end
175
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible_web/live/funnel_settings/form.ex DELETED
@@ -1,484 +0,0 @@
1
- defmodule PlausibleWeb.Live.FunnelSettings.Form do
2
- @moduledoc """
3
- Phoenix LiveComponent that renders a form used for setting up funnels.
4
- Makes use of dynamically placed `PlausibleWeb.Live.FunnelSettings.ComboBox` components
5
- to allow building searchable funnel definitions out of list of goals available.
6
- """
7
-
8
- use PlausibleWeb, :live_view
9
- use Plausible.Funnel
10
-
11
- import PlausibleWeb.Live.Components.Form
12
- alias Plausible.{Goals, Funnels}
13
- alias Plausible.Stats.QueryBuilder
14
-
15
- def mount(_params, %{"domain" => domain} = session, socket) do
16
- site =
17
- Plausible.Sites.get_for_user!(socket.assigns.current_user, domain,
18
- roles: [
19
- :owner,
20
- :admin,
21
- :editor,
22
- :super_admin
23
- ]
24
- )
25
-
26
- # We'll have the options trimmed to only the data we care about, to keep
27
- # it minimal at the socket assigns, yet, we want to retain specific %Goal{}
28
- # fields, so that `String.Chars` protocol and `Funnels.ephemeral_definition/3`
29
- # are applicable downstream.
30
- goals =
31
- site
32
- |> Goals.for_site()
33
- |> Enum.map(fn goal ->
34
- {goal.id,
35
- struct!(
36
- Plausible.Goal,
37
- Map.take(goal, [:id, :display_name, :event_name, :page_path, :currency])
38
- )}
39
- end)
40
-
41
- socket =
42
- socket
43
- |> assign(goals: goals, site: site, evaluation_result: nil)
44
- |> prepare_socket(site, session["funnel_id"])
45
-
46
- {:ok, socket}
47
- end
48
-
49
- def render(assigns) do
50
- ~H"""
51
- <div
52
- class="fixed inset-0 bg-gray-500/75 transition-opacity z-50"
53
- phx-window-keydown="cancel-add-funnel"
54
- phx-key="Escape"
55
- >
56
- </div>
57
- <div class="fixed inset-0 flex items-center justify-center mt-16 z-50 overlofw-y-auto overflow-x-hidden">
58
- <div class="md:w-2/3 max-w-lg h-full">
59
- <div id="funnel-form">
60
- <.form
61
- :let={f}
62
- for={@form}
63
- phx-change="validate"
64
- phx-submit="save"
65
- phx-target="#funnel-form"
66
- phx-click-away="cancel-add-funnel"
67
- onkeydown="return event.key != 'Enter';"
68
- class="bg-white dark:bg-gray-900 shadow-2xl rounded-lg px-8 pt-6 pb-8 mb-4 mt-8"
69
- >
70
- <.title class="mb-6">
71
- {if @funnel, do: "Edit", else: "Add"} funnel
72
- </.title>
73
-
74
- <.input
75
- field={f[:name]}
76
- phx-debounce={200}
77
- autocomplete="off"
78
- placeholder="e.g. From Blog to Purchase"
79
- autofocus
80
- label="Funnel name"
81
- />
82
-
83
- <div class="mt-6 flex items-center justify-between gap-4">
84
- <span class="text-sm font-medium text-gray-900 dark:text-gray-100">
85
- Allow other activity between funnel steps
86
- </span>
87
- <div class="flex items-center gap-3">
88
- <.toggle_switch
89
- id="toggle-strict-order"
90
- id_suffix="switch"
91
- checked={!@strict_order?}
92
- phx-click="toggle-strict-order"
93
- />
94
- </div>
95
- </div>
96
-
97
- <div id="steps-builder" class="mt-6">
98
- <.label>
99
- Funnel steps
100
- </.label>
101
-
102
- <div :for={step_idx <- @step_ids} class="flex my-3">
103
- <div class="w-2/5 flex-1">
104
- <.live_component
105
- selected={find_preselected(@funnel, @funnel_modified?, step_idx)}
106
- submit_name={"funnel[steps][#{step_idx}][goal_id]"}
107
- module={PlausibleWeb.Live.Components.ComboBox}
108
- suggest_fun={&PlausibleWeb.Live.Components.ComboBox.StaticSearch.suggest/2}
109
- on_selection_made={
110
- fn value, by_id ->
111
- send(self(), {:selection_made, %{submit_value: value, by: by_id}})
112
- end
113
- }
114
- id={"step-#{step_idx}"}
115
- options={reject_already_selected(@goals, @selections_made)}
116
- />
117
- </div>
118
-
119
- <div class="w-min inline-flex items-center align-middle">
120
- <.remove_step_button
121
- :if={length(@step_ids) > Funnel.min_steps()}
122
- step_idx={step_idx}
123
- />
124
- </div>
125
-
126
- <div class="w-4/12 mt-1 ml-4 text-gray-500 dark:text-gray-400">
127
- <.evaluation
128
- :if={@evaluation_result}
129
- result={@evaluation_result}
130
- at={Enum.find_index(@step_ids, &(&1 == step_idx))}
131
- />
132
- </div>
133
- </div>
134
-
135
- <div class="flex flex-col gap-y-4 mt-6">
136
- <.add_step_button :if={
137
- length(@step_ids) < Funnel.max_steps() and
138
- map_size(@selections_made) < length(@goals)
139
- } />
140
- <p id="funnel-eval" class="text-gray-800 dark:text-gray-200 text-sm">
141
- <%= if @evaluation_result do %>
142
- Last month conversion rate: <strong><%= List.last(@evaluation_result.steps).conversion_rate %></strong>%
143
- <% end %>
144
- </p>
145
- </div>
146
-
147
- <.button
148
- id="save"
149
- type="submit"
150
- class="w-full"
151
- disabled={
152
- has_steps_errors?(f) or map_size(@selections_made) < Funnel.min_steps() or
153
- length(@step_ids) > map_size(@selections_made)
154
- }
155
- >
156
- <span>{if @funnel, do: "Update", else: "Add"} funnel</span>
157
- </.button>
158
- </div>
159
- </.form>
160
- </div>
161
- </div>
162
- </div>
163
- """
164
- end
165
-
166
- attr(:step_idx, :integer, required: true)
167
-
168
- def remove_step_button(assigns) do
169
- ~H"""
170
- <div class="inline-flex items-center ml-2 text-red-500">
171
- <svg
172
- id={"remove-step-#{@step_idx}"}
173
- class="feather feather-sm cursor-pointer"
174
- xmlns="http://www.w3.org/2000/svg"
175
- viewBox="0 0 24 24"
176
- fill="none"
177
- stroke="currentColor"
178
- stroke-width="2"
179
- stroke-linecap="round"
180
- stroke-linejoin="round"
181
- phx-click="remove-step"
182
- phx-value-step-idx={@step_idx}
183
- >
184
- <polyline points="3 6 5 6 21 6"></polyline>
185
- <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2">
186
- </path>
187
- <line x1="10" y1="11" x2="10" y2="17"></line>
188
- <line x1="14" y1="11" x2="14" y2="17"></line>
189
- </svg>
190
- </div>
191
- """
192
- end
193
-
194
- def add_step_button(assigns) do
195
- ~H"""
196
- <a class="text-indigo-500 text-sm font-medium cursor-pointer" phx-click="add-step">
197
- + Add another step
198
- </a>
199
- """
200
- end
201
-
202
- attr(:at, :integer, required: true)
203
- attr(:result, :map, required: true)
204
-
205
- def evaluation(assigns) do
206
- ~H"""
207
- <span class="text-sm" id={"step-eval-#{@at}"}>
208
- <% step = Enum.at(@result.steps, @at) %>
209
- <span :if={step && @at == 0}>
210
- <span
211
- class="border-dotted border-b border-gray-400 "
212
- tooltip="Sample calculation for last month"
213
- >
214
- <span class="hidden md:inline">Visitors: </span><strong><%= PlausibleWeb.StatsView.large_number_format(@result.entering_visitors) %></strong>
215
- </span>
216
- </span>
217
- <span :if={step && @at > 0}>
218
- <span class="hidden md:inline">Dropoff: </span><strong><%= Map.get(step, :dropoff_percentage) %>%</strong>
219
- </span>
220
- </span>
221
- """
222
- end
223
-
224
- def handle_event("add-step", _value, socket) do
225
- step_ids = socket.assigns.step_ids
226
-
227
- socket = assign(socket, funnel_modified?: true)
228
-
229
- if length(step_ids) < Funnel.max_steps() do
230
- first_free_idx = find_sequence_break(step_ids)
231
- new_ids = step_ids ++ [first_free_idx]
232
- {:noreply, assign(socket, step_ids: new_ids)}
233
- else
234
- {:noreply, socket}
235
- end
236
- end
237
-
238
- def handle_event("remove-step", %{"step-idx" => idx}, socket) do
239
- idx = String.to_integer(idx)
240
- step_ids = List.delete(socket.assigns.step_ids, idx)
241
- selections_made = drop_selection(socket.assigns.selections_made, idx)
242
-
243
- send(self(), :evaluate_funnel)
244
-
245
- {:noreply,
246
- assign(socket, step_ids: step_ids, selections_made: selections_made, funnel_modified?: true)}
247
- end
248
-
249
- def handle_event("toggle-strict-order", _params, socket) do
250
- strict_order? = !socket.assigns.strict_order?
251
- send(self(), :evaluate_funnel)
252
-
253
- {:noreply, assign(socket, strict_order?: strict_order?)}
254
- end
255
-
256
- def handle_event("validate", %{"funnel" => params}, socket) do
257
- strict_order? = socket.assigns.strict_order?
258
-
259
- steps_from_assigns =
260
- socket.assigns.step_ids
261
- |> Enum.reduce([], fn step_id, acc ->
262
- goal = Map.get(socket.assigns.selections_made, "step-#{step_id}")
263
- if goal, do: [%{"goal_id" => goal.id} | acc], else: acc
264
- end)
265
- |> Enum.reverse()
266
-
267
- changeset =
268
- socket.assigns.site
269
- |> Funnels.create_changeset(
270
- params["name"],
271
- steps_from_assigns,
272
- strict_order?: strict_order?
273
- )
274
- |> Map.put(:action, :validate)
275
-
276
- {:noreply, assign(socket, form: to_form(changeset))}
277
- end
278
-
279
- def handle_event(
280
- "save",
281
- %{"funnel" => params},
282
- %{assigns: %{site: site, funnel: funnel, strict_order?: strict_order?}} = socket
283
- ) do
284
- steps = Enum.map(params["steps"], fn {_idx, payload} -> payload end)
285
-
286
- save_fn =
287
- case funnel do
288
- %Plausible.Funnel{} ->
289
- fn -> Funnels.update(funnel, params["name"], steps, strict_order?: strict_order?) end
290
-
291
- nil ->
292
- fn -> Funnels.create(site, params["name"], steps, strict_order?: strict_order?) end
293
- end
294
-
295
- case save_fn.() do
296
- {:ok, funnel} ->
297
- send(
298
- socket.parent_pid,
299
- {:funnel_saved, Map.put(funnel, :steps_count, length(steps))}
300
- )
301
-
302
- {:noreply, socket}
303
-
304
- {:error, changeset} ->
305
- {:noreply,
306
- assign(socket,
307
- form: to_form(Map.put(changeset, :action, :validate))
308
- )}
309
- end
310
- end
311
-
312
- def handle_event("cancel-add-funnel", _value, socket) do
313
- send(socket.parent_pid, :cancel_setup_funnel)
314
- {:noreply, socket}
315
- end
316
-
317
- def handle_info({:selection_made, %{submit_value: goal_id, by: combo_box}}, socket) do
318
- selections_made = store_selection(socket.assigns, combo_box, goal_id)
319
-
320
- send(self(), :evaluate_funnel)
321
-
322
- {:noreply,
323
- assign(socket,
324
- selections_made: selections_made
325
- )}
326
- end
327
-
328
- def handle_info(:evaluate_funnel, socket) do
329
- {:noreply, evaluate_funnel(socket)}
330
- end
331
-
332
- defp evaluate_funnel(%{assigns: %{selections_made: selections_made}} = socket)
333
- when map_size(selections_made) < Funnel.min_steps() do
334
- socket
335
- end
336
-
337
- defp evaluate_funnel(
338
- %{
339
- assigns: %{
340
- site: site,
341
- selections_made: selections_made,
342
- strict_order?: strict_order?
343
- }
344
- } = socket
345
- ) do
346
- with {:ok, {definition, query}} <-
347
- build_ephemeral_funnel(site, selections_made, strict_order?: strict_order?),
348
- {:ok, funnel} <- Plausible.Stats.funnel(site, query, definition) do
349
- assign(socket, evaluation_result: funnel)
350
- else
351
- _ ->
352
- socket
353
- end
354
- end
355
-
356
- defp build_ephemeral_funnel(site, selections_made, opts) do
357
- steps =
358
- selections_made
359
- |> Enum.sort_by(&elem(&1, 0))
360
- |> Enum.map(fn {_, goal} ->
361
- %{
362
- "goal_id" => goal.id,
363
- "goal" => %{
364
- "id" => goal.id,
365
- "event_name" => goal.event_name,
366
- "page_path" => goal.page_path
367
- }
368
- }
369
- end)
370
-
371
- definition =
372
- Funnels.ephemeral_definition(
373
- site,
374
- "Test funnel",
375
- steps,
376
- opts
377
- )
378
-
379
- query =
380
- QueryBuilder.build!(site,
381
- metrics: [:pageviews],
382
- input_date_range: :month
383
- )
384
-
385
- {:ok, {definition, query}}
386
- end
387
-
388
- defp find_sequence_break(input) do
389
- input
390
- |> Enum.sort()
391
- |> Enum.with_index(1)
392
- |> Enum.reduce_while(nil, fn {x, order}, _ ->
393
- if x != order do
394
- {:halt, order}
395
- else
396
- {:cont, order + 1}
397
- end
398
- end)
399
- end
400
-
401
- defp has_steps_errors?(f) do
402
- not f.source.valid?
403
- end
404
-
405
- defp get_goal(assigns, id) do
406
- assigns
407
- |> Map.fetch!(:goals)
408
- |> Enum.find_value(fn
409
- {goal_id, goal} when goal_id == id -> goal
410
- _ -> nil
411
- end)
412
- end
413
-
414
- defp store_selection(assigns, combo_box, goal_id) do
415
- Map.put(assigns.selections_made, combo_box, get_goal(assigns, goal_id))
416
- end
417
-
418
- defp drop_selection(selections_made, step_idx) do
419
- step_input_id = "step-#{step_idx}"
420
- Map.delete(selections_made, step_input_id)
421
- end
422
-
423
- defp reject_already_selected(goals, selections_made) do
424
- selection_ids =
425
- Enum.map(selections_made, fn
426
- {_, %{id: goal_id}} -> goal_id
427
- end)
428
-
429
- Enum.reject(goals, fn {goal_id, _} -> goal_id in selection_ids end)
430
- end
431
-
432
- defp find_preselected(%Funnel{} = funnel, false, idx) do
433
- if step = Enum.at(funnel.steps, idx - 1) do
434
- {step.goal.id, to_string(step.goal)}
435
- end
436
- end
437
-
438
- defp find_preselected(_, _, _), do: nil
439
-
440
- defp prepare_socket(socket, site, funnel_id) when is_integer(funnel_id) do
441
- funnel = Funnels.get(site.id, funnel_id)
442
-
443
- form =
444
- funnel
445
- |> Funnels.edit_changeset(
446
- funnel.name,
447
- Enum.map(funnel.steps, &%{goal_id: &1.goal.id}),
448
- strict_order?: funnel.strict_order
449
- )
450
- |> to_form()
451
-
452
- selections_made =
453
- Enum.reduce(Enum.with_index(funnel.steps, 1), %{}, fn {step, idx}, acc ->
454
- Map.put(acc, "step-#{idx}", step.goal)
455
- end)
456
-
457
- socket =
458
- assign(
459
- socket,
460
- form: form,
461
- funnel: funnel,
462
- strict_order?: funnel.strict_order,
463
- funnel_modified?: false,
464
- selections_made: selections_made,
465
- step_ids: Enum.to_list(1..Enum.count(funnel.steps))
466
- )
467
-
468
- evaluate_funnel(socket)
469
- end
470
-
471
- defp prepare_socket(socket, site, nil) do
472
- form = to_form(Funnels.create_changeset(site, "", []))
473
-
474
- assign(
475
- socket,
476
- form: form,
477
- funnel: nil,
478
- strict_order?: false,
479
- funnel_modified?: false,
480
- selections_made: Map.new(),
481
- step_ids: Enum.to_list(1..Funnel.min_steps())
482
- )
483
- end
484
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible_web/live/funnel_settings/list.ex DELETED
@@ -1,89 +0,0 @@
1
- defmodule PlausibleWeb.Live.FunnelSettings.List do
2
- @moduledoc """
3
- Phoenix LiveComponent module that renders a list of funnels with their names
4
- and the number of steps they have.
5
-
6
- Each funnel is displayed with a delete button, which triggers a confirmation
7
- message before deleting the funnel from the UI. If there are no funnels
8
- configured for the site, a message is displayed indicating so.
9
- """
10
- use PlausibleWeb, :live_component
11
-
12
- def render(assigns) do
13
- assigns = assign(assigns, :searching?, String.trim(assigns.filter_text) != "")
14
-
15
- ~H"""
16
- <div class="flex flex-col gap-4 sm:gap-6">
17
- <%= if @searching? or Enum.count(@funnels) > 0 do %>
18
- <.filter_bar filter_text={@filter_text} placeholder="Search Funnels">
19
- <.button id="add-funnel-button" phx-click="add-funnel" mt?={false}>
20
- Add funnel
21
- </.button>
22
- </.filter_bar>
23
- <% end %>
24
-
25
- <%= if Enum.count(@funnels) > 0 do %>
26
- <.table rows={@funnels}>
27
- <:tbody :let={funnel}>
28
- <.td truncate>
29
- <span class="font-medium">{funnel.name}</span>
30
- </.td>
31
- <.td hide_on_mobile>
32
- <span class="text-gray-500 dark:text-gray-400">
33
- {funnel.steps_count}-step funnel
34
- </span>
35
- </.td>
36
- <.td actions>
37
- <.edit_button phx-click="edit-funnel" phx-value-funnel-id={funnel.id} />
38
- <.delete_button
39
- id={"delete-funnel-#{funnel.id}"}
40
- phx-click="delete-funnel"
41
- phx-value-funnel-id={funnel.id}
42
- class="text-sm text-red-600"
43
- data-confirm={"Are you sure you want to remove funnel '#{funnel.name}'? This will just affect the UI, all of your analytics data will stay intact."}
44
- />
45
- </.td>
46
- </:tbody>
47
- </.table>
48
- <% else %>
49
- <.no_search_results :if={@searching?} />
50
- <.empty_state :if={not @searching?} />
51
- <% end %>
52
- </div>
53
- """
54
- end
55
-
56
- defp no_search_results(assigns) do
57
- ~H"""
58
- <p class="mt-12 mb-8 text-sm text-center">
59
- No funnels found for this site. Please refine or
60
- <.styled_link phx-click="reset-filter-text" id="reset-filter-hint">
61
- reset your search.
62
- </.styled_link>
63
- </p>
64
- """
65
- end
66
-
67
- defp empty_state(assigns) do
68
- ~H"""
69
- <div class="flex flex-col items-center justify-center pt-5 pb-6 max-w-md mx-auto">
70
- <h3 class="text-center text-base font-medium text-gray-900 dark:text-gray-100 leading-7">
71
- Create your first funnel
72
- </h3>
73
- <p class="text-center text-sm mt-1 text-gray-500 dark:text-gray-400 leading-5 text-pretty">
74
- Compose goals into funnels to track user flows and conversion rates.
75
- <.styled_link href="https://plausible.io/docs/funnel-analysis" target="_blank">
76
- Learn more
77
- </.styled_link>
78
- </p>
79
- <.button
80
- id="add-funnel-button"
81
- phx-click="add-funnel"
82
- class="mt-4"
83
- >
84
- Add funnel
85
- </.button>
86
- </div>
87
- """
88
- end
89
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible_web/live/sso_management.ex DELETED
@@ -1,740 +0,0 @@
1
- defmodule PlausibleWeb.Live.SSOManagement do
2
- @moduledoc """
3
- Live view for SSO setup and management.
4
- """
5
- use PlausibleWeb, :live_view
6
-
7
- alias Plausible.Auth.SSO
8
- alias Plausible.Teams
9
-
10
- alias PlausibleWeb.Router.Helpers, as: Routes
11
- use Plausible.Auth.SSO.Domain.Status
12
-
13
- @refresh_integration_interval :timer.seconds(5)
14
-
15
- def mount(_params, _session, socket) do
16
- socket = load_integration(socket, socket.assigns.current_team)
17
-
18
- if connected?(socket) do
19
- Process.send_after(self(), :refresh_integration, @refresh_integration_interval)
20
- end
21
-
22
- {:ok, route_mode(socket)}
23
- end
24
-
25
- def render(assigns) do
26
- ~H"""
27
- <.flash_messages flash={@flash} />
28
-
29
- <.tile
30
- :if={@mode != :manage}
31
- docs={if @mode in [:domain_setup, :domain_verify], do: "sso#sso-domains", else: "sso"}
32
- >
33
- <:title>
34
- <a id="sso-config">Single Sign-On</a>
35
- </:title>
36
- <:subtitle>
37
- Configure and manage Single Sign-On for your team.
38
- </:subtitle>
39
-
40
- <.init_view :if={@mode == :init} current_team={@current_team} />
41
-
42
- <.init_setup_view
43
- :if={@mode == :init_setup}
44
- integration={@integration}
45
- current_team={@current_team}
46
- />
47
-
48
- <.idp_form_view
49
- :if={@mode == :idp_form}
50
- integration={@integration}
51
- config_changeset={@config_changeset}
52
- />
53
-
54
- <.domain_setup_view
55
- :if={@mode == :domain_setup}
56
- integration={@integration}
57
- domain_changeset={@domain_changeset}
58
- />
59
-
60
- <.domain_verify_view :if={@mode == :domain_verify} domain={@domain} />
61
- </.tile>
62
-
63
- <.manage_view
64
- :if={@mode == :manage}
65
- integration={@integration}
66
- current_team={@current_team}
67
- can_toggle_force_sso?={@can_toggle_force_sso?}
68
- force_sso_warning={@force_sso_warning}
69
- policy_changeset={@policy_changeset}
70
- role_options={@role_options}
71
- domain_delete_checks={@domain_delete_checks}
72
- />
73
- """
74
- end
75
-
76
- def init_view(assigns) do
77
- ~H"""
78
- <form id="sso-init" for={} phx-submit="init-sso">
79
- <p class="text-sm">
80
- Single Sign-On (SSO) enables team members to sign in without having to register an account. For more details, <.styled_link href="https://plausible.io/docs/sso">see our documentation</.styled_link>.
81
- </p>
82
-
83
- <.button type="submit">Start Configuring SSO</.button>
84
- </form>
85
- """
86
- end
87
-
88
- def init_setup_view(assigns) do
89
- ~H"""
90
- <div class="flex-col space-y-6">
91
- <p class="text-sm">
92
- Use the following parameters when configuring your Identity Provider of choice:
93
- </p>
94
-
95
- <.input_with_clipboard
96
- id="sp-acs-url"
97
- name="sp-acs-url"
98
- label="ACS URL / Single Sign-On URL / Reply URL"
99
- value={saml_acs_url(@integration)}
100
- />
101
-
102
- <.form id="sso-sp-config" for={} class="flex-col space-y-4">
103
- <.input_with_clipboard
104
- id="sp-entity-id"
105
- name="sp-entity-id"
106
- label="Entity ID / Audience URI / Identifier"
107
- value={SSO.SAMLConfig.entity_id(@integration)}
108
- />
109
- </.form>
110
-
111
- <div class="flex-col space-y-3">
112
- <p class="text-sm">Following attribute mappings must be set up at Identity Provider:</p>
113
-
114
- <ul role="list" class="list-disc leading-6 text-sm ml-8">
115
- <li :for={param <- ["email", "first_name", "last_name"]}>
116
- <code>{param}</code>
117
- </li>
118
- </ul>
119
- </div>
120
-
121
- <form id="sso-idp-form" for={} phx-submit="show-idp-form">
122
- <p class="text-sm">Click below to start setting up Single Sign-On for your team.</p>
123
-
124
- <.button type="submit">Start Configuring</.button>
125
- </form>
126
- </div>
127
- """
128
- end
129
-
130
- def idp_form_view(assigns) do
131
- ~H"""
132
- <div class="flex-col space-y-6">
133
- <p class="text-sm">
134
- Enter configuration details of Identity Provider after configuring it:
135
- </p>
136
-
137
- <.form
138
- :let={f}
139
- id="sso-idp-config"
140
- for={@config_changeset}
141
- class="flex-col space-y-4"
142
- phx-submit="update-integration"
143
- >
144
- <.input
145
- field={f[:idp_signin_url]}
146
- label="SSO URL / Sign-on URL / Login URL"
147
- placeholder="<URL>"
148
- />
149
-
150
- <.input
151
- field={f[:idp_entity_id]}
152
- label="Entity ID / Issuer / Identifier"
153
- placeholder="<Entity ID>"
154
- />
155
-
156
- <.input field={f[:idp_cert_pem]} type="textarea" label="Signing Certificate in PEM format" />
157
-
158
- <.button type="submit">Save</.button>
159
- </.form>
160
- </div>
161
- """
162
- end
163
-
164
- def domain_setup_view(assigns) do
165
- ~H"""
166
- <div class="flex-col space-y-6">
167
- <p class="text-sm">
168
- In order for Single Sign-On to work, you have to allow at least one email address domain:
169
- </p>
170
-
171
- <.form
172
- :let={f}
173
- id="sso-add-domain"
174
- for={@domain_changeset}
175
- class="flex-col space-y-4"
176
- phx-submit="add-domain"
177
- >
178
- <.input field={f[:domain]} label="Domain" placeholder="example.com" />
179
-
180
- <.button type="submit">Add Domain</.button>
181
- </.form>
182
- </div>
183
- """
184
- end
185
-
186
- def domain_verify_view(assigns) do
187
- ~H"""
188
- <div class="flex-col space-y-6">
189
- <p class="text-sm">Verifying domain {@domain.domain}</p>
190
-
191
- <p class="text-sm">You can verify ownership of the domain using one of 3 methods:</p>
192
-
193
- <ul class="list-disc ml-4 space-y-6">
194
- <li>
195
- <.input_with_clipboard
196
- name="verification-dns-txt"
197
- label={"Add a TXT record to #{@domain.domain} domain with the following value"}
198
- id="verification-dns-txt"
199
- value={"plausible-sso-verification=#{@domain.identifier}"}
200
- />
201
- </li>
202
- <li>
203
- <.input_with_clipboard
204
- name="verification-url"
205
- label={"Publish a file or route at https://#{@domain.domain}/plausible-sso-verification rendering the following contents"}
206
- id="verification-url"
207
- value={@domain.identifier}
208
- />
209
- </li>
210
- <li>
211
- <.input_with_clipboard
212
- name="verification-meta-tag"
213
- label={"Add a following META tag to the web page at https://#{@domain.domain}"}
214
- id="verification-meta-tag"
215
- value={~s|<meta name="plausible-sso-verification" content="#{@domain.identifier}">|}
216
- />
217
- </li>
218
- </ul>
219
-
220
- <.notice>
221
- We'll keep checking your domain ownership. Once any of the above verification methods succeeds, we'll send you an e-mail. Thank you for your patience.
222
- </.notice>
223
-
224
- <form id="verify-domain-submit" for={} phx-submit="verify-domain-submit">
225
- <.input type="hidden" name="identifier" value={@domain.identifier} />
226
- <.button
227
- :if={@domain.status in [Status.in_progress(), Status.unverified(), Status.verified()]}
228
- type="submit"
229
- >
230
- Run Verification Now
231
- </.button>
232
-
233
- <.button :if={@domain.status == Status.pending()} type="submit">Continue</.button>
234
- </form>
235
- </div>
236
- """
237
- end
238
-
239
- def manage_view(assigns) do
240
- ~H"""
241
- <.tile docs="sso">
242
- <:title>
243
- <a id="sso-manage-config">Single Sign-On</a>
244
- </:title>
245
- <:subtitle>
246
- Configure and manage Single Sign-On for your team.
247
- </:subtitle>
248
-
249
- <div class="flex-col space-y-6">
250
- <p class="text-sm">
251
- Use the following parameters when configuring your Identity Provider of choice:
252
- </p>
253
-
254
- <form id="sso-sp-config" for={} class="flex-col space-y-4">
255
- <.input_with_clipboard
256
- id="sp-acs-url"
257
- name="sp-acs-url"
258
- label="ACS URL / Single Sign-On URL / Reply URL"
259
- value={saml_acs_url(@integration)}
260
- />
261
-
262
- <.input_with_clipboard
263
- id="sp-entity-id"
264
- name="sp-entity-id"
265
- label="Entity ID / Audience URI / Identifier"
266
- value={SSO.SAMLConfig.entity_id(@integration)}
267
- />
268
- </form>
269
-
270
- <div class="flex-col space-y-3">
271
- <p class="text-sm">Following attribute mappings must be setup at Identity Provider:</p>
272
-
273
- <ul role="list" class="list-disc leading-6 text-sm ml-8">
274
- <li :for={param <- ["email", "first_name", "last_name"]}>
275
- <code>{param}</code>
276
- </li>
277
- </ul>
278
- </div>
279
-
280
- <div class="flex-col space-y-3">
281
- <p class="text-sm">
282
- Current Identity Provider configuration:
283
- </p>
284
-
285
- <.form :let={f} id="sso-idp-config" for={} class="flex-col space-y-4">
286
- <.input
287
- field={f[:idp_signin_url]}
288
- value={@integration.config.idp_signin_url}
289
- label="SSO URL / Sign-on URL / Login URL"
290
- readonly={true}
291
- />
292
-
293
- <.input
294
- field={f[:idp_entity_id]}
295
- value={@integration.config.idp_entity_id}
296
- label="Entity ID / Issuer / Identifier"
297
- readonly={true}
298
- />
299
-
300
- <.input
301
- field={f[:idp_cert_pem]}
302
- type="textarea"
303
- label="Signing Certificate in PEM format"
304
- value={@integration.config.idp_cert_pem}
305
- readonly={true}
306
- />
307
- </.form>
308
-
309
- <form id="show-idp-form" for={} phx-submit="show-idp-form">
310
- <.button type="submit">Edit</.button>
311
- </form>
312
- </div>
313
- </div>
314
- </.tile>
315
-
316
- <.tile docs="sso#sso-domains">
317
- <:title>
318
- <a id="sso-domains-config">SSO Domains</a>
319
- </:title>
320
- <:subtitle>
321
- Email domains accepted from Identity Provider
322
- </:subtitle>
323
- <div class="flex-col space-y-3">
324
- <.table rows={@integration.sso_domains}>
325
- <:thead>
326
- <.th>Domain</.th>
327
- <.th hide_on_mobile>Added at</.th>
328
- <.th>Status</.th>
329
- <.th invisible>Actions</.th>
330
- </:thead>
331
- <:tbody :let={domain}>
332
- <.td>{domain.domain}</.td>
333
- <.td hide_on_mobile>
334
- {Calendar.strftime(domain.inserted_at, "%b %-d, %Y at %H:%m UTC")}
335
- </.td>
336
- <.td :if={domain.status != Status.in_progress()}>{domain.status}</.td>
337
- <.td :if={domain.status == Status.in_progress()}>
338
- <div class="flex items-center gap-x-2">
339
- <.spinner class="w-4 h-4" />
340
- <.styled_link
341
- id={"cancel-verify-domain-#{domain.identifier}"}
342
- phx-click="cancel-verify-domain"
343
- phx-value-identifier={domain.identifier}
344
- >
345
- Cancel
346
- </.styled_link>
347
- </div>
348
- </.td>
349
- <.td actions>
350
- <.styled_link
351
- :if={domain.status not in [Status.in_progress(), Status.verified()]}
352
- id={"verify-domain-#{domain.identifier}"}
353
- phx-click="verify-domain"
354
- phx-value-identifier={domain.identifier}
355
- >
356
- Verify
357
- </.styled_link>
358
-
359
- <.delete_button
360
- :if={is_nil(@domain_delete_checks[domain.identifier])}
361
- id={"remove-domain-#{domain.identifier}"}
362
- phx-click="remove-domain"
363
- phx-value-identifier={domain.identifier}
364
- class="text-sm text-red-600"
365
- data-confirm={"Are you sure you want to remove domain '#{domain.domain}'?"}
366
- />
367
-
368
- <.delete_button
369
- :if={@domain_delete_checks[domain.identifier]}
370
- id={"disabled-remove-domain-#{domain.identifier}"}
371
- class="text-sm text-red-600"
372
- data-confirm={"You cannot delete this domain. #{@domain_delete_checks[domain.identifier]}"}
373
- />
374
- </.td>
375
- </:tbody>
376
- </.table>
377
-
378
- <form id="show-domain-setup" for={} phx-submit="show-domain-setup">
379
- <.button type="submit">Add Domain</.button>
380
- </form>
381
- </div>
382
- </.tile>
383
-
384
- <.tile docs="sso#sso-policy">
385
- <:title>
386
- <a id="sso-policy-config">SSO Policy</a>
387
- </:title>
388
- <:subtitle>
389
- Adjust your SSO policy configuration
390
- </:subtitle>
391
- <div
392
- x-data={Jason.encode!(%{active: @current_team.policy.force_sso == :all_but_owners})}
393
- class="flex-col space-y-3"
394
- >
395
- <p class="text-sm">Enforce Single Sign-On for the whole team, except Owners:</p>
396
-
397
- <.tooltip enabled?={not @can_toggle_force_sso?}>
398
- <:tooltip_content>
399
- <div class="text-xs">
400
- To get access to this feature, {@force_sso_warning}.
401
- </div>
402
- </:tooltip_content>
403
- <div class="flex itemx-center mb-3">
404
- <PlausibleWeb.Components.Generic.toggle_switch
405
- id="enable-force-sso"
406
- id_suffix="toggle"
407
- js_active_var="active"
408
- disabled={not @can_toggle_force_sso?}
409
- phx-click="toggle-force-sso"
410
- />
411
- <span class={[
412
- "ml-3 text-sm font-medium",
413
- if(@can_toggle_force_sso?,
414
- do: "text-gray-900 dark:text-gray-100",
415
- else: "text-gray-500 dark:text-gray-400"
416
- )
417
- ]}>
418
- Force Single Sign-On
419
- </span>
420
- </div>
421
- </.tooltip>
422
- </div>
423
-
424
- <div class="flex-col space-y-3">
425
- <.form
426
- :let={f}
427
- id="sso-policy"
428
- for={@policy_changeset}
429
- class="flex-col space-y-4"
430
- phx-submit="update-policy"
431
- >
432
- <.input
433
- field={f[:sso_default_role]}
434
- label="Default role"
435
- type="select"
436
- options={@role_options}
437
- />
438
-
439
- <.input
440
- field={f[:sso_session_timeout_minutes]}
441
- label="Session timeout (minutes)"
442
- type="number"
443
- />
444
-
445
- <.button type="submit">Update</.button>
446
- </.form>
447
- </div>
448
- </.tile>
449
- """
450
- end
451
-
452
- def handle_event("init-sso", _params, socket) do
453
- team = socket.assigns.current_team
454
- integration = SSO.initiate_saml_integration(team)
455
-
456
- socket =
457
- socket
458
- |> assign(:integration, integration)
459
- |> load_integration(team)
460
- |> route_mode()
461
-
462
- {:noreply, socket}
463
- end
464
-
465
- def handle_event("show-idp-form", _params, socket) do
466
- {:noreply, route_mode(socket, :idp_form)}
467
- end
468
-
469
- def handle_event("update-integration", params, socket) do
470
- socket =
471
- case SSO.update_integration(socket.assigns.integration, params["saml_config"] || %{}) do
472
- {:ok, integration} ->
473
- socket
474
- |> assign(:integration, integration)
475
- |> load_integration(socket.assigns.current_team)
476
- |> route_mode()
477
-
478
- {:error, changeset} ->
479
- assign(socket, :config_changeset, changeset)
480
- end
481
-
482
- {:noreply, socket}
483
- end
484
-
485
- def handle_event("add-domain", params, socket) do
486
- integration = socket.assigns.integration
487
-
488
- socket =
489
- case SSO.Domains.add(integration, params["domain"]["domain"] || "") do
490
- {:ok, sso_domain} ->
491
- socket
492
- |> load_integration(socket.assigns.current_team)
493
- |> assign(:domain, sso_domain)
494
- |> route_mode(:domain_verify)
495
-
496
- {:error, changeset} ->
497
- socket
498
- |> assign(:domain_changeset, changeset)
499
- end
500
-
501
- {:noreply, socket}
502
- end
503
-
504
- def handle_event("verify-domain-submit", params, socket) do
505
- integration = socket.assigns.integration
506
- sso_domain = Enum.find(integration.sso_domains, &(&1.identifier == params["identifier"]))
507
-
508
- if sso_domain do
509
- SSO.Domains.start_verification(sso_domain.domain)
510
- {:noreply, route_mode(load_integration(socket, socket.assigns.current_team), :manage)}
511
- else
512
- {:noreply, socket}
513
- end
514
- end
515
-
516
- def handle_event("show-domain-setup", _params, socket) do
517
- {:noreply, route_mode(socket, :domain_setup)}
518
- end
519
-
520
- def handle_event("verify-domain", params, socket) do
521
- integration = socket.assigns.integration
522
- domain = Enum.find(integration.sso_domains, &(&1.identifier == params["identifier"]))
523
-
524
- if domain do
525
- socket =
526
- socket
527
- |> load_integration(socket.assigns.current_team)
528
- |> assign(:domain, domain)
529
- |> route_mode(:domain_verify)
530
-
531
- {:noreply, socket}
532
- else
533
- {:noreply, route_mode(socket, :manage)}
534
- end
535
- end
536
-
537
- def handle_event("cancel-verify-domain", params, socket) do
538
- integration = socket.assigns.integration
539
- domain = Enum.find(integration.sso_domains, &(&1.identifier == params["identifier"]))
540
-
541
- socket =
542
- if domain do
543
- :ok = SSO.Domains.cancel_verification(domain.domain)
544
- load_integration(socket, socket.assigns.current_team)
545
- else
546
- socket
547
- end
548
-
549
- {:noreply, socket}
550
- end
551
-
552
- def handle_event("remove-domain", params, socket) do
553
- integration = socket.assigns.integration
554
- domain = Enum.find(integration.sso_domains, &(&1.identifier == params["identifier"]))
555
-
556
- if domain do
557
- socket =
558
- case SSO.Domains.remove(domain) do
559
- :ok ->
560
- socket
561
- |> load_integration(socket.assigns.current_team)
562
- |> route_mode()
563
-
564
- {:error, :force_sso_enabled} ->
565
- socket
566
-
567
- {:error, :sso_users_present} ->
568
- socket
569
- end
570
-
571
- {:noreply, socket}
572
- else
573
- {:noreply, route_mode(socket, :manage)}
574
- end
575
- end
576
-
577
- def handle_event("toggle-force-sso", _params, socket) do
578
- team = socket.assigns.current_team
579
- new_toggle = if team.policy.force_sso == :none, do: :all_but_owners, else: :none
580
-
581
- case SSO.set_force_sso(socket.assigns.current_team, new_toggle) do
582
- {:ok, team} ->
583
- socket =
584
- socket
585
- |> assign(:current_team, team)
586
-
587
- {:noreply, socket}
588
-
589
- {:error, _} ->
590
- {:noreply, route_mode(socket, :manage)}
591
- end
592
- end
593
-
594
- def handle_event("update-policy", params, socket) do
595
- team = socket.assigns.current_team
596
- params = params["policy"]
597
-
598
- attrs = [
599
- sso_default_role: params["sso_default_role"],
600
- sso_session_timeout_minutes: params["sso_session_timeout_minutes"]
601
- ]
602
-
603
- socket =
604
- case SSO.update_policy(team, attrs) do
605
- {:ok, team} ->
606
- policy_changeset = Teams.Policy.update_changeset(team.policy, %{})
607
-
608
- socket
609
- |> assign(:current_team, team)
610
- |> assign(:policy_changeset, policy_changeset)
611
-
612
- {:error, changeset} ->
613
- socket
614
- |> assign(:policy_changeset, changeset)
615
- end
616
-
617
- {:noreply, socket}
618
- end
619
-
620
- def handle_info(:refresh_integration, socket) do
621
- Process.send_after(self(), :refresh_integration, @refresh_integration_interval)
622
- {:noreply, load_integration(socket, socket.assigns.current_team)}
623
- end
624
-
625
- defp load_integration(socket, team) do
626
- integration =
627
- case SSO.get_integration_for(team) do
628
- {:ok, integration} -> integration
629
- {:error, :not_found} -> nil
630
- end
631
-
632
- assign(socket, :integration, integration)
633
- end
634
-
635
- defp route_mode(socket, force_mode \\ nil) do
636
- integration = socket.assigns.integration
637
-
638
- mode =
639
- cond do
640
- force_mode ->
641
- force_mode
642
-
643
- is_nil(integration) ->
644
- :init
645
-
646
- not SSO.Integration.configured?(integration) ->
647
- :init_setup
648
-
649
- integration.sso_domains == [] ->
650
- :domain_setup
651
-
652
- true ->
653
- :manage
654
- end
655
-
656
- socket
657
- |> assign(:mode, mode)
658
- |> load(mode)
659
- end
660
-
661
- defp load(socket, :idp_form) do
662
- assign(
663
- socket,
664
- :config_changeset,
665
- SSO.SAMLConfig.changeset(socket.assigns.integration.config, %{})
666
- )
667
- end
668
-
669
- defp load(socket, :domain_setup) do
670
- assign(socket, :domain_changeset, SSO.Domain.create_changeset(socket.assigns.integration, ""))
671
- end
672
-
673
- defp load(socket, :manage) do
674
- team = socket.assigns.current_team
675
- toggle_mode = if team.policy.force_sso == :none, do: :all_but_owners, else: :none
676
-
677
- {can_toggle_force_sso?, toggle_disabled_reason} =
678
- case {SSO.check_force_sso(team, toggle_mode), socket.assigns.current_user.type} do
679
- {:ok, :sso} ->
680
- {true, nil}
681
-
682
- {:ok, :standard} ->
683
- {false, "you must be logged in via SSO"}
684
-
685
- {{:error, :no_integration}, _} ->
686
- {false, "you must first setup Single Sign-On"}
687
-
688
- {{:error, :no_domain}, _} ->
689
- {false, "you must add a domain"}
690
-
691
- {{:error, :no_verified_domain}, _} ->
692
- {false, "you must verify a domain"}
693
-
694
- {{:error, :owner_2fa_disabled}, _} ->
695
- {false, "all Owners must have 2FA enabled"}
696
-
697
- {{:error, :no_sso_user}, _} ->
698
- {false, "at least one SSO user must log in successfully"}
699
- end
700
-
701
- policy_changeset = Teams.Policy.update_changeset(team.policy, %{})
702
-
703
- role_options =
704
- Enum.map(Teams.Policy.sso_member_roles(), fn role ->
705
- {String.capitalize(to_string(role)), role}
706
- end)
707
- |> Enum.sort()
708
-
709
- domain_delete_checks =
710
- Enum.into(socket.assigns.integration.sso_domains, %{}, fn domain ->
711
- prevent_delete_reason =
712
- case Plausible.Auth.SSO.Domains.check_can_remove(domain) do
713
- :ok -> nil
714
- {:error, :force_sso_enabled} -> "You must disable 'Force SSO' first."
715
- {:error, :sso_users_present} -> "There are existing SSO accounts on this domain."
716
- end
717
-
718
- {domain.identifier, prevent_delete_reason}
719
- end)
720
-
721
- socket
722
- |> assign(:domain_delete_checks, domain_delete_checks)
723
- |> assign(:can_toggle_force_sso?, can_toggle_force_sso?)
724
- |> assign(:force_sso_warning, toggle_disabled_reason)
725
- |> assign(:policy_changeset, policy_changeset)
726
- |> assign(:role_options, role_options)
727
- end
728
-
729
- defp load(socket, _) do
730
- socket
731
- end
732
-
733
- defp saml_acs_url(integration) do
734
- Routes.sso_url(
735
- PlausibleWeb.Endpoint,
736
- :saml_consume,
737
- integration.identifier
738
- )
739
- end
740
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible_web/live/verification.ex DELETED
@@ -1,288 +0,0 @@
1
- defmodule PlausibleWeb.Live.Verification do
2
- @moduledoc """
3
- LiveView coordinating the site verification process.
4
- Onboarding new sites, renders a standalone component.
5
- Embedded modal variant is available for general site settings.
6
- """
7
- use PlausibleWeb, :live_view
8
-
9
- import PlausibleWeb.Components.Generic
10
-
11
- alias Plausible.InstallationSupport.{State, Verification}
12
-
13
- @component PlausibleWeb.Live.Components.Verification
14
- @slowdown_for_frequent_checking :timer.seconds(5)
15
-
16
- def mount(
17
- %{"domain" => domain} = params,
18
- _session,
19
- socket
20
- ) do
21
- current_user = socket.assigns.current_user
22
-
23
- site =
24
- Plausible.Sites.get_for_user!(current_user, domain,
25
- roles: [
26
- :owner,
27
- :admin,
28
- :editor,
29
- :super_admin,
30
- :viewer
31
- ]
32
- )
33
-
34
- true = Plausible.Sites.regular?(site)
35
-
36
- private = Map.get(socket.private.connect_info, :private, %{})
37
-
38
- super_admin? = Plausible.Auth.super_admin?(current_user)
39
- has_pageviews? = has_pageviews?(site)
40
-
41
- custom_url_input? = params["custom_url"] == "true"
42
-
43
- socket =
44
- assign(socket,
45
- url_to_verify: nil,
46
- site: site,
47
- super_admin?: super_admin?,
48
- domain: domain,
49
- has_pageviews?: has_pageviews?,
50
- component: @component,
51
- installation_type: get_installation_type(params, site),
52
- report_to: self(),
53
- delay: private[:delay] || 500,
54
- slowdown: private[:slowdown] || 500,
55
- flow: params["flow"] || "",
56
- checks_pid: nil,
57
- attempts: 0,
58
- polling_pageviews?: false,
59
- custom_url_input?: custom_url_input?
60
- )
61
-
62
- if connected?(socket) and not custom_url_input? do
63
- launch_delayed(socket)
64
- end
65
-
66
- {:ok, socket}
67
- end
68
-
69
- def render(assigns) do
70
- ~H"""
71
- <PlausibleWeb.Components.FlowProgress.render flow={@flow} current_step="Verify installation" />
72
- <.custom_url_form :if={@custom_url_input?} domain={@domain} />
73
- <.live_component
74
- :if={not @custom_url_input?}
75
- module={@component}
76
- installation_type={@installation_type}
77
- domain={@domain}
78
- id="verification-standalone"
79
- attempts={@attempts}
80
- flow={@flow}
81
- awaiting_first_pageview?={not @has_pageviews?}
82
- super_admin?={@super_admin?}
83
- />
84
- """
85
- end
86
-
87
- def handle_event("launch-verification", _, socket) do
88
- launch_delayed(socket)
89
- {:noreply, reset_component(socket)}
90
- end
91
-
92
- def handle_event("retry", _, socket) do
93
- launch_delayed(socket)
94
- {:noreply, reset_component(socket)}
95
- end
96
-
97
- def handle_event("verify-custom-url", %{"custom_url" => custom_url}, socket) do
98
- socket =
99
- socket
100
- |> assign(url_to_verify: custom_url)
101
- |> assign(custom_url_input?: false)
102
-
103
- launch_delayed(socket)
104
- {:noreply, reset_component(socket)}
105
- end
106
-
107
- def handle_info({:start, report_to}, socket) do
108
- domain = socket.assigns.domain
109
- checks_pid = socket.assigns.checks_pid
110
-
111
- if is_pid(checks_pid) and Process.alive?(checks_pid) do
112
- {:noreply, socket}
113
- else
114
- case Plausible.RateLimit.check_rate(
115
- "site_verification:#{domain}",
116
- :timer.minutes(60),
117
- 3
118
- ) do
119
- {:allow, _} -> :ok
120
- {:deny, _} -> :timer.sleep(@slowdown_for_frequent_checking)
121
- end
122
-
123
- {:ok, pid} =
124
- Verification.Checks.run(
125
- socket.assigns.url_to_verify,
126
- domain,
127
- socket.assigns.installation_type,
128
- report_to: report_to,
129
- slowdown: socket.assigns.slowdown
130
- )
131
-
132
- {:noreply, assign(socket, checks_pid: pid, attempts: socket.assigns.attempts + 1)}
133
- end
134
- end
135
-
136
- def handle_info({:check_start, {check, state}}, socket) do
137
- to_update = [message: check.report_progress_as()]
138
-
139
- to_update =
140
- if is_binary(state.url) do
141
- Keyword.put(to_update, :url_to_verify, state.url)
142
- else
143
- to_update
144
- end
145
-
146
- update_component(socket, to_update)
147
-
148
- {:noreply, socket}
149
- end
150
-
151
- def handle_info({:all_checks_done, %State{} = state}, socket) do
152
- interpretation = Verification.Checks.interpret_diagnostics(state)
153
-
154
- if not socket.assigns.has_pageviews? do
155
- schedule_pageviews_check(socket)
156
- end
157
-
158
- update_component(socket,
159
- finished?: true,
160
- success?: interpretation.ok?,
161
- interpretation: interpretation,
162
- verification_state: state
163
- )
164
-
165
- {:noreply, assign(socket, checks_pid: nil)}
166
- end
167
-
168
- def handle_info(:check_pageviews, socket) do
169
- socket =
170
- if has_pageviews?(socket.assigns.site) do
171
- redirect_to_stats(socket)
172
- else
173
- socket
174
- |> assign(polling_pageviews?: false)
175
- |> schedule_pageviews_check()
176
- end
177
-
178
- {:noreply, socket}
179
- end
180
-
181
- @supported_installation_types_atoms PlausibleWeb.Tracker.supported_installation_types()
182
- |> Enum.map(&String.to_atom/1)
183
- defp get_installation_type(params, site) do
184
- cond do
185
- params["installation_type"] in PlausibleWeb.Tracker.supported_installation_types() ->
186
- params["installation_type"]
187
-
188
- (saved_installation_type = get_saved_installation_type(site)) in @supported_installation_types_atoms ->
189
- Atom.to_string(saved_installation_type)
190
-
191
- true ->
192
- PlausibleWeb.Tracker.fallback_installation_type()
193
- end
194
- end
195
-
196
- defp get_saved_installation_type(site) do
197
- case PlausibleWeb.Tracker.get_tracker_script_configuration(site) do
198
- %{installation_type: installation_type} ->
199
- installation_type
200
-
201
- _ ->
202
- nil
203
- end
204
- end
205
-
206
- defp schedule_pageviews_check(socket) do
207
- if socket.assigns.polling_pageviews? do
208
- socket
209
- else
210
- Process.send_after(self(), :check_pageviews, socket.assigns.delay * 2)
211
- assign(socket, polling_pageviews?: true)
212
- end
213
- end
214
-
215
- defp redirect_to_stats(socket) do
216
- stats_url = Routes.stats_path(PlausibleWeb.Endpoint, :stats, socket.assigns.domain, [])
217
- redirect(socket, to: stats_url)
218
- end
219
-
220
- defp reset_component(socket) do
221
- update_component(socket,
222
- message: "We're visiting your site to ensure that everything is working",
223
- finished?: false,
224
- success?: false,
225
- diagnostics: nil
226
- )
227
-
228
- socket
229
- end
230
-
231
- defp update_component(_socket, updates) do
232
- send_update(
233
- @component,
234
- Keyword.merge(updates, id: "verification-standalone")
235
- )
236
- end
237
-
238
- defp launch_delayed(socket) do
239
- Process.send_after(self(), {:start, socket.assigns.report_to}, socket.assigns.delay)
240
- end
241
-
242
- defp has_pageviews?(site) do
243
- Plausible.Stats.Clickhouse.has_pageviews?(site)
244
- end
245
-
246
- defp custom_url_form(assigns) do
247
- ~H"""
248
- <.focus_box>
249
- <div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900">
250
- <Heroicons.globe_alt class="h-6 w-6 text-blue-600 dark:text-blue-200" />
251
- </div>
252
- <div class="mt-8">
253
- <h3 class="text-lg leading-6 font-medium text-gray-900 dark:text-white">
254
- Enter Your Custom URL
255
- </h3>
256
- <p class="text-sm mt-4 text-gray-600 dark:text-gray-400">
257
- Please enter the URL where your website with the Plausible script is located.
258
- </p>
259
- <form phx-submit="verify-custom-url" class="mt-6">
260
- <div class="mb-4">
261
- <label
262
- for="custom_url"
263
- class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
264
- >
265
- Website URL
266
- </label>
267
- <input
268
- type="url"
269
- name="custom_url"
270
- id="custom_url"
271
- required
272
- class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-xs focus:outline-hidden focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-800 dark:text-white"
273
- placeholder={"https://#{@domain}"}
274
- value={"https://#{@domain}"}
275
- />
276
- </div>
277
- <button
278
- type="submit"
279
- class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-xs text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-600"
280
- >
281
- Verify Installation
282
- </button>
283
- </form>
284
- </div>
285
- </.focus_box>
286
- """
287
- end
288
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible_web/plugins/api/controllers/funnels.ex DELETED
@@ -1,120 +0,0 @@
1
- defmodule PlausibleWeb.Plugins.API.Controllers.Funnels do
2
- @moduledoc """
3
- Controller for the Funnel resource under Plugins API
4
- """
5
- use PlausibleWeb, :plugins_api_controller
6
-
7
- operation(:create,
8
- operation_id: "Funnel.GetOrCreate",
9
- summary: "Get or create Funnel",
10
- request_body: {"Funnel params", "application/json", Schemas.Funnel.CreateRequest},
11
- responses: %{
12
- created: {"Funnel", "application/json", Schemas.Funnel},
13
- unauthorized: {"Unauthorized", "application/json", Schemas.Unauthorized},
14
- payment_required: {"Payment required", "application/json", Schemas.PaymentRequired},
15
- unprocessable_entity:
16
- {"Unprocessable entity", "application/json", Schemas.UnprocessableEntity}
17
- }
18
- )
19
-
20
- def create(
21
- %{private: %{open_api_spex: %{body_params: body_params}}} = conn,
22
- _params
23
- ) do
24
- site = conn.assigns.authorized_site
25
-
26
- case Plausible.Plugins.API.Funnels.create(site, body_params) do
27
- {:ok, funnel} ->
28
- headers = [{"location", plugins_api_funnels_url(conn, :get, funnel.id)}]
29
-
30
- conn
31
- |> prepend_resp_headers(headers)
32
- |> put_view(Views.Funnel)
33
- |> put_status(:created)
34
- |> render("funnel.json", funnel: funnel, authorized_site: site)
35
-
36
- {:error, :upgrade_required} ->
37
- payment_required(conn)
38
-
39
- {:error, changeset} ->
40
- Errors.error(conn, 422, changeset)
41
- end
42
- end
43
-
44
- operation(:index,
45
- summary: "Retrieve Funnels",
46
- parameters: [
47
- limit: [in: :query, type: :integer, description: "Maximum entries per page", example: 10],
48
- after: [
49
- in: :query,
50
- type: :string,
51
- description: "Cursor value to seek after - generated internally"
52
- ],
53
- before: [
54
- in: :query,
55
- type: :string,
56
- description: "Cursor value to seek before - generated internally"
57
- ]
58
- ],
59
- responses: %{
60
- ok: {"Funnels response", "application/json", Schemas.Funnel.ListResponse},
61
- unauthorized: {"Unauthorized", "application/json", Schemas.Unauthorized}
62
- }
63
- )
64
-
65
- @spec index(Plug.Conn.t(), %{}) :: Plug.Conn.t()
66
- def index(conn, _params) do
67
- {:ok, pagination} = API.Funnels.get_funnels(conn.assigns.authorized_site, conn.query_params)
68
-
69
- conn
70
- |> put_view(Views.Funnel)
71
- |> render("index.json", %{pagination: pagination})
72
- end
73
-
74
- operation(:get,
75
- summary: "Retrieve Funnel by ID",
76
- parameters: [
77
- id: [
78
- in: :path,
79
- type: :integer,
80
- description: "Funnel ID",
81
- example: 123,
82
- required: true
83
- ]
84
- ],
85
- responses: %{
86
- ok: {"Goal", "application/json", Schemas.Funnel},
87
- not_found: {"NotFound", "application/json", Schemas.NotFound},
88
- unauthorized: {"Unauthorized", "application/json", Schemas.Unauthorized},
89
- unprocessable_entity:
90
- {"Unprocessable entity", "application/json", Schemas.UnprocessableEntity}
91
- }
92
- )
93
-
94
- @spec get(Plug.Conn.t(), map()) :: Plug.Conn.t()
95
- def get(%{private: %{open_api_spex: %{params: %{id: id}}}} = conn, _params) do
96
- site = conn.assigns.authorized_site
97
-
98
- case API.Funnels.get(site, id) do
99
- nil ->
100
- conn
101
- |> put_view(Views.Error)
102
- |> put_status(:not_found)
103
- |> render("404.json")
104
-
105
- funnel ->
106
- conn
107
- |> put_view(Views.Funnel)
108
- |> put_status(:ok)
109
- |> render("funnel.json", funnel: funnel, authorized_site: site)
110
- end
111
- end
112
-
113
- defp payment_required(conn) do
114
- Errors.error(
115
- conn,
116
- 402,
117
- "#{Plausible.Billing.Feature.Funnels.display_name()} is part of the Plausible Business plan. To get access to this feature, please upgrade your account."
118
- )
119
- end
120
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible_web/plugins/api/schemas/funnel.ex DELETED
@@ -1,55 +0,0 @@
1
- defmodule PlausibleWeb.Plugins.API.Schemas.Funnel do
2
- @moduledoc """
3
- OpenAPI schema for Funnel
4
- """
5
- use Plausible.Funnel.Const
6
- use PlausibleWeb, :open_api_schema
7
-
8
- OpenApiSpex.schema(%{
9
- title: "Funnel",
10
- description: "Funnel object",
11
- type: :object,
12
- properties: %{
13
- funnel: %Schema{
14
- type: :object,
15
- required: [:id, :name, :steps],
16
- properties: %{
17
- id: %Schema{type: :integer, description: "Funnel ID"},
18
- name: %Schema{type: :string, description: "Name"},
19
- steps: %Schema{
20
- description: "Funnel Steps",
21
- type: :array,
22
- minItems: 2,
23
- maxItems: Funnel.Const.max_steps(),
24
- items: Schemas.Goal
25
- }
26
- }
27
- }
28
- },
29
- example: %{
30
- funnel: %{
31
- id: 123,
32
- name: "My Marketing Funnel",
33
- steps: [
34
- %{
35
- goal_type: "Goal.Pageview",
36
- goal: %{
37
- id: 1,
38
- display_name: "Visit /product/1",
39
- path: "/product/1"
40
- }
41
- },
42
- %{
43
- goal_type: "Goal.Revenue",
44
- goal: %{
45
- id: 2,
46
- display_name: "Purchase",
47
- currency: "EUR",
48
- event_name: "Purchase"
49
- }
50
- }
51
- ]
52
- }
53
- }
54
- })
55
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible_web/plugins/api/schemas/funnel/create_request.ex DELETED
@@ -1,56 +0,0 @@
1
- defmodule PlausibleWeb.Plugins.API.Schemas.Funnel.CreateRequest do
2
- @moduledoc """
3
- OpenAPI schema for Funnel creation request - get or creates goals along the way
4
- """
5
- use Plausible.Funnel.Const
6
- use PlausibleWeb, :open_api_schema
7
-
8
- OpenApiSpex.schema(%{
9
- title: "Funnel.CreateRequest",
10
- description: "Funnel creation params",
11
- type: :object,
12
- required: [:funnel],
13
- properties: %{
14
- funnel: %Schema{
15
- type: :object,
16
- required: [:steps, :name],
17
- properties: %{
18
- steps: %Schema{
19
- description: "Funnel Steps",
20
- type: :array,
21
- minItems: 2,
22
- maxItems: Funnel.Const.max_steps(),
23
- items: %Schema{
24
- oneOf: [
25
- Schemas.Goal.CreateRequest.CustomEvent,
26
- Schemas.Goal.CreateRequest.Revenue,
27
- Schemas.Goal.CreateRequest.Pageview
28
- ]
29
- }
30
- },
31
- name: %Schema{type: :string, description: "Funnel Name"}
32
- }
33
- }
34
- },
35
- example: %{
36
- funnel: %{
37
- name: "My First Funnel",
38
- steps: [
39
- %{
40
- goal_type: "Goal.Pageview",
41
- goal: %{
42
- path: "/product/123"
43
- }
44
- },
45
- %{
46
- goal_type: "Goal.Revenue",
47
- goal: %{
48
- currency: "EUR",
49
- event_name: "Purchase"
50
- }
51
- }
52
- ]
53
- }
54
- }
55
- })
56
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible_web/plugins/api/schemas/funnel/list_response.ex DELETED
@@ -1,26 +0,0 @@
1
- defmodule PlausibleWeb.Plugins.API.Schemas.Funnel.ListResponse do
2
- @moduledoc """
3
- OpenAPI schema for Funnel list response
4
- """
5
- use PlausibleWeb, :open_api_schema
6
-
7
- OpenApiSpex.schema(%{
8
- title: "Funnel.ListResponse",
9
- description: "Funnels list response",
10
- type: :object,
11
- required: [:funnels, :meta],
12
- properties: %{
13
- funnels: %Schema{
14
- items: Schemas.Funnel,
15
- type: :array
16
- },
17
- meta: %Schema{
18
- required: [:pagination],
19
- type: :object,
20
- properties: %{
21
- pagination: Schemas.PaginationMetadata
22
- }
23
- }
24
- }
25
- })
26
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible_web/plugins/api/views/funnel.ex DELETED
@@ -1,53 +0,0 @@
1
- defmodule PlausibleWeb.Plugins.API.Views.Funnel do
2
- @moduledoc """
3
- View for rendering Funnels in the Plugins API
4
- """
5
-
6
- use PlausibleWeb, :plugins_api_view
7
-
8
- def render("index.json", %{
9
- pagination: %{entries: funnels, metadata: metadata},
10
- authorized_site: site,
11
- conn: conn
12
- }) do
13
- %{
14
- funnels:
15
- render_many(funnels, __MODULE__, "funnel.json", authorized_site: site, as: :funnel),
16
- meta: render_metadata_links(metadata, :plugins_api_funnels_url, :index, conn.query_params)
17
- }
18
- end
19
-
20
- def render("index.json", %{
21
- funnels: funnels,
22
- authorized_site: site,
23
- conn: conn
24
- }) do
25
- %{
26
- funnels:
27
- render_many(funnels, __MODULE__, "funnel.json", authorized_site: site, as: :funnel),
28
- meta: render_metadata_links(%{}, :plugins_api_funnels_url, :index, conn.query_params)
29
- }
30
- end
31
-
32
- def render(
33
- "funnel.json",
34
- %{
35
- funnel: funnel,
36
- authorized_site: site
37
- }
38
- ) do
39
- goals = Enum.map(funnel.steps, & &1.goal)
40
-
41
- %{
42
- funnel: %{
43
- name: funnel.name,
44
- id: funnel.id,
45
- steps:
46
- render_many(goals, PlausibleWeb.Plugins.API.Views.Goal, "goal.json",
47
- authorized_site: site,
48
- as: :goal
49
- )
50
- }
51
- }
52
- end
53
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible_web/plugs/handle_expired_session.ex DELETED
@@ -1,64 +0,0 @@
1
- defmodule Plausible.Plugs.HandleExpiredSession do
2
- @moduledoc """
3
- Plug for handling expired session. Must be added after `AuthPlug`.
4
- """
5
-
6
- use Plausible
7
-
8
- import Plug.Conn
9
-
10
- alias PlausibleWeb.Router.Helpers, as: Routes
11
-
12
- def init(_) do
13
- []
14
- end
15
-
16
- def call(conn, []) do
17
- maybe_trigger_login(conn, conn.assigns[:expired_session])
18
- end
19
-
20
- defp maybe_trigger_login(conn, nil), do: conn
21
-
22
- defp maybe_trigger_login(conn, user_session) do
23
- if Plausible.Users.type(user_session.user) == :sso do
24
- Plausible.Auth.UserSessions.revoke_by_id(user_session.user, user_session.id)
25
- trigger_sso_login(conn, user_session.user.email)
26
- else
27
- conn
28
- end
29
- end
30
-
31
- defp trigger_sso_login(%{method: "GET"} = conn, email) do
32
- return_to =
33
- if conn.query_string && String.length(conn.query_string) > 0 do
34
- conn.request_path <> "?" <> conn.query_string
35
- else
36
- conn.request_path
37
- end
38
-
39
- conn
40
- |> Phoenix.Controller.redirect(
41
- to:
42
- Routes.sso_path(conn, :login_form,
43
- prefer: "manual",
44
- email: email,
45
- autosubmit: true,
46
- return_to: return_to
47
- )
48
- )
49
- |> halt()
50
- end
51
-
52
- defp trigger_sso_login(conn, email) do
53
- conn
54
- |> Phoenix.Controller.redirect(
55
- to:
56
- Routes.sso_path(conn, :login_form,
57
- prefer: "manual",
58
- email: email,
59
- autosubmit: true
60
- )
61
- )
62
- |> halt()
63
- end
64
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible_web/plugs/secure_sso.ex DELETED
@@ -1,39 +0,0 @@
1
- defmodule PlausibleWeb.Plugs.SecureSSO do
2
- @moduledoc """
3
- Plug for securing SSO routes by setting proper policies in headers.
4
- """
5
-
6
- alias PlausibleWeb.Router.Helpers, as: Routes
7
-
8
- @csp """
9
- default-src 'none';
10
- script-src 'self' 'nonce-<%= nonce %>' 'report-sample';
11
- img-src 'self' 'report-sample';
12
- report-uri <%= report_path %>;
13
- report-to csp-report-endpoint
14
- """
15
- |> String.replace("\n", " ")
16
-
17
- @behaviour Plug
18
- import Plug.Conn
19
-
20
- @impl true
21
- def init(opts), do: opts
22
-
23
- @impl true
24
- def call(conn, _) do
25
- nonce = :crypto.strong_rand_bytes(18) |> Base.encode64()
26
- csp_report_path = Routes.sso_path(conn, :csp_report)
27
-
28
- conn
29
- |> put_private(:sso_nonce, nonce)
30
- |> Phoenix.Controller.put_secure_browser_headers(%{
31
- "cache-control" => "no-cache, no-store, must-revalidate",
32
- "pragma" => "no-cache",
33
- "reporting-endpoints" => "csp-report-endpoint=\"#{csp_report_path}\"",
34
- "content-security-policy" =>
35
- EEx.eval_string(@csp, nonce: nonce, report_path: csp_report_path),
36
- "x-xss-protection" => "1; mode=block"
37
- })
38
- end
39
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible_web/sso/fake_saml_adapter.ex DELETED
@@ -1,77 +0,0 @@
1
- defmodule PlausibleWeb.SSO.FakeSAMLAdapter do
2
- @moduledoc """
3
- Fake implementation of SAML authentication interface.
4
- """
5
-
6
- alias Plausible.Auth
7
- alias Plausible.Auth.SSO
8
- alias Plausible.Repo
9
-
10
- alias PlausibleWeb.Router.Helpers, as: Routes
11
-
12
- def signin(conn, params) do
13
- conn
14
- |> Phoenix.Controller.put_layout(false)
15
- |> Phoenix.Controller.render("saml_signin.html",
16
- integration_id: params["integration_id"],
17
- email: params["email"],
18
- return_to: params["return_to"],
19
- nonce: conn.private[:sso_nonce]
20
- )
21
- end
22
-
23
- def consume(conn, params) do
24
- case SSO.get_integration(params["integration_id"]) do
25
- {:ok, integration} ->
26
- session_timeout_minutes = integration.team.policy.sso_session_timeout_minutes
27
-
28
- expires_at =
29
- NaiveDateTime.add(NaiveDateTime.utc_now(:second), session_timeout_minutes, :minute)
30
-
31
- identity =
32
- if user = Repo.get_by(Auth.User, email: params["email"]) do
33
- %SSO.Identity{
34
- id: user.sso_identity_id || Ecto.UUID.generate(),
35
- integration_id: integration.identifier,
36
- name: user.name,
37
- email: user.email,
38
- expires_at: expires_at
39
- }
40
- else
41
- %SSO.Identity{
42
- id: Ecto.UUID.generate(),
43
- integration_id: integration.identifier,
44
- name: name_from_email(params["email"]),
45
- email: params["email"],
46
- expires_at: expires_at
47
- }
48
- end
49
-
50
- "sso_login_success"
51
- |> Plausible.Audit.Entry.new(identity, %{team_id: integration.team.id})
52
- |> Plausible.Audit.Entry.include_change(identity)
53
- |> Plausible.Audit.Entry.persist!()
54
-
55
- PlausibleWeb.UserAuth.log_in_user(conn, identity, params["return_to"])
56
-
57
- {:error, :not_found} ->
58
- conn
59
- |> Phoenix.Controller.put_flash(
60
- :login_error,
61
- "We couldn't find a Single Sign-On account for that email."
62
- )
63
- |> Phoenix.Controller.redirect(
64
- to: Routes.sso_path(conn, :login_form, return_to: params["return_to"])
65
- )
66
- end
67
- end
68
-
69
- defp name_from_email(email) do
70
- email
71
- |> String.split("@", parts: 2)
72
- |> List.first()
73
- |> String.split(".")
74
- |> Enum.take(2)
75
- |> Enum.map_join(" ", &String.capitalize/1)
76
- end
77
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible_web/sso/real_saml_adapter.ex DELETED
@@ -1,244 +0,0 @@
1
- defmodule PlausibleWeb.SSO.RealSAMLAdapter do
2
- @moduledoc """
3
- Real implementation of SAML authentication interface.
4
- """
5
- alias Plausible.Auth.SSO
6
-
7
- alias PlausibleWeb.Router.Helpers, as: Routes
8
-
9
- @deflate "urn:oasis:names:tc:SAML:2.0:bindings:URL-Encoding:DEFLATE"
10
-
11
- @cookie_name "session_saml"
12
- @cookie_seconds 10 * 60
13
-
14
- def signin(conn, %{"integration_id" => integration_id} = params) do
15
- email = params["email"]
16
- return_to = params["return_to"]
17
-
18
- case SSO.get_integration(integration_id) do
19
- {:ok, integration} ->
20
- sp_entity_id = SSO.SAMLConfig.entity_id(integration)
21
- relay_state = gen_id()
22
- id = "saml_flow_#{gen_id()}"
23
-
24
- auth_xml = generate_auth_request(sp_entity_id, id, DateTime.utc_now())
25
-
26
- params = %{
27
- "SAMLEncoding" => @deflate,
28
- "SAMLRequest" => Base.encode64(:zlib.zip(auth_xml)),
29
- "RelayState" => relay_state,
30
- "login_hint" => email
31
- }
32
-
33
- url = %URI{} = URI.parse(integration.config.idp_signin_url)
34
-
35
- query_string =
36
- (url.query || "")
37
- |> URI.decode_query()
38
- |> Map.merge(params)
39
- |> URI.encode_query()
40
-
41
- url = URI.to_string(%{url | query: query_string})
42
-
43
- conn
44
- |> Plug.Conn.configure_session(renew: true)
45
- |> set_cookie(
46
- relay_state: relay_state,
47
- return_to: return_to
48
- )
49
- |> Phoenix.Controller.redirect(external: url)
50
-
51
- {:error, :not_found} ->
52
- conn
53
- |> Phoenix.Controller.put_flash(
54
- :login_error,
55
- "We couldn't find a Single Sign-On account for that email."
56
- )
57
- |> Phoenix.Controller.redirect(
58
- to: Routes.sso_path(conn, :login_form, return_to: return_to)
59
- )
60
- end
61
- end
62
-
63
- def consume(conn, _params) do
64
- integration_id = conn.path_params["integration_id"]
65
- saml_response = conn.body_params["SAMLResponse"]
66
- relay_state = conn.body_params["RelayState"] |> safe_decode_www_form()
67
-
68
- case get_cookie(conn) do
69
- {:ok, cookie} ->
70
- conn
71
- |> clear_cookie()
72
- |> consume(integration_id, cookie, saml_response, relay_state)
73
-
74
- {:error, :session_expired} ->
75
- conn
76
- |> Phoenix.Controller.put_flash(:login_error, "Session expired.")
77
- |> Phoenix.Controller.redirect(to: Routes.sso_path(conn, :login_form))
78
- end
79
- end
80
-
81
- @verify_opts if Mix.env() == :test, do: [skip_time_conditions?: true], else: []
82
-
83
- defp consume(conn, integration_id, cookie, saml_response, relay_state) do
84
- with {:ok, integration} <- SSO.get_integration(integration_id),
85
- :ok <- validate_authresp(cookie, relay_state),
86
- {:ok, {root, assertion}} <- SimpleSaml.parse_response(saml_response),
87
- {:ok, cert} <- convert_pem_cert(integration.config.idp_cert_pem),
88
- public_key = X509.Certificate.public_key(cert),
89
- :ok <-
90
- SimpleSaml.verify_and_validate_response(root, assertion, public_key, @verify_opts),
91
- {:ok, attributes} <- extract_attributes(assertion) do
92
- session_timeout_minutes = integration.team.policy.sso_session_timeout_minutes
93
-
94
- expires_at =
95
- NaiveDateTime.add(NaiveDateTime.utc_now(:second), session_timeout_minutes, :minute)
96
-
97
- identity =
98
- %SSO.Identity{
99
- id: assertion.name_id,
100
- integration_id: integration.identifier,
101
- name: name_from_attributes(attributes),
102
- email: attributes.email,
103
- expires_at: expires_at
104
- }
105
-
106
- "sso_login_success"
107
- |> Plausible.Audit.Entry.new(identity, %{team_id: integration.team.id})
108
- |> Plausible.Audit.Entry.include_change(identity)
109
- |> Plausible.Audit.Entry.persist!()
110
-
111
- PlausibleWeb.UserAuth.log_in_user(conn, identity, cookie.return_to)
112
- else
113
- {:error, :not_found} ->
114
- login_error(
115
- conn,
116
- cookie,
117
- "We couldn't find a Single Sign-On account for that email."
118
- )
119
-
120
- {:error, reason} ->
121
- with {:ok, integration} <- SSO.get_integration(integration_id) do
122
- "sso_login_failure"
123
- |> Plausible.Audit.Entry.new(integration, %{team_id: integration.team.id})
124
- |> Plausible.Audit.Entry.include_change(%{
125
- error: inspect(reason)
126
- })
127
- |> Plausible.Audit.Entry.persist!()
128
- end
129
-
130
- login_error(conn, cookie, "Authentication failed (reason: #{inspect(reason)})")
131
- end
132
- end
133
-
134
- defp convert_pem_cert(cert) do
135
- case X509.Certificate.from_pem(cert) do
136
- {:ok, cert} -> {:ok, cert}
137
- {:error, _} -> {:error, :malformed_certificate}
138
- end
139
- end
140
-
141
- defp name_from_attributes(attributes) do
142
- [attributes.first_name, attributes.last_name]
143
- |> Enum.reject(&is_nil/1)
144
- |> Enum.join(" ")
145
- |> String.trim()
146
- end
147
-
148
- defp extract_attributes(assertion) do
149
- attributes =
150
- Enum.reduce([:email, :first_name, :last_name], %{}, fn field, attrs ->
151
- value =
152
- assertion.attributes
153
- |> Map.get(to_string(field), [])
154
- |> List.first()
155
-
156
- Map.put(attrs, field, String.trim(value || ""))
157
- end)
158
-
159
- cond do
160
- attributes.email == "" ->
161
- {:error, :missing_email_attribute}
162
-
163
- # very rudimentary way to check if the attribute is at least email-like
164
- not String.contains?(attributes.email, "@") or String.length(attributes.email) < 3 ->
165
- {:error, :invalid_email_attribute}
166
-
167
- attributes.first_name == "" and attributes.last_name == "" ->
168
- {:error, :missing_name_attributes}
169
-
170
- true ->
171
- {:ok, attributes}
172
- end
173
- end
174
-
175
- defp safe_decode_www_form(nil), do: ""
176
- defp safe_decode_www_form(data), do: URI.decode_www_form(data)
177
-
178
- defp generate_auth_request(issuer_id, id, timestamp) do
179
- XmlBuilder.generate(
180
- {:"samlp:AuthnRequest",
181
- [
182
- "xmlns:samlp": "urn:oasis:names:tc:SAML:2.0:protocol",
183
- ID: id,
184
- Version: "2.0",
185
- IssueInstant: DateTime.to_iso8601(timestamp)
186
- ], [{:"saml:Issuer", ["xmlns:saml": "urn:oasis:names:tc:SAML:2.0:assertion"], issuer_id}]}
187
- )
188
- end
189
-
190
- defp validate_authresp(%{relay_state: relay_state}, relay_state)
191
- when byte_size(relay_state) == 32 do
192
- :ok
193
- end
194
-
195
- defp validate_authresp(_, _), do: {:error, :invalid_relay_state}
196
-
197
- defp gen_id() do
198
- 24 |> :crypto.strong_rand_bytes() |> Base.url_encode64()
199
- end
200
-
201
- @doc false
202
- def set_cookie(conn, attrs) do
203
- attrs = %{
204
- relay_state: Keyword.fetch!(attrs, :relay_state),
205
- return_to: Keyword.fetch!(attrs, :return_to)
206
- }
207
-
208
- Plug.Conn.put_resp_cookie(conn, @cookie_name, attrs,
209
- domain: conn.private.phoenix_endpoint.host(),
210
- secure: true,
211
- encrypt: true,
212
- max_age: @cookie_seconds,
213
- same_site: "None"
214
- )
215
- end
216
-
217
- defp get_cookie(conn) do
218
- conn = Plug.Conn.fetch_cookies(conn, encrypted: [@cookie_name])
219
-
220
- if cookie = conn.cookies[@cookie_name] do
221
- {:ok, cookie}
222
- else
223
- {:error, :session_expired}
224
- end
225
- end
226
-
227
- defp clear_cookie(conn) do
228
- Plug.Conn.delete_resp_cookie(conn, @cookie_name,
229
- domain: conn.private.phoenix_endpoint.host(),
230
- secure: true,
231
- encrypt: true,
232
- max_age: @cookie_seconds,
233
- same_site: "None"
234
- )
235
- end
236
-
237
- defp login_error(conn, cookie, login_error) do
238
- conn
239
- |> Phoenix.Controller.put_flash(:login_error, login_error)
240
- |> Phoenix.Controller.redirect(
241
- to: Routes.sso_path(conn, :login_form, return_to: cookie.return_to)
242
- )
243
- end
244
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible_web/templates/help_scout/layout.html.heex DELETED
@@ -1,13 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="en" class="h-full plausible">
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta http-equiv="X-UA-Compatible" content="IE=edge" />
6
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
- <title>Plausible · HelpScout Integration</title>
8
- <link rel="stylesheet" href={Routes.static_path(@conn, "/css/app.css")} />
9
- </head>
10
- <body class="flex flex-col bg-gray-50">
11
- {@inner_content}
12
- </body>
13
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible_web/templates/sso/cta.html.heex DELETED
@@ -1,18 +0,0 @@
1
- <.settings_tiles>
2
- <.tile docs="sso">
3
- <:title>
4
- <a id="sso-config">Single Sign-On</a>
5
- </:title>
6
-
7
- <.upgrade title="Available on our Enterprise plan">
8
- <:icon>
9
- <Heroicons.cloud solid class="size-6" />
10
- </:icon>
11
- Restrict access so only team members signing in via SSO can access your account. <br />Email
12
- <.styled_link href="mailto:hello@plausible.io" class="font-medium">
13
- hello@plausible.io
14
- </.styled_link>
15
- to upgrade.
16
- </.upgrade>
17
- </.tile>
18
- </.settings_tiles>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible_web/templates/sso/login_form.html.heex DELETED
@@ -1,54 +0,0 @@
1
- <.auth_container>
2
- <script :if={@autosubmit}>
3
- document.addEventListener("DOMContentLoaded", function () {
4
- document.getElementById("sso-login-form").submit();
5
- });
6
- </script>
7
-
8
- <.form
9
- :let={f}
10
- id="sso-login-form"
11
- for={@conn}
12
- action={Routes.sso_path(@conn, :login)}
13
- class="flex flex-col gap-y-6"
14
- >
15
- <div class="flex flex-col gap-y-2">
16
- <label for={f[:email].id} class="text-sm font-semibold text-gray-800 dark:text-gray-200">
17
- Email
18
- </label>
19
- <div>
20
- <.input
21
- type="email"
22
- autocomplete="username"
23
- placeholder="example@email.com"
24
- field={f[:email]}
25
- mt?={false}
26
- autofocus="autofocus"
27
- />
28
- </div>
29
- <p
30
- :if={login_error = Phoenix.Flash.get(@flash, :login_error)}
31
- class="text-xs text-red-500"
32
- >
33
- {login_error}
34
- </p>
35
- </div>
36
-
37
- <input type="hidden" name="return_to" value={@conn.params["return_to"]} />
38
-
39
- <div class="flex flex-col gap-y-4">
40
- <.button class="w-full" type="submit" mt?={false}>Sign in</.button>
41
-
42
- <p class="text-sm text-center text-gray-500 dark:text-gray-400">
43
- <.styled_link href={
44
- Routes.auth_path(@conn, :login_form,
45
- return_to: @conn.params["return_to"],
46
- prefer: "manual"
47
- )
48
- }>
49
- Use password instead
50
- </.styled_link>
51
- </p>
52
- </div>
53
- </.form>
54
- </.auth_container>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible_web/templates/sso/provision_issue.html.heex DELETED
@@ -1,38 +0,0 @@
1
- <.auth_container>
2
- <div class="flex flex-col items-center gap-y-4 text-center text-base text-gray-500 dark:text-gray-400 text-pretty">
3
- <p>
4
- The owner of
5
- <span :if={is_nil(@conn.assigns[:current_team])}>the team</span><span
6
- :if={@conn.assigns[:current_team]}
7
- class="font-semibold"
8
- >"{@conn.assigns[:current_team].name}"</span>
9
- has turned off email and password logins, so you can only sign in with your organization's Single Sign-On.
10
- <span :if={@issue == :not_a_member}>
11
- To access this team, you must be added as a team member first.
12
- </span>
13
- <span :if={@issue == :multiple_memberships}>
14
- To access this team, you must leave all other teams first.
15
- </span>
16
- <span :if={@issue == :multiple_memberships_noforce}>
17
- To sign in with SSO, you must leave all other teams first.
18
- </span>
19
- <span :if={@issue == :active_personal_team}>
20
- To access this team, remove or transfer all sites under
21
- <span class="font-semibold">"My Personal Sites"</span>
22
- and cancel its subscription if active.
23
- </span>
24
- <span :if={@issue == :active_personal_team_noforce}>
25
- To sign in with SSO, remove or transfer all sites under
26
- <span class="font-semibold">"My Personal Sites"</span>
27
- and cancel its subscription if active.
28
- </span>
29
- </p>
30
-
31
- <p :if={!@conn.assigns[:current_user]}>
32
- <.styled_link href={Routes.auth_path(@conn, :login_form, prefer: "manual")}>
33
- Sign in with email and password
34
- </.styled_link>
35
- to resolve the issue.
36
- </p>
37
- </div>
38
- </.auth_container>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible_web/templates/sso/provision_notice.html.heex DELETED
@@ -1,17 +0,0 @@
1
- <.auth_container>
2
- <div class="flex flex-col items-center gap-y-4 text-center text-base text-gray-500 dark:text-gray-400 text-pretty">
3
- <p>
4
- The owner of
5
- <span :if={is_nil(@conn.assigns[:current_team])}>the team</span><span
6
- :if={@conn.assigns[:current_team]}
7
- class="font-semibold"
8
- >"{@conn.assigns[:current_team].name}"</span>
9
- has turned off email and password logins, so you can only sign in with your organization's Single Sign-On.
10
- </p>
11
- <p>
12
- To access this team,
13
- <.styled_link href={Routes.auth_path(@conn, :logout)}>sign out</.styled_link>
14
- and sign in with SSO.
15
- </p>
16
- </div>
17
- </.auth_container>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible_web/templates/sso/saml_signin.html.heex DELETED
@@ -1,36 +0,0 @@
1
- <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
2
- "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
3
- <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
4
- <head>
5
- <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
6
- </head>
7
- <body>
8
- <script nonce={@nonce}>
9
- document.addEventListener("DOMContentLoaded", function () {
10
- document.getElementById("sso-req-form").submit();
11
- });
12
- </script>
13
- <div>
14
- <h1>
15
- Processing Single Sign-On request...
16
- </h1>
17
- <noscript>
18
- <p>
19
- Since your browser does not support JavaScript, you must press
20
- the button below to proceed.
21
- </p>
22
- </noscript>
23
- <.form
24
- :let={f}
25
- id="sso-req-form"
26
- for={@conn}
27
- action={Routes.sso_path(@conn, :saml_consume, @integration_id)}
28
- >
29
- <.input type="hidden" field={f[:email]} />
30
- <.input type="hidden" field={f[:return_to]} />
31
-
32
- <.button class="w-full" type="submit">sign in</.button>
33
- </.form>
34
- </div>
35
- </body>
36
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible_web/templates/sso/sso_settings.html.heex DELETED
@@ -1,3 +0,0 @@
1
- <.settings_tiles>
2
- {live_render(@conn, PlausibleWeb.Live.SSOManagement, id: "sso-management")}
3
- </.settings_tiles>
 
 
 
 
extra/lib/plausible_web/templates/sso/team_sessions.html.heex DELETED
@@ -1,36 +0,0 @@
1
- <.settings_tiles>
2
- <.tile docs="sso#team-management">
3
- <:title>
4
- <a id="user-sessions">SSO Login Management</a>
5
- </:title>
6
- <:subtitle>
7
- Review and log out Single Sign-On user sessions
8
- </:subtitle>
9
-
10
- <div :if={@sso_sessions == []} class="mt-4 dark:text-gray-100 text-center">
11
- There are currently no active SSO sessions
12
- </div>
13
-
14
- <.table id="sso-sessions-list" rows={@sso_sessions}>
15
- <:thead>
16
- <.th>User</.th>
17
- <.th hide_on_mobile>Device</.th>
18
- <.th hide_on_mobile>Last seen</.th>
19
- <.th invisible>Actions</.th>
20
- </:thead>
21
- <:tbody :let={session}>
22
- <.td truncate max_width="max-w-40">{session.user.name}</.td>
23
- <.td hide_on_mobile>{session.device}</.td>
24
- <.td hide_on_mobile>{Plausible.Auth.UserSessions.last_used_humanize(session)}</.td>
25
- <.td :if={@current_user_session.id == session.id} actions>Current session</.td>
26
- <.td :if={@current_user_session.id != session.id} actions>
27
- <.delete_button
28
- href={Routes.sso_path(@conn, :delete_session, session.id)}
29
- method="delete"
30
- data-confirm="Are you sure you want to log out this session?"
31
- />
32
- </.td>
33
- </:tbody>
34
- </.table>
35
- </.tile>
36
- </.settings_tiles>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible_web/views/help_scout_view.ex DELETED
@@ -1,297 +0,0 @@
1
- defmodule PlausibleWeb.HelpScoutView do
2
- use PlausibleWeb, :extra_view
3
-
4
- def render("callback.html", assigns) do
5
- ~H"""
6
- <.layout xhr?={assigns[:xhr?]}>
7
- <div class="search">
8
- <form action="/helpscout/search">
9
- <p class="entry w-full">
10
- <input
11
- type="text"
12
- name="term"
13
- class="w-48 bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block p-2.5"
14
- value={assigns[:email]}
15
- />
16
- <input type="submit" name="search" value="&nbsp;&#x1F50E;&nbsp;" />
17
- </p>
18
- <input type="hidden" name="token" value={@token} />
19
- <input type="hidden" name="conversation_id" value={@conversation_id} />
20
- <input type="hidden" name="customer_id" value={@customer_id} />
21
- </form>
22
- </div>
23
-
24
- <%= cond do %>
25
- <% @conn.assigns[:error] -> %>
26
- <p>
27
- Failed to get details: {@error}
28
- </p>
29
- <% @multiple_teams? -> %>
30
- <div class="teams">
31
- <p class="label">
32
- <a
33
- href={@user_link}
34
- class="text-blue-600 dark:text-blue-500 hover:underline"
35
- target="_blank"
36
- >
37
- Owner
38
- </a>
39
- of teams:
40
- </p>
41
-
42
- <div class="value">
43
- <ul>
44
- <li :for={team <- @teams}>
45
- <a
46
- class="text-blue-600 dark:text-blue-500 hover:underline"
47
- onclick={"loadContent('/helpscout/show?#{URI.encode_query(
48
- email: @email,
49
- conversation_id: @conversation_id,
50
- customer_id: @customer_id,
51
- team_identifier: team.identifier,
52
- token: @token)}')"}
53
- href="#"
54
- >
55
- {team.name} ({team.sites_count} sites)
56
- </a>
57
- </li>
58
- </ul>
59
- </div>
60
- </div>
61
-
62
- <div :if={@notes} class="notes">
63
- <p class="label">
64
- <b>User notes</b>
65
- </p>
66
-
67
- <div class="value">
68
- {PhoenixHTMLHelpers.Format.text_to_html(@notes, escape: true)}
69
- </div>
70
- </div>
71
- <% true -> %>
72
- <div :if={@team_setup?} class="team-name">
73
- <p class="label">
74
- Team name
75
- </p>
76
- <p class="value">
77
- <a
78
- href={@status_link}
79
- class="text-blue-600 dark:text-blue-500 hover:underline"
80
- target="_blank"
81
- >
82
- {@team_name}
83
- </a>
84
- </p>
85
- </div>
86
-
87
- <div class="status">
88
- <p class="label">
89
- Status
90
- </p>
91
- <p class="value">
92
- <a
93
- href={@status_link}
94
- class="text-blue-600 dark:text-blue-500 hover:underline"
95
- target="_blank"
96
- >
97
- {@status_label}
98
- </a>
99
- </p>
100
- </div>
101
-
102
- <div class="plan">
103
- <p class="label">
104
- Plan
105
- </p>
106
- <p class="value">
107
- <a
108
- href={@plan_link}
109
- class="text-blue-600 dark:text-blue-500 hover:underline"
110
- target="_blank"
111
- >
112
- {@plan_label}
113
- </a>
114
- </p>
115
- </div>
116
-
117
- <div class="sites">
118
- <p class="label">
119
- Owner of {@sites_count} sites
120
- </p>
121
- <p class="value"></p>
122
- </div>
123
-
124
- <div :if={@notes} class="notes">
125
- <p class="label">
126
- <b>Notes</b>
127
- </p>
128
-
129
- <div class="value">
130
- {PhoenixHTMLHelpers.Format.text_to_html(@notes, escape: true)}
131
- </div>
132
- </div>
133
- <% end %>
134
- </.layout>
135
- """
136
- end
137
-
138
- def render("search.html", assigns) do
139
- ~H"""
140
- <.layout>
141
- <%= if @conn.assigns[:error] do %>
142
- <p>
143
- Failed to run search: {@error}
144
- </p>
145
- <% else %>
146
- <div class="search">
147
- <form action="/helpscout/search">
148
- <p class="entry">
149
- <input
150
- type="text"
151
- class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
152
- name="term"
153
- value={@term}
154
- />
155
- <input type="submit" name="search" value="&nbsp;&#x1F50E;&nbsp;" />
156
- </p>
157
- <input type="hidden" name="token" value={@token} />
158
- <input type="hidden" name="conversation_id" value={@conversation_id} />
159
- <input type="hidden" name="customer_id" value={@customer_id} />
160
- </form>
161
- <ul :if={length(@users) > 0}>
162
- <li :for={user <- @users}>
163
- <a
164
- onclick={"loadContent('/helpscout/show?#{URI.encode_query(email: user.email, conversation_id: @conversation_id, customer_id: @customer_id, token: @token)}')"}
165
- class="text-blue-600 dark:text-blue-500 hover:underline"
166
- href="#"
167
- >
168
- {user.email} ({user.sites_count} sites)
169
- </a>
170
- </li>
171
- </ul>
172
- <div :if={@users == []}>
173
- No match found
174
- </div>
175
- </div>
176
- <% end %>
177
- </.layout>
178
- """
179
- end
180
-
181
- def render("bad_request.html", assigns) do
182
- ~H"""
183
- <.layout>
184
- <p>Missing expected parameters</p>
185
- </.layout>
186
- """
187
- end
188
-
189
- attr :xhr?, :boolean, default: false
190
- slot :inner_block, required: true
191
-
192
- defp layout(assigns) do
193
- if assigns.xhr? do
194
- ~H"""
195
- render_slot(@inner_block)
196
- """
197
- else
198
- ~H"""
199
- <!DOCTYPE html>
200
- <html lang="en">
201
- <head>
202
- <meta charset="utf-8" />
203
- <meta name="viewport" content="width=device-width, initial-scale=1" />
204
- <title>Helpscout Customer Details</title>
205
- <style type="text/css">
206
- * {
207
- margin: 0;
208
- padding: 0;
209
- }
210
-
211
- body {
212
- font-family: Helvetica, Arial, Sans-Serif;
213
- font-size: 14px;
214
- }
215
-
216
- ul {
217
- list-style-type: none;
218
- }
219
-
220
- p, ul {
221
- margin-left: 1.25em;
222
- }
223
-
224
- .entry {
225
- width: 100%;
226
- display: flex;
227
- }
228
-
229
- ul li, .entry {
230
- margin-bottom: 1.25em;
231
- }
232
-
233
- .teams .label {
234
- margin-bottom: 1em;
235
- }
236
-
237
- .value {
238
- margin-bottom: 1.25em;
239
- font-weight: bold;
240
- }
241
-
242
- .notes .value {
243
- font-weight: normal;
244
- }
245
- </style>
246
- </head>
247
-
248
- <body>
249
- <div id="content">
250
- {render_slot(@inner_block)}
251
- </div>
252
-
253
- <script type="text/javascript">
254
- /*
255
- * HelpScout call adjusting iframe height.
256
- *
257
- * Extracted from their JavaScript SDK and adapted.
258
- */
259
- function setAppHeight(height) {
260
- window.parent.postMessage(
261
- {
262
- value: height + 30,
263
- type: 'SET_APP_HEIGHT',
264
- appId: window.name && window.name.replace(/app-side-panel-|app-/, ''),
265
- iframeId: window.name
266
- },
267
- 'https://secure.helpscout.net/'
268
- )
269
- }
270
-
271
- const appContainer = document.getElementById("content")
272
-
273
- async function loadContent(uri) {
274
- const response = await fetch(uri)
275
- const html = await response.text()
276
- appContainer.innerHTML = html
277
- setAppHeight(appContainer.clientHeight)
278
- }
279
-
280
- setAppHeight(appContainer.clientHeight)
281
-
282
- /*
283
- * Tracking any resize of integration content
284
- * and adjusting iframe height accordingly.
285
- */
286
- const resizeObserver = new ResizeObserver(() => {
287
- setAppHeight(appContainer.clientHeight)
288
- })
289
-
290
- resizeObserver.observe(appContainer)
291
- </script>
292
- </body>
293
- </html>
294
- """
295
- end
296
- end
297
- end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extra/lib/plausible_web/views/sso_view.ex DELETED
@@ -1,3 +0,0 @@
1
- defmodule PlausibleWeb.SSOView do
2
- use PlausibleWeb, :extra_view
3
- end
 
 
 
 
fixture/ga4_api_disabled_error.json DELETED
@@ -1,27 +0,0 @@
1
- {
2
- "error": {
3
- "code": 403,
4
- "details": [
5
- {
6
- "@type": "type.googleapis.com/google.rpc.Help",
7
- "links": [
8
- {
9
- "description": "Google developers console API activation",
10
- "url": "https://console.developers.google.com/apis/api/analyticsadmin.googleapis.com/overview?project=752168887897"
11
- }
12
- ]
13
- },
14
- {
15
- "@type": "type.googleapis.com/google.rpc.ErrorInfo",
16
- "domain": "googleapis.com",
17
- "metadata": {
18
- "consumer": "projects/752168887897",
19
- "service": "analyticsadmin.googleapis.com"
20
- },
21
- "reason": "SERVICE_DISABLED"
22
- }
23
- ],
24
- "message": "Google Analytics Admin API has not been used in project 752168887897 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/analyticsadmin.googleapis.com/overview?project=752168887897 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.",
25
- "status": "PERMISSION_DENIED"
26
- }
27
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fixture/ga4_end_date.json DELETED
@@ -1,38 +0,0 @@
1
- {
2
- "kind": "analyticsData#batchRunReports",
3
- "reports": [
4
- {
5
- "dimensionHeaders": [
6
- {
7
- "name": "date"
8
- }
9
- ],
10
- "kind": "analyticsData#runReport",
11
- "metadata": {
12
- "currencyCode": "USD",
13
- "timeZone": "Europe/Warsaw"
14
- },
15
- "metricHeaders": [
16
- {
17
- "name": "screenPageViews",
18
- "type": "TYPE_INTEGER"
19
- }
20
- ],
21
- "rowCount": 3,
22
- "rows": [
23
- {
24
- "dimensionValues": [
25
- {
26
- "value": "20240302"
27
- }
28
- ],
29
- "metricValues": [
30
- {
31
- "value": "13"
32
- }
33
- ]
34
- }
35
- ]
36
- }
37
- ]
38
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fixture/ga4_get_property.json DELETED
@@ -1,12 +0,0 @@
1
- {
2
- "account": "accounts/28425555",
3
- "createTime": "2024-02-22T10:50:15.462Z",
4
- "currencyCode": "USD",
5
- "displayName": "account.one - GA4",
6
- "name": "properties/428685444",
7
- "parent": "accounts/28425555",
8
- "propertyType": "PROPERTY_TYPE_ORDINARY",
9
- "serviceLevel": "GOOGLE_ANALYTICS_STANDARD",
10
- "timeZone": "Europe/Warsaw",
11
- "updateTime": "2024-02-22T10:50:15.462Z"
12
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
fixture/ga4_list_properties.json DELETED
@@ -1,41 +0,0 @@
1
- {
2
- "accountSummaries": [
3
- {
4
- "account": "accounts/28425178",
5
- "displayName": "account.one",
6
- "name": "accountSummaries/28425178",
7
- "propertySummaries": [
8
- {
9
- "displayName": "account.one - GA4",
10
- "parent": "accounts/28425178",
11
- "property": "properties/428685906",
12
- "propertyType": "PROPERTY_TYPE_ORDINARY"
13
- }
14
- ]
15
- },
16
- {
17
- "account": "accounts/45336102",
18
- "displayName": "account.two",
19
- "name": "accountSummaries/45336102"
20
- },
21
- {
22
- "account": "accounts/54516992",
23
- "displayName": "Demo Account",
24
- "name": "accountSummaries/54516992",
25
- "propertySummaries": [
26
- {
27
- "displayName": "GA4 - Flood-It!",
28
- "parent": "accounts/54516992",
29
- "property": "properties/153293282",
30
- "propertyType": "PROPERTY_TYPE_ORDINARY"
31
- },
32
- {
33
- "displayName": "GA4 - Google Merch Shop",
34
- "parent": "accounts/54516992",
35
- "property": "properties/213025502",
36
- "propertyType": "PROPERTY_TYPE_ORDINARY"
37
- }
38
- ]
39
- }
40
- ]
41
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fixture/ga4_report_imported_browsers.json DELETED
The diff for this file is too large to render. See raw diff
 
fixture/ga4_report_imported_custom_events.json DELETED
@@ -1,1212 +0,0 @@
1
- {
2
- "kind": "analyticsData#batchRunReports",
3
- "reports": [
4
- {
5
- "dimensionHeaders": [
6
- {
7
- "name": "date"
8
- },
9
- {
10
- "name": "eventName"
11
- },
12
- {
13
- "name": "linkUrl"
14
- }
15
- ],
16
- "kind": "analyticsData#runReport",
17
- "metadata": {
18
- "currencyCode": "USD",
19
- "timeZone": "Europe/Amsterdam"
20
- },
21
- "metricHeaders": [
22
- {
23
- "name": "totalUsers",
24
- "type": "TYPE_INTEGER"
25
- },
26
- {
27
- "name": "eventCount",
28
- "type": "TYPE_INTEGER"
29
- }
30
- ],
31
- "rowCount": 56,
32
- "rows": [
33
- {
34
- "dimensionValues": [
35
- {
36
- "value": "20240131"
37
- },
38
- {
39
- "value": "scroll"
40
- },
41
- {
42
- "value": ""
43
- }
44
- ],
45
- "metricValues": [
46
- {
47
- "value": "40"
48
- },
49
- {
50
- "value": "55"
51
- }
52
- ]
53
- },
54
- {
55
- "dimensionValues": [
56
- {
57
- "value": "20240130"
58
- },
59
- {
60
- "value": "scroll"
61
- },
62
- {
63
- "value": ""
64
- }
65
- ],
66
- "metricValues": [
67
- {
68
- "value": "42"
69
- },
70
- {
71
- "value": "61"
72
- }
73
- ]
74
- },
75
- {
76
- "dimensionValues": [
77
- {
78
- "value": "20240129"
79
- },
80
- {
81
- "value": "scroll"
82
- },
83
- {
84
- "value": ""
85
- }
86
- ],
87
- "metricValues": [
88
- {
89
- "value": "40"
90
- },
91
- {
92
- "value": "57"
93
- }
94
- ]
95
- },
96
- {
97
- "dimensionValues": [
98
- {
99
- "value": "20240128"
100
- },
101
- {
102
- "value": "scroll"
103
- },
104
- {
105
- "value": ""
106
- }
107
- ],
108
- "metricValues": [
109
- {
110
- "value": "67"
111
- },
112
- {
113
- "value": "85"
114
- }
115
- ]
116
- },
117
- {
118
- "dimensionValues": [
119
- {
120
- "value": "20240128"
121
- },
122
- {
123
- "value": "click"
124
- },
125
- {
126
- "value": "https://www.facebook.com/kuhinjskeprice"
127
- }
128
- ],
129
- "metricValues": [
130
- {
131
- "value": "1"
132
- },
133
- {
134
- "value": "1"
135
- }
136
- ]
137
- },
138
- {
139
- "dimensionValues": [
140
- {
141
- "value": "20240128"
142
- },
143
- {
144
- "value": "view_search_results"
145
- },
146
- {
147
- "value": ""
148
- }
149
- ],
150
- "metricValues": [
151
- {
152
- "value": "1"
153
- },
154
- {
155
- "value": "1"
156
- }
157
- ]
158
- },
159
- {
160
- "dimensionValues": [
161
- {
162
- "value": "20240127"
163
- },
164
- {
165
- "value": "scroll"
166
- },
167
- {
168
- "value": ""
169
- }
170
- ],
171
- "metricValues": [
172
- {
173
- "value": "51"
174
- },
175
- {
176
- "value": "65"
177
- }
178
- ]
179
- },
180
- {
181
- "dimensionValues": [
182
- {
183
- "value": "20240127"
184
- },
185
- {
186
- "value": "click"
187
- },
188
- {
189
- "value": "http://www.coolinarika.com/recept/omiljeni-mramorni-kuglof/"
190
- }
191
- ],
192
- "metricValues": [
193
- {
194
- "value": "1"
195
- },
196
- {
197
- "value": "1"
198
- }
199
- ]
200
- },
201
- {
202
- "dimensionValues": [
203
- {
204
- "value": "20240126"
205
- },
206
- {
207
- "value": "scroll"
208
- },
209
- {
210
- "value": ""
211
- }
212
- ],
213
- "metricValues": [
214
- {
215
- "value": "50"
216
- },
217
- {
218
- "value": "70"
219
- }
220
- ]
221
- },
222
- {
223
- "dimensionValues": [
224
- {
225
- "value": "20240126"
226
- },
227
- {
228
- "value": "view_search_results"
229
- },
230
- {
231
- "value": ""
232
- }
233
- ],
234
- "metricValues": [
235
- {
236
- "value": "1"
237
- },
238
- {
239
- "value": "1"
240
- }
241
- ]
242
- },
243
- {
244
- "dimensionValues": [
245
- {
246
- "value": "20240125"
247
- },
248
- {
249
- "value": "scroll"
250
- },
251
- {
252
- "value": ""
253
- }
254
- ],
255
- "metricValues": [
256
- {
257
- "value": "58"
258
- },
259
- {
260
- "value": "98"
261
- }
262
- ]
263
- },
264
- {
265
- "dimensionValues": [
266
- {
267
- "value": "20240125"
268
- },
269
- {
270
- "value": "click"
271
- },
272
- {
273
- "value": "https://www.google.co.uk/imgres?imgurl=http://30.media.tumblr.com/oapJteChehjbp5traCrAENnXo1_500.jpg"
274
- }
275
- ],
276
- "metricValues": [
277
- {
278
- "value": "1"
279
- },
280
- {
281
- "value": "1"
282
- }
283
- ]
284
- },
285
- {
286
- "dimensionValues": [
287
- {
288
- "value": "20240125"
289
- },
290
- {
291
- "value": "view_search_results"
292
- },
293
- {
294
- "value": ""
295
- }
296
- ],
297
- "metricValues": [
298
- {
299
- "value": "1"
300
- },
301
- {
302
- "value": "1"
303
- }
304
- ]
305
- },
306
- {
307
- "dimensionValues": [
308
- {
309
- "value": "20240124"
310
- },
311
- {
312
- "value": "scroll"
313
- },
314
- {
315
- "value": ""
316
- }
317
- ],
318
- "metricValues": [
319
- {
320
- "value": "61"
321
- },
322
- {
323
- "value": "97"
324
- }
325
- ]
326
- },
327
- {
328
- "dimensionValues": [
329
- {
330
- "value": "20240124"
331
- },
332
- {
333
- "value": "click"
334
- },
335
- {
336
- "value": "http://www.ikea.com/dk/da/catalog/products/20213835/"
337
- }
338
- ],
339
- "metricValues": [
340
- {
341
- "value": "1"
342
- },
343
- {
344
- "value": "1"
345
- }
346
- ]
347
- },
348
- {
349
- "dimensionValues": [
350
- {
351
- "value": "20240124"
352
- },
353
- {
354
- "value": "click"
355
- },
356
- {
357
- "value": "https://www.facebook.com/kuhinjskeprice"
358
- }
359
- ],
360
- "metricValues": [
361
- {
362
- "value": "1"
363
- },
364
- {
365
- "value": "1"
366
- }
367
- ]
368
- },
369
- {
370
- "dimensionValues": [
371
- {
372
- "value": "20240123"
373
- },
374
- {
375
- "value": "scroll"
376
- },
377
- {
378
- "value": ""
379
- }
380
- ],
381
- "metricValues": [
382
- {
383
- "value": "74"
384
- },
385
- {
386
- "value": "114"
387
- }
388
- ]
389
- },
390
- {
391
- "dimensionValues": [
392
- {
393
- "value": "20240123"
394
- },
395
- {
396
- "value": "view_search_results"
397
- },
398
- {
399
- "value": ""
400
- }
401
- ],
402
- "metricValues": [
403
- {
404
- "value": "1"
405
- },
406
- {
407
- "value": "1"
408
- }
409
- ]
410
- },
411
- {
412
- "dimensionValues": [
413
- {
414
- "value": "20240122"
415
- },
416
- {
417
- "value": "scroll"
418
- },
419
- {
420
- "value": ""
421
- }
422
- ],
423
- "metricValues": [
424
- {
425
- "value": "68"
426
- },
427
- {
428
- "value": "95"
429
- }
430
- ]
431
- },
432
- {
433
- "dimensionValues": [
434
- {
435
- "value": "20240122"
436
- },
437
- {
438
- "value": "click"
439
- },
440
- {
441
- "value": "https://pinterest.com/pin/68539225549526297/"
442
- }
443
- ],
444
- "metricValues": [
445
- {
446
- "value": "1"
447
- },
448
- {
449
- "value": "1"
450
- }
451
- ]
452
- },
453
- {
454
- "dimensionValues": [
455
- {
456
- "value": "20240122"
457
- },
458
- {
459
- "value": "click"
460
- },
461
- {
462
- "value": "https://www.facebook.com/kuhinjskeprice"
463
- }
464
- ],
465
- "metricValues": [
466
- {
467
- "value": "1"
468
- },
469
- {
470
- "value": "1"
471
- }
472
- ]
473
- },
474
- {
475
- "dimensionValues": [
476
- {
477
- "value": "20240121"
478
- },
479
- {
480
- "value": "scroll"
481
- },
482
- {
483
- "value": ""
484
- }
485
- ],
486
- "metricValues": [
487
- {
488
- "value": "116"
489
- },
490
- {
491
- "value": "181"
492
- }
493
- ]
494
- },
495
- {
496
- "dimensionValues": [
497
- {
498
- "value": "20240121"
499
- },
500
- {
501
- "value": "view_search_results"
502
- },
503
- {
504
- "value": ""
505
- }
506
- ],
507
- "metricValues": [
508
- {
509
- "value": "2"
510
- },
511
- {
512
- "value": "3"
513
- }
514
- ]
515
- },
516
- {
517
- "dimensionValues": [
518
- {
519
- "value": "20240120"
520
- },
521
- {
522
- "value": "scroll"
523
- },
524
- {
525
- "value": ""
526
- }
527
- ],
528
- "metricValues": [
529
- {
530
- "value": "111"
531
- },
532
- {
533
- "value": "190"
534
- }
535
- ]
536
- },
537
- {
538
- "dimensionValues": [
539
- {
540
- "value": "20240120"
541
- },
542
- {
543
- "value": "click"
544
- },
545
- {
546
- "value": "https://www.facebook.com/kuhinjskeprice"
547
- }
548
- ],
549
- "metricValues": [
550
- {
551
- "value": "2"
552
- },
553
- {
554
- "value": "2"
555
- }
556
- ]
557
- },
558
- {
559
- "dimensionValues": [
560
- {
561
- "value": "20240120"
562
- },
563
- {
564
- "value": "view_search_results"
565
- },
566
- {
567
- "value": ""
568
- }
569
- ],
570
- "metricValues": [
571
- {
572
- "value": "2"
573
- },
574
- {
575
- "value": "6"
576
- }
577
- ]
578
- },
579
- {
580
- "dimensionValues": [
581
- {
582
- "value": "20240119"
583
- },
584
- {
585
- "value": "scroll"
586
- },
587
- {
588
- "value": ""
589
- }
590
- ],
591
- "metricValues": [
592
- {
593
- "value": "119"
594
- },
595
- {
596
- "value": "186"
597
- }
598
- ]
599
- },
600
- {
601
- "dimensionValues": [
602
- {
603
- "value": "20240119"
604
- },
605
- {
606
- "value": "click"
607
- },
608
- {
609
- "value": "https://www.facebook.com/kuhinjskeprice"
610
- }
611
- ],
612
- "metricValues": [
613
- {
614
- "value": "1"
615
- },
616
- {
617
- "value": "1"
618
- }
619
- ]
620
- },
621
- {
622
- "dimensionValues": [
623
- {
624
- "value": "20240119"
625
- },
626
- {
627
- "value": "view_search_results"
628
- },
629
- {
630
- "value": ""
631
- }
632
- ],
633
- "metricValues": [
634
- {
635
- "value": "1"
636
- },
637
- {
638
- "value": "1"
639
- }
640
- ]
641
- },
642
- {
643
- "dimensionValues": [
644
- {
645
- "value": "20240118"
646
- },
647
- {
648
- "value": "scroll"
649
- },
650
- {
651
- "value": ""
652
- }
653
- ],
654
- "metricValues": [
655
- {
656
- "value": "86"
657
- },
658
- {
659
- "value": "125"
660
- }
661
- ]
662
- },
663
- {
664
- "dimensionValues": [
665
- {
666
- "value": "20240117"
667
- },
668
- {
669
- "value": "scroll"
670
- },
671
- {
672
- "value": ""
673
- }
674
- ],
675
- "metricValues": [
676
- {
677
- "value": "43"
678
- },
679
- {
680
- "value": "55"
681
- }
682
- ]
683
- },
684
- {
685
- "dimensionValues": [
686
- {
687
- "value": "20240116"
688
- },
689
- {
690
- "value": "scroll"
691
- },
692
- {
693
- "value": ""
694
- }
695
- ],
696
- "metricValues": [
697
- {
698
- "value": "66"
699
- },
700
- {
701
- "value": "95"
702
- }
703
- ]
704
- },
705
- {
706
- "dimensionValues": [
707
- {
708
- "value": "20240116"
709
- },
710
- {
711
- "value": "view_search_results"
712
- },
713
- {
714
- "value": ""
715
- }
716
- ],
717
- "metricValues": [
718
- {
719
- "value": "1"
720
- },
721
- {
722
- "value": "1"
723
- }
724
- ]
725
- },
726
- {
727
- "dimensionValues": [
728
- {
729
- "value": "20240115"
730
- },
731
- {
732
- "value": "scroll"
733
- },
734
- {
735
- "value": ""
736
- }
737
- ],
738
- "metricValues": [
739
- {
740
- "value": "29"
741
- },
742
- {
743
- "value": "32"
744
- }
745
- ]
746
- },
747
- {
748
- "dimensionValues": [
749
- {
750
- "value": "20240114"
751
- },
752
- {
753
- "value": "scroll"
754
- },
755
- {
756
- "value": ""
757
- }
758
- ],
759
- "metricValues": [
760
- {
761
- "value": "31"
762
- },
763
- {
764
- "value": "36"
765
- }
766
- ]
767
- },
768
- {
769
- "dimensionValues": [
770
- {
771
- "value": "20240113"
772
- },
773
- {
774
- "value": "scroll"
775
- },
776
- {
777
- "value": ""
778
- }
779
- ],
780
- "metricValues": [
781
- {
782
- "value": "30"
783
- },
784
- {
785
- "value": "34"
786
- }
787
- ]
788
- },
789
- {
790
- "dimensionValues": [
791
- {
792
- "value": "20240113"
793
- },
794
- {
795
- "value": "click"
796
- },
797
- {
798
- "value": "http://www.coolinarika.com/recept/omiljeni-mramorni-kuglof/"
799
- }
800
- ],
801
- "metricValues": [
802
- {
803
- "value": "1"
804
- },
805
- {
806
- "value": "1"
807
- }
808
- ]
809
- },
810
- {
811
- "dimensionValues": [
812
- {
813
- "value": "20240113"
814
- },
815
- {
816
- "value": "click"
817
- },
818
- {
819
- "value": "http://www.jamieoliver.com/recipes/pasta-recipes/spinach-ricotta-cannelloni/"
820
- }
821
- ],
822
- "metricValues": [
823
- {
824
- "value": "1"
825
- },
826
- {
827
- "value": "1"
828
- }
829
- ]
830
- },
831
- {
832
- "dimensionValues": [
833
- {
834
- "value": "20240112"
835
- },
836
- {
837
- "value": "scroll"
838
- },
839
- {
840
- "value": ""
841
- }
842
- ],
843
- "metricValues": [
844
- {
845
- "value": "31"
846
- },
847
- {
848
- "value": "33"
849
- }
850
- ]
851
- },
852
- {
853
- "dimensionValues": [
854
- {
855
- "value": "20240112"
856
- },
857
- {
858
- "value": "click"
859
- },
860
- {
861
- "value": "http://www.ikea.com/dk/da/catalog/products/80010222/"
862
- }
863
- ],
864
- "metricValues": [
865
- {
866
- "value": "1"
867
- },
868
- {
869
- "value": "1"
870
- }
871
- ]
872
- },
873
- {
874
- "dimensionValues": [
875
- {
876
- "value": "20240111"
877
- },
878
- {
879
- "value": "scroll"
880
- },
881
- {
882
- "value": ""
883
- }
884
- ],
885
- "metricValues": [
886
- {
887
- "value": "26"
888
- },
889
- {
890
- "value": "29"
891
- }
892
- ]
893
- },
894
- {
895
- "dimensionValues": [
896
- {
897
- "value": "20240111"
898
- },
899
- {
900
- "value": "click"
901
- },
902
- {
903
- "value": "http://www.apartmenttherapy.com/minh-and-ted-bought-a-distress-150686"
904
- }
905
- ],
906
- "metricValues": [
907
- {
908
- "value": "1"
909
- },
910
- {
911
- "value": "1"
912
- }
913
- ]
914
- },
915
- {
916
- "dimensionValues": [
917
- {
918
- "value": "20240110"
919
- },
920
- {
921
- "value": "scroll"
922
- },
923
- {
924
- "value": ""
925
- }
926
- ],
927
- "metricValues": [
928
- {
929
- "value": "24"
930
- },
931
- {
932
- "value": "28"
933
- }
934
- ]
935
- },
936
- {
937
- "dimensionValues": [
938
- {
939
- "value": "20240109"
940
- },
941
- {
942
- "value": "scroll"
943
- },
944
- {
945
- "value": ""
946
- }
947
- ],
948
- "metricValues": [
949
- {
950
- "value": "32"
951
- },
952
- {
953
- "value": "56"
954
- }
955
- ]
956
- },
957
- {
958
- "dimensionValues": [
959
- {
960
- "value": "20240108"
961
- },
962
- {
963
- "value": "scroll"
964
- },
965
- {
966
- "value": ""
967
- }
968
- ],
969
- "metricValues": [
970
- {
971
- "value": "24"
972
- },
973
- {
974
- "value": "26"
975
- }
976
- ]
977
- },
978
- {
979
- "dimensionValues": [
980
- {
981
- "value": "20240108"
982
- },
983
- {
984
- "value": "click"
985
- },
986
- {
987
- "value": "http://www.coolinarika.com/recept/omiljeni-mramorni-kuglof/"
988
- }
989
- ],
990
- "metricValues": [
991
- {
992
- "value": "1"
993
- },
994
- {
995
- "value": "1"
996
- }
997
- ]
998
- },
999
- {
1000
- "dimensionValues": [
1001
- {
1002
- "value": "20240107"
1003
- },
1004
- {
1005
- "value": "scroll"
1006
- },
1007
- {
1008
- "value": ""
1009
- }
1010
- ],
1011
- "metricValues": [
1012
- {
1013
- "value": "27"
1014
- },
1015
- {
1016
- "value": "45"
1017
- }
1018
- ]
1019
- },
1020
- {
1021
- "dimensionValues": [
1022
- {
1023
- "value": "20240107"
1024
- },
1025
- {
1026
- "value": "view_search_results"
1027
- },
1028
- {
1029
- "value": ""
1030
- }
1031
- ],
1032
- "metricValues": [
1033
- {
1034
- "value": "1"
1035
- },
1036
- {
1037
- "value": "15"
1038
- }
1039
- ]
1040
- },
1041
- {
1042
- "dimensionValues": [
1043
- {
1044
- "value": "20240106"
1045
- },
1046
- {
1047
- "value": "scroll"
1048
- },
1049
- {
1050
- "value": ""
1051
- }
1052
- ],
1053
- "metricValues": [
1054
- {
1055
- "value": "22"
1056
- },
1057
- {
1058
- "value": "22"
1059
- }
1060
- ]
1061
- },
1062
- {
1063
- "dimensionValues": [
1064
- {
1065
- "value": "20240105"
1066
- },
1067
- {
1068
- "value": "scroll"
1069
- },
1070
- {
1071
- "value": ""
1072
- }
1073
- ],
1074
- "metricValues": [
1075
- {
1076
- "value": "38"
1077
- },
1078
- {
1079
- "value": "39"
1080
- }
1081
- ]
1082
- },
1083
- {
1084
- "dimensionValues": [
1085
- {
1086
- "value": "20240104"
1087
- },
1088
- {
1089
- "value": "scroll"
1090
- },
1091
- {
1092
- "value": ""
1093
- }
1094
- ],
1095
- "metricValues": [
1096
- {
1097
- "value": "24"
1098
- },
1099
- {
1100
- "value": "27"
1101
- }
1102
- ]
1103
- },
1104
- {
1105
- "dimensionValues": [
1106
- {
1107
- "value": "20240103"
1108
- },
1109
- {
1110
- "value": "scroll"
1111
- },
1112
- {
1113
- "value": ""
1114
- }
1115
- ],
1116
- "metricValues": [
1117
- {
1118
- "value": "34"
1119
- },
1120
- {
1121
- "value": "35"
1122
- }
1123
- ]
1124
- },
1125
- {
1126
- "dimensionValues": [
1127
- {
1128
- "value": "20240103"
1129
- },
1130
- {
1131
- "value": "click"
1132
- },
1133
- {
1134
- "value": "https://pinterest.com/pin/238198267760892524/"
1135
- }
1136
- ],
1137
- "metricValues": [
1138
- {
1139
- "value": "1"
1140
- },
1141
- {
1142
- "value": "1"
1143
- }
1144
- ]
1145
- },
1146
- {
1147
- "dimensionValues": [
1148
- {
1149
- "value": "20240102"
1150
- },
1151
- {
1152
- "value": "scroll"
1153
- },
1154
- {
1155
- "value": ""
1156
- }
1157
- ],
1158
- "metricValues": [
1159
- {
1160
- "value": "29"
1161
- },
1162
- {
1163
- "value": "39"
1164
- }
1165
- ]
1166
- },
1167
- {
1168
- "dimensionValues": [
1169
- {
1170
- "value": "20240102"
1171
- },
1172
- {
1173
- "value": "click"
1174
- },
1175
- {
1176
- "value": "http://www.lonelyplanet.com/denmark/images/legoland-copenhagen$25938-2"
1177
- }
1178
- ],
1179
- "metricValues": [
1180
- {
1181
- "value": "1"
1182
- },
1183
- {
1184
- "value": "1"
1185
- }
1186
- ]
1187
- },
1188
- {
1189
- "dimensionValues": [
1190
- {
1191
- "value": "20240101"
1192
- },
1193
- {
1194
- "value": "scroll"
1195
- },
1196
- {
1197
- "value": ""
1198
- }
1199
- ],
1200
- "metricValues": [
1201
- {
1202
- "value": "20"
1203
- },
1204
- {
1205
- "value": "20"
1206
- }
1207
- ]
1208
- }
1209
- ]
1210
- }
1211
- ]
1212
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fixture/ga4_report_imported_devices.json DELETED
@@ -1,2556 +0,0 @@
1
- {
2
- "kind": "analyticsData#batchRunReports",
3
- "reports": [
4
- {
5
- "dimensionHeaders": [
6
- {
7
- "name": "date"
8
- },
9
- {
10
- "name": "deviceCategory"
11
- }
12
- ],
13
- "kind": "analyticsData#runReport",
14
- "metadata": {
15
- "currencyCode": "USD",
16
- "timeZone": "Europe/Amsterdam"
17
- },
18
- "metricHeaders": [
19
- {
20
- "name": "screenPageViews",
21
- "type": "TYPE_INTEGER"
22
- },
23
- {
24
- "name": "totalUsers",
25
- "type": "TYPE_INTEGER"
26
- },
27
- {
28
- "name": "sessions",
29
- "type": "TYPE_INTEGER"
30
- },
31
- {
32
- "name": "bounces",
33
- "type": "TYPE_INTEGER"
34
- },
35
- {
36
- "name": "userEngagementDuration",
37
- "type": "TYPE_SECONDS"
38
- }
39
- ],
40
- "rowCount": 93,
41
- "rows": [
42
- {
43
- "dimensionValues": [
44
- {
45
- "value": "20240131"
46
- },
47
- {
48
- "value": "mobile"
49
- }
50
- ],
51
- "metricValues": [
52
- {
53
- "value": "175"
54
- },
55
- {
56
- "value": "123"
57
- },
58
- {
59
- "value": "128"
60
- },
61
- {
62
- "value": "51"
63
- },
64
- {
65
- "value": "4286"
66
- }
67
- ]
68
- },
69
- {
70
- "dimensionValues": [
71
- {
72
- "value": "20240131"
73
- },
74
- {
75
- "value": "desktop"
76
- }
77
- ],
78
- "metricValues": [
79
- {
80
- "value": "17"
81
- },
82
- {
83
- "value": "15"
84
- },
85
- {
86
- "value": "15"
87
- },
88
- {
89
- "value": "3"
90
- },
91
- {
92
- "value": "516"
93
- }
94
- ]
95
- },
96
- {
97
- "dimensionValues": [
98
- {
99
- "value": "20240131"
100
- },
101
- {
102
- "value": "tablet"
103
- }
104
- ],
105
- "metricValues": [
106
- {
107
- "value": "3"
108
- },
109
- {
110
- "value": "3"
111
- },
112
- {
113
- "value": "3"
114
- },
115
- {
116
- "value": "2"
117
- },
118
- {
119
- "value": "13"
120
- }
121
- ]
122
- },
123
- {
124
- "dimensionValues": [
125
- {
126
- "value": "20240130"
127
- },
128
- {
129
- "value": "mobile"
130
- }
131
- ],
132
- "metricValues": [
133
- {
134
- "value": "149"
135
- },
136
- {
137
- "value": "97"
138
- },
139
- {
140
- "value": "97"
141
- },
142
- {
143
- "value": "37"
144
- },
145
- {
146
- "value": "2916"
147
- }
148
- ]
149
- },
150
- {
151
- "dimensionValues": [
152
- {
153
- "value": "20240130"
154
- },
155
- {
156
- "value": "desktop"
157
- }
158
- ],
159
- "metricValues": [
160
- {
161
- "value": "19"
162
- },
163
- {
164
- "value": "15"
165
- },
166
- {
167
- "value": "15"
168
- },
169
- {
170
- "value": "4"
171
- },
172
- {
173
- "value": "464"
174
- }
175
- ]
176
- },
177
- {
178
- "dimensionValues": [
179
- {
180
- "value": "20240130"
181
- },
182
- {
183
- "value": "tablet"
184
- }
185
- ],
186
- "metricValues": [
187
- {
188
- "value": "3"
189
- },
190
- {
191
- "value": "3"
192
- },
193
- {
194
- "value": "3"
195
- },
196
- {
197
- "value": "1"
198
- },
199
- {
200
- "value": "101"
201
- }
202
- ]
203
- },
204
- {
205
- "dimensionValues": [
206
- {
207
- "value": "20240129"
208
- },
209
- {
210
- "value": "mobile"
211
- }
212
- ],
213
- "metricValues": [
214
- {
215
- "value": "113"
216
- },
217
- {
218
- "value": "82"
219
- },
220
- {
221
- "value": "86"
222
- },
223
- {
224
- "value": "40"
225
- },
226
- {
227
- "value": "2325"
228
- }
229
- ]
230
- },
231
- {
232
- "dimensionValues": [
233
- {
234
- "value": "20240129"
235
- },
236
- {
237
- "value": "desktop"
238
- }
239
- ],
240
- "metricValues": [
241
- {
242
- "value": "34"
243
- },
244
- {
245
- "value": "19"
246
- },
247
- {
248
- "value": "23"
249
- },
250
- {
251
- "value": "13"
252
- },
253
- {
254
- "value": "554"
255
- }
256
- ]
257
- },
258
- {
259
- "dimensionValues": [
260
- {
261
- "value": "20240129"
262
- },
263
- {
264
- "value": "tablet"
265
- }
266
- ],
267
- "metricValues": [
268
- {
269
- "value": "6"
270
- },
271
- {
272
- "value": "4"
273
- },
274
- {
275
- "value": "4"
276
- },
277
- {
278
- "value": "1"
279
- },
280
- {
281
- "value": "16"
282
- }
283
- ]
284
- },
285
- {
286
- "dimensionValues": [
287
- {
288
- "value": "20240128"
289
- },
290
- {
291
- "value": "mobile"
292
- }
293
- ],
294
- "metricValues": [
295
- {
296
- "value": "232"
297
- },
298
- {
299
- "value": "167"
300
- },
301
- {
302
- "value": "173"
303
- },
304
- {
305
- "value": "67"
306
- },
307
- {
308
- "value": "5963"
309
- }
310
- ]
311
- },
312
- {
313
- "dimensionValues": [
314
- {
315
- "value": "20240128"
316
- },
317
- {
318
- "value": "desktop"
319
- }
320
- ],
321
- "metricValues": [
322
- {
323
- "value": "24"
324
- },
325
- {
326
- "value": "14"
327
- },
328
- {
329
- "value": "14"
330
- },
331
- {
332
- "value": "5"
333
- },
334
- {
335
- "value": "642"
336
- }
337
- ]
338
- },
339
- {
340
- "dimensionValues": [
341
- {
342
- "value": "20240128"
343
- },
344
- {
345
- "value": "tablet"
346
- }
347
- ],
348
- "metricValues": [
349
- {
350
- "value": "3"
351
- },
352
- {
353
- "value": "3"
354
- },
355
- {
356
- "value": "3"
357
- },
358
- {
359
- "value": "2"
360
- },
361
- {
362
- "value": "256"
363
- }
364
- ]
365
- },
366
- {
367
- "dimensionValues": [
368
- {
369
- "value": "20240127"
370
- },
371
- {
372
- "value": "mobile"
373
- }
374
- ],
375
- "metricValues": [
376
- {
377
- "value": "186"
378
- },
379
- {
380
- "value": "140"
381
- },
382
- {
383
- "value": "142"
384
- },
385
- {
386
- "value": "61"
387
- },
388
- {
389
- "value": "3738"
390
- }
391
- ]
392
- },
393
- {
394
- "dimensionValues": [
395
- {
396
- "value": "20240127"
397
- },
398
- {
399
- "value": "desktop"
400
- }
401
- ],
402
- "metricValues": [
403
- {
404
- "value": "12"
405
- },
406
- {
407
- "value": "11"
408
- },
409
- {
410
- "value": "12"
411
- },
412
- {
413
- "value": "2"
414
- },
415
- {
416
- "value": "1515"
417
- }
418
- ]
419
- },
420
- {
421
- "dimensionValues": [
422
- {
423
- "value": "20240127"
424
- },
425
- {
426
- "value": "tablet"
427
- }
428
- ],
429
- "metricValues": [
430
- {
431
- "value": "4"
432
- },
433
- {
434
- "value": "4"
435
- },
436
- {
437
- "value": "4"
438
- },
439
- {
440
- "value": "4"
441
- },
442
- {
443
- "value": "6"
444
- }
445
- ]
446
- },
447
- {
448
- "dimensionValues": [
449
- {
450
- "value": "20240126"
451
- },
452
- {
453
- "value": "mobile"
454
- }
455
- ],
456
- "metricValues": [
457
- {
458
- "value": "159"
459
- },
460
- {
461
- "value": "110"
462
- },
463
- {
464
- "value": "119"
465
- },
466
- {
467
- "value": "47"
468
- },
469
- {
470
- "value": "3948"
471
- }
472
- ]
473
- },
474
- {
475
- "dimensionValues": [
476
- {
477
- "value": "20240126"
478
- },
479
- {
480
- "value": "desktop"
481
- }
482
- ],
483
- "metricValues": [
484
- {
485
- "value": "25"
486
- },
487
- {
488
- "value": "18"
489
- },
490
- {
491
- "value": "19"
492
- },
493
- {
494
- "value": "9"
495
- },
496
- {
497
- "value": "1368"
498
- }
499
- ]
500
- },
501
- {
502
- "dimensionValues": [
503
- {
504
- "value": "20240126"
505
- },
506
- {
507
- "value": "tablet"
508
- }
509
- ],
510
- "metricValues": [
511
- {
512
- "value": "5"
513
- },
514
- {
515
- "value": "4"
516
- },
517
- {
518
- "value": "4"
519
- },
520
- {
521
- "value": "1"
522
- },
523
- {
524
- "value": "229"
525
- }
526
- ]
527
- },
528
- {
529
- "dimensionValues": [
530
- {
531
- "value": "20240125"
532
- },
533
- {
534
- "value": "mobile"
535
- }
536
- ],
537
- "metricValues": [
538
- {
539
- "value": "180"
540
- },
541
- {
542
- "value": "127"
543
- },
544
- {
545
- "value": "131"
546
- },
547
- {
548
- "value": "62"
549
- },
550
- {
551
- "value": "3652"
552
- }
553
- ]
554
- },
555
- {
556
- "dimensionValues": [
557
- {
558
- "value": "20240125"
559
- },
560
- {
561
- "value": "desktop"
562
- }
563
- ],
564
- "metricValues": [
565
- {
566
- "value": "37"
567
- },
568
- {
569
- "value": "21"
570
- },
571
- {
572
- "value": "21"
573
- },
574
- {
575
- "value": "7"
576
- },
577
- {
578
- "value": "1500"
579
- }
580
- ]
581
- },
582
- {
583
- "dimensionValues": [
584
- {
585
- "value": "20240125"
586
- },
587
- {
588
- "value": "tablet"
589
- }
590
- ],
591
- "metricValues": [
592
- {
593
- "value": "12"
594
- },
595
- {
596
- "value": "3"
597
- },
598
- {
599
- "value": "3"
600
- },
601
- {
602
- "value": "2"
603
- },
604
- {
605
- "value": "213"
606
- }
607
- ]
608
- },
609
- {
610
- "dimensionValues": [
611
- {
612
- "value": "20240124"
613
- },
614
- {
615
- "value": "mobile"
616
- }
617
- ],
618
- "metricValues": [
619
- {
620
- "value": "183"
621
- },
622
- {
623
- "value": "119"
624
- },
625
- {
626
- "value": "128"
627
- },
628
- {
629
- "value": "55"
630
- },
631
- {
632
- "value": "3609"
633
- }
634
- ]
635
- },
636
- {
637
- "dimensionValues": [
638
- {
639
- "value": "20240124"
640
- },
641
- {
642
- "value": "desktop"
643
- }
644
- ],
645
- "metricValues": [
646
- {
647
- "value": "35"
648
- },
649
- {
650
- "value": "15"
651
- },
652
- {
653
- "value": "17"
654
- },
655
- {
656
- "value": "8"
657
- },
658
- {
659
- "value": "781"
660
- }
661
- ]
662
- },
663
- {
664
- "dimensionValues": [
665
- {
666
- "value": "20240124"
667
- },
668
- {
669
- "value": "tablet"
670
- }
671
- ],
672
- "metricValues": [
673
- {
674
- "value": "3"
675
- },
676
- {
677
- "value": "3"
678
- },
679
- {
680
- "value": "3"
681
- },
682
- {
683
- "value": "1"
684
- },
685
- {
686
- "value": "64"
687
- }
688
- ]
689
- },
690
- {
691
- "dimensionValues": [
692
- {
693
- "value": "20240123"
694
- },
695
- {
696
- "value": "mobile"
697
- }
698
- ],
699
- "metricValues": [
700
- {
701
- "value": "226"
702
- },
703
- {
704
- "value": "142"
705
- },
706
- {
707
- "value": "146"
708
- },
709
- {
710
- "value": "52"
711
- },
712
- {
713
- "value": "4415"
714
- }
715
- ]
716
- },
717
- {
718
- "dimensionValues": [
719
- {
720
- "value": "20240123"
721
- },
722
- {
723
- "value": "desktop"
724
- }
725
- ],
726
- "metricValues": [
727
- {
728
- "value": "38"
729
- },
730
- {
731
- "value": "22"
732
- },
733
- {
734
- "value": "23"
735
- },
736
- {
737
- "value": "8"
738
- },
739
- {
740
- "value": "422"
741
- }
742
- ]
743
- },
744
- {
745
- "dimensionValues": [
746
- {
747
- "value": "20240123"
748
- },
749
- {
750
- "value": "tablet"
751
- }
752
- ],
753
- "metricValues": [
754
- {
755
- "value": "3"
756
- },
757
- {
758
- "value": "2"
759
- },
760
- {
761
- "value": "2"
762
- },
763
- {
764
- "value": "1"
765
- },
766
- {
767
- "value": "13"
768
- }
769
- ]
770
- },
771
- {
772
- "dimensionValues": [
773
- {
774
- "value": "20240122"
775
- },
776
- {
777
- "value": "mobile"
778
- }
779
- ],
780
- "metricValues": [
781
- {
782
- "value": "190"
783
- },
784
- {
785
- "value": "140"
786
- },
787
- {
788
- "value": "142"
789
- },
790
- {
791
- "value": "63"
792
- },
793
- {
794
- "value": "3871"
795
- }
796
- ]
797
- },
798
- {
799
- "dimensionValues": [
800
- {
801
- "value": "20240122"
802
- },
803
- {
804
- "value": "desktop"
805
- }
806
- ],
807
- "metricValues": [
808
- {
809
- "value": "27"
810
- },
811
- {
812
- "value": "16"
813
- },
814
- {
815
- "value": "17"
816
- },
817
- {
818
- "value": "9"
819
- },
820
- {
821
- "value": "828"
822
- }
823
- ]
824
- },
825
- {
826
- "dimensionValues": [
827
- {
828
- "value": "20240122"
829
- },
830
- {
831
- "value": "tablet"
832
- }
833
- ],
834
- "metricValues": [
835
- {
836
- "value": "16"
837
- },
838
- {
839
- "value": "5"
840
- },
841
- {
842
- "value": "6"
843
- },
844
- {
845
- "value": "1"
846
- },
847
- {
848
- "value": "172"
849
- }
850
- ]
851
- },
852
- {
853
- "dimensionValues": [
854
- {
855
- "value": "20240121"
856
- },
857
- {
858
- "value": "mobile"
859
- }
860
- ],
861
- "metricValues": [
862
- {
863
- "value": "370"
864
- },
865
- {
866
- "value": "235"
867
- },
868
- {
869
- "value": "242"
870
- },
871
- {
872
- "value": "100"
873
- },
874
- {
875
- "value": "8146"
876
- }
877
- ]
878
- },
879
- {
880
- "dimensionValues": [
881
- {
882
- "value": "20240121"
883
- },
884
- {
885
- "value": "desktop"
886
- }
887
- ],
888
- "metricValues": [
889
- {
890
- "value": "24"
891
- },
892
- {
893
- "value": "18"
894
- },
895
- {
896
- "value": "24"
897
- },
898
- {
899
- "value": "14"
900
- },
901
- {
902
- "value": "685"
903
- }
904
- ]
905
- },
906
- {
907
- "dimensionValues": [
908
- {
909
- "value": "20240121"
910
- },
911
- {
912
- "value": "tablet"
913
- }
914
- ],
915
- "metricValues": [
916
- {
917
- "value": "3"
918
- },
919
- {
920
- "value": "2"
921
- },
922
- {
923
- "value": "2"
924
- },
925
- {
926
- "value": "0"
927
- },
928
- {
929
- "value": "14"
930
- }
931
- ]
932
- },
933
- {
934
- "dimensionValues": [
935
- {
936
- "value": "20240120"
937
- },
938
- {
939
- "value": "mobile"
940
- }
941
- ],
942
- "metricValues": [
943
- {
944
- "value": "353"
945
- },
946
- {
947
- "value": "197"
948
- },
949
- {
950
- "value": "205"
951
- },
952
- {
953
- "value": "95"
954
- },
955
- {
956
- "value": "6141"
957
- }
958
- ]
959
- },
960
- {
961
- "dimensionValues": [
962
- {
963
- "value": "20240120"
964
- },
965
- {
966
- "value": "desktop"
967
- }
968
- ],
969
- "metricValues": [
970
- {
971
- "value": "29"
972
- },
973
- {
974
- "value": "16"
975
- },
976
- {
977
- "value": "17"
978
- },
979
- {
980
- "value": "4"
981
- },
982
- {
983
- "value": "773"
984
- }
985
- ]
986
- },
987
- {
988
- "dimensionValues": [
989
- {
990
- "value": "20240120"
991
- },
992
- {
993
- "value": "tablet"
994
- }
995
- ],
996
- "metricValues": [
997
- {
998
- "value": "1"
999
- },
1000
- {
1001
- "value": "1"
1002
- },
1003
- {
1004
- "value": "1"
1005
- },
1006
- {
1007
- "value": "1"
1008
- },
1009
- {
1010
- "value": "0"
1011
- }
1012
- ]
1013
- },
1014
- {
1015
- "dimensionValues": [
1016
- {
1017
- "value": "20240119"
1018
- },
1019
- {
1020
- "value": "desktop"
1021
- }
1022
- ],
1023
- "metricValues": [
1024
- {
1025
- "value": "5293"
1026
- },
1027
- {
1028
- "value": "202"
1029
- },
1030
- {
1031
- "value": "40"
1032
- },
1033
- {
1034
- "value": "15"
1035
- },
1036
- {
1037
- "value": "93328"
1038
- }
1039
- ]
1040
- },
1041
- {
1042
- "dimensionValues": [
1043
- {
1044
- "value": "20240119"
1045
- },
1046
- {
1047
- "value": "mobile"
1048
- }
1049
- ],
1050
- "metricValues": [
1051
- {
1052
- "value": "277"
1053
- },
1054
- {
1055
- "value": "178"
1056
- },
1057
- {
1058
- "value": "187"
1059
- },
1060
- {
1061
- "value": "85"
1062
- },
1063
- {
1064
- "value": "5160"
1065
- }
1066
- ]
1067
- },
1068
- {
1069
- "dimensionValues": [
1070
- {
1071
- "value": "20240119"
1072
- },
1073
- {
1074
- "value": "tablet"
1075
- }
1076
- ],
1077
- "metricValues": [
1078
- {
1079
- "value": "1"
1080
- },
1081
- {
1082
- "value": "1"
1083
- },
1084
- {
1085
- "value": "1"
1086
- },
1087
- {
1088
- "value": "0"
1089
- },
1090
- {
1091
- "value": "61"
1092
- }
1093
- ]
1094
- },
1095
- {
1096
- "dimensionValues": [
1097
- {
1098
- "value": "20240118"
1099
- },
1100
- {
1101
- "value": "mobile"
1102
- }
1103
- ],
1104
- "metricValues": [
1105
- {
1106
- "value": "241"
1107
- },
1108
- {
1109
- "value": "156"
1110
- },
1111
- {
1112
- "value": "164"
1113
- },
1114
- {
1115
- "value": "75"
1116
- },
1117
- {
1118
- "value": "4570"
1119
- }
1120
- ]
1121
- },
1122
- {
1123
- "dimensionValues": [
1124
- {
1125
- "value": "20240118"
1126
- },
1127
- {
1128
- "value": "desktop"
1129
- }
1130
- ],
1131
- "metricValues": [
1132
- {
1133
- "value": "36"
1134
- },
1135
- {
1136
- "value": "20"
1137
- },
1138
- {
1139
- "value": "21"
1140
- },
1141
- {
1142
- "value": "10"
1143
- },
1144
- {
1145
- "value": "983"
1146
- }
1147
- ]
1148
- },
1149
- {
1150
- "dimensionValues": [
1151
- {
1152
- "value": "20240118"
1153
- },
1154
- {
1155
- "value": "tablet"
1156
- }
1157
- ],
1158
- "metricValues": [
1159
- {
1160
- "value": "1"
1161
- },
1162
- {
1163
- "value": "1"
1164
- },
1165
- {
1166
- "value": "1"
1167
- },
1168
- {
1169
- "value": "1"
1170
- },
1171
- {
1172
- "value": "0"
1173
- }
1174
- ]
1175
- },
1176
- {
1177
- "dimensionValues": [
1178
- {
1179
- "value": "20240117"
1180
- },
1181
- {
1182
- "value": "mobile"
1183
- }
1184
- ],
1185
- "metricValues": [
1186
- {
1187
- "value": "242"
1188
- },
1189
- {
1190
- "value": "180"
1191
- },
1192
- {
1193
- "value": "191"
1194
- },
1195
- {
1196
- "value": "82"
1197
- },
1198
- {
1199
- "value": "6238"
1200
- }
1201
- ]
1202
- },
1203
- {
1204
- "dimensionValues": [
1205
- {
1206
- "value": "20240117"
1207
- },
1208
- {
1209
- "value": "desktop"
1210
- }
1211
- ],
1212
- "metricValues": [
1213
- {
1214
- "value": "25"
1215
- },
1216
- {
1217
- "value": "21"
1218
- },
1219
- {
1220
- "value": "21"
1221
- },
1222
- {
1223
- "value": "12"
1224
- },
1225
- {
1226
- "value": "504"
1227
- }
1228
- ]
1229
- },
1230
- {
1231
- "dimensionValues": [
1232
- {
1233
- "value": "20240117"
1234
- },
1235
- {
1236
- "value": "tablet"
1237
- }
1238
- ],
1239
- "metricValues": [
1240
- {
1241
- "value": "4"
1242
- },
1243
- {
1244
- "value": "3"
1245
- },
1246
- {
1247
- "value": "4"
1248
- },
1249
- {
1250
- "value": "3"
1251
- },
1252
- {
1253
- "value": "35"
1254
- }
1255
- ]
1256
- },
1257
- {
1258
- "dimensionValues": [
1259
- {
1260
- "value": "20240116"
1261
- },
1262
- {
1263
- "value": "mobile"
1264
- }
1265
- ],
1266
- "metricValues": [
1267
- {
1268
- "value": "244"
1269
- },
1270
- {
1271
- "value": "182"
1272
- },
1273
- {
1274
- "value": "184"
1275
- },
1276
- {
1277
- "value": "61"
1278
- },
1279
- {
1280
- "value": "8239"
1281
- }
1282
- ]
1283
- },
1284
- {
1285
- "dimensionValues": [
1286
- {
1287
- "value": "20240116"
1288
- },
1289
- {
1290
- "value": "desktop"
1291
- }
1292
- ],
1293
- "metricValues": [
1294
- {
1295
- "value": "33"
1296
- },
1297
- {
1298
- "value": "16"
1299
- },
1300
- {
1301
- "value": "17"
1302
- },
1303
- {
1304
- "value": "4"
1305
- },
1306
- {
1307
- "value": "945"
1308
- }
1309
- ]
1310
- },
1311
- {
1312
- "dimensionValues": [
1313
- {
1314
- "value": "20240116"
1315
- },
1316
- {
1317
- "value": "tablet"
1318
- }
1319
- ],
1320
- "metricValues": [
1321
- {
1322
- "value": "3"
1323
- },
1324
- {
1325
- "value": "2"
1326
- },
1327
- {
1328
- "value": "2"
1329
- },
1330
- {
1331
- "value": "0"
1332
- },
1333
- {
1334
- "value": "197"
1335
- }
1336
- ]
1337
- },
1338
- {
1339
- "dimensionValues": [
1340
- {
1341
- "value": "20240115"
1342
- },
1343
- {
1344
- "value": "mobile"
1345
- }
1346
- ],
1347
- "metricValues": [
1348
- {
1349
- "value": "275"
1350
- },
1351
- {
1352
- "value": "225"
1353
- },
1354
- {
1355
- "value": "238"
1356
- },
1357
- {
1358
- "value": "79"
1359
- },
1360
- {
1361
- "value": "9717"
1362
- }
1363
- ]
1364
- },
1365
- {
1366
- "dimensionValues": [
1367
- {
1368
- "value": "20240115"
1369
- },
1370
- {
1371
- "value": "desktop"
1372
- }
1373
- ],
1374
- "metricValues": [
1375
- {
1376
- "value": "30"
1377
- },
1378
- {
1379
- "value": "25"
1380
- },
1381
- {
1382
- "value": "26"
1383
- },
1384
- {
1385
- "value": "6"
1386
- },
1387
- {
1388
- "value": "1668"
1389
- }
1390
- ]
1391
- },
1392
- {
1393
- "dimensionValues": [
1394
- {
1395
- "value": "20240115"
1396
- },
1397
- {
1398
- "value": "tablet"
1399
- }
1400
- ],
1401
- "metricValues": [
1402
- {
1403
- "value": "6"
1404
- },
1405
- {
1406
- "value": "5"
1407
- },
1408
- {
1409
- "value": "5"
1410
- },
1411
- {
1412
- "value": "1"
1413
- },
1414
- {
1415
- "value": "980"
1416
- }
1417
- ]
1418
- },
1419
- {
1420
- "dimensionValues": [
1421
- {
1422
- "value": "20240114"
1423
- },
1424
- {
1425
- "value": "mobile"
1426
- }
1427
- ],
1428
- "metricValues": [
1429
- {
1430
- "value": "280"
1431
- },
1432
- {
1433
- "value": "225"
1434
- },
1435
- {
1436
- "value": "237"
1437
- },
1438
- {
1439
- "value": "77"
1440
- },
1441
- {
1442
- "value": "10320"
1443
- }
1444
- ]
1445
- },
1446
- {
1447
- "dimensionValues": [
1448
- {
1449
- "value": "20240114"
1450
- },
1451
- {
1452
- "value": "desktop"
1453
- }
1454
- ],
1455
- "metricValues": [
1456
- {
1457
- "value": "17"
1458
- },
1459
- {
1460
- "value": "14"
1461
- },
1462
- {
1463
- "value": "16"
1464
- },
1465
- {
1466
- "value": "7"
1467
- },
1468
- {
1469
- "value": "590"
1470
- }
1471
- ]
1472
- },
1473
- {
1474
- "dimensionValues": [
1475
- {
1476
- "value": "20240114"
1477
- },
1478
- {
1479
- "value": "tablet"
1480
- }
1481
- ],
1482
- "metricValues": [
1483
- {
1484
- "value": "5"
1485
- },
1486
- {
1487
- "value": "4"
1488
- },
1489
- {
1490
- "value": "4"
1491
- },
1492
- {
1493
- "value": "2"
1494
- },
1495
- {
1496
- "value": "163"
1497
- }
1498
- ]
1499
- },
1500
- {
1501
- "dimensionValues": [
1502
- {
1503
- "value": "20240113"
1504
- },
1505
- {
1506
- "value": "mobile"
1507
- }
1508
- ],
1509
- "metricValues": [
1510
- {
1511
- "value": "255"
1512
- },
1513
- {
1514
- "value": "220"
1515
- },
1516
- {
1517
- "value": "230"
1518
- },
1519
- {
1520
- "value": "89"
1521
- },
1522
- {
1523
- "value": "9574"
1524
- }
1525
- ]
1526
- },
1527
- {
1528
- "dimensionValues": [
1529
- {
1530
- "value": "20240113"
1531
- },
1532
- {
1533
- "value": "desktop"
1534
- }
1535
- ],
1536
- "metricValues": [
1537
- {
1538
- "value": "19"
1539
- },
1540
- {
1541
- "value": "9"
1542
- },
1543
- {
1544
- "value": "12"
1545
- },
1546
- {
1547
- "value": "3"
1548
- },
1549
- {
1550
- "value": "445"
1551
- }
1552
- ]
1553
- },
1554
- {
1555
- "dimensionValues": [
1556
- {
1557
- "value": "20240113"
1558
- },
1559
- {
1560
- "value": "tablet"
1561
- }
1562
- ],
1563
- "metricValues": [
1564
- {
1565
- "value": "2"
1566
- },
1567
- {
1568
- "value": "2"
1569
- },
1570
- {
1571
- "value": "2"
1572
- },
1573
- {
1574
- "value": "0"
1575
- },
1576
- {
1577
- "value": "65"
1578
- }
1579
- ]
1580
- },
1581
- {
1582
- "dimensionValues": [
1583
- {
1584
- "value": "20240112"
1585
- },
1586
- {
1587
- "value": "mobile"
1588
- }
1589
- ],
1590
- "metricValues": [
1591
- {
1592
- "value": "231"
1593
- },
1594
- {
1595
- "value": "187"
1596
- },
1597
- {
1598
- "value": "197"
1599
- },
1600
- {
1601
- "value": "74"
1602
- },
1603
- {
1604
- "value": "8567"
1605
- }
1606
- ]
1607
- },
1608
- {
1609
- "dimensionValues": [
1610
- {
1611
- "value": "20240112"
1612
- },
1613
- {
1614
- "value": "desktop"
1615
- }
1616
- ],
1617
- "metricValues": [
1618
- {
1619
- "value": "32"
1620
- },
1621
- {
1622
- "value": "26"
1623
- },
1624
- {
1625
- "value": "26"
1626
- },
1627
- {
1628
- "value": "1"
1629
- },
1630
- {
1631
- "value": "1561"
1632
- }
1633
- ]
1634
- },
1635
- {
1636
- "dimensionValues": [
1637
- {
1638
- "value": "20240112"
1639
- },
1640
- {
1641
- "value": "tablet"
1642
- }
1643
- ],
1644
- "metricValues": [
1645
- {
1646
- "value": "2"
1647
- },
1648
- {
1649
- "value": "2"
1650
- },
1651
- {
1652
- "value": "2"
1653
- },
1654
- {
1655
- "value": "0"
1656
- },
1657
- {
1658
- "value": "67"
1659
- }
1660
- ]
1661
- },
1662
- {
1663
- "dimensionValues": [
1664
- {
1665
- "value": "20240111"
1666
- },
1667
- {
1668
- "value": "mobile"
1669
- }
1670
- ],
1671
- "metricValues": [
1672
- {
1673
- "value": "212"
1674
- },
1675
- {
1676
- "value": "163"
1677
- },
1678
- {
1679
- "value": "171"
1680
- },
1681
- {
1682
- "value": "58"
1683
- },
1684
- {
1685
- "value": "7415"
1686
- }
1687
- ]
1688
- },
1689
- {
1690
- "dimensionValues": [
1691
- {
1692
- "value": "20240111"
1693
- },
1694
- {
1695
- "value": "desktop"
1696
- }
1697
- ],
1698
- "metricValues": [
1699
- {
1700
- "value": "22"
1701
- },
1702
- {
1703
- "value": "19"
1704
- },
1705
- {
1706
- "value": "19"
1707
- },
1708
- {
1709
- "value": "6"
1710
- },
1711
- {
1712
- "value": "741"
1713
- }
1714
- ]
1715
- },
1716
- {
1717
- "dimensionValues": [
1718
- {
1719
- "value": "20240111"
1720
- },
1721
- {
1722
- "value": "tablet"
1723
- }
1724
- ],
1725
- "metricValues": [
1726
- {
1727
- "value": "1"
1728
- },
1729
- {
1730
- "value": "1"
1731
- },
1732
- {
1733
- "value": "1"
1734
- },
1735
- {
1736
- "value": "0"
1737
- },
1738
- {
1739
- "value": "44"
1740
- }
1741
- ]
1742
- },
1743
- {
1744
- "dimensionValues": [
1745
- {
1746
- "value": "20240110"
1747
- },
1748
- {
1749
- "value": "mobile"
1750
- }
1751
- ],
1752
- "metricValues": [
1753
- {
1754
- "value": "202"
1755
- },
1756
- {
1757
- "value": "164"
1758
- },
1759
- {
1760
- "value": "175"
1761
- },
1762
- {
1763
- "value": "58"
1764
- },
1765
- {
1766
- "value": "6400"
1767
- }
1768
- ]
1769
- },
1770
- {
1771
- "dimensionValues": [
1772
- {
1773
- "value": "20240110"
1774
- },
1775
- {
1776
- "value": "desktop"
1777
- }
1778
- ],
1779
- "metricValues": [
1780
- {
1781
- "value": "37"
1782
- },
1783
- {
1784
- "value": "23"
1785
- },
1786
- {
1787
- "value": "24"
1788
- },
1789
- {
1790
- "value": "2"
1791
- },
1792
- {
1793
- "value": "2458"
1794
- }
1795
- ]
1796
- },
1797
- {
1798
- "dimensionValues": [
1799
- {
1800
- "value": "20240110"
1801
- },
1802
- {
1803
- "value": "tablet"
1804
- }
1805
- ],
1806
- "metricValues": [
1807
- {
1808
- "value": "5"
1809
- },
1810
- {
1811
- "value": "3"
1812
- },
1813
- {
1814
- "value": "4"
1815
- },
1816
- {
1817
- "value": "1"
1818
- },
1819
- {
1820
- "value": "198"
1821
- }
1822
- ]
1823
- },
1824
- {
1825
- "dimensionValues": [
1826
- {
1827
- "value": "20240109"
1828
- },
1829
- {
1830
- "value": "mobile"
1831
- }
1832
- ],
1833
- "metricValues": [
1834
- {
1835
- "value": "208"
1836
- },
1837
- {
1838
- "value": "169"
1839
- },
1840
- {
1841
- "value": "178"
1842
- },
1843
- {
1844
- "value": "58"
1845
- },
1846
- {
1847
- "value": "8482"
1848
- }
1849
- ]
1850
- },
1851
- {
1852
- "dimensionValues": [
1853
- {
1854
- "value": "20240109"
1855
- },
1856
- {
1857
- "value": "desktop"
1858
- }
1859
- ],
1860
- "metricValues": [
1861
- {
1862
- "value": "79"
1863
- },
1864
- {
1865
- "value": "30"
1866
- },
1867
- {
1868
- "value": "33"
1869
- },
1870
- {
1871
- "value": "5"
1872
- },
1873
- {
1874
- "value": "2900"
1875
- }
1876
- ]
1877
- },
1878
- {
1879
- "dimensionValues": [
1880
- {
1881
- "value": "20240109"
1882
- },
1883
- {
1884
- "value": "tablet"
1885
- }
1886
- ],
1887
- "metricValues": [
1888
- {
1889
- "value": "3"
1890
- },
1891
- {
1892
- "value": "2"
1893
- },
1894
- {
1895
- "value": "2"
1896
- },
1897
- {
1898
- "value": "0"
1899
- },
1900
- {
1901
- "value": "269"
1902
- }
1903
- ]
1904
- },
1905
- {
1906
- "dimensionValues": [
1907
- {
1908
- "value": "20240108"
1909
- },
1910
- {
1911
- "value": "mobile"
1912
- }
1913
- ],
1914
- "metricValues": [
1915
- {
1916
- "value": "204"
1917
- },
1918
- {
1919
- "value": "170"
1920
- },
1921
- {
1922
- "value": "182"
1923
- },
1924
- {
1925
- "value": "65"
1926
- },
1927
- {
1928
- "value": "6056"
1929
- }
1930
- ]
1931
- },
1932
- {
1933
- "dimensionValues": [
1934
- {
1935
- "value": "20240108"
1936
- },
1937
- {
1938
- "value": "desktop"
1939
- }
1940
- ],
1941
- "metricValues": [
1942
- {
1943
- "value": "25"
1944
- },
1945
- {
1946
- "value": "21"
1947
- },
1948
- {
1949
- "value": "23"
1950
- },
1951
- {
1952
- "value": "5"
1953
- },
1954
- {
1955
- "value": "1402"
1956
- }
1957
- ]
1958
- },
1959
- {
1960
- "dimensionValues": [
1961
- {
1962
- "value": "20240108"
1963
- },
1964
- {
1965
- "value": "tablet"
1966
- }
1967
- ],
1968
- "metricValues": [
1969
- {
1970
- "value": "6"
1971
- },
1972
- {
1973
- "value": "4"
1974
- },
1975
- {
1976
- "value": "4"
1977
- },
1978
- {
1979
- "value": "1"
1980
- },
1981
- {
1982
- "value": "488"
1983
- }
1984
- ]
1985
- },
1986
- {
1987
- "dimensionValues": [
1988
- {
1989
- "value": "20240107"
1990
- },
1991
- {
1992
- "value": "mobile"
1993
- }
1994
- ],
1995
- "metricValues": [
1996
- {
1997
- "value": "246"
1998
- },
1999
- {
2000
- "value": "191"
2001
- },
2002
- {
2003
- "value": "203"
2004
- },
2005
- {
2006
- "value": "75"
2007
- },
2008
- {
2009
- "value": "8711"
2010
- }
2011
- ]
2012
- },
2013
- {
2014
- "dimensionValues": [
2015
- {
2016
- "value": "20240107"
2017
- },
2018
- {
2019
- "value": "desktop"
2020
- }
2021
- ],
2022
- "metricValues": [
2023
- {
2024
- "value": "24"
2025
- },
2026
- {
2027
- "value": "11"
2028
- },
2029
- {
2030
- "value": "12"
2031
- },
2032
- {
2033
- "value": "4"
2034
- },
2035
- {
2036
- "value": "1214"
2037
- }
2038
- ]
2039
- },
2040
- {
2041
- "dimensionValues": [
2042
- {
2043
- "value": "20240107"
2044
- },
2045
- {
2046
- "value": "tablet"
2047
- }
2048
- ],
2049
- "metricValues": [
2050
- {
2051
- "value": "4"
2052
- },
2053
- {
2054
- "value": "3"
2055
- },
2056
- {
2057
- "value": "3"
2058
- },
2059
- {
2060
- "value": "1"
2061
- },
2062
- {
2063
- "value": "123"
2064
- }
2065
- ]
2066
- },
2067
- {
2068
- "dimensionValues": [
2069
- {
2070
- "value": "20240106"
2071
- },
2072
- {
2073
- "value": "mobile"
2074
- }
2075
- ],
2076
- "metricValues": [
2077
- {
2078
- "value": "274"
2079
- },
2080
- {
2081
- "value": "235"
2082
- },
2083
- {
2084
- "value": "260"
2085
- },
2086
- {
2087
- "value": "111"
2088
- },
2089
- {
2090
- "value": "7478"
2091
- }
2092
- ]
2093
- },
2094
- {
2095
- "dimensionValues": [
2096
- {
2097
- "value": "20240106"
2098
- },
2099
- {
2100
- "value": "desktop"
2101
- }
2102
- ],
2103
- "metricValues": [
2104
- {
2105
- "value": "22"
2106
- },
2107
- {
2108
- "value": "16"
2109
- },
2110
- {
2111
- "value": "16"
2112
- },
2113
- {
2114
- "value": "5"
2115
- },
2116
- {
2117
- "value": "835"
2118
- }
2119
- ]
2120
- },
2121
- {
2122
- "dimensionValues": [
2123
- {
2124
- "value": "20240106"
2125
- },
2126
- {
2127
- "value": "tablet"
2128
- }
2129
- ],
2130
- "metricValues": [
2131
- {
2132
- "value": "7"
2133
- },
2134
- {
2135
- "value": "5"
2136
- },
2137
- {
2138
- "value": "5"
2139
- },
2140
- {
2141
- "value": "3"
2142
- },
2143
- {
2144
- "value": "127"
2145
- }
2146
- ]
2147
- },
2148
- {
2149
- "dimensionValues": [
2150
- {
2151
- "value": "20240105"
2152
- },
2153
- {
2154
- "value": "mobile"
2155
- }
2156
- ],
2157
- "metricValues": [
2158
- {
2159
- "value": "238"
2160
- },
2161
- {
2162
- "value": "199"
2163
- },
2164
- {
2165
- "value": "209"
2166
- },
2167
- {
2168
- "value": "75"
2169
- },
2170
- {
2171
- "value": "7581"
2172
- }
2173
- ]
2174
- },
2175
- {
2176
- "dimensionValues": [
2177
- {
2178
- "value": "20240105"
2179
- },
2180
- {
2181
- "value": "desktop"
2182
- }
2183
- ],
2184
- "metricValues": [
2185
- {
2186
- "value": "28"
2187
- },
2188
- {
2189
- "value": "23"
2190
- },
2191
- {
2192
- "value": "25"
2193
- },
2194
- {
2195
- "value": "7"
2196
- },
2197
- {
2198
- "value": "1518"
2199
- }
2200
- ]
2201
- },
2202
- {
2203
- "dimensionValues": [
2204
- {
2205
- "value": "20240105"
2206
- },
2207
- {
2208
- "value": "tablet"
2209
- }
2210
- ],
2211
- "metricValues": [
2212
- {
2213
- "value": "4"
2214
- },
2215
- {
2216
- "value": "4"
2217
- },
2218
- {
2219
- "value": "4"
2220
- },
2221
- {
2222
- "value": "2"
2223
- },
2224
- {
2225
- "value": "212"
2226
- }
2227
- ]
2228
- },
2229
- {
2230
- "dimensionValues": [
2231
- {
2232
- "value": "20240104"
2233
- },
2234
- {
2235
- "value": "mobile"
2236
- }
2237
- ],
2238
- "metricValues": [
2239
- {
2240
- "value": "230"
2241
- },
2242
- {
2243
- "value": "199"
2244
- },
2245
- {
2246
- "value": "210"
2247
- },
2248
- {
2249
- "value": "75"
2250
- },
2251
- {
2252
- "value": "9852"
2253
- }
2254
- ]
2255
- },
2256
- {
2257
- "dimensionValues": [
2258
- {
2259
- "value": "20240104"
2260
- },
2261
- {
2262
- "value": "desktop"
2263
- }
2264
- ],
2265
- "metricValues": [
2266
- {
2267
- "value": "34"
2268
- },
2269
- {
2270
- "value": "21"
2271
- },
2272
- {
2273
- "value": "23"
2274
- },
2275
- {
2276
- "value": "6"
2277
- },
2278
- {
2279
- "value": "1272"
2280
- }
2281
- ]
2282
- },
2283
- {
2284
- "dimensionValues": [
2285
- {
2286
- "value": "20240104"
2287
- },
2288
- {
2289
- "value": "tablet"
2290
- }
2291
- ],
2292
- "metricValues": [
2293
- {
2294
- "value": "10"
2295
- },
2296
- {
2297
- "value": "3"
2298
- },
2299
- {
2300
- "value": "3"
2301
- },
2302
- {
2303
- "value": "0"
2304
- },
2305
- {
2306
- "value": "927"
2307
- }
2308
- ]
2309
- },
2310
- {
2311
- "dimensionValues": [
2312
- {
2313
- "value": "20240103"
2314
- },
2315
- {
2316
- "value": "mobile"
2317
- }
2318
- ],
2319
- "metricValues": [
2320
- {
2321
- "value": "237"
2322
- },
2323
- {
2324
- "value": "203"
2325
- },
2326
- {
2327
- "value": "213"
2328
- },
2329
- {
2330
- "value": "69"
2331
- },
2332
- {
2333
- "value": "8451"
2334
- }
2335
- ]
2336
- },
2337
- {
2338
- "dimensionValues": [
2339
- {
2340
- "value": "20240103"
2341
- },
2342
- {
2343
- "value": "desktop"
2344
- }
2345
- ],
2346
- "metricValues": [
2347
- {
2348
- "value": "37"
2349
- },
2350
- {
2351
- "value": "23"
2352
- },
2353
- {
2354
- "value": "25"
2355
- },
2356
- {
2357
- "value": "8"
2358
- },
2359
- {
2360
- "value": "1592"
2361
- }
2362
- ]
2363
- },
2364
- {
2365
- "dimensionValues": [
2366
- {
2367
- "value": "20240103"
2368
- },
2369
- {
2370
- "value": "tablet"
2371
- }
2372
- ],
2373
- "metricValues": [
2374
- {
2375
- "value": "9"
2376
- },
2377
- {
2378
- "value": "7"
2379
- },
2380
- {
2381
- "value": "7"
2382
- },
2383
- {
2384
- "value": "2"
2385
- },
2386
- {
2387
- "value": "585"
2388
- }
2389
- ]
2390
- },
2391
- {
2392
- "dimensionValues": [
2393
- {
2394
- "value": "20240102"
2395
- },
2396
- {
2397
- "value": "mobile"
2398
- }
2399
- ],
2400
- "metricValues": [
2401
- {
2402
- "value": "224"
2403
- },
2404
- {
2405
- "value": "178"
2406
- },
2407
- {
2408
- "value": "184"
2409
- },
2410
- {
2411
- "value": "51"
2412
- },
2413
- {
2414
- "value": "9727"
2415
- }
2416
- ]
2417
- },
2418
- {
2419
- "dimensionValues": [
2420
- {
2421
- "value": "20240102"
2422
- },
2423
- {
2424
- "value": "desktop"
2425
- }
2426
- ],
2427
- "metricValues": [
2428
- {
2429
- "value": "44"
2430
- },
2431
- {
2432
- "value": "24"
2433
- },
2434
- {
2435
- "value": "25"
2436
- },
2437
- {
2438
- "value": "9"
2439
- },
2440
- {
2441
- "value": "1287"
2442
- }
2443
- ]
2444
- },
2445
- {
2446
- "dimensionValues": [
2447
- {
2448
- "value": "20240102"
2449
- },
2450
- {
2451
- "value": "tablet"
2452
- }
2453
- ],
2454
- "metricValues": [
2455
- {
2456
- "value": "1"
2457
- },
2458
- {
2459
- "value": "1"
2460
- },
2461
- {
2462
- "value": "1"
2463
- },
2464
- {
2465
- "value": "0"
2466
- },
2467
- {
2468
- "value": "154"
2469
- }
2470
- ]
2471
- },
2472
- {
2473
- "dimensionValues": [
2474
- {
2475
- "value": "20240101"
2476
- },
2477
- {
2478
- "value": "mobile"
2479
- }
2480
- ],
2481
- "metricValues": [
2482
- {
2483
- "value": "205"
2484
- },
2485
- {
2486
- "value": "174"
2487
- },
2488
- {
2489
- "value": "180"
2490
- },
2491
- {
2492
- "value": "65"
2493
- },
2494
- {
2495
- "value": "7199"
2496
- }
2497
- ]
2498
- },
2499
- {
2500
- "dimensionValues": [
2501
- {
2502
- "value": "20240101"
2503
- },
2504
- {
2505
- "value": "desktop"
2506
- }
2507
- ],
2508
- "metricValues": [
2509
- {
2510
- "value": "12"
2511
- },
2512
- {
2513
- "value": "10"
2514
- },
2515
- {
2516
- "value": "10"
2517
- },
2518
- {
2519
- "value": "3"
2520
- },
2521
- {
2522
- "value": "697"
2523
- }
2524
- ]
2525
- },
2526
- {
2527
- "dimensionValues": [
2528
- {
2529
- "value": "20240101"
2530
- },
2531
- {
2532
- "value": "tablet"
2533
- }
2534
- ],
2535
- "metricValues": [
2536
- {
2537
- "value": "7"
2538
- },
2539
- {
2540
- "value": "7"
2541
- },
2542
- {
2543
- "value": "7"
2544
- },
2545
- {
2546
- "value": "3"
2547
- },
2548
- {
2549
- "value": "184"
2550
- }
2551
- ]
2552
- }
2553
- ]
2554
- }
2555
- ]
2556
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fixture/ga4_report_imported_entry_pages.json DELETED
The diff for this file is too large to render. See raw diff