Fix user-reported errors (#2954)

This commit is contained in:
Andrew Dryga
2023-12-18 16:15:49 -06:00
committed by GitHub
parent f19b8fc5de
commit ded5feee9e
30 changed files with 521 additions and 317 deletions

View File

@@ -142,11 +142,9 @@ defmodule Domain.Auth do
end
def list_providers_pending_sync_by_adapter(adapter) do
datetime_filter = DateTime.utc_now() |> DateTime.add(-10, :minute)
Provider.Query.by_adapter(adapter)
|> Provider.Query.by_provisioner(:custom)
|> Provider.Query.last_synced_at({:lt, datetime_filter})
|> Provider.Query.only_ready_to_be_synced()
|> Provider.Query.not_disabled()
|> Repo.list()
end

View File

@@ -126,10 +126,23 @@ defmodule Domain.Auth.Adapters.GoogleWorkspace.APIClient do
{:ok, list} <- Map.fetch(json_response, key) do
{:ok, list, json_response["nextPageToken"]}
else
{:ok, %Finch.Response{status: status}} when status in 500..599 -> {:error, :retry_later}
{:ok, %Finch.Response{body: response, status: status}} -> {:error, {status, response}}
:error -> {:ok, [], nil}
other -> other
{:ok, %Finch.Response{status: status}} when status in 500..599 ->
{:error, :retry_later}
{:ok, %Finch.Response{body: response, status: status}} ->
case Jason.decode(response) do
{:ok, json_response} ->
{:error, {status, json_response}}
_error ->
{:error, {status, response}}
end
:error ->
{:ok, [], nil}
other ->
other
end
end
end

View File

@@ -100,7 +100,7 @@ defmodule Domain.Auth.Adapters.GoogleWorkspace.Jobs do
|> Ecto.Multi.append(Actors.sync_provider_groups_multi(provider, actor_groups_attrs))
|> Actors.sync_provider_memberships_multi(provider, tuples)
|> Ecto.Multi.update(:save_last_updated_at, fn _effects_so_far ->
Ecto.Changeset.change(provider, last_synced_at: DateTime.utc_now())
Auth.Provider.Changeset.sync_finished(provider)
end)
|> Domain.Repo.transaction()
|> case do
@@ -152,8 +152,25 @@ defmodule Domain.Auth.Adapters.GoogleWorkspace.Jobs do
)
end
else
{:error, {status, %{"error" => %{"message" => message}}}} ->
provider =
Auth.Provider.Changeset.sync_failed(provider, message)
|> Domain.Repo.update!()
log_sync_error(provider, "Google API returned #{status}: #{message}")
{:error, :retry_later} ->
message = "Google API is temporarily unavailable"
provider =
Auth.Provider.Changeset.sync_failed(provider, message)
|> Domain.Repo.update!()
log_sync_error(provider, message)
{:error, reason} ->
Logger.error("Failed syncing provider",
account_id: provider.account_id,
provider_id: provider.id,
reason: inspect(reason)
)
@@ -163,6 +180,20 @@ defmodule Domain.Auth.Adapters.GoogleWorkspace.Jobs do
end
end
defp log_sync_error(provider, message) do
metadata = [
account_id: provider.account_id,
provider_id: provider.id,
reason: message
]
if provider.last_syncs_failed >= 3 do
Logger.warning("Failed syncing provider", metadata)
else
Logger.info("Failed syncing provider", metadata)
end
end
defp list_membership_tuples(access_token, groups) do
Enum.reduce_while(groups, {:ok, []}, fn group, {:ok, tuples} ->
case GoogleWorkspace.APIClient.list_group_members(access_token, group["id"]) do

View File

@@ -17,7 +17,10 @@ defmodule Domain.Auth.Provider do
field :created_by, Ecto.Enum, values: ~w[system identity]a
belongs_to :created_by_identity, Domain.Auth.Identity
field :last_syncs_failed, :integer
field :last_sync_error, :string
field :last_synced_at, :utc_datetime_usec
field :disabled_at, :utc_datetime_usec
field :deleted_at, :utc_datetime_usec
timestamps()

View File

@@ -4,7 +4,7 @@ defmodule Domain.Auth.Provider.Changeset do
alias Domain.Auth.{Subject, Provider}
@create_fields ~w[id name adapter provisioner adapter_config adapter_state disabled_at]a
@update_fields ~w[name adapter_config adapter_state provisioner disabled_at deleted_at]a
@update_fields ~w[name adapter_config last_syncs_failed last_sync_error adapter_state provisioner disabled_at deleted_at]a
@required_fields ~w[name adapter adapter_config provisioner]a
def create(account, attrs, %Subject{} = subject) do
@@ -28,6 +28,24 @@ defmodule Domain.Auth.Provider.Changeset do
|> changeset()
end
def sync_finished(%Provider{} = provider) do
provider
|> change()
|> put_change(:last_synced_at, DateTime.utc_now())
|> put_change(:last_sync_error, nil)
|> put_change(:last_syncs_failed, 0)
end
def sync_failed(%Provider{} = provider, error) do
last_syncs_failed = provider.last_syncs_failed || 0
provider
|> change()
|> put_change(:last_synced_at, nil)
|> put_change(:last_sync_error, error)
|> put_change(:last_syncs_failed, last_syncs_failed + 1)
end
defp changeset(changeset) do
changeset
|> validate_length(:name, min: 1, max: 255)

View File

@@ -38,6 +38,19 @@ defmodule Domain.Auth.Provider.Query do
)
end
def only_ready_to_be_synced(queryable \\ not_deleted()) do
where(
queryable,
[provider: provider],
is_nil(provider.last_synced_at) or
fragment(
"? + LEAST((interval '10 minute' * (COALESCE(?, 0) ^ 2 + 1)), interval '4 hours') < NOW()",
provider.last_synced_at,
provider.last_syncs_failed
)
)
end
def by_non_empty_refresh_token(queryable \\ not_deleted()) do
where(
queryable,
@@ -66,6 +79,10 @@ defmodule Domain.Auth.Provider.Query do
where(queryable, [provider: provider], is_nil(provider.disabled_at))
end
def not_exceeded_attempts(queryable \\ not_deleted()) do
where(queryable, [provider: provider], provider.last_syncs_failed <= 10)
end
def lock(queryable \\ not_deleted()) do
lock(queryable, "FOR UPDATE")
end

View File

@@ -1,7 +1,7 @@
defmodule Domain.Clients do
use Supervisor
alias Domain.{Repo, Auth, Validator}
alias Domain.Actors
alias Domain.{Accounts, Actors}
alias Domain.Clients.{Client, Authorizer, Presence}
require Ecto.Query
@@ -127,13 +127,15 @@ defmodule Domain.Clients do
# TODO: this is ugly!
defp preload_online_status(client) do
connected_clients = Presence.list("clients:#{client.id}")
%{client | online?: Map.has_key?(connected_clients, client.id)}
case Presence.get_by_key("clients:#{client.account_id}", client.id) do
[] -> %{client | online?: false}
%{metas: [_ | _]} -> %{client | online?: true}
end
end
defp preload_online_statuses([]), do: []
def preload_online_statuses([]), do: []
defp preload_online_statuses([client | _] = clients) do
def preload_online_statuses([client | _] = clients) do
connected_clients = Presence.list("clients:#{client.account_id}")
Enum.map(clients, fn client ->
@@ -233,6 +235,18 @@ defmodule Domain.Clients do
:ok
end
def subscribe_for_clients_presence_in_account(%Accounts.Account{} = account) do
subscribe_for_clients_presence_in_account(account.id)
end
def subscribe_for_clients_presence_in_account(account_id) do
Phoenix.PubSub.subscribe(Domain.PubSub, "clients:#{account_id}")
end
def subscribe_for_clients_presence_for_actor(%Actors.Actor{} = actor) do
Phoenix.PubSub.subscribe(Domain.PubSub, "actor_clients:#{actor.id}")
end
def fetch_client_config!(%Client{} = client) do
%{
clients_upstream_dns: upstream_dns

View File

@@ -123,13 +123,17 @@ defmodule Domain.Cluster.GoogleComputeLabelsStrategy do
end
{:error, reason} ->
Logger.error("Can not fetch list of nodes or access token: #{inspect(reason)}",
module: __MODULE__
)
if remaining_retry_count == 0 do
Logger.error("Can not fetch list of nodes or access token: #{inspect(reason)}",
module: __MODULE__
)
{:error, reason}
else
Logger.warning("Can not fetch list of nodes or access token: #{inspect(reason)}",
module: __MODULE__
)
backoff_interval = Keyword.get(state.config, :backoff_interval, 1_000)
:timer.sleep(backoff_interval)
fetch_nodes(state, remaining_retry_count - 1)

View File

@@ -0,0 +1,10 @@
defmodule Domain.Repo.Migrations.AddAuthProvidersLastSyncError do
use Ecto.Migration
def change do
alter table(:auth_providers) do
add(:last_syncs_failed, :integer, default: 0)
add(:last_sync_error, :text)
end
end
end

View File

@@ -466,5 +466,75 @@ defmodule Domain.Auth.Adapters.GoogleWorkspace.JobsTest do
assert org_unit.id in membership_group_ids
assert deleted_group.id not in membership_group_ids
end
test "persists the sync error on the provider", %{provider: provider} do
bypass = Bypass.open()
GoogleWorkspaceDirectory.override_endpoint_url("http://localhost:#{bypass.port}/")
error_message =
"Admin SDK API has not been used in project XXXX before or it is disabled. " <>
"Enable it by visiting https://console.developers.google.com/apis/api/admin.googleapis.com/overview?project=XXXX " <>
"then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry."
response =
%{
"error" => %{
"code" => 403,
"message" => error_message,
"errors" => [
%{
"message" => error_message,
"domain" => "usageLimits",
"reason" => "accessNotConfigured",
"extendedHelp" => "https://console.developers.google.com"
}
],
"status" => "PERMISSION_DENIED",
"details" => [
%{
"@type" => "type.googleapis.com/google.rpc.Help",
"links" => [
%{
"description" => "Google developers console API activation",
"url" =>
"https://console.developers.google.com/apis/api/admin.googleapis.com/overview?project=100421656358"
}
]
},
%{
"@type" => "type.googleapis.com/google.rpc.ErrorInfo",
"reason" => "SERVICE_DISABLED",
"domain" => "googleapis.com",
"metadata" => %{
"service" => "admin.googleapis.com",
"consumer" => "projects/100421656358"
}
}
]
}
}
Bypass.expect_once(bypass, "GET", "/admin/directory/v1/users", fn conn ->
Plug.Conn.send_resp(conn, 403, Jason.encode!(response))
end)
assert sync_directory(%{}) == :ok
assert updated_provider = Repo.get(Domain.Auth.Provider, provider.id)
refute updated_provider.last_synced_at
assert updated_provider.last_syncs_failed == 1
assert updated_provider.last_sync_error == error_message
Bypass.expect_once(bypass, "GET", "/admin/directory/v1/users", fn conn ->
Plug.Conn.send_resp(conn, 500, "")
end)
assert sync_directory(%{}) == :ok
assert updated_provider = Repo.get(Domain.Auth.Provider, provider.id)
refute updated_provider.last_synced_at
assert updated_provider.last_syncs_failed == 2
assert updated_provider.last_sync_error == "Google API is temporarily unavailable"
end
end
end

View File

@@ -362,6 +362,31 @@ defmodule Domain.AuthTest do
assert Enum.map(providers, & &1.id) |> Enum.sort() ==
Enum.sort([provider1.id, provider2.id])
end
test "uses 1/2 regular timeout backoff for failed attempts" do
{provider, _bypass} = Fixtures.Auth.start_and_create_google_workspace_provider()
# backoff: 10 minutes * (1 + 3 ^ 2) = 100 minutes
provider = Domain.Fixture.update!(provider, %{last_sync_error: "foo", last_syncs_failed: 3})
ninety_nine_minute_ago = DateTime.utc_now() |> DateTime.add(-99, :minute)
Domain.Fixture.update!(provider, %{last_synced_at: ninety_nine_minute_ago})
assert list_providers_pending_sync_by_adapter(:google_workspace) == {:ok, []}
one_hundred_one_minute_ago = DateTime.utc_now() |> DateTime.add(-101, :minute)
Domain.Fixture.update!(provider, %{last_synced_at: one_hundred_one_minute_ago})
assert {:ok, [_provider]} = list_providers_pending_sync_by_adapter(:google_workspace)
# max backoff: 4 hours
provider = Domain.Fixture.update!(provider, %{last_syncs_failed: 300})
three_hours_fifty_nine_minutes_ago = DateTime.utc_now() |> DateTime.add(-239, :minute)
Domain.Fixture.update!(provider, %{last_synced_at: three_hours_fifty_nine_minutes_ago})
assert list_providers_pending_sync_by_adapter(:google_workspace) == {:ok, []}
four_hours_one_minute_ago = DateTime.utc_now() |> DateTime.add(-241, :minute)
Domain.Fixture.update!(provider, %{last_synced_at: four_hours_one_minute_ago})
assert {:ok, [_provider]} = list_providers_pending_sync_by_adapter(:google_workspace)
end
end
describe "new_provider/2" do

View File

@@ -71,6 +71,17 @@ defmodule Domain.ClientsTest do
assert fetch_client_by_id(client.id, subject) == {:ok, client}
end
test "preloads online status", %{unprivileged_actor: actor, unprivileged_subject: subject} do
client = Fixtures.Clients.create_client(actor: actor)
assert {:ok, client} = fetch_client_by_id(client.id, subject)
assert client.online? == false
assert connect_client(client) == :ok
assert {:ok, client} = fetch_client_by_id(client.id, subject)
assert client.online? == true
end
test "returns client that belongs to another actor with manage permission", %{
account: account,
unprivileged_subject: subject
@@ -182,6 +193,17 @@ defmodule Domain.ClientsTest do
assert length(clients) == 2
end
test "preloads online status", %{unprivileged_actor: actor, unprivileged_subject: subject} do
Fixtures.Clients.create_client(actor: actor)
assert {:ok, [client]} = list_clients(subject)
assert client.online? == false
assert connect_client(client) == :ok
assert {:ok, [client]} = list_clients(subject)
assert client.online? == true
end
test "returns error when subject has no permission to manage clients", %{
unprivileged_subject: subject
} do

View File

@@ -198,6 +198,18 @@ defmodule Domain.Fixtures.Auth do
update!(provider, deleted_at: DateTime.utc_now())
end
def fail_provider_sync(provider) do
update!(provider, last_sync_error: "Message from fixture", last_syncs_failed: 3)
end
def finish_provider_sync(provider) do
update!(provider,
last_synced_at: DateTime.utc_now(),
last_sync_error: nil,
last_syncs_failed: 0
)
end
def identity_attrs(attrs \\ %{}) do
Enum.into(attrs, %{
provider_virtual_state: %{}

View File

@@ -1,4 +1,3 @@
import StatusPage from "../vendor/status_page";
import { initTabs } from "flowbite";
let Hooks = {};
@@ -62,38 +61,4 @@ Hooks.Copy = {
},
};
// Update status indicator when sidebar is mounted or updated
let statusIndicatorClassNames = {
none: "bg-green-100 text-green-800",
minor: "bg-yellow-100 text-yellow-800",
major: "bg-orange-100 text-orange-800",
critical: "bg-red-100 text-red-800",
};
const statusUpdater = function () {
const self = this;
const sp = new StatusPage.page({ page: "firezone" });
sp.summary({
success: function (data) {
self.el.innerHTML = `
<span class="text-xs font-medium mr-2 px-2.5 py-0.5 rounded ${
statusIndicatorClassNames[data.status.indicator]
}">
${data.status.description}
</span>
`;
},
error: function (data) {
console.error("An error occurred while fetching status page data");
self.el.innerHTML = `<span class="${statusIndicatorClassNames.minor}">Unable to fetch status</span>`;
},
});
};
Hooks.StatusPage = {
mounted: statusUpdater,
updated: statusUpdater,
};
export default Hooks;

View File

@@ -1,184 +0,0 @@
// StatusPage API Wrapper. Vendored and reviewed by @jamilbk from https://cdn.statuspage.io/se-v2.js
;(StatusPage = "undefined" == typeof StatusPage ? {} : StatusPage),
(StatusPage.page = function (e) {
if (!(e = e || {}).page)
throw new Error("A pageId is required to initialize.")
;(this.apiKey = e.apiKey || null),
(this.error = e.error || this.error),
(this.format = e.format || "json"),
(this.pageId = e.page),
(this.version = e.version || "v2"),
(this.secure = !("secure" in e) || e.secure),
(this.protocol = this.secure ? "https" : "http"),
(this.host = e.host || "statuspage.io"),
(this.host_with_port_and_protocol = e.test
? ""
: this.protocol + "://" + this.pageId + "." + this.host)
}),
(StatusPage.page.prototype.serialize = function (e, t) {
var s = [],
r = { sms: "email_sms", webhook: "endpoint" }
for (var o in e)
if ("to_sentence" !== o) {
var i = o
o = o in r ? r[o] : o
var a = t ? t + "[" + o + "]" : o,
n = e[i]
s.push(
"object" == typeof n
? this.serialize(n, a)
: encodeURIComponent(a) + "=" + encodeURIComponent(n)
)
}
return s.join("&")
}),
(StatusPage.page.prototype.createStatusPageCORSRequest = function (e, t) {
var s = new XMLHttpRequest()
return (
"withCredentials" in s
? s.open(e, t, !0)
: "undefined" != typeof XDomainRequest
? (s = new XDomainRequest()).open(e, t)
: (s = null),
s
)
}),
(StatusPage.page.prototype.executeRequestAndCallbackWithResponse = function (
e
) {
if (!e.path) throw new Error("A path is required to make a request")
var t = e.path,
s = e.method || "GET",
r = e.success || null,
o = e.error || this.error,
i =
this.host_with_port_and_protocol +
"/api/" +
this.version +
"/" +
t +
"." +
this.format,
a = this.createStatusPageCORSRequest(s, i)
if (a)
if (
(this.apiKey &&
(console.log(
"!!! API KEY IN USE - REMOVE BEFORE DEPLOYING TO PRODUCTION !!!"
),
console.log(
"!!! API KEY IN USE - REMOVE BEFORE DEPLOYING TO PRODUCTION !!!"
),
console.log(
"!!! API KEY IN USE - REMOVE BEFORE DEPLOYING TO PRODUCTION !!!"
),
a.setRequestHeader("Authorization", "OAuth " + this.apiKey)),
(a.onload = function () {
var e = JSON.parse(a.responseText)
r && r(e)
}),
(a.onerror = o),
"POST" === s || "DELETE" === s)
) {
var n = e.data || {}
a.setRequestHeader("Content-type", "application/x-www-form-urlencoded"),
a.send(this.serialize(n))
} else a.send()
}),
(StatusPage.page.prototype.get = function (e, t) {
if (((t = t || {}), !e)) throw new Error("Path is required.")
if (!t.success) throw new Error("Success Callback is required.")
var s = t.success || {},
r = t.error || {}
this.executeRequestAndCallbackWithResponse({
path: e,
success: s,
error: r,
method: "GET",
})
}),
(StatusPage.page.prototype.post = function (e, t) {
if (((t = t || {}), !e)) throw new Error("Path is required.")
var s = {}
if ("subscribers" === e) {
if (!t.subscriber) throw new Error("Subscriber is required to post.")
s.subscriber = t.subscriber
} else {
if (!t.data) throw new Error("Data is required to post.")
s = t.data
}
var r = t.success || {},
o = t.error || {}
this.executeRequestAndCallbackWithResponse({
data: s,
path: e,
success: r,
error: o,
method: "POST",
})
}),
(StatusPage.page.prototype["delete"] = function (e, t) {
if (((t = t || {}), !e)) throw new Error("Path is required.")
if (!t.subscriber) throw new Error("Data is required to delete.")
var s = {}
"subscribers" === e ? (s.subscriber = t.subscriber) : (s = t.data)
var r = t.success || {},
o = t.error || {}
this.executeRequestAndCallbackWithResponse({
data: s,
path: e,
success: r,
error: o,
method: "DELETE",
})
}),
(StatusPage.page.prototype.error = function () {
console.log("There was an error with your request")
}),
(StatusPage.page.prototype.summary = function (e) {
this.get("summary", e)
}),
(StatusPage.page.prototype.status = function (e) {
this.get("status", e)
}),
(StatusPage.page.prototype.components = function (e) {
this.get("components", e)
}),
(StatusPage.page.prototype.incidents = function (e) {
switch (e.filter) {
case "unresolved":
this.get("incidents/unresolved", e)
break
case "resolved":
this.get("incidents/resolved", e)
break
default:
this.get("incidents", e)
}
}),
(StatusPage.page.prototype.scheduled_maintenances = function (e) {
switch (e.filter) {
case "active":
this.get("scheduled-maintenances/active", e)
break
case "upcoming":
this.get("scheduled-maintenances/upcoming", e)
break
default:
this.get("scheduled-maintenances", e)
}
}),
(StatusPage.page.prototype.subscribe = function (e) {
if (!e || !e.subscriber) throw new Error("A subscriber object is required.")
this.post("subscribers", e)
}),
(StatusPage.page.prototype.unsubscribe = function (e) {
if (!e || !e.subscriber) throw new Error("A subscriber object is required.")
if (!e.subscriber.id)
throw new Error(
"You must supply a subscriber.id in order to cancel a subscription."
)
this["delete"]("subscribers", e)
})
export default StatusPage

View File

@@ -673,16 +673,6 @@ defmodule Web.CoreComponents do
"""
end
def status_page_widget(assigns) do
~H"""
<div class="absolute bottom-0 left-0 justify-left p-4 space-x-4 w-full lg:flex bg-white z-20">
<.link href="https://firezone.statuspage.io" class="text-xs hover:underline">
<span id="status-page-widget" phx-update="ignore" phx-hook="StatusPage" />
</.link>
</div>
"""
end
attr :type, :string, default: "neutral"
attr :class, :string, default: nil
attr :rest, :global

View File

@@ -61,7 +61,13 @@
</.sidebar_item_group>
<:bottom>
<.status_page_widget />
<div class="absolute bottom-0 left-0 justify-left p-4 space-x-4 w-full lg:flex bg-white z-20">
<.link href="https://firezone.statuspage.io" class={["text-xs", link_style()]}>
<span class="mr-2 px-2.5 py-0.5 rounded bg-neutral-100 text-neutral-800">
Platform status
</span>
</.link>
</div>
</:bottom>
</.sidebar>

View File

@@ -1,7 +1,7 @@
defmodule Web.Actors.Show do
use Web, :live_view
import Web.Actors.Components
alias Domain.{Auth, Flows}
alias Domain.{Auth, Flows, Clients}
alias Domain.Actors
def mount(%{"id" => id}, _session, socket) do
@@ -17,6 +17,9 @@ defmodule Web.Actors.Show do
Flows.list_flows_for(actor, socket.assigns.subject,
preload: [gateway: [:group], client: [], policy: [:resource, :actor_group]]
) do
actor = %{actor | clients: Clients.preload_online_statuses(actor.clients)}
:ok = Clients.subscribe_for_clients_presence_for_actor(actor)
{:ok,
assign(socket,
actor: actor,
@@ -263,6 +266,17 @@ defmodule Web.Actors.Show do
"""
end
def handle_info(%Phoenix.Socket.Broadcast{topic: "actor_clients:" <> _account_id}, socket) do
{:ok, actor} =
Actors.fetch_actor_by_id(socket.assigns.actor.id, socket.assigns.subject,
preload: [clients: []]
)
actor = %{socket.assigns.actor | clients: Clients.preload_online_statuses(actor.clients)}
{:noreply, assign(socket, actor: actor)}
end
def handle_event("delete", _params, socket) do
with {:ok, _actor} <- Actors.delete_actor(socket.assigns.actor, socket.assigns.subject) do
{:noreply, push_navigate(socket, to: ~p"/#{socket.assigns.account}/actors")}

View File

@@ -4,14 +4,13 @@ defmodule Web.Clients.Index do
def mount(_params, _session, socket) do
with {:ok, clients} <- Clients.list_clients(socket.assigns.subject, preload: :actor) do
:ok = Clients.subscribe_for_clients_presence_in_account(socket.assigns.subject.account)
{:ok, assign(socket, clients: clients)}
else
{:error, _reason} -> raise Web.LiveErrors.NotFoundError
end
end
# subscribe for presence
def render(assigns) do
~H"""
<.breadcrumbs account={@account}>
@@ -58,43 +57,8 @@ defmodule Web.Clients.Index do
"""
end
# defp resource_filter(assigns) do
# ~H"""
# <div class="flex flex-col md:flex-row items-center justify-between space-y-3 md:space-y-0 md:space-x-4 p-4">
# <div class="w-full md:w-1/2">
# <form class="flex items-center">
# <label for="simple-search" class="sr-only">Search</label>
# <div class="relative w-full">
# <div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
# <.icon name="hero-magnifying-glass" class="w-5 h-5 text-neutral-500" />
# </div>
# <input
# type="text"
# id="simple-search"
# class =
# {[
# "bg-neutral-50 border border-neutral-300 text-neutral-900",
# "text-sm rounded-lg",
# "block w-full pl-10 p-2"
# ]}
# placeholder="Search"
# required=""
# />
# </div>
# </form>
# </div>
# <.button_group>
# <:first>
# All
# </:first>
# <:middle>
# Online
# </:middle>
# <:last>
# Archived
# </:last>
# </.button_group>
# </div>
# """
# end
def handle_info(%Phoenix.Socket.Broadcast{topic: "clients:" <> _account_id}, socket) do
{:ok, clients} = Clients.list_clients(socket.assigns.subject, preload: :actor)
{:noreply, assign(socket, clients: clients)}
end
end

View File

@@ -9,6 +9,8 @@ defmodule Web.Clients.Show do
Flows.list_flows_for(client, socket.assigns.subject,
preload: [gateway: [:group], policy: [:resource, :actor_group]]
) do
:ok = Clients.subscribe_for_clients_presence_in_account(client.account_id)
socket =
assign(
socket,
@@ -52,6 +54,10 @@ defmodule Web.Clients.Show do
<:label>Name</:label>
<:value><%= @client.name %></:value>
</.vertical_table_row>
<.vertical_table_row>
<:label>Status</:label>
<:value><.connection_status schema={@client} /></:value>
</.vertical_table_row>
<.vertical_table_row>
<:label>Owner</:label>
<:value>
@@ -152,6 +158,27 @@ defmodule Web.Clients.Show do
"""
end
def handle_info(
%Phoenix.Socket.Broadcast{topic: "clients:" <> _account_id, payload: payload},
socket
) do
client = socket.assigns.client
socket =
cond do
Map.has_key?(payload.joins, client.id) ->
assign(socket, client: %{client | online?: true})
Map.has_key?(payload.leaves, client.id) ->
assign(socket, client: %{client | online?: false})
true ->
socket
end
{:noreply, socket}
end
def handle_event("delete", _params, socket) do
{:ok, _client} = Clients.delete_client(socket.assigns.client, socket.assigns.subject)
{:noreply, push_navigate(socket, to: ~p"/#{socket.assigns.account}/clients")}

View File

@@ -121,17 +121,21 @@ defmodule Web.Gateways.Show do
%Phoenix.Socket.Broadcast{topic: "gateway_groups:" <> _account_id, payload: payload},
socket
) do
if Map.has_key?(payload.joins, socket.assigns.gateway.id) or
Map.has_key?(payload.leaves, socket.assigns.gateway.id) do
{:ok, gateway} =
Gateways.fetch_gateway_by_id(socket.assigns.gateway.id, socket.assigns.subject,
preload: :group
)
gateway = socket.assigns.gateway
{:noreply, assign(socket, gateway: gateway)}
else
{:noreply, socket}
end
socket =
cond do
Map.has_key?(payload.joins, gateway.id) ->
assign(socket, gateway: %{gateway | online?: true})
Map.has_key?(payload.leaves, gateway.id) ->
assign(socket, gateway: %{gateway | online?: false})
true ->
socket
end
{:noreply, socket}
end
def handle_event("delete", _params, socket) do

View File

@@ -110,12 +110,15 @@ defmodule Web.RelayGroups.Show do
end
def handle_info(%Phoenix.Socket.Broadcast{topic: "relay_groups:" <> _account_id}, socket) do
socket =
push_navigate(socket,
to: ~p"/#{socket.assigns.account}/relay_groups/#{socket.assigns.group}"
{:ok, group} =
Relays.fetch_group_by_id(socket.assigns.group.id, socket.assigns.subject,
preload: [
relays: [token: [created_by_identity: [:actor]]],
created_by_identity: [:actor]
]
)
{:noreply, socket}
{:noreply, assign(socket, group: group)}
end
def handle_event("delete", _params, socket) do

View File

@@ -118,15 +118,21 @@ defmodule Web.Relays.Show do
%Phoenix.Socket.Broadcast{topic: "relay_groups:" <> _account_id, payload: payload},
socket
) do
if Map.has_key?(payload.joins, socket.assigns.relay.id) or
Map.has_key?(payload.leaves, socket.assigns.relay.id) do
{:ok, relay} =
Relays.fetch_relay_by_id(socket.assigns.relay.id, socket.assigns.subject, preload: :group)
relay = socket.assigns.relay
{:noreply, assign(socket, relay: relay)}
else
{:noreply, socket}
end
socket =
cond do
Map.has_key?(payload.joins, relay.id) ->
assign(socket, relay: %{relay | online?: true})
Map.has_key?(payload.leaves, relay.id) ->
assign(socket, relay: %{relay | online?: false})
true ->
socket
end
{:noreply, socket}
end
def handle_event("delete", _params, socket) do

View File

@@ -148,7 +148,11 @@ defmodule Web.Settings.IdentityProviders.Components do
def sync_status(%{provider: %{provisioner: :custom}} = assigns) do
~H"""
<div :if={not is_nil(@provider.last_synced_at)} class="flex items-center">
<span class="w-3 h-3 bg-green-500 rounded-full"></span>
<span class={[
"w-3 h-3 rounded-full",
(@provider.last_syncs_failed > 3 && "bg-red-500") || "bg-green-500"
]}>
</span>
<span class="ml-3">
Synced
<.link navigate={~p"/#{@account}/actors?provider_id=#{@provider.id}"} class={link_style()}>

View File

@@ -47,7 +47,9 @@ defmodule Web.Settings.IdentityProviders.GoogleWorkspace.Connect do
GoogleWorkspace.verify_and_upsert_identity(subject.actor, provider, payload),
attrs = %{
adapter_state: identity.provider_state,
disabled_at: nil
disabled_at: nil,
last_syncs_failed: 0,
last_sync_error: nil
},
{:ok, _provider} <- Domain.Auth.update_provider(provider, attrs, subject) do
redirect(conn,

View File

@@ -1,14 +1,23 @@
defmodule Web.Settings.IdentityProviders.GoogleWorkspace.Show do
use Web, :live_view
import Web.Settings.IdentityProviders.Components
alias Domain.Auth
alias Domain.{Auth, Actors}
def mount(%{"provider_id" => provider_id}, _session, socket) do
with {:ok, provider} <-
Auth.fetch_provider_by_id(provider_id, socket.assigns.subject,
preload: [created_by_identity: [:actor]]
) do
{:ok, assign(socket, provider: provider)}
),
{:ok, identities_count_by_provider_id} <-
Auth.fetch_identities_count_grouped_by_provider_id(socket.assigns.subject),
{:ok, groups_count_by_provider_id} <-
Actors.fetch_groups_count_grouped_by_provider_id(socket.assigns.subject) do
{:ok,
assign(socket,
provider: provider,
identities_count_by_provider_id: identities_count_by_provider_id,
groups_count_by_provider_id: groups_count_by_provider_id
)}
else
_ -> raise Web.LiveErrors.NotFoundError
end
@@ -80,6 +89,33 @@ defmodule Web.Settings.IdentityProviders.GoogleWorkspace.Show do
<.status provider={@provider} />
</:value>
</.vertical_table_row>
<.vertical_table_row>
<:label>Sync Status</:label>
<:value>
<.sync_status
account={@account}
provider={@provider}
identities_count_by_provider_id={@identities_count_by_provider_id}
groups_count_by_provider_id={@groups_count_by_provider_id}
/>
<div
:if={
(is_nil(@provider.last_synced_at) and not is_nil(@provider.last_sync_error)) or
(@provider.last_syncs_failed > 3 and not is_nil(@provider.last_sync_error))
}
class="p-3 mt-2 border-l-4 border-red-500 bg-red-100 rounded-md"
>
<p class="font-bold text-red-700">
IdP provider reported an error during the last sync:
</p>
<div class="flex items-center mt-1">
<span class="text-red-500 font-mono"><%= @provider.last_sync_error %></span>
</div>
</div>
</:value>
</.vertical_table_row>
<.vertical_table_row>
<:label>Client ID</:label>
<:value><%= @provider.adapter_config["client_id"] %></:value>

View File

@@ -52,6 +52,44 @@ defmodule Web.Live.Actors.ShowTest do
assert breadcrumbs =~ actor.name
end
test "renders clients table", %{
conn: conn
} do
account = Fixtures.Accounts.create_account()
actor = Fixtures.Actors.create_actor(type: :account_admin_user, account: account)
identity = Fixtures.Auth.create_identity(account: account, actor: actor)
client = Fixtures.Clients.create_client(account: account, actor: actor)
{:ok, lv, _html} =
conn
|> authorize_conn(identity)
|> live(~p"/#{account}/actors/#{actor}")
[row] =
lv
|> element("#clients")
|> render()
|> table_to_map()
assert row["name"] == client.name
assert row["status"] == "Offline"
assert Domain.Clients.connect_client(client) == :ok
{:ok, lv, _html} =
conn
|> authorize_conn(identity)
|> live(~p"/#{account}/actors/#{actor}")
[row] =
lv
|> element("#clients")
|> render()
|> table_to_map()
assert row["status"] == "Online"
end
test "renders flows table", %{
conn: conn
} do

View File

@@ -91,6 +91,7 @@ defmodule Web.Live.Clients.ShowTest do
assert table["identifier"] == client.id
assert table["name"] == client.name
assert table["owner"] =~ actor.name
assert table["status"] =~ "Offline"
assert table["created"]
assert table["last seen"]
assert table["last seen remote ip"] =~ to_string(client.last_seen_remote_ip)

View File

@@ -149,6 +149,59 @@ defmodule Web.Live.Settings.IdentityProviders.GoogleWorkspace.Connect do
assert flash(conn, :error) == "Your session has expired, please try again."
end
test "resets the sync error when IdP is reconnected", %{
account: account,
conn: conn
} do
{provider, bypass} =
Fixtures.Auth.start_and_create_google_workspace_provider(account: account)
provider = Fixtures.Auth.fail_provider_sync(provider)
actor = Fixtures.Actors.create_actor(type: :account_admin_user, account: account)
identity = Fixtures.Auth.create_identity(account: account, actor: actor, provider: provider)
redirected_conn =
conn
|> authorize_conn(identity)
|> assign(:account, account)
|> get(
~p"/#{account}/settings/identity_providers/google_workspace/#{provider}/redirect",
%{}
)
{token, _claims} = Mocks.OpenIDConnect.generate_openid_connect_token(provider, identity)
Mocks.OpenIDConnect.expect_refresh_token(bypass, %{"id_token" => token})
Mocks.OpenIDConnect.expect_userinfo(bypass)
cookie_key = "fz_auth_state_#{provider.id}"
redirected_conn = fetch_cookies(redirected_conn)
{state, _verifier} = redirected_conn.cookies[cookie_key] |> :erlang.binary_to_term([:safe])
%{value: signed_state} = redirected_conn.resp_cookies[cookie_key]
conn =
conn
|> authorize_conn(identity)
|> assign(:account, account)
|> put_req_cookie(cookie_key, signed_state)
|> put_session(:foo, "bar")
|> put_session(:preferred_locale, "en_US")
|> get(
~p"/#{account.id}/settings/identity_providers/google_workspace/#{provider.id}/handle_callback",
%{
"state" => state,
"code" => "MyFakeCode"
}
)
assert redirected_to(conn) ==
~p"/#{account}/settings/identity_providers/google_workspace/#{provider}"
assert provider = Repo.get(Domain.Auth.Provider, provider.id)
assert provider.last_sync_error == nil
assert provider.last_syncs_failed == 0
end
test "redirects to the actors index when credentials are valid and return path is empty", %{
account: account,
conn: conn

View File

@@ -91,10 +91,48 @@ defmodule Web.Live.Settings.IdentityProviders.GoogleWorkspace.ShowTest do
assert table["name"] == provider.name
assert table["status"] == "Active"
assert table["sync status"] == "Never synced"
assert table["client id"] == provider.adapter_config["client_id"]
assert around_now?(table["created"])
end
test "renders sync status", %{
account: account,
provider: provider,
identity: identity,
conn: conn
} do
provider = Fixtures.Auth.fail_provider_sync(provider)
{:ok, lv, _html} =
conn
|> authorize_conn(identity)
|> live(~p"/#{account}/settings/identity_providers/google_workspace/#{provider}")
table =
lv
|> element("#provider")
|> render()
|> vertical_table_to_map()
assert table["sync status"] =~ provider.last_sync_error
provider = Fixtures.Auth.finish_provider_sync(provider)
{:ok, lv, _html} =
conn
|> authorize_conn(identity)
|> live(~p"/#{account}/settings/identity_providers/google_workspace/#{provider}")
table =
lv
|> element("#provider")
|> render()
|> vertical_table_to_map()
assert table["sync status"] =~ "Synced 1 identity and 0 groups"
end
test "renders name of actor that created provider", %{
account: account,
actor: actor,