-
Notifications
You must be signed in to change notification settings - Fork 0
RC-T40 IC bug fixes #40
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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()); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The Kody rule violation: Handle async operations with proper error handling Prompt for LLMTalk 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); | ||
|
|
@@ -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)}> | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Inline arrow function in the Kody rule violation: Avoid using .bind() or arrow functions in JSX props Prompt for LLMTalk to Kody by mentioning @kody Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction. |
||
| <ActionsheetBackdrop /> | ||
| <ActionsheetContent> | ||
| <ActionsheetDragIndicatorWrapper> | ||
|
|
@@ -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> | ||
|
|
||
| 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(); | ||
| }); | ||
| }); |
| 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; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unhandled promise rejection occurs when the
voidoperator discards the returned promise fromloadIncidentChannels, rendering retry failures invisible. Wrap the call in atry/catchblock or chain a.catchhandler to catch errors and invokeshowToast('error', t('command.chat_load_failed')).Kody rule violation: Handle async operations with proper error handling
Prompt for LLM
Talk to Kody by mentioning @kody
Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.