enterprise/providers: Add RAC [AUTH-15] (#7291)

* add basic guacamole

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* make everything mostly work

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add rac build to CI

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix resize, fix web lint, sendSize correctly

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* pre-send connection from client, format

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* improve throughput

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* cleanup

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* rework TokenOutpostConsumer into middleware

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix some layout issues

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add outpost controllers

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* start testing audio things

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix a bunch of things

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add deps

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix to work with outpost group

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add simple loadbalancing

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add simple reconnect

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* show reconnecting text

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix error when checking ports

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* move to providers

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add flow check to interface

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix go lint

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix rac app label

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix audio

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add logging

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* cleanup

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* allow overriding all settings

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix duplicate keyboard, debug high DPI

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* re-add deps

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix lint

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix missing __init__.py breaking model loading

I love python

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix tests

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* bump successful ws connection to info

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* hide cursor since guac draws that

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add clipboard support (bidirectional)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* make codespell not want to break the code

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* run pr comment in separate task

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* start endpoint and property mapping stuff

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* more endpoint things

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* unrelated: fix event model_pk filtering with ints

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* unrelated: improve event display for changelog

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* rebuild endpoint stuff again

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* idk special url

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* more stuff, connect token with session

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add disconnect

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* rework disconnect

cleanly disconnect from guacd instead of just letting the connection timeout

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* clear cache when creating outpost

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* support host:port and fix protocol

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* center smaller viewport

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* rework connection to wait more and stop after some time

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add policy control to endpoints

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* remove provider protocol

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* don't switch to different outpost connection when already chosen

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* start using property mappings, add static settings

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add some RAC mapping settings

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix lint

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* start adding tests

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add tests for event changes

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add tests and fix issues found by said tests

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add preview banner, move endpoints to main page

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add locale

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* auto-select endpoint if only one is available

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* backport https://github.com/goauthentik/authentik/pull/7831 to rac

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* dont select property mappings on endpoints

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* make table modal only load when opened

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* only auto-redirect when open

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix web deps

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* check for token expiry and terminate session

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* re-add endpoint name to title

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* disconnect connection when token is manually deleted

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add initial RAC docs

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add connection expiry setting to provider

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix flaky tests

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Jens L
2023-12-30 21:33:14 +01:00
committed by GitHub
parent a365ec81f3
commit 240cf6dd94
95 changed files with 6398 additions and 144 deletions

View File

@ -21,6 +21,7 @@ import {
OutpostsServiceConnectionsAllListRequest,
PaginatedLDAPProviderList,
PaginatedProxyProviderList,
PaginatedRACProviderList,
PaginatedRadiusProviderList,
ProvidersApi,
ServiceConnection,
@ -38,7 +39,8 @@ export class OutpostForm extends ModelForm<Outpost, string> {
providers?:
| PaginatedProxyProviderList
| PaginatedLDAPProviderList
| PaginatedRadiusProviderList;
| PaginatedRadiusProviderList
| PaginatedRACProviderList;
defaultConfig?: OutpostDefaultConfig;
@ -73,6 +75,12 @@ export class OutpostForm extends ModelForm<Outpost, string> {
applicationIsnull: false,
});
break;
case OutpostTypeEnum.Rac:
this.providers = await new ProvidersApi(DEFAULT_CONFIG).providersRacList({
ordering: "name",
applicationIsnull: false,
});
break;
case OutpostTypeEnum.UnknownDefaultOpenApi:
this.providers = undefined;
}
@ -133,6 +141,12 @@ export class OutpostForm extends ModelForm<Outpost, string> {
>
${msg("Radius")}
</option>
<option
value=${OutpostTypeEnum.Rac}
?selected=${this.instance?.type === OutpostTypeEnum.Rac}
>
${msg("RAC")}
</option>
</select>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Integration")} name="serviceConnection">

View File

@ -41,6 +41,8 @@ export function TypeToLabel(type?: OutpostTypeEnum): string {
return msg("LDAP");
case OutpostTypeEnum.Radius:
return msg("Radius");
case OutpostTypeEnum.Rac:
return msg("RAC");
case OutpostTypeEnum.UnknownDefaultOpenApi:
return msg("Unknown type");
}

View File

@ -1,5 +1,6 @@
import "@goauthentik/admin/property-mappings/PropertyMappingLDAPForm";
import "@goauthentik/admin/property-mappings/PropertyMappingNotification";
import "@goauthentik/admin/property-mappings/PropertyMappingRACForm";
import "@goauthentik/admin/property-mappings/PropertyMappingSAMLForm";
import "@goauthentik/admin/property-mappings/PropertyMappingSCIMForm";
import "@goauthentik/admin/property-mappings/PropertyMappingScopeForm";

View File

@ -0,0 +1,195 @@
import { first } from "@goauthentik/app/common/utils";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { docLink } from "@goauthentik/common/global";
import "@goauthentik/elements/CodeMirror";
import { CodeMirrorMode } from "@goauthentik/elements/CodeMirror";
import "@goauthentik/elements/forms/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement";
import { ModelForm } from "@goauthentik/elements/forms/ModelForm";
import { msg } from "@lit/localize";
import { TemplateResult, html } from "lit";
import { customElement } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { PropertymappingsApi, RACPropertyMapping } from "@goauthentik/api";
@customElement("ak-property-mapping-rac-form")
export class PropertyMappingLDAPForm extends ModelForm<RACPropertyMapping, string> {
loadInstance(pk: string): Promise<RACPropertyMapping> {
return new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsRacRetrieve({
pmUuid: pk,
});
}
getSuccessMessage(): string {
if (this.instance) {
return msg("Successfully updated mapping.");
} else {
return msg("Successfully created mapping.");
}
}
async send(data: RACPropertyMapping): Promise<RACPropertyMapping> {
if (this.instance) {
return new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsRacUpdate({
pmUuid: this.instance.pk || "",
rACPropertyMappingRequest: data,
});
} else {
return new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsRacCreate({
rACPropertyMappingRequest: data,
});
}
}
renderForm(): TemplateResult {
return html`
<ak-form-element-horizontal label=${msg("Name")} ?required=${true} name="name">
<input
type="text"
value="${ifDefined(this.instance?.name)}"
class="pf-c-form-control"
required
/>
</ak-form-element-horizontal>
<ak-form-group .expanded=${true}>
<span slot="header"> ${msg("General settings")} </span>
<div slot="body" class="pf-c-form">
<ak-form-element-horizontal
label=${msg("Username")}
?required=${true}
name="staticSettings.username"
>
<input
type="text"
value="${ifDefined(this.instance?.staticSettings.username)}"
class="pf-c-form-control"
required
/>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Password")}
?required=${true}
name="staticSettings.password"
>
<input
type="password"
value="${ifDefined(this.instance?.staticSettings.password)}"
class="pf-c-form-control"
required
/>
</ak-form-element-horizontal>
</div>
</ak-form-group>
<ak-form-group>
<span slot="header"> ${msg("RDP settings")} </span>
<div slot="body" class="pf-c-form">
<ak-form-element-horizontal name="staticSettings.ignore-cert">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${first(
this.instance?.staticSettings["ignore-cert"],
false,
)}
/>
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"></i>
</span>
</span>
<span class="pf-c-switch__label"
>${msg("Ignore server certificate")}</span
>
</label>
</ak-form-element-horizontal>
<ak-form-element-horizontal name="staticSettings.enable-wallpaper">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${first(
this.instance?.staticSettings["enable-wallpaper"],
false,
)}
/>
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"></i>
</span>
</span>
<span class="pf-c-switch__label">${msg("Enable wallpaper")}</span>
</label>
</ak-form-element-horizontal>
<ak-form-element-horizontal name="staticSettings.enable-font-smoothing">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${first(
this.instance?.staticSettings["enable-font-smoothing"],
false,
)}
/>
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"></i>
</span>
</span>
<span class="pf-c-switch__label">${msg("Enable font-smoothing")}</span>
</label>
</ak-form-element-horizontal>
<ak-form-element-horizontal name="staticSettings.enable-full-window-drag">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${first(
this.instance?.staticSettings["enable-full-window-drag"],
false,
)}
/>
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"></i>
</span>
</span>
<span class="pf-c-switch__label"
>${msg("Enable full window dragging")}</span
>
</label>
</ak-form-element-horizontal>
</div>
</ak-form-group>
<ak-form-group>
<span slot="header"> ${msg("Advanced settings")} </span>
<div slot="body" class="pf-c-form">
<ak-form-element-horizontal
label=${msg("Expression")}
?required=${true}
name="expression"
>
<ak-codemirror
mode=${CodeMirrorMode.Python}
value="${ifDefined(this.instance?.expression)}"
>
</ak-codemirror>
<p class="pf-c-form__helper-text">
${msg("Expression using Python.")}
<a
target="_blank"
href="${docLink(
"/docs/property-mappings/expression?utm_source=authentik",
)}"
>
${msg("See documentation for a list of all variables.")}
</a>
</p>
</ak-form-element-horizontal>
</div>
</ak-form-group>
`;
}
}

View File

@ -1,5 +1,6 @@
import "@goauthentik/admin/property-mappings/PropertyMappingLDAPForm";
import "@goauthentik/admin/property-mappings/PropertyMappingNotification";
import "@goauthentik/admin/property-mappings/PropertyMappingRACForm";
import "@goauthentik/admin/property-mappings/PropertyMappingSAMLForm";
import "@goauthentik/admin/property-mappings/PropertyMappingScopeForm";
import "@goauthentik/admin/property-mappings/PropertyMappingTestForm";

View File

@ -3,6 +3,7 @@ import "@goauthentik/admin/providers/ProviderWizard";
import "@goauthentik/admin/providers/ldap/LDAPProviderForm";
import "@goauthentik/admin/providers/oauth2/OAuth2ProviderForm";
import "@goauthentik/admin/providers/proxy/ProxyProviderForm";
import "@goauthentik/admin/providers/rac/RACProviderForm";
import "@goauthentik/admin/providers/radius/RadiusProviderForm";
import "@goauthentik/admin/providers/saml/SAMLProviderForm";
import "@goauthentik/admin/providers/scim/SCIMProviderForm";

View File

@ -1,6 +1,7 @@
import "@goauthentik/admin/providers/ldap/LDAPProviderViewPage";
import "@goauthentik/admin/providers/oauth2/OAuth2ProviderViewPage";
import "@goauthentik/admin/providers/proxy/ProxyProviderViewPage";
import "@goauthentik/admin/providers/rac/RACProviderViewPage";
import "@goauthentik/admin/providers/radius/RadiusProviderViewPage";
import "@goauthentik/admin/providers/saml/SAMLProviderViewPage";
import "@goauthentik/admin/providers/scim/SCIMProviderViewPage";
@ -65,6 +66,10 @@ export class ProviderViewPage extends AKElement {
return html`<ak-provider-radius-view
providerID=${ifDefined(this.provider.pk)}
></ak-provider-radius-view>`;
case "ak-provider-rac-form":
return html`<ak-provider-rac-view
providerID=${ifDefined(this.provider.pk)}
></ak-provider-rac-view>`;
default:
return html`<p>Invalid provider type ${this.provider?.component}</p>`;
}

View File

@ -0,0 +1,146 @@
import { first } from "@goauthentik/app/common/utils";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import "@goauthentik/components/ak-radio-input";
import "@goauthentik/elements/CodeMirror";
import "@goauthentik/elements/forms/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement";
import { ModelForm } from "@goauthentik/elements/forms/ModelForm";
import YAML from "yaml";
import { msg } from "@lit/localize";
import { TemplateResult, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import {
AuthModeEnum,
Endpoint,
PaginatedRACPropertyMappingList,
PropertymappingsApi,
ProtocolEnum,
RacApi,
} from "@goauthentik/api";
@customElement("ak-rac-endpoint-form")
export class EndpointForm extends ModelForm<Endpoint, string> {
@property({ type: Number })
providerID?: number;
propertyMappings?: PaginatedRACPropertyMappingList;
async load(): Promise<void> {
this.propertyMappings = await new PropertymappingsApi(
DEFAULT_CONFIG,
).propertymappingsRacList({
ordering: "name",
});
}
loadInstance(pk: string): Promise<Endpoint> {
return new RacApi(DEFAULT_CONFIG).racEndpointsRetrieve({
pbmUuid: pk,
});
}
getSuccessMessage(): string {
return this.instance
? msg("Successfully updated endpoint.")
: msg("Successfully created endpoint.");
}
async send(data: Endpoint): Promise<Endpoint> {
data.authMode = AuthModeEnum.Prompt;
if (!this.instance) {
data.provider = this.providerID || 0;
} else {
data.provider = this.instance.provider;
}
if (this.instance) {
return new RacApi(DEFAULT_CONFIG).racEndpointsPartialUpdate({
pbmUuid: this.instance.pk || "",
patchedEndpointRequest: data,
});
} else {
return new RacApi(DEFAULT_CONFIG).racEndpointsCreate({
endpointRequest: data,
});
}
}
renderForm(): TemplateResult {
return html`
<ak-form-element-horizontal label=${msg("Name")} name="name" ?required=${true}>
<input
type="text"
value="${ifDefined(this.instance?.name)}"
class="pf-c-form-control"
required
/>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Protocol")} ?required=${true} name="protocol">
<ak-radio
.options=${[
{
label: msg("RDP"),
value: ProtocolEnum.Rdp,
},
{
label: msg("SSH"),
value: ProtocolEnum.Ssh,
},
{
label: msg("VNC"),
value: ProtocolEnum.Vnc,
},
]}
.value=${this.instance?.protocol}
>
</ak-radio>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Host")} name="host" ?required=${true}>
<input
type="text"
value="${ifDefined(this.instance?.host)}"
class="pf-c-form-control"
required
/>
<p class="pf-c-form__helper-text">${msg("Hostname/IP to connect to.")}</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Property mappings")}
?required=${true}
name="propertyMappings"
>
<select class="pf-c-form-control" multiple>
${this.propertyMappings?.results.map((mapping) => {
let selected = false;
if (this.instance?.propertyMappings) {
selected = Array.from(this.instance?.propertyMappings).some((su) => {
return su == mapping.pk;
});
}
return html`<option value=${ifDefined(mapping.pk)} ?selected=${selected}>
${mapping.name}
</option>`;
})}
</select>
<p class="pf-c-form__helper-text">
${msg("Hold control/command to select multiple items.")}
</p>
</ak-form-element-horizontal>
<ak-form-group>
<span slot="header"> ${msg("Advanced settings")} </span>
<div slot="body" class="pf-c-form">
<ak-form-element-horizontal label=${msg("Settings")} name="settings">
<ak-codemirror
mode="yaml"
value="${YAML.stringify(first(this.instance?.settings, {}))}"
>
</ak-codemirror>
<p class="pf-c-form__helper-text">${msg("Connection settings.")}</p>
</ak-form-element-horizontal>
</div>
</ak-form-group>
`;
}
}

View File

@ -0,0 +1,142 @@
import "@goauthentik/admin/policies/BoundPoliciesList";
import "@goauthentik/app/admin/providers/rac/EndpointForm";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { uiConfig } from "@goauthentik/common/ui/config";
import "@goauthentik/elements/buttons/SpinnerButton";
import "@goauthentik/elements/forms/DeleteBulkForm";
import "@goauthentik/elements/forms/ModalForm";
import "@goauthentik/elements/rbac/ObjectPermissionModal";
import { PaginatedResponse, Table } from "@goauthentik/elements/table/Table";
import { TableColumn } from "@goauthentik/elements/table/Table";
import "@patternfly/elements/pf-tooltip/pf-tooltip.js";
import { msg } from "@lit/localize";
import { CSSResult, TemplateResult, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css";
import {
Endpoint,
RACProvider,
RacApi,
RbacPermissionsAssignedByUsersListModelEnum,
} from "@goauthentik/api";
@customElement("ak-rac-endpoint-list")
export class EndpointListPage extends Table<Endpoint> {
expandable = true;
checkbox = true;
searchEnabled(): boolean {
return true;
}
@property()
order = "name";
@property({ attribute: false })
provider?: RACProvider;
static get styles(): CSSResult[] {
return super.styles.concat(PFDescriptionList);
}
async apiEndpoint(page: number): Promise<PaginatedResponse<Endpoint>> {
return new RacApi(DEFAULT_CONFIG).racEndpointsList({
ordering: this.order,
page: page,
pageSize: (await uiConfig()).pagination.perPage,
search: this.search || "",
provider: this.provider?.pk,
superuserFullList: true,
});
}
columns(): TableColumn[] {
return [
new TableColumn(msg("Name"), "name"),
new TableColumn(msg("Host"), "host"),
new TableColumn(msg("Actions")),
];
}
renderToolbarSelected(): TemplateResult {
const disabled = this.selectedElements.length < 1;
return html`<ak-forms-delete-bulk
objectLabel=${msg("Endpoint(s)")}
.objects=${this.selectedElements}
.metadata=${(item: Endpoint) => {
return [
{ key: msg("Name"), value: item.name },
{ key: msg("Host"), value: item.host },
];
}}
.usedBy=${(item: Endpoint) => {
return new RacApi(DEFAULT_CONFIG).racEndpointsUsedByList({
pbmUuid: item.pk,
});
}}
.delete=${(item: Endpoint) => {
return new RacApi(DEFAULT_CONFIG).racEndpointsDestroy({
pbmUuid: item.pk,
});
}}
>
<button ?disabled=${disabled} slot="trigger" class="pf-c-button pf-m-danger">
${msg("Delete")}
</button>
</ak-forms-delete-bulk>`;
}
row(item: Endpoint): TemplateResult[] {
return [
html`${item.name}`,
html`${item.host}`,
html`<ak-forms-modal>
<span slot="submit"> ${msg("Update")} </span>
<span slot="header"> ${msg("Update Endpoint")} </span>
<ak-rac-endpoint-form slot="form" .instancePk=${item.pk}>
</ak-rac-endpoint-form>
<button slot="trigger" class="pf-c-button pf-m-plain">
<pf-tooltip position="top" content=${msg("Edit")}>
<i class="fas fa-edit"></i>
</pf-tooltip>
</button>
</ak-forms-modal>
<ak-rbac-object-permission-modal
model=${RbacPermissionsAssignedByUsersListModelEnum.ProvidersRacEndpoint}
objectPk=${item.pk}
>
</ak-rbac-object-permission-modal>`,
];
}
renderExpanded(item: Endpoint): TemplateResult {
return html` <td></td>
<td role="cell" colspan="4">
<div class="pf-c-table__expandable-row-content">
<div class="pf-c-content">
<p>
${msg(
"These bindings control which users will have access to this endpoint. Users must also have access to the application.",
)}
</p>
<ak-bound-policies-list .target=${item.pk}> </ak-bound-policies-list>
</div>
</div>
</td>`;
}
renderObjectCreate(): TemplateResult {
return html`
<ak-forms-modal>
<span slot="submit"> ${msg("Create")} </span>
<span slot="header"> ${msg("Create Endpoint")} </span>
<ak-rac-endpoint-form slot="form" .providerID=${this.provider?.pk}>
</ak-rac-endpoint-form>
<button slot="trigger" class="pf-c-button pf-m-primary">${msg("Create")}</button>
</ak-forms-modal>
`;
}
}

View File

@ -0,0 +1,158 @@
import "@goauthentik/admin/common/ak-crypto-certificate-search";
import "@goauthentik/admin/common/ak-flow-search/ak-tenanted-flow-search";
import { first } from "@goauthentik/app/common/utils";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import "@goauthentik/elements/CodeMirror";
import "@goauthentik/elements/forms/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement";
import { ModelForm } from "@goauthentik/elements/forms/ModelForm";
import "@goauthentik/elements/forms/Radio";
import "@goauthentik/elements/forms/SearchSelect";
import "@goauthentik/elements/utils/TimeDeltaHelp";
import YAML from "yaml";
import { msg } from "@lit/localize";
import { TemplateResult, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import {
FlowsInstancesListDesignationEnum,
PaginatedEndpointList,
PaginatedRACPropertyMappingList,
PropertymappingsApi,
ProvidersApi,
RACProvider,
RacApi,
} from "@goauthentik/api";
@customElement("ak-provider-rac-form")
export class RACProviderFormPage extends ModelForm<RACProvider, number> {
@state()
endpoints?: PaginatedEndpointList;
propertyMappings?: PaginatedRACPropertyMappingList;
async load(): Promise<void> {
this.endpoints = await new RacApi(DEFAULT_CONFIG).racEndpointsList({});
this.propertyMappings = await new PropertymappingsApi(
DEFAULT_CONFIG,
).propertymappingsRacList({
ordering: "name",
});
}
async loadInstance(pk: number): Promise<RACProvider> {
return new ProvidersApi(DEFAULT_CONFIG).providersRacRetrieve({
id: pk,
});
}
getSuccessMessage(): string {
if (this.instance) {
return msg("Successfully updated provider.");
} else {
return msg("Successfully created provider.");
}
}
async send(data: RACProvider): Promise<RACProvider> {
if (this.instance) {
return new ProvidersApi(DEFAULT_CONFIG).providersRacUpdate({
id: this.instance.pk || 0,
rACProviderRequest: data,
});
} else {
return new ProvidersApi(DEFAULT_CONFIG).providersRacCreate({
rACProviderRequest: data,
});
}
}
renderForm(): TemplateResult {
return html`
<ak-form-element-horizontal label=${msg("Name")} ?required=${true} name="name">
<input
type="text"
value="${ifDefined(this.instance?.name)}"
class="pf-c-form-control"
required
/>
</ak-form-element-horizontal>
<ak-form-element-horizontal
name="authorizationFlow"
label=${msg("Authorization flow")}
?required=${true}
>
<ak-flow-search
flowType=${FlowsInstancesListDesignationEnum.Authorization}
.currentFlow=${this.instance?.authorizationFlow}
required
></ak-flow-search>
<p class="pf-c-form__helper-text">
${msg("Flow used when authorizing this provider.")}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Connection expiry")}
?required=${true}
name="connectionExpiry"
>
<input
type="text"
value="${first(this.instance?.connectionExpiry, "hours=8")}"
class="pf-c-form-control"
required
/>
<p class="pf-c-form__helper-text">
${msg(
"Determines how long a session lasts before being disconnected and requiring re-authorization.",
)}
</p>
<ak-utils-time-delta-help></ak-utils-time-delta-help>
</ak-form-element-horizontal>
<ak-form-group .expanded=${true}>
<span slot="header"> ${msg("Protocol settings")} </span>
<div slot="body" class="pf-c-form">
<ak-form-element-horizontal
label=${msg("Property mappings")}
?required=${true}
name="propertyMappings"
>
<select class="pf-c-form-control" multiple>
${this.propertyMappings?.results.map((mapping) => {
let selected = false;
if (this.instance?.propertyMappings) {
selected = Array.from(this.instance?.propertyMappings).some(
(su) => {
return su == mapping.pk;
},
);
}
return html`<option
value=${ifDefined(mapping.pk)}
?selected=${selected}
>
${mapping.name}
</option>`;
})}
</select>
<p class="pf-c-form__helper-text">
${msg("Hold control/command to select multiple items.")}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Settings")} name="settings">
<ak-codemirror
mode="yaml"
value="${YAML.stringify(first(this.instance?.settings, {}))}"
>
</ak-codemirror>
<p class="pf-c-form__helper-text">${msg("Connection settings.")}</p>
</ak-form-element-horizontal>
</div>
</ak-form-group>
`;
}
}

View File

@ -0,0 +1,181 @@
import "@goauthentik/admin/providers/RelatedApplicationButton";
import "@goauthentik/admin/providers/rac/EndpointForm";
import "@goauthentik/admin/providers/rac/EndpointList";
import "@goauthentik/admin/providers/rac/RACProviderForm";
import "@goauthentik/app/elements/rbac/ObjectPermissionsPage";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { EVENT_REFRESH } from "@goauthentik/common/constants";
import "@goauthentik/components/ak-status-label";
import "@goauthentik/components/events/ObjectChangelog";
import { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/CodeMirror";
import "@goauthentik/elements/Tabs";
import "@goauthentik/elements/buttons/ModalButton";
import "@goauthentik/elements/buttons/SpinnerButton";
import { msg } from "@lit/localize";
import { CSSResult, TemplateResult, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import PFBanner from "@patternfly/patternfly/components/Banner/banner.css";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFCard from "@patternfly/patternfly/components/Card/card.css";
import PFContent from "@patternfly/patternfly/components/Content/content.css";
import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css";
import PFForm from "@patternfly/patternfly/components/Form/form.css";
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
import PFList from "@patternfly/patternfly/components/List/list.css";
import PFPage from "@patternfly/patternfly/components/Page/page.css";
import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import {
ProvidersApi,
RACProvider,
RbacPermissionsAssignedByUsersListModelEnum,
} from "@goauthentik/api";
@customElement("ak-provider-rac-view")
export class RACProviderViewPage extends AKElement {
@property()
set args(value: { [key: string]: number }) {
this.providerID = value.id;
}
@property({ type: Number })
set providerID(value: number) {
new ProvidersApi(DEFAULT_CONFIG)
.providersRacRetrieve({
id: value,
})
.then((prov) => (this.provider = prov));
}
@property({ attribute: false })
provider?: RACProvider;
static get styles(): CSSResult[] {
return [
PFBase,
PFButton,
PFPage,
PFGrid,
PFContent,
PFList,
PFForm,
PFFormControl,
PFCard,
PFDescriptionList,
PFBanner,
];
}
constructor() {
super();
this.addEventListener(EVENT_REFRESH, () => {
if (!this.provider?.pk) return;
this.providerID = this.provider?.pk;
});
}
render(): TemplateResult {
if (!this.provider) {
return html``;
}
return html`<ak-tabs>
<section slot="page-overview" data-tab-title="${msg("Overview")}">
${this.renderTabOverview()}
</section>
<section
slot="page-changelog"
data-tab-title="${msg("Changelog")}"
class="pf-c-page__main-section pf-m-no-padding-mobile"
>
<div class="pf-c-card">
<div class="pf-c-card__body">
<ak-object-changelog
targetModelPk=${this.provider?.pk || ""}
targetModelName=${this.provider?.metaModelName || ""}
>
</ak-object-changelog>
</div>
</div>
</section>
<ak-rbac-object-permission-page
slot="page-permissions"
data-tab-title="${msg("Permissions")}"
model=${RbacPermissionsAssignedByUsersListModelEnum.ProvidersRacRacprovider}
objectPk=${this.provider.pk}
></ak-rbac-object-permission-page>
</ak-tabs>`;
}
renderTabOverview(): TemplateResult {
if (!this.provider) {
return html``;
}
return html` <div slot="header" class="pf-c-banner pf-m-info">
${msg("RAC is in preview.")}
<a href="mailto:hello+feature/rac@goauthentik.io">${msg("Send us feedback!")}</a>
</div>
${this.provider?.assignedApplicationName
? html``
: html`<div slot="header" class="pf-c-banner pf-m-warning">
${msg("Warning: Provider is not used by an Application.")}
</div>`}
${this.provider?.outpostSet.length < 1
? html`<div slot="header" class="pf-c-banner pf-m-warning">
${msg("Warning: Provider is not used by any Outpost.")}
</div>`
: html``}
<div class="pf-c-page__main-section pf-m-no-padding-mobile pf-l-grid pf-m-gutter">
<div class="pf-c-card pf-l-grid__item pf-m-12-col">
<div class="pf-c-card__body">
<dl class="pf-c-description-list pf-m-3-col-on-lg">
<div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text">${msg("Name")}</span>
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">
${this.provider.name}
</div>
</dd>
</div>
<div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text"
>${msg("Assigned to application")}</span
>
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">
<ak-provider-related-application
.provider=${this.provider}
></ak-provider-related-application>
</div>
</dd>
</div>
</dl>
</div>
<div class="pf-c-card__footer">
<ak-forms-modal>
<span slot="submit"> ${msg("Update")} </span>
<span slot="header"> ${msg("Update RAC Provider")} </span>
<ak-provider-rac-form slot="form" .instancePk=${this.provider.pk || 0}>
</ak-provider-rac-form>
<button slot="trigger" class="pf-c-button pf-m-primary">
${msg("Edit")}
</button>
</ak-forms-modal>
</div>
</div>
<div class="pf-c-card pf-l-grid__item pf-m-12-col">
<div class="pf-c-card__title">${msg("Endpoints")}</div>
<div class="pf-c-card__body">
<ak-rac-endpoint-list .provider=${this.provider}> </ak-rac-endpoint-list>
</div>
</div>
</div>`;
}
}

View File

@ -1,3 +1,5 @@
import { EventGeo } from "@goauthentik/app/admin/events/utils";
import { actionToLabel } from "@goauthentik/app/common/labels";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { EventWithContext } from "@goauthentik/common/events";
import { uiConfig } from "@goauthentik/common/ui/config";
@ -73,7 +75,7 @@ export class ObjectChangelog extends Table<Event> {
row(item: EventWithContext): TemplateResult[] {
return [
html`${item.action}`,
html`${actionToLabel(item.action)}`,
html`<div>${item.user?.username}</div>
${item.user.on_behalf_of
? html`<small>
@ -81,7 +83,9 @@ export class ObjectChangelog extends Table<Event> {
</small>`
: html``}`,
html`<span>${item.created?.toLocaleString()}</span>`,
html`<span>${item.clientIp || msg("-")}</span>`,
html`<div>${item.clientIp || msg("-")}</div>
<small>${EventGeo(item)}</small>`,
];
}

View File

@ -1,5 +1,5 @@
import { AKElement } from "@goauthentik/elements/Base";
import { PFSize } from "@goauthentik/elements/Spinner";
import "@goauthentik/elements/EmptyState";
import { CSSResult, TemplateResult, css, html } from "lit";
import { customElement, property } from "lit/decorators.js";
@ -33,6 +33,8 @@ export class LoadingOverlay extends AKElement {
}
render(): TemplateResult {
return html`<ak-spinner size=${PFSize.XLarge}></ak-spinner>`;
return html`<ak-empty-state ?loading="${true}">
<slot name="body" slot="body"></slot>
</ak-empty-state>`;
}
}

View File

@ -27,6 +27,11 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { Pagination, ResponseError } from "@goauthentik/api";
export interface TableLike {
order?: string;
fetch: () => void;
}
export class TableColumn {
title: string;
orderBy?: string;
@ -38,7 +43,7 @@ export class TableColumn {
this.orderBy = orderBy;
}
headerClickHandler(table: Table<unknown>): void {
headerClickHandler(table: TableLike): void {
if (!this.orderBy) {
return;
}
@ -46,7 +51,7 @@ export class TableColumn {
table.fetch();
}
private getSortIndicator(table: Table<unknown>): string {
private getSortIndicator(table: TableLike): string {
switch (table.order) {
case this.orderBy:
return "fa-long-arrow-alt-down";
@ -57,7 +62,7 @@ export class TableColumn {
}
}
renderSortable(table: Table<unknown>): TemplateResult {
renderSortable(table: TableLike): TemplateResult {
return html` <button
class="pf-c-table__button"
@click=${() => this.headerClickHandler(table)}
@ -71,7 +76,7 @@ export class TableColumn {
</button>`;
}
render(table: Table<unknown>): TemplateResult {
render(table: TableLike): TemplateResult {
const classes = {
"pf-c-table__sort": !!this.orderBy,
"pf-m-selected": table.order === this.orderBy || table.order === `-${this.orderBy}`,
@ -89,7 +94,7 @@ export interface PaginatedResponse<T> {
results: Array<T>;
}
export abstract class Table<T> extends AKElement {
export abstract class Table<T> extends AKElement implements TableLike {
abstract apiEndpoint(page: number): Promise<PaginatedResponse<T>>;
abstract columns(): TableColumn[];
abstract row(item: T): TemplateResult[];
@ -123,6 +128,12 @@ export abstract class Table<T> extends AKElement {
@property({ type: Boolean })
checkbox = false;
@property({ type: Boolean })
clickable = false;
@property({ attribute: false })
clickHandler: (item: T) => void = () => {};
@property({ type: Boolean })
radioSelect = false;
@ -356,8 +367,12 @@ export abstract class Table<T> extends AKElement {
return html`<tbody role="rowgroup" class="${classMap(expandedClass)}">
<tr
role="row"
class="${this.checkbox ? "pf-m-hoverable" : ""}"
@click=${itemSelectHandler}
class="${this.checkbox || this.clickable ? "pf-m-hoverable" : ""}"
@click=${this.clickable
? () => {
this.clickHandler(item);
}
: itemSelectHandler}
>
${this.checkbox ? renderCheckbox() : html``}
${this.expandable ? renderExpansion() : html``}

View File

@ -19,7 +19,18 @@ export abstract class TableModal<T> extends Table<T> {
size: PFSize = PFSize.Large;
@property({ type: Boolean })
open = false;
set open(value: boolean) {
this._open = value;
if (value) {
this.fetch();
}
}
get open(): boolean {
return this._open;
}
_open = false;
static get styles(): CSSResult[] {
return super.styles.concat(
@ -43,6 +54,13 @@ export abstract class TableModal<T> extends Table<T> {
});
}
public async fetch(): Promise<void> {
if (!this.open) {
return;
}
return super.fetch();
}
resetForms(): void {
this.querySelectorAll<HTMLFormElement>("[slot=form]").forEach((form) => {
if ("resetForm" in form) {

View File

@ -0,0 +1,324 @@
import { TITLE_DEFAULT } from "@goauthentik/app/common/constants";
import { Interface } from "@goauthentik/elements/Base";
import "@goauthentik/elements/LoadingOverlay";
import Guacamole from "guacamole-common-js";
import { msg, str } from "@lit/localize";
import { CSSResult, TemplateResult, css, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import AKGlobal from "@goauthentik/common/styles/authentik.css";
import PFContent from "@patternfly/patternfly/components/Content/content.css";
import PFPage from "@patternfly/patternfly/components/Page/page.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
enum GuacClientState {
IDLE = 0,
CONNECTING = 1,
WAITING = 2,
CONNECTED = 3,
DISCONNECTING = 4,
DISCONNECTED = 5,
}
const AUDIO_INPUT_MIMETYPE = "audio/L16;rate=44100,channels=2";
const RECONNECT_ATTEMPTS_INITIAL = 5;
const RECONNECT_ATTEMPTS = 5;
@customElement("ak-rac")
export class RacInterface extends Interface {
static get styles(): CSSResult[] {
return [
PFBase,
PFPage,
PFContent,
AKGlobal,
css`
:host {
cursor: none;
}
canvas {
z-index: unset !important;
}
.container {
overflow: hidden;
height: 100vh;
background-color: black;
display: flex;
justify-content: center;
align-items: center;
}
ak-loading-overlay {
z-index: 5;
}
`,
];
}
client?: Guacamole.Client;
tunnel?: Guacamole.Tunnel;
@state()
container?: HTMLElement;
@state()
clientState?: GuacClientState;
@state()
reconnectingMessage = "";
@property()
token?: string;
@property()
endpointName?: string;
@state()
clipboardWatcherTimer = 0;
_previousClipboardValue: unknown;
// Set to `true` if we've successfully connected once
hasConnected = false;
// Keep track of current connection attempt
connectionAttempt = 0;
static domSize(): DOMRect {
return document.body.getBoundingClientRect();
}
constructor() {
super();
this.initKeyboard();
this.checkClipboard();
this.clipboardWatcherTimer = setInterval(
this.checkClipboard.bind(this),
500,
) as unknown as number;
}
connectedCallback(): void {
super.connectedCallback();
window.addEventListener(
"focus",
() => {
this.checkClipboard();
},
{
capture: false,
},
);
window.addEventListener("resize", () => {
this.client?.sendSize(
Math.floor(RacInterface.domSize().width),
Math.floor(RacInterface.domSize().height),
);
});
}
disconnectedCallback(): void {
super.disconnectedCallback();
clearInterval(this.clipboardWatcherTimer);
}
async firstUpdated(): Promise<void> {
this.updateTitle();
const wsUrl = `${window.location.protocol.replace("http", "ws")}//${
window.location.host
}/ws/rac/${this.token}/`;
this.tunnel = new Guacamole.WebSocketTunnel(wsUrl);
this.tunnel.receiveTimeout = 10 * 1000; // 10 seconds
this.tunnel.onerror = (status) => {
console.debug("authentik/rac: tunnel error: ", status);
this.reconnect();
};
this.client = new Guacamole.Client(this.tunnel);
this.client.onerror = (err) => {
console.debug("authentik/rac: error: ", err);
this.reconnect();
};
this.client.onstatechange = (state) => {
this.clientState = state;
if (state === GuacClientState.CONNECTED) {
this.onConnected();
}
};
this.client.onclipboard = (stream, mimetype) => {
// If the received data is text, read it as a simple string
if (/^text\//.exec(mimetype)) {
const reader = new Guacamole.StringReader(stream);
let data = "";
reader.ontext = (text) => {
data += text;
};
reader.onend = () => {
this._previousClipboardValue = data;
navigator.clipboard.writeText(data);
};
} else {
const reader = new Guacamole.BlobReader(stream, mimetype);
reader.onend = () => {
const blob = reader.getBlob();
navigator.clipboard.write([
new ClipboardItem({
[blob.type]: blob,
}),
]);
};
}
console.debug("authentik/rac: updated clipboard from remote");
};
const params = new URLSearchParams();
params.set("screen_width", Math.floor(RacInterface.domSize().width).toString());
params.set("screen_height", Math.floor(RacInterface.domSize().height).toString());
params.set("screen_dpi", (window.devicePixelRatio * 96).toString());
this.client.connect(params.toString());
}
reconnect(): void {
this.clientState = undefined;
this.connectionAttempt += 1;
if (!this.hasConnected) {
// Check connection attempts if we haven't had a successful connection
if (this.connectionAttempt >= RECONNECT_ATTEMPTS_INITIAL) {
this.hasConnected = true;
this.reconnectingMessage = msg(
str`Connection failed after ${this.connectionAttempt} attempts.`,
);
return;
}
} else {
if (this.connectionAttempt >= RECONNECT_ATTEMPTS) {
this.reconnectingMessage = msg(
str`Connection failed after ${this.connectionAttempt} attempts.`,
);
return;
}
}
const delay = 500 * this.connectionAttempt;
this.reconnectingMessage = msg(
str`Re-connecting in ${Math.max(1, delay / 1000)} second(s).`,
);
setTimeout(() => {
this.firstUpdated();
}, delay);
}
updateTitle(): void {
let title = this.tenant?.brandingTitle || TITLE_DEFAULT;
if (this.endpointName) {
title = `${this.endpointName} - ${title}`;
}
document.title = `${title}`;
}
onConnected(): void {
console.debug("authentik/rac: connected");
if (!this.client) {
return;
}
this.hasConnected = true;
this.container = this.client.getDisplay().getElement();
this.initMouse(this.container);
this.client?.sendSize(
Math.floor(RacInterface.domSize().width),
Math.floor(RacInterface.domSize().height),
);
}
initMouse(container: HTMLElement): void {
const mouse = new Guacamole.Mouse(container);
const handler = (mouseState: Guacamole.Mouse.State, scaleMouse = false) => {
if (!this.client) return;
if (scaleMouse) {
mouseState.y = mouseState.y / this.client.getDisplay().getScale();
mouseState.x = mouseState.x / this.client.getDisplay().getScale();
}
this.client.sendMouseState(mouseState);
};
mouse.onmouseup = mouse.onmousedown = (mouseState) => {
this.container?.focus();
handler(mouseState);
};
mouse.onmousemove = (mouseState) => {
handler(mouseState, true);
};
}
initAudioInput(): void {
const stream = this.client?.createAudioStream(AUDIO_INPUT_MIMETYPE);
if (!stream) return;
// Guacamole.AudioPlayer
const recorder = Guacamole.AudioRecorder.getInstance(stream, AUDIO_INPUT_MIMETYPE);
// If creation of the AudioRecorder failed, simply end the stream
if (!recorder) {
stream.sendEnd();
return;
}
// Otherwise, ensure that another audio stream is created after this
// audio stream is closed
recorder.onclose = this.initAudioInput.bind(this);
}
initKeyboard(): void {
const keyboard = new Guacamole.Keyboard(document);
keyboard.onkeydown = (keysym) => {
this.client?.sendKeyEvent(1, keysym);
};
keyboard.onkeyup = (keysym) => {
this.client?.sendKeyEvent(0, keysym);
};
}
async checkClipboard(): Promise<void> {
try {
if (!this._previousClipboardValue) {
this._previousClipboardValue = await navigator.clipboard.readText();
return;
}
const newValue = await navigator.clipboard.readText();
if (newValue !== this._previousClipboardValue) {
console.debug(`authentik/rac: new clipboard value: ${newValue}`);
this._previousClipboardValue = newValue;
this.writeClipboard(newValue);
}
} catch (ex) {
// The error is most likely caused by the document not being in focus
// in which case we can ignore it and just retry
if (ex instanceof DOMException) {
return;
}
console.warn("authentik/rac: error reading clipboard", ex);
}
}
private writeClipboard(value: string) {
if (!this.client) {
return;
}
const stream = this.client.createClipboardStream("text/plain", "clipboard");
const writer = new Guacamole.StringWriter(stream);
writer.sendText(value);
writer.sendEnd();
console.debug("authentik/rac: Sent clipboard");
}
render(): TemplateResult {
return html`
${this.clientState !== GuacClientState.CONNECTED
? html`
<ak-loading-overlay>
<span slot="body">
${this.hasConnected
? html`${this.reconnectingMessage}`
: html`${msg("Connecting...")}`}
</span>
</ak-loading-overlay>
`
: html``}
<div class="container">${this.container}</div>
`;
}
}

View File

@ -0,0 +1,71 @@
import { DEFAULT_CONFIG } from "@goauthentik/app/common/api/config";
import { PaginatedResponse, TableColumn } from "@goauthentik/app/elements/table/Table";
import { TableModal } from "@goauthentik/app/elements/table/TableModal";
import { msg } from "@lit/localize";
import { TemplateResult, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Application, Endpoint, RacApi } from "@goauthentik/api";
@customElement("ak-library-rac-endpoint-launch")
export class RACLaunchEndpointModal extends TableModal<Endpoint> {
clickable = true;
searchEnabled(): boolean {
return true;
}
clickHandler = (item: Endpoint) => {
if (!item.launchUrl) {
return;
}
if (this.app?.openInNewTab) {
window.open(item.launchUrl);
} else {
window.location.assign(item.launchUrl);
}
};
@property({ attribute: false })
app?: Application;
async apiEndpoint(page: number): Promise<PaginatedResponse<Endpoint>> {
const endpoints = await new RacApi(DEFAULT_CONFIG).racEndpointsList({
provider: this.app?.provider || 0,
page: page,
search: this.search,
});
if (this.open && endpoints.pagination.count === 1) {
this.clickHandler(endpoints.results[0]);
this.open = false;
}
return endpoints;
}
columns(): TableColumn[] {
return [new TableColumn("Name")];
}
row(item: Endpoint): TemplateResult[] {
return [html`${item.name}`];
}
renderModalInner(): TemplateResult {
return html`<section class="pf-c-modal-box__header pf-c-page__main-section pf-m-light">
<div class="pf-c-content">
<h1 class="pf-c-title pf-m-2xl">${msg("Select endpoint to connect to")}</h1>
</div>
</section>
<section class="pf-c-modal-box__body pf-m-light">${this.renderTable()}</section>
<footer class="pf-c-modal-box__footer">
<ak-spinner-button
.callAction=${async () => {
this.open = false;
}}
class="pf-m-secondary"
>
${msg("Cancel")}
</ak-spinner-button>
</footer>`;
}
}

View File

@ -3,6 +3,7 @@ import { truncateWords } from "@goauthentik/common/utils";
import "@goauthentik/components/ak-app-icon";
import { AKElement, rootInterface } from "@goauthentik/elements/Base";
import "@goauthentik/elements/Expand";
import "@goauthentik/user/LibraryApplication/RACLaunchEndpointModal";
import { UserInterface } from "@goauthentik/user/UserInterface";
import { msg } from "@lit/localize";
@ -85,6 +86,22 @@ export class LibraryApplication extends AKElement {
</ak-expand>`;
}
renderLaunch(): TemplateResult {
if (!this.application) {
return html``;
}
if (this.application?.launchUrl === "goauthentik.io://providers/rac/launch") {
return html`<ak-library-rac-endpoint-launch .app=${this.application}>
<a slot="trigger"> ${this.application.name} </a>
</ak-library-rac-endpoint-launch>`;
}
return html`<a
href="${ifDefined(this.application.launchUrl ?? "")}"
target="${ifDefined(this.application.openInNewTab ? "_blank" : undefined)}"
>${this.application.name}</a
>`;
}
render(): TemplateResult {
if (!this.application) {
return html`<ak-spinner></ak-spinner>`;
@ -111,13 +128,7 @@ export class LibraryApplication extends AKElement {
<ak-app-icon size=${PFSize.Large} .app=${this.application}></ak-app-icon>
</a>
</div>
<div class="pf-c-card__title">
<a
href="${ifDefined(this.application.launchUrl ?? "")}"
target="${ifDefined(this.application.openInNewTab ? "_blank" : undefined)}"
>${this.application.name}</a
>
</div>
<div class="pf-c-card__title">${this.renderLaunch()}</div>
<div class="expander"></div>
${expandable ? this.renderExpansion(this.application) : nothing}
</div>`;

View File

@ -2,10 +2,16 @@ import type { Application } from "@goauthentik/api";
const isFullUrlRe = new RegExp("://");
const isHttpRe = new RegExp("http(s?)://");
const isAuthentikSpecialRe = new RegExp("goauthentik.io://");
const isNotFullUrl = (url: string) => !isFullUrlRe.test(url);
const isHttp = (url: string) => isHttpRe.test(url);
const isAuthentikSpecial = (url: string) => isAuthentikSpecialRe.test(url);
export const appHasLaunchUrl = (app: Application) => {
const url = app.launchUrl;
return !!(typeof url === "string" && url !== "" && (isHttp(url) || isNotFullUrl(url)));
return !!(
typeof url === "string" &&
url !== "" &&
(isHttp(url) || isNotFullUrl(url) || isAuthentikSpecial(url))
);
};