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:
Jens L
2020-11-28 19:43:42 +01:00
committed by GitHub
parent 127ffbd456
commit 9466f91466
71 changed files with 15 additions and 26 deletions

View 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();
}
}

View 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
View 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>`;
}
}

View 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>`;
}
}