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
3 changes: 3 additions & 0 deletions src/__tests__/security-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,4 +130,7 @@ describe('Security Permission Logic', () => {
expect(shouldShowMenu).toBe(false);
});
});

// The IC authorization gate (CanLoginToCommandApp) is production code in
// src/lib/auth/command-app-access.ts and is covered by its own test suite.
});
20 changes: 12 additions & 8 deletions src/app/(app)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { useAppLifecycle } from '@/hooks/use-app-lifecycle';
import { useSignalRLifecycle } from '@/hooks/use-signalr-lifecycle';
import { getAppHeaderHeight } from '@/lib/app-shell-layout';
import { useAuthStore } from '@/lib/auth';
import { enforceCommandAppAccess } from '@/lib/auth/command-app-access';
import { logger } from '@/lib/logging';
import { getMapsHeaderState } from '@/lib/maps-route';
import { useIsFirstTime } from '@/lib/storage';
Expand All @@ -39,7 +40,6 @@ import { FeatureFlagKeys, featureFlagsStore } from '@/stores/feature-flags/store
import { useRolesStore } from '@/stores/roles/store';
import { securityStore } from '@/stores/security/store';
import { useSignalRStore } from '@/stores/signalr/signalr-store';
import { useToastStore } from '@/stores/toast/store';
import { useWeatherAlertsStore } from '@/stores/weather-alerts/store';

