Skip to content

Commit 4544b9f

Browse files
committed
feat(studio): bulk label purchase + admin user CRUD (EBE-107/EBE-108)
- Shipments: 'Buy labels' bulk action batch-buys selected drafts via POST /v1/batches/shipments, then routes to Batches (useBulkBuyLabels) - Team & roles: invite (create_user), edit role/active (update_user), remove (remove_user) over the ADMIN GraphQL schema, with role↔Django flag mapping (owner→superuser, admin→staff, member→neither) - Tests: bulk-buy navigation + payload, team invite/edit flows - Harden shell nav assertion to an ungated govern item (team), since tenants is gated behind MULTI_ORGANIZATIONS (off on OSS)
1 parent eefd5fe commit 4544b9f

7 files changed

Lines changed: 331 additions & 12 deletions

File tree

apps/studio/src/lib/karrio/hooks/govern.ts

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// canonical GraphQL: team + admin overview from the ADMIN schema
33
// (/admin/graphql), usage + audit from the tenant schema. `tenants` has no OSS
44
// source (no `accounts`) → empty/EE state. See STUDIO_GRAPHQL_REBUILD.md.
5-
import { useQuery } from "@tanstack/react-query";
5+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
66
import { adminGraphql, graphql } from "~/lib/karrio/client";
77
import { useKarrioCtx } from "~/lib/karrio/session";
88
import { graphqlEdges, keyExtra } from "~/lib/karrio/hooks/_shared";
@@ -49,6 +49,83 @@ export function useTeam() {
4949
});
5050
}
5151

