Nolt sso (along with a better nav dropdown) (#3395)

* Add SSO link with signed JWT token

* Falls back to Nolt URL without SSO if token cannot be generated

* Add profile image (gravatar) to Nolt SSO link

* Improve navbar dropdown

* Add 'contact support' link to nav dropdown

* Add CSS rule to prevent horizontal jumps

* Dark mode styling

* Close dropdown when link is clicked

* Clarify links in dropdown

* Clarify CSS comment

* Use Alpine.data() over window

* Rename suggestions_dropdown -> combo-box

* Mix format

* Make logout link look good on dark mode

* Use proxy for gravatar

* Do not use Gravatar proxy in self-hosted

* Changelog

* Add Github Repo link to nav dropdown

* Make dialyzer happy

* Add proxy for Gravatar

* Update assets/css/app.css

Co-authored-by: hq1 <hq@mtod.org>

* Update lib/plausible_web/controllers/avatar_controller.ex

Co-authored-by: hq1 <hq@mtod.org>

* Fix alpine <> Liveview integration

---------

Co-authored-by: hq1 <hq@mtod.org>
This commit is contained in:
Uku Taht 2023-10-17 12:01:27 +03:00 committed by GitHub
parent 99efb93082
commit 97b24c0492
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 472 additions and 189 deletions

View File

@ -9,6 +9,7 @@ All notable changes to this project will be documented in this file.
- Add `custom_props.csv` to CSV export (almost the same as the old `prop_breakdown.csv`, but has different column headers, and includes props for pageviews too, not only custom events)
- Add `referrers.csv` to CSV export
- Improve password validation in registration and password reset forms
- Adds Gravatar profile image to navbar
- Enforce email reverification on update
### Removed

View File

@ -41,6 +41,7 @@ html {
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
width:100vw; /* Prevents content from jumping when scrollbar is added/removed due to vertical overflow */
overflow-x: hidden;
}

View File

@ -2,11 +2,16 @@ import "../css/app.css"
import "flatpickr/dist/flatpickr.min.css"
import "./polyfills/closest"
import 'abortcontroller-polyfill/dist/polyfill-patch-fetch'
import 'alpinejs'
import Alpine from 'alpinejs'
import "./liveview/live_socket"
import "./liveview/suggestions_dropdown"
import comboBox from "./liveview/combo-box"
import dropdown from "./liveview/dropdown"
import "./liveview/phx_events"
Alpine.data('dropdown', dropdown)
Alpine.data('comboBox', comboBox)
Alpine.start()
const triggers = document.querySelectorAll('[data-dropdown-trigger]')
for (const trigger of triggers) {

View File

@ -0,0 +1,77 @@
// Courtesy of Benjamin von Polheim:
// https://blog.devgenius.io/build-a-performat-autocomplete-using-phoenix-liveview-and-alpine-js-8bcbbed17ba7
export default (id) => ({
isOpen: false,
id: id,
focus: null,
setFocus(f) {
this.focus = f;
},
initFocus() {
if (this.focus === null) {
this.setFocus(this.leastFocusableIndex())
}
},
open() {
this.initFocus()
this.isOpen = true
},
suggestionsCount() {
return this.$refs.suggestions?.querySelectorAll('li').length
},
hasCreatableOption() {
return this.$refs.suggestions?.querySelector('li').classList.contains("creatable")
},
leastFocusableIndex() {
if (this.suggestionsCount() === 0) {
return 0
}
return this.hasCreatableOption() ? 0 : 1
},
maxFocusableIndex() {
return this.hasCreatableOption() ? this.suggestionsCount() - 1 : this.suggestionsCount()
},
nextFocusableIndex() {
const currentFocus = this.focus
return currentFocus + 1 > this.maxFocusableIndex() ? this.leastFocusableIndex() : currentFocus + 1
},
prevFocusableIndex() {
const currentFocus = this.focus
return currentFocus - 1 >= this.leastFocusableIndex() ? currentFocus - 1 : this.maxFocusableIndex()
},
close(e) {
// Pressing Escape should not propagate to window,
// so we'll only close the suggestions pop-up
if (this.isOpen && e.key === "Escape") {
e.stopPropagation()
}
this.isOpen = false
},
select() {
this.$refs[`dropdown-${this.id}-option-${this.focus}`]?.click()
this.close()
document.getElementById(this.id).blur()
},
scrollTo(idx) {
this.$refs[`dropdown-${this.id}-option-${idx}`]?.scrollIntoView(
{ block: 'nearest', behavior: 'smooth', inline: 'start' }
)
},
focusNext() {
const nextIndex = this.nextFocusableIndex()
if (!this.isOpen) this.open()
this.setFocus(nextIndex)
this.scrollTo(nextIndex)
},
focusPrev() {
const prevIndex = this.prevFocusableIndex()
if (!this.isOpen) this.open()
this.setFocus(prevIndex)
this.scrollTo(prevIndex)
}
})

View File

@ -0,0 +1,26 @@
// From https://alpinejs.dev/component/dropdown
export default () => ({
open: false,
toggle() {
if (this.open) {
return this.close()
}
this.$refs.button.focus()
this.open = true
},
close(focusAfter) {
if (! this.open) return
this.open = false
focusAfter && focusAfter.focus()
},
onPanelClick(e) {
if (e.target.tagName === 'A') {
this.close()
}
}
})

View File

@ -1,4 +1,5 @@
import "phoenix_html"
import Alpine from 'alpinejs'
import { Socket } from "phoenix"
import { LiveSocket } from "phoenix_live_view"
@ -13,7 +14,7 @@ if (csrfToken && websocketUrl) {
this.pushEvent("send-metrics-after", {event_name, params})
}
setTimeout(afterMetrics, 5000)
params.callback = afterMetrics
params.callback = afterMetrics
window.plausible(event_name, params)
})
}
@ -26,8 +27,8 @@ if (csrfToken && websocketUrl) {
params: { _csrf_token: token }, hooks: Hooks, dom: {
// for alpinejs integration
onBeforeElUpdated(from, to) {
if (from.__x) {
window.Alpine.clone(from.__x, to);
if (from._x_dataStack) {
Alpine.clone(from, to);
}
},
}

View File

@ -1,80 +0,0 @@
// Courtesy of Benjamin von Polheim:
// https://blog.devgenius.io/build-a-performat-autocomplete-using-phoenix-liveview-and-alpine-js-8bcbbed17ba7
let suggestionsDropdown = function(id) {
return {
isOpen: false,
id: id,
focus: null,
setFocus(f) {
this.focus = f;
},
initFocus() {
if (this.focus === null) {
this.setFocus(this.leastFocusableIndex())
}
},
open() {
this.initFocus()
this.isOpen = true
},
suggestionsCount() {
return this.$refs.suggestions?.querySelectorAll('li').length
},
hasCreatableOption() {
return this.$refs.suggestions?.querySelector('li').classList.contains("creatable")
},
leastFocusableIndex() {
if (this.suggestionsCount() === 0) {
return 0
}
return this.hasCreatableOption() ? 0 : 1
},
maxFocusableIndex() {
return this.hasCreatableOption() ? this.suggestionsCount() - 1 : this.suggestionsCount()
},
nextFocusableIndex() {
const currentFocus = this.focus
return currentFocus + 1 > this.maxFocusableIndex() ? this.leastFocusableIndex() : currentFocus + 1
},
prevFocusableIndex() {
const currentFocus = this.focus
return currentFocus - 1 >= this.leastFocusableIndex() ? currentFocus - 1 : this.maxFocusableIndex()
},
close(e) {
// Pressing Escape should not propagate to window,
// so we'll only close the suggestions pop-up
if (this.isOpen && e.key === "Escape") {
e.stopPropagation()
}
this.isOpen = false
},
select() {
this.$refs[`dropdown-${this.id}-option-${this.focus}`]?.click()
this.close()
document.getElementById(this.id).blur()
},
scrollTo(idx) {
this.$refs[`dropdown-${this.id}-option-${idx}`]?.scrollIntoView(
{ block: 'nearest', behavior: 'smooth', inline: 'start' }
)
},
focusNext() {
const nextIndex = this.nextFocusableIndex()
if (!this.isOpen) this.open()
this.setFocus(nextIndex)
this.scrollTo(nextIndex)
},
focusPrev() {
const prevIndex = this.prevFocusableIndex()
if (!this.isOpen) this.open()
this.setFocus(prevIndex)
this.scrollTo(prevIndex)
}
}
}
window.suggestionsDropdown = suggestionsDropdown

View File

@ -21,7 +21,7 @@
"@tailwindcss/forms": "^0.5.6",
"@tailwindcss/typography": "^0.4.1",
"abortcontroller-polyfill": "^1.7.3",
"alpinejs": "^2.8.2",
"alpinejs": "^3.13.1",
"autoprefixer": "^10.4.15",
"babel-loader": "^8.2.2",
"chart.js": "^3.3.2",
@ -2025,6 +2025,19 @@
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz",
"integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA=="
},
"node_modules/@vue/reactivity": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz",
"integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==",
"dependencies": {
"@vue/shared": "3.1.5"
}
},
"node_modules/@vue/shared": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz",
"integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA=="
},
"node_modules/@webassemblyjs/ast": {
"version": "1.11.6",
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.6.tgz",
@ -2289,9 +2302,12 @@
"integrity": "sha1-l6ERlkmyEa0zaR2fn0hqjsn74KM= sha512-0FcBfdcmaumGPQ0qPn7Q5qTgz/ooXgIyp1rf8ik5bGX8mpE2YHjC0P/eyQvxu1GURYQgq9ozf2mteQ5ZD9YiyQ=="
},
"node_modules/alpinejs": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-2.8.2.tgz",
"integrity": "sha512-5yOUtckn4CBp0qsHpo2qgjZyZit84uXvHbB7NJ27sn4FA6UlFl2i9PGUAdTXkcbFvvxDJBM+zpOD8RuNYFvQAw=="
"version": "3.13.1",
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.13.1.tgz",
"integrity": "sha512-/LZ7mumW02V7AV5xTTftJFHS0I3KOXLl7tHm4xpxXAV+HJ/zjTT0n8MU7RZ6UoGPhmO/i+KEhQojaH/0RsH5tg==",
"dependencies": {
"@vue/reactivity": "~3.1.1"
}
},
"node_modules/ansi-colors": {
"version": "4.1.3",
@ -11101,6 +11117,19 @@
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz",
"integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA=="
},
"@vue/reactivity": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz",
"integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==",
"requires": {
"@vue/shared": "3.1.5"
}
},
"@vue/shared": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz",
"integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA=="
},
"@webassemblyjs/ast": {
"version": "1.11.6",
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.6.tgz",
@ -11328,9 +11357,12 @@
"integrity": "sha1-l6ERlkmyEa0zaR2fn0hqjsn74KM= sha512-0FcBfdcmaumGPQ0qPn7Q5qTgz/ooXgIyp1rf8ik5bGX8mpE2YHjC0P/eyQvxu1GURYQgq9ozf2mteQ5ZD9YiyQ=="
},
"alpinejs": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-2.8.2.tgz",
"integrity": "sha512-5yOUtckn4CBp0qsHpo2qgjZyZit84uXvHbB7NJ27sn4FA6UlFl2i9PGUAdTXkcbFvvxDJBM+zpOD8RuNYFvQAw=="
"version": "3.13.1",
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.13.1.tgz",
"integrity": "sha512-/LZ7mumW02V7AV5xTTftJFHS0I3KOXLl7tHm4xpxXAV+HJ/zjTT0n8MU7RZ6UoGPhmO/i+KEhQojaH/0RsH5tg==",
"requires": {
"@vue/reactivity": "~3.1.1"
}
},
"ansi-colors": {
"version": "4.1.3",

View File

@ -23,7 +23,7 @@
"@tailwindcss/forms": "^0.5.6",
"@tailwindcss/typography": "^0.4.1",
"abortcontroller-polyfill": "^1.7.3",
"alpinejs": "^2.8.2",
"alpinejs": "^3.13.1",
"autoprefixer": "^10.4.15",
"babel-loader": "^8.2.2",
"chart.js": "^3.3.2",

View File

@ -509,6 +509,9 @@ config :plausible, :hcaptcha,
sitekey: hcaptcha_sitekey,
secret: hcaptcha_secret
nolt_sso_secret = get_var_from_path_or_env(config_dir, "NOLT_SSO_SECRET")
config :joken, default_signer: nolt_sso_secret
config :plausible, Plausible.Sentry.Client,
finch_request_opts: [
pool_timeout: get_int_from_path_or_env(config_dir, "SENTRY_FINCH_POOL_TIMEOUT", 5000),

View File

@ -153,6 +153,17 @@ defmodule Plausible.Auth.User do
%{suggestions: [], warning: "", score: 3}
end
def profile_img_url(%__MODULE__{email: email}) do
hash =
email
|> String.trim()
|> String.downcase()
|> :erlang.md5()
|> Base.encode16(case: :lower)
Path.join(PlausibleWeb.Endpoint.url(), ["avatar/", hash])
end
defp validate_email_changed(changeset) do
if !get_change(changeset, :email) && !changeset.errors[:email] do
add_error(changeset, :email, "can't be the same", validation: :different_email)

View File

@ -47,11 +47,89 @@ defmodule PlausibleWeb.Components.Generic do
attr :id, :any, default: nil
attr :href, :string, required: true
attr :new_tab, :boolean
attr :new_tab, :boolean, default: false
attr :class, :string, default: ""
slot :inner_block
def styled_link(assigns) do
~H"""
<.unstyled_link
new_tab={@new_tab}
href={@href}
class="text-indigo-600 hover:text-indigo-700 dark:text-indigo-500 dark:hover:text-indigo-600"
>
<%= render_slot(@inner_block) %>
</.unstyled_link>
"""
end
slot :button, required: true do
attr :class, :string
end
slot :panel, required: true do
attr :class, :string
end
def dropdown(assigns) do
~H"""
<div class="flex justify-center">
<div
x-data="dropdown"
x-on:keydown.escape.prevent.stop="close($refs.button)"
x-on:focusin.window="! $refs.panel.contains($event.target) && close()"
x-id="['dropdown-button']"
class="relative"
>
<button
x-ref="button"
x-on:click="toggle()"
x-bind:aria-expanded="open"
x-bind:aria-controls="$id('dropdown-button')"
type="button"
class={List.first(@button).class}
>
<%= render_slot(List.first(@button)) %>
</button>
<div
x-ref="panel"
x-show="open"
x-transition.origin.top.left
x-on:click.outside="close($refs.button)"
x-on:click="onPanelClick"
x-bind:id="$id('dropdown-button')"
style="display: none;"
class={List.first(@panel).class}
>
<%= render_slot(List.first(@panel)) %>
</div>
</div>
</div>
"""
end
attr :href, :string, required: true
attr :new_tab, :boolean, default: false
slot :inner_block, required: true
def dropdown_link(assigns) do
~H"""
<.unstyled_link
new_tab={@new_tab}
href={@href}
class="w-full justify-between text-gray-700 dark:text-gray-300 block px-3.5 py-1.5 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-gray-100"
>
<%= render_slot(@inner_block) %>
</.unstyled_link>
"""
end
attr :href, :string, required: true
attr :new_tab, :boolean, default: false
attr :class, :string, default: ""
slot :inner_block
def unstyled_link(assigns) do
if assigns[:new_tab] do
assigns = assign(assigns, :icon_class, icon_class(assigns))
@ -59,7 +137,7 @@ defmodule PlausibleWeb.Components.Generic do
<.link
id={@id}
class={[
"inline-flex items-center gap-x-0.5 text-indigo-600 hover:text-indigo-700 dark:text-indigo-500 dark:hover:text-indigo-600",
"inline-flex items-center gap-x-0.5",
@class
]}
href={@href}
@ -67,18 +145,12 @@ defmodule PlausibleWeb.Components.Generic do
rel="noopener noreferrer"
>
<%= render_slot(@inner_block) %>
<Heroicons.arrow_top_right_on_square class={@icon_class} />
<Heroicons.arrow_top_right_on_square class={["opacity-60", @icon_class]} />
</.link>
"""
else
~H"""
<.link
class={[
"text-indigo-600 hover:text-indigo-700 dark:text-indigo-500 dark:hover:text-indigo-600",
@class
]}
href={@href}
>
<.link class={@class} href={@href}>
<%= render_slot(@inner_block) %>
</.link>
"""

View File

@ -0,0 +1,38 @@
defmodule PlausibleWeb.AvatarController do
@moduledoc """
This module proxies requests to BASE_URL/avatar/:hash to www.gravatar.com/avatar/:hash.
The purpose is to make use of Gravatar's convenient avatar service without exposing information
that could be used for tracking the Plausible user. Compared to requesting the Gravatar directly
from the browser, this proxy module protects the Plausible user from disclosing to Gravatar:
1. The client IP address
2. User-Agent
3. Referer header which can be used to track which site the user is visiting (i.e. plausible.io or self-hosted URL)
The downside is the added latency from the request having to go through the Plausible server, rather than contacting the
local CDN server operated by Gravatar's service.
"""
use PlausibleWeb, :controller
alias Plausible.HTTPClient
@gravatar_base_url "https://www.gravatar.com"
def avatar(conn, params) do
url = Path.join(@gravatar_base_url, ["avatar/", params["hash"]]) <> "?s=150&d=identicon"
case HTTPClient.impl().get(url) do
{:ok, %Finch.Response{status: 200, body: body, headers: headers}} ->
conn
|> forward_headers(headers)
|> send_resp(200, body)
{:error, _} ->
send_resp(conn, 400, "")
end
end
@forwarded_headers ["content-type", "cache-control", "expires"]
defp forward_headers(conn, headers) do
headers_to_forward = Enum.filter(headers, fn {k, _} -> k in @forwarded_headers end)
%Plug.Conn{conn | resp_headers: headers_to_forward}
end
end

View File

@ -80,7 +80,7 @@ defmodule PlausibleWeb.Live.Components.ComboBox do
<div
id={"input-picker-main-#{@id}"}
class={@class}
x-data={"window.suggestionsDropdown('#{@id}')"}
x-data={"comboBox('#{@id}')"}
x-on:keydown.arrow-up.prevent="focusPrev"
x-on:keydown.arrow-down.prevent="focusNext"
x-on:keydown.enter.prevent="select"

View File

@ -171,6 +171,7 @@ defmodule PlausibleWeb.Router do
post "/password/request-reset", AuthController, :password_reset_request
get "/password/reset", AuthController, :password_reset_form
post "/password/reset", AuthController, :password_reset
get "/avatar/:hash", AvatarController, :avatar
post "/error_report", ErrorReportController, :submit_error_report
end

View File

@ -1,72 +0,0 @@
<nav class="relative z-20 py-8">
<div class="container">
<nav class="relative flex items-center justify-between sm:h-10 md:justify-center">
<div class="flex items-center flex-1 md:absolute md:inset-y-0 md:left-0">
<div class="flex items-center justify-between">
<a href="<%= home_dest(@conn) %>">
<%= img_tag(PlausibleWeb.Router.Helpers.static_path(@conn, "/images/icon/plausible_logo_dark.png"), class: "h-8 w-auto sm:h-10 -mt-2 hidden dark:inline", alt: "Plausible logo", loading: "lazy")%>
<%= img_tag(PlausibleWeb.Router.Helpers.static_path(@conn, "/images/icon/plausible_logo.png"), class: "h-8 w-auto sm:h-10 -mt-2 inline dark:hidden", alt: "Plausible logo", loading: "lazy") %>
</a>
</div>
</div>
<div class="absolute inset-y-0 right-0 flex items-center justify-end w-2/3 sm:w-auto">
<%= cond do %>
<% @conn.assigns[:current_user] -> %>
<ul class="flex w-full sm:w-auto">
<%= if Application.get_env(:plausible, :is_selfhost) do %>
<li class="hidden mr-6 sm:block">
<%= link(to: "https://github.com/plausible/analytics", class: "font-bold rounded m-1 ml-0 p-1 hover:bg-gray-200 dark:hover:bg-gray-900 dark:text-gray-100", style: "line-height: 40px;", target: "_blank") do %>
<svg class="inline w-4 h-4 mr-px -mt-1" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>GitHub icon</title><path fill="currentColor" d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>
Repo
<% end %>
</li>
<% end %>
<%= if Plausible.Billing.on_trial?(@conn.assigns[:current_user]) && !Application.get_env(:plausible, :is_selfhost) do %>
<li class="hidden mr-6 sm:block">
<%= link(trial_notificaton(@conn.assigns[:current_user]), to: "/settings", class: "font-bold text-sm text-yellow-900 dark:text-yellow-900 rounded p-2 bg-yellow-100 dark:bg-yellow-100", style: "line-height: 40px;") %>
</li>
<% end %>
<li class="hidden mr-6 sm:block">
<%= link("Docs", to: "https://plausible.io/docs", class: "font-bold rounded m-1 p-1 hover:bg-gray-200 dark:hover:bg-gray-900 dark:text-gray-100", style: "line-height: 40px;", target: "_blank", rel: "noreferrer") %>
</li>
<li class="w-full sm:w-auto">
<div class="relative font-bold rounded">
<div data-dropdown-trigger class="flex items-center justify-end p-1 m-1 rounded cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-900 dark:text-gray-100">
<span class="pl-2 mr-2 truncate"><%= @conn.assigns[:current_user].name || @conn.assigns[:current_user].email %></span>
<svg style="height: 18px; transform: translateY(2px); fill: #606f7b;" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" x="0px" y="0px" viewBox="0 0 512 640" enable-background="new 0 0 512 512" xml:space="preserve"><g><circle cx="256" cy="52.8" r="50.8"/><circle cx="256" cy="256" r="50.8"/><circle cx="256" cy="459.2" r="50.8"/></g></svg>
</div>
<div data-dropdown style="top: 42px; right: 0px; width: 185px;" class="absolute right-0 hidden bg-white border border-gray-300 rounded shadow-md dropdown-content dark:bg-gray-800 dark:border-gray-500">
<%= link("Settings", to: "/settings", class: "block py-2 px-2 border-b border-gray-300 dark:border-gray-500 hover:bg-gray-100 dark:hover:bg-gray-900 dark:text-gray-100") %>
<%= link("Log out", to: "/logout", class: "block py-2 px-2 hover:bg-gray-100 dark:hover:bg-gray-900 dark:text-gray-100") %>
</div>
</div>
</li>
<%= if @conn.assigns[:current_user] && !Application.get_env(:plausible, :is_selfhost) do %>
<li id="changelog-notification" class="relative py-2"></li>
<% end %>
</ul>
<% Keyword.fetch!(Application.get_env(:plausible, :selfhost), :disable_registration) != false -> %>
<ul class="flex" x-show="!document.cookie.includes('logged_in=true')">
<li>
<div class="inline-flex">
<a href="/login" class="font-medium text-gray-500 dark:text-gray-200 hover:text-gray-900 focus:outline-none focus:text-gray-900 transition duration-150 ease-in-out">Login</a>
</div>
</li>
</ul>
<% true -> %>
<ul class="flex" x-show="!document.cookie.includes('logged_in=true')">
<li>
<div class="inline-flex">
<a href="/login" class="font-medium text-gray-500 dark:text-gray-200 hover:text-gray-900 focus:outline-none focus:text-gray-900 transition duration-150 ease-in-out">Login</a>
</div>
<div class="inline-flex ml-6 rounded shadow">
<a href="/register" class="inline-flex items-center justify-center px-5 py-2 text-base font-medium text-white bg-indigo-600 border border-transparent leading-6 rounded-md hover:bg-indigo-500 focus:outline-none focus:ring transition duration-150 ease-in-out">Sign up</a>
</div>
</li>
</ul>
<% end %>
</div>
</nav>
</div>
</nav>

View File

@ -0,0 +1,121 @@
<nav class="relative z-20 py-8">
<div class="container">
<nav class="relative flex items-center justify-between sm:h-10 md:justify-center">
<div class="flex items-center flex-1 md:absolute md:inset-y-0 md:left-0">
<a href={home_dest(@conn)}>
<%= img_tag(
PlausibleWeb.Router.Helpers.static_path(
@conn,
"/images/icon/plausible_logo_dark.png"
),
class: "h-8 w-auto sm:h-10 -mt-2 hidden dark:inline",
alt: "Plausible logo",
loading: "lazy"
) %>
<%= img_tag(
PlausibleWeb.Router.Helpers.static_path(@conn, "/images/icon/plausible_logo.png"),
class: "h-8 w-auto sm:h-10 -mt-2 inline dark:hidden",
alt: "Plausible logo",
loading: "lazy"
) %>
</a>
</div>
<div class="absolute inset-y-0 right-0 flex items-center justify-end">
<%= cond do %>
<% @conn.assigns[:current_user] -> %>
<ul class="flex items-center w-full sm:w-auto">
<%= if Plausible.Billing.on_trial?(@conn.assigns[:current_user]) && !Application.get_env(:plausible, :is_selfhost) do %>
<li class="hidden mr-6 sm:block">
<%= link(trial_notificaton(@conn.assigns[:current_user]),
to: "/settings",
class:
"text-sm text-yellow-900 dark:text-yellow-900 rounded px-3 py-2 rounded-md bg-yellow-100 dark:bg-yellow-100"
) %>
</li>
<% end %>
<li class="w-full sm:w-auto">
<.dropdown>
<:button class="flex items-center gap-3 px-3 py-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800">
<span class="font-medium truncate dark:text-gray-100">
<%= @conn.assigns[:current_user].name %>
</span>
<%= img_tag(Plausible.Auth.User.profile_img_url(@conn.assigns[:current_user]),
class: "w-7 rounded-full"
) %>
</:button>
<:panel class="absolute right-0 z-10 mt-2 w-60 origin-top-right divide-y divide-gray-100 dark:divide-gray-400 rounded-md bg-white dark:bg-gray-800 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
<div class="px-3.5 py-3" role="none">
<p class="block text-sm text-gray-500 dark:text-gray-400" role="none">
Signed in as
</p>
<p class="truncate font-medium text-gray-900 dark:text-gray-100" role="none">
<%= @conn.assigns[:current_user].email %>
</p>
</div>
<div class="py-1.5" role="none">
<.dropdown_link href="/settings">Account Settings</.dropdown_link>
<.dropdown_link new_tab href="https://plausible.io/docs">
Help Center
</.dropdown_link>
<%= if Application.get_env(:plausible, :is_selfhost) do %>
<.dropdown_link new_tab href="https://github.com/plausible/analytics">
Github Repo
</.dropdown_link>
<% else %>
<.dropdown_link new_tab href="https://plausible.io/contact">
Contact Support
</.dropdown_link>
<.dropdown_link new_tab href="https://feedback.plausible.io">
Feature Requests
</.dropdown_link>
<% end %>
</div>
<div class="py-1.5" role="none">
<.dropdown_link href="/logout">Log Out</.dropdown_link>
</div>
</:panel>
</.dropdown>
</li>
<%= if @conn.assigns[:current_user] && !Application.get_env(:plausible, :is_selfhost) do %>
<li id="changelog-notification" class="relative py-2"></li>
<% end %>
</ul>
<% Keyword.fetch!(Application.get_env(:plausible, :selfhost), :disable_registration) != false -> %>
<ul class="flex" x-show="!document.cookie.includes('logged_in=true')">
<li>
<div class="inline-flex">
<a
href="/login"
class="font-medium text-gray-500 dark:text-gray-200 hover:text-gray-900 focus:outline-none focus:text-gray-900 transition duration-150 ease-in-out"
>
Login
</a>
</div>
</li>
</ul>
<% true -> %>
<ul class="flex" x-show="!document.cookie.includes('logged_in=true')">
<li>
<div class="inline-flex">
<a
href="/login"
class="font-medium text-gray-500 dark:text-gray-200 hover:text-gray-900 focus:outline-none focus:text-gray-900 transition duration-150 ease-in-out"
>
Login
</a>
</div>
<div class="inline-flex ml-6 rounded shadow">
<a
href="/register"
class="inline-flex items-center justify-center px-5 py-2 text-base font-medium text-white bg-indigo-600 border border-transparent leading-6 rounded-md hover:bg-indigo-500 focus:outline-none focus:ring transition duration-150 ease-in-out"
>
Sign up
</a>
</div>
</li>
</ul>
<% end %>
</div>
</nav>
</div>
</nav>

View File

@ -12,7 +12,7 @@
<li class="py-4">
<div class="flex items-center space-x-4">
<div class="flex-shrink-0">
<%= gravatar(membership.user.email, class: "h-8 w-8 rounded-full") %>
<%= img_tag(Plausible.Auth.User.profile_img_url(membership.user), class: "h-8 w-8 rounded-full") %>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 dark:text-gray-50 truncate">

View File

@ -40,6 +40,27 @@ defmodule PlausibleWeb.LayoutView do
end
end
defmodule JWT do
use Joken.Config
end
def feedback_link(user) do
token_params = %{
"id" => user.id,
"email" => user.email,
"name" => user.name,
"imageUrl" => Plausible.Auth.User.profile_img_url(user)
}
case JWT.generate_and_sign(token_params) do
{:ok, token, _claims} ->
"https://feedback.plausible.io/sso/#{token}?returnUrl=https://feedback.plausible.io"
_ ->
"https://feedback.plausible.io"
end
end
def home_dest(conn) do
if conn.assigns[:current_user] do
"/sites"

View File

@ -23,18 +23,6 @@ defmodule PlausibleWeb.SiteView do
Plausible.Sites.shared_link_url(site, link)
end
def gravatar(email, opts) do
hash =
email
|> String.trim()
|> String.downcase()
|> :erlang.md5()
|> Base.encode16(case: :lower)
img = "https://www.gravatar.com/avatar/#{hash}?s=150&d=identicon"
img_tag(img, opts)
end
def render_snippet(site) do
tracker =
if site.custom_domain do

View File

@ -124,6 +124,7 @@ defmodule Plausible.MixProject do
{:heroicons, "~> 0.5.0"},
{:zxcvbn, git: "https://github.com/techgaun/zxcvbn-elixir.git"},
{:open_api_spex, "~> 3.18"},
{:joken, "~> 2.5"},
{:paginator, git: "https://github.com/duffelhq/paginator.git"}
]
end

View File

@ -67,6 +67,8 @@
"httpoison": {:hex, :httpoison, "1.8.2", "9eb9c63ae289296a544842ef816a85d881d4a31f518a0fec089aaa744beae290", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "2bb350d26972e30c96e2ca74a1aaf8293d61d0742ff17f01e0279fef11599921"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"},
"joken": {:hex, :joken, "2.6.0", "b9dd9b6d52e3e6fcb6c65e151ad38bf4bc286382b5b6f97079c47ade6b1bcc6a", [:mix], [{:jose, "~> 1.11.5", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5a95b05a71cd0b54abd35378aeb1d487a23a52c324fa7efdffc512b655b5aaa7"},
"jose": {:hex, :jose, "1.11.6", "613fda82552128aa6fb804682e3a616f4bc15565a048dabd05b1ebd5827ed965", [:mix, :rebar3], [], "hexpm", "6275cb75504f9c1e60eeacb771adfeee4905a9e182103aa59b53fed651ff9738"},
"jsx": {:hex, :jsx, "2.8.3", "a05252d381885240744d955fbe3cf810504eb2567164824e19303ea59eef62cf", [:mix, :rebar3], [], "hexpm", "fc3499fed7a726995aa659143a248534adc754ebd16ccd437cd93b649a95091f"},
"jumper": {:hex, :jumper, "1.0.1", "3c00542ef1a83532b72269fab9f0f0c82bf23a35e27d278bfd9ed0865cecabff", [:mix], [], "hexpm", "318c59078ac220e966d27af3646026db9b5a5e6703cb2aa3e26bcfaba65b7433"},
"kaffy": {:hex, :kaffy, "0.9.4", "6a5446cd2c782b8e122061eab409254eb1fa412adb5824169f0528d16775dc45", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.5", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}], "hexpm", "91736c9ddc34a94ed76cb56058fdb6b206c9d777b71856c90ef4554f485f13b9"},

View File

@ -0,0 +1,34 @@
defmodule PlausibleWeb.AvatarControllerTest do
use PlausibleWeb.ConnCase, async: true
import Mox
setup :verify_on_exit!
describe "GET /avatar/:hash" do
test "proxies the request to gravatar", %{conn: conn} do
expect(
Plausible.HTTPClient.Mock,
:get,
fn "https://www.gravatar.com/avatar/myhash?s=150&d=identicon" ->
{:ok,
%Finch.Response{
status: 200,
body: "avatar response body",
headers: [
{"content-type", "image/png"},
{"cache-control", "max-age=300"},
{"expires", "soon"}
]
}}
end
)
conn = get(conn, "/avatar/myhash")
assert response(conn, 200) =~ "avatar response body"
assert {"content-type", "image/png"} in conn.resp_headers
assert {"cache-control", "max-age=300"} in conn.resp_headers
assert {"expires", "soon"} in conn.resp_headers
end
end
end