diff --git a/backend/.credo.exs b/backend/.credo.exs index 15e59b6d1..1c22d839b 100644 --- a/backend/.credo.exs +++ b/backend/.credo.exs @@ -97,7 +97,7 @@ {Credo.Check.Readability.FunctionNames, []}, {Credo.Check.Readability.LargeNumbers, []}, # keep in sync with line_length in .formatter.exs - {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 140]}, + {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 180]}, {Credo.Check.Readability.ModuleAttributeNames, []}, {Credo.Check.Readability.ModuleDoc, []}, {Credo.Check.Readability.ModuleNames, []}, diff --git a/backend/.formatter.exs b/backend/.formatter.exs index 3fddccc08..e3da8ab26 100644 --- a/backend/.formatter.exs +++ b/backend/.formatter.exs @@ -3,5 +3,5 @@ inputs: ["*.{ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{ex,exs}"], subdirectories: ["priv/*/migrations"], # keep in sync with Credo.Check.Readability.MaxLineLength in .credo.exs - line_length: 140 + line_length: 180 ] diff --git a/backend/lib/azimutt/accounts.ex b/backend/lib/azimutt/accounts.ex index 6085d3409..3440e308d 100644 --- a/backend/lib/azimutt/accounts.ex +++ b/backend/lib/azimutt/accounts.ex @@ -2,7 +2,7 @@ defmodule Azimutt.Accounts do @moduledoc "The Accounts context." import Ecto.Query, warn: false alias Azimutt.Repo - alias Azimutt.Accounts.{User, UserNotifier, UserProfile, UserToken} + alias Azimutt.Accounts.{User, UserAuthToken, UserNotifier, UserProfile, UserToken} alias Azimutt.Organizations alias Azimutt.Organizations.OrganizationMember alias Azimutt.Tracking @@ -266,6 +266,51 @@ defmodule Azimutt.Accounts do |> Repo.update() end + ## Auth tokens + + def list_auth_tokens(%User{} = current_user, now) do + UserAuthToken + |> where([t], t.user_id == ^current_user.id and is_nil(t.deleted_at) and (is_nil(t.expire_at) or t.expire_at > ^now)) + |> Repo.all() + end + + def change_auth_token(%User{} = current_user, attrs \\ %{}) do + %UserAuthToken{} + |> UserAuthToken.create_changeset(%{ + name: attrs["name"], + expire_at: attrs["expire_at"], + user_id: current_user.id + }) + end + + def create_auth_token(%User{} = current_user, now, attrs) do + %UserAuthToken{} + |> UserAuthToken.create_changeset(%{ + name: attrs["name"], + expire_at: + case attrs["expire_at"] do + "hour" -> Timex.shift(now, hours: 1) + "day" -> Timex.shift(now, days: 1) + "month" -> Timex.shift(now, months: 1) + _ -> nil + end, + user_id: current_user.id + }) + |> Repo.insert() + end + + def delete_auth_token(token_id, %User{} = current_user, now) do + UserAuthToken + |> where([t], t.id == ^token_id and t.user_id == ^current_user.id and is_nil(t.deleted_at)) + |> Repo.one() + |> Result.from_nillable() + |> Result.flat_map(fn token -> + token + |> UserAuthToken.delete_changeset(now) + |> Repo.update() + end) + end + ## Session def generate_user_session_token(user) do @@ -276,7 +321,18 @@ defmodule Azimutt.Accounts do def get_user_by_session_token(token) do {:ok, query} = UserToken.verify_session_token_query(token) - Repo.one(query) + Repo.one(query) |> Result.from_nillable() + end + + def get_user_by_auth_token(token_id, now) do + UserAuthToken + |> where([t], t.id == ^token_id and is_nil(t.deleted_at) and (is_nil(t.expire_at) or t.expire_at > ^now)) + |> Repo.one() + |> Result.from_nillable() + |> Result.flat_map(fn token -> + token |> UserAuthToken.access_changeset(now) |> Repo.update() + User |> Repo.get(token.user_id) |> Result.from_nillable() + end) end def delete_session_token(token) do diff --git a/backend/lib/azimutt/accounts/user_auth_token.ex b/backend/lib/azimutt/accounts/user_auth_token.ex new file mode 100644 index 000000000..0d562be26 --- /dev/null +++ b/backend/lib/azimutt/accounts/user_auth_token.ex @@ -0,0 +1,35 @@ +defmodule Azimutt.Accounts.UserAuthToken do + @moduledoc "Auth tokens allowing to identify a user simply by passing this token" + use Ecto.Schema + use Azimutt.Schema + import Ecto.Changeset + alias Azimutt.Accounts.User + + schema "user_auth_tokens" do + belongs_to :user, User + field :name, :string + field :nb_access, :integer + field :last_access, :utc_datetime_usec + field :expire_at, :utc_datetime_usec + timestamps(updated_at: false) + field :deleted_at, :utc_datetime_usec + end + + def create_changeset(token, attrs) do + token + |> cast(attrs, [:name, :expire_at]) + |> put_change(:user_id, attrs.user_id) + |> put_change(:nb_access, 0) + |> validate_required([:name]) + end + + def access_changeset(token, now) do + token + |> cast(%{last_access: now, nb_access: token.nb_access + 1}, [:last_access, :nb_access]) + end + + def delete_changeset(token, now) do + token + |> cast(%{deleted_at: now}, [:deleted_at]) + end +end diff --git a/backend/lib/azimutt/accounts/user_token.ex b/backend/lib/azimutt/accounts/user_token.ex index faec8a143..8d4feebe2 100644 --- a/backend/lib/azimutt/accounts/user_token.ex +++ b/backend/lib/azimutt/accounts/user_token.ex @@ -1,11 +1,8 @@ defmodule Azimutt.Accounts.UserToken do - @moduledoc """ - base user token module generate by `mix phx.gen.auth` - """ + @moduledoc "base user token module generate by `mix phx.gen.auth`" use Ecto.Schema use Azimutt.Schema import Ecto.Query - alias Azimutt.Accounts.User alias Azimutt.Accounts.UserToken diff --git a/backend/lib/azimutt/projects.ex b/backend/lib/azimutt/projects.ex index 7a3ca5ebf..3a1b92a95 100644 --- a/backend/lib/azimutt/projects.ex +++ b/backend/lib/azimutt/projects.ex @@ -8,6 +8,7 @@ defmodule Azimutt.Projects do alias Azimutt.Organizations.OrganizationMember alias Azimutt.Projects.Project alias Azimutt.Projects.Project.Storage + alias Azimutt.Projects.ProjectFile alias Azimutt.Projects.ProjectToken alias Azimutt.Repo alias Azimutt.Tracking @@ -115,6 +116,22 @@ defmodule Azimutt.Projects do end end + def get_project_content(%Project{} = project) do + if project.storage_kind == Storage.remote() do + # FIXME: handle spaces in name + file_url = ProjectFile.url({project.file, project}, signed: true) + + if Application.get_env(:waffle, :storage) == Waffle.Storage.Local do + File.read("./#{file_url}") + else + # FIXME: improve http error handling + {:ok, HTTPoison.get!(file_url).body} + end + else + {:error, :not_remote} + end + end + def list_project_tokens(project_id, %User{} = current_user, now) do project_query() |> where([p, _, om], p.id == ^project_id and p.storage_kind == :remote and om.user_id == ^current_user.id) diff --git a/backend/lib/azimutt/services/cockpit_srv.ex b/backend/lib/azimutt/services/cockpit_srv.ex index 6bad6744b..570f0ac34 100644 --- a/backend/lib/azimutt/services/cockpit_srv.ex +++ b/backend/lib/azimutt/services/cockpit_srv.ex @@ -116,8 +116,7 @@ defmodule Azimutt.Services.CockpitSrv do admins: User |> where([u], u.is_admin == true) |> Repo.aggregate(:count), organizations: Organization |> Repo.aggregate(:count), np_organizations: Organization |> where([o], o.is_personal == false) |> Repo.aggregate(:count), - active_organizations: - Event |> where([e], e.created_at > ^last_30_days) |> select([e], count(e.organization_id, :distinct)) |> Repo.one(), + active_organizations: Event |> where([e], e.created_at > ^last_30_days) |> select([e], count(e.organization_id, :distinct)) |> Repo.one(), projects: Project |> Repo.aggregate(:count), active_projects: Event |> where([e], e.created_at > ^last_30_days) |> select([e], count(e.project_id, :distinct)) |> Repo.one(), events: Event |> Repo.aggregate(:count), diff --git a/backend/lib/azimutt/tracking.ex b/backend/lib/azimutt/tracking.ex index 3bffd746d..438d7cca2 100644 --- a/backend/lib/azimutt/tracking.ex +++ b/backend/lib/azimutt/tracking.ex @@ -237,8 +237,7 @@ defmodule Azimutt.Tracking do stripe_customer_id: org.stripe_customer_id, stripe_subscription_id: org.stripe_subscription_id, is_personal: org.is_personal, - clever_cloud: - if(Ecto.assoc_loaded?(org.clever_cloud_resource) && org.clever_cloud_resource, do: org.clever_cloud_resource.id, else: nil), + clever_cloud: if(Ecto.assoc_loaded?(org.clever_cloud_resource) && org.clever_cloud_resource, do: org.clever_cloud_resource.id, else: nil), heroku: if(Ecto.assoc_loaded?(org.heroku_resource) && org.heroku_resource, do: org.heroku_resource.id, else: nil), members: if(Ecto.assoc_loaded?(org.members), do: org.members |> length, else: nil), projects: if(Ecto.assoc_loaded?(org.projects), do: org.projects |> length, else: nil), diff --git a/backend/lib/azimutt/utils/page.ex b/backend/lib/azimutt/utils/page.ex index 1a92c9a35..f15d663dd 100644 --- a/backend/lib/azimutt/utils/page.ex +++ b/backend/lib/azimutt/utils/page.ex @@ -47,8 +47,7 @@ defmodule Azimutt.Utils.Page do query |> Map.filter(fn {k, _v} -> k |> String.starts_with?("#{prefix}f-") end) |> Mapx.map_keys(fn k -> k |> String.replace_leading("#{prefix}f-", "") end), - sort: - (query["#{prefix}sort"] || opts[:sort] || "") |> String.split(",") |> Enum.map(&String.trim/1) |> Enum.filter(fn s -> s != "" end) + sort: (query["#{prefix}sort"] || opts[:sort] || "") |> String.split(",") |> Enum.map(&String.trim/1) |> Enum.filter(fn s -> s != "" end) } end diff --git a/backend/lib/azimutt_web/admin/project/project_controller.ex b/backend/lib/azimutt_web/admin/project/project_controller.ex index b165d7b47..c79774b10 100644 --- a/backend/lib/azimutt_web/admin/project/project_controller.ex +++ b/backend/lib/azimutt_web/admin/project/project_controller.ex @@ -21,8 +21,7 @@ defmodule AzimuttWeb.Admin.ProjectController do conn |> render("show.html", project: project, - activity: - Dataset.chartjs_daily_data([Admin.daily_project_activity(project) |> Dataset.from_values("Daily events")], start_stats, now), + activity: Dataset.chartjs_daily_data([Admin.daily_project_activity(project) |> Dataset.from_values("Daily events")], start_stats, now), events: Admin.get_project_events(project, events_page) ) end diff --git a/backend/lib/azimutt_web/components/icon.ex b/backend/lib/azimutt_web/components/icon.ex index 5ce937111..792f9de29 100644 --- a/backend/lib/azimutt_web/components/icon.ex +++ b/backend/lib/azimutt_web/components/icon.ex @@ -69,6 +69,7 @@ defmodule AzimuttWeb.Components.Icon do "shield-check" -> shield_check(assigns) "shopping-cart" -> shopping_cart(assigns) "squares-plus" -> squares_plus(assigns) + "trash" -> trash(assigns) "user-circle" -> user_circle(assigns) "user-group" -> user_group(assigns) "x-circle" -> x_circle(assigns) @@ -1387,6 +1388,27 @@ defmodule AzimuttWeb.Components.Icon do end end + def trash(assigns) do + classes = if assigns[:class], do: " #{assigns[:class]}", else: "" + + case assigns[:kind] do + "outline" -> + ~H""" + <.outline classes={classes}> + """ + + "solid" -> + ~H""" + <.solid classes={classes}> + """ + + _ -> + ~H""" + <.mini classes={classes}> + """ + end + end + def user_circle(assigns) do classes = if assigns[:class], do: " #{assigns[:class]}", else: "" diff --git a/backend/lib/azimutt_web/controllers/api/source_controller.ex b/backend/lib/azimutt_web/controllers/api/source_controller.ex new file mode 100644 index 000000000..a4254175b --- /dev/null +++ b/backend/lib/azimutt_web/controllers/api/source_controller.ex @@ -0,0 +1,41 @@ +defmodule AzimuttWeb.Api.SourceController do + use AzimuttWeb, :controller + use PhoenixSwagger + alias Azimutt.Projects + alias Azimutt.Projects.Project + alias Azimutt.Utils.Result + alias AzimuttWeb.Utils.CtxParams + action_fallback AzimuttWeb.Api.FallbackController + + def index(conn, %{"organization_id" => _organization_id, "project_id" => project_id} = params) do + current_user = conn.assigns.current_user + ctx = CtxParams.from_params(params) + + with {:ok, %Project{} = project} <- Projects.get_project(project_id, current_user), + {:ok, content} <- Projects.get_project_content(project) |> Result.flat_map(fn c -> Jason.decode(c) end), + do: conn |> render("index.json", sources: content["sources"], ctx: ctx) + end + + def show(conn, %{"organization_id" => _organization_id, "project_id" => project_id, "source_id" => source_id} = params) do + current_user = conn.assigns.current_user + ctx = CtxParams.from_params(params) + + with {:ok, %Project{} = project} <- Projects.get_project(project_id, current_user), + {:ok, content} <- Projects.get_project_content(project) |> Result.flat_map(fn c -> Jason.decode(c) end), + {:ok, source} <- content["sources"] |> Enum.find(fn s -> s["id"] == source_id end) |> Result.from_nillable(), + do: conn |> render("show.json", source: source, ctx: ctx) + end + + def update(conn, %{"organization_organization_id" => _organization_id, "project_id" => _project_id, "source_id" => _source_id} = _params) do + # FIXME + conn |> send_resp(:not_found, "WIP") + end + + def create(conn, %{"organization_organization_id" => _organization_id, "project_id" => _project_id} = _params) do + conn |> send_resp(:not_found, "WIP") + end + + def delete(conn, %{"organization_organization_id" => _organization_id, "project_id" => _project_id, "source_id" => _source_id}) do + conn |> send_resp(:not_found, "WIP") + end +end diff --git a/backend/lib/azimutt_web/controllers/organization_member_controller.ex b/backend/lib/azimutt_web/controllers/organization_member_controller.ex index 27b8ba686..af5f05b4e 100644 --- a/backend/lib/azimutt_web/controllers/organization_member_controller.ex +++ b/backend/lib/azimutt_web/controllers/organization_member_controller.ex @@ -19,9 +19,7 @@ defmodule AzimuttWeb.OrganizationMemberController do end with {:ok, %Organization{} = organization} <- Organizations.get_organization(organization_id, current_user) do - organization_invitation_changeset = - OrganizationInvitation.create_changeset(%OrganizationInvitation{}, %{}, organization.id, current_user, now) - + organization_invitation_changeset = OrganizationInvitation.create_changeset(%OrganizationInvitation{}, %{}, organization.id, current_user, now) render_index(conn, organization, organization_invitation_changeset) end end diff --git a/backend/lib/azimutt_web/controllers/user_auth.ex b/backend/lib/azimutt_web/controllers/user_auth.ex index c52ccee02..97d3a9d34 100644 --- a/backend/lib/azimutt_web/controllers/user_auth.ex +++ b/backend/lib/azimutt_web/controllers/user_auth.ex @@ -125,11 +125,18 @@ defmodule AzimuttWeb.UserAuth do and remember me token. """ def fetch_current_user(conn, _opts) do + auth_token = conn.params["auth-token"] {user_token, conn} = ensure_user_token(conn) - user = user_token && Accounts.get_user_by_session_token(user_token) user = - user |> Azimutt.Repo.preload(:profile) |> Azimutt.Repo.preload(organizations: [:clever_cloud_resource, :heroku_resource, :projects]) + cond do + user_token -> Accounts.get_user_by_session_token(user_token) + auth_token -> Accounts.get_user_by_auth_token(auth_token, DateTime.utc_now()) + true -> {:ok, nil} + end + |> Result.or_else(nil) + + user = user |> Azimutt.Repo.preload(:profile) |> Azimutt.Repo.preload(organizations: [:clever_cloud_resource, :heroku_resource, :projects]) assign(conn, :current_user, user) end diff --git a/backend/lib/azimutt_web/controllers/user_settings_controller.ex b/backend/lib/azimutt_web/controllers/user_settings_controller.ex index 7bbc8c718..9fe50b701 100644 --- a/backend/lib/azimutt_web/controllers/user_settings_controller.ex +++ b/backend/lib/azimutt_web/controllers/user_settings_controller.ex @@ -7,7 +7,8 @@ defmodule AzimuttWeb.UserSettingsController do def show(conn, _params) do current_user = conn.assigns.current_user - conn |> show_html(current_user) + now = DateTime.utc_now() + conn |> show_html(current_user, now) end def update_account(conn, %{"user" => user_params}) do @@ -16,7 +17,7 @@ defmodule AzimuttWeb.UserSettingsController do Accounts.update_user_infos(current_user, user_params, now) |> Result.fold( - fn changeset_error -> conn |> show_html(current_user, infos_changeset: changeset_error) end, + fn changeset_error -> conn |> show_html(current_user, now, infos_changeset: changeset_error) end, fn _ -> conn |> put_flash(:info, "Infos updated!") |> redirect(to: Routes.user_settings_path(conn, :show)) end ) end @@ -24,10 +25,11 @@ defmodule AzimuttWeb.UserSettingsController do # FIXME: how to change email for users from social login? (no password) def update_email(conn, %{"user" => user_params}) do current_user = conn.assigns.current_user + now = DateTime.utc_now() Accounts.apply_user_email(current_user, user_params["current_password"], user_params) |> Result.fold( - fn changeset_error -> conn |> show_html(current_user, email_changeset: changeset_error) end, + fn changeset_error -> conn |> show_html(current_user, now, email_changeset: changeset_error) end, fn user -> {flash_kind, flash_message} = Accounts.send_email_update(user, current_user.email, &Routes.user_settings_url(conn, :confirm_update_email, &1)) @@ -61,7 +63,7 @@ defmodule AzimuttWeb.UserSettingsController do Accounts.update_user_password(current_user, user_params["current_password"], user_params, now) |> Result.fold( - fn changeset_error -> conn |> show_html(current_user, password_changeset: changeset_error) end, + fn changeset_error -> conn |> show_html(current_user, now, password_changeset: changeset_error) end, fn user -> conn |> UserAuth.login_user(user, "update_password") @@ -110,22 +112,52 @@ defmodule AzimuttWeb.UserSettingsController do ) end - defp show_html(conn, user, options \\ []) do + def create_auth_token(conn, %{"user_auth_token" => auth_token_params}) do + current_user = conn.assigns.current_user + now = DateTime.utc_now() + + Accounts.create_auth_token(current_user, now, auth_token_params) + |> Result.fold( + fn changeset_error -> conn |> show_html(current_user, now, auth_token_changeset: changeset_error) end, + fn _ -> conn |> put_flash(:info, "Authentication token created") |> redirect(to: Routes.user_settings_path(conn, :show)) end + ) + end + + def delete_auth_token(conn, %{"token_id" => token_id}) do + current_user = conn.assigns.current_user + now = DateTime.utc_now() + + Accounts.delete_auth_token(token_id, current_user, now) + |> Result.fold( + fn _err -> conn |> put_flash(:error, "Can't delete authentication token :/") end, + fn _ -> conn |> put_flash(:info, "Authentication token delete") end + ) + |> redirect(to: Routes.user_settings_path(conn, :show)) + end + + defp show_html(conn, user, now, options \\ []) do defaults = [ infos_changeset: Accounts.change_user_infos(user), email_changeset: Accounts.change_user_email(user), - password_changeset: Accounts.change_user_password(user) + password_changeset: Accounts.change_user_password(user), + auth_token_changeset: Accounts.change_auth_token(user) ] - %{infos_changeset: infos_changeset, email_changeset: email_changeset, password_changeset: password_changeset} = - Keyword.merge(defaults, options) |> Enum.into(%{}) + %{ + infos_changeset: infos_changeset, + email_changeset: email_changeset, + password_changeset: password_changeset, + auth_token_changeset: auth_token_changeset + } = Keyword.merge(defaults, options) |> Enum.into(%{}) conn |> render("show.html", user: user, + auth_tokens: Accounts.list_auth_tokens(user, now), infos_changeset: infos_changeset, email_changeset: email_changeset, - password_changeset: password_changeset + password_changeset: password_changeset, + auth_token_changeset: auth_token_changeset ) end end diff --git a/backend/lib/azimutt_web/router.ex b/backend/lib/azimutt_web/router.ex index 2e43b4d1f..8287215e0 100644 --- a/backend/lib/azimutt_web/router.ex +++ b/backend/lib/azimutt_web/router.ex @@ -30,28 +30,12 @@ defmodule AzimuttWeb.Router do plug(:fetch_heroku_resource) end - pipeline(:website_root_layout, - do: plug(:put_root_layout, {AzimuttWeb.LayoutView, "root_website.html"}) - ) - - pipeline(:hfull_root_layout, - do: plug(:put_root_layout, {AzimuttWeb.LayoutView, "root_hfull.html"}) - ) - - pipeline(:organization_root_layout, - do: plug(:put_root_layout, {AzimuttWeb.LayoutView, "root_organization.html"}) - ) - - pipeline(:admin_root_layout, - do: plug(:put_root_layout, {AzimuttWeb.LayoutView, "root_admin.html"}) - ) - + pipeline(:website_root_layout, do: plug(:put_root_layout, {AzimuttWeb.LayoutView, "root_website.html"})) + pipeline(:hfull_root_layout, do: plug(:put_root_layout, {AzimuttWeb.LayoutView, "root_hfull.html"})) + pipeline(:organization_root_layout, do: plug(:put_root_layout, {AzimuttWeb.LayoutView, "root_organization.html"})) + pipeline(:admin_root_layout, do: plug(:put_root_layout, {AzimuttWeb.LayoutView, "root_admin.html"})) pipeline(:elm_root_layout, do: plug(:put_root_layout, {AzimuttWeb.LayoutView, "root_elm.html"})) - - pipeline(:user_settings_root_layout, - do: plug(:put_root_layout, {AzimuttWeb.LayoutView, "root_user_settings.html"}) - ) - + pipeline(:user_settings_root_layout, do: plug(:put_root_layout, {AzimuttWeb.LayoutView, "root_user_settings.html"})) pipeline(:empty_layout, do: plug(:put_layout, {AzimuttWeb.LayoutView, "empty.html"})) # public routes @@ -143,6 +127,8 @@ defmodule AzimuttWeb.Router do put("/password", UserSettingsController, :update_password) post("/password", UserSettingsController, :set_password) delete("/providers/:provider", UserSettingsController, :remove_provider) + post("/auth-tokens", UserSettingsController, :create_auth_token) + delete("/auth-tokens/:token_id", UserSettingsController, :delete_auth_token) end resources "/organizations", OrganizationController, param: "organization_id", except: [:index] do @@ -227,6 +213,7 @@ defmodule AzimuttWeb.Router do post("/analyzer/rows", Api.AnalyzerController, :rows) get("/gallery", Api.GalleryController, :index) get("/organizations/:organization_id/projects/:project_id", Api.ProjectController, :show) + resources("/organizations/:organization_id/projects/:project_id/sources", Api.SourceController, param: "source_id", only: [:index, :show, :create, :update, :delete]) post("/events", Api.TrackingController, :create) end diff --git a/backend/lib/azimutt_web/templates/user_settings/show.html.heex b/backend/lib/azimutt_web/templates/user_settings/show.html.heex index fbc5e92b4..f24711427 100644 --- a/backend/lib/azimutt_web/templates/user_settings/show.html.heex +++ b/backend/lib/azimutt_web/templates/user_settings/show.html.heex @@ -178,3 +178,46 @@ <% end %> + +
+
+
+

