PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.10
Yatra – Travel Booking & Tour Operator Software v3.0.10
3.0.16 3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 All 84 releases
yatra / resources / js / pages / Team.tsx

Team.tsx in Yatra – Travel Booking & Tour Operator Software 3.0.10, at resources/js/pages/Team.tsx

3,862 lines 145.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import React, { useMemo, useState } from "react";
2 import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3 import {
4 Users,
5 Shield,
6 ShieldCheck,
7 KeyRound,
8 History,
9 Mail,
10 UserPlus,
11 Trash2,
12 LogOut,
13 Loader2,
14 ExternalLink,
15 Crown,
16 CheckCircle2,
17 XCircle,
18 Copy,
19 Check,
20 AlertTriangle,
21 Settings,
22 Lock,
23 Unlock,
24 } from "lucide-react";
25 import { __, sprintf, brandName } from "../lib/i18n";
26 import { PageHeader } from "../components/common/PageHeader";
27 import {
28 Card,
29 CardContent,
30 CardDescription,
31 CardHeader,
32 CardTitle,
33 } from "../components/ui/card";
34 import { Alert } from "../components/ui/alert";
35 import { Button } from "../components/ui/button";
36 import { Input } from "../components/ui/input";
37 import { Label } from "../components/ui/label";
38 import { Select } from "../components/ui/select";
39 import { Badge } from "../components/ui/badge";
40 import { Modal } from "../components/ui/modal";
41 import { Switch } from "../components/ui/switch";
42 import { Tooltip } from "../components/ui/tooltip";
43 import { ConfirmationDialog } from "../components/ui/confirmation-dialog";
44 import { Pagination } from "../components/shared/Pagination";
45 import { Table as SharedTable } from "../components/shared/Table";
46 import { Skeleton } from "../components/ui/skeleton";
47 import { useToast } from "../components/ui/toast";
48 import { setUserCaps } from "../hooks/useCapabilities";
49 import {
50 teamApi,
51 type CapabilityDef,
52 type TeamInvitation,
53 type TeamMeta,
54 type TeamRole,
55 type TeamUser,
56 type TeamUserWritePayload,
57 } from "../api/team-api";
58
59 /* -------------------------------------------------------------------------- */
60 /* Yatra → Team & Access — Agency-tier admin surface */
61 /* */
62 /* Mirrors the Webhooks page architecture: same upgrade-card / module- */
63 /* disabled-card gates, same tab strip pattern, same SharedTable primitive */
64 /* for every list view. Strictly composed from existing Yatra UI components */
65 /* — no bespoke styling, no new design tokens. */
66 /* -------------------------------------------------------------------------- */
67
68 type TeamTab = "members" | "roles" | "invitations" | "audit" | "settings";
69
70 const extractError = (e: any): string => {
71 return (
72 e?.response?.data?.message ||
73 e?.message ||
74 __("Something went wrong.", "yatra")
75 );
76 };
77
78 const getInitialTab = (): TeamTab => {
79 if (typeof window === "undefined") return "members";
80 const tab = new URLSearchParams(window.location.search).get("tab");
81 if (
82 tab === "roles" ||
83 tab === "invitations" ||
84 tab === "audit" ||
85 tab === "settings"
86 )
87 return tab;
88 return "members";
89 };
90
91 const Team: React.FC = () => {
92 const [activeTab, setActiveTab] = useState<TeamTab>(() => getInitialTab());
93
94 const switchTab = (next: TeamTab) => {
95 setActiveTab(next);
96 if (typeof window !== "undefined") {
97 const url = new URL(window.location.href);
98 url.searchParams.set("tab", next);
99 window.history.replaceState({}, "", url.toString());
100 }
101 };
102
103 const { data: meta, isLoading: metaLoading } = useQuery({
104 queryKey: ["team-meta"],
105 queryFn: () => teamApi.getMeta(),
106 });
107
108 // Forward-looking "keep access on module disable" setting. We fetch
109 // it via the same React Query key the Settings tab uses — sharing
110 // the cache so flipping the toggle re-renders this banner live
111 // (no page reload) without firing a second request. Initial paint
112 // uses the server-injected snapshot to avoid a flicker.
113 //
114 // CRITICAL: this useQuery MUST stay above any early-return branches
115 // below (meta loading / no-meta / not-agency / module-disabled).
116 // React enforces a stable hook order across renders; if it lived
117 // below an early return, switching from the loading state to the
118 // ready state would introduce a "new" hook and trigger React error
119 // #310.
120 const { data: settingsData } = useQuery({
121 queryKey: ["team-settings"],
122 queryFn: () => teamApi.getSettings(),
123 initialData: () => {
124 const seed = window.yatraAdmin?.teamKeepAccessOnModuleDisable;
125 // The option defaults to FALSE server-side (security-conservative:
126 // disabling the module strips Yatra roles unless the operator
127 // explicitly opts in). When the localized snapshot is undefined
128 // (very first paint on a fresh install before the toggle has
129 // ever been touched) mirror that default so the banner + tab
130 // contents agree from the first render.
131 return {
132 data: { keep_access_on_module_disable: seed === true },
133 } as { data: { keep_access_on_module_disable: boolean } };
134 },
135 // Don't fire the network request until the module is actually
136 // enabled — the /team/settings endpoint requires Team & Access
137 // to be active. Initial data still serves the banner during
138 // loading + non-agency states.
139 enabled: meta?.is_module_enabled === true,
140 });
141 // Strict equality on TRUE keeps this consistent with the SettingsTab
142 // toggle below (also `=== true`) so the banner and the toggle never
143 // disagree about which side of the default we're on.
144 const keepAccessOnDisable =
145 settingsData?.data?.keep_access_on_module_disable === true;
146
147 if (metaLoading) {
148 return (
149 <div className="space-y-6">
150 {/* Skeleton mirroring the loaded page: header copy + tab strip + */}
151 {/* table card. Keeps the layout stable during /team/meta load. */}
152 <div className="space-y-2">
153 <Skeleton className="h-6 w-1/3" />
154 <Skeleton className="h-4 w-2/3" />
155 </div>
156 <Card>
157 <div className="border-b border-gray-200 dark:border-gray-700 px-4 py-3 flex gap-6">
158 {[0, 1, 2, 3].map((i) => (
159 <Skeleton key={i} className="h-5 w-24" />
160 ))}
161 </div>
162 <CardContent className="space-y-3 pt-4">
163 {[0, 1, 2, 3, 4].map((i) => (
164 <Skeleton key={i} className="h-12 w-full" />
165 ))}
166 </CardContent>
167 </Card>
168 </div>
169 );
170 }
171 if (!meta) return null;
172
173 if (!meta.is_agency_active) {
174 return (
175 <div className="space-y-6">
176 <PageHeader
177 description={__(
178 "Granular roles, scoped access, magic-link invitations, and a tamper-evident audit log.",
179 "yatra",
180 )}
181 />
182 <UpgradeCard meta={meta} />
183 </div>
184 );
185 }
186
187 if (!meta.is_module_enabled) {
188 return (
189 <div className="space-y-6">
190 <PageHeader
191 description={__(
192 "Granular roles, scoped access, magic-link invitations, and a tamper-evident audit log.",
193 "yatra",
194 )}
195 />
196 <ModulePrompt />
197 </div>
198 );
199 }
200
201 const tabs: Array<{ key: TeamTab; label: string; icon: any }> = [
202 { key: "members", label: __("Members", "yatra"), icon: Users },
203 { key: "roles", label: __("Roles", "yatra"), icon: Shield },
204 { key: "invitations", label: __("Invitations", "yatra"), icon: Mail },
205 { key: "audit", label: __("Audit log", "yatra"), icon: History },
206 { key: "settings", label: __("Settings", "yatra"), icon: Settings },
207 ];
208
209 return (
210 <div className="space-y-6">
211 <PageHeader
212 description={__(
213 "Granular roles + capability-based access for multi-staff agencies. Defense-in-depth — every action gated on the server, the UI mirrors via cap-aware controls.",
214 "yatra",
215 )}
216 />
217
218 {/* Persistent banner. Visible on EVERY Team tab so operators */}
219 {/* understand the disable-behavior trade-off no matter which */}
220 {/* surface they're working on. Two variants: */}
221 {/* - Toggle ON (permissive, opt-in): info "team keeps access" */}
222 {/* - Toggle OFF (default, destructive on disable): warning */}
223 {/* The "Go to Settings" button is hidden when the active tab IS */}
224 {/* Settings (operator is already there). */}
225 {keepAccessOnDisable ? (
226 <div className="rounded-md border border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/40 p-4 flex flex-wrap items-start gap-3">
227 <Lock className="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
228 <div className="flex-1 min-w-0">
229 <h3 className="text-sm font-semibold text-blue-900 dark:text-blue-100">
230 {__(
231 "Your team will keep access if you ever turn off this module",
232 "yatra",
233 )}
234 </h3>
235 <p className="text-sm text-blue-800 dark:text-blue-200/90 mt-0.5">
236 {__(
237 "You've opted in to preserve team access when the module is disabled. Turning off Team & Access in Yatra → Modules will pause its advanced features (expiry, scopes, audit log) but your team members keep their roles and current access. You can change this in Settings.",
238 "yatra",
239 )}
240 </p>
241 </div>
242 {activeTab !== "settings" && (
243 <Button
244 type="button"
245 variant="outline"
246 onClick={() => switchTab("settings")}
247 className="border-blue-300 text-blue-900 hover:bg-blue-100 dark:border-blue-700 dark:text-blue-100 dark:hover:bg-blue-900"
248 >
249 {__("Open Settings", "yatra")}
250 </Button>
251 )}
252 </div>
253 ) : (
254 <div className="rounded-md border border-red-300 bg-red-50 dark:border-red-800 dark:bg-red-950/40 p-4 flex flex-wrap items-start gap-3">
255 <AlertTriangle className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
256 <div className="flex-1 min-w-0">
257 <h3 className="text-sm font-semibold text-red-900 dark:text-red-100">
258 {__(
259 "Heads up — turning off this module will revoke all team access",
260 "yatra",
261 )}
262 </h3>
263 <p className="text-sm text-red-800 dark:text-red-200/90 mt-0.5">
264 {__(
265 "This is the default. If you ever disable Team & Access in Yatra → Modules, every Yatra role on your site (Owner, Manager, Sales Agent, plus any custom roles you built) will be removed and your team members will lose all Yatra access. Re-enabling brings back the 8 built-in roles only — custom roles and member assignments don't come back. Switch the setting ON in Settings if you'd rather keep your team's access.",
266 "yatra",
267 )}
268 </p>
269 </div>
270 {activeTab !== "settings" && (
271 <Button
272 type="button"
273 variant="outline"
274 onClick={() => switchTab("settings")}
275 className="border-red-300 text-red-900 hover:bg-red-100 dark:border-red-700 dark:text-red-100 dark:hover:bg-red-900"
276 >
277 {__("Open Settings", "yatra")}
278 </Button>
279 )}
280 </div>
281 )}
282
283 <Card>
284 <div className="border-b border-gray-200 dark:border-gray-700">
285 <nav className="flex flex-wrap gap-1 px-4">
286 {tabs.map((tab) => {
287 const Icon = tab.icon;
288 const active = activeTab === tab.key;
289 return (
290 <button
291 key={tab.key}
292 type="button"
293 onClick={() => switchTab(tab.key)}
294 className={`flex items-center gap-2 px-4 py-3 border-b-2 font-medium text-sm transition-colors ${
295 active
296 ? "border-blue-500 text-blue-600 dark:text-blue-400"
297 : "border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600"
298 }`}
299 >
300 <Icon className="w-4 h-4" />
301 {tab.label}
302 </button>
303 );
304 })}
305 </nav>
306 </div>
307
308 <CardContent className="p-6">
309 {activeTab === "members" && <MembersTab />}
310 {activeTab === "roles" && <RolesTab />}
311 {activeTab === "invitations" && <InvitationsTab />}
312 {activeTab === "audit" && <AuditLogTab />}
313 {activeTab === "settings" && <SettingsTab />}
314 </CardContent>
315 </Card>
316 </div>
317 );
318 };
319
320 /* -------------------------------------------------------------------------- */
321 /* Upgrade / module-disabled cards */
322 /* -------------------------------------------------------------------------- */
323
324 const UpgradeCard: React.FC<{ meta: TeamMeta }> = ({ meta }) => (
325 <Card className="max-w-3xl">
326 <CardHeader>
327 <div className="flex items-start gap-3">
328 <div className="rounded-md bg-purple-100 p-2 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400">
329 <Crown className="h-5 w-5" />
330 </div>
331 <div className="flex-1">
332 <CardTitle>{__("Team & Access", "yatra")}</CardTitle>
333 <CardDescription className="mt-1">
334 {__(
335 "Available on the Scale plan. Add staff with role-appropriate access — front desk doesn't see refund history, guides see only their destinations, accountants get financial reports without booking edits.",
336 "yatra",
337 )}
338 </CardDescription>
339 </div>
340 </div>
341 </CardHeader>
342 <CardContent className="space-y-4">
343 <div className="grid grid-cols-1 gap-3 md:grid-cols-2">
344 {[
345 [
346 __("8 shipped roles + custom builder", "yatra"),
347 __(
348 "Owner, Manager, Sales Agent, Front Desk, Guide, Accountant, Marketing, Auditor.",
349 "yatra",
350 ),
351 ],
352 [
353 __("Per-user scope assignment", "yatra"),
354 __("Restrict by destination, activity, or trip.", "yatra"),
355 ],
356 [
357 __("Magic-link invitations", "yatra"),
358 __("Invite by email — no manual user creation.", "yatra"),
359 ],
360 [
361 __("180-day audit log", "yatra"),
362 __("Every sensitive action recorded, exportable to CSV.", "yatra"),
363 ],
364 ].map(([title, sub]) => (
365 <div
366 key={title}
367 className="rounded-md border border-gray-200 dark:border-gray-700 p-3"
368 >
369 <div className="flex items-start gap-2">
370 <CheckCircle2 className="h-4 w-4 text-green-500 mt-0.5 flex-shrink-0" />
371 <div>
372 <div className="text-sm font-medium text-gray-900 dark:text-white">
373 {title}
374 </div>
375 <div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
376 {sub}
377 </div>
378 </div>
379 </div>
380 </div>
381 ))}
382 </div>
383 <div className="flex gap-2 pt-2">
384 <Button asChild>
385 <a href={meta.upgrade_url} target="_blank" rel="noopener noreferrer">
386 {__("Upgrade to Scale", "yatra")}
387 <ExternalLink className="ml-1.5 h-4 w-4" />
388 </a>
389 </Button>
390 {meta.docs_url && (
391 <Button variant="outline" asChild>
392 <a href={meta.docs_url} target="_blank" rel="noopener noreferrer">
393 {__("Read the docs", "yatra")}
394 </a>
395 </Button>
396 )}
397 </div>
398 </CardContent>
399 </Card>
400 );
401
402 const ModulePrompt: React.FC = () => (
403 <Card className="max-w-3xl">
404 <CardHeader>
405 <CardTitle className="flex items-center gap-2">
406 <Users className="h-5 w-5 text-blue-500" />
407 {__("Enable the Team & Access module", "yatra")}
408 </CardTitle>
409 <CardDescription>
410 {sprintf(
411 /* translators: %s: brand name (e.g. "Yatra" or an operator's white-labeled brand) */
412 __(
413 'Toggle "Team & Access" on under %s → Modules to start configuring roles and members.',
414 "yatra",
415 ),
416 brandName(),
417 )}
418 </CardDescription>
419 </CardHeader>
420 </Card>
421 );
422
423 /* -------------------------------------------------------------------------- */
424 /* Time-windowed access helpers */
425 /* */
426 /* Operators set an "Access expires on" date on a member. Server stores a */
427 /* unix timestamp; UI uses `<input type="datetime-local"/>` which speaks */
428 /* `YYYY-MM-DDTHH:mm` in LOCAL time. These two helpers bridge formats and */
429 /* always round to the nearest minute (the picker doesn't show seconds). */
430 /* -------------------------------------------------------------------------- */
431
432 /** Convert a unix-seconds value into the local-input string the picker wants. */
433 const unixToLocalInputValue = (unixSec: number): string => {
434 if (!unixSec || unixSec <= 0) return "";
435 const d = new Date(unixSec * 1000);
436 // Build YYYY-MM-DDTHH:mm in LOCAL tz — Date constructor reads back the
437 // same wall-clock time when the picker submits, which is what operators
438 // intend ("expires at 5pm" means 5pm wherever they are).
439 const pad = (n: number) => n.toString().padStart(2, "0");
440 return (
441 d.getFullYear() +
442 "-" +
443 pad(d.getMonth() + 1) +
444 "-" +
445 pad(d.getDate()) +
446 "T" +
447 pad(d.getHours()) +
448 ":" +
449 pad(d.getMinutes())
450 );
451 };
452
453 /**
454 * Members-table cell that renders the access-expiry state. Three states:
455 *
456 * - permanent (expires_at === 0)
457 * - expiring soon (1–7 days remaining): amber badge
458 * - expired (timestamp in the past, but cron hasn't swept yet): red badge
459 * - long-future (> 7 days): muted relative text
460 */
461 const AccessExpiryCell: React.FC<{ user: TeamUser }> = ({ user }) => {
462 if (!user.expires_at || user.expires_at <= 0) {
463 return (
464 <span className="text-xs text-gray-400">{__("Permanent", "yatra")}</span>
465 );
466 }
467 const now = Math.floor(Date.now() / 1000);
468 const secondsRemaining = user.expires_at - now;
469 const daysRemaining = Math.ceil(secondsRemaining / 86400);
470 const formatted = new Date(user.expires_at * 1000).toLocaleString();
471
472 if (secondsRemaining <= 0) {
473 return (
474 <Tooltip content={formatted}>
475 <Badge className="bg-red-50 text-red-700 dark:bg-red-900/30 dark:text-red-300 border border-red-200 dark:border-red-800">
476 {__("Expired", "yatra")}
477 </Badge>
478 </Tooltip>
479 );
480 }
481 if (daysRemaining <= 7) {
482 return (
483 <Tooltip content={formatted}>
484 <Badge className="bg-amber-50 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300 border border-amber-200 dark:border-amber-800">
485 {sprintf(
486 /* translators: %d: number of days */
487 __("Expires in %d day(s)", "yatra"),
488 daysRemaining,
489 )}
490 </Badge>
491 </Tooltip>
492 );
493 }
494 return (
495 <span className="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">
496 {formatted}
497 </span>
498 );
499 };
500
501 /**
502 * Live "current time" caption shown under the expiry datetime-local input.
503 *
504 * Operators reach for `<input type="datetime-local"/>` without a frame of
505 * reference for "now" — the picker doesn't show it, the browser doesn't
506 * show it, and timezones vary. We display the local clock + a relative
507 * offset for the picked value (e.g. "in 3 days") so they can sanity-check
508 * before saving.
509 *
510 * Ticks once per minute. We don't tick per-second because the picker's
511 * resolution is one minute — sub-minute updates are visual noise.
512 */
513 const ExpiryNowHint: React.FC<{ expiresLocal: string }> = ({
514 expiresLocal,
515 }) => {
516 const [now, setNow] = useState(() => new Date());
517 React.useEffect(() => {
518 const id = window.setInterval(() => setNow(new Date()), 60_000);
519 return () => window.clearInterval(id);
520 }, []);
521
522 const nowFormatted = now.toLocaleString();
523
524 // Relative offset to the chosen expiry. Empty when nothing picked.
525 let relative = "";
526 if (expiresLocal) {
527 const target = new Date(expiresLocal).getTime();
528 const diffMs = target - now.getTime();
529 const absMin = Math.abs(Math.round(diffMs / 60_000));
530 if (diffMs < 0) {
531 relative = __("(in the past)", "yatra");
532 } else if (absMin < 60) {
533 relative = sprintf(
534 /* translators: %d: minutes from now */
535 __("in %d min", "yatra"),
536 absMin,
537 );
538 } else if (absMin < 60 * 24) {
539 relative = sprintf(
540 /* translators: %d: hours from now */
541 __("in %d hour(s)", "yatra"),
542 Math.round(absMin / 60),
543 );
544 } else {
545 relative = sprintf(
546 /* translators: %d: days from now */
547 __("in %d day(s)", "yatra"),
548 Math.round(absMin / (60 * 24)),
549 );
550 }
551 }
552
553 return (
554 <div className="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-gray-500 dark:text-gray-400">
555 <span>
556 {sprintf(
557 /* translators: %s: formatted current local datetime */
558 __("Current time: %s", "yatra"),
559 nowFormatted,
560 )}
561 </span>
562 {relative && (
563 <span className="font-medium text-blue-600 dark:text-blue-400">
564 {sprintf(
565 /* translators: %s: relative offset like "in 3 days" */
566 __("Expires %s", "yatra"),
567 relative,
568 )}
569 </span>
570 )}
571 </div>
572 );
573 };
574
575 /* -------------------------------------------------------------------------- */
576 /* Members tab */
577 /* -------------------------------------------------------------------------- */
578
579 const MembersTab: React.FC = () => {
580 const queryClient = useQueryClient();
581 const { showToast } = useToast();
582 const [editingId, setEditingId] = useState<number | null>(null);
583 const [pendingRemove, setPendingRemove] = useState<TeamUser | null>(null);
584 const [showAddModal, setShowAddModal] = useState(false);
585 const [showCreateModal, setShowCreateModal] = useState(false);
586 /* ----- Bulk-ops selection state ----- */
587 /* `selectedIds` holds the *checked* user ids. `pendingBulk` holds the */
588 /* confirmation-modal state — null when no modal is open. */
589 const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
590 const [pendingBulk, setPendingBulk] = useState<null | {
591 action: "change_role" | "remove" | "force_logout";
592 roleSlug?: string;
593 }>(null);
594
595 const { data, isLoading } = useQuery({
596 queryKey: ["team-users"],
597 queryFn: () => teamApi.listUsers(),
598 });
599 const { data: rolesData } = useQuery({
600 queryKey: ["team-roles"],
601 queryFn: () => teamApi.listRoles(),
602 });
603
604 const removeMutation = useMutation({
605 mutationFn: (id: number) => teamApi.removeUser(id),
606 onSuccess: () => {
607 queryClient.invalidateQueries({ queryKey: ["team-users"] });
608 showToast(__("Member removed.", "yatra"), "success");
609 setPendingRemove(null);
610 },
611 onError: (e: any) => {
612 showToast(extractError(e), "error");
613 setPendingRemove(null);
614 },
615 });
616
617 const forceLogoutMutation = useMutation({
618 mutationFn: (id: number) => teamApi.forceLogout(id),
619 onSuccess: () =>
620 showToast(__("All sessions invalidated.", "yatra"), "success"),
621 onError: (e: any) => showToast(extractError(e), "error"),
622 });
623
624 /* Bulk-ops mutation. Refreshes the member list + clears selection on */
625 /* success. Reports per-id failure count via toast (the response */
626 /* includes per-id results — we surface aggregate here, individual */
627 /* failure rows are visible by re-checking the table). */
628 const bulkMutation = useMutation({
629 mutationFn: (payload: Parameters<typeof teamApi.bulkUsers>[0]) =>
630 teamApi.bulkUsers(payload),
631 onSuccess: (res) => {
632 queryClient.invalidateQueries({ queryKey: ["team-users"] });
633 setSelectedIds(new Set());
634 setPendingBulk(null);
635 if (res.data.fail_count > 0) {
636 showToast(
637 sprintf(
638 /* translators: 1: ok count, 2: fail count */
639 __("Bulk done: %1$d succeeded, %2$d failed.", "yatra"),
640 res.data.ok_count,
641 res.data.fail_count,
642 ),
643 "warning",
644 );
645 } else {
646 showToast(
647 res.message || __("Bulk action complete.", "yatra"),
648 "success",
649 );
650 }
651 },
652 onError: (e: any) => {
653 showToast(extractError(e), "error");
654 setPendingBulk(null);
655 },
656 });
657
658 const users = data?.data ?? [];
659 /* Toggle row selection. Skips the current user — they can never be the */
660 /* target of a destructive bulk op on themselves. */
661 const currentUserId = window.yatraAdmin?.currentUser;
662 const toggleOne = (id: number, checked: boolean) => {
663 setSelectedIds((prev) => {
664 const next = new Set(prev);
665 if (checked) next.add(id);
666 else next.delete(id);
667 return next;
668 });
669 };
670 const toggleAll = (checked: boolean) => {
671 if (!checked) {
672 setSelectedIds(new Set());
673 return;
674 }
675 // Select every visible user except the operator themselves —
676 // saves them a click and avoids implicit "include yourself" foot-gun.
677 const next = new Set<number>();
678 users.forEach((u) => {
679 if (u.id !== currentUserId) next.add(u.id);
680 });
681 setSelectedIds(next);
682 };
683 const roleLabels = useMemo(() => {
684 const map: Record<string, string> = {};
685 (rolesData?.data ?? []).forEach((r) => {
686 map[r.slug] = r.display_name;
687 });
688 return map;
689 }, [rolesData]);
690
691 return (
692 <div className="space-y-4">
693 <div className="flex items-start justify-between gap-3 flex-wrap">
694 <div>
695 <h3 className="text-lg font-semibold text-gray-900 dark:text-white">
696 {__("Team members", "yatra")}
697 </h3>
698 <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
699 {sprintf(
700 /* translators: %s: brand name */
701 __(
702 "Every WordPress user with a %s role. Add an existing WP user here, or send a magic-link invitation from the Invitations tab.",
703 "yatra",
704 ),
705 brandName(),
706 )}
707 </p>
708 </div>
709 <div className="flex flex-wrap gap-2">
710 <Button variant="outline" onClick={() => setShowAddModal(true)}>
711 <UserPlus className="mr-1.5 h-4 w-4" />
712 {__("Add existing WP user", "yatra")}
713 </Button>
714 <Button onClick={() => setShowCreateModal(true)}>
715 <UserPlus className="mr-1.5 h-4 w-4" />
716 {__("Create new user", "yatra")}
717 </Button>
718 </div>
719 </div>
720
721 {selectedIds.size > 0 && (
722 <div className="flex flex-wrap items-center gap-2 rounded-md border border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/30 p-3">
723 <span className="text-sm text-blue-900 dark:text-blue-100 font-medium mr-auto">
724 {sprintf(
725 /* translators: %d: count of selected rows */
726 __("%d member(s) selected", "yatra"),
727 selectedIds.size,
728 )}
729 </span>
730 <Select
731 aria-label={__("Bulk: change role to", "yatra")}
732 defaultValue=""
733 onChange={(e) => {
734 const role = e.target.value;
735 if (!role) return;
736 setPendingBulk({ action: "change_role", roleSlug: role });
737 // Reset the picker after capturing — modal owns the value now.
738 e.target.value = "";
739 }}
740 >
741 <option value="">{__("Change role to…", "yatra")}</option>
742 {(rolesData?.data ?? []).map((r) => (
743 <option key={r.slug} value={r.slug}>
744 {r.display_name}
745 </option>
746 ))}
747 </Select>
748 <Button
749 type="button"
750 variant="outline"
751 onClick={() => setPendingBulk({ action: "force_logout" })}
752 disabled={bulkMutation.isPending}
753 >
754 <LogOut className="mr-1.5 h-4 w-4" />
755 {__("Force logout", "yatra")}
756 </Button>
757 <Button
758 type="button"
759 variant="destructive"
760 onClick={() => setPendingBulk({ action: "remove" })}
761 disabled={bulkMutation.isPending}
762 >
763 <Trash2 className="mr-1.5 h-4 w-4" />
764 {__("Remove", "yatra")}
765 </Button>
766 <Button
767 type="button"
768 variant="outline"
769 onClick={() => setSelectedIds(new Set())}
770 >
771 {__("Clear", "yatra")}
772 </Button>
773 </div>
774 )}
775
776 <Card className="overflow-visible">
777 <CardContent className="p-0 overflow-visible">
778 <SharedTable
779 data={users}
780 selectedItemIds={Array.from(selectedIds)}
781 onSelectItem={(id, checked) => toggleOne(Number(id), checked)}
782 onSelectAll={toggleAll}
783 isAllSelected={
784 users.length > 0 &&
785 users
786 .filter((u) => u.id !== currentUserId)
787 .every((u) => selectedIds.has(u.id))
788 }
789 getItemId={(u: TeamUser) => u.id}
790 columns={[
791 {
792 key: "display_name",
793 label: __("Member", "yatra"),
794 render: (u: TeamUser) => (
795 <div className="flex items-center gap-3">
796 <div className="w-10 h-10 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center flex-shrink-0">
797 <span className="text-sm font-semibold text-blue-600 dark:text-blue-400">
798 {(u.display_name || u.user_login)
799 .charAt(0)
800 .toUpperCase()}
801 </span>
802 </div>
803 <div className="min-w-0">
804 <button
805 type="button"
806 onClick={() => setEditingId(u.id)}
807 className="font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:underline transition-colors cursor-pointer text-left"
808 >
809 {u.display_name || u.user_login}
810 </button>
811 <div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate max-w-md">
812 {u.email}
813 </div>
814 {u.is_wp_admin && (
815 <Badge className="mt-1 bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300 text-[10px]">
816 <Crown className="w-2.5 h-2.5 mr-1" />
817 {__("WP Admin", "yatra")}
818 </Badge>
819 )}
820 </div>
821 </div>
822 ),
823 },
824 {
825 key: "primary_role",
826 label: __("Role", "yatra"),
827 render: (u: TeamUser) =>
828 u.primary_role ? (
829 <Badge className="bg-indigo-50 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300 border border-indigo-200 dark:border-indigo-800">
830 {roleLabels[u.primary_role] || u.primary_role}
831 </Badge>
832 ) : (
833 <span className="text-xs text-gray-400">—</span>
834 ),
835 },
836 {
837 key: "scope",
838 label: __("Scope", "yatra"),
839 render: (u: TeamUser) =>
840 u.has_scope ? (
841 <Tooltip
842 content={
843 <>
844 {u.scopes.destinations.length > 0 &&
845 `${u.scopes.destinations.length} dest.`}{" "}
846 {u.scopes.activities.length > 0 &&
847 `${u.scopes.activities.length} act.`}{" "}
848 {u.scopes.trips.length > 0 &&
849 `${u.scopes.trips.length} trips`}{" "}
850 {u.scopes.categories.length > 0 &&
851 `${u.scopes.categories.length} cat.`}
852 </>
853 }
854 >
855 <Badge className="bg-amber-50 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
856 {__("Scoped", "yatra")}
857 </Badge>
858 </Tooltip>
859 ) : (
860 <span className="text-xs text-gray-400">
861 {__("Unrestricted", "yatra")}
862 </span>
863 ),
864 },
865 {
866 key: "last_login",
867 label: __("Last login", "yatra"),
868 render: (u: TeamUser) =>
869 u.last_login ? (
870 <span className="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">
871 {new Date(u.last_login).toLocaleString()}
872 </span>
873 ) : (
874 <span className="text-xs text-gray-400">—</span>
875 ),
876 },
877 {
878 key: "expires_at",
879 label: __("Access expires", "yatra"),
880 render: (u: TeamUser) => <AccessExpiryCell user={u} />,
881 },
882 ]}
883 actions={[
884 {
885 key: "edit",
886 label: __("Edit access", "yatra"),
887 icon: <KeyRound className="w-4 h-4" />,
888 onClick: (u: TeamUser) => setEditingId(u.id),
889 },
890 {
891 key: "logout",
892 label: __("Force logout", "yatra"),
893 icon: <LogOut className="w-4 h-4" />,
894 onClick: (u: TeamUser) => forceLogoutMutation.mutate(u.id),
895 },
896 {
897 key: "remove",
898 label: __("Remove from team", "yatra"),
899 icon: <Trash2 className="w-4 h-4" />,
900 onClick: (u: TeamUser) => setPendingRemove(u),
901 variant: "destructive",
902 },
903 ]}
904 isLoading={isLoading}
905 emptyText={__("No team members yet", "yatra")}
906 emptyDescription={__(
907 "Invite a teammate from the Invitations tab to get started.",
908 "yatra",
909 )}
910 />
911 </CardContent>
912 </Card>
913
914 {editingId !== null && (
915 <MemberEditDrawer
916 userId={editingId}
917 onClose={() => setEditingId(null)}
918 />
919 )}
920
921 {showAddModal && (
922 <AddMemberModal onClose={() => setShowAddModal(false)} />
923 )}
924
925 {showCreateModal && (
926 <CreateUserModal onClose={() => setShowCreateModal(false)} />
927 )}
928
929 <ConfirmationDialog
930 isOpen={pendingRemove !== null}
931 onClose={() => !removeMutation.isPending && setPendingRemove(null)}
932 onConfirm={() =>
933 pendingRemove && removeMutation.mutate(pendingRemove.id)
934 }
935 title={sprintf(
936 /* translators: %s: brand name */
937 __("Remove member from %s?", "yatra"),
938 brandName(),
939 )}
940 description={
941 pendingRemove
942 ? sprintf(
943 /* translators: 1: brand name, 2: member display name, 3: brand name */
944 __(
945 'This strips %1$s role + caps + scopes from "%2$s". Their WordPress user is preserved (they keep any non-%3$s access on this site).',
946 "yatra",
947 ),
948 brandName(),
949 pendingRemove.display_name,
950 brandName(),
951 )
952 : ""
953 }
954 confirmText={__("Remove access", "yatra")}
955 cancelText={__("Cancel", "yatra")}
956 variant="danger"
957 isLoading={removeMutation.isPending}
958 />
959
960 {/* Bulk confirmation dialog. Copy varies by action; the operator */}
961 {/* always sees the affected count + a destructive-style warning */}
962 {/* when the action removes access or invalidates sessions. */}
963 <ConfirmationDialog
964 isOpen={pendingBulk !== null}
965 onClose={() => !bulkMutation.isPending && setPendingBulk(null)}
966 onConfirm={() => {
967 if (!pendingBulk) return;
968 const ids = Array.from(selectedIds);
969 if (pendingBulk.action === "change_role") {
970 bulkMutation.mutate({
971 action: "change_role",
972 user_ids: ids,
973 role_slug: pendingBulk.roleSlug,
974 });
975 } else if (pendingBulk.action === "remove") {
976 bulkMutation.mutate({ action: "remove", user_ids: ids });
977 } else if (pendingBulk.action === "force_logout") {
978 bulkMutation.mutate({ action: "force_logout", user_ids: ids });
979 }
980 }}
981 title={(() => {
982 if (!pendingBulk) return "";
983 if (pendingBulk.action === "change_role") {
984 return sprintf(
985 /* translators: %d: count */
986 __("Change role on %d member(s)?", "yatra"),
987 selectedIds.size,
988 );
989 }
990 if (pendingBulk.action === "remove") {
991 return sprintf(
992 /* translators: %d: count */
993 __("Remove %d member(s) from the team?", "yatra"),
994 selectedIds.size,
995 );
996 }
997 return sprintf(
998 /* translators: %d: count */
999 __("Force logout on %d member(s)?", "yatra"),
1000 selectedIds.size,
1001 );
1002 })()}
1003 description={(() => {
1004 if (!pendingBulk) return "";
1005 if (pendingBulk.action === "change_role") {
1006 const roleLabel =
1007 (rolesData?.data ?? []).find(
1008 (r) => r.slug === pendingBulk.roleSlug,
1009 )?.display_name ?? pendingBulk.roleSlug;
1010 return sprintf(
1011 /* translators: 1: role label, 2: count */
1012 __(
1013 'Sets the role to "%1$s" on %2$d member(s). Existing per-user grants and scopes are preserved. The last team administrator cannot be demoted — failures are reported per-id.',
1014 "yatra",
1015 ),
1016 roleLabel ?? "",
1017 selectedIds.size,
1018 );
1019 }
1020 if (pendingBulk.action === "remove") {
1021 return __(
1022 "Strips role + caps + scopes from each selected member. Their WordPress user accounts stay. Last-team-admin is protected — that row will report as failed.",
1023 "yatra",
1024 );
1025 }
1026 return __(
1027 "Invalidates every active session for the selected members. They will be logged out everywhere and need to re-authenticate.",
1028 "yatra",
1029 );
1030 })()}
1031 confirmText={
1032 pendingBulk?.action === "change_role"
1033 ? __("Apply role", "yatra")
1034 : pendingBulk?.action === "remove"
1035 ? __("Remove access", "yatra")
1036 : __("Force logout", "yatra")
1037 }
1038 cancelText={__("Cancel", "yatra")}
1039 variant={pendingBulk?.action === "remove" ? "danger" : "info"}
1040 isLoading={bulkMutation.isPending}
1041 />
1042 </div>
1043 );
1044 };
1045
1046 /* -------------------------------------------------------------------------- */
1047 /* Add Member modal — attaches a Yatra role to an existing WP user. */
1048 /* */
1049 /* This is intentionally NOT the same as "Invite by email" (Invitations tab) */
1050 /* — invitations create a brand-new WP user account. This flow is for users */
1051 /* who already exist on the WP site but don't yet have Yatra access. */
1052 /* -------------------------------------------------------------------------- */
1053
1054 const AddMemberModal: React.FC<{ onClose: () => void }> = ({ onClose }) => {
1055 const queryClient = useQueryClient();
1056 const { showToast } = useToast();
1057 const [searchQ, setSearchQ] = useState("");
1058 const [pickedId, setPickedId] = useState<number | null>(null);
1059 const [roleSlug, setRoleSlug] = useState("yatra_sales_agent");
1060
1061 // Debounced search — only refetch when the user pauses typing.
1062 const [debouncedQ, setDebouncedQ] = useState("");
1063 React.useEffect(() => {
1064 const t = window.setTimeout(() => setDebouncedQ(searchQ), 250);
1065 return () => window.clearTimeout(t);
1066 }, [searchQ]);
1067
1068 const { data: candidatesData, isLoading: candidatesLoading } = useQuery({
1069 queryKey: ["team-users-available", debouncedQ],
1070 queryFn: () => teamApi.listAvailableUsers(debouncedQ, 50),
1071 });
1072 const { data: rolesData } = useQuery({
1073 queryKey: ["team-roles"],
1074 queryFn: () => teamApi.listRoles(),
1075 });
1076
1077 const candidates = candidatesData?.data ?? [];
1078
1079 const addMutation = useMutation({
1080 mutationFn: () => teamApi.updateUser(pickedId!, { role_slug: roleSlug }),
1081 onSuccess: () => {
1082 queryClient.invalidateQueries({ queryKey: ["team-users"] });
1083 queryClient.invalidateQueries({ queryKey: ["team-users-available"] });
1084 showToast(__("Member added to the team.", "yatra"), "success");
1085 onClose();
1086 },
1087 onError: (e: any) => showToast(extractError(e), "error"),
1088 });
1089
1090 return (
1091 <Modal
1092 isOpen
1093 onClose={onClose}
1094 title={
1095 <div className="flex items-center gap-2">
1096 <UserPlus className="w-5 h-5 text-blue-500" />
1097 {__("Add existing WordPress user", "yatra")}
1098 </div>
1099 }
1100 size="md"
1101 footer={
1102 <div className="flex justify-end gap-2">
1103 <Button variant="outline" onClick={onClose}>
1104 {__("Cancel", "yatra")}
1105 </Button>
1106 <Button
1107 disabled={pickedId === null || addMutation.isPending}
1108 onClick={() => addMutation.mutate()}
1109 >
1110 {addMutation.isPending ? (
1111 <>
1112 <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
1113 {__("Adding…", "yatra")}
1114 </>
1115 ) : (
1116 __("Add to team", "yatra")
1117 )}
1118 </Button>
1119 </div>
1120 }
1121 >
1122 <div className="space-y-4">
1123 <Alert variant="info" title={__("Looking for someone new?", "yatra")}>
1124 {sprintf(
1125 /* translators: %s: brand name */
1126 __(
1127 "This picker shows WordPress users who don't yet have a %s role. For people who aren't on the site at all, send them an email invitation from the Invitations tab — that creates the WP user for them.",
1128 "yatra",
1129 ),
1130 brandName(),
1131 )}
1132 </Alert>
1133
1134 <div>
1135 <Label htmlFor="add-member-search">
1136 {__("Find a user", "yatra")}
1137 </Label>
1138 <Input
1139 id="add-member-search"
1140 value={searchQ}
1141 onChange={(e) => {
1142 setSearchQ(e.target.value);
1143 setPickedId(null);
1144 }}
1145 placeholder={__("Search by name, email, or login…", "yatra")}
1146 className="mt-1"
1147 />
1148 </div>
1149
1150 <div className="border border-gray-200 dark:border-gray-700 rounded-md max-h-72 overflow-y-auto">
1151 {candidatesLoading ? (
1152 <div className="divide-y divide-gray-100 dark:divide-gray-800">
1153 {[0, 1, 2, 3].map((i) => (
1154 <div key={i} className="flex items-center gap-3 px-3 py-2.5">
1155 <Skeleton className="h-4 w-4 rounded-full flex-shrink-0" />
1156 <Skeleton className="w-9 h-9 rounded-full flex-shrink-0" />
1157 <div className="min-w-0 flex-1 space-y-1.5">
1158 <Skeleton className="h-3.5 w-2/5" />
1159 <Skeleton className="h-3 w-3/5" />
1160 </div>
1161 </div>
1162 ))}
1163 </div>
1164 ) : candidates.length === 0 ? (
1165 <div className="text-center py-8 px-4 text-sm text-gray-500 dark:text-gray-400">
1166 {debouncedQ === ""
1167 ? sprintf(
1168 /* translators: %s: brand name */
1169 __(
1170 "No available users — every WP user on this site is already a %s team member. Use the Invitations tab to add new people.",
1171 "yatra",
1172 ),
1173 brandName(),
1174 )
1175 : __("No matching users.", "yatra")}
1176 </div>
1177 ) : (
1178 <div className="divide-y divide-gray-100 dark:divide-gray-800">
1179 {candidates.map((u) => (
1180 <label
1181 key={u.id}
1182 htmlFor={`pick-${u.id}`}
1183 className={`flex items-center gap-3 px-3 py-2.5 cursor-pointer transition-colors ${
1184 pickedId === u.id
1185 ? "bg-blue-50 dark:bg-blue-900/20"
1186 : "hover:bg-gray-50 dark:hover:bg-gray-800/50"
1187 }`}
1188 >
1189 <input
1190 id={`pick-${u.id}`}
1191 type="radio"
1192 name="add-member-pick"
1193 checked={pickedId === u.id}
1194 onChange={() => setPickedId(u.id)}
1195 className="h-4 w-4 flex-shrink-0"
1196 />
1197 <div className="w-9 h-9 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center flex-shrink-0">
1198 <span className="text-sm font-semibold text-blue-600 dark:text-blue-400">
1199 {(u.display_name || u.login).charAt(0).toUpperCase()}
1200 </span>
1201 </div>
1202 <div className="min-w-0 flex-1">
1203 <div className="text-sm font-medium text-gray-900 dark:text-white truncate">
1204 {u.display_name || u.login}
1205 </div>
1206 <div className="text-xs text-gray-500 dark:text-gray-400 truncate">
1207 {u.email}
1208 </div>
1209 </div>
1210 </label>
1211 ))}
1212 </div>
1213 )}
1214 </div>
1215
1216 <div>
1217 <Label htmlFor="add-member-role">
1218 {sprintf(
1219 /* translators: %s: brand name */
1220 __("%s role", "yatra"),
1221 brandName(),
1222 )}
1223 </Label>
1224 <Select
1225 id="add-member-role"
1226 value={roleSlug}
1227 onChange={(e) => setRoleSlug(e.target.value)}
1228 className="mt-1"
1229 >
1230 {(rolesData?.data ?? []).map((r) => (
1231 <option key={r.slug} value={r.slug}>
1232 {r.display_name} ({r.capability_count} {__("caps", "yatra")})
1233 </option>
1234 ))}
1235 </Select>
1236 <p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
1237 {__(
1238 "Per-user scopes + capability overrides can be set after the member is added (3-dot menu → Edit access).",
1239 "yatra",
1240 )}
1241 </p>
1242 </div>
1243 </div>
1244 </Modal>
1245 );
1246 };
1247
1248 /* -------------------------------------------------------------------------- */
1249 /* Create User modal — provisions a brand-new WP user + Yatra role in one go */
1250 /* */
1251 /* Sister flow to AddMemberModal (which attaches a role to an existing WP */
1252 /* user) and InvitationsTab (which sends a magic-link email). This one is */
1253 /* for operators who want to create the account directly — e.g. internal */
1254 /* staff, contractors with no inbox we need to involve, batch onboarding. */
1255 /* */
1256 /* Password handling: operator chooses between "I'll set it now" (8+ chars) */
1257 /* and "Send reset-password email" (recommended — operator never types/ */
1258 /* shares the password). The latter triggers `wp_mail` reset flow. */
1259 /* -------------------------------------------------------------------------- */
1260
1261 const CreateUserModal: React.FC<{ onClose: () => void }> = ({ onClose }) => {
1262 const queryClient = useQueryClient();
1263 const { showToast } = useToast();
1264
1265 const { data: rolesData } = useQuery({
1266 queryKey: ["team-roles"],
1267 queryFn: () => teamApi.listRoles(),
1268 });
1269
1270 const [email, setEmail] = useState("");
1271 const [firstName, setFirstName] = useState("");
1272 const [lastName, setLastName] = useState("");
1273 const [username, setUsername] = useState("");
1274 const [roleSlug, setRoleSlug] = useState("");
1275 type PwMode = "reset_email" | "manual";
1276 const [pwMode, setPwMode] = useState<PwMode>("reset_email");
1277 const [password, setPassword] = useState("");
1278
1279 // Email validation — RFC-flavored, good enough for UI. Server is the
1280 // source of truth (wp's `is_email`).
1281 const emailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
1282 const passwordOk = pwMode === "reset_email" || password.length >= 8;
1283 const canSubmit = emailValid && roleSlug !== "" && passwordOk;
1284
1285 const createMutation = useMutation({
1286 mutationFn: () =>
1287 teamApi.createUser({
1288 email,
1289 role_slug: roleSlug,
1290 first_name: firstName || undefined,
1291 last_name: lastName || undefined,
1292 username: username || undefined,
1293 password: pwMode === "manual" ? password : undefined,
1294 send_reset_email: pwMode === "reset_email",
1295 }),
1296 onSuccess: () => {
1297 queryClient.invalidateQueries({ queryKey: ["team-users"] });
1298 showToast(__("User created and added to the team.", "yatra"), "success");
1299 onClose();
1300 },
1301 onError: (e: any) => showToast(extractError(e), "error"),
1302 });
1303
1304 return (
1305 <Modal
1306 isOpen
1307 onClose={onClose}
1308 title={
1309 <div className="flex items-center gap-2">
1310 <UserPlus className="w-5 h-5 text-blue-500" />
1311 {__("Create new user", "yatra")}
1312 </div>
1313 }
1314 size="lg"
1315 footer={
1316 <div className="flex justify-end gap-2">
1317 <Button variant="outline" onClick={onClose}>
1318 {__("Cancel", "yatra")}
1319 </Button>
1320 <Button
1321 disabled={!canSubmit || createMutation.isPending}
1322 onClick={() => createMutation.mutate()}
1323 >
1324 {createMutation.isPending ? (
1325 <>
1326 <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
1327 {__("Creating…", "yatra")}
1328 </>
1329 ) : (
1330 __("Create user", "yatra")
1331 )}
1332 </Button>
1333 </div>
1334 }
1335 >
1336 <div className="space-y-4">
1337 <Alert
1338 variant="info"
1339 title={__("Creates a new WordPress user", "yatra")}
1340 >
1341 {sprintf(
1342 /* translators: %s: brand name */
1343 __(
1344 'This provisions a brand-new WP account and attaches the chosen %s role in one step. For people who already have a WP user, use "Add existing WP user" instead. To send a magic-link invite via email (account is created on accept), use the Invitations tab.',
1345 "yatra",
1346 ),
1347 brandName(),
1348 )}
1349 </Alert>
1350
1351 <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
1352 <div>
1353 <Label htmlFor="cu-first">{__("First name", "yatra")}</Label>
1354 <Input
1355 id="cu-first"
1356 value={firstName}
1357 onChange={(e) => setFirstName(e.target.value)}
1358 className="mt-1"
1359 />
1360 </div>
1361 <div>
1362 <Label htmlFor="cu-last">{__("Last name", "yatra")}</Label>
1363 <Input
1364 id="cu-last"
1365 value={lastName}
1366 onChange={(e) => setLastName(e.target.value)}
1367 className="mt-1"
1368 />
1369 </div>
1370 </div>
1371
1372 <div>
1373 <Label htmlFor="cu-email">{__("Email", "yatra")}</Label>
1374 <Input
1375 id="cu-email"
1376 type="email"
1377 value={email}
1378 onChange={(e) => setEmail(e.target.value)}
1379 placeholder="[email protected]"
1380 className="mt-1"
1381 required
1382 />
1383 {email !== "" && !emailValid && (
1384 <p className="text-xs text-red-600 mt-1">
1385 {__("Enter a valid email address.", "yatra")}
1386 </p>
1387 )}
1388 </div>
1389
1390 <div>
1391 <Label htmlFor="cu-username">
1392 {__("Username (optional)", "yatra")}
1393 </Label>
1394 <Input
1395 id="cu-username"
1396 value={username}
1397 onChange={(e) => setUsername(e.target.value)}
1398 placeholder={__("Auto-generated from email", "yatra")}
1399 className="mt-1"
1400 />
1401 <p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
1402 {__(
1403 "WP requires unique, lowercase, no spaces. If left blank, derived from the email local-part.",
1404 "yatra",
1405 )}
1406 </p>
1407 </div>
1408
1409 <div>
1410 <Label htmlFor="cu-role">
1411 {sprintf(
1412 /* translators: %s: brand name */
1413 __("%s role", "yatra"),
1414 brandName(),
1415 )}
1416 </Label>
1417 <Select
1418 id="cu-role"
1419 value={roleSlug}
1420 onChange={(e) => setRoleSlug(e.target.value)}
1421 className="mt-1"
1422 >
1423 <option value="">{__("Select a role…", "yatra")}</option>
1424 {(rolesData?.data ?? []).map((r) => (
1425 <option key={r.slug} value={r.slug}>
1426 {r.display_name} ({r.capability_count} {__("caps", "yatra")})
1427 </option>
1428 ))}
1429 </Select>
1430 </div>
1431
1432 <div className="rounded-md border border-gray-200 dark:border-gray-700 p-3">
1433 <Label className="text-sm font-medium">
1434 {__("Password", "yatra")}
1435 </Label>
1436 <div className="mt-2 space-y-2">
1437 <label className="flex items-start gap-2 cursor-pointer">
1438 <input
1439 type="radio"
1440 name="cu-pw-mode"
1441 checked={pwMode === "reset_email"}
1442 onChange={() => setPwMode("reset_email")}
1443 className="mt-0.5"
1444 />
1445 <div className="text-sm">
1446 <div className="font-medium text-gray-900 dark:text-white">
1447 {__("Send reset-password email (recommended)", "yatra")}
1448 </div>
1449 <div className="text-xs text-gray-500 dark:text-gray-400">
1450 {__(
1451 "User receives a standard WP password-reset link. You never see or type their password — strongest security posture.",
1452 "yatra",
1453 )}
1454 </div>
1455 </div>
1456 </label>
1457 <label className="flex items-start gap-2 cursor-pointer">
1458 <input
1459 type="radio"
1460 name="cu-pw-mode"
1461 checked={pwMode === "manual"}
1462 onChange={() => setPwMode("manual")}
1463 className="mt-0.5"
1464 />
1465 <div className="text-sm flex-1">
1466 <div className="font-medium text-gray-900 dark:text-white">
1467 {__("Set a password now", "yatra")}
1468 </div>
1469 <div className="text-xs text-gray-500 dark:text-gray-400 mb-2">
1470 {__(
1471 "You'll need to share this password with the user out-of-band (DM, in person). 8 character minimum.",
1472 "yatra",
1473 )}
1474 </div>
1475 {pwMode === "manual" && (
1476 <Input
1477 type="password"
1478 value={password}
1479 onChange={(e) => setPassword(e.target.value)}
1480 placeholder={__("At least 8 characters", "yatra")}
1481 className="w-full"
1482 />
1483 )}
1484 </div>
1485 </label>
1486 </div>
1487 </div>
1488 </div>
1489 </Modal>
1490 );
1491 };
1492
1493 /* -------------------------------------------------------------------------- */
1494 /* Member edit drawer — role + scopes + per-user caps + sessions */
1495 /* -------------------------------------------------------------------------- */
1496
1497 const MemberEditDrawer: React.FC<{
1498 userId: number;
1499 onClose: () => void;
1500 }> = ({ userId, onClose }) => {
1501 const queryClient = useQueryClient();
1502 const { showToast } = useToast();
1503
1504 const { data, isLoading } = useQuery({
1505 queryKey: ["team-user", userId],
1506 queryFn: () => teamApi.getUser(userId),
1507 });
1508 const { data: rolesData } = useQuery({
1509 queryKey: ["team-roles"],
1510 queryFn: () => teamApi.listRoles(),
1511 });
1512 const { data: capsData } = useQuery({
1513 queryKey: ["team-capabilities"],
1514 queryFn: () => teamApi.listCapabilities(),
1515 });
1516
1517 const user = data?.data;
1518 const [roleSlug, setRoleSlug] = useState<string>("");
1519 const [extraGrants, setExtraGrants] = useState<string[]>([]);
1520 const [extraRevokes, setExtraRevokes] = useState<string[]>([]);
1521 // Time-windowed access. Stored as `YYYY-MM-DDTHH:mm` for <input type="datetime-local"/>.
1522 // Empty string means "permanent" — the mutation omits the field when empty
1523 // OR sends 0 when the user explicitly clicks "Clear expiry".
1524 const [expiresLocal, setExpiresLocal] = useState<string>("");
1525 // True when the user touched the expiry field this session — only then
1526 // do we send `expires_at` to the server. Lets us distinguish "operator
1527 // didn't touch this" from "operator explicitly cleared it".
1528 const [expiryDirty, setExpiryDirty] = useState(false);
1529
1530 React.useEffect(() => {
1531 if (user) {
1532 setRoleSlug(user.primary_role || "");
1533 setExtraGrants(user.caps_grant);
1534 setExtraRevokes(user.caps_revoke);
1535 setExpiresLocal(unixToLocalInputValue(user.expires_at));
1536 setExpiryDirty(false);
1537 }
1538 }, [user]);
1539
1540 const isSelfEdit = user?.id === window.yatraAdmin?.currentUser;
1541
1542 const updateMutation = useMutation({
1543 mutationFn: () => {
1544 const payload: TeamUserWritePayload = {
1545 role_slug: roleSlug,
1546 caps_grant: extraGrants,
1547 caps_revoke: extraRevokes,
1548 };
1549 if (expiryDirty) {
1550 // Send 0 when cleared (= permanent), otherwise unix seconds.
1551 payload.expires_at = expiresLocal
1552 ? Math.floor(new Date(expiresLocal).getTime() / 1000)
1553 : 0;
1554 }
1555 return teamApi.updateUser(userId, payload);
1556 },
1557 onSuccess: (res) => {
1558 queryClient.invalidateQueries({ queryKey: ["team-users"] });
1559 queryClient.invalidateQueries({ queryKey: ["team-user", userId] });
1560 // If the operator edited their OWN account, refresh the
1561 // current-user cap cache so the UI updates without reload.
1562 if (res.data?.id === (window.yatraAdmin?.userCaps ? userId : -1)) {
1563 setUserCaps(res.data.effective_caps);
1564 }
1565 showToast(__("Member updated.", "yatra"), "success");
1566 onClose();
1567 },
1568 onError: (e: any) => showToast(extractError(e), "error"),
1569 });
1570
1571 return (
1572 <Modal
1573 isOpen
1574 onClose={onClose}
1575 title={
1576 <div className="flex items-center gap-2">
1577 <KeyRound className="w-5 h-5 text-blue-500" />
1578 {user ? user.display_name : __("Loading…", "yatra")}
1579 </div>
1580 }
1581 size="lg"
1582 hideFooter={false}
1583 footer={
1584 <div className="flex justify-end gap-2">
1585 <Button variant="outline" onClick={onClose}>
1586 {user?.is_wp_admin ? __("Close", "yatra") : __("Cancel", "yatra")}
1587 </Button>
1588 {/* Hide Save when the target is a WP admin — nothing to */}
1589 {/* persist (role / caps / scopes / expiry are all no-ops */}
1590 {/* against the admin fallback and the server would 409). */}
1591 {!user?.is_wp_admin && (
1592 <Button
1593 disabled={updateMutation.isPending}
1594 onClick={() => updateMutation.mutate()}
1595 >
1596 {updateMutation.isPending ? (
1597 <>
1598 <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
1599 {__("Saving…", "yatra")}
1600 </>
1601 ) : (
1602 __("Save changes", "yatra")
1603 )}
1604 </Button>
1605 )}
1606 </div>
1607 }
1608 >
1609 {isLoading || !user ? (
1610 <div className="space-y-4">
1611 {/* Skeleton mirroring the loaded drawer layout: role select, */}
1612 {/* expiry box, cap matrix groups. Same heights as the real form */}
1613 {/* so the modal doesn't jump on data arrival. */}
1614 <Skeleton className="h-4 w-24" />
1615 <Skeleton className="h-9 w-full" />
1616 <div className="rounded-md border border-gray-200 dark:border-gray-700 p-3 space-y-3">
1617 <Skeleton className="h-4 w-48" />
1618 <Skeleton className="h-9 w-full" />
1619 </div>
1620 <div className="space-y-2">
1621 {[0, 1, 2].map((i) => (
1622 <div
1623 key={i}
1624 className="rounded-md border border-gray-200 dark:border-gray-700 p-3 space-y-2"
1625 >
1626 <Skeleton className="h-4 w-32" />
1627 <div className="grid grid-cols-2 gap-2">
1628 <Skeleton className="h-6 w-full" />
1629 <Skeleton className="h-6 w-full" />
1630 <Skeleton className="h-6 w-full" />
1631 <Skeleton className="h-6 w-full" />
1632 </div>
1633 </div>
1634 ))}
1635 </div>
1636 </div>
1637 ) : (
1638 <div className="space-y-4">
1639 {user.is_wp_admin ? (
1640 // Admin-lock UI. WP administrators always pass every yatra_*
1641 // cap via the server-side admin fallback, so role / grant /
1642 // revoke / scope / expiry assignments against them would be
1643 // misleading no-ops. We show a read-only summary instead of
1644 // editable form controls, and the server rejects any write
1645 // attempts with `yatra_team_admin_locked` (409) as a defense-
1646 // in-depth check.
1647 <Alert
1648 variant="info"
1649 title={__("WordPress administrator", "yatra")}
1650 >
1651 <p className="text-sm">
1652 {sprintf(
1653 /* translators: %s: brand name */
1654 __(
1655 "This user is a WP administrator and always passes every %s capability check via the admin fallback. Role assignment, capability grants, revokes, scope restrictions, and access expiry cannot be enforced on them.",
1656 "yatra",
1657 ),
1658 brandName(),
1659 )}
1660 </p>
1661 <p className="text-sm mt-2">
1662 {__(
1663 "To scope this user's Yatra access, first remove their WordPress administrator role from the standard wp-admin Users screen, then return here to assign a Yatra role.",
1664 "yatra",
1665 )}
1666 </p>
1667 {/* Read-only summary of what they DO have — useful for */}
1668 {/* audit / "why does this person see X" conversations. */}
1669 <div className="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-3 text-xs">
1670 <div>
1671 <div className="text-gray-500 dark:text-gray-400 uppercase tracking-wide font-medium mb-0.5">
1672 {__("Effective capabilities", "yatra")}
1673 </div>
1674 <div className="text-gray-900 dark:text-white font-medium">
1675 {sprintf(
1676 /* translators: %d: count of caps */
1677 __("%d (all yatra_*)", "yatra"),
1678 user.effective_caps.length,
1679 )}
1680 </div>
1681 </div>
1682 <div>
1683 <div className="text-gray-500 dark:text-gray-400 uppercase tracking-wide font-medium mb-0.5">
1684 {__("Yatra role on record", "yatra")}
1685 </div>
1686 <div className="text-gray-900 dark:text-white font-medium">
1687 {user.primary_role
1688 ? roleSlug || user.primary_role
1689 : __("None (admin fallback only)", "yatra")}
1690 </div>
1691 </div>
1692 </div>
1693 </Alert>
1694 ) : (
1695 <>
1696 <div>
1697 <Label>
1698 {sprintf(
1699 /* translators: %s: brand name */
1700 __("%s role", "yatra"),
1701 brandName(),
1702 )}
1703 </Label>
1704 <Select
1705 value={roleSlug}
1706 onChange={(e) => setRoleSlug(e.target.value)}
1707 className="mt-1"
1708 aria-label={__("Role", "yatra")}
1709 >
1710 <option value="">
1711 {sprintf(
1712 /* translators: %s: brand name */
1713 __("No %s role", "yatra"),
1714 brandName(),
1715 )}
1716 </option>
1717 {(rolesData?.data ?? []).map((r) => (
1718 <option key={r.slug} value={r.slug}>
1719 {r.display_name} ({r.capability_count}{" "}
1720 {__("caps", "yatra")})
1721 </option>
1722 ))}
1723 </Select>
1724 </div>
1725
1726 {/* Time-windowed access. Hidden when: */
1727 /* - operator is editing themselves (server blocks self-expiry) */
1728 /* - target is a WP admin (admin fallback makes expiry a no-op) */}
1729 {!isSelfEdit && !user.is_wp_admin && (
1730 <div className="rounded-md border border-gray-200 dark:border-gray-700 p-3 space-y-2">
1731 <div className="flex items-start justify-between gap-3">
1732 <div>
1733 <Label
1734 htmlFor="member-expires-at"
1735 className="text-sm font-medium"
1736 >
1737 {__("Access expires on (optional)", "yatra")}
1738 </Label>
1739 <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
1740 {__(
1741 "Time-windowed access: caps are revoked automatically once this passes. Useful for contractors, seasonal staff, or temporary vendor access. Leave blank for permanent access.",
1742 "yatra",
1743 )}
1744 </p>
1745 </div>
1746 {expiresLocal && (
1747 <Button
1748 type="button"
1749 variant="outline"
1750 size="sm"
1751 onClick={() => {
1752 setExpiresLocal("");
1753 setExpiryDirty(true);
1754 }}
1755 >
1756 {__("Clear expiry", "yatra")}
1757 </Button>
1758 )}
1759 </div>
1760 <Input
1761 id="member-expires-at"
1762 type="datetime-local"
1763 value={expiresLocal}
1764 min={unixToLocalInputValue(
1765 Math.floor(Date.now() / 1000) + 60,
1766 )}
1767 onChange={(e) => {
1768 setExpiresLocal(e.target.value);
1769 setExpiryDirty(true);
1770 }}
1771 className="w-full"
1772 />
1773 <ExpiryNowHint expiresLocal={expiresLocal} />
1774 {user.is_expired && (
1775 <Alert
1776 variant="warning"
1777 title={__("Access has already expired", "yatra")}
1778 >
1779 {__(
1780 "This member is past their expiry and currently has no access. The next hourly sweep will fully strip their role + grants. Set a new date above to extend access — or clear the expiry to make it permanent.",
1781 "yatra",
1782 )}
1783 </Alert>
1784 )}
1785 </div>
1786 )}
1787
1788 {capsData && (
1789 <CapabilityOverridesEditor
1790 registry={capsData.capabilities}
1791 effective={user.effective_caps}
1792 grants={extraGrants}
1793 revokes={extraRevokes}
1794 onChangeGrants={setExtraGrants}
1795 onChangeRevokes={setExtraRevokes}
1796 />
1797 )}
1798 </>
1799 )}
1800 </div>
1801 )}
1802 </Modal>
1803 );
1804 };
1805
1806 const CapabilityOverridesEditor: React.FC<{
1807 registry: Record<string, CapabilityDef>;
1808 effective: string[];
1809 grants: string[];
1810 revokes: string[];
1811 onChangeGrants: (next: string[]) => void;
1812 onChangeRevokes: (next: string[]) => void;
1813 }> = ({
1814 registry,
1815 effective,
1816 grants,
1817 revokes,
1818 onChangeGrants,
1819 onChangeRevokes,
1820 }) => {
1821 const byCategory = useMemo(() => {
1822 const map: Record<string, Array<[string, CapabilityDef]>> = {};
1823 Object.entries(registry).forEach(([cap, def]) => {
1824 if (!map[def.category]) map[def.category] = [];
1825 map[def.category].push([cap, def]);
1826 });
1827 return map;
1828 }, [registry]);
1829
1830 const sensColor = (s: string): string => {
1831 if (s === "critical")
1832 return "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300";
1833 if (s === "high")
1834 return "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300";
1835 if (s === "medium")
1836 return "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300";
1837 return "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300";
1838 };
1839
1840 return (
1841 <div>
1842 <div className="text-sm font-medium text-gray-900 dark:text-white mb-1">
1843 {__("Per-user capability overrides", "yatra")}
1844 </div>
1845 <p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
1846 {__(
1847 "Override the role's defaults for THIS user. Grants extend access; revokes deny even when the role allows.",
1848 "yatra",
1849 )}
1850 </p>
1851 <div className="space-y-3 max-h-80 overflow-y-auto pr-1 border border-gray-200 dark:border-gray-700 rounded-md p-2">
1852 {Object.entries(byCategory).map(([cat, rows]) => (
1853 <div key={cat}>
1854 <div className="text-[11px] font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 px-1 py-1">
1855 {cat}
1856 </div>
1857 {rows.map(([cap, def]) => {
1858 const isEffective = effective.includes(cap);
1859 const isGranted = grants.includes(cap);
1860 const isRevoked = revokes.includes(cap);
1861 return (
1862 <div
1863 key={cap}
1864 className="flex items-center justify-between gap-3 px-2 py-1.5 rounded hover:bg-gray-50 dark:hover:bg-gray-800/50"
1865 >
1866 <div className="min-w-0 flex-1">
1867 <div className="flex items-center gap-2">
1868 <span className="text-sm text-gray-900 dark:text-white">
1869 {def.label}
1870 </span>
1871 <span
1872 className={`text-[10px] font-medium px-1.5 py-0.5 rounded ${sensColor(def.sensitivity)}`}
1873 >
1874 {def.sensitivity}
1875 </span>
1876 </div>
1877 <code className="text-[11px] text-gray-400 dark:text-gray-500 font-mono">
1878 {cap}
1879 </code>
1880 </div>
1881 <div className="flex items-center gap-1 flex-shrink-0">
1882 {isEffective && !isGranted && !isRevoked && (
1883 <Badge className="bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-300 text-[10px]">
1884 {__("via role", "yatra")}
1885 </Badge>
1886 )}
1887 <Tooltip
1888 content={__("Grant this cap on top of the role", "yatra")}
1889 >
1890 <button
1891 type="button"
1892 onClick={() => {
1893 if (isGranted) {
1894 onChangeGrants(grants.filter((c) => c !== cap));
1895 } else {
1896 onChangeGrants([...grants, cap]);
1897 onChangeRevokes(revokes.filter((c) => c !== cap));
1898 }
1899 }}
1900 className={`text-[10px] px-2 py-1 rounded border transition-colors ${
1901 isGranted
1902 ? "bg-blue-600 text-white border-blue-600"
1903 : "bg-white text-gray-600 border-gray-200 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-700 dark:hover:bg-gray-700"
1904 }`}
1905 >
1906 {__("Grant", "yatra")}
1907 </button>
1908 </Tooltip>
1909 <Tooltip
1910 content={__(
1911 "Deny this cap even if the role allows",
1912 "yatra",
1913 )}
1914 >
1915 <button
1916 type="button"
1917 onClick={() => {
1918 if (isRevoked) {
1919 onChangeRevokes(revokes.filter((c) => c !== cap));
1920 } else {
1921 onChangeRevokes([...revokes, cap]);
1922 onChangeGrants(grants.filter((c) => c !== cap));
1923 }
1924 }}
1925 className={`text-[10px] px-2 py-1 rounded border transition-colors ${
1926 isRevoked
1927 ? "bg-red-600 text-white border-red-600"
1928 : "bg-white text-gray-600 border-gray-200 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-700 dark:hover:bg-gray-700"
1929 }`}
1930 >
1931 {__("Revoke", "yatra")}
1932 </button>
1933 </Tooltip>
1934 </div>
1935 </div>
1936 );
1937 })}
1938 </div>
1939 ))}
1940 </div>
1941 </div>
1942 );
1943 };
1944
1945 /* -------------------------------------------------------------------------- */
1946 /* Roles tab */
1947 /* -------------------------------------------------------------------------- */
1948
1949 const RolesTab: React.FC = () => {
1950 const queryClient = useQueryClient();
1951 const { showToast } = useToast();
1952 const [editingRoleSlug, setEditingRoleSlug] = useState<string | null>(null);
1953 // "new" sentinel = create-flow with empty bundle.
1954 // "clone:<slug>" sentinel = create-flow seeded with that role's caps.
1955 const [createSeed, setCreateSeed] = useState<string | null>(null);
1956 const [pendingDelete, setPendingDelete] = useState<TeamRole | null>(null);
1957
1958 const { data, isLoading } = useQuery({
1959 queryKey: ["team-roles"],
1960 queryFn: () => teamApi.listRoles(),
1961 });
1962
1963 const deleteMutation = useMutation({
1964 mutationFn: (slug: string) => teamApi.deleteRole(slug),
1965 onSuccess: () => {
1966 queryClient.invalidateQueries({ queryKey: ["team-roles"] });
1967 showToast(__("Role deleted.", "yatra"), "success");
1968 setPendingDelete(null);
1969 },
1970 onError: (e: any) => {
1971 showToast(extractError(e), "error");
1972 setPendingDelete(null);
1973 },
1974 });
1975
1976 const roles = data?.data ?? [];
1977
1978 return (
1979 <div className="space-y-4">
1980 <div className="flex items-start justify-between gap-3 flex-wrap">
1981 <div>
1982 <h3 className="text-lg font-semibold text-gray-900 dark:text-white">
1983 {__("Roles", "yatra")}
1984 </h3>
1985 <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
1986 {__(
1987 "Eight shipped system roles cover most agencies. Click any role to see + edit its capabilities. Clone or create your own with the buttons here.",
1988 "yatra",
1989 )}
1990 </p>
1991 </div>
1992 <Button onClick={() => setCreateSeed("new")}>
1993 <Shield className="mr-1.5 h-4 w-4" />
1994 {__("Create custom role", "yatra")}
1995 </Button>
1996 </div>
1997
1998 <Card className="overflow-visible">
1999 <CardContent className="p-0 overflow-visible">
2000 <SharedTable
2001 data={roles}
2002 columns={[
2003 {
2004 key: "display_name",
2005 label: __("Role", "yatra"),
2006 render: (r: TeamRole) => (
2007 <div className="flex items-center gap-3">
2008 <div className="w-10 h-10 rounded-lg bg-indigo-100 dark:bg-indigo-900/30 flex items-center justify-center flex-shrink-0">
2009 <Shield className="w-5 h-5 text-indigo-600 dark:text-indigo-400" />
2010 </div>
2011 <div className="min-w-0">
2012 <button
2013 type="button"
2014 onClick={() => setEditingRoleSlug(r.slug)}
2015 className="font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:underline transition-colors cursor-pointer text-left"
2016 >
2017 {r.display_name}
2018 </button>
2019 <div>
2020 <code className="text-xs text-gray-500 dark:text-gray-400 font-mono">
2021 {r.slug}
2022 </code>
2023 </div>
2024 </div>
2025 </div>
2026 ),
2027 },
2028 {
2029 key: "is_system",
2030 label: __("Type", "yatra"),
2031 render: (r: TeamRole) =>
2032 r.is_system ? (
2033 <Badge className="bg-purple-50 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300">
2034 {__("System", "yatra")}
2035 </Badge>
2036 ) : (
2037 <Badge className="bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300">
2038 {__("Custom", "yatra")}
2039 </Badge>
2040 ),
2041 },
2042 {
2043 key: "capability_count",
2044 label: __("Capabilities", "yatra"),
2045 render: (r: TeamRole) => (
2046 <Badge
2047 variant="outline"
2048 className="cursor-pointer"
2049 onClick={() => setEditingRoleSlug(r.slug)}
2050 >
2051 {r.capability_count} {__("caps", "yatra")}
2052 </Badge>
2053 ),
2054 },
2055 {
2056 key: "member_count",
2057 label: __("Members", "yatra"),
2058 render: (r: TeamRole) => (
2059 <Badge variant="outline">{r.member_count}</Badge>
2060 ),
2061 },
2062 ]}
2063 actions={[
2064 {
2065 key: "edit",
2066 label: __("View capabilities", "yatra"),
2067 icon: <KeyRound className="w-4 h-4" />,
2068 onClick: (r: TeamRole) => setEditingRoleSlug(r.slug),
2069 },
2070 {
2071 key: "clone",
2072 label: __("Clone to custom role", "yatra"),
2073 icon: <Copy className="w-4 h-4" />,
2074 onClick: (r: TeamRole) => setCreateSeed(`clone:${r.slug}`),
2075 },
2076 {
2077 key: "delete",
2078 label: __("Delete role", "yatra"),
2079 icon: <Trash2 className="w-4 h-4" />,
2080 onClick: (r: TeamRole) => setPendingDelete(r),
2081 condition: (r: TeamRole) => !r.is_system,
2082 variant: "destructive",
2083 },
2084 ]}
2085 isLoading={isLoading}
2086 emptyText={__("No roles found", "yatra")}
2087 emptyDescription={__(
2088 "System roles should have populated automatically. Try toggling the module off and on.",
2089 "yatra",
2090 )}
2091 />
2092 </CardContent>
2093 </Card>
2094
2095 {editingRoleSlug !== null && (
2096 <RoleEditDrawer
2097 slug={editingRoleSlug}
2098 onClose={() => setEditingRoleSlug(null)}
2099 onClone={(slug) => {
2100 setEditingRoleSlug(null);
2101 setCreateSeed(`clone:${slug}`);
2102 }}
2103 />
2104 )}
2105
2106 {createSeed !== null && (
2107 <RoleCreateDrawer
2108 seedSlug={
2109 createSeed.startsWith("clone:") ? createSeed.slice(6) : null
2110 }
2111 onClose={() => setCreateSeed(null)}
2112 />
2113 )}
2114
2115 <ConfirmationDialog
2116 isOpen={pendingDelete !== null}
2117 onClose={() => !deleteMutation.isPending && setPendingDelete(null)}
2118 onConfirm={() =>
2119 pendingDelete && deleteMutation.mutate(pendingDelete.slug)
2120 }
2121 title={__("Delete custom role?", "yatra")}
2122 description={
2123 pendingDelete
2124 ? __(
2125 'Members assigned to "{name}" will lose this role. Their WordPress user account is preserved.',
2126 "yatra",
2127 ).replace("{name}", pendingDelete.display_name)
2128 : ""
2129 }
2130 confirmText={__("Delete role", "yatra")}
2131 cancelText={__("Cancel", "yatra")}
2132 variant="danger"
2133 isLoading={deleteMutation.isPending}
2134 />
2135 </div>
2136 );
2137 };
2138
2139 /* -------------------------------------------------------------------------- */
2140 /* Role edit drawer — shows every capability the role has, grouped by */
2141 /* category with sensitivity badges. System roles render read-only with a */
2142 /* "Clone to edit" button; custom roles get an editable matrix + save. */
2143 /* -------------------------------------------------------------------------- */
2144
2145 const RoleEditDrawer: React.FC<{
2146 slug: string;
2147 onClose: () => void;
2148 onClone: (slug: string) => void;
2149 }> = ({ slug, onClose, onClone }) => {
2150 const queryClient = useQueryClient();
2151 const { showToast } = useToast();
2152
2153 const { data, isLoading } = useQuery({
2154 queryKey: ["team-role", slug],
2155 queryFn: () => teamApi.getRole(slug),
2156 });
2157 const { data: capsData } = useQuery({
2158 queryKey: ["team-capabilities"],
2159 queryFn: () => teamApi.listCapabilities(),
2160 });
2161
2162 const role = data?.data;
2163 const [displayName, setDisplayName] = useState("");
2164 const [selectedCaps, setSelectedCaps] = useState<string[]>([]);
2165
2166 React.useEffect(() => {
2167 if (role) {
2168 setDisplayName(role.display_name);
2169 setSelectedCaps(role.capabilities);
2170 }
2171 }, [role]);
2172
2173 const updateMutation = useMutation({
2174 mutationFn: () =>
2175 teamApi.updateRole(slug, {
2176 display_name: displayName,
2177 capabilities: selectedCaps,
2178 }),
2179 onSuccess: () => {
2180 queryClient.invalidateQueries({ queryKey: ["team-roles"] });
2181 queryClient.invalidateQueries({ queryKey: ["team-role", slug] });
2182 showToast(__("Role updated.", "yatra"), "success");
2183 onClose();
2184 },
2185 onError: (e: any) => showToast(extractError(e), "error"),
2186 });
2187
2188 const isSystem = role?.is_system ?? false;
2189
2190 return (
2191 <Modal
2192 isOpen
2193 onClose={onClose}
2194 title={
2195 <div className="flex items-center gap-2">
2196 <Shield className="w-5 h-5 text-indigo-500" />
2197 {role ? role.display_name : __("Loading…", "yatra")}
2198 {isSystem && (
2199 <Badge className="bg-purple-50 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300 ml-2">
2200 {__("System role", "yatra")}
2201 </Badge>
2202 )}
2203 </div>
2204 }
2205 size="lg"
2206 hideFooter={false}
2207 footer={
2208 <div className="flex justify-end gap-2">
2209 <Button variant="outline" onClick={onClose}>
2210 {__("Close", "yatra")}
2211 </Button>
2212 {isSystem ? (
2213 <Button onClick={() => onClone(slug)}>
2214 <Copy className="mr-1.5 h-4 w-4" />
2215 {__("Clone to edit", "yatra")}
2216 </Button>
2217 ) : (
2218 <Button
2219 disabled={updateMutation.isPending}
2220 onClick={() => updateMutation.mutate()}
2221 >
2222 {updateMutation.isPending ? (
2223 <>
2224 <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
2225 {__("Saving…", "yatra")}
2226 </>
2227 ) : (
2228 __("Save changes", "yatra")
2229 )}
2230 </Button>
2231 )}
2232 </div>
2233 }
2234 >
2235 {isLoading || !role ? (
2236 <div className="space-y-4">
2237 {/* Skeleton mirrors the loaded role-edit form: name input + */}
2238 {/* category-grouped cap matrix. Same shape so the dialog */}
2239 {/* doesn't reflow when data arrives. */}
2240 <Skeleton className="h-4 w-20" />
2241 <Skeleton className="h-9 w-full" />
2242 <div className="space-y-2">
2243 {[0, 1, 2, 3].map((i) => (
2244 <div
2245 key={i}
2246 className="rounded-md border border-gray-200 dark:border-gray-700 p-3 space-y-2"
2247 >
2248 <Skeleton className="h-4 w-28" />
2249 <div className="grid grid-cols-2 gap-2">
2250 <Skeleton className="h-6 w-full" />
2251 <Skeleton className="h-6 w-full" />
2252 </div>
2253 </div>
2254 ))}
2255 </div>
2256 </div>
2257 ) : (
2258 <div className="space-y-4">
2259 {isSystem && (
2260 <Alert variant="info" title={__("System role", "yatra")}>
2261 {sprintf(
2262 /* translators: 1: brand name, 2: brand name */
2263 __(
2264 "System roles ship with %1$s and can't be edited directly — clone to a custom role to change capabilities. This protects your team if %2$s ships new capabilities in future releases (clones won't auto-update; system roles will).",
2265 "yatra",
2266 ),
2267 brandName(),
2268 brandName(),
2269 )}
2270 </Alert>
2271 )}
2272
2273 <div>
2274 <Label htmlFor="role-display-name">
2275 {__("Role name", "yatra")}
2276 </Label>
2277 <Input
2278 id="role-display-name"
2279 value={displayName}
2280 onChange={(e) => setDisplayName(e.target.value)}
2281 disabled={isSystem}
2282 className="mt-1"
2283 />
2284 <p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
2285 <code className="font-mono">{role.slug}</code> ·{" "}
2286 {role.member_count}{" "}
2287 {role.member_count === 1
2288 ? __("member", "yatra")
2289 : __("members", "yatra")}
2290 </p>
2291 </div>
2292
2293 {capsData && (
2294 <CapabilityMatrix
2295 registry={capsData.capabilities}
2296 selected={selectedCaps}
2297 onChange={setSelectedCaps}
2298 disabled={isSystem}
2299 />
2300 )}
2301 </div>
2302 )}
2303 </Modal>
2304 );
2305 };
2306
2307 /* -------------------------------------------------------------------------- */
2308 /* Role create drawer — same UX as edit but starts from an empty bundle */
2309 /* (or seeded from a system role when the operator clicked "Clone"). */
2310 /* -------------------------------------------------------------------------- */
2311
2312 const RoleCreateDrawer: React.FC<{
2313 seedSlug: string | null;
2314 onClose: () => void;
2315 }> = ({ seedSlug, onClose }) => {
2316 const queryClient = useQueryClient();
2317 const { showToast } = useToast();
2318
2319 const { data: seedData } = useQuery({
2320 queryKey: ["team-role", seedSlug],
2321 queryFn: () => teamApi.getRole(seedSlug!),
2322 enabled: seedSlug !== null,
2323 });
2324 const { data: capsData } = useQuery({
2325 queryKey: ["team-capabilities"],
2326 queryFn: () => teamApi.listCapabilities(),
2327 });
2328 // Server-curated role templates — fetched once. Empty when there
2329 // are no templates configured (e.g. an operator-side filter wiped
2330 // the list).
2331 const { data: templatesData } = useQuery({
2332 queryKey: ["team-role-templates"],
2333 queryFn: () => teamApi.listRoleTemplates(),
2334 enabled: seedSlug === null, // only show templates for fresh creation
2335 });
2336
2337 const [displayName, setDisplayName] = useState("");
2338 const [selectedCaps, setSelectedCaps] = useState<string[]>([]);
2339 const [appliedTemplateId, setAppliedTemplateId] = useState<string | null>(
2340 null,
2341 );
2342
2343 React.useEffect(() => {
2344 if (seedData?.data) {
2345 setDisplayName(`${seedData.data.display_name} (copy)`);
2346 setSelectedCaps(seedData.data.capabilities);
2347 }
2348 }, [seedData]);
2349
2350 /**
2351 * Apply a template. Sets the cap list to the template's caps + always
2352 * appends the umbrella `yatra_access_admin` so the role can see the
2353 * Yatra menu (otherwise the role is functionally useless on day 1).
2354 * Name stays as whatever the operator typed — they likely picked the
2355 * template AFTER typing.
2356 */
2357 const applyTemplate = (templateId: string) => {
2358 const tpl = (templatesData?.data ?? []).find((t) => t.id === templateId);
2359 if (!tpl) return;
2360 const caps = Array.from(
2361 new Set([...tpl.capabilities, "yatra_access_admin"]),
2362 );
2363 setSelectedCaps(caps);
2364 setAppliedTemplateId(templateId);
2365 if (displayName.trim() === "") {
2366 setDisplayName(tpl.label);
2367 }
2368 };
2369
2370 const createMutation = useMutation({
2371 mutationFn: () =>
2372 teamApi.createRole({
2373 display_name: displayName,
2374 capabilities: selectedCaps,
2375 }),
2376 onSuccess: () => {
2377 queryClient.invalidateQueries({ queryKey: ["team-roles"] });
2378 showToast(__("Custom role created.", "yatra"), "success");
2379 onClose();
2380 },
2381 onError: (e: any) => showToast(extractError(e), "error"),
2382 });
2383
2384 const canSave = displayName.trim() !== "" && !createMutation.isPending;
2385
2386 return (
2387 <Modal
2388 isOpen
2389 onClose={onClose}
2390 title={
2391 <div className="flex items-center gap-2">
2392 <Shield className="w-5 h-5 text-indigo-500" />
2393 {seedSlug
2394 ? __("Clone role", "yatra")
2395 : __("Create custom role", "yatra")}
2396 </div>
2397 }
2398 size="lg"
2399 footer={
2400 <div className="flex justify-end gap-2">
2401 <Button variant="outline" onClick={onClose}>
2402 {__("Cancel", "yatra")}
2403 </Button>
2404 <Button disabled={!canSave} onClick={() => createMutation.mutate()}>
2405 {createMutation.isPending ? (
2406 <>
2407 <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
2408 {__("Creating…", "yatra")}
2409 </>
2410 ) : (
2411 __("Create role", "yatra")
2412 )}
2413 </Button>
2414 </div>
2415 }
2416 >
2417 <div className="space-y-4">
2418 <div>
2419 <Label htmlFor="new-role-name">{__("Role name", "yatra")}</Label>
2420 <Input
2421 id="new-role-name"
2422 value={displayName}
2423 onChange={(e) => setDisplayName(e.target.value)}
2424 placeholder={__("e.g. Senior Sales Agent", "yatra")}
2425 className="mt-1"
2426 />
2427 <p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
2428 {__(
2429 "Slug auto-generated from the name (yatra_* prefix added).",
2430 "yatra",
2431 )}
2432 </p>
2433 </div>
2434
2435 {/* Template picker — only when creating fresh (clone path skips it). */}
2436 {seedSlug === null && (templatesData?.data?.length ?? 0) > 0 && (
2437 <div className="rounded-md border border-gray-200 dark:border-gray-700 p-3 space-y-2">
2438 <div className="flex items-start justify-between gap-3">
2439 <div>
2440 <Label className="text-sm font-medium">
2441 {__("Start from a template (optional)", "yatra")}
2442 </Label>
2443 <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
2444 {__(
2445 "Pre-curated cap bundles for common archetypes. Picking one prefills the matrix below — fully editable before save.",
2446 "yatra",
2447 )}
2448 </p>
2449 </div>
2450 {appliedTemplateId && (
2451 <Button
2452 type="button"
2453 variant="outline"
2454 size="sm"
2455 onClick={() => {
2456 setSelectedCaps([]);
2457 setAppliedTemplateId(null);
2458 }}
2459 >
2460 {__("Clear", "yatra")}
2461 </Button>
2462 )}
2463 </div>
2464 <div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
2465 {(templatesData?.data ?? []).map((tpl) => {
2466 const active = appliedTemplateId === tpl.id;
2467 return (
2468 <button
2469 key={tpl.id}
2470 type="button"
2471 onClick={() => applyTemplate(tpl.id)}
2472 className={`text-left rounded-md border p-3 transition-colors ${
2473 active
2474 ? "border-blue-500 bg-blue-50 dark:bg-blue-900/20 dark:border-blue-400"
2475 : "border-gray-200 dark:border-gray-700 hover:border-blue-300 hover:bg-blue-50/40 dark:hover:bg-blue-900/10"
2476 }`}
2477 >
2478 <div className="flex items-center justify-between gap-2">
2479 <span className="text-sm font-medium text-gray-900 dark:text-white">
2480 {tpl.label}
2481 </span>
2482 <span className="text-[10px] uppercase tracking-wide text-gray-400">
2483 {tpl.capabilities.length} {__("caps", "yatra")}
2484 </span>
2485 </div>
2486 <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
2487 {tpl.description}
2488 </p>
2489 </button>
2490 );
2491 })}
2492 </div>
2493 </div>
2494 )}
2495
2496 {capsData && (
2497 <CapabilityMatrix
2498 registry={capsData.capabilities}
2499 selected={selectedCaps}
2500 onChange={setSelectedCaps}
2501 disabled={false}
2502 />
2503 )}
2504 </div>
2505 </Modal>
2506 );
2507 };
2508
2509 /* -------------------------------------------------------------------------- */
2510 /* Capability matrix — categorized checklist of every Yatra cap. */
2511 /* Reused by RoleEditDrawer + RoleCreateDrawer. Read-only when `disabled`. */
2512 /* -------------------------------------------------------------------------- */
2513
2514 const CapabilityMatrix: React.FC<{
2515 registry: Record<string, CapabilityDef>;
2516 selected: string[];
2517 onChange: (next: string[]) => void;
2518 disabled: boolean;
2519 }> = ({ registry, selected, onChange, disabled }) => {
2520 const byCategory = useMemo(() => {
2521 const map: Record<string, Array<[string, CapabilityDef]>> = {};
2522 Object.entries(registry).forEach(([cap, def]) => {
2523 if (!map[def.category]) map[def.category] = [];
2524 map[def.category].push([cap, def]);
2525 });
2526 return map;
2527 }, [registry]);
2528
2529 const selectedSet = useMemo(() => new Set(selected), [selected]);
2530
2531 const sensColor = (s: string): string => {
2532 if (s === "critical")
2533 return "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300";
2534 if (s === "high")
2535 return "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300";
2536 if (s === "medium")
2537 return "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300";
2538 return "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300";
2539 };
2540
2541 const toggleCap = (cap: string) => {
2542 if (disabled) return;
2543 if (selectedSet.has(cap)) {
2544 onChange(selected.filter((c) => c !== cap));
2545 } else {
2546 onChange([...selected, cap]);
2547 }
2548 };
2549
2550 const toggleCategory = (caps: Array<[string, CapabilityDef]>) => {
2551 if (disabled) return;
2552 const ids = caps.map(([c]) => c);
2553 const allSelected = ids.every((id) => selectedSet.has(id));
2554 if (allSelected) {
2555 onChange(selected.filter((c) => !ids.includes(c)));
2556 } else {
2557 const next = new Set(selected);
2558 ids.forEach((c) => next.add(c));
2559 onChange(Array.from(next));
2560 }
2561 };
2562
2563 return (
2564 <div>
2565 <div className="flex items-center justify-between gap-2 mb-1">
2566 <div>
2567 <div className="text-sm font-medium text-gray-900 dark:text-white">
2568 {__("Capabilities", "yatra")}
2569 </div>
2570 <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
2571 {disabled
2572 ? __(
2573 "Read-only — system roles can't be edited. Use Clone to make an editable copy.",
2574 "yatra",
2575 )
2576 : __(
2577 "Each capability gates a specific action. Sensitivity drives audit-log defaults.",
2578 "yatra",
2579 )}
2580 </p>
2581 </div>
2582 <Badge variant="outline">
2583 {selected.length} / {Object.keys(registry).length}
2584 </Badge>
2585 </div>
2586 <div className="space-y-2 max-h-96 overflow-y-auto pr-1 border border-gray-200 dark:border-gray-700 rounded-md p-2">
2587 {Object.entries(byCategory).map(([category, rows]) => {
2588 const ids = rows.map(([c]) => c);
2589 const checked = ids.filter((c) => selectedSet.has(c)).length;
2590 const allChecked = checked === ids.length;
2591 return (
2592 <div key={category}>
2593 <div className="flex items-center justify-between gap-2 px-1 py-1 sticky top-0 bg-white dark:bg-gray-900 z-10">
2594 <div className="text-[11px] font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
2595 {category}{" "}
2596 <span className="font-normal text-gray-400">
2597 ({checked}/{ids.length})
2598 </span>
2599 </div>
2600 {!disabled && (
2601 <button
2602 type="button"
2603 onClick={() => toggleCategory(rows)}
2604 className="text-[11px] font-medium text-blue-600 dark:text-blue-400 hover:underline"
2605 >
2606 {allChecked
2607 ? __("Clear all", "yatra")
2608 : __("Select all", "yatra")}
2609 </button>
2610 )}
2611 </div>
2612 {rows.map(([cap, def]) => {
2613 const isChecked = selectedSet.has(cap);
2614 return (
2615 <label
2616 key={cap}
2617 htmlFor={`cap-${cap}`}
2618 className={`flex items-center gap-3 px-2 py-1.5 rounded transition-colors ${
2619 disabled
2620 ? "cursor-default"
2621 : "cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50"
2622 } ${isChecked ? "bg-blue-50/40 dark:bg-blue-900/10" : ""}`}
2623 >
2624 <input
2625 id={`cap-${cap}`}
2626 type="checkbox"
2627 checked={isChecked}
2628 onChange={() => toggleCap(cap)}
2629 disabled={disabled}
2630 className="h-4 w-4 rounded border-gray-300 flex-shrink-0"
2631 />
2632 <div className="min-w-0 flex-1">
2633 <div className="flex items-center gap-2 flex-wrap">
2634 <span className="text-sm text-gray-900 dark:text-white">
2635 {def.label}
2636 </span>
2637 <span
2638 className={`text-[10px] font-medium px-1.5 py-0.5 rounded ${sensColor(def.sensitivity)}`}
2639 >
2640 {def.sensitivity}
2641 </span>
2642 </div>
2643 <code className="text-[11px] text-gray-400 dark:text-gray-500 font-mono">
2644 {cap}
2645 </code>
2646 </div>
2647 </label>
2648 );
2649 })}
2650 </div>
2651 );
2652 })}
2653 </div>
2654 </div>
2655 );
2656 };
2657
2658 /* -------------------------------------------------------------------------- */
2659 /* Invitations tab */
2660 /* -------------------------------------------------------------------------- */
2661
2662 const InvitationsTab: React.FC = () => {
2663 const queryClient = useQueryClient();
2664 const { showToast } = useToast();
2665 const [showInviteModal, setShowInviteModal] = useState(false);
2666 const [revealAcceptUrl, setRevealAcceptUrl] = useState<string | null>(null);
2667 // Revoke flow uses a confirmation dialog so the operator can opt
2668 // in to also purging the record. A simple one-click revoke would
2669 // leave a stale `revoked` row in the table forever.
2670 const [pendingRevoke, setPendingRevoke] = useState<TeamInvitation | null>(
2671 null,
2672 );
2673 const [revokeAlsoDelete, setRevokeAlsoDelete] = useState(true);
2674 // Standalone delete for non-pending rows (revoked / accepted / expired).
2675 // Confirms before purging so cleanup is intentional.
2676 const [pendingDelete, setPendingDelete] = useState<TeamInvitation | null>(
2677 null,
2678 );
2679
2680 const { data, isLoading } = useQuery({
2681 queryKey: ["team-invitations"],
2682 queryFn: () => teamApi.listInvitations(),
2683 });
2684
2685 const revokeMutation = useMutation({
2686 mutationFn: (vars: { id: string; purge: boolean }) =>
2687 teamApi.revokeInvitation(vars.id, { purge: vars.purge }),
2688 onSuccess: (res) => {
2689 queryClient.invalidateQueries({ queryKey: ["team-invitations"] });
2690 // Mirror the audit log entry into the audit-tab cache too so
2691 // a freshly-open Audit tab reflects the new event.
2692 queryClient.invalidateQueries({ queryKey: ["team-audit"] });
2693 showToast(res.message, "success");
2694 setPendingRevoke(null);
2695 setPendingDelete(null);
2696 },
2697 onError: (e: any) => {
2698 showToast(extractError(e), "error");
2699 setPendingRevoke(null);
2700 setPendingDelete(null);
2701 },
2702 });
2703
2704 const rows = useMemo(() => {
2705 const map = data?.data ?? {};
2706 return Object.values(map);
2707 }, [data]);
2708
2709 return (
2710 <div className="space-y-4">
2711 <div className="flex items-start justify-between gap-3 flex-wrap">
2712 <div>
2713 <h3 className="text-lg font-semibold text-gray-900 dark:text-white">
2714 {__("Invitations", "yatra")}
2715 </h3>
2716 <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
2717 {__(
2718 "Magic-link invitations expire after 72 hours by default. Tokens are stored hashed; the link is only shown once.",
2719 "yatra",
2720 )}
2721 </p>
2722 </div>
2723 <Button onClick={() => setShowInviteModal(true)}>
2724 <UserPlus className="mr-1.5 h-4 w-4" />
2725 {__("Invite member", "yatra")}
2726 </Button>
2727 </div>
2728
2729 <Card className="overflow-visible">
2730 <CardContent className="p-0 overflow-visible">
2731 <SharedTable
2732 data={rows}
2733 columns={[
2734 {
2735 key: "email",
2736 label: __("Email", "yatra"),
2737 render: (i: TeamInvitation) => (
2738 <div className="flex items-center gap-2">
2739 <Mail className="w-4 h-4 text-gray-400" />
2740 <span className="text-sm text-gray-900 dark:text-white">
2741 {i.email}
2742 </span>
2743 </div>
2744 ),
2745 },
2746 {
2747 key: "role_slug",
2748 label: __("Role", "yatra"),
2749 render: (i: TeamInvitation) => (
2750 <Badge className="bg-indigo-50 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300">
2751 {i.role_slug}
2752 </Badge>
2753 ),
2754 },
2755 {
2756 key: "status",
2757 label: __("Status", "yatra"),
2758 render: (i: TeamInvitation) => {
2759 const cls: Record<string, string> = {
2760 pending:
2761 "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300",
2762 accepted:
2763 "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300",
2764 revoked:
2765 "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300",
2766 expired:
2767 "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300",
2768 };
2769 return <Badge className={cls[i.status]}>{i.status}</Badge>;
2770 },
2771 },
2772 {
2773 key: "expires_at",
2774 label: __("Expires", "yatra"),
2775 render: (i: TeamInvitation) => (
2776 <span className="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">
2777 {new Date(i.expires_at * 1000).toLocaleString()}
2778 </span>
2779 ),
2780 },
2781 ]}
2782 actions={[
2783 // Revoke — only meaningful for a still-pending invitation.
2784 // Opens a confirmation that lets the operator opt-in to
2785 // ALSO deleting the row (default checked — most operators
2786 // don't want orphaned `revoked` rows piling up).
2787 {
2788 key: "revoke",
2789 label: __("Revoke", "yatra"),
2790 icon: <XCircle className="w-4 h-4" />,
2791 onClick: (i: TeamInvitation) => {
2792 setRevokeAlsoDelete(true);
2793 setPendingRevoke(i);
2794 },
2795 condition: (i: TeamInvitation) => i.status === "pending",
2796 variant: "destructive",
2797 },
2798 // Delete — for any non-pending row (revoked, accepted,
2799 // expired). Pure storage hygiene. Without this, the
2800 // table grew without bound as old invitations accumulated.
2801 {
2802 key: "delete",
2803 label: __("Delete record", "yatra"),
2804 icon: <Trash2 className="w-4 h-4" />,
2805 onClick: (i: TeamInvitation) => setPendingDelete(i),
2806 condition: (i: TeamInvitation) => i.status !== "pending",
2807 variant: "destructive",
2808 },
2809 ]}
2810 isLoading={isLoading}
2811 emptyText={__("No invitations yet", "yatra")}
2812 emptyDescription={__(
2813 "Send an invitation to add a teammate.",
2814 "yatra",
2815 )}
2816 onCreateClick={() => setShowInviteModal(true)}
2817 />
2818 </CardContent>
2819 </Card>
2820
2821 {showInviteModal && (
2822 <InvitationModal
2823 onClose={() => setShowInviteModal(false)}
2824 onSent={(acceptUrl) => {
2825 setShowInviteModal(false);
2826 setRevealAcceptUrl(acceptUrl);
2827 }}
2828 />
2829 )}
2830
2831 <AcceptUrlRevealDialog
2832 url={revealAcceptUrl}
2833 onClose={() => setRevealAcceptUrl(null)}
2834 />
2835
2836 {/* Revoke confirmation. Opt-in checkbox to also purge the row */}
2837 {/* (default ON — most operators don't want orphaned `revoked` */}
2838 {/* rows piling up over time). */}
2839 {pendingRevoke && (
2840 <Modal
2841 isOpen
2842 onClose={() => {
2843 if (!revokeMutation.isPending) setPendingRevoke(null);
2844 }}
2845 title={__("Revoke invitation?", "yatra")}
2846 size="md"
2847 >
2848 <div className="space-y-4">
2849 <p className="text-sm text-gray-700 dark:text-gray-300">
2850 {sprintf(
2851 /* translators: %s: invited email address */
2852 __(
2853 "The magic-link sent to %s will stop working immediately. Anyone who already clicked the link before now has already accepted (you can confirm in the audit log).",
2854 "yatra",
2855 ),
2856 pendingRevoke.email,
2857 )}
2858 </p>
2859 <label className="flex items-start gap-2 cursor-pointer p-3 rounded-md border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800/40">
2860 <input
2861 type="checkbox"
2862 checked={revokeAlsoDelete}
2863 onChange={(e) => setRevokeAlsoDelete(e.target.checked)}
2864 disabled={revokeMutation.isPending}
2865 className="mt-0.5"
2866 />
2867 <span className="text-sm">
2868 <span className="block font-medium text-gray-900 dark:text-white">
2869 {__("Also delete the invitation record", "yatra")}
2870 </span>
2871 <span className="block text-xs text-gray-500 dark:text-gray-400 mt-0.5">
2872 {__(
2873 "Removes the row from this list. The audit log keeps a permanent trail of who was invited, by whom, and when — that's preserved separately.",
2874 "yatra",
2875 )}
2876 </span>
2877 </span>
2878 </label>
2879 <div className="flex justify-end gap-2 pt-1">
2880 <Button
2881 variant="outline"
2882 onClick={() => setPendingRevoke(null)}
2883 disabled={revokeMutation.isPending}
2884 >
2885 {__("Cancel", "yatra")}
2886 </Button>
2887 <Button
2888 variant="destructive"
2889 onClick={() =>
2890 revokeMutation.mutate({
2891 id: pendingRevoke.id,
2892 purge: revokeAlsoDelete,
2893 })
2894 }
2895 disabled={revokeMutation.isPending}
2896 >
2897 {revokeMutation.isPending && (
2898 <Loader2 className="h-4 w-4 mr-1 animate-spin" />
2899 )}
2900 {revokeAlsoDelete
2901 ? __("Revoke and delete", "yatra")
2902 : __("Revoke", "yatra")}
2903 </Button>
2904 </div>
2905 </div>
2906 </Modal>
2907 )}
2908
2909 {/* Standalone delete — for already-terminal rows. */}
2910 <ConfirmationDialog
2911 isOpen={pendingDelete !== null}
2912 onClose={() => {
2913 if (!revokeMutation.isPending) setPendingDelete(null);
2914 }}
2915 onConfirm={() => {
2916 if (pendingDelete) {
2917 revokeMutation.mutate({ id: pendingDelete.id, purge: true });
2918 }
2919 }}
2920 title={__("Delete invitation record?", "yatra")}
2921 description={
2922 pendingDelete
2923 ? sprintf(
2924 /* translators: 1: invited email address, 2: current status */
2925 __(
2926 "Permanently remove the %1$s invitation (status: %2$s) from this list. The audit log entry stays, so you can still see who was invited and when — only the live record is removed.",
2927 "yatra",
2928 ),
2929 pendingDelete.email,
2930 pendingDelete.status,
2931 )
2932 : ""
2933 }
2934 confirmText={__("Delete record", "yatra")}
2935 cancelText={__("Cancel", "yatra")}
2936 variant="danger"
2937 isLoading={revokeMutation.isPending}
2938 />
2939 </div>
2940 );
2941 };
2942
2943 const InvitationModal: React.FC<{
2944 onClose: () => void;
2945 onSent: (acceptUrl: string) => void;
2946 }> = ({ onClose, onSent }) => {
2947 const queryClient = useQueryClient();
2948 const { showToast } = useToast();
2949 const { data: rolesData } = useQuery({
2950 queryKey: ["team-roles"],
2951 queryFn: () => teamApi.listRoles(),
2952 });
2953
2954 const [email, setEmail] = useState("");
2955 const [role, setRole] = useState("yatra_sales_agent");
2956 const [expiresIn, setExpiresIn] = useState(259200);
2957
2958 const sendMutation = useMutation({
2959 mutationFn: () =>
2960 teamApi.sendInvitation({ email, role, expires_in: expiresIn }),
2961 onSuccess: (res) => {
2962 queryClient.invalidateQueries({ queryKey: ["team-invitations"] });
2963 showToast(__("Invitation sent.", "yatra"), "success");
2964 onSent(res.data.accept_url);
2965 },
2966 onError: (e: any) => showToast(extractError(e), "error"),
2967 });
2968
2969 return (
2970 <Modal
2971 isOpen
2972 onClose={onClose}
2973 title={
2974 <div className="flex items-center gap-2">
2975 <UserPlus className="w-5 h-5 text-blue-500" />
2976 {__("Invite a team member", "yatra")}
2977 </div>
2978 }
2979 size="md"
2980 footer={
2981 <div className="flex justify-end gap-2">
2982 <Button variant="outline" onClick={onClose}>
2983 {__("Cancel", "yatra")}
2984 </Button>
2985 <Button
2986 disabled={email.trim() === "" || sendMutation.isPending}
2987 onClick={() => sendMutation.mutate()}
2988 >
2989 {sendMutation.isPending ? (
2990 <>
2991 <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
2992 {__("Sending…", "yatra")}
2993 </>
2994 ) : (
2995 __("Send invitation", "yatra")
2996 )}
2997 </Button>
2998 </div>
2999 }
3000 >
3001 <div className="space-y-4">
3002 <div>
3003 <Label htmlFor="invite-email">{__("Email address", "yatra")}</Label>
3004 <Input
3005 id="invite-email"
3006 type="email"
3007 value={email}
3008 onChange={(e) => setEmail(e.target.value)}
3009 placeholder="[email protected]"
3010 className="mt-1"
3011 />
3012 </div>
3013
3014 <div>
3015 <Label htmlFor="invite-role">{__("Role", "yatra")}</Label>
3016 <Select
3017 id="invite-role"
3018 value={role}
3019 onChange={(e) => setRole(e.target.value)}
3020 className="mt-1"
3021 >
3022 {(rolesData?.data ?? []).map((r) => (
3023 <option key={r.slug} value={r.slug}>
3024 {r.display_name}
3025 </option>
3026 ))}
3027 </Select>
3028 </div>
3029
3030 <div>
3031 <Label htmlFor="invite-expiry">
3032 {__("Link expires in", "yatra")}
3033 </Label>
3034 <Select
3035 id="invite-expiry"
3036 value={String(expiresIn)}
3037 onChange={(e) => setExpiresIn(Number(e.target.value))}
3038 className="mt-1"
3039 >
3040 <option value="86400">{__("24 hours", "yatra")}</option>
3041 <option value="259200">
3042 {__("72 hours (recommended)", "yatra")}
3043 </option>
3044 <option value="604800">{__("7 days", "yatra")}</option>
3045 </Select>
3046 </div>
3047
3048 <Alert variant="info" title={__("How it works", "yatra")}>
3049 {__(
3050 "The invitee receives an email with a magic link. Clicking it attaches the chosen role to an existing WP user with that email, or creates a new WP user + sends them a password-reset link.",
3051 "yatra",
3052 )}
3053 </Alert>
3054 </div>
3055 </Modal>
3056 );
3057 };
3058
3059 const AcceptUrlRevealDialog: React.FC<{
3060 url: string | null;
3061 onClose: () => void;
3062 }> = ({ url, onClose }) => {
3063 const [copied, setCopied] = useState(false);
3064 const doCopy = async () => {
3065 if (!url) return;
3066 try {
3067 await navigator.clipboard.writeText(url);
3068 setCopied(true);
3069 window.setTimeout(() => setCopied(false), 2000);
3070 } catch (_e) {
3071 /* no-op */
3072 }
3073 };
3074 if (!url) return null;
3075 return (
3076 <Modal
3077 isOpen
3078 onClose={onClose}
3079 title={
3080 <div className="flex items-center gap-2">
3081 <ShieldCheck className="w-5 h-5 text-green-500" />
3082 {__("Invitation link", "yatra")}
3083 </div>
3084 }
3085 size="md"
3086 footer={
3087 <div className="flex justify-end gap-2">
3088 <Button onClick={onClose}>{__("Done", "yatra")}</Button>
3089 </div>
3090 }
3091 >
3092 <div className="space-y-4">
3093 <p className="text-sm text-gray-700 dark:text-gray-200">
3094 {__(
3095 "The invitation email is on its way. If you'd like to share the link manually (e.g. via Slack), copy it below — it's only shown once.",
3096 "yatra",
3097 )}
3098 </p>
3099 <div className="flex gap-2">
3100 <Input
3101 value={url}
3102 readOnly
3103 onFocus={(e) => e.currentTarget.select()}
3104 className="font-mono text-xs"
3105 aria-label={__("Accept URL", "yatra")}
3106 />
3107 <Button variant="outline" onClick={doCopy}>
3108 {copied ? (
3109 <>
3110 <Check className="mr-1.5 h-4 w-4" />
3111 {__("Copied", "yatra")}
3112 </>
3113 ) : (
3114 <>
3115 <Copy className="mr-1.5 h-4 w-4" />
3116 {__("Copy", "yatra")}
3117 </>
3118 )}
3119 </Button>
3120 </div>
3121 <Alert
3122 variant="warning"
3123 title={__("Treat this link like a password", "yatra")}
3124 >
3125 {__(
3126 "Anyone with this URL can claim the invited role until it expires.",
3127 "yatra",
3128 )}
3129 </Alert>
3130 </div>
3131 </Modal>
3132 );
3133 };
3134
3135 /* -------------------------------------------------------------------------- */
3136 /* Audit log tab */
3137 /* -------------------------------------------------------------------------- */
3138
3139 const AuditLogTab: React.FC = () => {
3140 const queryClient = useQueryClient();
3141 const { showToast } = useToast();
3142 const [page, setPage] = useState(1);
3143 const [actionFilter, setActionFilter] = useState("");
3144 const [entityFilter, setEntityFilter] = useState("");
3145 const [resultFilter, setResultFilter] = useState("");
3146 // Selection + confirm-modal state for bulk delete + clear-all.
3147 // selectedIds is reset on filter / page change so an operator
3148 // can't accidentally carry a stale selection across views.
3149 const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
3150 const [confirmClear, setConfirmClear] = useState(false);
3151 const [confirmBulk, setConfirmBulk] = useState(false);
3152 const perPage = 50;
3153
3154 const { data, isLoading } = useQuery({
3155 queryKey: [
3156 "team-audit-log",
3157 page,
3158 actionFilter,
3159 entityFilter,
3160 resultFilter,
3161 ],
3162 queryFn: () =>
3163 teamApi.listAuditLog({
3164 page,
3165 per_page: perPage,
3166 ...(actionFilter ? { action: actionFilter } : {}),
3167 ...(entityFilter ? { entity_type: entityFilter } : {}),
3168 ...(resultFilter
3169 ? { result: resultFilter as "allowed" | "denied" }
3170 : {}),
3171 }),
3172 placeholderData: (prev) => prev,
3173 });
3174 const { data: facets } = useQuery({
3175 queryKey: ["team-audit-facets"],
3176 queryFn: () => teamApi.auditFacets(),
3177 });
3178
3179 const rows = data?.data ?? [];
3180 const total = data?.total ?? 0;
3181 const totalPages = Math.max(1, Math.ceil(total / perPage));
3182 const hasFilters = !!(actionFilter || entityFilter || resultFilter);
3183
3184 const invalidateAudit = () => {
3185 queryClient.invalidateQueries({ queryKey: ["team-audit-log"] });
3186 queryClient.invalidateQueries({ queryKey: ["team-audit-facets"] });
3187 setSelectedIds(new Set());
3188 };
3189
3190 const clearMutation = useMutation({
3191 mutationFn: () => teamApi.clearAuditLog(),
3192 onSuccess: (res) => {
3193 invalidateAudit();
3194 setConfirmClear(false);
3195 showToast(res.message, "success");
3196 },
3197 onError: (e: any) => {
3198 setConfirmClear(false);
3199 showToast(extractError(e), "error");
3200 },
3201 });
3202
3203 const bulkDeleteMutation = useMutation({
3204 mutationFn: (ids: number[]) => teamApi.bulkDeleteAuditLog(ids),
3205 onSuccess: (res) => {
3206 invalidateAudit();
3207 setConfirmBulk(false);
3208 showToast(res.message, "success");
3209 },
3210 onError: (e: any) => {
3211 setConfirmBulk(false);
3212 showToast(extractError(e), "error");
3213 },
3214 });
3215
3216 // Reset selection whenever the filter / page changes so it never
3217 // points at rows the operator can't see.
3218 React.useEffect(() => {
3219 setSelectedIds(new Set());
3220 }, [page, actionFilter, entityFilter, resultFilter]);
3221
3222 const toggleOne = (id: number, checked: boolean) => {
3223 setSelectedIds((prev) => {
3224 const next = new Set(prev);
3225 if (checked) next.add(id);
3226 else next.delete(id);
3227 return next;
3228 });
3229 };
3230 const toggleAll = (checked: boolean) => {
3231 if (!checked) {
3232 setSelectedIds(new Set());
3233 return;
3234 }
3235 setSelectedIds(new Set(rows.map((r: any) => r.id as number)));
3236 };
3237 const isAllSelected =
3238 rows.length > 0 && rows.every((r: any) => selectedIds.has(r.id));
3239
3240 return (
3241 <div className="space-y-4">
3242 <div className="flex items-start justify-between gap-3 flex-wrap">
3243 <div>
3244 <h3 className="text-lg font-semibold text-gray-900 dark:text-white">
3245 {__("Audit log", "yatra")}
3246 </h3>
3247 <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
3248 {__(
3249 "Append-only event stream. High + critical sensitivity actions and every denied attempt are logged. Default retention 180 days.",
3250 "yatra",
3251 )}
3252 </p>
3253 </div>
3254 {/* Clear-all is always available (independent of selection) so */}
3255 {/* operators can wipe the whole log without scrolling pages. */}
3256 {/* The destructive action confirms via dialog + writes a final */}
3257 {/* audit entry recording who triggered the clear. */}
3258 {total > 0 && (
3259 <Button
3260 type="button"
3261 variant="outline"
3262 onClick={() => setConfirmClear(true)}
3263 disabled={clearMutation.isPending}
3264 >
3265 <Trash2 className="mr-1.5 h-4 w-4" />
3266 {__("Clear all", "yatra")}
3267 </Button>
3268 )}
3269 </div>
3270
3271 {/* Bulk-action bar — only shown when at least one row is checked. */}
3272 {selectedIds.size > 0 && (
3273 <div className="flex flex-wrap items-center gap-2 rounded-md border border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/30 p-3">
3274 <span className="text-sm text-blue-900 dark:text-blue-100 font-medium mr-auto">
3275 {sprintf(
3276 /* translators: %d: count of selected audit-log entries */
3277 __("%d entries selected", "yatra"),
3278 selectedIds.size,
3279 )}
3280 </span>
3281 <Button
3282 type="button"
3283 variant="destructive"
3284 onClick={() => setConfirmBulk(true)}
3285 disabled={bulkDeleteMutation.isPending}
3286 >
3287 <Trash2 className="mr-1.5 h-4 w-4" />
3288 {__("Delete selected", "yatra")}
3289 </Button>
3290 <Button
3291 type="button"
3292 variant="outline"
3293 onClick={() => setSelectedIds(new Set())}
3294 >
3295 {__("Clear selection", "yatra")}
3296 </Button>
3297 </div>
3298 )}
3299
3300 <Card>
3301 <CardContent className="p-3">
3302 <div className="flex flex-col lg:flex-row lg:items-center gap-2">
3303 <Select
3304 value={actionFilter}
3305 onChange={(e) => {
3306 setActionFilter(e.target.value);
3307 setPage(1);
3308 }}
3309 aria-label={__("Filter by action", "yatra")}
3310 className="w-full lg:w-56"
3311 >
3312 <option value="">{__("All actions", "yatra")}</option>
3313 {(facets?.actions ?? []).map((a) => (
3314 <option key={a} value={a}>
3315 {a}
3316 </option>
3317 ))}
3318 </Select>
3319 <Select
3320 value={entityFilter}
3321 onChange={(e) => {
3322 setEntityFilter(e.target.value);
3323 setPage(1);
3324 }}
3325 aria-label={__("Filter by entity", "yatra")}
3326 className="w-full lg:w-48"
3327 >
3328 <option value="">{__("All entities", "yatra")}</option>
3329 {(facets?.entity_types ?? []).map((e) => (
3330 <option key={e} value={e}>
3331 {e}
3332 </option>
3333 ))}
3334 </Select>
3335 <Select
3336 value={resultFilter}
3337 onChange={(e) => {
3338 setResultFilter(e.target.value);
3339 setPage(1);
3340 }}
3341 aria-label={__("Filter by result", "yatra")}
3342 className="w-full lg:w-40"
3343 >
3344 <option value="">{__("Any result", "yatra")}</option>
3345 <option value="allowed">{__("Allowed", "yatra")}</option>
3346 <option value="denied">{__("Denied", "yatra")}</option>
3347 </Select>
3348 {hasFilters && (
3349 <Button
3350 variant="outline"
3351 onClick={() => {
3352 setActionFilter("");
3353 setEntityFilter("");
3354 setResultFilter("");
3355 setPage(1);
3356 }}
3357 >
3358 {__("Reset", "yatra")}
3359 </Button>
3360 )}
3361 </div>
3362 </CardContent>
3363 </Card>
3364
3365 <Card>
3366 <CardContent className="p-0 overflow-visible">
3367 <SharedTable
3368 data={rows}
3369 selectedItemIds={Array.from(selectedIds)}
3370 onSelectItem={(id, checked) => toggleOne(Number(id), checked)}
3371 onSelectAll={toggleAll}
3372 isAllSelected={isAllSelected}
3373 getItemId={(r: any) => r.id}
3374 columns={[
3375 {
3376 key: "occurred_at",
3377 label: __("When", "yatra"),
3378 render: (r: any) => (
3379 <span className="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">
3380 {new Date(r.occurred_at).toLocaleString()}
3381 </span>
3382 ),
3383 },
3384 {
3385 key: "actor",
3386 label: __("Who", "yatra"),
3387 render: (r: any) =>
3388 r.actor_user_id ? (
3389 <div className="min-w-0">
3390 <div className="text-sm text-gray-900 dark:text-white truncate">
3391 {r.actor_display_name || `#${r.actor_user_id}`}
3392 </div>
3393 {r.actor_ip && (
3394 <div className="text-[10px] text-gray-400 font-mono">
3395 {r.actor_ip}
3396 </div>
3397 )}
3398 </div>
3399 ) : (
3400 <Badge className="bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300">
3401 {__("system", "yatra")}
3402 </Badge>
3403 ),
3404 },
3405 {
3406 key: "action",
3407 label: __("Action", "yatra"),
3408 render: (r: any) => (
3409 <code className="text-xs font-mono text-indigo-700 dark:text-indigo-300">
3410 {r.action}
3411 </code>
3412 ),
3413 },
3414 {
3415 key: "entity",
3416 label: __("Entity", "yatra"),
3417 render: (r: any) =>
3418 r.entity_type ? (
3419 <span className="text-xs text-gray-700 dark:text-gray-300">
3420 {r.entity_type}
3421 {r.entity_id ? ` #${r.entity_id}` : ""}
3422 </span>
3423 ) : (
3424 <span className="text-xs text-gray-400">—</span>
3425 ),
3426 },
3427 {
3428 key: "result",
3429 label: __("Result", "yatra"),
3430 render: (r: any) =>
3431 r.result === "denied" ? (
3432 <Badge className="bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300">
3433 <XCircle className="mr-1 h-3 w-3" />
3434 {__("Denied", "yatra")}
3435 </Badge>
3436 ) : (
3437 <Badge className="bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">
3438 <CheckCircle2 className="mr-1 h-3 w-3" />
3439 {__("Allowed", "yatra")}
3440 </Badge>
3441 ),
3442 },
3443 ]}
3444 actions={[]}
3445 isLoading={isLoading}
3446 emptyText={__("No audit events yet", "yatra")}
3447 emptyDescription={__(
3448 "High and critical sensitivity actions will appear here as they happen.",
3449 "yatra",
3450 )}
3451 />
3452 {rows.length > 0 && totalPages > 1 && (
3453 <div className="border-t border-gray-200 px-4 py-3 dark:border-gray-700">
3454 <Pagination
3455 currentPage={page}
3456 totalPages={totalPages}
3457 totalItems={total}
3458 itemsPerPage={perPage}
3459 onPageChange={setPage}
3460 itemName={__("events", "yatra")}
3461 />
3462 </div>
3463 )}
3464 </CardContent>
3465 </Card>
3466
3467 {/* Clear-all confirmation. Destructive variant + explicit warning */}
3468 {/* that the operator's own clear-action will be the only entry */}
3469 {/* surviving in the new log. */}
3470 <ConfirmationDialog
3471 isOpen={confirmClear}
3472 onClose={() => !clearMutation.isPending && setConfirmClear(false)}
3473 onConfirm={() => clearMutation.mutate()}
3474 title={__("Clear the entire audit log?", "yatra")}
3475 description={sprintf(
3476 /* translators: %d: count of audit-log entries about to be wiped */
3477 __(
3478 "This permanently deletes all %d audit-log entries. The clear action itself will be recorded as a new entry — so the operator who wiped history is documented. There is no undo.",
3479 "yatra",
3480 ),
3481 total,
3482 )}
3483 confirmText={__("Yes, clear all", "yatra")}
3484 cancelText={__("Cancel", "yatra")}
3485 variant="danger"
3486 isLoading={clearMutation.isPending}
3487 />
3488
3489 {/* Bulk-delete confirmation. Shows the selected-row count + that */}
3490 {/* deletion is permanent and audit-logged. */}
3491 <ConfirmationDialog
3492 isOpen={confirmBulk}
3493 onClose={() => !bulkDeleteMutation.isPending && setConfirmBulk(false)}
3494 onConfirm={() => bulkDeleteMutation.mutate(Array.from(selectedIds))}
3495 title={sprintf(
3496 /* translators: %d: count of selected audit-log entries */
3497 __("Delete %d audit-log entries?", "yatra"),
3498 selectedIds.size,
3499 )}
3500 description={__(
3501 "Permanent. The deletion itself is recorded as a new audit entry so the operator who removed history is documented.",
3502 "yatra",
3503 )}
3504 confirmText={__("Delete entries", "yatra")}
3505 cancelText={__("Cancel", "yatra")}
3506 variant="danger"
3507 isLoading={bulkDeleteMutation.isPending}
3508 />
3509 </div>
3510 );
3511 };
3512
3513 /* -------------------------------------------------------------------------- */
3514 /* SettingsTab */
3515 /* */
3516 /* Module-level settings for Team & Access. Currently a single forward- */
3517 /* looking toggle: what happens to existing team access if the operator */
3518 /* ever turns the module off? */
3519 /* */
3520 /* When the module is ON (which is the only time this tab is reachable), */
3521 /* every assigned role just works. The toggle is purely about post-disable */
3522 /* behavior — set it now, and it kicks in if/when the module is disabled. */
3523 /* -------------------------------------------------------------------------- */
3524
3525 const SettingsTab: React.FC = () => {
3526 const queryClient = useQueryClient();
3527 const { showToast } = useToast();
3528
3529 const { data, isLoading } = useQuery({
3530 queryKey: ["team-settings"],
3531 queryFn: () => teamApi.getSettings(),
3532 });
3533
3534 const keepAccess = data?.data?.keep_access_on_module_disable === true;
3535 // IP allowlist for staff (yatra_* role) logins. Empty = no
3536 // restriction. The textarea is bound to a local draft so the operator
3537 // can edit without firing a save on every keystroke; we save on blur
3538 // OR explicit click.
3539 const serverAllowlist = data?.data?.login_ip_allowlist ?? "";
3540 const [allowlistDraft, setAllowlistDraft] = React.useState(serverAllowlist);
3541 React.useEffect(() => {
3542 setAllowlistDraft(serverAllowlist);
3543 }, [serverAllowlist]);
3544 const allowlistDirty = allowlistDraft.trim() !== serverAllowlist.trim();
3545
3546 const updateMutation = useMutation({
3547 mutationFn: (next: boolean) =>
3548 teamApi.updateSettings({ keep_access_on_module_disable: next }),
3549 onSuccess: (res) => {
3550 // Keep the server-injected snapshot in sync so any other code
3551 // reading window.yatraAdmin.teamKeepAccessOnModuleDisable gets
3552 // the fresh value without a page reload.
3553 if (typeof window !== "undefined" && window.yatraAdmin) {
3554 window.yatraAdmin.teamKeepAccessOnModuleDisable =
3555 res.data.keep_access_on_module_disable === true;
3556 }
3557 queryClient.invalidateQueries({ queryKey: ["team-settings"] });
3558 // Audit log entry written by the server — invalidate so the
3559 // Audit log tab reflects the toggle on next visit.
3560 queryClient.invalidateQueries({ queryKey: ["team-audit"] });
3561 showToast(res.message, "success");
3562 },
3563 onError: (e: any) => showToast(extractError(e), "error"),
3564 });
3565
3566 const saveAllowlist = useMutation({
3567 mutationFn: (next: string) =>
3568 teamApi.updateSettings({ login_ip_allowlist: next }),
3569 onSuccess: (res) => {
3570 queryClient.invalidateQueries({ queryKey: ["team-settings"] });
3571 queryClient.invalidateQueries({ queryKey: ["team-audit"] });
3572 showToast(res.message, "success");
3573 // Re-sync the draft to whatever the server normalized to (so
3574 // invalid CIDRs the operator typed are visibly dropped).
3575 setAllowlistDraft(res.data.login_ip_allowlist ?? "");
3576 },
3577 onError: (e: any) => showToast(extractError(e), "error"),
3578 });
3579
3580 if (isLoading) {
3581 return (
3582 <div className="space-y-3">
3583 <Skeleton className="h-5 w-48" />
3584 <Skeleton className="h-20 w-full" />
3585 <Skeleton className="h-32 w-full" />
3586 </div>
3587 );
3588 }
3589
3590 return (
3591 <div className="space-y-4">
3592 <div>
3593 <h3 className="text-lg font-semibold text-gray-900 dark:text-white">
3594 {__("Team & Access settings", "yatra")}
3595 </h3>
3596 <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
3597 {__(
3598 "One setting: decide what happens to your team's access if you ever turn off this module. Every change here is recorded in the Audit log.",
3599 "yatra",
3600 )}
3601 </p>
3602 </div>
3603
3604 {/* Top notice — explains the current state + the trade-off in */}
3605 {/* plain English BEFORE the operator touches the toggle. The */}
3606 {/* default is OFF (security-conservative: revoke on disable). */}
3607 {/* When the operator flips it ON they've opted in to preserving */}
3608 {/* team access when the module is later disabled. */}
3609 {keepAccess ? (
3610 <Alert
3611 variant="info"
3612 title={__("You've opted in to keep team access on disable", "yatra")}
3613 >
3614 {__(
3615 "This toggle is ON. If you ever turn off the Team & Access module, your team members keep their access and their Yatra roles stay on your site. This is a permissive choice — only leave it ON if you specifically want roles to survive a module-off period (for example, a brief maintenance window).",
3616 "yatra",
3617 )}
3618 </Alert>
3619 ) : (
3620 <Alert
3621 variant="info"
3622 title={__(
3623 "On the default — team access is revoked when the module is off",
3624 "yatra",
3625 )}
3626 >
3627 {__(
3628 "This toggle is OFF, which is the default. If you ever turn off the Team & Access module, every Yatra role on your site (Owner, Manager, Sales Agent, and any custom roles you built) will be removed, and your team members will lose all Yatra access. Re-enabling the module brings back the 8 built-in roles, but your custom roles and the original assignments do NOT come back. Flip this toggle ON if you'd rather keep your team's access when the module is off.",
3629 "yatra",
3630 )}
3631 </Alert>
3632 )}
3633
3634 <Card>
3635 <CardContent className="p-5 space-y-5">
3636 {/* Single forward-looking toggle. Switch on the right, plain- */}
3637 {/* language label + dynamic helper text on the left. */}
3638 <div className="flex items-start justify-between gap-4">
3639 <div className="flex items-start gap-3 min-w-0">
3640 {keepAccess ? (
3641 <Lock className="w-5 h-5 text-green-600 mt-0.5 flex-shrink-0" />
3642 ) : (
3643 <Unlock className="w-5 h-5 text-amber-600 mt-0.5 flex-shrink-0" />
3644 )}
3645 <div className="min-w-0">
3646 <Label className="text-base font-medium text-gray-900 dark:text-white block">
3647 {__(
3648 "Keep team access running when this module is turned off",
3649 "yatra",
3650 )}
3651 <span className="ml-2 text-xs font-normal text-gray-500 dark:text-gray-400">
3652 {__("(off by default)", "yatra")}
3653 </span>
3654 </Label>
3655 <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
3656 {keepAccess
3657 ? __(
3658 "ON. If you turn this module off later, your team members keep their roles and current access. Nothing on your site changes until you decide to switch back.",
3659 "yatra",
3660 )
3661 : __(
3662 "OFF (default). If you turn this module off later, every Yatra role on your site will be removed and your team members will lose their Yatra access. You (the site owner) always keep your own admin access.",
3663 "yatra",
3664 )}
3665 </p>
3666 {updateMutation.isPending && (
3667 <p className="text-xs text-gray-400 mt-1 flex items-center gap-1.5">
3668 <Loader2 className="h-3 w-3 animate-spin" />
3669 {__("Saving…", "yatra")}
3670 </p>
3671 )}
3672 </div>
3673 </div>
3674 <Switch
3675 checked={keepAccess}
3676 disabled={updateMutation.isPending}
3677 onCheckedChange={(next) => updateMutation.mutate(next)}
3678 />
3679 </div>
3680
3681 {/* Plain-language "what happens" matrix — covers both module */}
3682 {/* states so the operator can see how the setting plays out */}
3683 {/* across the lifecycle. */}
3684 <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
3685 <div className="rounded-md border border-green-200 bg-green-50/40 dark:border-green-900 dark:bg-green-950/20 p-3">
3686 <div className="flex items-center gap-2 mb-1.5">
3687 <Lock className="w-4 h-4 text-green-600" />
3688 <span className="text-sm font-semibold text-gray-900 dark:text-white">
3689 {__("While this module stays ON", "yatra")}
3690 </span>
3691 </div>
3692 <ul className="text-xs text-gray-600 dark:text-gray-400 space-y-1 list-disc pl-4">
3693 <li>{__("You (site owner) see everything.", "yatra")}</li>
3694 <li>
3695 {__("Team members see only what their role allows.", "yatra")}
3696 </li>
3697 <li>
3698 {__(
3699 "Customers and other users see nothing in the admin.",
3700 "yatra",
3701 )}
3702 </li>
3703 <li className="text-gray-400 italic">
3704 {__(
3705 "This setting doesn't apply yet — it kicks in only if you turn the module off.",
3706 "yatra",
3707 )}
3708 </li>
3709 </ul>
3710 </div>
3711 <div
3712 className={`rounded-md border p-3 ${
3713 keepAccess
3714 ? "border-green-200 bg-green-50/40 dark:border-green-900 dark:bg-green-950/20"
3715 : "border-red-200 bg-red-50/40 dark:border-red-900 dark:bg-red-950/20"
3716 }`}
3717 >
3718 <div className="flex items-center gap-2 mb-1.5">
3719 {keepAccess ? (
3720 <Lock className="w-4 h-4 text-green-600" />
3721 ) : (
3722 <AlertTriangle className="w-4 h-4 text-red-600" />
3723 )}
3724 <span className="text-sm font-semibold text-gray-900 dark:text-white">
3725 {__("If you ever turn this module OFF", "yatra")}
3726 </span>
3727 </div>
3728 <ul className="text-xs text-gray-600 dark:text-gray-400 space-y-1 list-disc pl-4">
3729 <li>{__("You (site owner) still see everything.", "yatra")}</li>
3730 {keepAccess ? (
3731 <>
3732 <li className="text-green-700 dark:text-green-300 font-medium">
3733 {__(
3734 "Team members keep their access — based on their assigned role.",
3735 "yatra",
3736 )}
3737 </li>
3738 <li>
3739 {__(
3740 "Advanced features (expiry, per-user grants, scopes, audit log) pause until you turn the module back on.",
3741 "yatra",
3742 )}
3743 </li>
3744 </>
3745 ) : (
3746 <>
3747 <li className="text-red-700 dark:text-red-300 font-medium">
3748 {__(
3749 "All Yatra roles are deleted (Owner, Manager, custom roles you built — everything except the Yatra Customer role).",
3750 "yatra",
3751 )}
3752 </li>
3753 <li className="text-red-700 dark:text-red-300 font-medium">
3754 {__("Team members lose all their Yatra access.", "yatra")}
3755 </li>
3756 <li>
3757 {__(
3758 "Re-enabling the module brings back the 8 built-in roles. Custom roles and the original member assignments don't come back automatically.",
3759 "yatra",
3760 )}
3761 </li>
3762 </>
3763 )}
3764 </ul>
3765 </div>
3766 </div>
3767
3768 <Alert variant="info" title={__("Quick recap", "yatra")}>
3769 {__(
3770 "Leave the toggle OFF (default) for the security-conservative behavior — every Yatra role is removed if the module is later disabled, so no stale role-based access lingers. Flip it ON only if you specifically need team access to survive a module-off period (e.g. a brief maintenance toggle). Your audit log and member data are preserved either way.",
3771 "yatra",
3772 )}
3773 </Alert>
3774 </CardContent>
3775 </Card>
3776
3777 {/* Login IP allowlist — security feature for compliance */}
3778 {/* operators (SOX, ISO 27001 A.9.4.2, PCI DSS 8.1.5). Empty is */}
3779 {/* the safe default; operators opt in deliberately. The */}
3780 {/* WordPress admin (manage_options) is always exempt, so a */}
3781 {/* misconfigured list can never lock the site owner out. */}
3782 <Card>
3783 <CardHeader>
3784 <CardTitle className="flex items-center gap-2 text-base">
3785 <Shield className="w-4 h-4 text-blue-500" />
3786 {__("Restrict staff logins by source IP", "yatra")}
3787 </CardTitle>
3788 <CardDescription>
3789 {__(
3790 "Optional. Only users who hold a Yatra role are affected — WordPress administrators are always exempt and can never lock themselves out. Drop a comma- or newline-separated list of CIDRs (e.g. 203.0.113.0/24, 198.51.100.5). Empty = no restriction.",
3791 "yatra",
3792 )}
3793 </CardDescription>
3794 </CardHeader>
3795 <CardContent className="space-y-4">
3796 <textarea
3797 className="w-full font-mono text-xs px-3 py-2 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 min-h-[120px]"
3798 value={allowlistDraft}
3799 onChange={(e) => setAllowlistDraft(e.target.value)}
3800 placeholder={"203.0.113.0/24\n2001:db8::/32\n198.51.100.5"}
3801 />
3802 <div className="flex items-center justify-between gap-3 flex-wrap">
3803 <p className="text-xs text-gray-500 dark:text-gray-400">
3804 {serverAllowlist.trim() === ""
3805 ? __(
3806 "Currently no restriction — every authenticated staff member can sign in from anywhere.",
3807 "yatra",
3808 )
3809 : sprintf(
3810 /* translators: %d: number of CIDR entries currently active */
3811 __("%d source IP rule(s) active.", "yatra"),
3812 serverAllowlist.split(",").filter((s) => s.trim() !== "")
3813 .length,
3814 )}
3815 </p>
3816 <div className="flex items-center gap-2">
3817 {allowlistDirty && (
3818 <Button
3819 variant="outline"
3820 onClick={() => setAllowlistDraft(serverAllowlist)}
3821 disabled={saveAllowlist.isPending}
3822 >
3823 {__("Reset", "yatra")}
3824 </Button>
3825 )}
3826 <Button
3827 onClick={() => saveAllowlist.mutate(allowlistDraft)}
3828 disabled={!allowlistDirty || saveAllowlist.isPending}
3829 >
3830 {saveAllowlist.isPending && (
3831 <Loader2 className="h-4 w-4 mr-1 animate-spin" />
3832 )}
3833 {__("Save allowlist", "yatra")}
3834 </Button>
3835 </div>
3836 </div>
3837 <Alert
3838 variant={serverAllowlist.trim() === "" ? "info" : "warning"}
3839 title={
3840 serverAllowlist.trim() === ""
3841 ? __("Failsafe — admins are always exempt", "yatra")
3842 : __("Live restriction is in effect", "yatra")
3843 }
3844 >
3845 {serverAllowlist.trim() === ""
3846 ? __(
3847 "If you do enable a restriction here and ever lock yourself out, drop the constant `YATRA_DISABLE_LOGIN_IP_ALLOWLIST = true` into wp-config.php to bypass the gate entirely — no database access required.",
3848 "yatra",
3849 )
3850 : __(
3851 "Logins from outside the allowlist are blocked at the password step. WordPress administrators are exempt — you can never lock yourself out as the site owner. Every block is recorded in the Audit log as `team.login_blocked_by_ip`.",
3852 "yatra",
3853 )}
3854 </Alert>
3855 </CardContent>
3856 </Card>
3857 </div>
3858 );
3859 };
3860
3861 export default Team;
3862