52+
// --- Team mutations (ADMIN GraphQL create/update/remove user) ---------------
53+
// Role is encoded onto the Django flags the admin schema exposes:
54+
// owner → is_superuser admin → is_staff member → neither.
55+
type Role = "owner" | "admin" | "member";
56+
const roleFlags = (role: Role) => ({
57+
is_superuser: role === "owner",
58+
is_staff: role === "owner" || role === "admin",
59+
});
60+
61+
const CREATE_USER = `mutation($input: CreateUserMutationInput!) {
62+
create_user(input: $input) { user { id email } errors { field messages } }
63+
}`;
64+
const UPDATE_USER = `mutation($input: UpdateUserMutationInput!) {
65+
update_user(input: $input) { user { id email } errors { field messages } }
66+
}`;
67+
const REMOVE_USER = `mutation($input: DeleteUserMutationInput!) {
68+
remove_user(input: $input) { id }
69+
}`;
70+
71+
export type InviteUserInput = { email: string; full_name?: string; role: Role; redirect_url?: string };
72+
73+
// create_user requires password1/password2; for an invite we generate a random
74+
// one-time password (the user resets via the email flow / redirect_url).
75+
function randomPassword(): string {
76+
const r = () => Math.random().toString(36).slice(2);
77+
return `St${r()}${r()}!`.slice(0, 24);
78+
}
79+
80+
export function useInviteUser() {
81+
const ctx = useKarrioCtx();
82+
const qc = useQueryClient();
83+
return useMutation({
84+
mutationFn: (vars: InviteUserInput) => {
85+
const password = randomPassword();
86+
return adminGraphql(ctx, CREATE_USER, {
87+
input: {
88+
email: vars.email,
89+
full_name: vars.full_name || undefined,
90+
password1: password,
91+
password2: password,
92+
redirect_url: vars.redirect_url || (typeof window !== "undefined" ? window.location.origin : undefined),
93+
...roleFlags(vars.role),
94+
},
95+
});
96+
},
97+
onSuccess: () => qc.invalidateQueries({ queryKey: ["team"] }),
98+
});
99+
}
100+
101+
export type UpdateUserInput = { id: string; role?: Role; is_active?: boolean; full_name?: string };
102+
103+
export function useUpdateUser() {
104+
const ctx = useKarrioCtx();
105+
const qc = useQueryClient();
106+
return useMutation({
107+
mutationFn: (vars: UpdateUserInput) =>
108+
adminGraphql(ctx, UPDATE_USER, {
109+
input: {
110+
id: Number(vars.id),
111+
...(vars.full_name !== undefined ? { full_name: vars.full_name } : {}),
112+
...(vars.is_active !== undefined ? { is_active: vars.is_active } : {}),
113+
...(vars.role ? roleFlags(vars.role) : {}),
114+
},
115+
}),
116+
onSuccess: () => qc.invalidateQueries({ queryKey: ["team"] }),
117+
});
118+
}
119+
120+
export function useRemoveUser() {
121+
const ctx = useKarrioCtx();
122+
const qc = useQueryClient();
123+
return useMutation({
124+
mutationFn: (id: string) => adminGraphql(ctx, REMOVE_USER, { input: { id: Number(id) } }),
125+
onSuccess: () => qc.invalidateQueries({ queryKey: ["team"] }),
126+
});
127+
}
128+
52129
// --- Admin overview (ADMIN GraphQL `worker_health`) -------------------------
53130
const ADMIN_OVERVIEW_QUERY = `query {
54131
worker_health { is_available }

apps/studio/src/lib/karrio/hooks/resources.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,3 +416,19 @@ export function useDeleteDocumentTemplate() {
416416
onSuccess: () => qc.invalidateQueries({ queryKey: ["document-templates"] }),
417417
});
418418
}
419+
420+
// === Bulk label purchase (REST batch operation) =============================
421+
// POST /v1/batches/shipments with references to existing shipments → creates a
422+
// batch operation that buys labels for the selected (purchasable) shipments.
423+
export function useBulkBuyLabels() {
424+
const ctx = useKarrioCtx();
425+
const qc = useQueryClient();
426+
return useMutation({
427+
mutationFn: (shipmentIds: string[]) =>
428+
restMutate(ctx, "POST", "/v1/batches/shipments", { shipments: shipmentIds.map((id) => ({ id })) }),
429+
onSuccess: () => {
430+
qc.invalidateQueries({ queryKey: ["batches"] });
431+
qc.invalidateQueries({ queryKey: ["shipments"] });
432+
},
433+
});
434+
}
Lines changed: 131 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,156 @@
1-
// TeamScreen.tsx — Govern › Team & roles (E3).
2-
import { useMemo } from "react";
1+
// TeamScreen.tsx — Govern › Team & roles (E3). Real admin user management:
2+
// invite (create_user), edit role/active (update_user), remove (remove_user)
3+
// via the ADMIN GraphQL schema. (EBE-108)
4+
import { useMemo, useState } from "react";
35
import { Icon } from "~/components/ui/icons";
46
import { PageHeader, StateRow, StatusPill, TableFooter } from "~/components/ui/primitives";
5-
import { useTeam } from "~/lib/karrio/hooks";
7+
import { Sheet, Field } from "~/components/ui/Sheet";
8+
import { useInviteUser, useRemoveUser, useTeam, useUpdateUser } from "~/lib/karrio/hooks";
9+
import type { TeamMember } from "~/lib/karrio/types";
10+
11+
type Role = "owner" | "admin" | "member";
12+
const ROLES: Role[] = ["owner", "admin", "member"];
13+
const asRole = (r?: string): Role => (r === "owner" || r === "admin" ? r : "member");
14+
15+
type EditorState = "closed" | "invite" | { edit: TeamMember };
616

717
export function TeamScreen() {
18+
const [editor, setEditor] = useState<EditorState>("closed");
819
const { data, isLoading, isError, error } = useTeam();
920
const rows = useMemo(() => data?.results ?? [], [data]);
21+
1022
return (
1123
<div className="page" data-testid="screen-team">
12-
<PageHeader title="Team & roles" actions={<button className="btn btn-primary"><Icon.Plus size={14} /> Invite member</button>} />
24+
<PageHeader
25+
title="Team & roles"
26+
actions={
27+
<button className="btn btn-primary" onClick={() => setEditor("invite")} data-testid="team-invite">
28+
<Icon.Plus size={14} /> Invite member
29+
</button>
30+
}
31+
/>
1332
<div className="card card-scroll">
1433
<table className="table">
15-
<thead><tr><th>Member</th><th>Email</th><th>Role</th><th>Status</th></tr></thead>
34+
<thead><tr><th>Member</th><th>Email</th><th>Role</th><th>Status</th><th className="actions-cell" /></tr></thead>
1635
<tbody>
17-
{isLoading && <StateRow colSpan={4} kind="loading" message="Loading team…" />}
18-
{isError && !isLoading && <StateRow colSpan={4} kind="error" message={(error as Error)?.message ?? "Failed to load"} />}
19-
{!isLoading && !isError && rows.length === 0 && <StateRow colSpan={4} kind="empty" message="No members." />}
36+
{isLoading && <StateRow colSpan={5} kind="loading" message="Loading team…" />}
37+
{isError && !isLoading && <StateRow colSpan={5} kind="error" message={(error as Error)?.message ?? "Failed to load"} />}
38+
{!isLoading && !isError && rows.length === 0 && <StateRow colSpan={5} kind="empty" message="No members." />}
2039
{rows.map((m) => (
21-
<tr key={m.id} data-testid={`member-row-${m.id}`}>
40+
<tr key={m.id} onClick={() => setEditor({ edit: m })} data-testid={`member-row-${m.id}`}>
2241
<td className="recipient-name">{m.name ?? "—"}</td>
2342
<td className="mono" style={{ fontSize: 12 }}>{m.email}</td>
2443
<td><span className="tag">{m.role ?? "member"}</span></td>
2544
<td><StatusPill status={m.status} /></td>
45+
<td className="actions-cell" onClick={(e) => e.stopPropagation()}><span className="icon-action"><Icon.Dots size={14} /></span></td>
2646
</tr>
2747
))}
2848
</tbody>
2949
</table>
3050
<TableFooter shown={rows.length} total={data?.count ?? rows.length} noun="members" />
3151
</div>
52+
53+
{editor !== "closed" && (
54+
<MemberEditor
55+
key={editor === "invite" ? "invite" : editor.edit.id}
56+
member={editor === "invite" ? undefined : editor.edit}
57+
onClose={() => setEditor("closed")}
58+
/>
59+
)}
3260
</div>
3361
);
3462
}
63+
64+
function MemberEditor({ member, onClose }: { member?: TeamMember; onClose: () => void }) {
65+
const invite = useInviteUser();
66+
const update = useUpdateUser();
67+
const remove = useRemoveUser();
68+
69+
const [email, setEmail] = useState(member?.email ?? "");
70+
const [name, setName] = useState(member?.name ?? "");
71+
const [role, setRole] = useState<Role>(asRole(member?.role));
72+
const [active, setActive] = useState(member ? member.status !== "inactive" : true);
73+
const [err, setErr] = useState<string | null>(null);
74+
75+
const onSubmit = async (e: React.FormEvent) => {
76+
e.preventDefault();
77+
setErr(null);
78+
try {
79+
if (member) {
80+
await update.mutateAsync({ id: member.id, role, is_active: active, full_name: name });
81+
} else {
82+
if (!email.trim()) return setErr("An email address is required.");
83+
await invite.mutateAsync({ email: email.trim(), full_name: name.trim() || undefined, role });
84+
}
85+
onClose();
86+
} catch (e2) {
87+
setErr((e2 as Error)?.message ?? "Could not save the member.");
88+
}
89+
};
90+
91+
const onRemove = async () => {
92+
if (!member) return;
93+
setErr(null);
94+
try {
95+
await remove.mutateAsync(member.id);
96+
onClose();
97+
} catch (e2) {
98+
setErr((e2 as Error)?.message ?? "Could not remove the member.");
99+
}
100+
};
101+
102+
const pending = invite.isPending || update.isPending;
103+
104+
return (
105+
<Sheet
106+
open onClose={onClose} size="sm" crumb="Team & roles"
107+
title={member ? "Edit member" : "Invite member"} id={member?.id}
108+
footer={
109+
<>
110+
{member && (
111+
<button className="btn" onClick={onRemove} disabled={remove.isPending} data-testid="member-remove">
112+
{remove.isPending ? "Removing…" : "Remove"}
113+
</button>
114+
)}
115+
<div style={{ flex: 1 }} />
116+
<button className="btn" onClick={onClose}>Cancel</button>
117+
<button className="btn btn-primary" form="member-form" type="submit" disabled={pending} data-testid="member-save">
118+
{pending ? "Saving…" : member ? "Save changes" : "Send invite"}
119+
</button>
120+
</>
121+
}
122+
>
123+
<form id="member-form" className="sheet-body-pad" onSubmit={onSubmit} data-testid="member-sheet-body">
124+
<Field label="Email *">
125+
<input
126+
className="field-input" type="email" value={email} disabled={!!member}
127+
onChange={(e) => setEmail(e.target.value)} data-testid="member-email"
128+
/>
129+
</Field>
130+
<Field label="Full name">
131+
<input className="field-input" value={name} onChange={(e) => setName(e.target.value)} data-testid="member-name" />
132+
</Field>
133+
<Field label="Role">
134+
<select className="field-input" value={role} onChange={(e) => setRole(e.target.value as Role)} data-testid="member-role">
135+
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
136+
</select>
137+
</Field>
138+
{member && (
139+
<Field label="Status">
140+
<select className="field-input" value={active ? "active" : "inactive"} onChange={(e) => setActive(e.target.value === "active")} data-testid="member-status">
141+
<option value="active">active</option>
142+
<option value="inactive">inactive</option>
143+
</select>
144+
</Field>
145+
)}
146+
{!member && (
147+
<p className="muted" style={{ fontSize: 12, marginTop: 8 }}>
148+
An invitation email is sent to set up the account. Roles map to access:
149+
owner (full), admin (staff), member (standard).
150+
</p>
151+
)}
152+
{err && <div className="auth-error" data-testid="member-form-error">{err}</div>}
153+
</form>
154+
</Sheet>
155+
);
156+
}

apps/studio/src/screens/ship/ShipmentsScreen.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ import {
1414
Tabs,
1515
type TabDef,
1616
} from "~/components/ui/primitives";
17-
import { useShipments } from "~/lib/karrio/hooks";
17+
import { useNavigate } from "@tanstack/react-router";
18+
import { useShipments, useBulkBuyLabels } from "~/lib/karrio/hooks";
1819
import {
1920
carrierKey,
2021
formatDate,
@@ -94,6 +95,20 @@ export function ShipmentsScreen() {
9495
};
9596
const selectedRows = useMemo(() => all.filter((s) => selected.includes(s.id)), [all, selected]);
9697

98+
// Bulk: buy labels for the selected shipments (batch operation), then jump to Batches.
99+
const navigate = useNavigate();
100+
const bulkBuy = useBulkBuyLabels();
101+
const buyLabels = async () => {
102+
if (selected.length === 0) return;
103+
try {
104+
await bulkBuy.mutateAsync(selected);
105+
setSelected([]);
106+
void navigate({ to: "/$screen", params: { screen: "batches" } });
107+
} catch {
108+
/* surfaced inline below via bulkBuy.isError */
109+
}
110+
};
111+
97112
return (
98113
<div className="page" data-testid="screen-shipments">
99114
<PageHeader
@@ -107,6 +122,9 @@ export function ShipmentsScreen() {
107122
<button className="btn" onClick={() => printLabels(selectedRows)} data-testid="bulk-print"><Icon.Print size={14} /> Print labels</button>
108123
<button className="btn" onClick={() => exportShipments(selectedRows)} data-testid="bulk-export"><Icon.Download size={14} /> Export</button>
109124
<button className="btn" onClick={() => setSelected([])} data-testid="bulk-clear">Clear</button>
125+
<button className="btn btn-primary" onClick={buyLabels} disabled={bulkBuy.isPending} data-testid="bulk-buy">
126+
<Icon.Tag size={14} /> {bulkBuy.isPending ? "Buying…" : "Buy labels"}
127+
</button>
110128
</>
111129
) : (
112130
<>

packages/e2e/tests/studio/govern.spec.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,63 @@ test.describe("Govern mode (E)", () => {
6969
await expect(page.getByTestId("audit-row-ev_1")).toContainText("shipment.purchased");
7070
});
7171

72+
test("Team: invite a member via the admin schema", async ({ page }) => {
73+
// Dynamic users list so the refetch after create_user shows the new member.
74+
const users = [{ id: 1, email: "dan@karrio.io", full_name: "Daniel K", is_active: true, is_staff: true, is_superuser: true }];
75+
let createInput: Record<string, unknown> | undefined;
76+
await page.route("**/admin/graphql", (route) => {
77+
if (route.request().method() === "OPTIONS") return route.fulfill({ status: 204, headers: CORS, body: "" });
78+
const q = route.request().postData() ?? "";
79+
if (q.includes("create_user")) {
80+
const vars = JSON.parse(q).variables ?? {};
81+
createInput = vars.input;
82+
users.push({ id: 2, email: createInput.email as string, full_name: (createInput.full_name as string) ?? "", is_active: true, is_staff: !!createInput.is_staff, is_superuser: !!createInput.is_superuser });
83+
return json(route, { data: { create_user: { user: { id: 2, email: createInput.email }, errors: [] } } });
84+
}
85+
if (q.includes("worker_health")) return json(route, GQL_ADMIN.worker_health);
86+
return json(route, { data: { users: { edges: users.map((node) => ({ node })) } } });
87+
});
88+
89+
await page.goto("/team");
90+
await expect(page.getByTestId("member-row-1")).toBeVisible();
91+
await page.getByTestId("team-invite").click();
92+
await page.getByTestId("member-email").fill("newbie@karrio.io");
93+
await page.getByTestId("member-name").fill("New Bie");
94+
await page.getByTestId("member-role").selectOption("admin");
95+
await page.getByTestId("member-save").click();
96+
97+
await expect(page.getByTestId("member-row-2")).toContainText("newbie@karrio.io");
98+
expect(createInput?.email).toBe("newbie@karrio.io");
99+
expect(createInput?.is_staff).toBe(true);
100+
expect(createInput?.is_superuser).toBe(false);
101+
});
102+
103+
test("Team: edit a member role via update_user", async ({ page }) => {
104+
const users = [{ id: 1, email: "dan@karrio.io", full_name: "Daniel K", is_active: true, is_staff: true, is_superuser: true }];
105+
let updateInput: Record<string, unknown> | undefined;
106+
await page.route("**/admin/graphql", (route) => {
107+
if (route.request().method() === "OPTIONS") return route.fulfill({ status: 204, headers: CORS, body: "" });
108+
const q = route.request().postData() ?? "";
109+
if (q.includes("update_user")) {
110+
updateInput = (JSON.parse(q).variables ?? {}).input;
111+
return json(route, { data: { update_user: { user: { id: 1, email: "dan@karrio.io" }, errors: [] } } });
112+
}
113+
if (q.includes("worker_health")) return json(route, GQL_ADMIN.worker_health);
114+
return json(route, { data: { users: { edges: users.map((node) => ({ node })) } } });
115+
});
116+
117+
await page.goto("/team");
118+
await page.getByTestId("member-row-1").click();
119+
await page.getByTestId("member-role").selectOption("member");
120+
await page.getByTestId("member-status").selectOption("inactive");
121+
await page.getByTestId("member-save").click();
122+
123+
await expect(page.getByTestId("sheet")).not.toBeVisible();
124+
expect(updateInput?.id).toBe(1);
125+
expect(updateInput?.is_staff).toBe(false);
126+
expect(updateInput?.is_active).toBe(false);
127+
});
128+
72129
test("Security toggles flip", async ({ page }) => {
73130
await page.goto("/security");
74131
await page.waitForLoadState("networkidle");

packages/e2e/tests/studio/shell.spec.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@ test.describe("Studio shell", () => {
2323

2424
await switchMode(page, "govern");
2525
await expect(page).toHaveURL(/\/admin$/);
26-
await expect(page.getByTestId("nav-tenants")).toBeVisible();
26+
// Assert an ungated govern item: `tenants` is gated behind the
27+
// MULTI_ORGANIZATIONS feature flag (off on OSS), so `team` is the stable
28+
// signal that the govern nav rendered.
29+
await expect(page.getByTestId("nav-team")).toBeVisible();
2730

2831
await switchMode(page, "ship");
2932
await expect(page).toHaveURL(/\/home$/);

0 commit comments

Comments
 (0)