Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: access tokens management ui #1896

Merged
merged 7 commits into from
Dec 20, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion assets/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@ import LiveReact, { initLiveReact } from "phoenix_live_react";

import sourceLiveViewHooks from "./source_lv_hooks";
import logsLiveViewHooks from "./log_event_live_hooks";

import $ from "jquery"
import moment from "moment";

// set moment globally before daterangepicker
window.moment = moment;

Expand Down Expand Up @@ -100,4 +101,24 @@ window.liveSocket = liveSocket;

document.addEventListener("DOMContentLoaded", (e) => {
initLiveReact();

});

// Use `:text` on the `:detail` optoin to pass values to event listener
window.addEventListener("logflare:copy-to-clipboard", (event) => {
if ("clipboard" in navigator) {
const text = event.detail?.text || event.target.textContent;
navigator.clipboard.writeText(text);
} else {
console.error("Your browser does not support clipboard copy.");
}
});


window.addEventListener("phx:page-loading-stop", _info => {
// enable all tooltips
$(function () {
$('[data-toggle="tooltip"]').tooltip({delay: {show: 100, hide: 200}})
});

})
1 change: 1 addition & 0 deletions lib/logflare_web.ex
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ defmodule LogflareWeb do
quote do
use LogflareWeb.LiveCommons
use LogflareWeb.ModalLiveHelpers
alias Phoenix.LiveView.JS
end
end

Expand Down
3 changes: 2 additions & 1 deletion lib/logflare_web/core_components.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@ defmodule LogflareWeb.CoreComponents do
attr :variant, :string,
values: ["primary", "secondary", "success", "danger", "warning", "info", "light", "dark"]

attr :class, :string, required: false, default: ""
slot :inner_block, required: true

def alert(assigns) do
~H"""
<div class={"alert alert-#{@variant}"} role="alert">
<div class={["alert alert-#{@variant}", @class]} role="alert">
<%= render_slot(@inner_block) %>
</div>
"""
Expand Down
111 changes: 79 additions & 32 deletions lib/logflare_web/live/access_tokens_live.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,69 +6,113 @@ defmodule LogflareWeb.AccessTokensLive do

