mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-06 09:49:04 +02:00
feat: pin table row actions and add them to details pages
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
<script lang="ts" generics="T">
|
||||
import {
|
||||
buttonVariants,
|
||||
type ButtonSize,
|
||||
type ButtonVariant
|
||||
} from '#lib/components/ui/button/index.ts';
|
||||
import * as DropdownMenu from '#lib/components/ui/dropdown-menu/index.js';
|
||||
import { m } from '#lib/paraglide/messages.js';
|
||||
import type { AdvancedTableAction } from '#lib/types/advanced-table.type.ts';
|
||||
import { LucideEllipsis } from '@lucide/svelte';
|
||||
|
||||
let {
|
||||
item,
|
||||
actions,
|
||||
label = m.toggle_menu(),
|
||||
variant = 'ghost',
|
||||
size = 'icon'
|
||||
}: {
|
||||
item: T;
|
||||
actions: AdvancedTableAction<T>[];
|
||||
label?: string;
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
} = $props();
|
||||
|
||||
let visibleActions = $derived(actions.filter((a) => !a.hidden));
|
||||
</script>
|
||||
|
||||
{#if visibleActions.length > 0}
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger class={buttonVariants({ variant, size })}>
|
||||
<LucideEllipsis class="size-4" />
|
||||
<span class="sr-only">{label}</span>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
{#each visibleActions as action (action.label)}
|
||||
<DropdownMenu.Item
|
||||
onclick={() => action.onClick(item)}
|
||||
disabled={action.disabled}
|
||||
variant={action.variant === 'danger' ? 'destructive' : 'default'}
|
||||
>
|
||||
{#if action.icon}
|
||||
{@const Icon = action.icon}
|
||||
<Icon class="mr-2 size-4" />
|
||||
{/if}
|
||||
{action.label}
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{/if}
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts" generics="T extends {id:string}">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page as currentPage } from '$app/state';
|
||||
import ActionsMenu from '#lib/components/actions-menu.svelte';
|
||||
import Checkbox from '#lib/components/ui/checkbox/checkbox.svelte';
|
||||
import * as Pagination from '#lib/components/ui/pagination/index.ts';
|
||||
import * as Select from '#lib/components/ui/select/index.ts';
|
||||
@@ -13,12 +14,11 @@
|
||||
} from '#lib/types/advanced-table.type.ts';
|
||||
import type { ListRequestOptions, Paginated, SortRequest } from '#lib/types/list-request.type.ts';
|
||||
import { cn } from '#lib/utils/style.ts';
|
||||
import { ChevronDown, LucideEllipsis } from '@lucide/svelte';
|
||||
import { ChevronDown } from '@lucide/svelte';
|
||||
import { PersistedState } from 'runed';
|
||||
import { onMount } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
import Button, { buttonVariants } from '../ui/button/button.svelte';
|
||||
import * as DropdownMenu from '../ui/dropdown-menu/index.js';
|
||||
import Button from '../ui/button/button.svelte';
|
||||
import { Skeleton } from '../ui/skeleton';
|
||||
import AdvancedTableToolbar from './advanced-table-toolbar.svelte';
|
||||
|
||||
@@ -213,7 +213,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div in:fade>
|
||||
<Table.Root class="min-w-full table-auto overflow-x-auto">
|
||||
<Table.Root class="min-w-full table-auto">
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
{#if selectedIds}
|
||||
@@ -263,7 +263,7 @@
|
||||
</Table.Head>
|
||||
{/each}
|
||||
{#if actions}
|
||||
<Table.Head align="right" class="w-12">
|
||||
<Table.Head align="right" class="bg-card sticky right-0 z-10 w-12">
|
||||
<span class="sr-only">{m.actions()}</span>
|
||||
</Table.Head>
|
||||
{/if}
|
||||
@@ -273,6 +273,7 @@
|
||||
{#each items.data as item (item.id)}
|
||||
<Table.Row
|
||||
class={{
|
||||
'group/row': true,
|
||||
'bg-muted/20': selectedIds?.includes(item.id),
|
||||
'cursor-pointer': getPrimaryAction(item) || onRowClick
|
||||
}}
|
||||
@@ -301,30 +302,12 @@
|
||||
</Table.Cell>
|
||||
{/each}
|
||||
{#if actions}
|
||||
<Table.Cell align="right" class="w-12 py-0">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class={buttonVariants({ variant: 'ghost', size: 'icon' })}
|
||||
>
|
||||
<LucideEllipsis class="size-4" />
|
||||
<span class="sr-only">{m.toggle_menu()}</span>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
{#each actions(item).filter((a) => !a.hidden) as action (action.label)}
|
||||
<DropdownMenu.Item
|
||||
onclick={() => action.onClick(item)}
|
||||
disabled={action.disabled}
|
||||
class={action.variant === 'danger' ? 'text-red-500!' : ''}
|
||||
>
|
||||
{#if action.icon}
|
||||
{@const Icon = action.icon}
|
||||
<Icon class="mr-2 size-4" />
|
||||
{/if}
|
||||
{action.label}
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
<!-- Pinned to the right edge so the actions stay reachable when the table scrolls horizontally -->
|
||||
<Table.Cell
|
||||
align="right"
|
||||
class="bg-card sticky right-0 z-10 w-12 py-0 transition-colors group-hover/row:bg-[color-mix(in_oklab,var(--color-muted)_50%,var(--color-card))]"
|
||||
>
|
||||
<ActionsMenu {item} actions={actions(item)} />
|
||||
</Table.Cell>
|
||||
{/if}
|
||||
</Table.Row>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
{sideOffset}
|
||||
{align}
|
||||
class={cn(
|
||||
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/5 dark:ring-foreground/10 text-popover-foreground data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 bg-popover/70 **:data-[slot$=-item]:focus:bg-foreground/10 **:data-[slot$=-item]:data-highlighted:bg-foreground/10 **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-foreground/10 **:data-[slot$=-trigger]:aria-expanded:bg-foreground/10! **:data-[variant=destructive]:focus:bg-foreground/10! **:data-[variant=destructive]:text-accent-foreground! **:data-[variant=destructive]:**:text-accent-foreground! relative z-50 min-w-48 animate-none! overflow-x-hidden overflow-y-auto rounded-3xl p-1.5 shadow-lg ring-1 duration-100 outline-none before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:backdrop-blur-2xl before:backdrop-saturate-150 data-closed:overflow-hidden',
|
||||
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/5 dark:ring-foreground/10 text-popover-foreground data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 bg-popover/70 **:data-[slot$=-item]:focus:bg-foreground/10 **:data-[slot$=-item]:data-highlighted:bg-foreground/10 **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-foreground/10 **:data-[slot$=-trigger]:aria-expanded:bg-foreground/10! **:data-[variant=destructive]:data-highlighted:bg-destructive/10! dark:**:data-[variant=destructive]:data-highlighted:bg-destructive/20! relative z-50 min-w-48 animate-none! overflow-x-hidden overflow-y-auto rounded-3xl p-1.5 shadow-lg ring-1 duration-100 outline-none before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:backdrop-blur-2xl before:backdrop-saturate-150 data-closed:overflow-hidden',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
bind:ref
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
class={cn(
|
||||
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/5 dark:ring-foreground/10 text-popover-foreground bg-popover/70 **:data-[slot$=-item]:focus:bg-foreground/10 **:data-[slot$=-item]:data-highlighted:bg-foreground/10 **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-foreground/10 **:data-[slot$=-trigger]:aria-expanded:bg-foreground/10! **:data-[variant=destructive]:focus:bg-foreground/10! **:data-[variant=destructive]:text-accent-foreground! **:data-[variant=destructive]:**:text-accent-foreground! relative w-auto min-w-36 animate-none! rounded-3xl p-1.5 shadow-lg ring-1 duration-100 before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:backdrop-blur-2xl before:backdrop-saturate-150',
|
||||
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/5 dark:ring-foreground/10 text-popover-foreground bg-popover/70 **:data-[slot$=-item]:focus:bg-foreground/10 **:data-[slot$=-item]:data-highlighted:bg-foreground/10 **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-foreground/10 **:data-[slot$=-trigger]:aria-expanded:bg-foreground/10! **:data-[variant=destructive]:data-highlighted:bg-destructive/10! dark:**:data-[variant=destructive]:data-highlighted:bg-destructive/20! relative w-auto min-w-36 animate-none! rounded-3xl p-1.5 shadow-lg ring-1 duration-100 before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:backdrop-blur-2xl before:backdrop-saturate-150',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import ActionsMenu from '#lib/components/actions-menu.svelte';
|
||||
import CollapsibleCard from '#lib/components/collapsible-card.svelte';
|
||||
import * as Card from '#lib/components/ui/card/index.ts';
|
||||
import { m } from '#lib/paraglide/messages.js';
|
||||
@@ -7,6 +8,7 @@
|
||||
import { trackUnsavedValue } from '#lib/utils/unsaved-changes-util.svelte.ts';
|
||||
import { LucideChevronLeft } from '@lucide/svelte';
|
||||
import { backNavigate } from '../../users/navigate-back-util';
|
||||
import { deleteApiAction } from '../api-actions';
|
||||
import ApiForm from '../api-form.svelte';
|
||||
import ApiAccessCard from './api-access-card.svelte';
|
||||
import ApiPermissionsInput from './api-permissions-input.svelte';
|
||||
@@ -33,6 +35,7 @@
|
||||
|
||||
const apisService = new ApisService();
|
||||
const backNavigation = backNavigate('/settings/admin/apis');
|
||||
const actions = [deleteApiAction(backNavigation.leave)];
|
||||
|
||||
let accessCard = $state<ApiAccessCard>();
|
||||
|
||||
@@ -70,11 +73,12 @@
|
||||
<title>{api.name}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<button type="button" class="text-muted-foreground flex text-sm" onclick={backNavigation.go}>
|
||||
<LucideChevronLeft class="size-5" />
|
||||
{m.back()}
|
||||
</button>
|
||||
<ActionsMenu item={api} {actions} label={m.actions()} variant="outline" size="icon-sm" />
|
||||
</div>
|
||||
|
||||
<Card.Root>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { openConfirmDialog } from '#lib/components/confirm-dialog/index.ts';
|
||||
import { m } from '#lib/paraglide/messages.js';
|
||||
import ApisService from '#lib/services/apis-service.ts';
|
||||
import type { AdvancedTableAction } from '#lib/types/advanced-table.type.ts';
|
||||
import type { Api } from '#lib/types/api.type.ts';
|
||||
import { axiosErrorToast } from '#lib/utils/error-util.ts';
|
||||
import { LucideTrash } from '@lucide/svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
const apisService = new ApisService();
|
||||
|
||||
export function deleteApiAction(onDeleted: () => unknown): AdvancedTableAction<Api> {
|
||||
return {
|
||||
label: m.delete(),
|
||||
icon: LucideTrash,
|
||||
variant: 'danger',
|
||||
onClick: (api) =>
|
||||
openConfirmDialog({
|
||||
title: m.delete_name({ name: api.name }),
|
||||
message: m.are_you_sure_you_want_to_delete_this_api(),
|
||||
confirm: {
|
||||
label: m.delete(),
|
||||
destructive: true,
|
||||
action: async () => {
|
||||
try {
|
||||
await apisService.remove(api.id);
|
||||
await onDeleted();
|
||||
toast.success(m.api_deleted_successfully());
|
||||
} catch (e) {
|
||||
axiosErrorToast(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { openConfirmDialog } from '#lib/components/confirm-dialog/index.ts';
|
||||
import AdvancedTable from '#lib/components/table/advanced-table.svelte';
|
||||
import { m } from '#lib/paraglide/messages.js';
|
||||
import ApisService from '#lib/services/apis-service.ts';
|
||||
@@ -9,9 +8,8 @@
|
||||
CreateAdvancedTableActions
|
||||
} from '#lib/types/advanced-table.type.ts';
|
||||
import type { Api } from '#lib/types/api.type.ts';
|
||||
import { axiosErrorToast } from '#lib/utils/error-util.ts';
|
||||
import { LucidePencil, LucideTrash } from '@lucide/svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { LucidePencil } from '@lucide/svelte';
|
||||
import { deleteApiAction } from './api-actions';
|
||||
|
||||
const apisService = new ApisService();
|
||||
let tableRef: AdvancedTable<Api>;
|
||||
@@ -40,33 +38,8 @@
|
||||
variant: 'ghost',
|
||||
onClick: (api) => goto(`/settings/admin/apis/${api.id}`)
|
||||
},
|
||||
{
|
||||
label: m.delete(),
|
||||
icon: LucideTrash,
|
||||
variant: 'danger',
|
||||
onClick: (api) => deleteApi(api)
|
||||
}
|
||||
deleteApiAction(refresh)
|
||||
];
|
||||
|
||||
async function deleteApi(api: Api) {
|
||||
openConfirmDialog({
|
||||
title: m.delete_name({ name: api.name }),
|
||||
message: m.are_you_sure_you_want_to_delete_this_api(),
|
||||
confirm: {
|
||||
label: m.delete(),
|
||||
destructive: true,
|
||||
action: async () => {
|
||||
try {
|
||||
await apisService.remove(api.id);
|
||||
await refresh();
|
||||
toast.success(m.api_deleted_successfully());
|
||||
} catch (e) {
|
||||
axiosErrorToast(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<AdvancedTable
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
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 @@
|
||||
<button type="button" class="text-muted-foreground flex text-sm" onclick={backNavigation.go}
|
||||
><LucideChevronLeft class="size-5" /> {m.back()}</button
|
||||
>
|
||||
<Button variant="outline" size="sm" onclick={() => (showPreview = true)}>
|
||||
<LucideEye class="mr-2 size-4" />
|
||||
{m.oidc_data_preview()}
|
||||
</Button>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onclick={() => (showPreview = true)}>
|
||||
<LucideEye class="mr-2 size-4" />
|
||||
{m.oidc_data_preview()}
|
||||
</Button>
|
||||
<ActionsMenu item={client} {actions} label={m.actions()} variant="outline" size="icon-sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs.Root value="general" useHash class="gap-6">
|
||||
<div class="[scrollbar-width:none] overflow-x-auto border-b">
|
||||
<Tabs.List variant="line" class="min-w-max">
|
||||
<Tabs.Trigger value="general">{m.general()}</Tabs.Trigger>
|
||||
<Tabs.Trigger value="access">
|
||||
{m.access()}
|
||||
{#if client.isGroupRestricted && client.allowedUserGroupIds.length === 0}
|
||||
<span class="size-1.5 rounded-full bg-yellow-500"></span>
|
||||
{/if}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="credentials">
|
||||
{m.credentials()}
|
||||
<Badge variant="secondary" class="text-muted-foreground h-4.5 px-1.5 text-[11px]">
|
||||
{credentialCount}
|
||||
</Badge>
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="scim">{m.scim_provisioning()}</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
</div>
|
||||
{#key reloadCount}
|
||||
<Tabs.Root value="general" useHash class="gap-6">
|
||||
<div class="[scrollbar-width:none] overflow-x-auto border-b">
|
||||
<Tabs.List variant="line" class="min-w-max">
|
||||
<Tabs.Trigger value="general">{m.general()}</Tabs.Trigger>
|
||||
<Tabs.Trigger value="access">
|
||||
{m.access()}
|
||||
{#if client.isGroupRestricted && client.allowedUserGroupIds.length === 0}
|
||||
<span class="size-1.5 rounded-full bg-yellow-500"></span>
|
||||
{/if}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="credentials">
|
||||
{m.credentials()}
|
||||
<Badge variant="secondary" class="text-muted-foreground h-4.5 px-1.5 text-[11px]">
|
||||
{credentialCount}
|
||||
</Badge>
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="scim">{m.scim_provisioning()}</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
</div>
|
||||
|
||||
<Tabs.Content value="general" class="flex flex-col gap-6">
|
||||
<OidcClientConnectionDetailsCard
|
||||
{client}
|
||||
secrets={clientSecrets}
|
||||
oidcConfiguration={data.oidcConfiguration}
|
||||
/>
|
||||
<OidcForm existingClient={client} callback={updateClient} />
|
||||
<OidcClientTokenLifetimesCard {client} callback={updateTokenLifetimes} />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="general" class="flex flex-col gap-6">
|
||||
<OidcClientConnectionDetailsCard
|
||||
{client}
|
||||
secrets={clientSecrets}
|
||||
oidcConfiguration={data.oidcConfiguration}
|
||||
/>
|
||||
<OidcForm existingClient={client} callback={updateClient} />
|
||||
<OidcClientTokenLifetimesCard {client} callback={updateTokenLifetimes} />
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="access" class="flex flex-col gap-6">
|
||||
<OidcClientAllowedUserGroupsCard bind:client />
|
||||
<ApiAccessCard clientId={client.id} isPublicClient={client.isPublic} />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="access" class="flex flex-col gap-6">
|
||||
<OidcClientAllowedUserGroupsCard bind:client />
|
||||
<ApiAccessCard clientId={client.id} isPublicClient={client.isPublic} />
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="credentials" class="flex flex-col gap-6">
|
||||
<OidcClientSecretsCard {client} bind:secrets={clientSecrets} />
|
||||
<OidcClientFederatedCredentialsCard {client} callback={updateFederatedCredentials} />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="credentials" class="flex flex-col gap-6">
|
||||
<OidcClientSecretsCard {client} bind:secrets={clientSecrets} />
|
||||
<OidcClientFederatedCredentialsCard {client} callback={updateFederatedCredentials} />
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="scim">
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>{m.scim_provisioning()}</Card.Title>
|
||||
<Card.Description>
|
||||
<FormattedMessage message={m.scim_provisioning_description} />
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<ScimResourceProviderForm
|
||||
oidcClientId={client.id}
|
||||
existingProvider={scimServiceProvider}
|
||||
onSave={saveScimServiceProvider}
|
||||
/>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
<Tabs.Content value="scim">
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>{m.scim_provisioning()}</Card.Title>
|
||||
<Card.Description>
|
||||
<FormattedMessage message={m.scim_provisioning_description} />
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<ScimResourceProviderForm
|
||||
oidcClientId={client.id}
|
||||
existingProvider={scimServiceProvider}
|
||||
onSave={saveScimServiceProvider}
|
||||
/>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
{/key}
|
||||
|
||||
<OidcClientPreviewModal bind:open={showPreview} clientId={client.id} />
|
||||
|
||||
+1
-1
@@ -150,7 +150,7 @@
|
||||
<span class="sr-only">{m.toggle_menu()}</span>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item class="text-red-500!" onclick={() => deleteSecret(secret)}>
|
||||
<DropdownMenu.Item variant="destructive" onclick={() => deleteSecret(secret)}>
|
||||
<LucideTrash2 class="mr-2 size-4" />
|
||||
{m.delete()}
|
||||
</DropdownMenu.Item>
|
||||
|
||||
@@ -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<T extends OidcClient>(
|
||||
client: T,
|
||||
onRefreshed: () => unknown
|
||||
): AdvancedTableAction<T> {
|
||||
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<T extends OidcClient>(
|
||||
onDeleted: () => unknown
|
||||
): AdvancedTableAction<T> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { openConfirmDialog } from '#lib/components/confirm-dialog/index.ts';
|
||||
import ImageBox from '#lib/components/image-box.svelte';
|
||||
import AdvancedTable from '#lib/components/table/advanced-table.svelte';
|
||||
import { ScrollArea } from '#lib/components/ui/scroll-area/index.ts';
|
||||
@@ -11,13 +10,12 @@
|
||||
AdvancedTableColumn,
|
||||
CreateAdvancedTableActions
|
||||
} from '#lib/types/advanced-table.type.ts';
|
||||
import type { OidcClient, OidcClientWithAllowedGroups } from '#lib/types/oidc.type.ts';
|
||||
import type { OidcClientWithAllowedGroups } from '#lib/types/oidc.type.ts';
|
||||
import { cachedOidcClientLogo } from '#lib/utils/cached-image-util.ts';
|
||||
import { encodeClientIdParam } from '#lib/utils/client-id-util.ts';
|
||||
import { axiosErrorToast } from '#lib/utils/error-util.ts';
|
||||
import { LucidePencil, LucideRefreshCcw, LucideTrash } from '@lucide/svelte';
|
||||
import { LucidePencil } from '@lucide/svelte';
|
||||
import { mode } from 'mode-watcher';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { deleteClientAction, refreshClientAction } from './oidc-client-actions';
|
||||
|
||||
const oidcService = new OIDCService();
|
||||
let tableRef: AdvancedTable<OidcClientWithAllowedGroups>;
|
||||
@@ -102,49 +100,9 @@
|
||||
icon: LucidePencil,
|
||||
onClick: (client) => goto(`/settings/admin/oidc-clients/${encodeClientIdParam(client.id)}`)
|
||||
},
|
||||
{
|
||||
label: m.refresh(),
|
||||
icon: LucideRefreshCcw,
|
||||
hidden: client.clientType !== 'cimd',
|
||||
onClick: (client) => refreshClient(client)
|
||||
},
|
||||
{
|
||||
label: m.delete(),
|
||||
icon: LucideTrash,
|
||||
variant: 'danger',
|
||||
onClick: (client) => deleteClient(client)
|
||||
}
|
||||
refreshClientAction(client, refresh),
|
||||
deleteClientAction(refresh)
|
||||
];
|
||||
|
||||
async function refreshClient(client: OidcClient) {
|
||||
try {
|
||||
await oidcService.refreshClient(client.id);
|
||||
await refresh();
|
||||
toast.success(m.oidc_client_metadata_refreshed_successfully());
|
||||
} catch (e) {
|
||||
axiosErrorToast(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteClient(client: OidcClient) {
|
||||
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 refresh();
|
||||
toast.success(m.oidc_client_deleted_successfully());
|
||||
} catch (e) {
|
||||
axiosErrorToast(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet AllowedGroupCountCell({ item }: { item: OidcClientWithAllowedGroups })}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import ActionsMenu from '#lib/components/actions-menu.svelte';
|
||||
import CustomClaimsInput from '#lib/components/form/custom-claims-input.svelte';
|
||||
import { Badge } from '#lib/components/ui/badge/index.ts';
|
||||
import * as Card from '#lib/components/ui/card/index.ts';
|
||||
@@ -11,6 +12,7 @@
|
||||
import { trackUnsavedValue } from '#lib/utils/unsaved-changes-util.svelte.ts';
|
||||
import { LucideChevronLeft } from '@lucide/svelte';
|
||||
import { backNavigate } from '../../users/navigate-back-util';
|
||||
import { deleteUserGroupAction } from '../user-group-actions';
|
||||
import UserGroupForm from '../user-group-form.svelte';
|
||||
import UserSelection from '../user-selection.svelte';
|
||||
import OidcClientSelection from './oidc-client-selection.svelte';
|
||||
@@ -26,6 +28,7 @@
|
||||
const userGroupService = new UserGroupService();
|
||||
const customClaimService = new CustomClaimService();
|
||||
const backNavigation = backNavigate('/settings/admin/user-groups');
|
||||
const actions = [deleteUserGroupAction(backNavigation.leave)];
|
||||
|
||||
async function updateUserGroup(updatedUserGroup: UserGroupCreate) {
|
||||
await userGroupService.update(userGroup.id, updatedUserGroup);
|
||||
@@ -67,9 +70,12 @@
|
||||
<button type="button" class="text-muted-foreground flex text-sm" onclick={backNavigation.go}
|
||||
><LucideChevronLeft class="size-5" /> {m.back()}</button
|
||||
>
|
||||
{#if !!userGroup.ldapId}
|
||||
<Badge class="rounded-full" variant="default">{m.ldap()}</Badge>
|
||||
{/if}
|
||||
<div class="flex items-center gap-2">
|
||||
{#if !!userGroup.ldapId}
|
||||
<Badge class="rounded-full" variant="default">{m.ldap()}</Badge>
|
||||
{/if}
|
||||
<ActionsMenu item={userGroup} {actions} label={m.actions()} variant="outline" size="icon-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<Tabs.Root value="general" useHash class="gap-4">
|
||||
<div class="overflow-x-auto pb-1">
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { openConfirmDialog } from '#lib/components/confirm-dialog/index.ts';
|
||||
import { m } from '#lib/paraglide/messages.js';
|
||||
import UserGroupService from '#lib/services/user-group-service.ts';
|
||||
import type { AdvancedTableAction } from '#lib/types/advanced-table.type.ts';
|
||||
import type { UserGroup } from '#lib/types/user-group.type.ts';
|
||||
import { axiosErrorToast } from '#lib/utils/error-util.ts';
|
||||
import { LucideTrash } from '@lucide/svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
const userGroupService = new UserGroupService();
|
||||
|
||||
export function deleteUserGroupAction<T extends Pick<UserGroup, 'id' | 'name'>>(
|
||||
onDeleted: () => unknown
|
||||
): AdvancedTableAction<T> {
|
||||
return {
|
||||
label: m.delete(),
|
||||
icon: LucideTrash,
|
||||
variant: 'danger',
|
||||
onClick: (userGroup) =>
|
||||
openConfirmDialog({
|
||||
title: m.delete_name({ name: userGroup.name }),
|
||||
message: m.are_you_sure_you_want_to_delete_this_user_group(),
|
||||
confirm: {
|
||||
label: m.delete(),
|
||||
destructive: true,
|
||||
action: async () => {
|
||||
try {
|
||||
await userGroupService.remove(userGroup.id);
|
||||
await onDeleted();
|
||||
toast.success(m.user_group_deleted_successfully());
|
||||
} catch (e) {
|
||||
axiosErrorToast(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { openConfirmDialog } from '#lib/components/confirm-dialog/index.ts';
|
||||
import AdvancedTable from '#lib/components/table/advanced-table.svelte';
|
||||
import { Badge } from '#lib/components/ui/badge/index.ts';
|
||||
import { m } from '#lib/paraglide/messages.js';
|
||||
@@ -11,9 +10,8 @@
|
||||
CreateAdvancedTableActions
|
||||
} from '#lib/types/advanced-table.type.ts';
|
||||
import type { UserGroupMinimal } from '#lib/types/user-group.type.ts';
|
||||
import { axiosErrorToast } from '#lib/utils/error-util.ts';
|
||||
import { LucidePencil, LucideTrash } from '@lucide/svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { LucidePencil } from '@lucide/svelte';
|
||||
import { deleteUserGroupAction } from './user-group-actions';
|
||||
|
||||
const userGroupService = new UserGroupService();
|
||||
let tableRef: AdvancedTable<UserGroupMinimal>;
|
||||
@@ -38,7 +36,7 @@
|
||||
{ label: m.source(), key: 'source', hidden: !$appConfigStore.ldapEnabled, cell: SourceCell }
|
||||
];
|
||||
|
||||
const actions: CreateAdvancedTableActions<UserGroupMinimal> = (group) => [
|
||||
const actions: CreateAdvancedTableActions<UserGroupMinimal> = () => [
|
||||
{
|
||||
label: m.edit(),
|
||||
primary: true,
|
||||
@@ -46,34 +44,8 @@
|
||||
variant: 'ghost',
|
||||
onClick: (group) => goto(`/settings/admin/user-groups/${group.id}`)
|
||||
},
|
||||
{
|
||||
label: m.delete(),
|
||||
icon: LucideTrash,
|
||||
variant: 'danger',
|
||||
onClick: (group) => deleteUserGroup(group),
|
||||
visible: group.ldapId || $appConfigStore.ldapEnabled
|
||||
}
|
||||
deleteUserGroupAction(refresh)
|
||||
];
|
||||
|
||||
async function deleteUserGroup(userGroup: UserGroupMinimal) {
|
||||
openConfirmDialog({
|
||||
title: m.delete_name({ name: userGroup.name }),
|
||||
message: m.are_you_sure_you_want_to_delete_this_user_group(),
|
||||
confirm: {
|
||||
label: m.delete(),
|
||||
destructive: true,
|
||||
action: async () => {
|
||||
try {
|
||||
await userGroupService.remove(userGroup.id);
|
||||
await refresh();
|
||||
toast.success(m.user_group_deleted_successfully());
|
||||
} catch (e) {
|
||||
axiosErrorToast(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet SourceCell({ item }: { item: UserGroupMinimal })}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import ActionsMenu from '#lib/components/actions-menu.svelte';
|
||||
import CustomClaimsInput from '#lib/components/form/custom-claims-input.svelte';
|
||||
import ProfilePictureSettings from '#lib/components/form/profile-picture-settings.svelte';
|
||||
import OneTimeLinkModal from '#lib/components/one-time-link-modal.svelte';
|
||||
import Badge from '#lib/components/ui/badge/badge.svelte';
|
||||
import * as Card from '#lib/components/ui/card/index.ts';
|
||||
import * as Item from '#lib/components/ui/item/index.js';
|
||||
@@ -17,6 +19,7 @@
|
||||
import { KeyRound, LucideChevronLeft } from '@lucide/svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { backNavigate } from '../navigate-back-util';
|
||||
import { deleteUserAction, loginCodeAction } from '../user-actions';
|
||||
import UserForm from '../user-form.svelte';
|
||||
import AdminPasskeyList from './admin-passkey-list.svelte';
|
||||
|
||||
@@ -26,13 +29,21 @@
|
||||
userGroupIds: data.user.userGroups.map((g) => g.id)
|
||||
});
|
||||
let passkeys: Passkey[] = $state(data.passkeys);
|
||||
let userIdToCreateOneTimeLink: string | null = $state(null);
|
||||
|
||||
const userService = new UserService();
|
||||
const customClaimService = new CustomClaimService();
|
||||
const backNavigation = backNavigate('/settings/admin/users');
|
||||
|
||||
const actions = $derived([
|
||||
loginCodeAction((u) => (userIdToCreateOneTimeLink = u.id)),
|
||||
deleteUserAction(user, backNavigation.leave)
|
||||
]);
|
||||
|
||||
async function updateUser(updatedUser: UserCreate) {
|
||||
await userService.update(user.id, updatedUser);
|
||||
// Whether the user can be deleted depends on the saved disabled state
|
||||
user.disabled = updatedUser.disabled;
|
||||
}
|
||||
|
||||
trackUnsavedValue(
|
||||
@@ -79,9 +90,12 @@
|
||||
<button class="text-muted-foreground flex text-sm" onclick={() => backNavigation.go()}
|
||||
><LucideChevronLeft class="size-5" /> {m.back()}</button
|
||||
>
|
||||
{#if !!user.ldapId}
|
||||
<Badge class="rounded-full" variant="default">{m.ldap()}</Badge>
|
||||
{/if}
|
||||
<div class="flex items-center gap-2">
|
||||
{#if !!user.ldapId}
|
||||
<Badge class="rounded-full" variant="default">{m.ldap()}</Badge>
|
||||
{/if}
|
||||
<ActionsMenu item={user} {actions} label={m.actions()} variant="outline" size="icon-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<Tabs.Root value="general" useHash class="gap-4">
|
||||
<div class="overflow-x-auto pb-1">
|
||||
@@ -165,3 +179,5 @@
|
||||
</Card.Root>
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
|
||||
<OneTimeLinkModal bind:userId={userIdToCreateOneTimeLink} />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterNavigate, goto } from '$app/navigation';
|
||||
import unsavedChanges from '#lib/stores/unsaved-changes-store.svelte.ts';
|
||||
|
||||
export const backNavigate = (defaultRoute: string) => {
|
||||
let previousUrl: URL | undefined;
|
||||
@@ -8,13 +9,20 @@ export const backNavigate = (defaultRoute: string) => {
|
||||
}
|
||||
});
|
||||
|
||||
const go = () => {
|
||||
if (previousUrl && previousUrl.pathname === defaultRoute) {
|
||||
window.history.back();
|
||||
} else {
|
||||
goto(defaultRoute);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
go: () => {
|
||||
if (previousUrl && previousUrl.pathname === defaultRoute) {
|
||||
window.history.back();
|
||||
} else {
|
||||
goto(defaultRoute);
|
||||
}
|
||||
go,
|
||||
// Used once the entity is deleted, so pending edits are dropped instead of blocking the navigation
|
||||
leave: () => {
|
||||
unsavedChanges.discardAll();
|
||||
go();
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { openConfirmDialog } from '#lib/components/confirm-dialog/index.ts';
|
||||
import { m } from '#lib/paraglide/messages.js';
|
||||
import UserService from '#lib/services/user-service.ts';
|
||||
import userStore from '#lib/stores/user-store.ts';
|
||||
import type { AdvancedTableAction } from '#lib/types/advanced-table.type.ts';
|
||||
import type { User } from '#lib/types/user.type.ts';
|
||||
import { axiosErrorToast } from '#lib/utils/error-util.ts';
|
||||
import { LucideLink, LucideTrash } from '@lucide/svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
const userService = new UserService();
|
||||
|
||||
export function loginCodeAction(onClick: (user: User) => void): AdvancedTableAction<User> {
|
||||
return { label: m.login_code(), icon: LucideLink, onClick };
|
||||
}
|
||||
|
||||
export function deleteUserAction(user: User, onDeleted: () => unknown): AdvancedTableAction<User> {
|
||||
return {
|
||||
label: m.delete(),
|
||||
icon: LucideTrash,
|
||||
variant: 'danger',
|
||||
onClick: (user) =>
|
||||
openConfirmDialog({
|
||||
title: m.delete_firstname_lastname({
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName ?? ''
|
||||
}),
|
||||
message: m.are_you_sure_you_want_to_delete_this_user(),
|
||||
confirm: {
|
||||
label: m.delete(),
|
||||
destructive: true,
|
||||
action: async () => {
|
||||
try {
|
||||
await userService.remove(user.id);
|
||||
await onDeleted();
|
||||
toast.success(m.user_deleted_successfully());
|
||||
} catch (e) {
|
||||
axiosErrorToast(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
hidden: !!user.ldapId && !user.disabled,
|
||||
disabled: user.id === get(userStore)?.id
|
||||
};
|
||||
}
|
||||
@@ -16,14 +16,9 @@
|
||||
import type { User } from '#lib/types/user.type.ts';
|
||||
import { cachedProfilePicture } from '#lib/utils/cached-image-util.ts';
|
||||
import { axiosErrorToast } from '#lib/utils/error-util.ts';
|
||||
import {
|
||||
LucideLink,
|
||||
LucidePencil,
|
||||
LucideTrash,
|
||||
LucideUserCheck,
|
||||
LucideUserX
|
||||
} from '@lucide/svelte';
|
||||
import { LucidePencil, LucideUserCheck, LucideUserX } from '@lucide/svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { deleteUserAction, loginCodeAction } from './user-actions';
|
||||
|
||||
let userIdToCreateOneTimeLink: string | null = $state(null);
|
||||
let tableRef: AdvancedTable<User>;
|
||||
@@ -34,29 +29,6 @@
|
||||
return tableRef?.refresh();
|
||||
}
|
||||
|
||||
async function deleteUser(user: User) {
|
||||
openConfirmDialog({
|
||||
title: m.delete_firstname_lastname({
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName ?? ''
|
||||
}),
|
||||
message: m.are_you_sure_you_want_to_delete_this_user(),
|
||||
confirm: {
|
||||
label: m.delete(),
|
||||
destructive: true,
|
||||
action: async () => {
|
||||
try {
|
||||
await userService.remove(user.id);
|
||||
await refresh();
|
||||
toast.success(m.user_deleted_successfully());
|
||||
} catch (e) {
|
||||
axiosErrorToast(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function enableUser(user: User) {
|
||||
await userService
|
||||
.update(user.id, {
|
||||
@@ -137,11 +109,7 @@
|
||||
];
|
||||
|
||||
const actions: CreateAdvancedTableActions<User> = (u) => [
|
||||
{
|
||||
label: m.login_code(),
|
||||
icon: LucideLink,
|
||||
onClick: (u) => (userIdToCreateOneTimeLink = u.id)
|
||||
},
|
||||
loginCodeAction((u) => (userIdToCreateOneTimeLink = u.id)),
|
||||
{
|
||||
label: m.edit(),
|
||||
primary: true,
|
||||
@@ -155,14 +123,7 @@
|
||||
hidden: !!u.ldapId || $appConfigStore.ldapEnabled,
|
||||
disabled: u.id === $userStore?.id
|
||||
},
|
||||
{
|
||||
label: m.delete(),
|
||||
icon: LucideTrash,
|
||||
variant: 'danger',
|
||||
onClick: (u) => deleteUser(u),
|
||||
hidden: !!u.ldapId && !u.disabled,
|
||||
disabled: u.id === $userStore?.id
|
||||
}
|
||||
deleteUserAction(u, refresh)
|
||||
];
|
||||
</script>
|
||||
|
||||
|
||||
@@ -92,9 +92,7 @@
|
||||
>
|
||||
{/if}
|
||||
{#if client.lastUsedAt}
|
||||
<DropdownMenu.Item
|
||||
class="text-red-500 focus:!text-red-700"
|
||||
onclick={() => onRevoke(client)}
|
||||
<DropdownMenu.Item variant="destructive" onclick={() => onRevoke(client)}
|
||||
><LucideBan class="mr-2 size-4" />{m.revoke()}</DropdownMenu.Item
|
||||
>
|
||||
{/if}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user