feat(portal): Allow client verification and add a policy condition to enforce it (#6604)

<img width="1414" alt="Screenshot 2024-09-05 at 1 17 08 PM"
src="https://github.com/user-attachments/assets/f50816e5-1e16-413c-be35-15ef9153a95d">
<img width="1404" alt="Screenshot 2024-09-05 at 1 17 13 PM"
src="https://github.com/user-attachments/assets/a5e055d0-321d-417e-9fd8-78e9643498cd">
<img width="1178" alt="Screenshot 2024-09-05 at 1 17 23 PM"
src="https://github.com/user-attachments/assets/6ea45486-98fb-495f-96d9-a96eb01925dd">
<img width="678" alt="Screenshot 2024-09-05 at 1 17 31 PM"
src="https://github.com/user-attachments/assets/45b4e798-d1b8-4574-97b3-a41dec1619fd">
<img width="632" alt="Screenshot 2024-09-05 at 1 17 46 PM"
src="https://github.com/user-attachments/assets/3c7c02e0-fc78-442e-86d3-fa711c9bb77c">
This commit is contained in:
Andrew Dryga
2024-09-05 14:35:38 -07:00
committed by GitHub
parent 2cf2d447c5
commit da81fb7f41
25 changed files with 537 additions and 25 deletions

View File

@@ -19,7 +19,7 @@ defmodule Domain.Actors.Group.Changeset do
|> changeset()
|> put_change(:account_id, account.id)
|> cast_membership_assocs(account.id)
|> put_created_by(subject)
|> put_subject_trail(:created_by, subject)
end
def create(%Accounts.Account{} = account, attrs) do

View File

@@ -184,6 +184,40 @@ defmodule Domain.Clients do
end
end
def verify_client(%Client{} = client, %Auth.Subject{} = subject) do
with :ok <- authorize_actor_client_management(client.actor_id, subject),
:ok <- Auth.ensure_has_permissions(subject, Authorizer.verify_clients_permission()) do
Client.Query.not_deleted()
|> Client.Query.by_id(client.id)
|> Authorizer.for_subject(subject)
|> Repo.fetch_and_update(Client.Query,
with: &Client.Changeset.verify(&1, subject),
preload: [:online?]
)
|> case do
{:ok, client} ->
client = Repo.preload(client, [:verified_by_actor, :verified_by_identity])
{:ok, client}
{:error, reason} ->
{:error, reason}
end
end
end
def remove_client_verification(%Client{} = client, %Auth.Subject{} = subject) do
with :ok <- authorize_actor_client_management(client.actor_id, subject),
:ok <- Auth.ensure_has_permissions(subject, Authorizer.verify_clients_permission()) do
Client.Query.not_deleted()
|> Client.Query.by_id(client.id)
|> Authorizer.for_subject(subject)
|> Repo.fetch_and_update(Client.Query,
with: &Client.Changeset.remove_verification(&1),
preload: [:online?]
)
end
end
def delete_client(%Client{} = client, %Auth.Subject{} = subject) do
queryable =
Client.Query.not_deleted()

View File

@@ -4,13 +4,15 @@ defmodule Domain.Clients.Authorizer do
def manage_own_clients_permission, do: build(Client, :manage_own)
def manage_clients_permission, do: build(Client, :manage)
def verify_clients_permission, do: build(Client, :verify)
@impl Domain.Auth.Authorizer
def list_permissions_for_role(:account_admin_user) do
[
manage_own_clients_permission(),
manage_clients_permission()
manage_clients_permission(),
verify_clients_permission()
]
end

View File

@@ -27,6 +27,11 @@ defmodule Domain.Clients.Client do
belongs_to :identity, Domain.Auth.Identity
belongs_to :last_used_token, Domain.Tokens.Token
field :verified_at, :utc_datetime_usec
field :verified_by, Ecto.Enum, values: [:system, :actor, :identity]
belongs_to :verified_by_actor, Domain.Actors.Actor
belongs_to :verified_by_identity, Domain.Auth.Identity
field :deleted_at, :utc_datetime_usec
timestamps()
end

View File

@@ -72,6 +72,22 @@ defmodule Domain.Clients.Client.Changeset do
|> unique_constraint(:ipv6, name: :clients_account_id_ipv6_index)
end
def verify(%Clients.Client{} = client, %Auth.Subject{} = subject) do
client
|> change()
|> put_default_value(:verified_at, DateTime.utc_now())
|> put_subject_trail(:verified_by, subject)
end
def remove_verification(%Clients.Client{} = client) do
client
|> change()
|> put_change(:verified_at, nil)
|> put_change(:verified_by, nil)
|> put_change(:verified_by_actor_id, nil)
|> put_change(:verified_by_identity_id, nil)
end
def update(%Clients.Client{} = client, attrs) do
client
|> cast(attrs, @update_fields)

View File

@@ -113,6 +113,16 @@ defmodule Domain.Clients.Client.Query do
type: {:string, :websearch},
fun: &filter_by_name_fts/2
},
%Domain.Repo.Filter{
name: :verification,
title: "Verification Status",
type: :string,
values: [
{"Verified", "verified"},
{"Not Verified", "not_verified"}
],
fun: &filter_by_verification/2
},
%Domain.Repo.Filter{
name: :client_or_actor_name,
title: "Client Name or Actor Name",
@@ -125,6 +135,14 @@ defmodule Domain.Clients.Client.Query do
{queryable, dynamic([clients: clients], fulltext_search(clients.name, ^name))}
end
def filter_by_verification(queryable, "verified") do
{queryable, dynamic([clients: clients], not is_nil(clients.verified_at))}
end
def filter_by_verification(queryable, "not_verified") do
{queryable, dynamic([clients: clients], is_nil(clients.verified_at))}
end
def filter_by_client_or_actor_name(queryable, name) do
queryable =
with_named_binding(queryable, :actor, fn queryable, binding ->

View File

@@ -9,14 +9,14 @@ defmodule Domain.Gateways.Group.Changeset do
%Gateways.Group{account: account}
|> changeset(attrs)
|> put_change(:account_id, account.id)
|> put_created_by(subject)
|> put_subject_trail(:created_by, subject)
end
def create(%Accounts.Account{} = account, attrs) do
%Gateways.Group{account: account}
|> changeset(attrs)
|> put_change(:account_id, account.id)
|> put_created_by(:system)
|> put_subject_trail(:created_by, :system)
end
def update(%Gateways.Group{} = group, attrs, %Auth.Subject{}) do

View File

@@ -3,15 +3,21 @@ defmodule Domain.Policies.Condition do
@primary_key false
embedded_schema do
field :property, Ecto.Enum,
values: ~w[remote_ip_location_region remote_ip provider_id current_utc_datetime]a
field :property, Ecto.Enum, values: ~w[
remote_ip_location_region
remote_ip
provider_id
current_utc_datetime
client_verified
]a
field :operator, Ecto.Enum, values: ~w[
contains does_not_contain
is_in is_not_in
is_in_day_of_week_time_ranges
is_in_cidr is_not_in_cidr
]a
is
]a
field :values, {:array, :string}
end

View File

@@ -15,6 +15,7 @@ defmodule Domain.Policies.Condition.Changeset do
def valid_operators_for_property(:remote_ip), do: [:is_in_cidr, :is_not_in_cidr]
def valid_operators_for_property(:provider_id), do: [:is_in, :is_not_in]
def valid_operators_for_property(:current_utc_datetime), do: [:is_in_day_of_week_time_ranges]
def valid_operators_for_property(:client_verified), do: [:is]
defp validate_operator(changeset) do
case fetch_field(changeset, :property) do
@@ -44,6 +45,13 @@ defmodule Domain.Policies.Condition.Changeset do
validate_day_of_week_time_ranges(changeset, field)
end)
{_data_or_changes, :client_verified} ->
changeset
|> validate_required(:operator)
|> validate_inclusion(:operator, valid_operators_for_property(:client_verified))
|> validate_length(:values, min: 1, max: 1)
|> validate_list(:values, :boolean)
{_data_or_changes, nil} ->
changeset

