root: move webapp to /web (#347)
* root: move webapp to /web * root: fix static build * root: fix static files not being served for e2e tests
This commit is contained in:
18
web/src/api/application.ts
Normal file
18
web/src/api/application.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { DefaultClient } from "./client";
|
||||
|
||||
export class Application {
|
||||
pk?: string;
|
||||
name?: string;
|
||||
slug?: string;
|
||||
provider?: number;
|
||||
|
||||
meta_launch_url?: string;
|
||||
meta_icon?: string;
|
||||
meta_description?: string;
|
||||
meta_publisher?: string;
|
||||
policies?: string[];
|
||||
|
||||
static get(slug: string): Promise<Application> {
|
||||
return DefaultClient.fetch<Application>("core", "applications", slug);
|
||||
}
|
||||
}
|
||||
35
web/src/api/client.ts
Normal file
35
web/src/api/client.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { NotFoundError, RequestError } from "./errors";
|
||||
|
||||
export const VERSION = "v2beta";
|
||||
|
||||
export class Client {
|
||||
makeUrl(...url: string[]): string {
|
||||
return `/api/${VERSION}/${url.join("/")}/`;
|
||||
}
|
||||
|
||||
fetch<T>(...url: string[]): Promise<T> {
|
||||
return fetch(this.makeUrl(...url))
|
||||
.then((r) => {
|
||||
if (r.status > 300) {
|
||||
switch (r.status) {
|
||||
case 404:
|
||||
throw new NotFoundError(`URL ${this.makeUrl(...url)} not found`);
|
||||
default:
|
||||
throw new RequestError(r.statusText);
|
||||
}
|
||||
}
|
||||
return r;
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((r) => <T>r);
|
||||
}
|
||||
}
|
||||
|
||||
export const DefaultClient = new Client();
|
||||
|
||||
export interface PBResponse {
|
||||
count: number;
|
||||
next: string;
|
||||
previous: string;
|
||||
results: Array<any>;
|
||||
}
|
||||
10
web/src/api/config.ts
Normal file
10
web/src/api/config.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { DefaultClient } from "./client";
|
||||
|
||||
export class Config {
|
||||
branding_logo?: string;
|
||||
branding_title?: string;
|
||||
|
||||
static get(): Promise<Config> {
|
||||
return DefaultClient.fetch<Config>("root", "config");
|
||||
}
|
||||
}
|
||||
2
web/src/api/errors.ts
Normal file
2
web/src/api/errors.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export class NotFoundError extends Error {}
|
||||
export class RequestError extends Error {}
|
||||
11
web/src/api/token.ts
Normal file
11
web/src/api/token.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { DefaultClient } from "./client";
|
||||
|
||||
interface TokenResponse {
|
||||
key: string;
|
||||
}
|
||||
|
||||
export function tokenByIdentifier(identifier: string): Promise<string> {
|
||||
return DefaultClient.fetch<TokenResponse>("core", "tokens", identifier, "view_key").then(
|
||||
(r) => r.key
|
||||
);
|
||||
}
|
||||
15
web/src/api/user.ts
Normal file
15
web/src/api/user.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { Primitive } from "lit-html/lib/parts";
|
||||
import { DefaultClient } from "./client";
|
||||
|
||||
export class User {
|
||||
pk?: number;
|
||||
username?: string;
|
||||
name?: string;
|
||||
is_superuser?: boolean;
|
||||
email?: boolean;
|
||||
avatar?: string;
|
||||
|
||||
static me(): Promise<User> {
|
||||
return DefaultClient.fetch<User>("core", "users", "me");
|
||||
}
|
||||
}
|
||||
BIN
web/src/assets/fonts/DINEngschriftStd.woff
Normal file
BIN
web/src/assets/fonts/DINEngschriftStd.woff
Normal file
Binary file not shown.
BIN
web/src/assets/fonts/DINEngschriftStd.woff2
Normal file
BIN
web/src/assets/fonts/DINEngschriftStd.woff2
Normal file
Binary file not shown.
BIN
web/src/assets/images/flow_background.jpg
Normal file
BIN
web/src/assets/images/flow_background.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 670 KiB |
BIN
web/src/assets/images/logo.png
Normal file
BIN
web/src/assets/images/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
55
web/src/assets/images/logo.svg
Normal file
55
web/src/assets/images/logo.svg
Normal file
@ -0,0 +1,55 @@
|
||||
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;" xml:space="preserve">
|
||||
<path style="fill:#57565C;" d="M407,512H105C47.103,512,0,464.897,0,407V105C0,47.103,47.103,0,105,0h302
|
||||
c57.897,0,105,47.103,105,105v302C512,464.897,464.897,512,407,512z"/>
|
||||
<path style="fill:#3E3D42;" d="M407,0H256v512h151c57.897,0,105-47.103,105-105V105C512,47.103,464.897,0,407,0z"/>
|
||||
<rect x="91" y="141" style="fill:#00C3FF;" width="330" height="44"/>
|
||||
<rect x="256" y="141" style="fill:#00AAF0;" width="165" height="44"/>
|
||||
<rect x="91" y="176" style="fill:#FFDC40;" width="330" height="44"/>
|
||||
<rect x="256" y="176" style="fill:#FFAB15;" width="165" height="44"/>
|
||||
<rect x="91" y="206" style="fill:#87E694;" width="330" height="44"/>
|
||||
<rect x="256" y="206" style="fill:#66CC70;" width="165" height="44"/>
|
||||
<path style="fill:#F2F2F2;" d="M421,381c0,8.284-6.716,15-15,15H106c-8.284,0-15-6.716-15-15v-85h89.997
|
||||
c9.31,0,17.688,4.938,21.868,12.888C213.277,328.695,233.638,341,256,341s42.723-12.305,53.135-32.111
|
||||
c4.18-7.95,12.559-12.889,21.868-12.889H421V381z"/>
|
||||
<path style="fill:#FF6849;" d="M421,266h-89.997c-20.487,0-39.041,11.085-48.423,28.929C277.369,304.842,267.185,311,256,311
|
||||
s-21.369-6.158-26.58-16.071C220.038,277.085,201.484,266,180.997,266H91v-30h330V266z"/>
|
||||
<path style="fill:#F2F2F2;" d="M421,146H91v-15c0-8.284,6.716-15,15-15h300c8.284,0,15,6.716,15,15V146z"/>
|
||||
<path style="fill:#E5E5E5;" d="M331.003,296c-9.31,0-17.688,4.938-21.868,12.889C298.723,328.695,278.362,341,256,341v55h150
|
||||
c8.284,0,15-6.716,15-15v-85H331.003z"/>
|
||||
<path style="fill:#FD4B2D;" d="M256,236v75c11.185,0,21.369-6.158,26.58-16.071C291.962,277.085,310.516,266,331.003,266H421v-30
|
||||
H256z"/>
|
||||
<path style="fill:#E5E5E5;" d="M406,116H256v30h165v-15C421,122.716,414.284,116,406,116z"/>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
BIN
web/src/assets/images/user-default.png
Normal file
BIN
web/src/assets/images/user-default.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 68 B |
10
web/src/common/styles.ts
Normal file
10
web/src/common/styles.ts
Normal file
@ -0,0 +1,10 @@
|
||||
// @ts-ignore
|
||||
import PF from "@patternfly/patternfly/patternfly.css";
|
||||
// @ts-ignore
|
||||
import PFAddons from "@patternfly/patternfly/patternfly-addons.css";
|
||||
// @ts-ignore
|
||||
import FA from "@fortawesome/fontawesome-free/css/fontawesome.css";
|
||||
// @ts-ignore
|
||||
import PBGlobal from "../passbook.css";
|
||||
|
||||
export const COMMON_STYLES = [PF, PFAddons, FA, PBGlobal];
|
||||
30
web/src/constants.ts
Normal file
30
web/src/constants.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { css } from "lit-element";
|
||||
|
||||
export const PRIMARY_CLASS = "pf-m-primary";
|
||||
export const SUCCESS_CLASS = "pf-m-success";
|
||||
export const ERROR_CLASS = "pf-m-danger";
|
||||
export const PROGRESS_CLASS = "pf-m-in-progress";
|
||||
export const CURRENT_CLASS = "pf-m-current";
|
||||
export const ColorStyles = css`
|
||||
.pf-m-success {
|
||||
color: var(--pf-global--success-color--100);
|
||||
}
|
||||
.pf-c-button.pf-m-success {
|
||||
color: var(--pf-c-button--m-primary--Color);
|
||||
background-color: var(--pf-global--success-color--100);
|
||||
}
|
||||
.pf-m-warning {
|
||||
color: var(--pf-global--warning-color--100);
|
||||
}
|
||||
.pf-c-button.pf-m-warning {
|
||||
color: var(--pf-c-button--m-primary--Color);
|
||||
background-color: var(--pf-global--warning-color--100);
|
||||
}
|
||||
.pf-m-danger {
|
||||
color: var(--pf-global--danger-color--100);
|
||||
}
|
||||
.pf-c-button.pf-m-danger {
|
||||
color: var(--pf-c-button--m-primary--Color);
|
||||
background-color: var(--pf-global--danger-color--100);
|
||||
}
|
||||
`;
|
||||
105
web/src/elements/ActionButton.ts
Normal file
105
web/src/elements/ActionButton.ts
Normal file
@ -0,0 +1,105 @@
|
||||
import { getCookie } from "../utils";
|
||||
import { css, customElement, html, LitElement, property } from "lit-element";
|
||||
// @ts-ignore
|
||||
import GlobalsStyle from "@patternfly/patternfly/base/patternfly-globals.css";
|
||||
// @ts-ignore
|
||||
import ButtonStyle from "@patternfly/patternfly/components/Button/button.css";
|
||||
// @ts-ignore
|
||||
import SpinnerStyle from "@patternfly/patternfly/components/Spinner/spinner.css";
|
||||
import {
|
||||
ColorStyles,
|
||||
ERROR_CLASS,
|
||||
PRIMARY_CLASS,
|
||||
PROGRESS_CLASS,
|
||||
SUCCESS_CLASS,
|
||||
} from "../constants";
|
||||
|
||||
@customElement("pb-action-button")
|
||||
export class ActionButton extends LitElement {
|
||||
@property()
|
||||
url: string = "";
|
||||
|
||||
@property()
|
||||
isRunning = false;
|
||||
|
||||
static get styles() {
|
||||
return [
|
||||
GlobalsStyle,
|
||||
ButtonStyle,
|
||||
SpinnerStyle,
|
||||
ColorStyles,
|
||||
css`
|
||||
button {
|
||||
/* Have to use !important here, as buttons with pf-m-progress have transition already */
|
||||
transition: all var(--pf-c-button--m-progress--TransitionDuration) ease 0s !important;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.classList.add(PRIMARY_CLASS);
|
||||
}
|
||||
|
||||
setLoading() {
|
||||
this.isRunning = true;
|
||||
this.classList.add(PROGRESS_CLASS);
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
setDone(statusClass: string) {
|
||||
this.isRunning = false;
|
||||
this.classList.remove(PROGRESS_CLASS);
|
||||
this.classList.replace(PRIMARY_CLASS, statusClass);
|
||||
this.requestUpdate();
|
||||
setTimeout(() => {
|
||||
this.classList.replace(statusClass, PRIMARY_CLASS);
|
||||
this.requestUpdate();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
callAction() {
|
||||
if (this.isRunning === true) {
|
||||
return;
|
||||
}
|
||||
this.setLoading();
|
||||
const csrftoken = getCookie("passbook_csrf");
|
||||
const request = new Request(this.url, {
|
||||
headers: { "X-CSRFToken": csrftoken! },
|
||||
});
|
||||
fetch(request, {
|
||||
method: "POST",
|
||||
mode: "same-origin",
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((r) => {
|
||||
this.setDone(SUCCESS_CLASS);
|
||||
})
|
||||
.catch(() => {
|
||||
this.setDone(ERROR_CLASS);
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`<button
|
||||
class="pf-c-button pf-m-progress ${this.classList.toString()}"
|
||||
@click=${() => this.callAction()}
|
||||
>
|
||||
${this.isRunning
|
||||
? html` <span class="pf-c-button__progress">
|
||||
<span
|
||||
class="pf-c-spinner pf-m-md"
|
||||
role="progressbar"
|
||||
aria-valuetext="Loading..."
|
||||
>
|
||||
<span class="pf-c-spinner__clipper"></span>
|
||||
<span class="pf-c-spinner__lead-ball"></span>
|
||||
<span class="pf-c-spinner__tail-ball"></span>
|
||||
</span>
|
||||
</span>`
|
||||
: ""}
|
||||
<slot></slot>
|
||||
</button>`;
|
||||
}
|
||||
}
|
||||
108
web/src/elements/AdminLoginsChart.ts
Normal file
108
web/src/elements/AdminLoginsChart.ts
Normal file
@ -0,0 +1,108 @@
|
||||
import { css, customElement, html, LitElement, property } from "lit-element";
|
||||
import Chart from "chart.js";
|
||||
|
||||
interface TickValue {
|
||||
value: number;
|
||||
major: boolean;
|
||||
}
|
||||
|
||||
@customElement("pb-admin-logins-chart")
|
||||
export class AdminLoginsChart extends LitElement {
|
||||
@property()
|
||||
url: string = "";
|
||||
|
||||
chart: any;
|
||||
|
||||
static get styles() {
|
||||
return css`
|
||||
:host {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: block;
|
||||
min-height: 25rem;
|
||||
}
|
||||
canvas {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
window.addEventListener("resize", () => {
|
||||
if (this.chart) {
|
||||
this.chart.resize();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
fetch(this.url)
|
||||
.then((r) => r.json())
|
||||
.catch((e) => console.error(e))
|
||||
.then((r) => {
|
||||
let ctx = (<HTMLCanvasElement>this.shadowRoot?.querySelector("canvas")).getContext(
|
||||
"2d"
|
||||
)!;
|
||||
this.chart = new Chart(ctx, {
|
||||
type: "bar",
|
||||
data: {
|
||||
datasets: [
|
||||
{
|
||||
label: "Failed Logins",
|
||||
backgroundColor: "rgba(201, 25, 11, .5)",
|
||||
spanGaps: true,
|
||||
data: r.logins_failed_per_1h,
|
||||
},
|
||||
{
|
||||
label: "Successful Logins",
|
||||
backgroundColor: "rgba(189, 229, 184, .5)",
|
||||
spanGaps: true,
|
||||
data: r.logins_per_1h,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
maintainAspectRatio: false,
|
||||
spanGaps: true,
|
||||
scales: {
|
||||
xAxes: [
|
||||
{
|
||||
stacked: true,
|
||||
gridLines: {
|
||||
color: "rgba(0, 0, 0, 0)",
|
||||
},
|
||||
type: "time",
|
||||
offset: true,
|
||||
ticks: {
|
||||
callback: function (value, index: number, values) {
|
||||
const valueStamp = <TickValue>(<unknown>values[index]);
|
||||
const delta = Date.now() - valueStamp.value;
|
||||
const ago = Math.round(delta / 1000 / 3600);
|
||||
return `${ago} Hours ago`;
|
||||
},
|
||||
autoSkip: true,
|
||||
maxTicksLimit: 8,
|
||||
},
|
||||
},
|
||||
],
|
||||
yAxes: [
|
||||
{
|
||||
stacked: true,
|
||||
gridLines: {
|
||||
color: "rgba(0, 0, 0, 0)",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`<canvas></canvas>`;
|
||||
}
|
||||
}
|
||||
40
web/src/elements/CodeMirror.ts
Normal file
40
web/src/elements/CodeMirror.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { customElement, html, LitElement, property } from "lit-element";
|
||||
|
||||
// @ts-ignore
|
||||
import CodeMirror from "codemirror";
|
||||
import "codemirror/addon/display/autorefresh";
|
||||
import "codemirror/mode/xml/xml.js";
|
||||
import "codemirror/mode/yaml/yaml.js";
|
||||
import "codemirror/mode/python/python.js";
|
||||
|
||||
@customElement("pb-codemirror")
|
||||
export class CodeMirrorTextarea extends LitElement {
|
||||
@property()
|
||||
readOnly: boolean = false;
|
||||
|
||||
@property()
|
||||
mode: string = "yaml";
|
||||
|
||||
editor?: CodeMirror.EditorFromTextArea;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
const textarea = this.querySelector("textarea");
|
||||
if (!textarea) {
|
||||
return;
|
||||
}
|
||||
this.editor = CodeMirror.fromTextArea(textarea, {
|
||||
mode: this.mode,
|
||||
theme: "monokai",
|
||||
lineNumbers: false,
|
||||
readOnly: this.readOnly,
|
||||
autoRefresh: true,
|
||||
});
|
||||
this.editor.on("blur", (e) => {
|
||||
this.editor?.save();
|
||||
});
|
||||
}
|
||||
}
|
||||
18
web/src/elements/Dropdown.ts
Normal file
18
web/src/elements/Dropdown.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { customElement, html, LitElement } from "lit-element";
|
||||
|
||||
@customElement("pb-dropdown")
|
||||
export class DropdownButton extends LitElement {
|
||||
constructor() {
|
||||
super();
|
||||
const menu = <HTMLElement>this.querySelector(".pf-c-dropdown__menu")!;
|
||||
this.querySelectorAll("button.pf-c-dropdown__toggle").forEach((btn) => {
|
||||
btn.addEventListener("click", (e) => {
|
||||
menu.hidden = !menu.hidden;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`<slot></slot>`;
|
||||
}
|
||||
}
|
||||
91
web/src/elements/FetchFillSlot.ts
Normal file
91
web/src/elements/FetchFillSlot.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import { LitElement, html, customElement, property } from "lit-element";
|
||||
|
||||
interface ComparisonHash {
|
||||
[key: string]: (a: any, b: any) => boolean;
|
||||
}
|
||||
|
||||
@customElement("fetch-fill-slot")
|
||||
export class FetchFillSlot extends LitElement {
|
||||
@property()
|
||||
url: string = "";
|
||||
|
||||
@property()
|
||||
key: string = "";
|
||||
|
||||
@property()
|
||||
value: string = "";
|
||||
|
||||
comparison(slotName: string) {
|
||||
let comparisonOperatorsHash = <ComparisonHash>{
|
||||
"<": function (a: any, b: any) {
|
||||
return a < b;
|
||||
},
|
||||
">": function (a: any, b: any) {
|
||||
return a > b;
|
||||
},
|
||||
">=": function (a: any, b: any) {
|
||||
return a >= b;
|
||||
},
|
||||
"<=": function (a: any, b: any) {
|
||||
return a <= b;
|
||||
},
|
||||
"==": function (a: any, b: any) {
|
||||
return a == b;
|
||||
},
|
||||
"!=": function (a: any, b: any) {
|
||||
return a != b;
|
||||
},
|
||||
"===": function (a: any, b: any) {
|
||||
return a === b;
|
||||
},
|
||||
"!==": function (a: any, b: any) {
|
||||
return a !== b;
|
||||
},
|
||||
};
|
||||
const tokens = slotName.split(" ");
|
||||
if (tokens.length < 3) {
|
||||
throw new Error("nah");
|
||||
}
|
||||
let a: any = tokens[0];
|
||||
if (a === "value") {
|
||||
a = this.value;
|
||||
} else {
|
||||
a = parseInt(a, 10);
|
||||
}
|
||||
let b: any = tokens[2];
|
||||
if (b === "value") {
|
||||
b = this.value;
|
||||
} else {
|
||||
b = parseInt(b, 10);
|
||||
}
|
||||
const comp = tokens[1];
|
||||
if (!(comp in comparisonOperatorsHash)) {
|
||||
throw new Error("Invalid comparison");
|
||||
}
|
||||
return comparisonOperatorsHash[comp](a, b);
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
fetch(this.url)
|
||||
.then((r) => r.json())
|
||||
.then((r) => r[this.key])
|
||||
.then((r) => (this.value = r));
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.value === undefined) {
|
||||
return html`<slot></slot>`;
|
||||
}
|
||||
let selectedSlot = "";
|
||||
this.querySelectorAll("[slot]").forEach((slot) => {
|
||||
const comp = slot.getAttribute("slot")!;
|
||||
if (this.comparison(comp)) {
|
||||
selectedSlot = comp;
|
||||
}
|
||||
});
|
||||
this.querySelectorAll("[data-value]").forEach((dv) => {
|
||||
dv.textContent = this.value;
|
||||
});
|
||||
return html`<slot name=${selectedSlot}></slot>`;
|
||||
}
|
||||
}
|
||||
115
web/src/elements/Messages.ts
Normal file
115
web/src/elements/Messages.ts
Normal file
@ -0,0 +1,115 @@
|
||||
import { LitElement, html, customElement, property } from "lit-element";
|
||||
|
||||
const LEVEL_ICON_MAP: { [key: string]: string } = {
|
||||
error: "fas fa-exclamation-circle",
|
||||
warning: "fas fa-exclamation-triangle",
|
||||
success: "fas fa-check-circle",
|
||||
info: "fas fa-info",
|
||||
};
|
||||
|
||||
let ID = function (prefix: string) {
|
||||
return prefix + Math.random().toString(36).substr(2, 9);
|
||||
};
|
||||
|
||||
interface Message {
|
||||
levelTag: string;
|
||||
tags: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
@customElement("pb-messages")
|
||||
export class Messages extends LitElement {
|
||||
@property()
|
||||
url: string = "";
|
||||
|
||||
messageSocket?: WebSocket;
|
||||
retryDelay: number = 200;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
try {
|
||||
this.connect();
|
||||
} catch (error) {
|
||||
console.warn(`passbook/messages: failed to connect to ws ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
this.fetchMessages();
|
||||
}
|
||||
|
||||
connect() {
|
||||
const wsUrl = `${window.location.protocol.replace("http", "ws")}//${
|
||||
window.location.host
|
||||
}/ws/client/`;
|
||||
this.messageSocket = new WebSocket(wsUrl);
|
||||
this.messageSocket.addEventListener("open", (e) => {
|
||||
console.debug(`passbook/messages: connected to ${wsUrl}`);
|
||||
});
|
||||
this.messageSocket.addEventListener("close", (e) => {
|
||||
console.debug(`passbook/messages: closed ws connection: ${e}`);
|
||||
setTimeout(() => {
|
||||
console.debug(`passbook/messages: reconnecting ws in ${this.retryDelay}ms`);
|
||||
this.connect();
|
||||
}, this.retryDelay);
|
||||
this.retryDelay = this.retryDelay * 2;
|
||||
});
|
||||
this.messageSocket.addEventListener("message", (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
this.renderMessage(data);
|
||||
});
|
||||
this.messageSocket.addEventListener("error", (e) => {
|
||||
console.warn(`passbook/messages: error ${e}`);
|
||||
this.retryDelay = this.retryDelay * 2;
|
||||
});
|
||||
}
|
||||
|
||||
/* Fetch messages which were stored in the session.
|
||||
* This mostly gets messages which were created when the user arrives/leaves the site
|
||||
* and especially the login flow */
|
||||
fetchMessages() {
|
||||
console.debug(`passbook/messages: fetching messages over direct api`);
|
||||
return fetch(this.url)
|
||||
.then((r) => r.json())
|
||||
.then((r) => {
|
||||
r.forEach((m: any) => {
|
||||
const message = <Message>{
|
||||
levelTag: m.level_tag,
|
||||
tags: m.tags,
|
||||
message: m.message,
|
||||
};
|
||||
this.renderMessage(message);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
renderMessage(message: Message) {
|
||||
const container = <HTMLElement>this.querySelector(".pf-c-alert-group")!;
|
||||
const id = ID("pb-message");
|
||||
const el = document.createElement("template");
|
||||
el.innerHTML = `<li id=${id} class="pf-c-alert-group__item">
|
||||
<div class="pf-c-alert pf-m-${message.levelTag} ${
|
||||
message.levelTag === "error" ? "pf-m-danger" : ""
|
||||
}">
|
||||
<div class="pf-c-alert__icon">
|
||||
<i class="${LEVEL_ICON_MAP[message.levelTag]}"></i>
|
||||
</div>
|
||||
<p class="pf-c-alert__title">
|
||||
${message.message}
|
||||
</p>
|
||||
</div>
|
||||
</li>`;
|
||||
setTimeout(() => {
|
||||
this.querySelector(`#${id}`)?.remove();
|
||||
}, 1500);
|
||||
container.appendChild(el.content.firstChild!);
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`<ul class="pf-c-alert-group pf-m-toast"></ul>`;
|
||||
}
|
||||
}
|
||||
157
web/src/elements/ModalButton.ts
Normal file
157
web/src/elements/ModalButton.ts
Normal file
@ -0,0 +1,157 @@
|
||||
import { css, customElement, html, LitElement, property } from "lit-element";
|
||||
// @ts-ignore
|
||||
import ModalBoxStyle from "@patternfly/patternfly/components/ModalBox/modal-box.css";
|
||||
// @ts-ignore
|
||||
import BullseyeStyle from "@patternfly/patternfly/layouts/Bullseye/bullseye.css";
|
||||
// @ts-ignore
|
||||
import BackdropStyle from "@patternfly/patternfly/components/Backdrop/backdrop.css";
|
||||
// @ts-ignore
|
||||
import ButtonStyle from "@patternfly/patternfly/components/Button/button.css";
|
||||
// @ts-ignore
|
||||
import fa from "@fortawesome/fontawesome-free/css/solid.css";
|
||||
|
||||
import { convertToSlug } from "../utils";
|
||||
|
||||
@customElement("pb-modal-button")
|
||||
export class ModalButton extends LitElement {
|
||||
@property()
|
||||
href?: string;
|
||||
|
||||
@property()
|
||||
open: boolean = false;
|
||||
|
||||
static get styles() {
|
||||
return [
|
||||
css`
|
||||
:host {
|
||||
text-align: left;
|
||||
}
|
||||
::slotted(*) {
|
||||
overflow-y: auto;
|
||||
}
|
||||
`,
|
||||
ModalBoxStyle,
|
||||
BullseyeStyle,
|
||||
BackdropStyle,
|
||||
ButtonStyle,
|
||||
fa,
|
||||
];
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
window.addEventListener("keyup", (e) => {
|
||||
if (e.code === "Escape") {
|
||||
this.open = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateHandlers() {
|
||||
// Ensure links close the modal
|
||||
this.querySelectorAll<HTMLAnchorElement>("[slot=modal] a").forEach((a) => {
|
||||
// Make click on a close the modal
|
||||
a.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
this.open = false;
|
||||
});
|
||||
});
|
||||
// Make name field update slug field
|
||||
this.querySelectorAll<HTMLInputElement>("input[name=name]").forEach((input) => {
|
||||
input.addEventListener("input", (e) => {
|
||||
const form = input.closest("form");
|
||||
if (form === null) {
|
||||
return;
|
||||
}
|
||||
const slugField = form.querySelector<HTMLInputElement>("input[name=slug]");
|
||||
if (!slugField) {
|
||||
return;
|
||||
}
|
||||
slugField.value = convertToSlug(input.value);
|
||||
});
|
||||
});
|
||||
// Ensure forms sends in AJAX
|
||||
this.querySelectorAll<HTMLFormElement>("[slot=modal] form").forEach((form) => {
|
||||
form.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
let formData = new FormData(form);
|
||||
fetch(this.href ? this.href : form.action, {
|
||||
method: form.method,
|
||||
body: formData,
|
||||
redirect: "manual",
|
||||
})
|
||||
.then((response) => {
|
||||
return response.text();
|
||||
})
|
||||
.then((data) => {
|
||||
if (data.indexOf("csrfmiddlewaretoken") !== -1) {
|
||||
this.querySelector("[slot=modal]")!.innerHTML = data;
|
||||
console.debug(`passbook/modalbutton: re-showing form`);
|
||||
this.updateHandlers();
|
||||
} else {
|
||||
this.open = false;
|
||||
console.debug(`passbook/modalbutton: successful submit`);
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("hashchange", {
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onClick(e: MouseEvent) {
|
||||
if (!this.href) {
|
||||
this.updateHandlers();
|
||||
this.open = true;
|
||||
} else {
|
||||
const request = new Request(this.href);
|
||||
fetch(request, {
|
||||
mode: "same-origin",
|
||||
})
|
||||
.then((r) => r.text())
|
||||
.then((t) => {
|
||||
this.querySelector("[slot=modal]")!.innerHTML = t;
|
||||
this.updateHandlers();
|
||||
this.open = true;
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
renderModal() {
|
||||
return html`<div class="pf-c-backdrop">
|
||||
<div class="pf-l-bullseye">
|
||||
<div
|
||||
class="pf-c-modal-box pf-m-lg"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-md-title"
|
||||
aria-describedby="modal-md-description"
|
||||
>
|
||||
<button
|
||||
@click=${() => (this.open = false)}
|
||||
class="pf-c-button pf-m-plain"
|
||||
type="button"
|
||||
aria-label="Close dialog"
|
||||
>
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
</button>
|
||||
<slot name="modal"> </slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html` <slot name="trigger" @click=${(e: any) => this.onClick(e)}></slot>
|
||||
${this.open ? this.renderModal() : ""}`;
|
||||
}
|
||||
}
|
||||
212
web/src/elements/Sidebar.ts
Normal file
212
web/src/elements/Sidebar.ts
Normal file
@ -0,0 +1,212 @@
|
||||
import { css, customElement, html, LitElement, property, TemplateResult } from "lit-element";
|
||||
// @ts-ignore
|
||||
import PageStyle from "@patternfly/patternfly/components/Page/page.css";
|
||||
// @ts-ignore
|
||||
import NavStyle from "@patternfly/patternfly/components/Nav/nav.css";
|
||||
// @ts-ignore
|
||||
import GlobalsStyle from "@patternfly/patternfly/base/patternfly-globals.css";
|
||||
|
||||
import { User } from "../api/user";
|
||||
|
||||
export interface SidebarItem {
|
||||
name: string;
|
||||
path?: string[];
|
||||
children?: SidebarItem[];
|
||||
condition?: (sb: Sidebar) => boolean;
|
||||
}
|
||||
|
||||
export const SIDEBAR_ITEMS: SidebarItem[] = [
|
||||
{
|
||||
name: "Library",
|
||||
path: ["/-/overview/"],
|
||||
},
|
||||
{
|
||||
name: "Monitor",
|
||||
path: ["/audit/audit/"],
|
||||
condition: (sb: Sidebar) => {
|
||||
return sb.user?.is_superuser!;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Administration",
|
||||
children: [
|
||||
{
|
||||
name: "Overview",
|
||||
path: ["/administration/overview/"],
|
||||
},
|
||||
{
|
||||
name: "System Tasks",
|
||||
path: ["/administration/tasks/"],
|
||||
},
|
||||
{
|
||||
name: "Applications",
|
||||
path: ["/administration/applications/"],
|
||||
},
|
||||
{
|
||||
name: "Sources",
|
||||
path: ["/administration/sources/"],
|
||||
},
|
||||
{
|
||||
name: "Providers",
|
||||
path: ["/administration/providers/"],
|
||||
},
|
||||
{
|
||||
name: "User Management",
|
||||
children: [
|
||||
{
|
||||
name: "User",
|
||||
path: ["/administration/users/"],
|
||||
},
|
||||
{
|
||||
name: "Groups",
|
||||
path: ["/administration/groups/"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Outposts",
|
||||
children: [
|
||||
{
|
||||
name: "Outposts",
|
||||
path: ["/administration/outposts/"],
|
||||
},
|
||||
{
|
||||
name: "Service Connections",
|
||||
path: ["/administration/outposts/service_connections/"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Policies",
|
||||
path: ["/administration/policies/"],
|
||||
},
|
||||
{
|
||||
name: "Property Mappings",
|
||||
path: ["/administration/property-mappings/"],
|
||||
},
|
||||
{
|
||||
name: "Flows",
|
||||
children: [
|
||||
{
|
||||
name: "Flows",
|
||||
path: ["/administration/flows/"],
|
||||
},
|
||||
{
|
||||
name: "Stages",
|
||||
path: ["/administration/stages/"],
|
||||
},
|
||||
{
|
||||
name: "Prompts",
|
||||
path: ["/administration/stages/prompts/"],
|
||||
},
|
||||
{
|
||||
name: "Invitations",
|
||||
path: ["/administration/stages/invitations/"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Certificates",
|
||||
path: ["/administration/crypto/certificates/"],
|
||||
},
|
||||
{
|
||||
name: "Tokens",
|
||||
path: ["/administration/tokens/"],
|
||||
},
|
||||
],
|
||||
condition: (sb: Sidebar) => {
|
||||
return sb.user?.is_superuser!;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@customElement("pb-sidebar")
|
||||
export class Sidebar extends LitElement {
|
||||
@property()
|
||||
activePath: string;
|
||||
|
||||
@property()
|
||||
user?: User;
|
||||
|
||||
static get styles() {
|
||||
return [
|
||||
GlobalsStyle,
|
||||
PageStyle,
|
||||
NavStyle,
|
||||
css`
|
||||
.pf-c-nav__link {
|
||||
--pf-c-nav__link--PaddingTop: 0.5rem;
|
||||
--pf-c-nav__link--PaddingRight: 0.5rem;
|
||||
--pf-c-nav__link--PaddingBottom: 0.5rem;
|
||||
}
|
||||
.pf-c-nav__subnav {
|
||||
--pf-c-nav__subnav--PaddingBottom: 0px;
|
||||
}
|
||||
|
||||
.pf-c-nav__item-bottom {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
User.me().then((u) => (this.user = u));
|
||||
this.activePath = window.location.hash.slice(1, Infinity);
|
||||
window.addEventListener("hashchange", (e) => {
|
||||
this.activePath = window.location.hash.slice(1, Infinity);
|
||||
});
|
||||
}
|
||||
|
||||
renderItem(item: SidebarItem): TemplateResult {
|
||||
if (item.condition) {
|
||||
const result = item.condition(this);
|
||||
if (!result) {
|
||||
return html``;
|
||||
}
|
||||
}
|
||||
return html` <li
|
||||
class="pf-c-nav__item ${item.children ? "pf-m-expandable pf-m-expanded" : ""}"
|
||||
>
|
||||
${item.path
|
||||
? html`<a
|
||||
href="#${item.path}"
|
||||
class="pf-c-nav__link ${item.path.some((v) => v === this.activePath)
|
||||
? "pf-m-current"
|
||||
: ""}"
|
||||
>
|
||||
${item.name}
|
||||
</a>`
|
||||
: html`<a class="pf-c-nav__link" aria-expanded="true"
|
||||
>${item.name}
|
||||
<span class="pf-c-nav__toggle">
|
||||
<i class="fas fa-angle-right" aria-hidden="true"></i>
|
||||
</span>
|
||||
</a>
|
||||
<section class="pf-c-nav__subnav">
|
||||
<ul class="pf-c-nav__simple-list">
|
||||
${item.children?.map((i) => this.renderItem(i))}
|
||||
</ul>
|
||||
</section>`}
|
||||
</li>`;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`<div class="pf-c-page__sidebar-body">
|
||||
<nav class="pf-c-nav" aria-label="Global">
|
||||
<ul class="pf-c-nav__list">
|
||||
<li class="pf-c-nav__item">
|
||||
<pb-sidebar-brand></pb-sidebar-brand>
|
||||
</li>
|
||||
${SIDEBAR_ITEMS.map((i) => this.renderItem(i))}
|
||||
<li class="pf-c-nav__item pf-c-nav__item-bottom">
|
||||
<pb-sidebar-user .user=${this.user}></pb-sidebar-user>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
56
web/src/elements/SidebarBrand.ts
Normal file
56
web/src/elements/SidebarBrand.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { css, customElement, html, LitElement, property } from "lit-element";
|
||||
// @ts-ignore
|
||||
import PageStyle from "@patternfly/patternfly/components/Page/page.css";
|
||||
// @ts-ignore
|
||||
import GlobalsStyle from "@patternfly/patternfly/base/patternfly-globals.css";
|
||||
import { Config } from "../api/config";
|
||||
|
||||
@customElement("pb-sidebar-brand")
|
||||
export class SidebarBrand extends LitElement {
|
||||
@property()
|
||||
config?: Config;
|
||||
|
||||
static get styles() {
|
||||
return [
|
||||
GlobalsStyle,
|
||||
PageStyle,
|
||||
css`
|
||||
.pf-c-brand {
|
||||
font-family: "DIN 1451 Std";
|
||||
line-height: 60px;
|
||||
font-size: 3rem;
|
||||
color: var(--pf-c-nav__link--m-current--Color);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
margin: 0 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.pf-c-brand img {
|
||||
max-height: 60px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
Config.get().then((c) => (this.config = c));
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.config) {
|
||||
return html``;
|
||||
}
|
||||
return html` <a href="" class="pf-c-page__header-brand-link">
|
||||
<div class="pf-c-brand pb-brand">
|
||||
<img src="${this.config?.branding_logo}" alt="passbook icon" loading="lazy" />
|
||||
${this.config?.branding_title
|
||||
? html`<span>${this.config.branding_title}</span>`
|
||||
: ""}
|
||||
</div>
|
||||
</a>`;
|
||||
}
|
||||
}
|
||||
61
web/src/elements/SidebarUser.ts
Normal file
61
web/src/elements/SidebarUser.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { css, customElement, html, LitElement, property } from "lit-element";
|
||||
// @ts-ignore
|
||||
import NavStyle from "@patternfly/patternfly/components/Nav/nav.css";
|
||||
// @ts-ignore
|
||||
import fa from "@fortawesome/fontawesome-free/css/all.css";
|
||||
// @ts-ignore
|
||||
import AvatarStyle from "@patternfly/patternfly/components/Avatar/avatar.css";
|
||||
import { User } from "../api/user";
|
||||
|
||||
@customElement("pb-sidebar-user")
|
||||
export class SidebarUser extends LitElement {
|
||||
@property()
|
||||
user?: User;
|
||||
|
||||
static get styles() {
|
||||
return [
|
||||
fa,
|
||||
NavStyle,
|
||||
AvatarStyle,
|
||||
css`
|
||||
:host {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.pf-c-nav__link {
|
||||
align-items: center;
|
||||
}
|
||||
.user-avatar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
.user-avatar > span {
|
||||
line-height: var(--pf-c-avatar--Height);
|
||||
padding-left: var(--pf-global--spacer--sm);
|
||||
font-size: var(--pf-global--FontSize--lg);
|
||||
}
|
||||
.user-logout {
|
||||
flex-shrink: 3;
|
||||
max-width: 75px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.user) {
|
||||
return html``;
|
||||
}
|
||||
return html`
|
||||
<a href="#/-/user/" class="pf-c-nav__link user-avatar" id="user-settings">
|
||||
<img class="pf-c-avatar" src="${this.user?.avatar}" alt="" />
|
||||
<span>${this.user?.username}</span>
|
||||
</a>
|
||||
<a href="/flows/-/default/invalidation/" class="pf-c-nav__link user-logout" id="logout">
|
||||
<i class="fas fa-sign-out-alt" aria-hidden="true"></i>
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
}
|
||||
58
web/src/elements/Table.ts
Normal file
58
web/src/elements/Table.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import { css, html, LitElement, TemplateResult } from "lit-element";
|
||||
import { until } from "lit-html/directives/until.js";
|
||||
import { DefaultClient, PBResponse } from "../api/client";
|
||||
|
||||
export abstract class Table extends LitElement {
|
||||
abstract apiEndpoint(): string[];
|
||||
abstract columns(): Array<string>;
|
||||
abstract row(item: any): Array<TemplateResult>;
|
||||
|
||||
private data: PBResponse = <PBResponse>{};
|
||||
|
||||
public static get styles() {
|
||||
return css`
|
||||
table {
|
||||
width: 100%;
|
||||
}
|
||||
table,
|
||||
tr,
|
||||
td {
|
||||
border: 1px inset white;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
td,
|
||||
th {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
td:hover {
|
||||
border: 1px solid red;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderRows() {
|
||||
return DefaultClient.fetch<PBResponse>(...this.apiEndpoint())
|
||||
.then((r) => (this.data = r))
|
||||
.then(() => {
|
||||
return this.data.results.map((item) => {
|
||||
return this.row(item).map((col) => {
|
||||
// let t = <TemplateStringsArray>[];
|
||||
return col;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`<table>
|
||||
<thead>
|
||||
<tr>
|
||||
${this.columns().map((col) => html`<th>${col}</th>`)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${until(this.renderRows(), html`<tr><td>loading...</tr></td>`)}
|
||||
</tbody>
|
||||
</table>`;
|
||||
}
|
||||
}
|
||||
50
web/src/elements/Tabs.ts
Normal file
50
web/src/elements/Tabs.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { LitElement, html, customElement, property } from "lit-element";
|
||||
// @ts-ignore
|
||||
import TabsStyle from "@patternfly/patternfly/components/Tabs/tabs.css";
|
||||
// @ts-ignore
|
||||
import GlobalsStyle from "@patternfly/patternfly/base/patternfly-globals.css";
|
||||
import { CURRENT_CLASS } from "../constants";
|
||||
|
||||
@customElement("pb-tabs")
|
||||
export class Tabs extends LitElement {
|
||||
@property()
|
||||
currentPage?: string;
|
||||
|
||||
static get styles() {
|
||||
return [GlobalsStyle, TabsStyle];
|
||||
}
|
||||
|
||||
render() {
|
||||
let pages = Array.from(this.querySelectorAll("[slot]")!);
|
||||
if (!this.currentPage) {
|
||||
if (pages.length < 1) {
|
||||
return html`<h1>no tabs defined</h1>`;
|
||||
}
|
||||
this.currentPage = pages[0].attributes.getNamedItem("slot")?.value;
|
||||
}
|
||||
return html`<div class="pf-c-tabs">
|
||||
<ul class="pf-c-tabs__list">
|
||||
${pages.map((page) => {
|
||||
const slot = page.attributes.getNamedItem("slot")?.value;
|
||||
return html` <li
|
||||
class="pf-c-tabs__item ${slot === this.currentPage
|
||||
? CURRENT_CLASS
|
||||
: ""}"
|
||||
>
|
||||
<button
|
||||
class="pf-c-tabs__link"
|
||||
@click=${() => {
|
||||
this.currentPage = slot;
|
||||
}}
|
||||
>
|
||||
<span class="pf-c-tabs__item-text">
|
||||
${page.attributes.getNamedItem("tab-title")?.value}
|
||||
</span>
|
||||
</button>
|
||||
</li>`;
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
<slot name="${this.currentPage}"></slot>`;
|
||||
}
|
||||
}
|
||||
53
web/src/elements/TokenCopyButton.ts
Normal file
53
web/src/elements/TokenCopyButton.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { css, customElement, html, LitElement, property } from "lit-element";
|
||||
// @ts-ignore
|
||||
import GlobalsStyle from "@patternfly/patternfly/base/patternfly-globals.css";
|
||||
// @ts-ignore
|
||||
import ButtonStyle from "@patternfly/patternfly/components/Button/button.css";
|
||||
import { tokenByIdentifier } from "../api/token";
|
||||
import { ColorStyles, ERROR_CLASS, PRIMARY_CLASS, SUCCESS_CLASS } from "../constants";
|
||||
|
||||
@customElement("pb-token-copy-button")
|
||||
export class TokenCopyButton extends LitElement {
|
||||
@property()
|
||||
identifier?: string;
|
||||
|
||||
@property()
|
||||
buttonClass: string = PRIMARY_CLASS;
|
||||
|
||||
static get styles() {
|
||||
return [
|
||||
GlobalsStyle,
|
||||
ButtonStyle,
|
||||
ColorStyles,
|
||||
css`
|
||||
button {
|
||||
transition: background-color 0.3s ease 0s;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
onClick() {
|
||||
if (!this.identifier) {
|
||||
this.buttonClass = ERROR_CLASS;
|
||||
setTimeout(() => {
|
||||
this.buttonClass = PRIMARY_CLASS;
|
||||
}, 1500);
|
||||
return;
|
||||
}
|
||||
tokenByIdentifier(this.identifier).then((token) => {
|
||||
navigator.clipboard.writeText(token).then(() => {
|
||||
this.buttonClass = SUCCESS_CLASS;
|
||||
setTimeout(() => {
|
||||
this.buttonClass = PRIMARY_CLASS;
|
||||
}, 1500);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`<button @click=${() => this.onClick()} class="pf-c-button ${this.buttonClass}">
|
||||
<slot></slot>
|
||||
</button>`;
|
||||
}
|
||||
}
|
||||
58
web/src/index.html
Normal file
58
web/src/index.html
Normal file
@ -0,0 +1,58 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link
|
||||
rel="preload"
|
||||
href="/static/passbook/fonts/DINEngschriftStd.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin
|
||||
/>
|
||||
<link
|
||||
rel="preload"
|
||||
href="/static/passbook/fonts/DINEngschriftStd.woff"
|
||||
as="font"
|
||||
type="font/woff"
|
||||
crossorigin
|
||||
/>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
|
||||
<title>passbook</title>
|
||||
<link rel="icon" type="image/png" href="/static/dist/assets/images/logo.png" />
|
||||
<link rel="shortcut icon" type="image/png" href="/static/dist/assets/images/logo.png" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
type="text/css"
|
||||
href="/static/node_modules/%40patternfly/patternfly/patternfly.css"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
type="text/css"
|
||||
href="/static/node_modules/%40patternfly/patternfly/patternfly-addons.css"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
type="text/css"
|
||||
href="/static/node_modules/%40fortawesome/fontawesome-free/css/fontawesome.min.css"
|
||||
/>
|
||||
<link rel="stylesheet" type="text/css" href="/static/passbook/passbook.css" />
|
||||
<script src="/static/dist/main.js" type="module"></script>
|
||||
</head>
|
||||
<body>
|
||||
<pb-messages url="/api/v2beta/root/messages/"></pb-messages>
|
||||
<div class="pf-c-page">
|
||||
<a class="pf-c-skip-to-content pf-c-button pf-m-primary" href="#main-content"
|
||||
>Skip to content</a
|
||||
>
|
||||
<pb-sidebar class="pf-c-page__sidebar"> </pb-sidebar>
|
||||
<pb-router-outlet
|
||||
role="main"
|
||||
class="pf-c-page__main"
|
||||
tabindex="-1"
|
||||
id="main-content"
|
||||
defaultUrl="/-/overview/"
|
||||
>
|
||||
</pb-router-outlet>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
18
web/src/main.ts
Normal file
18
web/src/main.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import "construct-style-sheets-polyfill";
|
||||
|
||||
import "./elements/ActionButton";
|
||||
import "./elements/AdminLoginsChart";
|
||||
import "./elements/CodeMirror";
|
||||
import "./elements/Dropdown";
|
||||
import "./elements/FetchFillSlot";
|
||||
import "./elements/Messages";
|
||||
import "./elements/ModalButton";
|
||||
import "./elements/Sidebar";
|
||||
import "./elements/SidebarBrand";
|
||||
import "./elements/SidebarUser";
|
||||
import "./elements/Tabs";
|
||||
import "./elements/TokenCopyButton";
|
||||
import "./pages/applications/ApplicationViewPage";
|
||||
import "./pages/FlowShellCard";
|
||||
import "./pages/RouterOutlet";
|
||||
import "./pages/SiteShell";
|
||||
169
web/src/pages/FlowShellCard.ts
Normal file
169
web/src/pages/FlowShellCard.ts
Normal file
@ -0,0 +1,169 @@
|
||||
import { LitElement, html, customElement, property } from "lit-element";
|
||||
|
||||
enum ResponseType {
|
||||
redirect = "redirect",
|
||||
template = "template",
|
||||
}
|
||||
|
||||
interface Response {
|
||||
type: ResponseType;
|
||||
to?: string;
|
||||
body?: string;
|
||||
}
|
||||
|
||||
@customElement("pb-flow-shell-card")
|
||||
export class FlowShellCard extends LitElement {
|
||||
@property()
|
||||
flowBodyUrl: string = "";
|
||||
|
||||
@property()
|
||||
flowBody?: string;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
fetch(this.flowBodyUrl)
|
||||
.then((r) => {
|
||||
if (r.status === 404) {
|
||||
// Fallback when the flow does not exist, just redirect to the root
|
||||
window.location.pathname = "/";
|
||||
} else if (!r.ok) {
|
||||
throw Error(r.statusText);
|
||||
}
|
||||
return r;
|
||||
})
|
||||
.then((r) => {
|
||||
return r.json();
|
||||
})
|
||||
.then((r) => {
|
||||
this.updateCard(r);
|
||||
})
|
||||
.catch((e) => {
|
||||
// Catch JSON or Update errors
|
||||
this.errorMessage(e);
|
||||
});
|
||||
}
|
||||
|
||||
async updateCard(data: Response) {
|
||||
switch (data.type) {
|
||||
case ResponseType.redirect:
|
||||
window.location.assign(data.to!);
|
||||
break;
|
||||
case ResponseType.template:
|
||||
this.flowBody = data.body;
|
||||
await this.requestUpdate();
|
||||
this.checkAutofocus();
|
||||
this.loadFormCode();
|
||||
this.setFormSubmitHandlers();
|
||||
break;
|
||||
default:
|
||||
console.debug(`passbook/flows: unexpected data type ${data.type}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
loadFormCode() {
|
||||
this.querySelectorAll("script").forEach((script) => {
|
||||
let newScript = document.createElement("script");
|
||||
newScript.src = script.src;
|
||||
document.head.appendChild(newScript);
|
||||
});
|
||||
}
|
||||
|
||||
checkAutofocus() {
|
||||
const autofocusElement = <HTMLElement>this.querySelector("[autofocus]");
|
||||
if (autofocusElement !== null) {
|
||||
autofocusElement.focus();
|
||||
}
|
||||
}
|
||||
|
||||
updateFormAction(form: HTMLFormElement) {
|
||||
for (let index = 0; index < form.elements.length; index++) {
|
||||
const element = <HTMLInputElement>form.elements[index];
|
||||
if (element.value === form.action) {
|
||||
console.debug(
|
||||
"passbook/flows: Found Form action URL in form elements, not changing form action."
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
form.action = this.flowBodyUrl;
|
||||
console.debug(`passbook/flows: updated form.action ${this.flowBodyUrl}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
checkAutosubmit(form: HTMLFormElement) {
|
||||
if ("autosubmit" in form.attributes) {
|
||||
return form.submit();
|
||||
}
|
||||
}
|
||||
|
||||
setFormSubmitHandlers() {
|
||||
this.querySelectorAll("form").forEach((form) => {
|
||||
console.debug(`passbook/flows: Checking for autosubmit attribute ${form}`);
|
||||
this.checkAutosubmit(form);
|
||||
console.debug(`passbook/flows: Setting action for form ${form}`);
|
||||
this.updateFormAction(form);
|
||||
console.debug(`passbook/flows: Adding handler for form ${form}`);
|
||||
form.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
let formData = new FormData(form);
|
||||
this.flowBody = undefined;
|
||||
fetch(this.flowBodyUrl, {
|
||||
method: "post",
|
||||
body: formData,
|
||||
})
|
||||
.then((response) => {
|
||||
return response.json();
|
||||
})
|
||||
.then((data) => {
|
||||
this.updateCard(data);
|
||||
})
|
||||
.catch((e) => {
|
||||
this.errorMessage(e);
|
||||
});
|
||||
});
|
||||
form.classList.add("pb-flow-wrapped");
|
||||
});
|
||||
}
|
||||
|
||||
errorMessage(error: string) {
|
||||
this.flowBody = `
|
||||
<style>
|
||||
.pb-exception {
|
||||
font-family: monospace;
|
||||
overflow-x: scroll;
|
||||
}
|
||||
</style>
|
||||
<header class="pf-c-login__main-header">
|
||||
<h1 class="pf-c-title pf-m-3xl">
|
||||
Whoops!
|
||||
</h1>
|
||||
</header>
|
||||
<div class="pf-c-login__main-body">
|
||||
<h3>
|
||||
Something went wrong! Please try again later.
|
||||
</h3>
|
||||
<pre class="pb-exception">${error}</pre>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
loading() {
|
||||
return html` <div class="pf-c-login__main-body pb-loading">
|
||||
<span class="pf-c-spinner" role="progressbar" aria-valuetext="Loading...">
|
||||
<span class="pf-c-spinner__clipper"></span>
|
||||
<span class="pf-c-spinner__lead-ball"></span>
|
||||
<span class="pf-c-spinner__tail-ball"></span>
|
||||
</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.flowBody) {
|
||||
return html(<TemplateStringsArray>(<unknown>[this.flowBody]));
|
||||
}
|
||||
return this.loading();
|
||||
}
|
||||
}
|
||||
146
web/src/pages/RouterOutlet.ts
Normal file
146
web/src/pages/RouterOutlet.ts
Normal file
@ -0,0 +1,146 @@
|
||||
import { css, customElement, html, LitElement, property, TemplateResult } from "lit-element";
|
||||
// @ts-ignore
|
||||
import CodeMirrorStyle from "codemirror/lib/codemirror.css";
|
||||
// @ts-ignore
|
||||
import CodeMirrorTheme from "codemirror/theme/monokai.css";
|
||||
import { ColorStyles } from "../constants";
|
||||
import { COMMON_STYLES } from "../common/styles";
|
||||
|
||||
export class Route {
|
||||
url: RegExp;
|
||||
|
||||
private element?: TemplateResult;
|
||||
private callback?: (args: { [key: string]: string }) => TemplateResult;
|
||||
|
||||
constructor(url: RegExp, element?: TemplateResult) {
|
||||
this.url = url;
|
||||
this.element = element;
|
||||
}
|
||||
|
||||
redirect(to: string): Route {
|
||||
this.callback = () => {
|
||||
console.debug(`passbook/router: redirecting ${to}`);
|
||||
window.location.hash = `#${to}`;
|
||||
return html``;
|
||||
};
|
||||
return this;
|
||||
}
|
||||
|
||||
then(render: (args: { [key: string]: string }) => TemplateResult): Route {
|
||||
this.callback = render;
|
||||
return this;
|
||||
}
|
||||
|
||||
render(args: { [key: string]: string }): TemplateResult {
|
||||
if (this.callback) {
|
||||
return this.callback(args);
|
||||
}
|
||||
if (this.element) {
|
||||
return this.element;
|
||||
}
|
||||
throw new Error("Route does not have callback or element");
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return `<Route url=${this.url} callback=${this.callback ? "true" : "false"}>`;
|
||||
}
|
||||
}
|
||||
|
||||
export const SLUG_REGEX = "[-a-zA-Z0-9_]+";
|
||||
export const ROUTES: Route[] = [
|
||||
// Prevent infinite Shell loops
|
||||
new Route(new RegExp(`^/$`)).redirect("/-/overview/"),
|
||||
new Route(new RegExp(`^#.*`)).redirect("/-/overview/"),
|
||||
new Route(new RegExp(`^/applications/$`), html`<h1>test</h1>`),
|
||||
new Route(new RegExp(`^/applications/(?<slug>${SLUG_REGEX})/$`)).then((args) => {
|
||||
return html`<pb-application-view .args=${args}></pb-application-view>`;
|
||||
}),
|
||||
];
|
||||
|
||||
class RouteMatch {
|
||||
route: Route;
|
||||
arguments?: RegExpExecArray;
|
||||
fullUrl?: string;
|
||||
|
||||
constructor(route: Route) {
|
||||
this.route = route;
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
return this.route.render(this.arguments!.groups || {});
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return `<RouteMatch url=${this.fullUrl} route=${this.route} arguments=${this.arguments}>`;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement("pb-router-outlet")
|
||||
export class RouterOutlet extends LitElement {
|
||||
@property()
|
||||
current?: RouteMatch;
|
||||
|
||||
@property()
|
||||
defaultUrl?: string;
|
||||
|
||||
static get styles() {
|
||||
return [
|
||||
CodeMirrorStyle,
|
||||
CodeMirrorTheme,
|
||||
ColorStyles,
|
||||
css`
|
||||
:host {
|
||||
height: 100%;
|
||||
}
|
||||
`,
|
||||
].concat(...COMMON_STYLES);
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
window.addEventListener("hashchange", (e) => this.navigate());
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
this.navigate();
|
||||
}
|
||||
|
||||
navigate() {
|
||||
let activeUrl = window.location.hash.slice(1, Infinity);
|
||||
if (activeUrl === "") {
|
||||
activeUrl = this.defaultUrl!;
|
||||
window.location.hash = `#${activeUrl}`;
|
||||
console.debug(`passbook/router: set to ${window.location.hash}`);
|
||||
return;
|
||||
}
|
||||
let matchedRoute: RouteMatch | null = null;
|
||||
ROUTES.forEach((route) => {
|
||||
console.debug(`passbook/router: matching ${activeUrl} against ${route.url}`);
|
||||
const match = route.url.exec(activeUrl);
|
||||
if (match != null) {
|
||||
matchedRoute = new RouteMatch(route);
|
||||
matchedRoute.arguments = match;
|
||||
matchedRoute.fullUrl = activeUrl;
|
||||
console.debug(`passbook/router: found match ${matchedRoute}`);
|
||||
return;
|
||||
}
|
||||
});
|
||||
if (!matchedRoute) {
|
||||
console.debug(`passbook/router: route "${activeUrl}" not defined, defaulting to shell`);
|
||||
const route = new Route(
|
||||
RegExp(""),
|
||||
html`<pb-site-shell url=${activeUrl}>
|
||||
<div slot="body"></div>
|
||||
</pb-site-shell>`
|
||||
);
|
||||
matchedRoute = new RouteMatch(route);
|
||||
matchedRoute.arguments = route.url.exec(activeUrl)!;
|
||||
matchedRoute.fullUrl = activeUrl;
|
||||
}
|
||||
this.current = matchedRoute;
|
||||
}
|
||||
|
||||
render() {
|
||||
return this.current?.render();
|
||||
}
|
||||
}
|
||||
120
web/src/pages/SiteShell.ts
Normal file
120
web/src/pages/SiteShell.ts
Normal file
@ -0,0 +1,120 @@
|
||||
import { css, customElement, html, LitElement, property } from "lit-element";
|
||||
// @ts-ignore
|
||||
import BullseyeStyle from "@patternfly/patternfly/layouts/Bullseye/bullseye.css";
|
||||
// @ts-ignore
|
||||
import SpinnerStyle from "@patternfly/patternfly/components/Spinner/spinner.css";
|
||||
// @ts-ignore
|
||||
import BackdropStyle from "@patternfly/patternfly/components/Backdrop/backdrop.css";
|
||||
|
||||
@customElement("pb-site-shell")
|
||||
export class SiteShell extends LitElement {
|
||||
@property()
|
||||
set url(value: string) {
|
||||
this._url = value;
|
||||
this.loadContent();
|
||||
}
|
||||
|
||||
_url?: string;
|
||||
|
||||
@property()
|
||||
loading: boolean = false;
|
||||
|
||||
static get styles() {
|
||||
return [
|
||||
css`
|
||||
:host,
|
||||
::slotted(*) {
|
||||
height: 100%;
|
||||
}
|
||||
:host .pf-l-bullseye {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 2000;
|
||||
}
|
||||
.pf-c-backdrop {
|
||||
--pf-c-backdrop--BackgroundColor: rgba(0, 0, 0, 0) !important;
|
||||
}
|
||||
`,
|
||||
BackdropStyle,
|
||||
BullseyeStyle,
|
||||
SpinnerStyle,
|
||||
];
|
||||
}
|
||||
|
||||
loadContent() {
|
||||
if (!this._url) {
|
||||
return;
|
||||
}
|
||||
this.loading = true;
|
||||
fetch(this._url)
|
||||
.then((r) => {
|
||||
if (r.ok) {
|
||||
return r;
|
||||
}
|
||||
console.debug(`passbook/site-shell: Request failed ${this._url}`);
|
||||
window.location.hash = "#/";
|
||||
throw new Error("Request failed");
|
||||
})
|
||||
.then((r) => r.text())
|
||||
.then((t) => {
|
||||
this.querySelector("[slot=body]")!.innerHTML = t;
|
||||
})
|
||||
.then(() => {
|
||||
// Ensure anchors only change the hash
|
||||
this.querySelectorAll<HTMLAnchorElement>("a:not(.pb-root-link)").forEach((a) => {
|
||||
if (a.href === "") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const url = new URL(a.href);
|
||||
const qs = url.search || "";
|
||||
a.href = `#${url.pathname}${qs}`;
|
||||
} catch (e) {
|
||||
a.href = `#${a.href}`;
|
||||
}
|
||||
});
|
||||
// Create refresh buttons
|
||||
this.querySelectorAll("[role=pb-refresh]").forEach((rt) => {
|
||||
rt.addEventListener("click", (e) => {
|
||||
this.loadContent();
|
||||
});
|
||||
});
|
||||
// Make get forms (search bar) notify us on submit so we can change the hash
|
||||
this.querySelectorAll("form").forEach((f) => {
|
||||
f.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(f);
|
||||
const qs = new URLSearchParams(<any>(<unknown>formData)).toString();
|
||||
window.location.hash = `#${this._url}?${qs}`;
|
||||
});
|
||||
});
|
||||
setTimeout(() => {
|
||||
this.loading = false;
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return html` ${this.loading
|
||||
? html`<div class="pf-c-backdrop">
|
||||
<div class="pf-l-bullseye">
|
||||
<div class="pf-l-bullseye__item">
|
||||
<span
|
||||
class="pf-c-spinner pf-m-xl"
|
||||
role="progressbar"
|
||||
aria-valuetext="Loading..."
|
||||
>
|
||||
<span class="pf-c-spinner__clipper"></span>
|
||||
<span class="pf-c-spinner__lead-ball"></span>
|
||||
<span class="pf-c-spinner__tail-ball"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>`
|
||||
: ""}
|
||||
<slot name="body"> </slot>`;
|
||||
}
|
||||
}
|
||||
111
web/src/pages/applications/ApplicationViewPage.ts
Normal file
111
web/src/pages/applications/ApplicationViewPage.ts
Normal file
@ -0,0 +1,111 @@
|
||||
import { css, customElement, html, LitElement, property, TemplateResult } from "lit-element";
|
||||
import { Application } from "../../api/application";
|
||||
import { DefaultClient } from "../../api/client";
|
||||
import { COMMON_STYLES } from "../../common/styles";
|
||||
import { Table } from "../../elements/Table";
|
||||
|
||||
@customElement("pb-bound-policies-list")
|
||||
export class BoundPoliciesList extends Table {
|
||||
@property()
|
||||
target?: string;
|
||||
|
||||
apiEndpoint(): string[] {
|
||||
return ["policies", "bindings", `?target=${this.target}`];
|
||||
}
|
||||
|
||||
columns(): string[] {
|
||||
return ["Foo"];
|
||||
}
|
||||
|
||||
row(item: any): TemplateResult[] {
|
||||
return [html`${item}`];
|
||||
}
|
||||
}
|
||||
|
||||
@customElement("pb-application-view")
|
||||
export class ApplicationViewPage extends LitElement {
|
||||
@property()
|
||||
set args(value: { [key: string]: string }) {
|
||||
this.applicationSlug = value.slug;
|
||||
}
|
||||
|
||||
@property()
|
||||
set applicationSlug(value: string) {
|
||||
Application.get(value).then((app) => (this.application = app));
|
||||
}
|
||||
|
||||
@property()
|
||||
application?: Application;
|
||||
|
||||
static get styles() {
|
||||
return COMMON_STYLES.concat(
|
||||
css`
|
||||
img.pf-icon {
|
||||
max-height: 24px;
|
||||
}
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.application) {
|
||||
return html``;
|
||||
}
|
||||
return html`<section class="pf-c-page__main-section pf-m-light">
|
||||
<div class="pf-c-content">
|
||||
<h1>
|
||||
<img class="pf-icon" src="${this.application?.meta_icon || ""}" />
|
||||
${this.application?.name}
|
||||
</h1>
|
||||
<p>${this.application?.meta_publisher}</p>
|
||||
</div>
|
||||
</section>
|
||||
<pb-tabs>
|
||||
<section
|
||||
slot="page-1"
|
||||
tab-title="Users"
|
||||
class="pf-c-page__main-section pf-m-no-padding-mobile"
|
||||
>
|
||||
<div class="pf-l-gallery pf-m-gutter">
|
||||
<div
|
||||
class="pf-c-card pf-c-card-aggregate pf-l-gallery__item pf-m-4-col"
|
||||
style="grid-column-end: span 3;grid-row-end: span 2;"
|
||||
>
|
||||
<div class="pf-c-card__header">
|
||||
<div class="pf-c-card__header-main">
|
||||
<i class="pf-icon pf-icon-server"></i> Logins over the last 24
|
||||
hours
|
||||
</div>
|
||||
</div>
|
||||
<div class="pf-c-card__body">
|
||||
<pb-admin-logins-chart
|
||||
url="${DefaultClient.makeUrl(
|
||||
"core",
|
||||
"applications",
|
||||
this.application?.slug!,
|
||||
"metrics"
|
||||
)}"
|
||||
></pb-admin-logins-chart>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div
|
||||
slot="page-2"
|
||||
tab-title="Policy Bindings"
|
||||
class="pf-c-page__main-section pf-m-no-padding-mobile"
|
||||
>
|
||||
<div class="pf-l-gallery pf-m-gutter">
|
||||
<div
|
||||
class="pf-c-card pf-c-card-aggregate pf-l-gallery__item pf-m-4-col"
|
||||
style="grid-column-end: span 3;grid-row-end: span 2;"
|
||||
>
|
||||
<pb-bound-policies-list
|
||||
.target=${this.application.pk}
|
||||
></pb-bound-policies-list>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</pb-tabs>`;
|
||||
}
|
||||
}
|
||||
313
web/src/passbook.css
Normal file
313
web/src/passbook.css
Normal file
@ -0,0 +1,313 @@
|
||||
@font-face {
|
||||
font-family: "DIN 1451 Std";
|
||||
src: url("assets/fonts/DINEngschriftStd.woff2") format("woff2"),
|
||||
url("assets/fonts/DINEngschriftStd.woff") format("woff");
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
html {
|
||||
--pf-c-nav__link--PaddingTop: 0.5rem;
|
||||
--pf-c-nav__link--PaddingRight: 0.5rem;
|
||||
--pf-c-nav__link--PaddingBottom: 0.5rem;
|
||||
--pf-c-nav__link--PaddingLeft: 0.5rem;
|
||||
}
|
||||
|
||||
.pb-brand {
|
||||
font-family: "DIN 1451 Std";
|
||||
font-size: 4em;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-right: 0.5em;
|
||||
color: var(--pf-global--Color--light-200);
|
||||
}
|
||||
|
||||
.pb-brand > a:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.pb-brand > img {
|
||||
max-height: 68px;
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
|
||||
.pf-c-background-image::before {
|
||||
background-image: url("assets/images/flow_background.jpg");
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
/* Fix patternfly sidebar and header with open Modal */
|
||||
.pf-c-page__sidebar {
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.pf-c-page__header {
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* Ensure card is displayed on small screens */
|
||||
.pf-c-login__main {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* login page's icons */
|
||||
.pf-c-login__main-footer-links-item-link img {
|
||||
fill: var(--pf-c-login__main-footer-links-item-link-svg--Fill);
|
||||
width: 100%;
|
||||
max-width: var(--pf-c-login__main-footer-links-item-link-svg--Width);
|
||||
height: 100%;
|
||||
max-height: var(--pf-c-login__main-footer-links-item-link-svg--Height);
|
||||
}
|
||||
|
||||
/* fix multiple selects height */
|
||||
select[multiple] {
|
||||
height: initial;
|
||||
}
|
||||
|
||||
/* Selector */
|
||||
.selector {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 45vh;
|
||||
}
|
||||
|
||||
.selector .selector-filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.selector .selector-filter label {
|
||||
margin: 0 8px 0 0;
|
||||
}
|
||||
|
||||
.selector .selector-filter input {
|
||||
width: auto;
|
||||
min-height: 0;
|
||||
flex: 1 1;
|
||||
}
|
||||
|
||||
.selector-available,
|
||||
.selector-chosen {
|
||||
width: auto;
|
||||
flex: 1 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.selector select {
|
||||
width: 100%;
|
||||
flex: 1 0 auto;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.selector ul.selector-chooser {
|
||||
width: 26px;
|
||||
height: 52px;
|
||||
padding: 2px 0;
|
||||
margin: auto 15px;
|
||||
border-radius: 20px;
|
||||
transform: translateY(-10px);
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.selector-add,
|
||||
.selector-remove {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background-size: 20px auto;
|
||||
}
|
||||
|
||||
.selector-add {
|
||||
background-position: 0 -120px;
|
||||
}
|
||||
|
||||
.selector-remove {
|
||||
background-position: 0 -80px;
|
||||
}
|
||||
|
||||
a.selector-chooseall,
|
||||
a.selector-clearall {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.stacked {
|
||||
flex-direction: column;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.stacked > * {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
.stacked select {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.stacked .selector-available,
|
||||
.stacked .selector-chosen {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.stacked ul.selector-chooser {
|
||||
width: 52px;
|
||||
height: 26px;
|
||||
padding: 0 2px;
|
||||
margin: 15px auto;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.stacked .selector-chooser li {
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
.stacked .selector-add,
|
||||
.stacked .selector-remove {
|
||||
background-size: 20px auto;
|
||||
}
|
||||
|
||||
.stacked .selector-add {
|
||||
background-position: 0 -40px;
|
||||
}
|
||||
|
||||
.stacked .active.selector-add {
|
||||
background-position: 0 -60px;
|
||||
}
|
||||
|
||||
.stacked .selector-remove {
|
||||
background-position: 0 0;
|
||||
}
|
||||
|
||||
.stacked .active.selector-remove {
|
||||
background-position: 0 -20px;
|
||||
}
|
||||
|
||||
.help-tooltip,
|
||||
.selector .help-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
form .form-row p.datetime {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.datetime input {
|
||||
width: 50%;
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
.datetime span {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.datetime .timezonewarning {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.datetimeshortcuts {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.inline-group {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.selector-add,
|
||||
.selector-remove {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: block;
|
||||
text-indent: -3000px;
|
||||
overflow: hidden;
|
||||
cursor: default;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.active.selector-add,
|
||||
.active.selector-remove {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.active.selector-add:hover,
|
||||
.active.selector-remove:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.selector-add {
|
||||
background: url(../admin/img/selector-icons.svg) 0 -96px no-repeat;
|
||||
}
|
||||
|
||||
.active.selector-add:focus,
|
||||
.active.selector-add:hover {
|
||||
background-position: 0 -112px;
|
||||
}
|
||||
|
||||
.selector-remove {
|
||||
background: url(../admin/img/selector-icons.svg) 0 -64px no-repeat;
|
||||
}
|
||||
|
||||
input[data-is-monospace] {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* Form with user */
|
||||
.form-control-static {
|
||||
margin-top: var(--pf-global--spacer--sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.form-control-static .left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.form-control-static img {
|
||||
margin-right: var(--pf-global--spacer--xs);
|
||||
}
|
||||
.form-control-static a {
|
||||
padding-top: var(--pf-global--spacer--xs);
|
||||
padding-bottom: var(--pf-global--spacer--xs);
|
||||
line-height: var(--pf-global--spacer--xl);
|
||||
}
|
||||
|
||||
/* Static OTP Tokens, passbook.stages.otp_static */
|
||||
.pb-otp-tokens {
|
||||
list-style: circle;
|
||||
columns: 2;
|
||||
-webkit-columns: 2;
|
||||
-moz-columns: 2;
|
||||
margin-left: var(--pf-global--spacer--xs);
|
||||
}
|
||||
.pb-otp-tokens li {
|
||||
font-size: var(--pf-global--FontSize--2xl);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* Fix pre elements within alerts */
|
||||
.pf-c-alert pre {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* Aggregate Cards */
|
||||
.pb-aggregate-card {
|
||||
font-size: var(--pf-global--icon--FontSize--lg);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pf-c-content h1 {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.pf-c-content h1 i {
|
||||
font-style: normal;
|
||||
}
|
||||
.pf-c-content h1 :first-child {
|
||||
margin-right: var(--pf-global--spacer--sm);
|
||||
}
|
||||
|
||||
.pf-c-empty-state {
|
||||
height: 100vh;
|
||||
}
|
||||
22
web/src/utils.ts
Normal file
22
web/src/utils.ts
Normal file
@ -0,0 +1,22 @@
|
||||
export function getCookie(name: string) {
|
||||
let cookieValue = null;
|
||||
if (document.cookie && document.cookie !== "") {
|
||||
const cookies = document.cookie.split(";");
|
||||
for (let i = 0; i < cookies.length; i++) {
|
||||
const cookie = cookies[i].trim();
|
||||
// Does this cookie string begin with the name we want?
|
||||
if (cookie.substring(0, name.length + 1) === name + "=") {
|
||||
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return cookieValue;
|
||||
}
|
||||
|
||||
export function convertToSlug(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/ /g, "-")
|
||||
.replace(/[^\w-]+/g, "");
|
||||
}
|
||||
Reference in New Issue
Block a user