@voyant-travel/admin 0.113.0 → 0.115.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,10 @@
1
+ export interface TeamSettingsPageApi {
2
+ get: <T = unknown>(path: string) => Promise<T>;
3
+ post: <T = unknown>(path: string, body?: unknown) => Promise<T>;
4
+ put: <T = unknown>(path: string, body?: unknown) => Promise<T>;
5
+ delete: <T = unknown>(path: string) => Promise<T>;
6
+ }
7
+ export declare const TeamSettingsPageApiContext: import("react").Context<TeamSettingsPageApi | null>;
8
+ export declare function createTeamSettingsPageApi(baseUrl: string, fetcher: (url: string, init?: RequestInit) => Promise<Response>): TeamSettingsPageApi;
9
+ export declare function useTeamSettingsPageApi(): TeamSettingsPageApi;
10
+ //# sourceMappingURL=team-settings-api.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"team-settings-api.d.ts","sourceRoot":"","sources":["../../src/components/team-settings-api.ts"],"names":[],"mappings":"AAIA,MAAM,WAAW,mBAAmB;IAClC,GAAG,EAAE,CAAC,CAAC,GAAG,OAAO,EAAE,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,CAAA;IAC9C,IAAI,EAAE,CAAC,CAAC,GAAG,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,KAAK,OAAO,CAAC,CAAC,CAAC,CAAA;IAC/D,GAAG,EAAE,CAAC,CAAC,GAAG,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,KAAK,OAAO,CAAC,CAAC,CAAC,CAAA;IAC9D,MAAM,EAAE,CAAC,CAAC,GAAG,OAAO,EAAE,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,CAAA;CAClD;AAED,eAAO,MAAM,0BAA0B,qDAAkD,CAAA;AA2BzF,wBAAgB,yBAAyB,CACvC,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,uBA0BhE;AAED,wBAAgB,sBAAsB,wBAIrC"}
@@ -0,0 +1,54 @@
1
+ "use client";
2
+ import { createContext, useContext } from "react";
3
+ export const TeamSettingsPageApiContext = createContext(null);
4
+ function joinUrl(baseUrl, path) {
5
+ const trimmedBase = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
6
+ const trimmedPath = path.startsWith("/") ? path : `/${path}`;
7
+ return `${trimmedBase}${trimmedPath}`;
8
+ }
9
+ async function readJson(response) {
10
+ if (!response.ok) {
11
+ let body;
12
+ try {
13
+ body = await response.json();
14
+ }
15
+ catch {
16
+ body = await response.text().catch(() => undefined);
17
+ }
18
+ const message = typeof body === "object" && body !== null && "error" in body
19
+ ? String(body.error)
20
+ : `API error: ${response.status} ${response.statusText}`;
21
+ throw new Error(message);
22
+ }
23
+ if (response.status === 204)
24
+ return undefined;
25
+ return response.json();
26
+ }
27
+ export function createTeamSettingsPageApi(baseUrl, fetcher) {
28
+ const request = async (path, init = {}) => {
29
+ const headers = new Headers(init.headers);
30
+ if (init.body !== undefined && !headers.has("Content-Type")) {
31
+ headers.set("Content-Type", "application/json");
32
+ }
33
+ return readJson(await fetcher(joinUrl(baseUrl, path), { ...init, headers }));
34
+ };
35
+ const api = {
36
+ get: (path) => request(path, { method: "GET" }),
37
+ post: (path, body) => request(path, {
38
+ method: "POST",
39
+ body: body !== undefined ? JSON.stringify(body) : undefined,
40
+ }),
41
+ put: (path, body) => request(path, {
42
+ method: "PUT",
43
+ body: body !== undefined ? JSON.stringify(body) : undefined,
44
+ }),
45
+ delete: (path) => request(path, { method: "DELETE" }),
46
+ };
47
+ return api;
48
+ }
49
+ export function useTeamSettingsPageApi() {
50
+ const api = useContext(TeamSettingsPageApiContext);
51
+ if (!api)
52
+ throw new Error("TeamSettingsPage requires a TeamSettingsPageApiContext provider");
53
+ return api;
54
+ }
@@ -0,0 +1,2 @@
1
+ export declare function CloudTeamView(): import("react/jsx-runtime").JSX.Element;
2
+ //# sourceMappingURL=team-settings-cloud.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"team-settings-cloud.d.ts","sourceRoot":"","sources":["../../src/components/team-settings-cloud.tsx"],"names":[],"mappings":"AAqEA,wBAAgB,aAAa,4CAqJ5B"}
@@ -0,0 +1,185 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
4
+ import { API_KEY_PERMISSION_GROUPS, hasApiKeyPermission, permissionStringsToPermissions, } from "@voyant-travel/types/api-keys";
5
+ import { MEMBER_ROLE_PRESETS, scopesForRole } from "@voyant-travel/types/member-roles";
6
+ import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, Input, Label, } from "@voyant-travel/ui/components";
7
+ import { Checkbox } from "@voyant-travel/ui/components/checkbox";
8
+ import { ScrollArea } from "@voyant-travel/ui/components/scroll-area";
9
+ import { Skeleton } from "@voyant-travel/ui/components/skeleton";
10
+ import { Copy, Loader2, Mail, Trash2, UserPlus } from "lucide-react";
11
+ import { useState } from "react";
12
+ import { formatMessage } from "../lib/i18n.js";
13
+ import { useLocale } from "../providers/locale.js";
14
+ import { useOperatorAdminMessages } from "../providers/operator-admin-messages.js";
15
+ import { useTeamSettingsPageApi } from "./team-settings-api.js";
16
+ const CLOUD_MEMBERS_QK = ["admin-team-members"];
17
+ const CLOUD_INVITES_QK = ["admin-team-invitations"];
18
+ export function CloudTeamView() {
19
+ const api = useTeamSettingsPageApi();
20
+ const messages = useOperatorAdminMessages();
21
+ const { resolvedLocale } = useLocale();
22
+ const queryClient = useQueryClient();
23
+ const membersQuery = useQuery({
24
+ queryKey: CLOUD_MEMBERS_QK,
25
+ queryFn: () => api.get("/v1/admin/team/members"),
26
+ });
27
+ const invitesQuery = useQuery({
28
+ queryKey: CLOUD_INVITES_QK,
29
+ queryFn: () => api.get("/v1/admin/team/invitations"),
30
+ });
31
+ const revoke = useMutation({
32
+ mutationFn: (id) => api.delete(`/v1/admin/team/invitations/${id}`),
33
+ onSuccess: () => void queryClient.invalidateQueries({ queryKey: CLOUD_INVITES_QK }),
34
+ });
35
+ const members = membersQuery.data?.data ?? [];
36
+ const invites = invitesQuery.data?.data ?? [];
37
+ const pending = invites.filter((i) => i.state === "pending" || i.state === "expired");
38
+ return (_jsxs("div", { className: "flex flex-col gap-6 p-6", children: [_jsxs("div", { className: "flex items-start justify-between gap-4", children: [_jsxs("div", { children: [_jsx("h1", { className: "text-2xl font-semibold tracking-tight", children: messages.team.title }), _jsx("p", { className: "mt-1 text-sm text-muted-foreground", children: messages.team.description })] }), _jsx(CloudInviteMemberDialog, {})] }), _jsxs(Card, { children: [_jsxs(CardHeader, { children: [_jsx(CardTitle, { children: messages.team.members.title }), _jsx(CardDescription, { children: messages.team.members.description })] }), _jsx(CardContent, { children: membersQuery.isPending ? (_jsx("ul", { className: "flex flex-col divide-y", children: Array.from({ length: 3 }).map((_, i) => (_jsxs("li", { className: "flex items-center gap-4 py-3", children: [_jsxs("div", { className: "min-w-0 flex-1 space-y-1.5", children: [_jsx(Skeleton, { className: "h-3.5 w-48" }), _jsx(Skeleton, { className: "h-3 w-24" })] }), _jsx(Skeleton, { className: "h-7 w-24" })] }, i))) })) : members.length === 0 ? (_jsx("p", { className: "text-sm text-muted-foreground", children: messages.team.members.empty })) : (_jsx("ul", { className: "flex flex-col divide-y", children: members.map((member) => (_jsxs("li", { className: "flex items-center gap-4 py-3", children: [_jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("p", { className: "truncate text-sm font-medium", children: member.email ?? member.externalUserId }), _jsx("p", { className: "text-xs text-muted-foreground", children: member.roleName ?? member.roleSlug ?? messages.team.members.role })] }), member.hasFullPlatformAccess ? (_jsx("span", { className: "rounded-full bg-muted px-2.5 py-1 text-xs text-muted-foreground", title: messages.team.members.fullAccessHint, children: messages.team.members.fullAccess })) : (_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "text-xs text-muted-foreground", children: memberAccessSummary(member, messages) }), _jsx(MemberPermissionsDialog, { member: member })] }))] }, member.membershipId))) })) })] }), _jsxs(Card, { children: [_jsxs(CardHeader, { children: [_jsx(CardTitle, { children: messages.team.pendingInvitations }), _jsx(CardDescription, { children: pending.length === 0
39
+ ? messages.team.noOutstandingInvitations
40
+ : formatMessage(pending.length === 1
41
+ ? messages.team.invitationWaitingSingular
42
+ : messages.team.invitationWaitingPlural, { count: pending.length }) })] }), _jsx(CardContent, { children: invitesQuery.isPending ? (_jsx(Skeleton, { className: "h-10 w-full" })) : pending.length === 0 ? (_jsx("p", { className: "text-sm text-muted-foreground", children: messages.team.nothingHereYet })) : (_jsx("ul", { className: "flex flex-col divide-y", children: pending.map((invite) => (_jsxs("li", { className: "flex items-center gap-4 py-3", children: [_jsx(Mail, { className: "h-4 w-4 shrink-0 text-muted-foreground" }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("p", { className: "truncate text-sm font-medium", children: invite.email }), _jsx("p", { className: "text-xs text-muted-foreground", children: formatMessage(messages.team.expires, {
43
+ date: new Date(invite.expiresAt).toLocaleString(resolvedLocale),
44
+ }) })] }), _jsxs(Button, { type: "button", variant: "ghost", size: "sm", className: "text-destructive", onClick: () => {
45
+ if (confirm(formatMessage(messages.team.revokeConfirm, { email: invite.email }))) {
46
+ revoke.mutate(invite.id);
47
+ }
48
+ }, disabled: revoke.isPending, children: [_jsx(Trash2, { className: "mr-1 h-3.5 w-3.5" }), messages.team.revoke] })] }, invite.id))) })) })] })] }));
49
+ }
50
+ function CloudInviteMemberDialog() {
51
+ const api = useTeamSettingsPageApi();
52
+ const messages = useOperatorAdminMessages();
53
+ const queryClient = useQueryClient();
54
+ const [open, setOpen] = useState(false);
55
+ const [email, setEmail] = useState("");
56
+ const [roleSlug, setRoleSlug] = useState("");
57
+ const [result, setResult] = useState(null);
58
+ const [error, setError] = useState(null);
59
+ const [copied, setCopied] = useState(false);
60
+ const rolesQuery = useQuery({
61
+ queryKey: ["admin-team-roles"],
62
+ queryFn: () => api.get("/v1/admin/team/roles"),
63
+ enabled: open,
64
+ });
65
+ const roles = rolesQuery.data?.data ?? [];
66
+ const create = useMutation({
67
+ mutationFn: () => api.post("/v1/admin/team/invitations", {
68
+ email: email.trim(),
69
+ roleSlug: roleSlug || undefined,
70
+ }),
71
+ onSuccess: (response) => {
72
+ setResult(response.data);
73
+ setError(null);
74
+ void queryClient.invalidateQueries({ queryKey: CLOUD_INVITES_QK });
75
+ },
76
+ onError: (e) => setError(e instanceof Error ? e.message : messages.team.errorCouldNotSendInvitation),
77
+ });
78
+ const close = () => {
79
+ setOpen(false);
80
+ window.setTimeout(() => {
81
+ setEmail("");
82
+ setRoleSlug("");
83
+ setResult(null);
84
+ setError(null);
85
+ setCopied(false);
86
+ }, 200);
87
+ };
88
+ const copyLink = async () => {
89
+ if (!result)
90
+ return;
91
+ await navigator.clipboard.writeText(result.acceptInvitationUrl);
92
+ setCopied(true);
93
+ window.setTimeout(() => setCopied(false), 1500);
94
+ };
95
+ return (_jsxs(_Fragment, { children: [_jsxs(Button, { size: "sm", onClick: () => setOpen(true), children: [_jsx(UserPlus, { className: "mr-1 h-4 w-4" }), messages.team.inviteMember] }), _jsx(Dialog, { open: open, onOpenChange: (o) => (o ? setOpen(true) : close()), children: _jsxs(DialogContent, { children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: messages.team.inviteDialogTitle }), _jsx(DialogDescription, { children: messages.team.inviteDialogDescription })] }), result ? (_jsxs("div", { className: "space-y-4", children: [_jsx("div", { className: "rounded-md border bg-muted/30 p-3 text-sm", children: _jsx("p", { children: formatMessage(messages.team.inviteCreated, { email: result.email }) }) }), _jsxs("div", { className: "space-y-2", children: [_jsx(Label, { children: messages.team.acceptLink }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Input, { value: result.acceptInvitationUrl, readOnly: true, className: "font-mono text-xs" }), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => void copyLink(), children: [_jsx(Copy, { className: "mr-1 h-3.5 w-3.5" }), copied ? messages.team.copied : messages.team.copy] })] })] }), _jsx(DialogFooter, { children: _jsx(Button, { onClick: close, children: messages.team.done }) })] })) : (_jsxs("form", { className: "space-y-4", onSubmit: (e) => {
96
+ e.preventDefault();
97
+ create.mutate();
98
+ }, children: [error && (_jsx("div", { className: "rounded-md bg-destructive/10 p-3 text-sm text-destructive", children: error })), _jsxs("div", { className: "space-y-2", children: [_jsx(Label, { htmlFor: "cloud-invite-email", children: messages.team.emailLabel }), _jsx(Input, { id: "cloud-invite-email", type: "email", value: email, onChange: (e) => setEmail(e.target.value), placeholder: messages.team.emailPlaceholder, required: true, autoComplete: "off", autoFocus: true })] }), roles.length > 0 && (_jsxs("div", { className: "space-y-2", children: [_jsx(Label, { htmlFor: "cloud-invite-role", children: messages.team.members.roleLabel }), _jsxs("select", { id: "cloud-invite-role", value: roleSlug, onChange: (e) => setRoleSlug(e.target.value), className: "flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm", children: [_jsx("option", { value: "", children: messages.team.members.role }), roles.map((role) => (_jsx("option", { value: role.slug, children: role.name }, role.slug)))] })] })), _jsxs(DialogFooter, { children: [_jsx(Button, { type: "button", variant: "ghost", onClick: close, children: messages.team.cancel }), _jsxs(Button, { type: "submit", disabled: create.isPending, children: [create.isPending && _jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }), messages.team.sendInvitation] })] })] }))] }) })] }));
99
+ }
100
+ /** Expand a (possibly wildcard) scope list to the concrete catalog permissions it grants. */
101
+ function expandToConcrete(scopes) {
102
+ const permissions = permissionStringsToPermissions(scopes);
103
+ return new Set(API_KEY_PERMISSION_GROUPS.flatMap((group) => group.permissions
104
+ .filter((p) => hasApiKeyPermission(permissions, p.resource, p.action))
105
+ .map((p) => `${p.resource}:${p.action}`)));
106
+ }
107
+ /** The member's effective scopes today: explicit set, else role default if they have access. */
108
+ function memberCurrentScopes(member) {
109
+ if (member.permissions && member.permissions.length > 0)
110
+ return member.permissions;
111
+ if (member.hasDeploymentAccess)
112
+ return scopesForRole(member.roleSlug) ?? [];
113
+ return [];
114
+ }
115
+ function memberAccessSummary(member, messages) {
116
+ if (!member.hasDeploymentAccess)
117
+ return messages.team.members.noAccess;
118
+ if (member.permissions && member.permissions.length > 0)
119
+ return messages.team.members.custom;
120
+ return member.roleName ?? member.roleSlug ?? messages.team.members.roleDefault;
121
+ }
122
+ // Concrete-scope presets. "Admin" is intentionally NOT here: full access must
123
+ // persist the real `*` wildcard (so it covers PII + future resources), not an
124
+ // expansion of the visible catalog — handled as a dedicated control below.
125
+ const SCOPE_PRESETS = [
126
+ { key: "editor", label: MEMBER_ROLE_PRESETS.editor.label, scopes: scopesForRole("editor") ?? [] },
127
+ { key: "viewer", label: MEMBER_ROLE_PRESETS.viewer.label, scopes: scopesForRole("viewer") ?? [] },
128
+ ];
129
+ /**
130
+ * Granular permission editor for a deployment member (cloud mode). Operates on
131
+ * concrete `resource:action` strings drawn from the shared API-key catalog;
132
+ * presets seed the selection and any box is then toggleable. Saving an empty
133
+ * selection revokes the member's access to this deployment.
134
+ */
135
+ function MemberPermissionsDialog({ member }) {
136
+ const api = useTeamSettingsPageApi();
137
+ const messages = useOperatorAdminMessages();
138
+ const queryClient = useQueryClient();
139
+ const [open, setOpen] = useState(false);
140
+ const [selected, setSelected] = useState(new Set());
141
+ // Full access is a real `*`, not an expansion of the visible catalog — so it
142
+ // keeps PII + any future resources. Tracked separately from the checklist.
143
+ const [wildcard, setWildcard] = useState(false);
144
+ const openDialog = () => {
145
+ const scopes = memberCurrentScopes(member);
146
+ setWildcard(scopes.includes("*"));
147
+ setSelected(expandToConcrete(scopes));
148
+ setOpen(true);
149
+ };
150
+ const save = useMutation({
151
+ mutationFn: () => api.put(`/v1/admin/team/members/${member.membershipId}/permissions`, {
152
+ permissions: wildcard ? ["*"] : [...selected],
153
+ }),
154
+ onSuccess: () => {
155
+ void queryClient.invalidateQueries({ queryKey: CLOUD_MEMBERS_QK });
156
+ setOpen(false);
157
+ },
158
+ });
159
+ const applyPreset = (scopes) => {
160
+ setWildcard(false);
161
+ setSelected(expandToConcrete([...scopes]));
162
+ };
163
+ const applyFullAccess = () => {
164
+ setWildcard(true);
165
+ setSelected(expandToConcrete(["*"]));
166
+ };
167
+ const toggle = (key, checked) => {
168
+ setWildcard(false);
169
+ setSelected((prev) => {
170
+ const next = new Set(prev);
171
+ if (checked)
172
+ next.add(key);
173
+ else
174
+ next.delete(key);
175
+ return next;
176
+ });
177
+ };
178
+ return (_jsxs(_Fragment, { children: [_jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: openDialog, children: messages.team.members.managePermissions }), _jsx(Dialog, { open: open, onOpenChange: (o) => (o ? openDialog() : setOpen(false)), children: _jsxs(DialogContent, { children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: messages.team.members.permissionsTitle }), _jsx(DialogDescription, { children: messages.team.members.permissionsDescription })] }), _jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsxs("span", { className: "text-xs text-muted-foreground", children: [messages.team.members.presetLabel, ":"] }), _jsx(Button, { type: "button", variant: wildcard ? "default" : "secondary", size: "sm", onClick: applyFullAccess, children: MEMBER_ROLE_PRESETS.admin.label }), SCOPE_PRESETS.map((preset) => (_jsx(Button, { type: "button", variant: "secondary", size: "sm", onClick: () => applyPreset(preset.scopes), children: preset.label }, preset.key))), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => {
179
+ setWildcard(false);
180
+ setSelected(new Set());
181
+ }, children: messages.team.members.noAccess })] }), _jsx(ScrollArea, { className: "h-80 pr-4", children: _jsx("div", { className: "flex flex-col gap-4", children: API_KEY_PERMISSION_GROUPS.map((group) => (_jsxs("div", { className: "space-y-2", children: [_jsx("p", { className: "text-sm font-medium", children: group.label }), _jsx("div", { className: "grid grid-cols-1 gap-1.5 sm:grid-cols-2", children: group.permissions.map((perm) => {
182
+ const key = `${perm.resource}:${perm.action}`;
183
+ return (_jsxs("label", { htmlFor: `perm-${key}`, className: "flex items-center gap-2 text-sm", children: [_jsx(Checkbox, { id: `perm-${key}`, checked: selected.has(key), onCheckedChange: (c) => toggle(key, c === true) }), _jsx("span", { children: perm.label })] }, key));
184
+ }) })] }, group.resource))) }) }), _jsxs(DialogFooter, { children: [_jsx(Button, { type: "button", variant: "ghost", onClick: () => setOpen(false), children: messages.team.cancel }), _jsxs(Button, { type: "button", disabled: save.isPending, onClick: () => save.mutate(), children: [save.isPending && _jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }), save.isPending ? messages.team.members.saving : messages.team.members.save] })] })] }) })] }));
185
+ }
@@ -1,8 +1,5 @@
1
- export interface TeamSettingsPageApi {
2
- get: <T = unknown>(path: string) => Promise<T>;
3
- post: <T = unknown>(path: string, body?: unknown) => Promise<T>;
4
- delete: <T = unknown>(path: string) => Promise<T>;
5
- }
1
+ import { type TeamSettingsPageApi } from "./team-settings-api.js";
2
+ export type { TeamSettingsPageApi };
6
3
  export interface TeamSettingsPageProps {
7
4
  api?: TeamSettingsPageApi;
8
5
  }