def render(assigns) do
~H"""
<div class="subhead">
<div class="container mx-auto">
<h5>~/account/access tokens</h5>
<.subheader>
<:path>
~/accounts/<.subheader_path_link live_patch to={~p"/access-tokens"}>access tokens</.subheader_path_link>
</:path>
<.subheader_link to="https://docs.logflare.app/concepts/access-tokens/" text="docs" fa_icon="book" />
</.subheader>

<section class="content container mx-auto tw-flex tw-flex-col w-full tw-gap-4">
<div>
<button class="btn btn-primary" phx-click="toggle-create-form" phx-value-show="true">
Create access token
</button>
</div>
</div>

<section class="content container mx-auto flex flex-col w-full">
<div class="mb-4">
<p>
<strong>Accesss tokens are only supported for Logflare Endpoints for now.</strong>
</p>
<p style="white-space: pre-wrap">Theree 3 ways of authenticating with the API: in the <code>Authorization</code> header, the <code>X-API-KEY</code> header, or the <code>api_key</code> query parameter.
<div>
<p style="white-space: pre-wrap">There are 3 ways of authenticating with the API: in the <code>Authorization</code> header, the <code>X-API-KEY</code> header, or the <code>api_key</code> query parameter.

The <code>Authorization</code> header method expects the header format <code>Authorization: Bearer your-access-token</code>.
The <code>X-API-KEY</code> header method expects the header format <code>X-API-KEY: your-access-token</code>.
The <code>api_key</code> query parameter method expects the search format <code>?api_key=your-access-token</code>.</p>
<button class="btn btn-primary" phx-click="toggle-create-form" phx-value-show="true">
Create access token
</button>

<form phx-submit="create-token" class={["mt-4", if(@show_create_form == false, do: "hidden")]}>
<label>Description</label>
<input name="description" autofocus />
<%= submit("Create") %>
<button type="button" phx-click="toggle-create-form" phx-value-show="false">Cancel</button>
</form>
<.form for={%{}} action="#" phx-submit="create-token" class={["mt-4", "jumbotron jumbotron-fluid tw-p-4", if(@show_create_form == false, do: "hidden")]}>
<h5>New Access Token</h5>
<div class="form-group">
<label name="description">Description</label>
<input name="description" autofocus class="form-control" />
<small class="form-text text-muted">A short description for identifying what this access token is to be used for.</small>
</div>

<div class="form-group ">
<label name="scopes" class="tw-mr-3">Scope</label>
<%= for %{value: value, description: description} <- [%{
value: "public",
description: "For ingestion and endpoints"
}, %{
value: "private",
description: "For account management"
}] do %>
<div class="form-check form-check-inline tw-mr-2">
<input class="form-check-input" type="radio" name="scopes" id={["scopes", value]} value={value} checked={value == "public"} />
<label class="form-check-label tw-px-1" for={["scopes", value]}><%= String.capitalize(value) %></label>
<small class="form-text text-muted"><%= description %></small>
</div>
<% end %>
</div>
<button type="button" class="btn btn-secondary" phx-click="toggle-create-form" phx-value-show="false">Cancel</button>
<%= submit("Create", class: "btn btn-primary") %>
</.form>

<%= if @created_token do %>
<div class="mt-4">
<.alert variant="success">
<p>Access token created successfully, copy this token to a safe location. For security purposes, this token will not be shown again.</p>

<pre class="p-2"><%= @created_token.token %></pre>
<button phx-click="dismiss-created-token">
<button class="btn btn-secondary" phx-click={JS.dispatch("logflare:copy-to-clipboard", detail: %{text: @created_token.token})} data-toggle="tooltip" data-placement="top" title="Copy to clipboard">
<i class="fa fa-clone" aria-hidden="true"></i> Copy
</button>
<button class="btn btn-secondary" phx-click="dismiss-created-token">
Dismiss
</button>
</div>
</.alert>
<% end %>
</div>

<%= if @access_tokens == [] do %>
<p>You do not have any access tokens yet.</p>
<.alert variant="dark" class="tw-max-w-md">
<h5>Legacy Ingest API Key</h5>
<p><strong>Deprecated</strong>, use access tokens instead.</p>
<button class="btn btn-secondary btn-sm" phx-click={JS.dispatch("logflare:copy-to-clipboard", detail: %{text: @user.api_key})} data-toggle="tooltip" data-placement="top" title="Copy to clipboard">
<i class="fa fa-clone" aria-hidden="true"></i> Copy
</button>
</.alert>
<% end %>

<table class={["table-dark", "table-auto", "mt-4", "w-full", "flex-grow", if(@access_tokens == [], do: "hidden")]}>
<table class={["table-dark", "table-auto", "w-full", "flex-grow", if(@access_tokens == [], do: "hidden")]}>
<thead>
<tr>
<th class="p-2">Description</th>
<th class="p-2">Scope</th>
<th class="p-2">Created on</th>
<th class="p-2"></th>
<th class="p-2">Actions</th>
</tr>
</thead>
<tbody>
<%= for token <- @access_tokens do %>
<tr>
<td class="p-2">
<%= token.description %>
<span class="tw-text-sm">
<%= if token.description do %>
<%= token.description %>
<% else %>
<span class="tw-italic">No description</span>
<% end %>
</span>
</td>
<td class="p-2">
<td>
<span :for={scope <- String.split(token.scopes || "")} class="badge badge-secondary"><%= scope %></span>
</td>
<td class="p-2 tw-text-sm">
<%= Calendar.strftime(token.inserted_at, "%d %b %Y, %I:%M:%S %p") %>
</td>

<td class="p-2">
<button class="btn text-danger text-bold" data-confirm="Are you sure? This cannot be undone." phx-click="revoke-token" phx-value-token-id={token.id}>
Revoke
<button :if={token.scopes =~ "public"} class="btn btn-secondary btn-sm" phx-click={JS.dispatch("logflare:copy-to-clipboard", detail: %{text: token.token})} data-toggle="tooltip" data-placement="top" title="Copy to clipboard">
<i class="fa fa-clone" aria-hidden="true"></i> Copy
</button>
<button class="btn text-danger btn-sm" data-confirm="Are you sure? This cannot be undone." phx-click="revoke-token" phx-value-token-id={token.id} data-toggle="tooltip" data-placement="top" title="Revoke access token forever">
<i class="fa fa-trash" aria-hidden="true"></i> Revoke
</button>
</td>
</tr>
Expand Down Expand Up @@ -102,14 +146,16 @@ defmodule LogflareWeb.AccessTokensLive do

def handle_event(
"create-token",
%{"description" => description} = params,
params,
%{assigns: %{user: user}} = socket
) do
Logger.debug(
"Creating access token for user, user_id=#{inspect(user.id)}, params: #{inspect(params)}"
)

{:ok, token} = Auth.create_access_token(user, %{description: description})
attrs = Map.take(params, ["description", "scopes"])

{:ok, token} = Auth.create_access_token(user, attrs)

socket =
socket
Expand Down Expand Up @@ -141,5 +187,6 @@ defmodule LogflareWeb.AccessTokensLive do

socket
|> assign(access_tokens: tokens)
|> assign(created_token: nil)
end
end
7 changes: 6 additions & 1 deletion lib/logflare_web/templates/source/dashboard.html.eex
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,15 @@
<ul>
<li>
<i class="fa fa-info-circle" aria-hidden="true"></i>
<span>Ingest API key is
<span>ingest API key
<code class="pointer-cursor logflare-tooltip" id="api-key" data-showing-api-key="false"
data-clipboard-text="<%= @user.api_key %>" data-toggle="tooltip" data-html=true data-placement="top"
title="<span id=&quot;copy-tooltip&quot;>Copy this</span>">CLICK ME</code></span></li>
<li>
<%= link to: ~p"/access-tokens" do %>
<i class="fas fa-key"></i><span class="hide-on-mobile"> access tokens</span>
<% end %>
</li>
<li><%= link to: Routes.vercel_log_drains_path(@conn, :edit) do %>▲</i><span class="hide-on-mobile"> vercel
integration</span><% end %></li>
<li><%= link to: Routes.billing_account_path(@conn, :edit) do %><i class="fas fa-money-bill"></i><span
Expand Down
84 changes: 84 additions & 0 deletions test/logflare_web/live/access_tokens_live_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
defmodule LogflareWeb.AccessTokensLiveTest do
@moduledoc false
use LogflareWeb.ConnCase

setup %{conn: conn} do
insert(:plan)
user = insert(:user)
conn = conn |> put_session(:user_id, user.id) |> assign(:user, user)

{:ok, user: user, conn: conn}
end

test "subheader", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/access-tokens")

assert view
|> element("a", "docs")
|> has_element?()
end

test "legacy api key - show only when no access tokens", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/access-tokens")
html = render(view)
# able to copy, visible
assert view
|> element("button", "Copy")
|> has_element?()

# able to see legacy user token
assert html =~ "Deprecated"
assert html =~ "Copy"
end

test "public token", %{conn: conn, user: user} do
token = insert(:access_token, scopes: "public", resource_owner: user)
{:ok, view, _html} = live(conn, ~p"/access-tokens")
html = render(view)
# able to copy, visible
assert view
|> element("button", "Copy")
|> has_element?()

assert html =~ token.token
assert html =~ "public"
refute html =~ "Deprecated"
assert html =~ "No description"
end

test "create private token", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/access-tokens")

assert view
|> element("button", "Create access token")
|> render_click()

assert view |> element("button", "Create") |> has_element?()
assert view |> element("label", "Scope") |> has_element?()

assert view
|> element("form")
|> render_submit(%{
description: "some description",
scopes: "private"
}) =~ "created successfully"

html = view |> element("table") |> render()
assert html =~ "some description"
assert html =~ "private"
end

test "show private token", %{conn: conn, user: user} do
token = insert(:access_token, scopes: "private", resource_owner: user)
{:ok, view, _html} = live(conn, ~p"/access-tokens")

# not able to copy, not visible
refute view
|> element("button", "Copy")
|> has_element?()

html = render(view)
refute html =~ token.token
assert html =~ "private"
end
end
3 changes: 2 additions & 1 deletion test/support/factory.ex
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,8 @@ defmodule Logflare.Factory do
def access_token_factory do
%OauthAccessToken{
token: TestUtils.random_string(20),
resource_owner: build(:user)
resource_owner: build(:user),
scopes: "public"
}
end

Expand Down
Loading