Skip to content
Open
38 changes: 38 additions & 0 deletions frontend/src/v1/lib/http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import axios from "axios";

const SAFE_METHODS = new Set(["get", "head", "options"]);

function cookie(name: string): string | undefined {
const prefix = `${name}=`;
const entry = document.cookie
.split("; ")
.find((part) => part.startsWith(prefix));
if (entry === undefined) {
return undefined;
}
try {
return decodeURIComponent(entry.slice(prefix.length));
} catch {
// A malformed value is no token: send no header and let the server refuse.
return undefined;
}
}

/**
* The one HTTP client v1 services use. Django's session cookie authenticates;
* unsafe methods carry the CSRF cookie back as X-CSRFToken. Set here rather
* than through axios's xsrf options, which only the browser adapter applies.
* ponytail: no 401 or error-toast handling yet; add it when a page needs it.
*/
export const http = axios.create({ withCredentials: true });

http.interceptors.request.use((config) => {
const token = cookie("csrftoken");
if (
token !== undefined &&
!SAFE_METHODS.has((config.method ?? "get").toLowerCase())
) {
config.headers.set("X-CSRFToken", token);
}
return config;
});
109 changes: 109 additions & 0 deletions frontend/src/v1/lib/organizations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { describe, expect, test } from "vitest";
import {
describeOrganization,
initial,
initials,
landingPath,
organizationInitials,
roleLabel,
} from "./organizations";

describe("roleLabel", () => {
test.each([
["admin", "Admin"],
["unstract_admin", "Admin"],
["user", "Member"],
["unstract_user", "Member"],
["unstract_supervisor", "Supervisor"],
["unstract_reviewer", "Reviewer"],
])("%s reads %s", (role, label) => {
expect(roleLabel(role)).toBe(label);
});

test("an unknown role is not guessed", () => {
expect(roleLabel("auditor")).toBeUndefined();
expect(roleLabel(undefined)).toBeUndefined();
});
});

describe("describeOrganization", () => {
test("role, member count and region, as frame NEW10 shows", () => {
expect(
describeOrganization({ role: "admin", member_count: 40, region: "US" }),
).toBe("Admin · 40 members · US");
});

test("one member is singular", () => {
expect(describeOrganization({ role: "admin", member_count: 1 })).toBe(
"Admin · 1 member",
);
});

test("absent fields are left out, never guessed (OSS sends no region)", () => {
expect(describeOrganization({ role: "user" })).toBe("Member");
expect(describeOrganization({ member_count: 3 })).toBe("3 members");
expect(describeOrganization({})).toBe("");
});
});

describe("initials", () => {
test.each([
["Zipstack", "Z"],
["Tanushree Daga", "TD"],
["Tanushree’s organisation", "TO"],
[" ", "?"],
])("%s gives %s", (name, expected) => {
expect(initials(name)).toBe(expected);
});
});

describe("initial", () => {
test.each([
["Tanushree Daga", "T"],
["[email protected]", "A"],
[" ", "?"],
])("%s gives %s", (name, expected) => {
expect(initial(name)).toBe(expected);
});
});

describe("organizationInitials", () => {
test.each([
["Zipstack", "ZI"],
["Tanushree’s organisation", "TO"],
["Acme Data Labs", "AD"],
["X", "X"],
[" ", "?"],
])("%s gives %s", (name, expected) => {
expect(organizationInitials(name)).toBe(expected);
});
});

describe("landingPath", () => {
test("keeps the section in the new organisation", () => {
expect(landingPath("/zipstack/workflows", "zipstack", "tanushree")).toBe(
"/tanushree/workflows",
);
});

test("drops anything under the section: that record may not exist there", () => {
expect(
landingPath("/zipstack/tools/123/settings", "zipstack", "tanushree"),
).toBe("/tanushree/tools");
});

test("falls back to the dashboard when the path has no section", () => {
expect(landingPath("/zipstack", "zipstack", "tanushree")).toBe(
"/tanushree/dashboard",
);
expect(landingPath("/zipstack/", "zipstack", "tanushree")).toBe(
"/tanushree/dashboard",
);
});

test("falls back to the dashboard outside the current organisation's paths", () => {
expect(landingPath("/landing", "zipstack", "tanushree")).toBe(
"/tanushree/dashboard",
);
});
});
81 changes: 81 additions & 0 deletions frontend/src/v1/lib/organizations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/** Wording and navigation rules for the organisation switcher (page 19). */