@@ -1 +1 @@
1
- {"version":3,"file":"team-settings-page.d.ts","sourceRoot":"","sources":["../../src/components/team-settings-page.tsx"],"names":[],"mappings":"AAiDA,MAAM,WAAW,mBAAmB;IAClC,GAAG,EAAE,CAAC,CAAC,GAAG,OAAO,EAAE,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,CAAA;IAC9C,IAAI,EAAE,CAAC,CAAC,GAAG,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,KAAK,OAAO,CAAC,CAAC,CAAC,CAAA;IAC/D,MAAM,EAAE,CAAC,CAAC,GAAG,OAAO,EAAE,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,CAAA;CAClD;AAED,MAAM,WAAW,qBAAqB;IACpC,GAAG,CAAC,EAAE,mBAAmB,CAAA;CAC1B;AA4DD,wBAAgB,gBAAgB,CAAC,EAAE,GAAG,EAAE,OAAO,EAAE,GAAE,qBAA0B,2CAG5E"}
1
+ {"version":3,"file":"team-settings-page.d.ts","sourceRoot":"","sources":["../../src/components/team-settings-page.tsx"],"names":[],"mappings":"AA2BA,OAAO,EAEL,KAAK,mBAAmB,EAGzB,MAAM,wBAAwB,CAAA;AAwB/B,YAAY,EAAE,mBAAmB,EAAE,CAAA;AAEnC,MAAM,WAAW,qBAAqB;IACpC,GAAG,CAAC,EAAE,mBAAmB,CAAA;CAC1B;AAED,wBAAgB,gBAAgB,CAAC,EAAE,GAAG,EAAE,OAAO,EAAE,GAAE,qBAA0B,2CAG5E"}
@@ -5,59 +5,13 @@ import { useVoyantReactContext } from "@voyant-travel/react";
5
5
  import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, Input, Label, } from "@voyant-travel/ui/components";
