| 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 |
|