diff --git a/frontend/src/lib/components/actions-menu.svelte b/frontend/src/lib/components/actions-menu.svelte new file mode 100644 index 00000000..5db8302d --- /dev/null +++ b/frontend/src/lib/components/actions-menu.svelte @@ -0,0 +1,51 @@ + + +{#if visibleActions.length > 0} + + + + {label} + + + {#each visibleActions as action (action.label)} + action.onClick(item)} + disabled={action.disabled} + variant={action.variant === 'danger' ? 'destructive' : 'default'} + > + {#if action.icon} + {@const Icon = action.icon} + + {/if} + {action.label} + + {/each} + + +{/if} diff --git a/frontend/src/lib/components/table/advanced-table.svelte b/frontend/src/lib/components/table/advanced-table.svelte index 3978051a..0e3793de 100644 --- a/frontend/src/lib/components/table/advanced-table.svelte +++ b/frontend/src/lib/components/table/advanced-table.svelte @@ -1,6 +1,7 @@ + import { invalidateAll } from '$app/navigation'; + import ActionsMenu from '#lib/components/actions-menu.svelte'; import FormattedMessage from '#lib/components/formatted-message.svelte'; import * as Alert from '#lib/components/ui/alert/index.ts'; import { Badge } from '#lib/components/ui/badge/index.ts'; @@ -9,6 +11,7 @@ import OidcService from '#lib/services/oidc-service.ts'; import ScimService from '#lib/services/scim-service.ts'; import clientSecretStore from '#lib/stores/client-secret-store.ts'; + import unsavedChanges from '#lib/stores/unsaved-changes-store.svelte.ts'; import type { OidcClientCreateWithLogo, OidcClientCredentials, @@ -21,6 +24,7 @@ import { LucideChevronLeft, LucideEye, LucideInfo } from '@lucide/svelte'; import { onDestroy } from 'svelte'; import { backNavigate } from '../../users/navigate-back-util'; + import { deleteClientAction, refreshClientAction } from '../oidc-client-actions'; import OidcForm from '../oidc-client-form.svelte'; import OidcClientPreviewModal from '../oidc-client-preview-modal.svelte'; import ApiAccessCard from './api-access-card.svelte'; @@ -41,6 +45,8 @@ let scimServiceProvider = $state(data.scimServiceProvider); let showPreview = $state(false); + // Bumped after the client was reloaded so the forms, which only read the client on mount, pick up the new values + let reloadCount = $state(0); const credentialCount = $derived( clientSecrets.length + (client.credentials?.federatedIdentities?.length ?? 0) @@ -50,6 +56,25 @@ const scimService = new ScimService(); const backNavigation = backNavigate('/settings/admin/oidc-clients'); + const actions = $derived([ + refreshClientAction(client, reloadClient), + deleteClientAction(backNavigation.leave) + ]); + + async function reloadClient() { + // The refreshed metadata replaces the fields it manages, so pending edits to them would be stale + unsavedChanges.discardAll(); + await invalidateAll(); + + client = { + ...data.client, + allowedUserGroupIds: data.client.allowedUserGroups.map((g) => g.id) + }; + clientSecrets = data.client.credentials?.secrets ?? []; + scimServiceProvider = data.scimServiceProvider; + reloadCount++; + } + async function updateClient(updatedClient: OidcClientCreateWithLogo) { const dataPromise = oidcService.updateClient(client.id, updatedClient); const imagePromise = @@ -138,69 +163,74 @@ - +
+ + +
- -
- - {m.general()} - - {m.access()} - {#if client.isGroupRestricted && client.allowedUserGroupIds.length === 0} - - {/if} - - - {m.credentials()} - - {credentialCount} - - - {m.scim_provisioning()} - -
+{#key reloadCount} + +
+ + {m.general()} + + {m.access()} + {#if client.isGroupRestricted && client.allowedUserGroupIds.length === 0} + + {/if} + + + {m.credentials()} + + {credentialCount} + + + {m.scim_provisioning()} + +
- - - - - + + + + + - - - - + + + + - - - - + + + + - - - - {m.scim_provisioning()} - - - - - - - - - -
+ + + + {m.scim_provisioning()} + + + + + + + + + +
+{/key} diff --git a/frontend/src/routes/settings/admin/oidc-clients/[id]/oidc-client-secrets-card.svelte b/frontend/src/routes/settings/admin/oidc-clients/[id]/oidc-client-secrets-card.svelte index 5c08d1ca..8fd24349 100644 --- a/frontend/src/routes/settings/admin/oidc-clients/[id]/oidc-client-secrets-card.svelte +++ b/frontend/src/routes/settings/admin/oidc-clients/[id]/oidc-client-secrets-card.svelte @@ -150,7 +150,7 @@ {m.toggle_menu()} - deleteSecret(secret)}> + deleteSecret(secret)}> {m.delete()} diff --git a/frontend/src/routes/settings/admin/oidc-clients/oidc-client-actions.ts b/frontend/src/routes/settings/admin/oidc-clients/oidc-client-actions.ts new file mode 100644 index 00000000..e08f260a --- /dev/null +++ b/frontend/src/routes/settings/admin/oidc-clients/oidc-client-actions.ts @@ -0,0 +1,59 @@ +import { openConfirmDialog } from '#lib/components/confirm-dialog/index.ts'; +import { m } from '#lib/paraglide/messages.js'; +import OIDCService from '#lib/services/oidc-service.ts'; +import type { AdvancedTableAction } from '#lib/types/advanced-table.type.ts'; +import type { OidcClient } from '#lib/types/oidc.type.ts'; +import { axiosErrorToast } from '#lib/utils/error-util.ts'; +import { LucideRefreshCcw, LucideTrash } from '@lucide/svelte'; +import { toast } from 'svelte-sonner'; + +const oidcService = new OIDCService(); + +export function refreshClientAction( + client: T, + onRefreshed: () => unknown +): AdvancedTableAction { + return { + label: m.refresh(), + icon: LucideRefreshCcw, + // Only metadata document clients have a remote document to refresh from + hidden: client.clientType !== 'cimd', + onClick: async (client) => { + try { + await oidcService.refreshClient(client.id); + await onRefreshed(); + toast.success(m.oidc_client_metadata_refreshed_successfully()); + } catch (e) { + axiosErrorToast(e); + } + } + }; +} + +export function deleteClientAction( + onDeleted: () => unknown +): AdvancedTableAction { + return { + label: m.delete(), + icon: LucideTrash, + variant: 'danger', + onClick: (client) => + openConfirmDialog({ + title: m.delete_name({ name: client.name }), + message: m.are_you_sure_you_want_to_delete_this_oidc_client(), + confirm: { + label: m.delete(), + destructive: true, + action: async () => { + try { + await oidcService.removeClient(client.id); + await onDeleted(); + toast.success(m.oidc_client_deleted_successfully()); + } catch (e) { + axiosErrorToast(e); + } + } + } + }) + }; +} diff --git a/frontend/src/routes/settings/admin/oidc-clients/oidc-client-list.svelte b/frontend/src/routes/settings/admin/oidc-clients/oidc-client-list.svelte index 335349fa..2a35692b 100644 --- a/frontend/src/routes/settings/admin/oidc-clients/oidc-client-list.svelte +++ b/frontend/src/routes/settings/admin/oidc-clients/oidc-client-list.svelte @@ -1,6 +1,5 @@ {#snippet AllowedGroupCountCell({ item }: { item: OidcClientWithAllowedGroups })} diff --git a/frontend/src/routes/settings/admin/user-groups/[id]/+page.svelte b/frontend/src/routes/settings/admin/user-groups/[id]/+page.svelte index bd7aeb84..9a76f887 100644 --- a/frontend/src/routes/settings/admin/user-groups/[id]/+page.svelte +++ b/frontend/src/routes/settings/admin/user-groups/[id]/+page.svelte @@ -1,4 +1,5 @@ {#snippet SourceCell({ item }: { item: UserGroupMinimal })} diff --git a/frontend/src/routes/settings/admin/users/[id]/+page.svelte b/frontend/src/routes/settings/admin/users/[id]/+page.svelte index 08934c1b..c83512cd 100644 --- a/frontend/src/routes/settings/admin/users/[id]/+page.svelte +++ b/frontend/src/routes/settings/admin/users/[id]/+page.svelte @@ -1,6 +1,8 @@ diff --git a/frontend/src/routes/settings/apps/authorized-oidc-client-card.svelte b/frontend/src/routes/settings/apps/authorized-oidc-client-card.svelte index efb90d4d..f28d282b 100644 --- a/frontend/src/routes/settings/apps/authorized-oidc-client-card.svelte +++ b/frontend/src/routes/settings/apps/authorized-oidc-client-card.svelte @@ -92,9 +92,7 @@ > {/if} {#if client.lastUsedAt} - onRevoke(client)} + onRevoke(client)} >{m.revoke()} {/if} diff --git a/tests/specs/api.spec.ts b/tests/specs/api.spec.ts index 0fcea653..b62af2f4 100644 --- a/tests/specs/api.spec.ts +++ b/tests/specs/api.spec.ts @@ -101,6 +101,18 @@ test('Delete an API', async ({ page }) => { await expect(page.getByRole('row', { name: apis.orders.name })).not.toBeVisible(); }); +test('Delete an API from the details page', async ({ page }) => { + await page.goto(`/settings/admin/apis/${apis.orders.id}`); + + await page.getByRole('button', { name: 'Actions' }).click(); + await page.getByRole('menuitem', { name: 'Delete' }).click(); + await page.getByRole('alertdialog').getByRole('button', { name: 'Delete' }).click(); + + await expect(page.locator('[data-type="success"]')).toHaveText('API deleted successfully'); + await page.waitForURL((url) => url.pathname === '/settings/admin/apis'); + await expect(page.getByRole('row', { name: apis.orders.name })).not.toBeVisible(); +}); + test('Grant a client user-delegated and client access to API permissions', async ({ page }) => { // Nextcloud has no API access granted by default await page.goto(`/settings/admin/oidc-clients/${oidcClients.nextcloud.id}`); diff --git a/tests/specs/oidc-client-settings.spec.ts b/tests/specs/oidc-client-settings.spec.ts index 9a4e36c0..0e15052c 100644 --- a/tests/specs/oidc-client-settings.spec.ts +++ b/tests/specs/oidc-client-settings.spec.ts @@ -716,6 +716,21 @@ test('Delete OIDC client', async ({ page }) => { await expect(page.getByRole('row', { name: oidcClient.name })).not.toBeVisible(); }); +test('Delete OIDC client from the details page', async ({ page }) => { + const oidcClient = oidcClients.nextcloud; + await page.goto(`/settings/admin/oidc-clients/${oidcClient.id}`); + + await page.getByRole('button', { name: 'Actions' }).click(); + await page.getByRole('menuitem', { name: 'Delete' }).click(); + await page.getByRole('button', { name: 'Delete' }).click(); + + await expect(page.locator('[data-type="success"]')).toHaveText( + 'OIDC client deleted successfully' + ); + await page.waitForURL((url) => url.pathname === '/settings/admin/oidc-clients'); + await expect(page.getByRole('row', { name: oidcClient.name })).not.toBeVisible(); +}); + test('Filter OIDC clients by PAR requirement', async ({ page, request }) => { const parClient = oidcClients.parClient; diff --git a/tests/specs/user-group.spec.ts b/tests/specs/user-group.spec.ts index daeb2847..ba065aba 100644 --- a/tests/specs/user-group.spec.ts +++ b/tests/specs/user-group.spec.ts @@ -72,6 +72,19 @@ test('Delete user group', async ({ page }) => { await expect(page.getByRole('row', { name: group.name })).not.toBeVisible(); }); +test('Delete user group from the details page', async ({ page }) => { + const group = userGroups.developers; + await page.goto(`/settings/admin/user-groups/${group.id}`); + + await page.getByRole('button', { name: 'Actions' }).click(); + await page.getByRole('menuitem', { name: 'Delete' }).click(); + await page.getByRole('button', { name: 'Delete' }).click(); + + await expect(page.locator('[data-type="success"]')).toHaveText('User group deleted successfully'); + await page.waitForURL((url) => url.pathname === '/settings/admin/user-groups'); + await expect(page.getByRole('row', { name: group.name })).not.toBeVisible(); +}); + test('Update user group custom claims', async ({ page }) => { await page.goto(`/settings/admin/user-groups/${userGroups.designers.id}`); diff --git a/tests/specs/user-settings.spec.ts b/tests/specs/user-settings.spec.ts index dbaab3f2..64bbd1cf 100644 --- a/tests/specs/user-settings.spec.ts +++ b/tests/specs/user-settings.spec.ts @@ -113,6 +113,37 @@ test('Delete user', async ({ page }) => { ).not.toBeVisible(); }); +test('Delete user from the details page', async ({ page }) => { + await page.goto('/settings/admin/users'); + await page.getByRole('row', { name: `${users.craig.firstname} ${users.craig.lastname}` }).click(); + await page.waitForURL(`/settings/admin/users/${users.craig.id}`); + + // A pending edit must not block leaving the page once the user is gone + await page.getByLabel('First name').fill('Changed'); + + await page.getByRole('button', { name: 'Actions' }).click(); + await page.getByRole('menuitem', { name: 'Delete' }).click(); + await page.getByRole('alertdialog').getByRole('button', { name: 'Delete' }).click(); + + await expect(page.locator('[data-type="success"]')).toHaveText('User deleted successfully'); + await page.waitForURL((url) => url.pathname === '/settings/admin/users'); + await expect( + page.getByRole('row', { + name: `${users.craig.firstname} ${users.craig.lastname}` + }) + ).not.toBeVisible(); +}); + +test('Create one time access token from the details page', async ({ page }) => { + await page.goto(`/settings/admin/users/${users.craig.id}`); + + await page.getByRole('button', { name: 'Actions' }).click(); + await page.getByRole('menuitem', { name: 'Login Code' }).click(); + await page.getByRole('button', { name: 'Show Code' }).click(); + + await expect(page.getByTestId('login-code-link')).toBeVisible(); +}); + test('Update user', async ({ page }) => { const user = users.craig;