6
6
  import { Skeleton } from "@voyant-travel/ui/components/skeleton";
7
7
  import { Copy, Loader2, Mail, Trash2, UserPlus } from "lucide-react";
8
- import { createContext, useContext, useMemo, useState } from "react";
8
+ import { useMemo, useState } from "react";
9
9
  import { formatMessage } from "../lib/i18n.js";
10
10
  import { useLocale } from "../providers/locale.js";
11
11
  import { useOperatorAdminMessages } from "../providers/operator-admin-messages.js";
12
+ import { createTeamSettingsPageApi, TeamSettingsPageApiContext, useTeamSettingsPageApi, } from "./team-settings-api.js";
13
+ import { CloudTeamView } from "./team-settings-cloud.js";
12
14
  const QK = ["admin-invitations"];
13
- const TeamSettingsPageApiContext = createContext(null);
14
- function joinUrl(baseUrl, path) {
15
- const trimmedBase = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
16
- const trimmedPath = path.startsWith("/") ? path : `/${path}`;
17
- return `${trimmedBase}${trimmedPath}`;
18
- }
19
- async function readJson(response) {
20
- if (!response.ok) {
21
- let body;
22
- try {
23
- body = await response.json();
24
- }
25
- catch {
26
- body = await response.text().catch(() => undefined);
27
- }
28
- const message = typeof body === "object" && body !== null && "error" in body
29
- ? String(body.error)
30
- : `API error: ${response.status} ${response.statusText}`;
31
- throw new Error(message);
32
- }
33
- if (response.status === 204)
34
- return undefined;
35
- return response.json();
36
- }
37
- function createTeamSettingsPageApi(baseUrl, fetcher) {
38
- const request = async (path, init = {}) => {
39
- const headers = new Headers(init.headers);
40
- if (init.body !== undefined && !headers.has("Content-Type")) {
41
- headers.set("Content-Type", "application/json");
42
- }
43
- return readJson(await fetcher(joinUrl(baseUrl, path), { ...init, headers }));
44
- };
45
- const api = {
46
- get: (path) => request(path, { method: "GET" }),
47
- post: (path, body) => request(path, {
48
- method: "POST",
49
- body: body !== undefined ? JSON.stringify(body) : undefined,
50
- }),
51
- delete: (path) => request(path, { method: "DELETE" }),
52
- };
53
- return api;
54
- }
55
- function useTeamSettingsPageApi() {
56
- const api = useContext(TeamSettingsPageApiContext);
57
- if (!api)
58
- throw new Error("TeamSettingsPage requires a TeamSettingsPageApiContext provider");
59
- return api;
60
- }
61
15
  export function TeamSettingsPage({ api: apiProp } = {}) {
62
16
  if (apiProp)
63
17
  return _jsx(TeamSettingsPageContent, { api: apiProp });
@@ -69,6 +23,19 @@ function TeamSettingsPageWithDefaultApi() {
69
23
  return _jsx(TeamSettingsPageContent, { api: api });
70
24
  }
71
25
  function TeamSettingsPageContent({ api }) {
26
+ // The auth mode decides which team surface backs the page: local invitations
27
+ // (credential users in this deployment's DB) or the Voyant Cloud member roster
28
+ // proxied through /v1/admin/team/*. Default to local until known.
29
+ const bootstrapQuery = useQuery({
30
+ queryKey: ["admin-team-auth-mode"],
31
+ queryFn: () => api.get("/auth/bootstrap-status"),
32
+ staleTime: 5 * 60 * 1000,
33
+ });
34
+ const isCloud = bootstrapQuery.data?.authMode === "voyant-cloud";
35
+ return (_jsx(TeamSettingsPageApiContext.Provider, { value: api, children: isCloud ? _jsx(CloudTeamView, {}) : _jsx(LocalTeamView, {}) }));
36
+ }
37
+ function LocalTeamView() {
38
+ const api = useTeamSettingsPageApi();
72
39
  const messages = useOperatorAdminMessages();
73
40
  const { resolvedLocale } = useLocale();
74
41
  const queryClient = useQueryClient();
@@ -83,25 +50,25 @@ function TeamSettingsPageContent({ api }) {
83
50
  const invites = invitesQuery.data?.data ?? [];
84
51
  const pending = invites.filter((i) => !i.redeemedAt && new Date(i.expiresAt) > new Date());
85
52
  const spent = invites.filter((i) => i.redeemedAt || new Date(i.expiresAt) <= new Date());
86
- return (_jsx(TeamSettingsPageApiContext.Provider, { value: api, children: _jsxs("div", { className: "flex flex-col gap-6 p-6", children: [_jsxs("div", { className: "flex items-start justify-between gap-4", children: [_jsxs("div", { children: [_jsx("h1", { className: "text-2xl font-semibold tracking-tight", children: messages.team.title }), _jsx("p", { className: "mt-1 text-sm text-muted-foreground", children: messages.team.description })] }), _jsx(InviteMemberDialog, {})] }), _jsxs(Card, { children: [_jsxs(CardHeader, { children: [_jsx(CardTitle, { children: messages.team.pendingInvitations }), _jsx(CardDescription, { children: pending.length === 0
87
- ? messages.team.noOutstandingInvitations
88
- : formatMessage(pending.length === 1
89
- ? messages.team.invitationWaitingSingular
90
- : messages.team.invitationWaitingPlural, { count: pending.length }) })] }), _jsx(CardContent, { children: invitesQuery.isPending ? (_jsx("ul", { className: "flex flex-col divide-y", children: Array.from({ length: 3 }).map((_, i) => (_jsxs("li", { className: "flex items-center gap-4 py-3", children: [_jsx(Skeleton, { className: "h-4 w-4 rounded" }), _jsxs("div", { className: "min-w-0 flex-1 space-y-1.5", children: [_jsx(Skeleton, { className: "h-3.5 w-48" }), _jsx(Skeleton, { className: "h-3 w-40" })] }), _jsx(Skeleton, { className: "h-7 w-20" })] }, i))) })) : pending.length === 0 ? (_jsx("p", { className: "text-sm text-muted-foreground", children: messages.team.nothingHereYet })) : (_jsx("ul", { className: "flex flex-col divide-y", children: pending.map((invite) => (_jsxs("li", { className: "flex items-center gap-4 py-3", children: [_jsx(Mail, { className: "h-4 w-4 shrink-0 text-muted-foreground" }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("p", { className: "truncate text-sm font-medium", children: invite.email }), _jsx("p", { className: "text-xs text-muted-foreground", children: formatMessage(messages.team.expires, {
91
- date: new Date(invite.expiresAt).toLocaleString(resolvedLocale),
92
- }) })] }), _jsxs(Button, { type: "button", variant: "ghost", size: "sm", className: "text-destructive", onClick: () => {
93
- if (confirm(formatMessage(messages.team.revokeConfirm, {
94
- email: invite.email,
95
- }))) {
96
- revoke.mutate(invite.id);
97
- }
98
- }, disabled: revoke.isPending, children: [_jsx(Trash2, { className: "mr-1 h-3.5 w-3.5" }), messages.team.revoke] })] }, invite.id))) })) })] }), spent.length > 0 ? (_jsxs(Card, { children: [_jsxs(CardHeader, { children: [_jsx(CardTitle, { children: messages.team.history }), _jsx(CardDescription, { children: messages.team.historyDescription })] }), _jsx(CardContent, { children: _jsx("ul", { className: "flex flex-col divide-y text-sm", children: spent.map((invite) => (_jsxs("li", { className: "flex items-center gap-4 py-2.5 text-muted-foreground", children: [_jsx(Mail, { className: "h-4 w-4 shrink-0" }), _jsx("span", { className: "min-w-0 flex-1 truncate", children: invite.email }), _jsx("span", { className: "text-xs", children: invite.redeemedAt
99
- ? formatMessage(messages.team.redeemed, {
100
- date: new Date(invite.redeemedAt).toLocaleDateString(resolvedLocale),
101
- })
102
- : formatMessage(messages.team.expired, {
103
- date: new Date(invite.expiresAt).toLocaleDateString(resolvedLocale),
104
- }) })] }, invite.id))) }) })] })) : null] }) }));
53
+ return (_jsxs("div", { className: "flex flex-col gap-6 p-6", children: [_jsxs("div", { className: "flex items-start justify-between gap-4", children: [_jsxs("div", { children: [_jsx("h1", { className: "text-2xl font-semibold tracking-tight", children: messages.team.title }), _jsx("p", { className: "mt-1 text-sm text-muted-foreground", children: messages.team.description })] }), _jsx(InviteMemberDialog, {})] }), _jsxs(Card, { children: [_jsxs(CardHeader, { children: [_jsx(CardTitle, { children: messages.team.pendingInvitations }), _jsx(CardDescription, { children: pending.length === 0
54
+ ? messages.team.noOutstandingInvitations
55
+ : formatMessage(pending.length === 1
56
+ ? messages.team.invitationWaitingSingular
57
+ : messages.team.invitationWaitingPlural, { count: pending.length }) })] }), _jsx(CardContent, { children: invitesQuery.isPending ? (_jsx("ul", { className: "flex flex-col divide-y", children: Array.from({ length: 3 }).map((_, i) => (_jsxs("li", { className: "flex items-center gap-4 py-3", children: [_jsx(Skeleton, { className: "h-4 w-4 rounded" }), _jsxs("div", { className: "min-w-0 flex-1 space-y-1.5", children: [_jsx(Skeleton, { className: "h-3.5 w-48" }), _jsx(Skeleton, { className: "h-3 w-40" })] }), _jsx(Skeleton, { className: "h-7 w-20" })] }, i))) })) : pending.length === 0 ? (_jsx("p", { className: "text-sm text-muted-foreground", children: messages.team.nothingHereYet })) : (_jsx("ul", { className: "flex flex-col divide-y", children: pending.map((invite) => (_jsxs("li", { className: "flex items-center gap-4 py-3", children: [_jsx(Mail, { className: "h-4 w-4 shrink-0 text-muted-foreground" }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("p", { className: "truncate text-sm font-medium", children: invite.email }), _jsx("p", { className: "text-xs text-muted-foreground", children: formatMessage(messages.team.expires, {
58
+ date: new Date(invite.expiresAt).toLocaleString(resolvedLocale),
59
+ }) })] }), _jsxs(Button, { type: "button", variant: "ghost", size: "sm", className: "text-destructive", onClick: () => {
60
+ if (confirm(formatMessage(messages.team.revokeConfirm, {
61
+ email: invite.email,
62
+ }))) {
63
+ revoke.mutate(invite.id);
64
+ }
65
+ }, disabled: revoke.isPending, children: [_jsx(Trash2, { className: "mr-1 h-3.5 w-3.5" }), messages.team.revoke] })] }, invite.id))) })) })] }), spent.length > 0 ? (_jsxs(Card, { children: [_jsxs(CardHeader, { children: [_jsx(CardTitle, { children: messages.team.history }), _jsx(CardDescription, { children: messages.team.historyDescription })] }), _jsx(CardContent, { children: _jsx("ul", { className: "flex flex-col divide-y text-sm", children: spent.map((invite) => (_jsxs("li", { className: "flex items-center gap-4 py-2.5 text-muted-foreground", children: [_jsx(Mail, { className: "h-4 w-4 shrink-0" }), _jsx("span", { className: "min-w-0 flex-1 truncate", children: invite.email }), _jsx("span", { className: "text-xs", children: invite.redeemedAt
66
+ ? formatMessage(messages.team.redeemed, {
67
+ date: new Date(invite.redeemedAt).toLocaleDateString(resolvedLocale),
68
+ })
69
+ : formatMessage(messages.team.expired, {
70
+ date: new Date(invite.expiresAt).toLocaleDateString(resolvedLocale),
71
+ }) })] }, invite.id))) }) })] })) : null] }));
105
72
  }
