diff --git a/.gitignore b/.gitignore index 52b9121c40..77910f7a75 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ ladle-build result/ .aider* .env +.zellij_layout.kdl diff --git a/crates/defguard_core/src/enterprise/handlers/openid_providers.rs b/crates/defguard_core/src/enterprise/handlers/openid_providers.rs index c80ef757e8..0d215509aa 100644 --- a/crates/defguard_core/src/enterprise/handlers/openid_providers.rs +++ b/crates/defguard_core/src/enterprise/handlers/openid_providers.rs @@ -328,11 +328,83 @@ pub(crate) async fn modify_openid_provider( let mut transaction = appstate.pool.begin().await?; let provider = OpenIdProvider::find_by_name(&mut *transaction, &provider_data.name).await?; if let Some(mut provider) = provider { + let private_key = match &provider_data.google_service_account_key { + Some(key) => { + if RsaPrivateKey::from_pkcs8_pem(key).is_ok() { + debug!( + "User {} provided a valid RSA private key for provider's directory sync. Using it.", + session.user.username + ); + provider_data.google_service_account_key.clone() + } else { + debug!( + "User {} did not provide a valid RSA private key for provider's directory sync or the key did not change. Using the existing key", + session.user.username + ); + provider.google_service_account_key.clone() + } + } + None => provider.google_service_account_key.clone(), + }; + + let okta_private_jwk = match &provider_data.okta_private_jwk { + Some(key) => { + if serde_json::from_str::(key).is_ok() { + debug!( + "User {} provided a valid JWK private key for provider's Okta directory sync. Using it.", + session.user.username + ); + provider_data.okta_private_jwk.clone() + } else { + debug!( + "User {} did not provide a valid JWK private key for provider's Okta directory sync or the key did not change. Using the existing key.", + session.user.username + ); + provider.okta_private_jwk.clone() + } + } + None => provider.okta_private_jwk.clone(), + }; + + let mut settings = Settings::get_current_settings(); + settings.openid_create_account = provider_data.create_account; + settings.openid_username_handling = provider_data.username_handling; + update_current_settings(&appstate.pool, settings).await?; + + let group_match = if let Some(group_match) = provider_data.directory_sync_group_match { + if group_match.is_empty() { + Vec::new() + } else { + group_match + .split(',') + .map(|s| s.trim().to_string()) + .collect() + } + } else { + Vec::new() + }; + provider.base_url = provider_data.base_url; provider.kind = provider_data.kind; provider.client_id = provider_data.client_id; provider.client_secret = provider_data.client_secret; + provider.display_name = provider_data.display_name; + provider.google_service_account_key = private_key; + provider.google_service_account_email = provider_data.google_service_account_email; + provider.admin_email = provider_data.admin_email; + provider.directory_sync_enabled = provider_data.directory_sync_enabled; + provider.directory_sync_interval = provider_data.directory_sync_interval; + provider.directory_sync_user_behavior = provider_data.directory_sync_user_behavior.into(); + provider.directory_sync_admin_behavior = provider_data.directory_sync_admin_behavior.into(); + provider.directory_sync_target = provider_data.directory_sync_target.into(); + provider.okta_private_jwk = okta_private_jwk; + provider.okta_dirsync_client_id = provider_data.okta_dirsync_client_id; + provider.directory_sync_group_match = group_match; + provider.jumpcloud_api_key = provider_data.jumpcloud_api_key; + provider.prefetch_users = provider_data.prefetch_users; provider.save(&mut *transaction).await?; + transaction.commit().await?; + info!( "User {} modified OpenID client {}", session.user.username, provider.name @@ -372,6 +444,45 @@ pub(crate) async fn list_openid_providers( Ok(ApiResponse::json(providers, StatusCode::OK)) } +/// Get current OpenID provider. +/// +/// # Returns +/// - HTTP Status "OK" on success. +#[utoipa::path( + get, + path = "/api/v1/openid/provider/current", + tag = "OpenID", + responses( + (status = OK, description = "Get current OpenID provider"), + ), + params( + ("name" = String, Path, description = "The name of a provider",) + ) +)] +pub(crate) async fn get_current_openid_provider( + _admin: AdminRole, + State(appstate): State, +) -> ApiResult { + let settings = Settings::get_current_settings(); + let settings_json = json!({"create_account": settings.openid_create_account, + "username_handling": settings.openid_username_handling}); + match OpenIdProvider::get_current(&appstate.pool).await? { + Some(mut provider) => { + // Get rid of it, it should stay on the backend only. + provider.google_service_account_key = None; + provider.okta_private_jwk = None; + Ok(ApiResponse::new( + json!({"provider": provider, "settings": settings_json}), + StatusCode::OK, + )) + } + None => Ok(ApiResponse::new( + json!({"provider": null, "settings": settings_json}), + StatusCode::NO_CONTENT, + )), + } +} + pub(crate) async fn test_dirsync_connection( _license: LicenseInfo, _admin: AdminRole, diff --git a/crates/defguard_core/src/lib.rs b/crates/defguard_core/src/lib.rs index bc6ae24ca6..083e88e9ec 100644 --- a/crates/defguard_core/src/lib.rs +++ b/crates/defguard_core/src/lib.rs @@ -99,8 +99,9 @@ use crate::{ enterprise_settings::{get_enterprise_settings, patch_enterprise_settings}, openid_login::{auth_callback, get_auth_info}, openid_providers::{ - add_openid_provider, delete_openid_provider, get_openid_provider, - list_openid_providers, modify_openid_provider, test_dirsync_connection, + add_openid_provider, delete_openid_provider, get_current_openid_provider, + get_openid_provider, list_openid_providers, modify_openid_provider, + test_dirsync_connection, }, }, snat::handlers::{ @@ -394,6 +395,7 @@ pub fn build_webapp( .put(modify_openid_provider) .delete(delete_openid_provider), ) + .route("/provider/current", get(get_current_openid_provider)) .route("/callback", post(auth_callback)) .route("/auth_info", get(get_auth_info)), ); diff --git a/web/src/pages/AddExternalOpenIdWizardPage/steps/AddExternalOpenIdDirectoryStep/forms/MicrosoftProviderForm.tsx b/web/src/pages/AddExternalOpenIdWizardPage/steps/AddExternalOpenIdDirectoryStep/forms/MicrosoftProviderForm.tsx index 2b3fb8c9bb..aea2f0a601 100644 --- a/web/src/pages/AddExternalOpenIdWizardPage/steps/AddExternalOpenIdDirectoryStep/forms/MicrosoftProviderForm.tsx +++ b/web/src/pages/AddExternalOpenIdWizardPage/steps/AddExternalOpenIdDirectoryStep/forms/MicrosoftProviderForm.tsx @@ -6,6 +6,7 @@ import { SizedBox } from '../../../../../shared/defguard-ui/components/SizedBox/ import { ThemeSpacing } from '../../../../../shared/defguard-ui/types'; import { useAppForm } from '../../../../../shared/form'; import { formChangeLogic } from '../../../../../shared/formLogic'; +import { joinCsv } from '../../../../../shared/utils/csv'; import { directorySyncBehaviorOptions, directorySyncTargetOptions, @@ -29,7 +30,7 @@ export const MicrosoftProviderForm = ({ onSubmit }: ProviderFormProps) => { const defaultValues = useMemo( (): FormFields => ({ directory_sync_admin_behavior: providerState.directory_sync_admin_behavior, - directory_sync_group_match: providerState.directory_sync_group_match ?? null, + directory_sync_group_match: joinCsv(providerState.directory_sync_group_match), directory_sync_interval: providerState.directory_sync_interval, directory_sync_target: providerState.directory_sync_target, directory_sync_user_behavior: providerState.directory_sync_user_behavior, @@ -46,7 +47,10 @@ export const MicrosoftProviderForm = ({ onSubmit }: ProviderFormProps) => { onChange: microsoftProviderSyncSchema, }, onSubmit: async ({ value }) => { - await onSubmit(value); + await onSubmit({ + ...value, + directory_sync_group_match: value.directory_sync_group_match ?? '', + }); }, }); @@ -113,10 +117,18 @@ export const MicrosoftProviderForm = ({ onSubmit }: ProviderFormProps) => { { - back(form.state.values); + back({ + ...form.state.values, + directory_sync_group_match: + form.state.values.directory_sync_group_match ?? '', + }); }} onNext={() => { - mutate(form.state.values); + mutate({ + ...form.state.values, + directory_sync_group_match: + form.state.values.directory_sync_group_match ?? '', + }); }} /> diff --git a/web/src/pages/AddExternalOpenIdWizardPage/steps/AddExternalOpenIdDirectoryStep/forms/schemas.ts b/web/src/pages/AddExternalOpenIdWizardPage/steps/AddExternalOpenIdDirectoryStep/forms/schemas.ts index f33ee3ea63..cbd825a805 100644 --- a/web/src/pages/AddExternalOpenIdWizardPage/steps/AddExternalOpenIdDirectoryStep/forms/schemas.ts +++ b/web/src/pages/AddExternalOpenIdWizardPage/steps/AddExternalOpenIdDirectoryStep/forms/schemas.ts @@ -33,7 +33,7 @@ export const googleProviderSyncSchema = baseExternalProviderSyncSchema.extend({ export const microsoftProviderSyncSchema = baseExternalProviderSyncSchema.extend({ prefetch_users: z.boolean(), - directory_sync_group_match: z.string().trim().nullable(), + directory_sync_group_match: z.string().trim(), }); export const oktaProviderSyncSchema = baseExternalProviderSyncSchema.extend({ diff --git a/web/src/pages/AddExternalOpenIdWizardPage/useAddExternalOpenIdStore.tsx b/web/src/pages/AddExternalOpenIdWizardPage/useAddExternalOpenIdStore.tsx index 8492d444e9..0a13af7ab0 100644 --- a/web/src/pages/AddExternalOpenIdWizardPage/useAddExternalOpenIdStore.tsx +++ b/web/src/pages/AddExternalOpenIdWizardPage/useAddExternalOpenIdStore.tsx @@ -118,7 +118,7 @@ export const useAddExternalOpenIdStore = create()( }); }, initialize: (provider) => { - const initialProviderState = addExternalOpenIdStoreDefaults.providerState; + const initialProviderState = { ...addExternalOpenIdStoreDefaults.providerState }; initialProviderState.name = provider; initialProviderState.kind = provider; if (provider !== OpenIdProviderKind.Custom) { @@ -137,6 +137,7 @@ export const useAddExternalOpenIdStore = create()( break; } set({ + provider, activeStep: 'client-settings', providerState: initialProviderState, }); diff --git a/web/src/pages/AliasesPage/AliasesPage.tsx b/web/src/pages/AliasesPage/AliasesPage.tsx index 4f0480a87b..35a2a2440e 100644 --- a/web/src/pages/AliasesPage/AliasesPage.tsx +++ b/web/src/pages/AliasesPage/AliasesPage.tsx @@ -4,6 +4,7 @@ import { Suspense, useMemo, useState } from 'react'; import { AclDeploymentState, type AclDeploymentStateValue } from '../../shared/api/types'; import { Page } from '../../shared/components/Page/Page'; import { TableSkeleton } from '../../shared/components/skeleton/TableSkeleton/TableSkeleton'; +import { IconKind } from '../../shared/defguard-ui/components/Icon'; import { Tabs } from '../../shared/defguard-ui/components/Tabs/Tabs'; import type { TabsItem } from '../../shared/defguard-ui/components/Tabs/types'; import { TablePageLayout } from '../../shared/layout/TablePageLayout/TablePageLayout'; @@ -18,6 +19,10 @@ export const AliasesPage = () => { AclDeploymentState.Applied, ); + const pendingCount = aliasesCount?.pending ?? 0; + const pendingTitle = pendingCount ? `Pending (${pendingCount})` : 'Pending'; + const pendingIcon = pendingCount > 0 ? IconKind.AttentionFilled : undefined; + const tabs = useMemo( (): TabsItem[] => [ { @@ -32,10 +37,11 @@ export const AliasesPage = () => { onClick: () => { setActiveTab(AclDeploymentState.Modified); }, - title: aliasesCount?.pending ? `Pending (${aliasesCount.pending})` : 'Pending', + title: pendingTitle, + icon: pendingIcon, }, ], - [activeTab, aliasesCount], + [activeTab, pendingIcon, pendingTitle], ); return ( diff --git a/web/src/pages/CEAliasPage/CEAliasPage.tsx b/web/src/pages/CEAliasPage/CEAliasPage.tsx index e131d36225..7202bcd0ff 100644 --- a/web/src/pages/CEAliasPage/CEAliasPage.tsx +++ b/web/src/pages/CEAliasPage/CEAliasPage.tsx @@ -20,6 +20,7 @@ import { Button } from '../../shared/defguard-ui/components/Button/Button'; import { Divider } from '../../shared/defguard-ui/components/Divider/Divider'; import { MarkedSection } from '../../shared/defguard-ui/components/MarkedSection/MarkedSection'; import { SizedBox } from '../../shared/defguard-ui/components/SizedBox/SizedBox'; +import { Snackbar } from '../../shared/defguard-ui/providers/snackbar/snackbar'; import { TooltipContent } from '../../shared/defguard-ui/providers/tooltip/TooltipContent'; import { TooltipProvider } from '../../shared/defguard-ui/providers/tooltip/TooltipContext'; import { TooltipTrigger } from '../../shared/defguard-ui/providers/tooltip/TooltipTrigger'; @@ -141,6 +142,7 @@ const FormContent = ({ alias }: { alias?: AclAlias }) => { } else { await addAlias(toSend); } + Snackbar.default('Aliases added to Pending tab and awaiting deployment.'); router.history.back(); }, }); diff --git a/web/src/pages/CEDestinationPage/CEDestinationPage.tsx b/web/src/pages/CEDestinationPage/CEDestinationPage.tsx index d044f0443a..8579c95b0a 100644 --- a/web/src/pages/CEDestinationPage/CEDestinationPage.tsx +++ b/web/src/pages/CEDestinationPage/CEDestinationPage.tsx @@ -80,7 +80,7 @@ export const CEDestinationPage = ({ destination }: Props) => { const { mutateAsync: addDestination } = useMutation({ mutationFn: api.acl.destination.addDestination, onSuccess: () => { - Snackbar.success('Destination added'); + Snackbar.default('Destinations added to Pending tab and awaiting deployment.'); }, onError: (e) => { Snackbar.error('Error occurred'); @@ -94,7 +94,7 @@ export const CEDestinationPage = ({ destination }: Props) => { const { mutateAsync: editDestination } = useMutation({ mutationFn: api.acl.destination.editDestination, onSuccess: () => { - Snackbar.success('Destination modified'); + Snackbar.default('Destinations added to Pending tab and awaiting deployment.'); }, onError: (e) => { Snackbar.error('Error occurred'); diff --git a/web/src/pages/CERulePage/CERulePage.tsx b/web/src/pages/CERulePage/CERulePage.tsx index 526e187ccc..1685ad6160 100644 --- a/web/src/pages/CERulePage/CERulePage.tsx +++ b/web/src/pages/CERulePage/CERulePage.tsx @@ -151,7 +151,7 @@ const Content = ({ rule: initialRule }: Props) => { invalidate: ['acl'], }, onSuccess: () => { - Snackbar.success('Rule added'); + Snackbar.default('Rules added to Pending tab and awaiting deployment.'); router.history.back(); }, }); @@ -162,7 +162,7 @@ const Content = ({ rule: initialRule }: Props) => { invalidate: ['acl'], }, onSuccess: () => { - Snackbar.success('Rule changed'); + Snackbar.default('Rules added to Pending tab and awaiting deployment.'); router.history.back(); }, }); diff --git a/web/src/pages/DestinationsPage/DestinationsPage.tsx b/web/src/pages/DestinationsPage/DestinationsPage.tsx index fc6ccbfd7c..7ffb0ff54c 100644 --- a/web/src/pages/DestinationsPage/DestinationsPage.tsx +++ b/web/src/pages/DestinationsPage/DestinationsPage.tsx @@ -3,6 +3,7 @@ import { Suspense, useMemo, useState } from 'react'; import { AclDeploymentState, type AclDeploymentStateValue } from '../../shared/api/types'; import { Page } from '../../shared/components/Page/Page'; import { TableSkeleton } from '../../shared/components/skeleton/TableSkeleton/TableSkeleton'; +import { IconKind } from '../../shared/defguard-ui/components/Icon'; import { Tabs } from '../../shared/defguard-ui/components/Tabs/Tabs'; import type { TabsItem } from '../../shared/defguard-ui/components/Tabs/types'; import { TablePageLayout } from '../../shared/layout/TablePageLayout/TablePageLayout'; @@ -17,6 +18,10 @@ export const DestinationsPage = () => { AclDeploymentState.Applied, ); + const pendingCount = destinationsCount?.pending ?? 0; + const pendingTitle = pendingCount ? `Pending (${pendingCount})` : 'Pending'; + const pendingIcon = pendingCount > 0 ? IconKind.AttentionFilled : undefined; + const tabs = useMemo( (): TabsItem[] => [ { @@ -31,12 +36,11 @@ export const DestinationsPage = () => { onClick: () => { setActiveTab(AclDeploymentState.Modified); }, - title: destinationsCount?.pending - ? `Pending (${destinationsCount.pending})` - : 'Pending', + title: pendingTitle, + icon: pendingIcon, }, ], - [activeTab, destinationsCount], + [activeTab, pendingIcon, pendingTitle], ); return ( diff --git a/web/src/pages/RulesPage/RulesPage.tsx b/web/src/pages/RulesPage/RulesPage.tsx index a4024ac889..80c7330a5e 100644 --- a/web/src/pages/RulesPage/RulesPage.tsx +++ b/web/src/pages/RulesPage/RulesPage.tsx @@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query'; import { Suspense, useMemo, useState } from 'react'; import { Page } from '../../shared/components/Page/Page'; import { TableSkeleton } from '../../shared/components/skeleton/TableSkeleton/TableSkeleton'; +import { IconKind } from '../../shared/defguard-ui/components/Icon'; import { SizedBox } from '../../shared/defguard-ui/components/SizedBox/SizedBox'; import { Tabs } from '../../shared/defguard-ui/components/Tabs/Tabs'; import type { TabsItem } from '../../shared/defguard-ui/components/Tabs/types'; @@ -17,10 +18,12 @@ export const RulesPage = () => { const { data: rulesCount } = useQuery(getRulesCountQueryOptions); + const pendingCount = rulesCount?.pending ?? 0; const pendingTabTitle = useMemo( - () => `Pending${rulesCount?.pending ? ` (${rulesCount.pending})` : ''}`, - [rulesCount], + () => `Pending${pendingCount ? ` (${pendingCount})` : ''}`, + [pendingCount], ); + const pendingIcon = pendingCount > 0 ? IconKind.AttentionFilled : undefined; const tabs = useMemo( (): TabsItem[] => [ @@ -33,13 +36,14 @@ export const RulesPage = () => { }, { title: pendingTabTitle, + icon: pendingIcon, active: activeTab === RulesPageTab.Pending, onClick: () => { setActiveTab(RulesPageTab.Pending); }, }, ], - [activeTab, pendingTabTitle], + [activeTab, pendingIcon, pendingTabTitle], ); return ( diff --git a/web/src/pages/settings/SettingsEditOpenIdProviderPage/SettingsEditOpenIdProviderPage.tsx b/web/src/pages/settings/SettingsEditOpenIdProviderPage/SettingsEditOpenIdProviderPage.tsx index 12fd111fb0..c953189cac 100644 --- a/web/src/pages/settings/SettingsEditOpenIdProviderPage/SettingsEditOpenIdProviderPage.tsx +++ b/web/src/pages/settings/SettingsEditOpenIdProviderPage/SettingsEditOpenIdProviderPage.tsx @@ -6,6 +6,7 @@ import { type AddOpenIdProvider, OpenIdProviderKind } from '../../../shared/api/ import { EditPage } from '../../../shared/components/EditPage/EditPage'; import { isPresent } from '../../../shared/defguard-ui/utils/isPresent'; import { getExternalProviderQueryOptions } from '../../../shared/query'; +import { joinCsv } from '../../../shared/utils/csv'; import { EditCustomProviderForm } from './form/EditCustomProviderForm'; import { EditGoogleProviderForm } from './form/EditGoogleProviderForm'; import { EditJumpCloudProviderForm } from './form/EditJumpCloudProviderForm'; @@ -54,7 +55,11 @@ export const SettingsEditOpenIdProviderPage = () => { const handleSubmit = useCallback( async (values: Partial) => { if (isPresent(formData)) { - await mutateAsync({ ...formData, ...values }); + const normalizedFormData = { + ...formData, + directory_sync_group_match: joinCsv(formData.directory_sync_group_match), + }; + await mutateAsync({ ...normalizedFormData, ...values }); } }, [formData, mutateAsync], diff --git a/web/src/pages/settings/SettingsEditOpenIdProviderPage/form/EditMicrosoftProviderForm.tsx b/web/src/pages/settings/SettingsEditOpenIdProviderPage/form/EditMicrosoftProviderForm.tsx index 7e71aee422..402e28919d 100644 --- a/web/src/pages/settings/SettingsEditOpenIdProviderPage/form/EditMicrosoftProviderForm.tsx +++ b/web/src/pages/settings/SettingsEditOpenIdProviderPage/form/EditMicrosoftProviderForm.tsx @@ -9,6 +9,7 @@ import { SizedBox } from '../../../../shared/defguard-ui/components/SizedBox/Siz import { ThemeSpacing } from '../../../../shared/defguard-ui/types'; import { useAppForm } from '../../../../shared/form'; import { formChangeLogic } from '../../../../shared/formLogic'; +import { joinCsv } from '../../../../shared/utils/csv'; import { directorySyncBehaviorOptions, directorySyncTargetOptions, @@ -28,6 +29,7 @@ const basicSchema = z .string(m.form_error_required()) .trim() .min(1, m.form_error_required()), + directory_sync_group_match: z.string().trim().optional(), }) .extend(omit(baseExternalProviderConfigSchema.shape, ['base_url'])); @@ -47,7 +49,8 @@ export const EditMicrosoftProviderForm = ({ onSubmit, }: EditProviderFormProps) => { const defaultValues = useMemo((): FormFields => { - const tenantId = provider.base_url.split('/')[provider.base_url.length - 2]; + const urlParts = provider.base_url.split('/'); + const tenantId = urlParts[urlParts.length - 2] ?? ''; return { client_id: provider.client_id, client_secret: provider.client_secret, @@ -59,7 +62,14 @@ export const EditMicrosoftProviderForm = ({ directory_sync_target: provider.directory_sync_target, directory_sync_user_behavior: provider.directory_sync_user_behavior, directory_sync_enabled: provider.directory_sync_enabled, - directory_sync_group_match: provider.directory_sync_group_match ?? '', + prefetch_users: provider.prefetch_users ?? false, + directory_sync_group_match: joinCsv( + Array.isArray(provider.directory_sync_group_match) + ? provider.directory_sync_group_match + : provider.directory_sync_group_match + ? [provider.directory_sync_group_match] + : null, + ), microsoftTenantId: tenantId, }; }, [provider]); @@ -73,7 +83,11 @@ export const EditMicrosoftProviderForm = ({ }, onSubmit: async ({ value }) => { const base_url = formatMicrosoftBaseUrl(value.microsoftTenantId); - await onSubmit({ ...value, base_url }); + await onSubmit({ + ...value, + base_url, + directory_sync_group_match: value.directory_sync_group_match ?? '', + }); }, }); diff --git a/web/src/shared/api/api.ts b/web/src/shared/api/api.ts index b0aec85709..02d56840b8 100644 --- a/web/src/shared/api/api.ts +++ b/web/src/shared/api/api.ts @@ -424,7 +424,8 @@ const api = { getLdapConnectionStatus: () => client.get(`/ldap/test`), }, openIdProvider: { - getOpenIdProvider: () => client.get('/openid/provider'), + getOpenIdProvider: () => + client.get('/openid/provider/current'), addOpenIdProvider: (data: AddOpenIdProvider) => client.post('/openid/provider', data), deleteOpenIdProvider: (name: string) => client.delete(`/openid/provider/${name}`), editOpenIdProvider: (data: AddOpenIdProvider) => diff --git a/web/src/shared/api/types.ts b/web/src/shared/api/types.ts index 4cd865a130..d5b788419f 100644 --- a/web/src/shared/api/types.ts +++ b/web/src/shared/api/types.ts @@ -876,7 +876,7 @@ export interface OpenIdProvider { directory_sync_target: DirectorySyncTargetValue; okta_private_jwk?: string | null; okta_dirsync_client_id?: string | null; - directory_sync_group_match?: string | null; + directory_sync_group_match?: string[] | null; jumpcloud_api_key?: string | null; prefetch_users: boolean; } @@ -888,7 +888,13 @@ export interface OpenIdProviders { export type OpenIdProvidersResponse = OpenIdProviders | undefined; -export type AddOpenIdProvider = Omit & OpenIdProviderSettings; +export type AddOpenIdProvider = Omit< + OpenIdProvider, + 'id' | 'directory_sync_group_match' +> & + OpenIdProviderSettings & { + directory_sync_group_match?: string | null; + }; export interface TestDirectorySyncResponse { success: boolean; diff --git a/web/src/shared/components/Navigation/Navigation.tsx b/web/src/shared/components/Navigation/Navigation.tsx index 68651c06fd..327f1d74b7 100644 --- a/web/src/shared/components/Navigation/Navigation.tsx +++ b/web/src/shared/components/Navigation/Navigation.tsx @@ -1,5 +1,6 @@ import { useMemo, useState } from 'react'; import { m } from '../../../paraglide/messages'; +import { CounterLabel } from '../../defguard-ui/components/CounterLabel/CounterLabel'; import { Icon, IconKind } from '../../defguard-ui/components/Icon'; import type { IconKindValue } from '../../defguard-ui/components/Icon/icon-types'; import { IconButton } from '../../defguard-ui/components/IconButton/IconButton'; @@ -16,7 +17,12 @@ import { TooltipProvider } from '../../defguard-ui/providers/tooltip/TooltipCont import { TooltipTrigger } from '../../defguard-ui/providers/tooltip/TooltipTrigger'; import { isPresent } from '../../defguard-ui/utils/isPresent'; import { useTheme } from '../../hooks/theme/useTheme'; -import { getLicenseInfoQueryOptions } from '../../query'; +import { + getAliasesCountQueryOptions, + getDestinationsCountQueryOptions, + getLicenseInfoQueryOptions, + getRulesCountQueryOptions, +} from '../../query'; import { canUseBusinessFeature } from '../../utils/license'; interface NavGroupProps { @@ -34,6 +40,7 @@ interface NavItemProps { licenseTier?: LicenseTierValue; license?: LicenseInfo | null; testId?: string; + pendingCount?: number; } const navigationConfig: NavGroupProps[] = [ @@ -160,6 +167,37 @@ export const Navigation = () => { enabled: isAdmin, }); + const { data: rulesCount } = useQuery({ + ...getRulesCountQueryOptions, + enabled: isAdmin, + }); + + const { data: destinationsCount } = useQuery({ + ...getDestinationsCountQueryOptions, + enabled: isAdmin, + }); + + const { data: aliasesCount } = useQuery({ + ...getAliasesCountQueryOptions, + enabled: isAdmin, + }); + + const navigationGroups = useMemo(() => { + const pendingCounts = { + rules: rulesCount?.pending, + destinations: destinationsCount?.pending, + aliases: aliasesCount?.pending, + }; + + return navigationConfig.map((group) => ({ + ...group, + items: group.items.map((item) => ({ + ...item, + pendingCount: pendingCounts[item.id as keyof typeof pendingCounts], + })), + })); + }, [aliasesCount, destinationsCount, rulesCount]); + if (!isAdmin || !isOpen) return null; return (
@@ -177,7 +215,7 @@ export const Navigation = () => {
- {navigationConfig.map((group) => ( + {navigationGroups.map((group) => ( ))}
@@ -212,7 +250,15 @@ const NavGroup = ({ items, label, licenseInfo }: NavGroupProps) => { ); }; -const NavItem = ({ icon, link, label, testId, license, licenseTier }: NavItemProps) => { +const NavItem = ({ + icon, + link, + label, + testId, + license, + licenseTier, + pendingCount, +}: NavItemProps) => { const showLock = useMemo(() => { if (licenseTier === undefined) { return isPresent(licenseTier); @@ -229,20 +275,26 @@ const NavItem = ({ icon, link, label, testId, license, licenseTier }: NavItemPro return false; }, [license, licenseTier]); + const showPending = isPresent(pendingCount) && pendingCount > 0; + const showRight = showPending || (showLock && isPresent(licenseTier)); + return ( {label} - {showLock && isPresent(licenseTier) && ( + {showRight && (
- - - - - -

{`This is ${licenseTier ?? 'Unknown tier'} feature`}

-
-
+ {showPending && } + {showLock && isPresent(licenseTier) && ( + + + + + +

{`This is ${licenseTier ?? 'Unknown tier'} feature`}

+
+
+ )}
)} diff --git a/web/src/shared/components/Navigation/style.scss b/web/src/shared/components/Navigation/style.scss index 3ccd7eb55e..5b89b3c5f2 100644 --- a/web/src/shared/components/Navigation/style.scss +++ b/web/src/shared/components/Navigation/style.scss @@ -121,7 +121,7 @@ @include animate(fill); } - span { + & > span { font: var(--t-body-sm-500); color: inherit; } @@ -132,5 +132,6 @@ flex-flow: column; align-items: center; justify-content: center; + row-gap: var(--spacing-xxs); } } diff --git a/web/src/shared/defguard-ui b/web/src/shared/defguard-ui index c99a4b62dc..9324251835 160000 --- a/web/src/shared/defguard-ui +++ b/web/src/shared/defguard-ui @@ -1 +1 @@ -Subproject commit c99a4b62dca79d866666969d8ac5f2471be8219d +Subproject commit 9324251835b78599d3c1f8313b58d81485108dd4 diff --git a/web/src/shared/utils/csv.ts b/web/src/shared/utils/csv.ts new file mode 100644 index 0000000000..f36d27f7d9 --- /dev/null +++ b/web/src/shared/utils/csv.ts @@ -0,0 +1,13 @@ +export const joinCsv = (values?: string[] | string | null): string => { + if (!values) return ''; + if (typeof values === 'string') return values; + if (values.length === 0) return ''; + return values.join(', '); +}; + +export const splitCsv = (value: string): string[] => { + return value + .split(',') + .map((item) => item.trim()) + .filter((item) => item.length > 0); +};