export default function TabLayout() {
Expand Down Expand Up @@ -177,14 +177,10 @@ export default function TabLayout() {
await useWeatherAlertsStore.getState().init();
await securityStore.getState().getRights();

// The IC app is for commanders. A member the department has not authorized must not get past
// initialization — the server refuses them the board endpoints anyway, so signing them straight
// back out is far clearer than an app that loads and then fails every request.
// An unauthorized member is toasted and signed out here rather than left in an app that would
// fail every board request; see enforceCommandAppAccess.
if (!isCurrentRun()) return;
if (securityStore.getState().rights?.CanLoginToCommandApp === false) {
logger.warn({ message: 'User is not authorized to use the IC app; signing out', context: { userId } });
useToastStore.getState().showToast('error', t('login.command_not_authorized'));
await useAuthStore.getState().logout();
if (await enforceCommandAppAccess({ deniedMessage: t('login.command_not_authorized'), userId })) {
return;
}

Expand Down Expand Up @@ -644,11 +640,15 @@ interface CreateDrawerMenuButtonProps {
}

const CreateDrawerMenuButton = ({ setIsOpen }: CreateDrawerMenuButtonProps) => {
const { t } = useTranslation();

return (
<Pressable
className="p-3"
hitSlop={8}
testID="drawer-menu-button"
accessibilityRole="button"
accessibilityLabel={t('sidebar.menu')}
onPress={() => {
setIsOpen(true);
}}
Expand All @@ -662,11 +662,15 @@ const CreateDrawerMenuButton = ({ setIsOpen }: CreateDrawerMenuButtonProps) => {
};

const CreateHeaderBackButton = () => {
const { t } = useTranslation();

return (
<Pressable
className="p-3"
hitSlop={8}
testID="header-back-button"
accessibilityRole="button"
accessibilityLabel={t('common.back')}
onPress={() => {
if (router.canGoBack()) {
router.back();
Expand Down
22 changes: 21 additions & 1 deletion src/app/(app)/command.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -207,19 +207,39 @@ export default function CommandBoard() {

const incidentChannels = useChatStore((state) => (boardCallId ? state.incidentChannelsByCallId[boardCallId] : undefined));

const incidentChannelsStatus = useChatStore((state) => (boardCallId ? state.incidentChannelsStatusByCallId[boardCallId] : undefined));

// The channel map holds undefined mid-fetch, after a failure, and for an incident that genuinely
// has no such channel, so a tap would otherwise claim chat is unavailable in all three cases. The
// status is still absent between the board opening and the load effect firing — treat that as
// loading too, so only a completed request can produce a message.
const isLoadingIncidentChannels = boardCallId ? (incidentChannelsStatus ?? 'loading') === 'loading' : false;
const didIncidentChannelsFail = incidentChannelsStatus === 'failed';

const commandChatChannelId = useMemo(() => incidentChannels?.find((channel) => channel.ChannelType === ChatChannelType.IncidentCommand)?.ChatChannelId ?? null, [incidentChannels]);

const laneChatChannelId = useCallback((nodeId: string) => incidentChannels?.find((channel) => channel.CommandStructureNodeId === nodeId)?.ChatChannelId ?? null, [incidentChannels]);

const openChatChannel = useCallback(
(channelId: string | null, unavailableMessage: string) => {
if (!channelId) {
// Still fetching: stay silent rather than report a channel missing that may yet arrive.
if (isLoadingIncidentChannels) {
return;
}
// The channel list never arrived, so nothing is known about this incident's chat — say the
// load failed and let the commander retry instead of declaring the channel missing.
if (didIncidentChannelsFail) {
showToast('error', t('command.chat_load_failed'));
void useChatStore.getState().loadIncidentChannels(boardCallId ?? '');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Unhandled promise rejection occurs when the void operator discards the returned promise from loadIncidentChannels, rendering retry failures invisible. Wrap the call in a try/catch block or chain a .catch handler to catch errors and invoke showToast('error', t('command.chat_load_failed')).

Kody rule violation: Handle async operations with proper error handling

Prompt for LLM

File src/app/(app)/command.tsx:

Line 234:

Unhandled promise rejection occurs when the `void` operator discards the returned promise from `loadIncidentChannels`, rendering retry failures invisible. Wrap the call in a `try/catch` block or chain a `.catch` handler to catch errors and invoke `showToast('error', t('command.chat_load_failed'))`.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

return;
}
showToast('info', unavailableMessage);
return;
}
router.push(`/chat/${channelId}`);
},
[showToast]
[showToast, isLoadingIncidentChannels, didIncidentChannelsFail, boardCallId, t]
);

const handleOpenCommandChat = useCallback(() => openChatChannel(commandChatChannelId, t('command.command_chat_unavailable')), [openChatChannel, commandChatChannelId, t]);
Expand Down
38 changes: 28 additions & 10 deletions src/app/chat/[channelId].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,32 @@ export default function ChannelConversationScreen() {
[channelId, isFrozen]
);

/**
* The actions sheet already hides Edit on a frozen channel, but a channel can freeze while the edit
* sheet is open — the incident closes and SignalR flips IsArchived under it. Drop the in-progress
* edit rather than leave a sheet whose save the server would reject.
*/
useEffect(() => {
if (isFrozen && editMessage) {
setEditMessage(null);
setEditText('');
useToastStore.getState().showToast('info', t('chat.frozen_notice'));
}
}, [isFrozen, editMessage, t]);

const handleSaveEdit = useCallback(() => {
// Guards the race between the freeze landing and this press.
if (isFrozen) {
setEditMessage(null);
setEditText('');
return;
}
if (editMessage && channelId && editText.trim()) {
void useChatStore.getState().editMessage(editMessage.ChatMessageId, channelId, editText.trim());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

The void operator suppresses the promise returned by editMessage, silently swallowing network errors and violating Rule 1. Attach a .catch handler or convert handleSaveEdit to an async function using try/catch.

Kody rule violation: Handle async operations with proper error handling

Prompt for LLM

File src/app/chat/[channelId].tsx:

Line 291:

The `void` operator suppresses the promise returned by `editMessage`, silently swallowing network errors and violating Rule 1. Attach a `.catch` handler or convert `handleSaveEdit` to an async function using try/catch.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

}
setEditMessage(null);
}, [isFrozen, editMessage, channelId, editText]);

const openThread = useCallback(
(message: ChatMessageResultData) => {
router.push(`/chat/thread/${message.ChatMessageId}?channelId=${channelId ?? ''}` as Href);
Expand Down Expand Up @@ -422,7 +448,7 @@ export default function ChannelConversationScreen() {
/>

{/* Edit message sheet */}
<Actionsheet isOpen={editMessage !== null} onClose={() => setEditMessage(null)}>
<Actionsheet isOpen={editMessage !== null && !isFrozen} onClose={() => setEditMessage(null)}>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Inline arrow function in the onClose prop creates a new function on every render, degrading performance. Move the function definition outside the render method.

Kody rule violation: Avoid using .bind() or arrow functions in JSX props

Prompt for LLM

File src/app/chat/[channelId].tsx:

Line 451:

Inline arrow function in the `onClose` prop creates a new function on every render, degrading performance. Move the function definition outside the render method.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

<ActionsheetBackdrop />
<ActionsheetContent>
<ActionsheetDragIndicatorWrapper>
Expand All @@ -433,15 +459,7 @@ export default function ChannelConversationScreen() {
<Textarea>
<TextareaInput value={editText} onChangeText={setEditText} multiline />
</Textarea>
<Button
className="bg-primary-600"
onPress={() => {
if (editMessage && channelId && editText.trim()) {
void useChatStore.getState().editMessage(editMessage.ChatMessageId, channelId, editText.trim());
}
setEditMessage(null);
}}
>
<Button className="bg-primary-600" isDisabled={isFrozen} onPress={handleSaveEdit} testID="chat-edit-save">
<ButtonText>{t('chat.save')}</ButtonText>
</Button>
</VStack>
Expand Down
10 changes: 7 additions & 3 deletions src/components/ui/bottom-sheet.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useColorScheme } from 'nativewind';
import type { ReactNode } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { Animated, KeyboardAvoidingView, Modal, Platform, Pressable, ScrollView, useWindowDimensions } from 'react-native';

import { Center } from './center';
Expand Down Expand Up @@ -48,9 +48,13 @@ export function CustomBottomSheet({
const backdropOpacity = useRef(new Animated.Value(0)).current;

// Read inside the close-animation callback, which fires after the animation and would otherwise
// see a stale `isOpen` from the render that started it.
// see a stale `isOpen` from the render that started it. Written in a layout effect rather than
// during render so an abandoned render can't leave the ref describing a state never committed;
// layout effects still run before the passive effect below starts the animation.
const isOpenRef = useRef(isOpen);
isOpenRef.current = isOpen;
useLayoutEffect(() => {
isOpenRef.current = isOpen;
}, [isOpen]);

// Compute sheet height from first snap point (percentage of screen height).
// Clamp between 300px and the screen height to remain usable in all orientations.
Expand Down
10 changes: 7 additions & 3 deletions src/components/ui/side-drawer.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useColorScheme } from 'nativewind';
import type { ReactNode } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { Animated, Modal, Pressable, ScrollView, useWindowDimensions } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';

Expand Down Expand Up @@ -37,9 +37,13 @@ export function SideDrawer({ children, isOpen, onClose, testID }: SideDrawerProp
const backdropOpacity = useRef(new Animated.Value(0)).current;

// Read inside the close-animation callback, which fires after the animation and would otherwise
// see a stale `isOpen` from the render that started it.
// see a stale `isOpen` from the render that started it. Written in a layout effect rather than
// during render so an abandoned render can't leave the ref describing a state never committed;
// layout effects still run before the passive effect below starts the animation.
const isOpenRef = useRef(isOpen);
isOpenRef.current = isOpen;
useLayoutEffect(() => {
isOpenRef.current = isOpen;
}, [isOpen]);

useEffect(() => {
if (isOpen) {
Expand Down
124 changes: 124 additions & 0 deletions src/lib/auth/__tests__/command-app-access.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/**
* The IC app is commander-only. These cover the gate initializeApp() runs after fetching rights:
* an unauthorized member is told why and signed out, and every other rights state — including a
* server that omits the field or a fetch that failed — leaves the session alone.
*/
import { enforceCommandAppAccess } from '../command-app-access';

import type { DepartmentRightsResultData } from '@/models/v4/security/departmentRightsResultData';

const mockLogout = jest.fn();
const mockShowToast = jest.fn();
let mockRights: DepartmentRightsResultData | null = null;

jest.mock('@/lib/logging', () => ({
logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
}));

jest.mock('@/stores/auth/store', () => ({
__esModule: true,
default: { getState: () => ({ logout: mockLogout }) },
}));

jest.mock('@/stores/security/store', () => ({
securityStore: { getState: () => ({ rights: mockRights }) },
}));

jest.mock('@/stores/toast/store', () => ({
useToastStore: { getState: () => ({ showToast: mockShowToast }) },
}));

const buildRights = (overrides: Partial<DepartmentRightsResultData> = {}): DepartmentRightsResultData =>
({
DepartmentName: 'Test Department',
DepartmentCode: 'TEST',
FullName: 'Test User',
EmailAddress: 'test@example.com',
DepartmentId: '1',
IsAdmin: false,
CanViewPII: false,
CanCreateCalls: true,
CanAddNote: false,
CanCreateMessage: false,
CanLoginToCommandApp: true,
Groups: [],
...overrides,
}) as DepartmentRightsResultData;

describe('enforceCommandAppAccess', () => {
beforeEach(() => {
mockLogout.mockReset().mockResolvedValue(undefined);
mockShowToast.mockReset();
mockRights = null;
});

it('toasts the denial and signs the user out when CanLoginToCommandApp is false', async () => {
mockRights = buildRights({ CanLoginToCommandApp: false });

const denied = await enforceCommandAppAccess({ deniedMessage: 'Not authorized', userId: 'user-1' });

// Returning true is how the caller knows to abandon the rest of initialization.
expect(denied).toBe(true);
expect(mockShowToast).toHaveBeenCalledWith('error', 'Not authorized');
expect(mockLogout).toHaveBeenCalledTimes(1);
});

it('toasts before signing out so the reason survives the sign-out navigation', async () => {
mockRights = buildRights({ CanLoginToCommandApp: false });

await enforceCommandAppAccess({ deniedMessage: 'Not authorized' });

expect(mockShowToast.mock.invocationCallOrder[0]).toBeLessThan(mockLogout.mock.invocationCallOrder[0]);
});

it('waits for the sign-out to finish before reporting the denial', async () => {
mockRights = buildRights({ CanLoginToCommandApp: false });
let logoutFinished = false;
mockLogout.mockImplementation(
() =>
new Promise<void>((resolve) => {
setImmediate(() => {
logoutFinished = true;
resolve();
});
})
);

await enforceCommandAppAccess({ deniedMessage: 'Not authorized' });

expect(logoutFinished).toBe(true);
});

it('allows initialization to continue when CanLoginToCommandApp is true', async () => {
mockRights = buildRights();

const denied = await enforceCommandAppAccess({ deniedMessage: 'Not authorized' });

expect(denied).toBe(false);
expect(mockShowToast).not.toHaveBeenCalled();
expect(mockLogout).not.toHaveBeenCalled();
});

it('allows initialization to continue when the server omits CanLoginToCommandApp', async () => {
// The check is a strict === false and the model defaults the field to true, so an older
// server that omits it must not lock commanders out.
const rights = buildRights();
delete (rights as Partial<DepartmentRightsResultData>).CanLoginToCommandApp;
mockRights = rights;

const denied = await enforceCommandAppAccess({ deniedMessage: 'Not authorized' });

expect(denied).toBe(false);
expect(mockLogout).not.toHaveBeenCalled();
});

it('allows initialization to continue when rights are not available', async () => {
// A failed rights fetch is not a denial; only an explicit false ends the session.
mockRights = null;

const denied = await enforceCommandAppAccess({ deniedMessage: 'Not authorized' });

expect(denied).toBe(false);
expect(mockLogout).not.toHaveBeenCalled();
});
});
31 changes: 31 additions & 0 deletions src/lib/auth/command-app-access.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { logger } from '@/lib/logging';
import useAuthStore from '@/stores/auth/store';
import { securityStore } from '@/stores/security/store';
import { useToastStore } from '@/stores/toast/store';

interface EnforceCommandAppAccessArgs {
/** Already-localized denial text; the caller owns translation so language changes take effect. */
deniedMessage: string;
userId?: string | null;
}

/**
* The IC app is for commanders. A member the department has not authorized must not get past
* initialization — the server refuses them the board endpoints anyway, so signing them straight
* back out is far clearer than an app that loads and then fails every request.
*
* The check is deliberately strict: only an explicit false denies. Rights that failed to load, or a
* server old enough to omit the field, leave the session alone rather than locking a commander out.
*
* @returns true when the user was denied and signed out — the caller must stop initializing.
*/
export async function enforceCommandAppAccess({ deniedMessage, userId }: EnforceCommandAppAccessArgs): Promise<boolean> {
if (securityStore.getState().rights?.CanLoginToCommandApp !== false) {
return false;
}

logger.warn({ message: 'User is not authorized to use the IC app; signing out', context: { userId } });
useToastStore.getState().showToast('error', deniedMessage);
await useAuthStore.getState().logout();
return true;
}
Loading
Loading