Your authentication tokens

+

+ Authentication tokens allows to identify you without a password. + This is useful for API calls to avoid the login/password flow, send them in your request in `auth-token` parameter, and it will grant you your accesses. +

+
+ <%= for {token, index} <- Enum.with_index(@auth_tokens) do %> +
+
+

<%= token.name %> (<%= if token.expire_at == nil do %>does not expire<% else %>expire on <%= format_datetime(token.expire_at) %><% end %>)

+

<%= token.nb_access %> access since its creation on <%= format_datetime(token.created_at) %><%= if token.last_access != nil do %>, last accessed on <%= format_datetime(token.last_access) %><% end %>.

+
+
+

<%= token.id %>

+
+ <%= link(title: "Delete auth token", to: Routes.user_settings_path(@conn, :delete_auth_token, token.id), method: :delete) do %> + + <% end %> +
+ <% end %> + <.form let={f} for={@auth_token_changeset} action={Routes.user_settings_path(@conn, :create_auth_token)}> +
+
+ <%= label f, :name, "Token name", class: "sr-only" %> + <%= text_input f, :name, placeholder: "Token name", required: true, class: "#{if length(@auth_tokens) == 0, do: "rounded-tl-md"} rounded-bl-md relative block w-full border border-gray-300 bg-transparent focus:z-10 focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" %> + <%= error_tag f, :name %> +
+
+ <%= label f, :expire_at, "Expiration", class: "sr-only" %> + <%= select f, :expire_at, ["does not expire": "none", "expire in 1 hour": "hour", "expire in 1 day": "day", "expire in 1 month": "month"], class: "relative block w-full border border-gray-300 bg-transparent focus:z-10 focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" %> + <%= error_tag f, :expire_at %> +
+ <%= submit "Create token", class: "#{if length(@auth_tokens) == 0, do: "rounded-tr-md"} rounded-br-md relative block border border-gray-300 px-4 py-2 text-sm font-medium bg-gray-50 text-gray-700 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 disabled:bg-gray-100 disabled:text-gray-400" %> +
+ +
+
+
+
diff --git a/backend/lib/azimutt_web/views/api/project_view.ex b/backend/lib/azimutt_web/views/api/project_view.ex index 5fe64f443..6be332c02 100644 --- a/backend/lib/azimutt_web/views/api/project_view.ex +++ b/backend/lib/azimutt_web/views/api/project_view.ex @@ -1,8 +1,8 @@ defmodule AzimuttWeb.Api.ProjectView do use AzimuttWeb, :view + alias Azimutt.Projects alias Azimutt.Projects.Project - alias Azimutt.Projects.Project.Storage - alias Azimutt.Projects.ProjectFile + alias Azimutt.Utils.Result alias AzimuttWeb.Utils.CtxParams def render("index.json", %{projects: projects}) do @@ -47,24 +47,10 @@ defmodule AzimuttWeb.Api.ProjectView do defp put_content(json, %Project{} = project, %CtxParams{} = ctx) do if ctx.expand |> Enum.member?("content") do - json |> Map.put(:content, get_content(project)) + Projects.get_project_content(project) + |> Result.fold(fn _ -> json end, fn content -> json |> Map.put(:content, content) end) else json end end - - defp get_content(%Project{} = project) do - if project.storage_kind == Storage.remote() do - # FIXME: handle spaces in name - file_url = ProjectFile.url({project.file, project}, signed: true) - - if Application.get_env(:waffle, :storage) == Waffle.Storage.Local do - with {:ok, body} <- File.read("./#{file_url}"), do: body - else - HTTPoison.get!(file_url).body - end - else - "{}" - end - end end diff --git a/backend/lib/azimutt_web/views/api/source_view.ex b/backend/lib/azimutt_web/views/api/source_view.ex new file mode 100644 index 000000000..4bd5e4beb --- /dev/null +++ b/backend/lib/azimutt_web/views/api/source_view.ex @@ -0,0 +1,20 @@ +defmodule AzimuttWeb.Api.SourceView do + use AzimuttWeb, :view + alias AzimuttWeb.Utils.CtxParams + + def render("index.json", %{sources: sources, ctx: %CtxParams{} = ctx}) do + render_many(sources, __MODULE__, "meta.json", ctx: ctx) + end + + def render("meta.json", %{source: source, ctx: %CtxParams{} = _ctx}) do + source + |> Map.delete("content") + |> Map.delete("tables") + |> Map.delete("relations") + |> Map.delete("types") + end + + def render("show.json", %{source: source, ctx: %CtxParams{} = _ctx}) do + source + end +end diff --git a/backend/lib/azimutt_web/views/user_settings_view.ex b/backend/lib/azimutt_web/views/user_settings_view.ex index 3d3b77345..1127c0c07 100644 --- a/backend/lib/azimutt_web/views/user_settings_view.ex +++ b/backend/lib/azimutt_web/views/user_settings_view.ex @@ -1,3 +1,8 @@ defmodule AzimuttWeb.UserSettingsView do use AzimuttWeb, :view + + def format_datetime(date) do + {:ok, formatted} = Timex.format(date, "{Mshort} {D}, {YYYY} at {h24}:{m}") + formatted + end end diff --git a/backend/priv/repo/migrations/20231110120742_create_user_auth_tokens.exs b/backend/priv/repo/migrations/20231110120742_create_user_auth_tokens.exs new file mode 100644 index 000000000..129fcc949 --- /dev/null +++ b/backend/priv/repo/migrations/20231110120742_create_user_auth_tokens.exs @@ -0,0 +1,15 @@ +defmodule Azimutt.Repo.Migrations.CreateUserAuthTokens do + use Ecto.Migration + + def change do + create table(:user_auth_tokens, comments: "Tokens allowing to log as the user, useful for API") do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :name, :string, null: false + add :nb_access, :integer, null: false + add :last_access, :utc_datetime_usec + add :expire_at, :utc_datetime_usec + timestamps(updated_at: false) + add :deleted_at, :utc_datetime_usec + end + end +end diff --git a/backend/test/azimutt/accounts_test.exs b/backend/test/azimutt/accounts_test.exs index 6a0959e1d..2b9c66c20 100644 --- a/backend/test/azimutt/accounts_test.exs +++ b/backend/test/azimutt/accounts_test.exs @@ -299,6 +299,34 @@ defmodule Azimutt.AccountsTest do end end + describe "create_auth_token/3" do + setup do + %{user: user_fixture()} + end + + test "create, access and delete auth token", %{user: user} do + now = DateTime.utc_now() + assert Accounts.list_auth_tokens(user, now) == [] + + {:ok, token} = Accounts.create_auth_token(user, now, %{"name" => "test"}) + user_token = Accounts.list_auth_tokens(user, now) |> hd() + assert user_token.id == token.id + assert user_token.name == "test" + assert user_token.nb_access == 0 + assert user_token.last_access == nil + + {:ok, fetched_user} = Accounts.get_user_by_auth_token(token.id, now) + assert fetched_user.id == user.id + accessed_token = Accounts.list_auth_tokens(user, now) |> hd() + assert accessed_token.nb_access == 1 + assert accessed_token.last_access == now + + {:ok, _deleted} = Accounts.delete_auth_token(token.id, user, now) + assert Accounts.list_auth_tokens(user, now) == [] + {:error, :not_found} = Accounts.get_user_by_auth_token(token.id, now) + end + end + describe "generate_user_session_token/1" do setup do %{user: user_fixture()} diff --git a/backend/test/azimutt/blog/article_test.exs b/backend/test/azimutt/blog/article_test.exs index 102406318..3e075c8b8 100644 --- a/backend/test/azimutt/blog/article_test.exs +++ b/backend/test/azimutt/blog/article_test.exs @@ -5,8 +5,7 @@ defmodule Azimutt.Blog.ArticleTest do describe "article" do test "path_to_id" do - assert "the-story-behind-azimutt" = - Article.path_to_id("priv/static/blog/2021-10-01-the-story-behind-azimutt/the-story-behind-azimutt.md") + assert "the-story-behind-azimutt" = Article.path_to_id("priv/static/blog/2021-10-01-the-story-behind-azimutt/the-story-behind-azimutt.md") end test "path_to_date" do diff --git a/backend/test/azimutt/organizations_test.exs b/backend/test/azimutt/organizations_test.exs index 4e8017992..fef0d17ed 100644 --- a/backend/test/azimutt/organizations_test.exs +++ b/backend/test/azimutt/organizations_test.exs @@ -90,8 +90,7 @@ defmodule Azimutt.OrganizationsTest do organization = organization_fixture(user) valid_attrs = %{sent_to: "hey@mail.com"} - assert {:ok, %OrganizationInvitation{} = organization_invitation} = - Organizations.create_organization_invitation(valid_attrs, "url", organization.id, user, now) + assert {:ok, %OrganizationInvitation{} = organization_invitation} = Organizations.create_organization_invitation(valid_attrs, "url", organization.id, user, now) assert organization_invitation.sent_to == "some sent_to" end diff --git a/backend/test/azimutt/services/twitter_srv_test.exs b/backend/test/azimutt/services/twitter_srv_test.exs index 3810f3e73..09e2343d8 100644 --- a/backend/test/azimutt/services/twitter_srv_test.exs +++ b/backend/test/azimutt/services/twitter_srv_test.exs @@ -5,11 +5,9 @@ defmodule Azimutt.Services.TwitterSrvTest do describe "TwitterSrv" do test "extract id" do - assert {:ok, %{user: "loicknuchel", tweet: "1604135251755663361"}} = - TwitterSrv.parse_url("https://twitter.com/loicknuchel/status/1604135251755663361") + assert {:ok, %{user: "loicknuchel", tweet: "1604135251755663361"}} = TwitterSrv.parse_url("https://twitter.com/loicknuchel/status/1604135251755663361") - assert {:ok, %{user: "loicknuchel", tweet: "1604135251755663361"}} = - TwitterSrv.parse_url("https://twitter.com/loicknuchel/status/1604135251755663361?s=20") + assert {:ok, %{user: "loicknuchel", tweet: "1604135251755663361"}} = TwitterSrv.parse_url("https://twitter.com/loicknuchel/status/1604135251755663361?s=20") {:ok, %{user: user, tweet: tweet}} = TwitterSrv.parse_url("https://twitter.com/loicknuchel/status/1604135251755663361") assert "loicknuchel" = user diff --git a/backend/test/azimutt_web/controllers/organization_invitation_controller_test.exs b/backend/test/azimutt_web/controllers/organization_invitation_controller_test.exs index a8f820902..a16faaee8 100644 --- a/backend/test/azimutt_web/controllers/organization_invitation_controller_test.exs +++ b/backend/test/azimutt_web/controllers/organization_invitation_controller_test.exs @@ -25,8 +25,7 @@ defmodule AzimuttWeb.OrganizationInvitationControllerTest do test "redirects to show when data is valid", %{conn: conn, organization: organization} do invitation_attrs = @create_attrs |> Map.put(:organization_id, organization.id) - conn = - post(conn, Routes.organization_member_path(conn, :create_invitation, organization.id), organization_invitation: invitation_attrs) + conn = post(conn, Routes.organization_member_path(conn, :create_invitation, organization.id), organization_invitation: invitation_attrs) assert %{id: id} = redirected_params(conn) assert redirected_to(conn) == Routes.invitation_path(conn, :show, id) diff --git a/frontend/src/Services/Analysis/MissingRelations.elm b/frontend/src/Services/Analysis/MissingRelations.elm index 62623f801..1033863d7 100644 --- a/frontend/src/Services/Analysis/MissingRelations.elm +++ b/frontend/src/Services/Analysis/MissingRelations.elm @@ -16,6 +16,10 @@ import Models.Project.TableId exposing (TableId) import PagesComponents.Organization_.Project_.Models.SuggestedRelation exposing (SuggestedRelation, SuggestedRelationRef) + +-- report columns ending with `_by` as possible relations (probably to `users` or `accounts` tables) + + forTables : Dict TableId Table -> List Relation -> Dict TableId (List ColumnPath) -> Dict TableId (Dict ColumnPathStr (List SuggestedRelation)) forTables tables relations ignoredRelations = let