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
8 changes: 8 additions & 0 deletions src/router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,14 @@ const authenticatedRoutes = [
requiresAuth: true,
},
},
{
path: '/admin/notifications',
name: 'admin-notifications',
component: () => import('../views/admin/NotificationsView.vue'),
meta: {
requiresAuth: true,
},
},
{
path: '/admin/risks',
name: 'admin-risks',
Expand Down
5 changes: 5 additions & 0 deletions src/views/LeftSideNav.vue
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,11 @@ const links = ref<Array<NavigationItem>>([
name: 'admin-risk-templates',
title: 'Risk Templates',
},
{
name: 'admin-notifications',
title: 'Notifications',
abbr: 'NTF',
},
{
name: 'admin-import',
title: 'Import',
Expand Down
172 changes: 146 additions & 26 deletions src/views/PreferencesView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,11 @@ interface UpdateNotificationPreferencesOptions {
silent?: boolean;
}

type NotificationProviderAvailability = Record<
NotificationAlertChannel,
boolean
>;

interface SlackAvailabilityState {
loading: boolean;
configured: boolean;
Expand All @@ -309,6 +314,43 @@ const normalizeNotificationChannels = (
return normalized;
};

const normalizeNotificationProviderAvailability = (
providers: unknown,
): NotificationProviderAvailability => {
const availability: NotificationProviderAvailability = {
email: false,
slack: false,
};

if (!Array.isArray(providers)) {
return availability;
}

for (const provider of providers) {
if (!provider || typeof provider !== 'object') {
continue;
}

const providerType = (provider as { providerType?: unknown }).providerType;
const enabled = (provider as { enabled?: unknown }).enabled;

if (
(providerType === 'email' || providerType === 'slack') &&
typeof enabled === 'boolean'
) {
availability[providerType] = enabled;
}
}

return availability;
};

const fallbackNotificationProviderAvailability =
(): NotificationProviderAvailability => ({
email: true,
slack: true,
});

const axios = useAuthenticatedInstance();
const user = ref<CCFUser | null>(null);
const loading = ref(true);
Expand All @@ -320,6 +362,11 @@ const riskNotificationsAlertChannels = ref<NotificationAlertChannel[]>([]);
const slackLinkConfigured = ref(false);
const slackStatusLoading = ref(true);
const isSlackLinked = ref(false);
const notificationProvidersLoading = ref(true);
const notificationProviderAvailability = ref<NotificationProviderAvailability>({
email: false,
slack: false,
});
const lastSavedPreferences = ref<SubscriptionsPreferencesPayload>({
notifications: {
evidence_digest: [],
Expand All @@ -336,46 +383,68 @@ let notificationUpdateQueue: Promise<void> = Promise.resolve();
let pendingNotificationUpdateOptions: UpdateNotificationPreferencesOptions | null =
null;

const canSelectEmailAlertChannel = computed(
() =>
!notificationProvidersLoading.value &&
notificationProviderAvailability.value.email,
);

const canSelectSlackAlertChannel = computed(
() =>
!notificationProvidersLoading.value &&
notificationProviderAvailability.value.slack &&
!slackStatusLoading.value &&
slackLinkConfigured.value &&
isSlackLinked.value,
);

const sanitizeNotificationChannels = (
channels: NotificationAlertChannel[],
): NotificationAlertChannel[] => {
return channels.filter(
(channel) => channel !== 'slack' || canSelectSlackAlertChannel.value,
);
const canSelectNotificationAlertChannel = (
channel: NotificationAlertChannel,
): boolean => {
if (channel === 'email') {
return canSelectEmailAlertChannel.value;
}

return canSelectSlackAlertChannel.value;
};

const removeSlackChannels = (
const sanitizeNotificationChannels = (
channels: NotificationAlertChannel[],
): NotificationAlertChannel[] => {
return channels.filter((channel) => channel !== 'slack');
return channels.filter((channel) => {
if (channel === 'slack') {
// Keep slack selections while Slack status is still loading to avoid
// dropping a valid selection before we know whether it is linked.
if (notificationProvidersLoading.value || slackStatusLoading.value) {
return true;
}

return canSelectNotificationAlertChannel(channel);
}

return canSelectNotificationAlertChannel(channel);
});
};

const removeUnavailableSlackSelections = () => {
if (slackStatusLoading.value || canSelectSlackAlertChannel.value) {
const removeUnavailableNotificationSelections = () => {
if (notificationProvidersLoading.value) {
return false;
}

const sanitizedEvidenceDigestChannels = removeSlackChannels(
const sanitizedEvidenceDigestChannels = sanitizeNotificationChannels(
evidenceDigestAlertChannels.value,
);
const sanitizedTaskAvailableChannels = removeSlackChannels(
const sanitizedTaskAvailableChannels = sanitizeNotificationChannels(
taskAvailableAlertChannels.value,
);
const sanitizedTaskDailyDigestChannels = removeSlackChannels(
const sanitizedTaskDailyDigestChannels = sanitizeNotificationChannels(
taskDailyDigestAlertChannels.value,
);
const sanitizedRiskNotificationsChannels = removeSlackChannels(
const sanitizedRiskNotificationsChannels = sanitizeNotificationChannels(
riskNotificationsAlertChannels.value,
);

const hadUnavailableSlackSelection =
const hadUnavailableNotificationSelection =
sanitizedEvidenceDigestChannels.length !==
evidenceDigestAlertChannels.value.length ||
sanitizedTaskAvailableChannels.length !==
Expand All @@ -385,7 +454,7 @@ const removeUnavailableSlackSelections = () => {
sanitizedRiskNotificationsChannels.length !==
riskNotificationsAlertChannels.value.length;

if (!hadUnavailableSlackSelection) {
if (!hadUnavailableNotificationSelection) {
return false;
}

Expand Down Expand Up @@ -528,8 +597,8 @@ const updateNotificationPreferences = (
return notificationUpdateQueue;
};

const syncUnavailableSlackSelections = async () => {
if (!removeUnavailableSlackSelections()) {
const syncUnavailableNotificationSelections = async () => {
if (!removeUnavailableNotificationSelections()) {
return;
}

Expand All @@ -544,7 +613,14 @@ const notificationChannelOptions = computed<
disabledTooltip?: string;
}>
>(() => [
{ label: 'Email', value: 'email' },
{
label: 'Email',
value: 'email',
disabled: !canSelectEmailAlertChannel.value,
disabledTooltip: !canSelectEmailAlertChannel.value
? (emailAlertUnavailableReason.value ?? undefined)
: undefined,
},
{
label: 'Slack',
value: 'slack',
Expand All @@ -567,7 +643,27 @@ const taskAvailableTooltipText = computed(() => {
: 'Task alerts will be sent to your chosen channels.';
});

const emailAlertUnavailableReason = computed(() => {
if (notificationProvidersLoading.value) {
return 'Email alerts are unavailable until notification providers finish loading.';
}

if (!notificationProviderAvailability.value.email) {
return 'Email alerts are unavailable because email notifications are disabled for this environment.';
}

return null;
});

const slackAlertUnavailableReason = computed(() => {
if (notificationProvidersLoading.value) {
return 'Slack alerts are unavailable until notification providers finish loading.';
}

if (!notificationProviderAvailability.value.slack) {
return 'Slack alerts are unavailable because Slack notifications are disabled for this environment.';
}

if (slackStatusLoading.value) {
return 'Slack alerts are unavailable until Slack link status finishes loading.';
}
Expand Down Expand Up @@ -598,14 +694,37 @@ const riskNotificationsTooltipText = computed(() =>
// Load user data and subscriptions
const loadUserData = async () => {
try {
const notificationProvidersRequest = axios
.get<{
data: Array<{
providerType?: string;
enabled?: boolean;
}>;
}>('/api/notifications/providers')
.catch((error) => {
console.error('Error loading notification providers:', error);
return null;
});

const [userResponse, subscriptionResponse, notificationProvidersResponse] =
await Promise.all([
axios.get<{ data: CCFUser }>('/api/users/me'),
axios.get<{ data: SubscriptionsPreferencesResponse }>(
'/api/users/me/subscriptions',
),
notificationProvidersRequest,
]);
Comment on lines +709 to +716
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

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

loadUserData() now uses Promise.all([... , axios.get('/api/notifications/providers')]), so any failure in the providers request will throw and set loadError, preventing the preferences page from loading even if /api/users/me and /api/users/me/subscriptions succeeded. To avoid making preferences availability depend on this auxiliary endpoint, consider fetching providers with Promise.allSettled or a nested try/catch and falling back to a safe default (e.g. keep channels enabled / keep existing selections) when the providers call fails.

Copilot uses AI. Check for mistakes.

// Get current user info
const userResponse = await axios.get<{ data: CCFUser }>('/api/users/me');
user.value = userResponse.data.data;

// Get subscriptions status
const subscriptionResponse = await axios.get<{
data: SubscriptionsPreferencesResponse;
}>('/api/users/me/subscriptions');
notificationProviderAvailability.value =
notificationProvidersResponse === null
? fallbackNotificationProviderAvailability()
: normalizeNotificationProviderAvailability(
notificationProvidersResponse.data.data,
);
notificationProvidersLoading.value = false;

const evidenceDigestChannels = normalizeNotificationChannels(
subscriptionResponse.data.data.notifications?.evidenceDigest,
Expand Down Expand Up @@ -640,12 +759,13 @@ const loadUserData = async () => {
},
};

await syncUnavailableSlackSelections();
await syncUnavailableNotificationSelections();
} catch (error) {
console.error('Error loading user data:', error);
loadError.value =
'Failed to load preferences. Please refresh the page to try again.';
} finally {
notificationProvidersLoading.value = false;
loading.value = false;
}
};
Expand Down Expand Up @@ -685,7 +805,7 @@ const onSlackLinkStatusChange = (state: SlackAvailabilityState) => {
};

watch([slackStatusLoading, canSelectSlackAlertChannel], () => {
void syncUnavailableSlackSelections();
void syncUnavailableNotificationSelections();
});

onMounted(() => {
Expand Down
17 changes: 16 additions & 1 deletion src/views/__tests__/LeftSideNav.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,29 @@ describe('LeftSideNav', () => {

const systemUsersIndex = linkTexts.indexOf('System Users');
const agentsIndex = linkTexts.indexOf('Agents');
const notificationsIndex = linkTexts.indexOf('Notifications');
const risksIndex = linkTexts.indexOf('Risks');
const subjectTemplatesIndex = linkTexts.indexOf('Subject Templates');
const riskTemplatesIndex = linkTexts.indexOf('Risk Templates');
const importIndex = linkTexts.indexOf('Import');

for (const index of [
systemUsersIndex,
agentsIndex,
notificationsIndex,
risksIndex,
subjectTemplatesIndex,
riskTemplatesIndex,
importIndex,
]) {
expect(index).toBeGreaterThanOrEqual(0);
}

expect(systemUsersIndex).toBeGreaterThanOrEqual(0);
expect(agentsIndex).toBeGreaterThan(systemUsersIndex);
expect(risksIndex).toBeGreaterThan(agentsIndex);
expect(subjectTemplatesIndex).toBeGreaterThan(risksIndex);
expect(riskTemplatesIndex).toBeGreaterThan(subjectTemplatesIndex);
expect(notificationsIndex).toBeGreaterThan(riskTemplatesIndex);
expect(importIndex).toBeGreaterThan(notificationsIndex);
Comment on lines 46 to +71
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

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

This ordering test can produce false positives because indexOf() returns -1 for missing links, and several indices (e.g. notificationsIndex, risksIndex, importIndex) are never asserted to be >= 0. Consider asserting existence for each expected link, and re-adding an explicit ordering check between Agents and Risks (it was removed), so the test actually validates the full admin section ordering.

Copilot uses AI. Check for mistakes.
});
});
Loading
Loading