// Stored roles: `admin`/`user` in OSS, `unstract_*` in cloud (account_v2/ReadMe.md).
const ROLE_LABELS: Record<string, string> = {
admin: "Admin",
unstract_admin: "Admin",
user: "Member",
unstract_user: "Member",
unstract_supervisor: "Supervisor",
unstract_reviewer: "Reviewer",
};

export function roleLabel(role: string | undefined): string | undefined {
return role === undefined ? undefined : ROLE_LABELS[role];
}

export interface OrganizationDetails {
role?: string;
member_count?: number;
region?: string;
}

/**
* "Admin · 40 members · US". The list leaves a field out when this deployment
* cannot answer for it (OSS has no regions), so a missing part is skipped.
*/
export function describeOrganization({
role,
member_count,
region,
}: OrganizationDetails): string {
const members =
member_count === undefined
? undefined
: `${member_count} member${member_count === 1 ? "" : "s"}`;

Check warning on line 35 in frontend/src/v1/lib/organizations.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=Zipstack_unstract&issues=AaGBOz5eUvgOB0NM6fhk&open=AaGBOz5eUvgOB0NM6fhk&pullRequest=2395
return [roleLabel(role), members, region].filter(Boolean).join(" · ");
}

export function initials(name: string): string {
const words = name.trim().split(/\s+/).filter(Boolean);
if (words.length === 0) {
return "?";
}
return words
.slice(0, 2)
.map((word) => word[0].toUpperCase())
.join("");
}

/** One letter, for the small marks (the account trigger, the organisation pill). */
export function initial(name: string): string {
return name.trim()[0]?.toUpperCase() ?? "?";
}

/** Two letters for an organisation avatar; a one-word name gives its first two. */
export function organizationInitials(name: string): string {
const words = name.trim().split(/\s+/).filter(Boolean);
if (words.length === 0) {
return "?";
}
const letters =
words.length === 1 ? [...words[0]].slice(0, 2) : words.map((w) => w[0]);
return letters.slice(0, 2).join("").toUpperCase();
}

/**
* Where the app lands after switching: the same section in the new
* organisation, else its dashboard. Paths are `/<org name>/<section>/...`.
* ponytail: keeps only the section, since a record id below it belongs to the
* old organisation; ask the route registry which sections exist once it does.
*/
export function landingPath(
pathname: string,
fromOrg: string,
toOrg: string,
): string {
const [org, section] = pathname.split("/").filter(Boolean);
return org === fromOrg && section
? `/${toOrg}/${section}`
: `/${toOrg}/dashboard`;
}
22 changes: 22 additions & 0 deletions frontend/src/v1/services/organizations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { http } from "@v1/lib/http";
import type { OrganizationDetails } from "@v1/lib/organizations";

/** A row of GET /api/v1/organization. role, member_count and region may be absent. */
export interface Organization extends OrganizationDetails {
id: string;
/** The URL name: paths are /<name>/... */
name: string;
display_name: string;
}

export async function listOrganizations(): Promise<Organization[]> {
const { data } = await http.get<{ organizations: Organization[] }>(
"/api/v1/organization",
);
return data.organizations;
}

/** Makes the organisation the session's current one. */
export async function switchOrganization(id: string): Promise<void> {
await http.post(`/api/v1/organization/${encodeURIComponent(id)}/set`);
}
144 changes: 144 additions & 0 deletions frontend/src/v1/services/services.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { http } from "@v1/lib/http";
import type { AxiosRequestConfig, InternalAxiosRequestConfig } from "axios";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { listOrganizations, switchOrganization } from "./organizations";
import { signOut, signOutAndLeave } from "./session";

const reset = vi.fn();
vi.mock("posthog-js", () => ({ default: { reset: () => reset() } }));

const sent: AxiosRequestConfig[] = [];
let reply: { status: number; data: unknown };

beforeEach(() => {
sent.length = 0;
reset.mockReset();
reply = { status: 200, data: {} };
http.defaults.adapter = async (config: InternalAxiosRequestConfig) => {
sent.push(config);
const response = { ...reply, statusText: "", headers: {}, config };
if (reply.status >= 400) {
throw Object.assign(new Error(`HTTP ${reply.status}`), { response });
}
return response;
};
// biome-ignore lint/suspicious/noDocumentCookie: the client reads this cookie
document.cookie = "csrftoken=tok123";
});