106
73
  function InviteMemberDialog() {
107
74
  const messages = useOperatorAdminMessages();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voyant-travel/admin",
3
- "version": "0.113.0",
3
+ "version": "0.115.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -181,11 +181,12 @@
181
181
  "react": "^19.0.0",
182
182
  "react-dom": "^19.0.0",
183
183
  "recharts": "^3.0.0",
184
- "@voyant-travel/ui": "^0.107.0"
184
+ "@voyant-travel/ui": "^0.108.1"
185
185
  },
186
186
  "dependencies": {
187
- "@voyant-travel/i18n": "^0.106.1",
188
- "@voyant-travel/react": "^0.104.1"
187
+ "@voyant-travel/i18n": "^0.108.0",
188
+ "@voyant-travel/react": "^0.104.1",
189
+ "@voyant-travel/types": "^0.105.0"
189
190
  },
190
191
  "devDependencies": {
191
192
  "@tanstack/react-query": "^5.100.11",
@@ -200,7 +201,7 @@
200
201
  "recharts": "3.8.1",
201
202
  "typescript": "^6.0.2",
202
203
  "vitest": "^4.1.2",
203
- "@voyant-travel/ui": "^0.107.0",
204
+ "@voyant-travel/ui": "^0.108.1",
204
205
  "@voyant-travel/voyant-typescript-config": "^0.1.0"
205
206
  },
206
207
  "files": [