refactor(portal): Update IDP creation flow (#4984)

Why:

* The new flow for creating an identity provider in Firezone allows the
user to not have to worry what features their plan has enabled. It will
allow the user to select which identity provider they use and will take
them to the appropriate form depending on the features they have enabled
on their plan.

## Screenshots

### Selecting an identity provider
<img width="937" alt="Screenshot 2024-05-14 at 11 53 17 AM"
src="https://github.com/firezone/firezone/assets/2646332/31337ad9-13c8-43a2-942c-adb0a951167c">


### New OIDC form when a custom provider is selected but IDP sync is not
enabled for account
<img width="903" alt="Screenshot 2024-05-14 at 11 54 58 AM"
src="https://github.com/firezone/firezone/assets/2646332/2e18d788-60c3-4fad-b749-351559a24aca">
This commit is contained in:
Brian Manifold
2024-05-14 15:48:36 -04:00
committed by GitHub
parent b7ced7ef56
commit 3ba7962c23
7 changed files with 207 additions and 124 deletions

View File

@@ -110,7 +110,8 @@ defmodule Domain.Auth do
|> Enum.map(fn adapter ->
capabilities = Adapters.fetch_capabilities!(adapter)
requires_idp_sync_feature? = capabilities[:default_provisioner] == :custom
{adapter, enabled: idp_sync_enabled? or not requires_idp_sync_feature?}
enabled_for_account? = idp_sync_enabled? or not requires_idp_sync_feature?
{adapter, enabled: enabled_for_account?, sync: requires_idp_sync_feature?}
end)
end

View File

@@ -11,19 +11,19 @@ defmodule Domain.AuthTest do
account = Fixtures.Accounts.create_account(features: %{idp_sync: true})
assert Enum.sort(all_user_provisioned_provider_adapters!(account)) == [
google_workspace: [enabled: true],
microsoft_entra: [enabled: true],
okta: [enabled: true],
openid_connect: [enabled: true]
google_workspace: [enabled: true, sync: true],
microsoft_entra: [enabled: true, sync: true],
okta: [enabled: true, sync: true],
openid_connect: [enabled: true, sync: false]
]
account = Fixtures.Accounts.create_account(features: %{idp_sync: false})
assert Enum.sort(all_user_provisioned_provider_adapters!(account)) == [
google_workspace: [enabled: false],
microsoft_entra: [enabled: false],
okta: [enabled: false],
openid_connect: [enabled: true]
google_workspace: [enabled: false, sync: true],
microsoft_entra: [enabled: false, sync: true],
okta: [enabled: false, sync: true],
openid_connect: [enabled: true, sync: false]
]
end
end

View File

@@ -18,6 +18,10 @@ defmodule Web.Settings.IdentityProviders.New do
{:noreply, push_navigate(socket, to: next)}
end
def handle_event("next_step", %{"next" => next}, socket) do
{:noreply, push_navigate(socket, to: next_step_path(next, socket.assigns.account))}
end
def render(assigns) do
~H"""
<.breadcrumbs account={@account}>
@@ -32,135 +36,95 @@ defmodule Web.Settings.IdentityProviders.New do
<:title>
Add a new Identity Provider
</:title>
<:help>
Set up SSO authentication using your own identity provider. Directory sync
also available for certain providers. <br /> Learn more about
<.website_link href="/kb/authenticate/oidc">SSO authentication</.website_link>
and
<.website_link href="/kb/authenticate/directory-sync">directory sync</.website_link>
in our docs.
</:help>
<:content>
<div class="max-w-2xl px-4 py-8 mx-auto lg:py-16">
<h2 class="mb-4 text-xl text-neutral-900">Choose type</h2>
<.form id="identity-provider-type-form" for={%{}} phx-submit="submit">
<div class="grid gap-4 mb-4 sm:grid-cols-1 sm:gap-6 sm:mb-6">
<fieldset>
<legend class="sr-only">Identity Provider Type</legend>
<.adapter
:for={{adapter, opts} <- @adapters}
adapter={adapter}
opts={opts}
account={@account}
/>
</fieldset>
<div class="container mx-auto">
<div class="max-w-sm mb-8 mx-auto">
<div class="flex flex-col gap-4">
<%= for {adapter, opts} <- @adapters, opts[:sync] == true do %>
<.provider_card adapter={adapter} opts={opts} account={@account} />
<% end %>
<%= for {adapter, opts} <- @adapters, opts[:sync] == false do %>
<.provider_card adapter={adapter} opts={opts} account={@account} />
<% end %>
</div>
<.submit_button>
Next: Configure
</.submit_button>
</.form>
</div>
</div>
</:content>
</.section>
"""
end
def adapter(%{adapter: :google_workspace} = assigns) do
~H"""
<.adapter_item
adapter={@adapter}
account={@account}
opts={@opts}
name="Google Workspace"
description="Authenticate users and synchronize users and groups with a custom Google Workspace connector."
/>
"""
end
def adapter(%{adapter: :microsoft_entra} = assigns) do
~H"""
<.adapter_item
adapter={@adapter}
account={@account}
opts={@opts}
name="Microsoft Entra"
description="Authenticate users and synchronize users and groups with a custom Microsoft Entra ID connector."
/>
"""
end
def adapter(%{adapter: :okta} = assigns) do
~H"""
<.adapter_item
adapter={@adapter}
account={@account}
opts={@opts}
name="Okta"
description="Authenticate users and synchronize users and groups with a custom Okta connector."
/>
"""
end
def adapter(%{adapter: :openid_connect} = assigns) do
~H"""
<.adapter_item
adapter={@adapter}
account={@account}
opts={@opts}
name="OpenID Connect"
description="Authenticate users with a universal OpenID Connect adapter and manager users and groups manually."
/>
"""
end
attr :adapter, :any
attr :account, :any
attr :opts, :any
attr :name, :string
attr :description, :string
def adapter_item(assigns) do
def provider_card(assigns) do
~H"""
<div>
<div class="flex items-center mb-4">
<input
id={"idp-option-#{@adapter}"}
type="radio"
name="next"
value={next_step_path(@adapter, @account)}
class={[
"w-4 h-4 border-neutral-300",
@opts[:enabled] == false && "cursor-not-allowed"
]}
disabled={@opts[:enabled] == false}
required
/>
<.provider_icon adapter={@adapter} class="w-8 h-8 ml-4" />
<label for={"idp-option-#{@adapter}"} class="block ml-2 text-lg text-neutral-900">
<%= @name %>
</label>
<%= if @opts[:enabled] == false do %>
<.link navigate={~p"/#{@account}/settings/billing"} class="ml-2 text-sm text-primary-500">
<.badge class="ml-2" type="primary" title="Feature available on a higher pricing plan">
<.icon name="hero-lock-closed" class="w-3.5 h-3.5 mr-1" /> UPGRADE TO UNLOCK
</.badge>
</.link>
<% end %>
<div
id={"idp-option-#{@adapter}"}
class={[
"component bg-white rounded",
"px-4 py-2 flex items-center",
"cursor-pointer hover:bg-gray-50",
"border border-neutral-200"
]}
phx-click="next_step"
phx-value-next={@adapter}
>
<div class="w-full">
<.provider_icon adapter={@adapter} class="w-10 h-10 inline-block mr-2" />
<span class="inline-block"><%= pretty_print_provider(@adapter) %></span>
</div>
<div :if={@opts[:sync] == true} class="w-1/2 flex justify-end">
<.icon name="hero-arrow-path" class="w-5 h-5 text-neutral-400" />
</div>
<p class="ml-6 mb-6 text-sm text-neutral-500">
<%= @description %>
</p>
</div>
"""
end
def next_step_path(:openid_connect, account) do
def next_step_path("openid_connect", account) do
~p"/#{account}/settings/identity_providers/openid_connect/new"
end
def next_step_path(:google_workspace, account) do
~p"/#{account}/settings/identity_providers/google_workspace/new"
def next_step_path("google_workspace" = provider, account) do
if Domain.Accounts.idp_sync_enabled?(account) do
~p"/#{account}/settings/identity_providers/google_workspace/new"
else
~p"/#{account}/settings/identity_providers/openid_connect/new?provider=#{provider}"
end
end
def next_step_path(:microsoft_entra, account) do
~p"/#{account}/settings/identity_providers/microsoft_entra/new"
def next_step_path("microsoft_entra" = provider, account) do
if Domain.Accounts.idp_sync_enabled?(account) do
~p"/#{account}/settings/identity_providers/microsoft_entra/new"
else
~p"/#{account}/settings/identity_providers/openid_connect/new?provider=#{provider}"
end
end
def next_step_path(:okta, account) do
~p"/#{account}/settings/identity_providers/okta/new"
def next_step_path("okta" = provider, account) do
if Domain.Accounts.idp_sync_enabled?(account) do
~p"/#{account}/settings/identity_providers/okta/new"
else
~p"/#{account}/settings/identity_providers/openid_connect/new?provider=#{provider}"
end
end
def pretty_print_provider(adapter) do
case adapter do
:openid_connect -> "OpenID Connect"
:google_workspace -> "Google Workspace"
:microsoft_entra -> "Microsoft EntraID"
:okta -> "Okta"
end
end
end

View File

@@ -1,6 +1,11 @@
defmodule Web.Settings.IdentityProviders.OpenIDConnect.Components do
use Web, :component_library
attr :id, :string
attr :account, :any
attr :form, :any
attr :show_sync_msg, :boolean, default: false
def provider_form(assigns) do
~H"""
<div class="max-w-2xl px-4 py-8 mx-auto lg:py-12">
@@ -95,12 +100,47 @@ defmodule Web.Settings.IdentityProviders.OpenIDConnect.Components do
</div>
</.inputs_for>
</div>
<.submit_button>
Connect Identity Provider
</.submit_button>
</:content>
</.step>
<.step :if={@show_sync_msg}>
<:title>
Step 3. Enable Directory Sync <.icon name="hero-arrow-path" class="w-5 h-5 ml-2" />
<span class="inline-flex w-1/2 justify-end">
<.link
navigate={~p"/#{@account}/settings/billing"}
class="text-sm text-primary-500 ml-2"
>
<.badge type="primary" title="Feature available on a higher pricing plan">
<.icon name="hero-lock-closed" class="w-3.5 h-3.5 mr-1" /> UPGRADE TO UNLOCK
</.badge>
</.link>
</span>
</:title>
<:content>
Directory sync is not enabled on your current plan.
<div class="blur-sm">
<p class="mb-4">
Ensure the following scopes are added to the OAuth application:
</p>
<.code_block
id="scope-fake"
class="w-full text-xs mb-4 whitespace-pre-line rounded"
phx-no-format
><%= "placeholder\nscopes\nfor\nsync" %></.code_block>
<p class="mb-4">
Placeholder for any additional instructions here:
</p>
<.code_block
id="placeholder-instructions"
class="w-full text-xs mb-4 whitespace-pre-line rounded"
phx-no-format
>placeholder instructions here</.code_block>
</div>
</:content>
</.step>
<.submit_button>
Connect Identity Provider
</.submit_button>
</.form>
</div>
"""

View File

@@ -3,7 +3,7 @@ defmodule Web.Settings.IdentityProviders.OpenIDConnect.New do
import Web.Settings.IdentityProviders.OpenIDConnect.Components
alias Domain.Auth
def mount(_params, _session, socket) do
def mount(params, _session, socket) do
id = Ecto.UUID.generate()
account = socket.assigns.account
@@ -17,7 +17,8 @@ defmodule Web.Settings.IdentityProviders.OpenIDConnect.New do
assign(socket,
id: id,
form: to_form(changeset),
page_title: "New Identity Provider"
page_title: "New Identity Provider",
provider: params["provider"]
)
{:ok, socket, temporary_assigns: [form: %Phoenix.HTML.Form{}]}
@@ -41,7 +42,7 @@ defmodule Web.Settings.IdentityProviders.OpenIDConnect.New do
Add a new OpenID Connect Identity Provider
</:title>
<:content>
<.provider_form account={@account} id={@id} form={@form} />
<.provider_form account={@account} id={@id} form={@form} show_sync_msg={!!@provider} />
</:content>
</.section>
"""

View File

@@ -44,8 +44,6 @@ defmodule Web.Live.Settings.IdentityProviders.NewTest do
assert has_element?(lv, "#idp-option-google_workspace")
assert html =~ "Google Workspace"
assert html =~ "Feature available on a higher pricing plan"
assert html =~ "UPGRADE TO UNLOCK"
assert has_element?(lv, "#idp-option-microsoft_entra")
assert html =~ "Microsoft Entra"
@@ -56,4 +54,53 @@ defmodule Web.Live.Settings.IdentityProviders.NewTest do
assert has_element?(lv, "#idp-option-openid_connect")
assert html =~ "OpenID Connect"
end
test "next step for non-idp-sync plans is OIDC form", %{
account: account,
identity: identity,
conn: conn
} do
{:ok, lv, _html} =
conn
|> authorize_conn(identity)
|> live(~p"/#{account}/settings/identity_providers/new")
lv
|> element("#idp-option-google_workspace")
|> render_click()
assert_redirect(
lv,
~p"/#{account}/settings/identity_providers/openid_connect/new?provider=google_workspace"
)
end
test "next step for idp-sync plans is to custom adapter form", %{
account: account,
identity: identity,
conn: conn
} do
Domain.Config.feature_flag_override(:idp_sync, true)
{:ok, account} =
Domain.Accounts.update_account(account, %{
features: %{
idp_sync: true
}
})
{:ok, lv, _html} =
conn
|> authorize_conn(identity)
|> live(~p"/#{account}/settings/identity_providers/new")
lv
|> element("#idp-option-google_workspace")
|> render_click()
assert_redirect(
lv,
~p"/#{account}/settings/identity_providers/google_workspace/new"
)
end
end

View File

@@ -35,7 +35,7 @@ defmodule Web.Live.Settings.IdentityProviders.OpenIDConnect.NewTest do
identity: identity,
conn: conn
} do
{:ok, lv, _html} =
{:ok, lv, html} =
conn
|> authorize_conn(identity)
|> live(~p"/#{account}/settings/identity_providers/openid_connect/new")
@@ -51,6 +51,36 @@ defmodule Web.Live.Settings.IdentityProviders.OpenIDConnect.NewTest do
"provider[adapter_config][scope]",
"provider[name]"
]
refute html =~ "Enable Directory Sync"
end
test "renders provider creation form with blurred sync step", %{
account: account,
identity: identity,
conn: conn
} do
{:ok, lv, html} =
conn
|> authorize_conn(identity)
|> live(
~p"/#{account}/settings/identity_providers/openid_connect/new?provider=google_workspace"
)
form = form(lv, "form")
assert find_inputs(form) == [
"provider[adapter_config][_persistent_id]",
"provider[adapter_config][client_id]",
"provider[adapter_config][client_secret]",
"provider[adapter_config][discovery_document_uri]",
"provider[adapter_config][response_type]",
"provider[adapter_config][scope]",
"provider[name]"
]
assert html =~ "Enable Directory Sync"
assert html =~ "UPGRADE TO UNLOCK"
end
test "creates a new provider on valid attrs", %{