afterEach(() => {
// biome-ignore lint/suspicious/noDocumentCookie: clears the cookie set above
document.cookie = "csrftoken=; expires=Thu, 01 Jan 1970 00:00:00 GMT";
vi.unstubAllGlobals();
});

describe("organizations", () => {
test("lists the user's organisations", async () => {
reply.data = {
organizations: [
{
id: "org_1",
name: "zipstack",
display_name: "Zipstack",
role: "admin",
},
],
};
await expect(listOrganizations()).resolves.toEqual([
{
id: "org_1",
name: "zipstack",
display_name: "Zipstack",
role: "admin",
},
]);
expect(sent[0]).toMatchObject({
method: "get",
url: "/api/v1/organization",
});
expect(sent[0].headers?.["X-CSRFToken"]).toBeUndefined();
});

test("switching posts to the organisation's set endpoint with the CSRF token", async () => {
await switchOrganization("org/1");
expect(sent[0]).toMatchObject({
method: "post",
url: "/api/v1/organization/org%2F1/set",
});
expect(sent[0].headers?.["X-CSRFToken"]).toBe("tok123");
});

test("a failed request rejects instead of resolving empty", async () => {
reply = { status: 500, data: {} };
await expect(listOrganizations()).rejects.toThrow("HTTP 500");
});
});

describe("http CSRF cookie", () => {
test("a malformed cookie sends no header instead of throwing", async () => {
// biome-ignore lint/suspicious/noDocumentCookie: sets a cookie that cannot be decoded
document.cookie = "csrftoken=%E0%A4%A";
await expect(switchOrganization("org_1")).resolves.toBeUndefined();
expect(sent[0].headers?.["X-CSRFToken"]).toBeUndefined();
});

test("a missing cookie sends no header", async () => {
// biome-ignore lint/suspicious/noDocumentCookie: clears the cookie set in beforeEach
document.cookie = "csrftoken=; expires=Thu, 01 Jan 1970 00:00:00 GMT";
await switchOrganization("org_1");
expect(sent[0].headers?.["X-CSRFToken"]).toBeUndefined();
});
});

describe("signOut", () => {
test("posts to logout with the CSRF token and returns where to go", async () => {
reply.data = { redirect_url: "https://id.example.com/v2/logout" };
await expect(signOut()).resolves.toBe("https://id.example.com/v2/logout");
expect(sent[0]).toMatchObject({ method: "post", url: "/api/v1/logout" });
expect(sent[0].headers?.["X-CSRFToken"]).toBe("tok123");
});

test("a reply without a redirect URL is an error, not /undefined", async () => {
reply.data = {};
await expect(signOut()).rejects.toThrow();
reply.data = { redirect_url: "" };
await expect(signOut()).rejects.toThrow();
});

test("only an http(s) redirect is followed; a relative one resolves on this origin", async () => {
reply.data = { redirect_url: "javascript:alert(1)" };
await expect(signOut()).rejects.toThrow();
reply.data = { redirect_url: "/landing" };
await expect(signOut()).resolves.toBe(`${window.location.origin}/landing`);
});
});

describe("signOutAndLeave", () => {
const assign = vi.fn();
beforeEach(() => {
assign.mockReset();
vi.stubGlobal("location", { ...window.location, assign });
});

test("resets analytics and opens the returned URL", async () => {
reply.data = { redirect_url: "https://id.example.com/v2/logout" };
await signOutAndLeave();
expect(reset).toHaveBeenCalledTimes(1);
expect(assign).toHaveBeenCalledWith("https://id.example.com/v2/logout");
});

test("a failed POST resets analytics and falls back to the GET logout", async () => {
reply = { status: 403, data: {} };
await signOutAndLeave();
expect(reset).toHaveBeenCalledTimes(1);
expect(assign).toHaveBeenCalledWith("/api/v1/logout");
});

test("a reply without a redirect URL falls back to the GET logout", async () => {
reply.data = {};
await signOutAndLeave();
expect(reset).toHaveBeenCalledTimes(1);
expect(assign).toHaveBeenCalledWith("/api/v1/logout");
expect(assign).not.toHaveBeenCalledWith(undefined);
});
});
Loading
Loading