`;
+
+exports[`Storyshots Table|Table Default 1`] = `
+
+`;
+
+exports[`Storyshots Table|Table Empty 1`] = `
+
+
+ No data found.
+
+`;
+
+exports[`Storyshots Table|Table TextColumn 1`] = `
+
+
+
+ |
+ Id
+
+ |
+
+ Name
+
+ |
+
+ Description
+
+ |
+
+
+
+
+ |
+ 21
+ |
+
+ Pommes
+ |
+
+ Fried potato sticks
+ |
+
+
+ |
+ 42
+ |
+
+ Quarter-Pounder
+ |
+
+ Big burger
+ |
+
+
+ |
+ -84
+ |
+
+ Icecream
+ |
+
+ Cold dessert
+ |
+
+
+
+`;
diff --git a/scm-ui/ui-components/src/apiclient.test.ts b/scm-ui/ui-components/src/apiclient.test.ts
index 93f535728f..871d8089b7 100644
--- a/scm-ui/ui-components/src/apiclient.test.ts
+++ b/scm-ui/ui-components/src/apiclient.test.ts
@@ -1,4 +1,4 @@
-import { apiClient, createUrl } from "./apiclient";
+import { apiClient, createUrl, extractXsrfTokenFromCookie } from "./apiclient";
import fetchMock from "fetch-mock";
import { BackendError } from "./errors";
@@ -70,3 +70,22 @@ describe("error handling tests", () => {
});
});
});
+
+describe("extract xsrf token", () => {
+ it("should return undefined if no cookie exists", () => {
+ const token = extractXsrfTokenFromCookie(undefined);
+ expect(token).toBeUndefined();
+ });
+
+ it("should return undefined without X-Bearer-Token exists", () => {
+ const token = extractXsrfTokenFromCookie("a=b; c=d; e=f");
+ expect(token).toBeUndefined();
+ });
+
+ it("should return xsrf token", () => {
+ const cookie =
+ "a=b; X-Bearer-Token=eyJhbGciOiJIUzI1NiJ9.eyJ4c3JmIjoiYjE0NDRmNWEtOWI5Mi00ZDA0LWFkMzMtMTAxYjY3MWQ1YTc0Iiwic3ViIjoic2NtYWRtaW4iLCJqdGkiOiI2RFJpQVphNWwxIiwiaWF0IjoxNTc0MDcyNDQ4LCJleHAiOjE1NzQwNzYwNDgsInNjbS1tYW5hZ2VyLnJlZnJlc2hFeHBpcmF0aW9uIjoxNTc0MTE1NjQ4OTU5LCJzY20tbWFuYWdlci5wYXJlbnRUb2tlbklkIjoiNkRSaUFaYTVsMSJ9.VUJtKeWUn3xtHCEbG51r7ceXZ8CF3cmN8J-eb9EDY_U; c=d";
+ const token = extractXsrfTokenFromCookie(cookie);
+ expect(token).toBe("b1444f5a-9b92-4d04-ad33-101b671d5a74");
+ });
+});
diff --git a/scm-ui/ui-components/src/apiclient.ts b/scm-ui/ui-components/src/apiclient.ts
index 60789ff5dd..bd7f4eb6ab 100644
--- a/scm-ui/ui-components/src/apiclient.ts
+++ b/scm-ui/ui-components/src/apiclient.ts
@@ -9,14 +9,52 @@ const sessionId = (
.substr(2, 5)
).toUpperCase();
+const extractXsrfTokenFromJwt = (jwt: string) => {
+ const parts = jwt.split(".");
+ if (parts.length === 3) {
+ return JSON.parse(atob(parts[1])).xsrf;
+ }
+};
+
+// @VisibleForTesting
+export const extractXsrfTokenFromCookie = (cookieString?: string) => {
+ if (cookieString) {
+ const cookies = cookieString.split(";");
+ for (const c of cookies) {
+ const parts = c.trim().split("=");
+ if (parts[0] === "X-Bearer-Token") {
+ return extractXsrfTokenFromJwt(parts[1]);
+ }
+ }
+ }
+};
+
+const extractXsrfToken = () => {
+ return extractXsrfTokenFromCookie(document.cookie);
+};
+
const applyFetchOptions: (p: RequestInit) => RequestInit = o => {
+ if (!o.headers) {
+ o.headers = {};
+ }
+
+ // @ts-ignore We are sure that here we only get headers of type Record
+ const headers: Record = o.headers;
+ headers["Cache"] = "no-cache";
+ // identify the request as ajax request
+ headers["X-Requested-With"] = "XMLHttpRequest";
+ // identify the web interface
+ headers["X-SCM-Client"] = "WUI";
+ // identify the window session
+ headers["X-SCM-Session-ID"] = sessionId
+
+ const xsrf = extractXsrfToken();
+ if (xsrf) {
+ headers["X-XSRF-Token"] = xsrf;
+ }
+
o.credentials = "same-origin";
- o.headers = {
- Cache: "no-cache",
- // identify the request as ajax request
- "X-Requested-With": "XMLHttpRequest",
- "X-SCM-Session-ID": sessionId
- };
+ o.headers = headers;
return o;
};
@@ -55,23 +93,32 @@ class ApiClient {
return fetch(createUrl(url), applyFetchOptions({})).then(handleFailure);
}
- post(url: string, payload: any, contentType = "application/json") {
- return this.httpRequestWithJSONBody("POST", url, contentType, payload);
+ post(url: string, payload?: any, contentType = "application/json", additionalHeaders: Record = {}) {
+ return this.httpRequestWithJSONBody("POST", url, contentType, additionalHeaders, payload);
}
- postBinary(url: string, fileAppender: (p: FormData) => void) {
+ postText(url: string, payload: string, additionalHeaders: Record = {}) {
+ return this.httpRequestWithTextBody("POST", url, additionalHeaders, payload);
+ }
+
+ putText(url: string, payload: string, additionalHeaders: Record = {}) {
+ return this.httpRequestWithTextBody("PUT", url, additionalHeaders, payload);
+ }
+
+ postBinary(url: string, fileAppender: (p: FormData) => void, additionalHeaders: Record = {}) {
const formData = new FormData();
fileAppender(formData);
const options: RequestInit = {
method: "POST",
- body: formData
+ body: formData,
+ headers: additionalHeaders
};
return this.httpRequestWithBinaryBody(options, url);
}
- put(url: string, payload: any, contentType = "application/json") {
- return this.httpRequestWithJSONBody("PUT", url, contentType, payload);
+ put(url: string, payload: any, contentType = "application/json", additionalHeaders: Record = {}) {
+ return this.httpRequestWithJSONBody("PUT", url, contentType, additionalHeaders, payload);
}
head(url: string) {
@@ -90,21 +137,44 @@ class ApiClient {
return fetch(createUrl(url), options).then(handleFailure);
}
- httpRequestWithJSONBody(method: string, url: string, contentType: string, payload: any): Promise {
+ httpRequestWithJSONBody(
+ method: string,
+ url: string,
+ contentType: string,
+ additionalHeaders: Record,
+ payload?: any
+ ): Promise {
const options: RequestInit = {
method: method,
- body: JSON.stringify(payload)
+ headers: additionalHeaders
};
+ if (payload) {
+ options.body = JSON.stringify(payload);
+ }
return this.httpRequestWithBinaryBody(options, url, contentType);
}
+ httpRequestWithTextBody(
+ method: string,
+ url: string,
+ additionalHeaders: Record = {},
+ payload: string
+ ) {
+ const options: RequestInit = {
+ method: method,
+ headers: additionalHeaders
+ };
+ options.body = payload;
+ return this.httpRequestWithBinaryBody(options, url, "text/plain");
+ }
+
httpRequestWithBinaryBody(options: RequestInit, url: string, contentType?: string) {
options = applyFetchOptions(options);
if (contentType) {
if (!options.headers) {
- options.headers = new Headers();
+ options.headers = {};
}
- // @ts-ignore
+ // @ts-ignore We are sure that here we only get headers of type Record
options.headers["Content-Type"] = contentType;
}
diff --git a/scm-ui/ui-components/src/buttons/Button.tsx b/scm-ui/ui-components/src/buttons/Button.tsx
index 8c1004ac52..3507ba7a06 100644
--- a/scm-ui/ui-components/src/buttons/Button.tsx
+++ b/scm-ui/ui-components/src/buttons/Button.tsx
@@ -18,6 +18,7 @@ export type ButtonProps = {
type Props = ButtonProps &
RouteComponentProps & {
+ title?: string;
type?: "button" | "submit" | "reset";
color?: string;
};
@@ -38,7 +39,19 @@ class Button extends React.Component {
};
render() {
- const { label, loading, disabled, type, color, className, icon, fullWidth, reducedMobile, children } = this.props;
+ const {
+ label,
+ title,
+ loading,
+ disabled,
+ type,
+ color,
+ className,
+ icon,
+ fullWidth,
+ reducedMobile,
+ children
+ } = this.props;
const loadingClass = loading ? "is-loading" : "";
const fullWidthClass = fullWidth ? "is-fullwidth" : "";
const reducedMobileClass = reducedMobile ? "is-reduced-mobile" : "";
@@ -46,6 +59,7 @@ class Button extends React.Component {
return (