Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 26 additions & 16 deletions apps/csm-portal/webapp/src/components/header/UserProfileModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ import {
// E.164 phone validator: leading + required, then country code digit + 7-14 more digits.
const E164 = /^\+[1-9]\d{7,14}$/;

// Show at most this many group chips inline before collapsing with "+N more".
const GROUPS_PREVIEW_LIMIT = 4;
// Show at most this many role chips inline before collapsing with "+N more".
const ROLES_PREVIEW_LIMIT = 4;

export interface UserProfileModalProps {
open: boolean;
Expand Down Expand Up @@ -149,8 +149,12 @@ export default function UserProfileModal({
onClose,
]);

const visibleGroups = info.groups.slice(0, GROUPS_PREVIEW_LIMIT);
const hiddenGroupCount = info.groups.length - visibleGroups.length;
// The platform authorizes off its own data, not IdP claims (Asgardeo groups
// are auth-only, not a role list) — so this shows `/users/me`'s `roles`
// rather than `claims.groups`.
const roles = userMe?.roles ?? [];
const visibleRoles = roles.slice(0, ROLES_PREVIEW_LIMIT);
const hiddenRoleCount = roles.length - visibleRoles.length;

return (
<Dialog open={open} onClose={onClose} fullWidth maxWidth="sm">
Expand Down Expand Up @@ -194,10 +198,10 @@ export default function UserProfileModal({
</Box>
</Box>

{info.groups.length > 0 && (
{(isLoading || roles.length > 0) && (
<Box>
<Typography variant="caption" color="text.secondary">
Groups
Roles
</Typography>
<Box
sx={{
Expand All @@ -207,16 +211,22 @@ export default function UserProfileModal({
mt: 0.5,
}}
>
{visibleGroups.map((g) => (
<Chip key={g} size="small" label={g} variant="outlined" />
))}
{hiddenGroupCount > 0 && (
<Chip
size="small"
label={`+${hiddenGroupCount} more`}
variant="outlined"
color="default"
/>
{isLoading ? (
<Skeleton width={160} />
) : (
<>
{visibleRoles.map((r) => (
<Chip key={r} size="small" label={r} variant="outlined" />
))}
{hiddenRoleCount > 0 && (
<Chip
size="small"
label={`+${hiddenRoleCount} more`}
variant="outlined"
color="default"
/>
)}
</>
)}
</Box>
</Box>
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
import {
Box,
Button,
Chip,
FormControl,
MenuItem,
Select,
Expand All @@ -27,7 +26,6 @@ import {
import type { JSX } from "react";
import {
DASHBOARD_OPTIONS,
type CsmDashboardEngineer,
type DashboardKey,
type DashboardScope,
} from "@features/csm-dashboard/types/abtDashboard";
Expand All @@ -43,21 +41,17 @@ const ABT_SCOPING_ENABLED = false;
const DASHBOARD_SWITCHER_ENABLED = false;

interface AbtDashboardHeaderProps {
engineer?: CsmDashboardEngineer;
scope: DashboardScope;
onScopeChange: (scope: DashboardScope) => void;
dashboardKey: DashboardKey;
onDashboardChange: (key: DashboardKey) => void;
isError?: boolean;
}

export default function AbtDashboardHeader({
engineer,
scope,
onScopeChange,
dashboardKey,
onDashboardChange,
isError,
}: AbtDashboardHeaderProps): JSX.Element {
const currentOption = DASHBOARD_OPTIONS.find((o) => o.key === dashboardKey);
const showScopeButtons = currentOption?.scopeBased ?? false;
Expand All @@ -74,25 +68,9 @@ export default function AbtDashboardHeader({
>
<Box>
<Typography variant="h5">Dashboard</Typography>
<Box
sx={{ display: "flex", alignItems: "center", gap: 1, mt: 0.5 }}
>
<Typography variant="body2" color="text.secondary">
{engineer
? engineer.name
: isError
? "Engineer overview"
: "Loading engineer…"}
</Typography>
{engineer?.abtName && (
<Chip
size="small"
label={engineer.abtName}
variant="outlined"
color="primary"
/>
)}
</Box>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
Engineer overview
</Typography>
</Box>
<Box sx={{ display: "flex", gap: 1, alignItems: "center", flexWrap: "wrap" }}>
{showScopeButtons && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import AbtDashboardHeader from "@features/csm-dashboard/components/AbtDashboardH
import CaseCompositionCharts from "@features/csm-dashboard/components/CaseCompositionCharts";
import CaseCountsMatrix from "@features/csm-dashboard/components/CaseCountsMatrix";
import MyAssignedCases from "@features/csm-dashboard/components/MyAssignedCases";
import { useGetCsmDashboard } from "@features/csm-dashboard/api/useGetCsmDashboard";
import {
DASHBOARD_OPTIONS,
type DashboardKey,
Expand All @@ -37,36 +36,21 @@ import {
* tab+widget model (DashboardsAndReportsProposal.md, entity-service reports
* DSL) lands. The placeholder dashboards below are kept for that restore.
*/
// csm-portal-backend has no route for this yet (no `/csm/dashboard` handler
// registered in cmd/server/main.go), so the call would always 404. Skip it
// until that backend work lands — the header already renders a graceful
// "Engineer overview" fallback for the no-data case, same as it would for a
// real fetch failure, so gating here is a no-op once the endpoint exists.
const CSM_DASHBOARD_API_IMPLEMENTED = false;

export default function CsmDashboardPage(): JSX.Element {
// ABT scoping is not implemented yet, so default to (and stay on)
// all-customers; the My ABT / All customers toggle is disabled in the header.
const [scope, setScope] = useState<DashboardScope>("all_customers");
// Locked to the Engineer dashboard: the switcher is disabled in the header
// (the other dashboards are mock placeholders), so this never changes today.
const [dashboardKey, setDashboardKey] = useState<DashboardKey>("engineer");
// Only the engineer-overview header consumes this now; the queue/SLA/customer/
// activity widgets are hidden, leaving the standalone severity-by-state matrix
// (CaseCountsMatrix, which loads from its own source) as the only widget.
const { data, isError } = useGetCsmDashboard(scope, {
enabled: CSM_DASHBOARD_API_IMPLEMENTED,
});

return (
<Box sx={{ display: "flex", flexDirection: "column", gap: 3 }}>
<AbtDashboardHeader
engineer={data?.engineer}
scope={scope}
onScopeChange={setScope}
dashboardKey={dashboardKey}
onDashboardChange={setDashboardKey}
isError={isError || !CSM_DASHBOARD_API_IMPLEMENTED}
/>
{dashboardKey === "engineer" ? (
<>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,6 @@ export type SlaClockType = "ack" | "first_response" | "resolution";

export type DashboardScope = "my_abt" | "all_customers";

export interface CsmDashboardEngineer {
name: string;
email: string;
abtName: string;
}

export interface CsmQueueCase {
id: string;
caseNumber: string;
Expand Down Expand Up @@ -108,15 +102,6 @@ export interface CsmRecentActivity {
summary: string;
}

export interface CsmAbtDashboardData {
engineer: CsmDashboardEngineer;
scope: DashboardScope;
queue: CsmQueueSummary;
slaAtRisk: CsmSlaAtRiskCase[];
customers: CsmCustomerSummary[];
recentActivity: CsmRecentActivity[];
}

// Multi-dashboard switcher — mirrors ServiceNow Performance Analytics where
// engineers pivot between several dashboards (Engineer / Operations / IAM /
// Security / Team Performance). See DashboardsAndReportsProposal.md.
Expand Down
54 changes: 49 additions & 5 deletions apps/csm-portal/webapp/src/hooks/useAuthApiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ import { CORRELATION_ID_HEADER, newCorrelationId } from "@utils/correlationId";
// fail authentication at once.
let signInInFlight = false;

// Shared across every caller's hook instance for the same reason as
// `signInInFlight`: many concurrent requests can discover a dead refresh
// token at once, and they should all await the SAME hidden-iframe silent
// sign-in attempt rather than each opening their own.
let silentSignInInFlight: Promise<boolean> | null = null;

// Only the Asgardeo "unauthenticated" code means the token was expired/missing
// when the call ran (e.g. the refresh token itself has expired, so the SDK's
// periodic background refresh can no longer mint a new access token). Anything
Expand Down Expand Up @@ -148,7 +154,7 @@ function buildRequestHeaders(
// backend; calls to any other origin are refused so credentials can't be
// leaked to third-party hosts.
export function useAuthApiClient() {
const { getAccessToken, getIdToken, signIn } = useAsgardeo();
const { getAccessToken, getIdToken, signIn, signInSilently } = useAsgardeo();
const logger = useLogger();

// Redirect to a full sign-in, single-flighted so concurrent auth failures
Expand All @@ -165,6 +171,27 @@ export function useAuthApiClient() {
return new Promise<Response>(() => {});
}, [signIn]);

// Before giving up and bouncing the whole tab to a full sign-in redirect
// (which discards any in-progress work — an open comment draft, an unsaved
// dialog), try a silent, hidden-iframe re-authentication. If the user's IdP
// session (SSO cookie) is still alive, this mints a fresh token without any
// visible navigation; only a genuinely dead IdP session falls through to
// `redirectToSignIn`. Single-flighted for the same reason as sign-in above.
const trySilentSignIn = useCallback((): Promise<boolean> => {
if (!silentSignInInFlight) {
silentSignInInFlight = Promise.resolve(signInSilently())
.then((result) => Boolean(result))
.catch((error) => {
logger.debug("[auth] silent sign-in failed", error);
return false;
})
.finally(() => {
silentSignInInFlight = null;
});
}
return silentSignInInFlight;
}, [signInSilently, logger]);

const attemptFetch = useCallback(
async (input: RequestInfo | URL, options?: RequestInit): Promise<Response> => {
const url = resolveRequestUrl(input);
Expand Down Expand Up @@ -262,13 +289,30 @@ export function useAuthApiClient() {
throw retryError;
}

// Still unauthenticated after the retry — the session (refresh
// token) is gone. Redirect for a full sign-in instead of letting
// the SDK's periodic refresh keep retrying forever.
// Still unauthenticated after the retry — the refresh token is
// dead. Try a silent re-auth first: if the IdP session is still
// alive this mints a fresh token with no visible navigation, so
// in-progress work survives.
if (await trySilentSignIn()) {
try {
return await attemptFetch(input, options);
} catch (afterSilentSignInError) {
if (!isTokenExpiredError(afterSilentSignInError)) {
throw afterSilentSignInError;
}
// Silent sign-in reported success but the token still won't
// authenticate (e.g. a race with a session that expired a
// moment later) — fall through to the hard redirect below.
}
}

// Silent re-auth was unavailable or the IdP session itself is
// gone. Redirect for a full sign-in instead of letting the SDK's
// periodic refresh keep retrying forever.
return redirectToSignIn();
}
}
},
[attemptFetch, redirectToSignIn],
[attemptFetch, redirectToSignIn, trySilentSignIn],
);
}
21 changes: 19 additions & 2 deletions apps/csm-portal/webapp/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,11 @@ if (typeof window !== "undefined") {
// fetching one 404s to the SPA fallback (text/html), which Vite reports as
// `vite:preloadError` (and plain `import()` calls reject the same way). Reload
// once to pick up the new build — guarded by a session flag so a genuinely
// broken deployment doesn't reload-loop the tab forever.
// broken deployment doesn't reload-loop the tab forever. The guard is re-armed
// a while after a clean mount (below) so a tab that lives across *multiple*
// deploys still gets one auto-reload per deploy instead of only its first.
const RELOAD_GUARD_KEY = "csm_chunk_reload_once";
if (typeof window !== "undefined") {
const RELOAD_GUARD_KEY = "csm_chunk_reload_once";
const reloadForNewBuild = (): void => {
let alreadyReloaded = false;
try {
Expand Down Expand Up @@ -88,3 +90,18 @@ createRoot(document.getElementById("root")!).render(
<AppWithConfig />
</React.StrictMode>,
);

// Clear the reload guard once the tab has stayed up long enough to prove this
// mount is healthy (not the same failure looping). Without this, the guard —
// set once and never cleared — would only ever auto-recover the *first*
// stale-chunk failure a tab hits; every later one (e.g. after a subsequent
// deploy, hours or days into the same tab session) would silently do nothing.
if (typeof window !== "undefined") {
setTimeout(() => {
try {
window.sessionStorage.removeItem(RELOAD_GUARD_KEY);
} catch {
/* sessionStorage may be unavailable; nothing to clear. */
}
}, 10_000);
}