View File

@@ -124,6 +124,36 @@ defmodule Domain.Policies.Condition.Evaluator do
end
end
def fetch_conformation_expiration(
%Condition{
property: :client_verified,
operator: :is,
values: ["true"]
},
%Clients.Client{verified_at: verified_at}
) do
if is_nil(verified_at) do
:error
else
{:ok, nil}
end
end
def fetch_conformation_expiration(
%Condition{
property: :client_verified,
operator: :is,
values: ["false"]
},
%Clients.Client{verified_at: verified_at}
) do
if is_nil(verified_at) do
{:ok, nil}
else
:error
end
end
def fetch_conformation_expiration(
%Condition{
property: :current_utc_datetime,

View File

@@ -14,7 +14,7 @@ defmodule Domain.Policies.Policy.Changeset do
|> cast_embed(:conditions, with: &Domain.Policies.Condition.Changeset.changeset/3)
|> changeset()
|> put_change(:account_id, subject.account.id)
|> put_created_by(subject)
|> put_subject_trail(:created_by, subject)
end
def update(%Policy{} = policy, attrs) do

View File

@@ -139,22 +139,22 @@ defmodule Domain.Repo.Changeset do
end
end
def put_created_by(changeset, :system) do
def put_subject_trail(changeset, field, :system) do
changeset
|> put_change(:created_by, :system)
|> put_default_value(field, :system)
end
def put_created_by(changeset, %Domain.Auth.Subject{identity: nil} = subject) do
def put_subject_trail(changeset, field, %Domain.Auth.Subject{identity: nil} = subject) do
changeset
|> put_change(:created_by_actor_id, subject.actor.id)
|> put_change(:created_by, :actor)
|> put_default_value(field, :actor)
|> put_default_value(:"#{field}_actor_id", subject.actor.id)
end
def put_created_by(changeset, %Domain.Auth.Subject{} = subject) do
def put_subject_trail(changeset, field, %Domain.Auth.Subject{} = subject) do
changeset
|> put_change(:created_by, :identity)
|> put_change(:created_by_identity_id, subject.identity.id)
|> put_change(:created_by_actor_id, subject.actor.id)
|> put_default_value(field, :identity)
|> put_default_value(:"#{field}_actor_id", subject.actor.id)
|> put_default_value(:"#{field}_identity_id", subject.identity.id)
end
# Validations

View File

@@ -7,7 +7,7 @@ defmodule Domain.Resources.Connection.Changeset do
def changeset(account_id, connection, attrs, %Auth.Subject{} = subject) do
changeset(account_id, connection, attrs)
|> put_created_by(subject)
|> put_subject_trail(:created_by, subject)
end
def changeset(account_id, connection, attrs) do

View File

@@ -41,7 +41,7 @@ defmodule Domain.Resources.Resource.Changeset do
with: &Connection.Changeset.changeset(account.id, &1, &2, subject),
required: true
)
|> put_created_by(subject)
|> put_subject_trail(:created_by, subject)
end
def create(%Accounts.Account{} = account, attrs) do

View File

@@ -46,7 +46,7 @@ defmodule Domain.Tokens.Token.Changeset do
:service_account_client
])
|> changeset()
|> put_created_by(subject)
|> put_subject_trail(:created_by, subject)
end
defp changeset(changeset) do

View File

@@ -0,0 +1,32 @@
defmodule Domain.Repo.Migrations.AddClientsVerification do
use Ecto.Migration
def change do
alter table(:clients) do
add(:verified_at, :utc_datetime_usec, default: nil)
add(:verified_by, :string)
add(:verified_by_actor_id, references(:actors, type: :binary_id))
add(:verified_by_identity_id, references(:auth_identities, type: :binary_id))
end
create(
constraint(:clients, :verified_fields_set,
check: """
(
verified_at IS NULL
AND (verified_by IS NULL AND verified_by_actor_id IS NULL AND verified_by_identity_id IS NULL )
)
OR
(
verified_at IS NOT NULL
AND (
(verified_by = 'system' AND verified_by_actor_id IS NULL AND verified_by_identity_id IS NULL)
OR (verified_by = 'actor' AND verified_by_actor_id IS NOT NULL AND verified_by_identity_id IS NULL)
OR (verified_by = 'identity' AND verified_by_actor_id IS NOT NULL AND verified_by_identity_id IS NOT NULL)
)
)
"""
)
)
end
end

View File

@@ -454,6 +454,8 @@ defmodule Domain.ClientsTest do
assert client.last_seen_user_agent == subject.context.user_agent
assert client.last_seen_version == "1.3.0"
assert client.last_seen_at
assert is_nil(client.verified_at)
end
test "updates client when it already exists", %{
@@ -576,6 +578,7 @@ defmodule Domain.ClientsTest do
assert client.actor_id == subject.actor.id
assert client.account_id == account.id
refute client.identity_id
assert is_nil(client.verified_at)
end
test "does not allow to reuse IP addresses", %{
@@ -751,6 +754,76 @@ defmodule Domain.ClientsTest do
end
end
describe "verify_client/2" do
test "allows admin actor to verify clients", %{admin_actor: actor, admin_subject: subject} do
client = Fixtures.Clients.create_client(actor: actor)
assert {:ok, client} = verify_client(client, subject)
assert client.verified_at
assert client.verified_by == :identity
assert client.verified_by_actor_id == subject.actor.id
assert client.verified_by_identity_id == subject.identity.id
assert {:ok, double_verified_client} = verify_client(client, subject)
assert double_verified_client.verified_at == client.verified_at
end
test "returns error when subject has no permission to verify clients", %{
admin_actor: actor,
admin_subject: subject
} do
client = Fixtures.Clients.create_client(actor: actor)
subject =
Fixtures.Auth.remove_permission(
subject,
Clients.Authorizer.verify_clients_permission()
)
assert verify_client(client, subject) ==
{:error,
{:unauthorized,
reason: :missing_permissions,
missing_permissions: [Clients.Authorizer.verify_clients_permission()]}}
end
end
describe "remove_client_verification/2" do
test "allows admin actor to remove client verification", %{
admin_actor: actor,
admin_subject: subject
} do
client = Fixtures.Clients.create_client(actor: actor)
assert {:ok, client} = verify_client(client, subject)
assert {:ok, client} = remove_client_verification(client, subject)
assert is_nil(client.verified_at)
assert is_nil(client.verified_by)
assert is_nil(client.verified_by_actor_id)
assert is_nil(client.verified_by_identity_id)
end
test "returns error when subject has no permission to verify clients", %{
admin_actor: actor,
admin_subject: subject
} do
client = Fixtures.Clients.create_client(actor: actor)
subject =
Fixtures.Auth.remove_permission(
subject,
Clients.Authorizer.verify_clients_permission()
)
assert remove_client_verification(client, subject) ==
{:error,
{:unauthorized,
reason: :missing_permissions,
missing_permissions: [Clients.Authorizer.verify_clients_permission()]}}
end
end
describe "delete_client/2" do
test "returns error on state conflict", %{admin_actor: actor, admin_subject: subject} do
client = Fixtures.Clients.create_client(actor: actor)

View File

@@ -180,6 +180,29 @@ defmodule Domain.Policies.Condition.EvaluatorTest do
{:ok, nil}
end
test "when client verified is required" do
verified_client = %Domain.Clients.Client{verified_at: DateTime.utc_now()}
not_verified_client = %Domain.Clients.Client{verified_at: nil}
condition = %Domain.Policies.Condition{
property: :client_verified,
operator: :is,
values: ["true"]
}
assert fetch_conformation_expiration(condition, verified_client) == {:ok, nil}
assert fetch_conformation_expiration(condition, not_verified_client) == :error
condition = %Domain.Policies.Condition{
property: :client_verified,
operator: :is,
values: ["false"]
}
assert fetch_conformation_expiration(condition, verified_client) == :error
assert fetch_conformation_expiration(condition, not_verified_client) == {:ok, nil}
end
test "when client current UTC datetime is in the day of the week time ranges" do
# this is deeply tested separately in find_day_of_the_week_time_range/2
condition = %Domain.Policies.Condition{

View File

@@ -698,6 +698,10 @@ defmodule Domain.Fixtures.Auth do
{token, nonce <> Domain.Tokens.encode_fragment!(token)}
end
def remove_permission(%Auth.Subject{} = subject, permission) do
%{subject | permissions: MapSet.delete(subject.permissions, permission)}
end
def remove_permissions(%Auth.Subject{} = subject) do
%{subject | permissions: MapSet.new()}
end

View File

@@ -835,12 +835,13 @@ defmodule Web.CoreComponents do
Renders online or offline status using an `online?` field of the schema.
"""
attr :schema, :any, required: true
attr :class, :any, default: nil
def connection_status(assigns) do
assigns = assign_new(assigns, :relative_to, fn -> DateTime.utc_now() end)
~H"""
<span class="flex items-center">
<span class={["flex items-center", @class]}>
<.ping_icon color={if @schema.online?, do: "success", else: "danger"} />
<span
class="ml-2.5"
@@ -935,6 +936,46 @@ defmodule Web.CoreComponents do
"""
end
@doc """
Renders verification timestamp and entity.
"""
attr :account, :any, required: true
attr :schema, :any, required: true
def verified_by(%{schema: %{verified_by: :system}} = assigns) do
~H"""
<.icon name="hero-shield-check" class="w-4 h-4 mr-1" /> Verified
<.relative_datetime datetime={@schema.verified_at} /> by system
"""
end
def verified_by(%{schema: %{verified_by: :actor}} = assigns) do
~H"""
<.icon name="hero-shield-check" class="w-4 h-4 mr-1" /> Verified
<.relative_datetime datetime={@schema.verified_at} /> by
<.actor_link account={@account} actor={@schema.verified_by_actor} />
"""
end
def verified_by(%{schema: %{verified_by: :identity}} = assigns) do
~H"""
<.icon name="hero-shield-check" class="w-4 h-4 mr-1" /> Verified
<.relative_datetime datetime={@schema.verified_at} /> by
<.link
class="text-accent-500 hover:underline"
navigate={~p"/#{@schema.account_id}/actors/#{@schema.verified_by_identity.actor_id}"}
>
<%= assigns.schema.verified_by_actor.name %>
</.link>
"""
end
def verified_by(%{schema: %{verified_at: nil}} = assigns) do
~H"""
Not Verified
"""
end
attr :account, :any, required: true
attr :actor, :any, required: true

View File

@@ -13,7 +13,8 @@ defmodule Web.Clients.Index do
|> assign_live_table("clients",
query_module: Clients.Client.Query,
sortable_fields: [
{:clients, :name}
{:clients, :name},
{:clients, :inserted_at}
],
hide_filters: [
:name
@@ -68,6 +69,7 @@ defmodule Web.Clients.Index do
<.link navigate={~p"/#{@account}/clients/#{client.id}"} class={[link_style()]}>
<%= client.name %>
</.link>
<.icon :if={not is_nil(client.verified_at)} name="hero-shield-check" class="w-4 h-4" />
</:col>
<:col :let={client} label="user">
<.link navigate={~p"/#{@account}/actors/#{client.actor.id}"} class={[link_style()]}>
@@ -77,6 +79,9 @@ defmodule Web.Clients.Index do
<:col :let={client} label="status">
<.connection_status schema={client} />
</:col>
<:col :let={client} field={{:clients, :inserted_at}} label="created at">
<.relative_datetime datetime={client.inserted_at} />
</:col>
<:empty>
<div class="text-center text-neutral-500 p-4">
No clients to display. Clients are created automatically when a user connects to a resource.

View File

@@ -6,7 +6,13 @@ defmodule Web.Clients.Show do
def mount(%{"id" => id}, _session, socket) do
with {:ok, client} <-
Clients.fetch_client_by_id(id, socket.assigns.subject,
preload: [:online?, :actor, last_used_token: [identity: [:provider]]]
preload: [
:online?,
:actor,
:verified_by_identity,
:verified_by_actor,
last_used_token: [identity: [:provider]]
]
) do
if connected?(socket) do
:ok = Clients.subscribe_to_clients_presence_for_actor(client.actor)
@@ -68,6 +74,47 @@ defmodule Web.Clients.Show do
Client Details
<span :if={not is_nil(@client.deleted_at)} class="text-red-600">(deleted)</span>
</:title>
<:action :if={is_nil(@client.deleted_at) and not is_nil(@client.verified_at)}>
<.button_with_confirmation
id="remove_client_verification"
style="danger"
icon="hero-shield-exclamation"
on_confirm="remove_client_verification"
>
<:dialog_title>Remove verification</:dialog_title>
<:dialog_content>
Are you sure you want to remove verification of this Client?
</:dialog_content>
<:dialog_confirm_button>
Remove
</:dialog_confirm_button>
<:dialog_cancel_button>
Cancel
</:dialog_cancel_button>
Remove verification
</.button_with_confirmation>
</:action>
<:action :if={is_nil(@client.deleted_at) and is_nil(@client.verified_at)}>
<.button_with_confirmation
id="verify_client"
style="warning"
icon="hero-shield-check"
on_confirm="verify_client"
>
<:dialog_title>Verify Client</:dialog_title>
<:dialog_content>
Are you sure you want to verify this Client?
</:dialog_content>
<:dialog_confirm_button>
Verify
</:dialog_confirm_button>
<:dialog_cancel_button>
Cancel
</:dialog_cancel_button>
Verify
</.button_with_confirmation>
</:action>
<:action :if={is_nil(@client.deleted_at)}>
<.edit_button navigate={~p"/#{@account}/clients/#{@client}/edit"}>
Edit Client
@@ -85,7 +132,13 @@ defmodule Web.Clients.Show do
</.vertical_table_row>
<.vertical_table_row>
<:label>Status</:label>
<:value><.connection_status schema={@client} /></:value>
<:value><.connection_status class="ml-1/2" schema={@client} /></:value>
</.vertical_table_row>
<.vertical_table_row>
<:label>Verification</:label>
<:value>
<.verified_by account={@account} schema={@client} />
</:value>
</.vertical_table_row>
<.vertical_table_row>
<:label>Last used sign in method</:label>
@@ -219,7 +272,12 @@ defmodule Web.Clients.Show do
Map.has_key?(payload.joins, client.id) ->
{:ok, client} =
Clients.fetch_client_by_id(client.id, socket.assigns.subject,
preload: [:actor, last_used_token: [identity: [:provider]]]
preload: [
:actor,
:verified_by_identity,
:verified_by_actor,
last_used_token: [identity: [:provider]]
]
)
assign(socket, client: %{client | online?: true})
@@ -234,6 +292,33 @@ defmodule Web.Clients.Show do
{:noreply, socket}
end
def handle_event("verify_client", _params, socket) do
{:ok, client} = Clients.verify_client(socket.assigns.client, socket.assigns.subject)
client = %{
client
| online?: socket.assigns.client.online?,
actor: socket.assigns.client.actor,
last_used_token: socket.assigns.client.last_used_token
}
{:noreply, assign(socket, :client, client)}
end
def handle_event("remove_client_verification", _params, socket) do
{:ok, client} =
Clients.remove_client_verification(socket.assigns.client, socket.assigns.subject)
client = %{
client
| online?: socket.assigns.client.online?,
actor: socket.assigns.client.actor,
last_used_token: socket.assigns.client.last_used_token
}
{:noreply, assign(socket, :client, client)}
end
def handle_event(event, params, socket) when event in ["paginate", "order_by", "filter"],
do: handle_live_table_event(event, params, socket)
end

View File

@@ -168,6 +168,16 @@ defmodule Web.Policies.Components do
"""
end
defp condition(%{property: :client_verified} = assigns) do
~H"""
<span :if={@values != []} class="mr-1">
<span>by clients that are</span>
<span :if={@values == ["true"]}>verified</span>
<span :if={@values == ["false"]}>not verified</span>
</span>
"""
end
defp condition(%{property: :current_utc_datetime, values: values} = assigns) do
assigns =
assign_new(assigns, :tz_time_ranges_by_dow, fn ->
@@ -221,6 +231,7 @@ defmodule Web.Policies.Components do
defp condition_operator_option_name(:contains), do: "contains"
defp condition_operator_option_name(:does_not_contain), do: "does not contain"
defp condition_operator_option_name(:is_in), do: "is in"
defp condition_operator_option_name(:is), do: "is"
defp condition_operator_option_name(:is_not_in), do: "is not in"
defp condition_operator_option_name(:is_in_day_of_week_time_ranges), do: ""
defp condition_operator_option_name(:is_in_cidr), do: "is in"
@@ -261,6 +272,7 @@ defmodule Web.Policies.Components do
providers={@providers}
disabled={@policy_conditions_enabled? == false}
/>
<.client_verified_condition_form form={@form} disabled={@policy_conditions_enabled? == false} />
<.current_utc_datetime_condition_form
form={@form}
timezone={@timezone}
@@ -528,6 +540,83 @@ defmodule Web.Policies.Components do
"""
end
defp client_verified_condition_form(assigns) do
~H"""
<fieldset class="mb-4">
<% condition_form = find_condition_form(@form[:conditions], :client_verified) %>
<.input
type="hidden"
field={condition_form[:property]}
name="policy[conditions][client_verified][property]"
id="policy_conditions_client_verified_property"
value="client_verified"
/>
<.input
type="hidden"
name="policy[conditions][client_verified][operator]"
id="policy_conditions_client_verified_operator"
field={condition_form[:operator]}
value={:is}
/>
<div
class="hover:bg-neutral-100 cursor-pointer border border-neutral-200 shadow-b rounded-t px-4 py-2"
phx-click={
JS.toggle_class("hidden",
to: "#policy_conditions_client_verified_condition"
)
|> JS.toggle_class("bg-neutral-50")
|> JS.toggle_class("hero-chevron-down",
to: "#policy_conditions_client_verified_chevron"
)
|> JS.toggle_class("hero-chevron-up",
to: "#policy_conditions_client_verified_chevron"
)
}
>
<legend class="flex justify-between items-center text-neutral-700">
<span class="flex items-center">
<.icon name="hero-shield-check" class="w-5 h-5 mr-2" /> Client verification
</span>
<span class="shadow bg-white w-6 h-6 flex items-center justify-center rounded-full">
<.icon
id="policy_conditions_client_verified_chevron"
name="hero-chevron-down"
class="w-5 h-5"
/>
</span>
</legend>
</div>
<div
id="policy_conditions_client_verified_condition"
class={[
"p-4 border-neutral-200 border-l border-r border-b rounded-b",
condition_values_empty?(condition_form) && "hidden"
]}
>
<p class="text-sm text-neutral-500 mb-4">
Allow access when the Client is manually verified by the administrator.
</p>
<div class="space-y-2">
<.input
type="checkbox"
label="Require client verification"
field={condition_form[:values]}
name="policy[conditions][client_verified][values][]"
id="policy_conditions_client_verified_value"
disabled={@disabled}
checked={List.first(List.wrap(condition_form[:values].value)) == "true"}
value="true"
/>
</div>
</div>
</fieldset>
"""
end
defp current_utc_datetime_condition_form(assigns) do
assigns = assign_new(assigns, :days_of_week, fn -> @days_of_week end)

View File

@@ -95,6 +95,7 @@ defmodule Web.Live.Clients.ShowTest do
assert table["last seen remote ip"] =~ to_string(client.last_seen_remote_ip)
assert table["client version"] =~ client.last_seen_version
assert table["user agent"] =~ client.last_seen_user_agent
assert table["verification"] =~ "Not Verified"
end
test "shows client online status", %{
@@ -291,4 +292,30 @@ defmodule Web.Live.Clients.ShowTest do
{:error,
{:live_redirect, %{to: ~p"/#{account}/clients/#{client}/edit", kind: :push}}}
end
test "allows verifying clients", %{
account: account,
client: client,
identity: identity,
conn: conn
} do
{:ok, lv, _html} =
conn
|> authorize_conn(identity)
|> live(~p"/#{account}/clients/#{client}")
assert lv
|> element("button[type=submit]", "Verify")
|> render_click()
table =
lv
|> element("#client")
|> render()
|> vertical_table_to_map()
refute table["verification"] =~ "Not"
assert table["verification"] =~ "Verified"
assert table["verification"] =~ "by"
end
end

View File

@@ -60,6 +60,9 @@ defmodule Web.Live.Policies.NewTest do
assert find_inputs(form) == [
"policy[actor_group_id]",
"policy[conditions][client_verified][operator]",
"policy[conditions][client_verified][property]",
"policy[conditions][client_verified][values][]",
"policy[conditions][current_utc_datetime][operator]",
"policy[conditions][current_utc_datetime][property]",
"policy[conditions][current_utc_datetime][timezone]",
@@ -99,6 +102,9 @@ defmodule Web.Live.Policies.NewTest do
assert find_inputs(form) == [
"policy[actor_group_id]",
"policy[conditions][client_verified][operator]",
"policy[conditions][client_verified][property]",
"policy[conditions][client_verified][values][]",
"policy[conditions][current_utc_datetime][operator]",
"policy[conditions][current_utc_datetime][property]",
"policy[conditions][current_utc_datetime][timezone]",
@@ -149,6 +155,9 @@ defmodule Web.Live.Policies.NewTest do
assert find_inputs(form) == [
"policy[actor_group_id]",
"policy[conditions][client_verified][operator]",
"policy[conditions][client_verified][property]",
"policy[conditions][client_verified][values][]",
"policy[conditions][current_utc_datetime][operator]",
"policy[conditions][current_utc_datetime][property]",
"policy[conditions][current_utc_datetime][timezone]",
@@ -329,6 +338,11 @@ defmodule Web.Live.Policies.NewTest do
assert policy.resource_id == resource.id
assert policy.conditions == [
%Domain.Policies.Condition{
property: :client_verified,
operator: :is,
values: nil
},
%Domain.Policies.Condition{
property: :current_utc_datetime,
operator: :is_in_day_of_week_time_ranges,