mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-08-24 21:17:31 +00:00
feat: hide apps without launch url on My Apps page
This commit is contained in:
@@ -394,6 +394,7 @@ func (oc *OidcController) updateAllowedUserGroupsHandler(c *gin.Context) error {
|
||||
// @Param pagination[limit] query int false "Number of items per page" default(20)
|
||||
// @Param sort[column] query string false "Column to sort by"
|
||||
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
||||
// @Param filters[hasLaunchURL] query bool false "Filter clients by whether a launch URL is configured"
|
||||
// @Success 200 {object} dto.Paginated[dto.AuthorizedOidcClientDto]
|
||||
// @Router /api/oidc/users/me/authorized-clients [get]
|
||||
func (oc *OidcController) listOwnAuthorizedClientsHandler(c *gin.Context) error {
|
||||
@@ -410,6 +411,7 @@ func (oc *OidcController) listOwnAuthorizedClientsHandler(c *gin.Context) error
|
||||
// @Param pagination[limit] query int false "Number of items per page" default(20)
|
||||
// @Param sort[column] query string false "Column to sort by"
|
||||
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
||||
// @Param filters[hasLaunchURL] query bool false "Filter clients by whether a launch URL is configured"
|
||||
// @Success 200 {object} dto.Paginated[dto.AuthorizedOidcClientDto]
|
||||
// @Router /api/oidc/users/{id}/authorized-clients [get]
|
||||
func (oc *OidcController) listAuthorizedClientsHandler(c *gin.Context) error {
|
||||
@@ -467,6 +469,7 @@ func (oc *OidcController) revokeOwnClientAuthorizationHandler(c *gin.Context) er
|
||||
// @Param pagination[limit] query int false "Number of items per page" default(20)
|
||||
// @Param sort[column] query string false "Column to sort by"
|
||||
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
||||
// @Param filters[hasLaunchURL] query bool false "Filter clients by whether a launch URL is configured"
|
||||
// @Success 200 {object} dto.Paginated[dto.AccessibleOidcClientDto]
|
||||
// @Router /api/oidc/users/me/clients [get]
|
||||
func (oc *OidcController) listOwnAccessibleClientsHandler(c *gin.Context) error {
|
||||
|
||||
@@ -192,6 +192,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
||||
ID: "606c7782-f2b1-49e5-8ea9-26eb1b06d018",
|
||||
},
|
||||
Name: "Immich",
|
||||
LaunchURL: new("https://immich.local"),
|
||||
Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x
|
||||
CallbackURLs: datatype.StringList{"http://immich.localhost/auth/callback"},
|
||||
CreatedByID: new(users[1].ID),
|
||||
|
||||
@@ -592,6 +592,16 @@ func (s *OidcService) ListAuthorizedClients(ctx context.Context, userID string,
|
||||
Preload("Client").
|
||||
Where("user_id = ?", userID)
|
||||
|
||||
// Apply the launch URL filter before pagination so hidden authorizations have their own page count
|
||||
if hasLaunchURL, ok := getHasLaunchURLFilter(listRequestOptions); ok {
|
||||
query = query.Joins("JOIN oidc_clients ON oidc_clients.id = user_authorized_oidc_clients.client_id")
|
||||
if hasLaunchURL {
|
||||
query = query.Where("oidc_clients.launch_url IS NOT NULL AND oidc_clients.launch_url <> ''")
|
||||
} else {
|
||||
query = query.Where("oidc_clients.launch_url IS NULL OR oidc_clients.launch_url = ''")
|
||||
}
|
||||
}
|
||||
|
||||
var authorizedClients []model.UserAuthorizedOidcClient
|
||||
response, err := utils.PaginateFilterAndSort(listRequestOptions, query, &authorizedClients)
|
||||
|
||||
@@ -667,6 +677,15 @@ func (s *OidcService) ListAccessibleOidcClients(ctx context.Context, userID stri
|
||||
WHERE oidc_clients_allowed_user_groups.oidc_client_id = oidc_clients.id
|
||||
AND oidc_clients_allowed_user_groups.user_group_id IN (?))`, false, userGroupIDs)
|
||||
|
||||
// Apply the launch URL filter before pagination so the app launcher never contains empty pages
|
||||
if hasLaunchURL, ok := getHasLaunchURLFilter(listRequestOptions); ok {
|
||||
if hasLaunchURL {
|
||||
query = query.Where("oidc_clients.launch_url IS NOT NULL AND oidc_clients.launch_url <> ''")
|
||||
} else {
|
||||
query = query.Where("oidc_clients.launch_url IS NULL OR oidc_clients.launch_url = ''")
|
||||
}
|
||||
}
|
||||
|
||||
var clients []model.OidcClient
|
||||
|
||||
// Handle custom sorting for lastUsedAt column
|
||||
@@ -705,6 +724,16 @@ func (s *OidcService) ListAccessibleOidcClients(ctx context.Context, userID stri
|
||||
return dtos, response, err
|
||||
}
|
||||
|
||||
func getHasLaunchURLFilter(listRequestOptions utils.ListRequestOptions) (bool, bool) {
|
||||
values := listRequestOptions.Filters["hasLaunchURL"]
|
||||
if len(values) == 0 {
|
||||
return false, false
|
||||
}
|
||||
|
||||
hasLaunchURL, ok := values[0].(bool)
|
||||
return hasLaunchURL, ok
|
||||
}
|
||||
|
||||
func (s *OidcService) GetClientPreview(ctx context.Context, clientID string, userID string, scopes []string, authenticationMethod string) (*dto.OidcClientPreviewDto, error) {
|
||||
client, err := s.getClientInternal(ctx, clientID, s.db, false)
|
||||
if err != nil {
|
||||
|
||||
@@ -858,6 +858,60 @@ func TestOidcService_ListAccessibleOidcClients_requiresExplicitGroupPermission(t
|
||||
assert.Equal(t, []string{"Unrestricted"}, accessibleClientNames(noGroupClients))
|
||||
}
|
||||
|
||||
func TestOidcService_ListClientViewsFilterByLaunchURLPresence(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
s, err := NewOidcService(db, nil, nil, nil, nil, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
user := model.User{Username: "launch-url-filter"}
|
||||
require.NoError(t, db.Create(&user).Error)
|
||||
|
||||
launchURL := "https://launchable.example.com"
|
||||
emptyLaunchURL := ""
|
||||
clients := []model.OidcClient{
|
||||
{Name: "Launchable", LaunchURL: &launchURL},
|
||||
{Name: "Missing launch URL"},
|
||||
{Name: "Empty launch URL", LaunchURL: &emptyLaunchURL},
|
||||
}
|
||||
for i := range clients {
|
||||
require.NoError(t, db.Create(&clients[i]).Error)
|
||||
require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{
|
||||
UserID: user.ID,
|
||||
ClientID: clients[i].ID,
|
||||
}).Error)
|
||||
}
|
||||
|
||||
withLaunchURL := utils.ListRequestOptions{
|
||||
Filters: map[string][]any{"hasLaunchURL": {true}},
|
||||
}
|
||||
withoutLaunchURL := utils.ListRequestOptions{
|
||||
Filters: map[string][]any{"hasLaunchURL": {false}},
|
||||
}
|
||||
|
||||
allClients, allClientsPagination, err := s.ListAccessibleOidcClients(t.Context(), user.ID, utils.ListRequestOptions{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(3), allClientsPagination.TotalItems)
|
||||
assert.ElementsMatch(t, []string{"Launchable", "Missing launch URL", "Empty launch URL"}, accessibleClientNames(allClients))
|
||||
|
||||
launchableClients, launchablePagination, err := s.ListAccessibleOidcClients(t.Context(), user.ID, withLaunchURL)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), launchablePagination.TotalItems)
|
||||
assert.Equal(t, []string{"Launchable"}, accessibleClientNames(launchableClients))
|
||||
|
||||
allAuthorizations, allAuthorizationsPagination, err := s.ListAuthorizedClients(t.Context(), user.ID, utils.ListRequestOptions{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(3), allAuthorizationsPagination.TotalItems)
|
||||
assert.Len(t, allAuthorizations, 3)
|
||||
|
||||
hiddenAuthorizations, hiddenPagination, err := s.ListAuthorizedClients(t.Context(), user.ID, withoutLaunchURL)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(2), hiddenPagination.TotalItems)
|
||||
assert.ElementsMatch(t, []string{"Missing launch URL", "Empty launch URL"}, []string{
|
||||
hiddenAuthorizations[0].Client.Name,
|
||||
hiddenAuthorizations[1].Client.Name,
|
||||
})
|
||||
}
|
||||
|
||||
func accessibleClientNames(clients []dto.AccessibleOidcClientDto) []string {
|
||||
names := make([]string, len(clients))
|
||||
for i := range clients {
|
||||
|
||||
@@ -491,6 +491,9 @@
|
||||
"client_name_description": "The name of the client that shows in the Pocket ID UI.",
|
||||
"client_description": "Description",
|
||||
"client_description_description": "An optional description of the client that shows in the Pocket ID UI.",
|
||||
"show_all_apps": "Show all apps",
|
||||
"show_hidden_apps": "Show hidden apps",
|
||||
"hide_all_apps": "Hide all apps",
|
||||
"revoke_access": "Revoke Access",
|
||||
"revoke_access_description": "Revoke access to {#b}{clientName}{/b}. {#b}{clientName}{/b} will no longer be able to access your account information.",
|
||||
"revoke_access_successful": "The access to {clientName} has been successfully revoked.",
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from '$lib/utils/style.js';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="empty-content"
|
||||
class={cn(
|
||||
'gap-4 text-sm flex w-full max-w-sm min-w-0 flex-col items-center text-balance',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from '$lib/utils/style.js';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="empty-description"
|
||||
class={cn(
|
||||
'text-sm/relaxed text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from '$lib/utils/style.js';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="empty-header"
|
||||
class={cn('gap-2 flex max-w-sm flex-col items-center', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script lang="ts" module>
|
||||
import { tv, type VariantProps } from 'tailwind-variants';
|
||||
|
||||
export const emptyMediaVariants = tv({
|
||||
base: 'mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-transparent',
|
||||
icon: "flex size-10 shrink-0 items-center justify-center rounded-xl bg-muted text-foreground [&_svg:not([class*='size-'])]:size-5"
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default'
|
||||
}
|
||||
});
|
||||
|
||||
export type EmptyMediaVariant = VariantProps<typeof emptyMediaVariants>['variant'];
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from '$lib/utils/style.js';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
variant = 'default',
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { variant?: EmptyMediaVariant } = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="empty-icon"
|
||||
data-variant={variant}
|
||||
class={cn(emptyMediaVariants({ variant }), className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from '$lib/utils/style.js';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="empty-title"
|
||||
class={cn('text-lg font-medium tracking-tight', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from '$lib/utils/style.js';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="empty"
|
||||
class={cn(
|
||||
'gap-4 rounded-2xl border-dashed p-12 flex w-full min-w-0 flex-1 flex-col items-center justify-center text-center text-balance',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,22 @@
|
||||
import Content from './empty-content.svelte';
|
||||
import Description from './empty-description.svelte';
|
||||
import Header from './empty-header.svelte';
|
||||
import Media from './empty-media.svelte';
|
||||
import Title from './empty-title.svelte';
|
||||
import Root from './empty.svelte';
|
||||
|
||||
export {
|
||||
Root,
|
||||
Header,
|
||||
Media,
|
||||
Title,
|
||||
Description,
|
||||
Content,
|
||||
//
|
||||
Root as Empty,
|
||||
Header as EmptyHeader,
|
||||
Media as EmptyMedia,
|
||||
Title as EmptyTitle,
|
||||
Description as EmptyDescription,
|
||||
Content as EmptyContent
|
||||
};
|
||||
@@ -27,17 +27,21 @@
|
||||
{sideOffset}
|
||||
{side}
|
||||
class={cn(
|
||||
'data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-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 inline-flex items-center gap-1.5 rounded-xl px-3 py-1.5 text-xs has-data-[slot=kbd]:pr-1.5 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-lg bg-foreground text-background z-50 w-fit max-w-xs origin-(--bits-tooltip-content-transform-origin)',
|
||||
'data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-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 text-popover-foreground relative isolate inline-flex w-fit max-w-xs origin-(--bits-tooltip-content-transform-origin) items-center gap-1.5 rounded-xl px-3 py-1.5 text-xs shadow-lg ring-1 has-data-[slot=kbd]:pr-1.5 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-lg z-50',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="tooltip-surface pointer-events-none absolute inset-0 -z-1 rounded-[inherit]"
|
||||
></span>
|
||||
{@render children?.()}
|
||||
<TooltipPrimitive.Arrow>
|
||||
{#snippet child({ props })}
|
||||
<div
|
||||
class={cn(
|
||||
'size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] data-[side=left]:translate-x-[-1.5px] data-[side=right]:translate-x-[1.5px] bg-foreground fill-foreground z-50',
|
||||
'tooltip-surface size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] data-[side=left]:translate-x-[-1.5px] data-[side=right]:translate-x-[1.5px] z-50',
|
||||
'data-[side=top]:translate-x-1/2 data-[side=top]:translate-y-[calc(-50%+2px)]',
|
||||
'data-[side=bottom]:-translate-x-1/2 data-[side=bottom]:-translate-y-[calc(-50%+1px)]',
|
||||
'data-[side=right]:translate-x-[calc(50%+2px)] data-[side=right]:translate-y-1/2',
|
||||
@@ -50,3 +54,12 @@
|
||||
</TooltipPrimitive.Arrow>
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPortal>
|
||||
|
||||
<style>
|
||||
.tooltip-surface {
|
||||
background-color: color-mix(in oklab, var(--popover) 70%, transparent);
|
||||
background-image: linear-gradient(color-mix(in oklab, var(--foreground) 16%, transparent) 0 0);
|
||||
backdrop-filter: blur(40px) saturate(1.5);
|
||||
-webkit-backdrop-filter: blur(40px) saturate(1.5);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ListRequestOptions, Paginated } from '$lib/types/list-request.type';
|
||||
import type {
|
||||
AccessibleOidcClient,
|
||||
AuthorizedOidcClient,
|
||||
CompleteInteractionResponse,
|
||||
InteractionSession,
|
||||
InteractionStep,
|
||||
@@ -121,6 +122,11 @@ class OidcService extends APIService {
|
||||
return res.data as Paginated<AccessibleOidcClient>;
|
||||
};
|
||||
|
||||
listOwnAuthorizedClients = async (options?: ListRequestOptions) => {
|
||||
const res = await this.api.get('/oidc/users/me/authorized-clients', { params: options });
|
||||
return res.data as Paginated<AuthorizedOidcClient>;
|
||||
};
|
||||
|
||||
revokeOwnAuthorizedClient = async (clientId: string) => {
|
||||
await this.api.delete(`/oidc/users/me/authorized-clients/${encodeClientIdParam(clientId)}`);
|
||||
};
|
||||
|
||||
@@ -94,6 +94,12 @@ export type AccessibleOidcClient = OidcClientMetaData & {
|
||||
lastUsedAt: Date | null;
|
||||
};
|
||||
|
||||
export type AuthorizedOidcClient = {
|
||||
scope: string;
|
||||
client: OidcClientMetaData;
|
||||
lastUsedAt: Date;
|
||||
};
|
||||
|
||||
export type InteractionStep = 'authenticate' | 'select_account' | 'reauthenticate' | 'consent';
|
||||
|
||||
export type InteractionScopeInfo = {
|
||||
|
||||
@@ -1,28 +1,65 @@
|
||||
<script lang="ts">
|
||||
import { openConfirmDialog } from '$lib/components/confirm-dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Empty from '$lib/components/ui/empty';
|
||||
import * as Pagination from '$lib/components/ui/pagination';
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import OIDCService from '$lib/services/oidc-service';
|
||||
import type { ListRequestOptions, Paginated } from '$lib/types/list-request.type';
|
||||
import type { AccessibleOidcClient, OidcClientMetaData } from '$lib/types/oidc.type';
|
||||
import type {
|
||||
AccessibleOidcClient,
|
||||
AuthorizedOidcClient,
|
||||
OidcClientMetaData
|
||||
} from '$lib/types/oidc.type';
|
||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||
import { LayoutDashboard } from '@lucide/svelte';
|
||||
import { cn } from '$lib/utils/style';
|
||||
import { ChevronDown, LayoutDashboard } from '@lucide/svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { slide } from 'svelte/transition';
|
||||
import AuthorizedOidcClientCard from './authorized-oidc-client-card.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
let clients: Paginated<AccessibleOidcClient> = $state(data.clients);
|
||||
let requestOptions: ListRequestOptions = $state(data.appRequestOptions);
|
||||
|
||||
let authorizedClientsWithoutLaunchURL: Paginated<AuthorizedOidcClient> = $state(
|
||||
data.authorizedClientsWithoutLaunchURL
|
||||
);
|
||||
let authorizedClientRequestOptions: ListRequestOptions = $state(
|
||||
data.authorizedClientRequestOptions
|
||||
);
|
||||
let showAllApps = $state(false);
|
||||
const hiddenAuthorizedClients = $derived(
|
||||
authorizedClientsWithoutLaunchURL.data.map(({ client, lastUsedAt }) => ({
|
||||
...client,
|
||||
lastUsedAt
|
||||
}))
|
||||
);
|
||||
const oidcService = new OIDCService();
|
||||
|
||||
async function onRefresh(options: ListRequestOptions) {
|
||||
clients = await oidcService.listOwnAccessibleClients(options);
|
||||
async function refreshClients() {
|
||||
[clients, authorizedClientsWithoutLaunchURL] = await Promise.all([
|
||||
oidcService.listOwnAccessibleClients(requestOptions),
|
||||
oidcService.listOwnAuthorizedClients(authorizedClientRequestOptions)
|
||||
]);
|
||||
if (authorizedClientsWithoutLaunchURL.pagination.totalItems === 0) {
|
||||
showAllApps = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onPageChange(page: number) {
|
||||
requestOptions.pagination = { limit: clients.pagination.itemsPerPage, page };
|
||||
onRefresh(requestOptions);
|
||||
clients = await oidcService.listOwnAccessibleClients(requestOptions);
|
||||
}
|
||||
|
||||
async function onAuthorizedClientPageChange(page: number) {
|
||||
authorizedClientRequestOptions.pagination = {
|
||||
limit: authorizedClientsWithoutLaunchURL.pagination.itemsPerPage,
|
||||
page
|
||||
};
|
||||
authorizedClientsWithoutLaunchURL = await oidcService.listOwnAuthorizedClients(
|
||||
authorizedClientRequestOptions
|
||||
);
|
||||
}
|
||||
|
||||
async function revokeAuthorizedClient(client: OidcClientMetaData) {
|
||||
@@ -38,7 +75,7 @@
|
||||
action: async () => {
|
||||
try {
|
||||
await oidcService.revokeOwnAuthorizedClient(client.id);
|
||||
onRefresh(requestOptions);
|
||||
await refreshClients();
|
||||
toast.success(
|
||||
m.revoke_access_successful({
|
||||
clientName: client.name
|
||||
@@ -56,74 +93,153 @@
|
||||
<svelte:head>
|
||||
<title>{m.my_apps()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<div>
|
||||
<h1 class="flex items-center gap-2 text-2xl font-bold">
|
||||
<h1 class="flex items-center gap-2 text-2xl font-bold mb-5">
|
||||
<LayoutDashboard class="text-primary/80 size-6" />
|
||||
{m.my_apps()}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{#if clients.data.length === 0}
|
||||
<div class="py-16 text-center">
|
||||
<LayoutDashboard class="text-muted-foreground mx-auto mb-4 size-16" />
|
||||
<h3 class="text-muted-foreground mb-2 text-lg font-medium">
|
||||
{m.no_apps_available()}
|
||||
</h3>
|
||||
<p class="text-muted-foreground mx-auto max-w-md text-sm">
|
||||
{m.contact_your_administrator_for_app_access()}
|
||||
</p>
|
||||
</div>
|
||||
{#if clients.data.length === 0 && !showAllApps}
|
||||
<Empty.Root>
|
||||
<Empty.Header>
|
||||
<Empty.Media variant="icon">
|
||||
<LayoutDashboard />
|
||||
</Empty.Media>
|
||||
<Empty.Title>{m.no_apps_available()}</Empty.Title>
|
||||
<Empty.Description>
|
||||
{m.contact_your_administrator_for_app_access()}
|
||||
</Empty.Description>
|
||||
</Empty.Header>
|
||||
<Empty.Content>
|
||||
<Button variant="outline" size="sm" onclick={() => (showAllApps = !showAllApps)}
|
||||
>{m.show_hidden_apps()}</Button
|
||||
>
|
||||
</Empty.Content>
|
||||
</Empty.Root>
|
||||
{:else}
|
||||
<div class="space-y-8">
|
||||
{#if clients.data.length > 0}
|
||||
<div
|
||||
class="grid gap-3"
|
||||
style="grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr));"
|
||||
style="grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr));"
|
||||
>
|
||||
{#each clients.data as client (client.id)}
|
||||
<AuthorizedOidcClientCard {client} onRevoke={revokeAuthorizedClient} />
|
||||
{/each}
|
||||
<!-- Gap fix if two elements are present-->
|
||||
{#if clients.data.length == 2}
|
||||
{#if clients.data.length === 2}
|
||||
<div></div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if clients.pagination.totalPages > 1}
|
||||
<div class="border-border flex items-center justify-center border-t pt-3">
|
||||
<Pagination.Root
|
||||
class="mx-0 w-auto"
|
||||
count={clients.pagination.totalItems}
|
||||
perPage={clients.pagination.itemsPerPage}
|
||||
{onPageChange}
|
||||
page={clients.pagination.currentPage}
|
||||
>
|
||||
{#snippet children({ pages })}
|
||||
<Pagination.Content class="flex justify-center">
|
||||
<Pagination.Item>
|
||||
<Pagination.PrevButton />
|
||||
</Pagination.Item>
|
||||
{#each pages as page (page.key)}
|
||||
{#if page.type !== 'ellipsis' && page.value != 0}
|
||||
<Pagination.Item>
|
||||
<Pagination.Link
|
||||
{page}
|
||||
isActive={clients.pagination.currentPage === page.value}
|
||||
>
|
||||
{page.value}
|
||||
</Pagination.Link>
|
||||
</Pagination.Item>
|
||||
{/if}
|
||||
{/each}
|
||||
<Pagination.Item>
|
||||
<Pagination.NextButton />
|
||||
</Pagination.Item>
|
||||
</Pagination.Content>
|
||||
{/snippet}
|
||||
</Pagination.Root>
|
||||
{#if clients.pagination.totalPages > 1}
|
||||
<div class="flex items-center justify-center mt-5">
|
||||
<Pagination.Root
|
||||
class="mx-0 w-auto"
|
||||
count={clients.pagination.totalItems}
|
||||
perPage={clients.pagination.itemsPerPage}
|
||||
{onPageChange}
|
||||
page={clients.pagination.currentPage}
|
||||
>
|
||||
{#snippet children({ pages })}
|
||||
<Pagination.Content class="flex justify-center">
|
||||
<Pagination.Item>
|
||||
<Pagination.PrevButton />
|
||||
</Pagination.Item>
|
||||
{#each pages as page (page.key)}
|
||||
{#if page.type !== 'ellipsis' && page.value != 0}
|
||||
<Pagination.Item>
|
||||
<Pagination.Link
|
||||
{page}
|
||||
isActive={clients.pagination.currentPage === page.value}
|
||||
>
|
||||
{page.value}
|
||||
</Pagination.Link>
|
||||
</Pagination.Item>
|
||||
{/if}
|
||||
{/each}
|
||||
<Pagination.Item>
|
||||
<Pagination.NextButton />
|
||||
</Pagination.Item>
|
||||
</Pagination.Content>
|
||||
{/snippet}
|
||||
</Pagination.Root>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showAllApps}
|
||||
<div transition:slide={{ duration: 200 }}>
|
||||
{#if clients.data.length > 0}
|
||||
<Separator class="my-8" />
|
||||
{/if}
|
||||
<div
|
||||
class="grid gap-3"
|
||||
style="grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr));"
|
||||
>
|
||||
{#each hiddenAuthorizedClients as client (client.id)}
|
||||
<AuthorizedOidcClientCard {client} onRevoke={revokeAuthorizedClient} />
|
||||
{/each}
|
||||
<!-- Gap fix if two elements are present-->
|
||||
{#if hiddenAuthorizedClients.length === 2}
|
||||
<div></div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if authorizedClientsWithoutLaunchURL.pagination.totalPages > 1}
|
||||
<div class="flex items-center justify-center mt-5">
|
||||
<Pagination.Root
|
||||
class="mx-0 w-auto"
|
||||
count={authorizedClientsWithoutLaunchURL.pagination.totalItems}
|
||||
perPage={authorizedClientsWithoutLaunchURL.pagination.itemsPerPage}
|
||||
onPageChange={onAuthorizedClientPageChange}
|
||||
page={authorizedClientsWithoutLaunchURL.pagination.currentPage}
|
||||
>
|
||||
{#snippet children({ pages })}
|
||||
<Pagination.Content class="flex justify-center">
|
||||
<Pagination.Item>
|
||||
<Pagination.PrevButton />
|
||||
</Pagination.Item>
|
||||
{#each pages as page (page.key)}
|
||||
{#if page.type !== 'ellipsis' && page.value != 0}
|
||||
<Pagination.Item>
|
||||
<Pagination.Link
|
||||
{page}
|
||||
isActive={authorizedClientsWithoutLaunchURL.pagination.currentPage ===
|
||||
page.value}
|
||||
>
|
||||
{page.value}
|
||||
</Pagination.Link>
|
||||
</Pagination.Item>
|
||||
{/if}
|
||||
{/each}
|
||||
<Pagination.Item>
|
||||
<Pagination.NextButton />
|
||||
</Pagination.Item>
|
||||
</Pagination.Content>
|
||||
{/snippet}
|
||||
</Pagination.Root>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if authorizedClientsWithoutLaunchURL.pagination.totalItems > 0 && clients.data.length !== 0}
|
||||
<div class="flex justify-center mt-10">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="text-muted-foreground"
|
||||
onclick={() => (showAllApps = !showAllApps)}
|
||||
>
|
||||
{showAllApps ? m.hide_all_apps() : m.show_all_apps()}
|
||||
({authorizedClientsWithoutLaunchURL.pagination.totalItems})
|
||||
<ChevronDown
|
||||
data-icon="inline-end"
|
||||
class={cn('transition-transform duration-200', showAllApps && 'rotate-180 transform')}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -13,10 +13,35 @@ export const load: PageLoad = async () => {
|
||||
sort: {
|
||||
column: 'lastUsedAt',
|
||||
direction: 'desc'
|
||||
},
|
||||
filters: {
|
||||
hasLaunchURL: [true]
|
||||
}
|
||||
};
|
||||
|
||||
const clients = await oidcService.listOwnAccessibleClients(appRequestOptions);
|
||||
const authorizedClientRequestOptions: ListRequestOptions = {
|
||||
pagination: {
|
||||
page: 1,
|
||||
limit: 20
|
||||
},
|
||||
sort: {
|
||||
column: 'lastUsedAt',
|
||||
direction: 'desc'
|
||||
},
|
||||
filters: {
|
||||
hasLaunchURL: [false]
|
||||
}
|
||||
};
|
||||
|
||||
return { clients, appRequestOptions };
|
||||
const [clients, authorizedClientsWithoutLaunchURL] = await Promise.all([
|
||||
oidcService.listOwnAccessibleClients(appRequestOptions),
|
||||
oidcService.listOwnAuthorizedClients(authorizedClientRequestOptions)
|
||||
]);
|
||||
|
||||
return {
|
||||
clients,
|
||||
appRequestOptions,
|
||||
authorizedClientsWithoutLaunchURL,
|
||||
authorizedClientRequestOptions
|
||||
};
|
||||
};
|
||||
|
||||
@@ -33,10 +33,11 @@
|
||||
</script>
|
||||
|
||||
<Card.Root
|
||||
class="border-muted group relative h-[160px] p-5 transition-all duration-200 hover:shadow-md sm:max-w-[50vw] md:max-w-[430px]"
|
||||
data-testid="authorized-oidc-client-card"
|
||||
class="border-muted group relative h-[160px] p-5 hover:shadow-md sm:max-w-[50vw] md:max-w-[450px]"
|
||||
role="article"
|
||||
aria-label={client.name}
|
||||
>
|
||||
<Card.Content class=" p-0">
|
||||
<Card.Content class="p-0">
|
||||
<div class="flex gap-3">
|
||||
<div class="aspect-square h-[56px]">
|
||||
{#if client.hasLogo}
|
||||
@@ -124,17 +125,18 @@
|
||||
{:else}
|
||||
<div></div>
|
||||
{/if}
|
||||
<Button
|
||||
href={client.launchURL}
|
||||
target="_blank"
|
||||
size="sm"
|
||||
class="h-8 text-xs"
|
||||
rel="noopener noreferrer"
|
||||
disabled={!client.launchURL}
|
||||
>
|
||||
{m.launch()}
|
||||
<LucideExternalLink class="ml-1 size-3" />
|
||||
</Button>
|
||||
{#if client.launchURL}
|
||||
<Button
|
||||
href={client.launchURL}
|
||||
target="_blank"
|
||||
size="sm"
|
||||
class="h-8 text-xs"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{m.launch()}
|
||||
<LucideExternalLink data-icon="inline-end" />
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
+2
-1
@@ -37,7 +37,8 @@ export const oidcClients = {
|
||||
id: '606c7782-f2b1-49e5-8ea9-26eb1b06d018',
|
||||
name: 'Immich',
|
||||
callbackUrl: 'http://immich.localhost/auth/callback',
|
||||
secret: 'PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x'
|
||||
secret: 'PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x',
|
||||
launchURL: 'https://immich.local'
|
||||
},
|
||||
tailscale: {
|
||||
id: '7c21a609-96b5-4011-9900-272b8d31a9d1',
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
"image_type": null,
|
||||
"is_group_restricted": true,
|
||||
"is_public": false,
|
||||
"launch_url": null,
|
||||
"launch_url": "https://immich.local",
|
||||
"logout_callback_urls": "bnVsbA==",
|
||||
"metadata_expires_at": null,
|
||||
"metadata_grant_types": "bnVsbA==",
|
||||
|
||||
@@ -5,63 +5,71 @@ import { cleanupBackend } from '../utils/cleanup.util';
|
||||
|
||||
test.beforeEach(async () => await cleanupBackend());
|
||||
|
||||
test('Dashboard shows all clients in the correct order', async ({ page }) => {
|
||||
const client1 = oidcClients.tailscale;
|
||||
const client2 = oidcClients.nextcloud;
|
||||
test('Dashboard shows only clients with launch URLs in the correct order', async ({ page }) => {
|
||||
const client1 = oidcClients.nextcloud;
|
||||
const client2 = oidcClients.immich;
|
||||
|
||||
await page.goto('/settings/apps');
|
||||
|
||||
await expect(page.getByTestId('authorized-oidc-client-card')).toHaveCount(7);
|
||||
const appCards = page.getByRole('article');
|
||||
await expect(appCards).toHaveCount(2);
|
||||
|
||||
// Should be first
|
||||
const card1 = page.getByTestId('authorized-oidc-client-card').first();
|
||||
const card1 = appCards.first();
|
||||
|
||||
await expect(card1.getByRole('heading')).toHaveText(client1.name);
|
||||
await expect(card1.getByText(new URL(client1.launchURL).hostname)).toBeVisible();
|
||||
|
||||
const card2 = page.getByTestId('authorized-oidc-client-card').nth(1);
|
||||
const card2 = page.getByRole('article', { name: client2.name });
|
||||
await expect(card2.getByRole('heading', { name: client2.name })).toBeVisible();
|
||||
await expect(card2.getByText(new URL(client2.launchURL).hostname)).toBeVisible();
|
||||
|
||||
await expect(page.getByRole('article', { name: oidcClients.tailscale.name })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test.describe('Dashboard shows only clients where user has access', () => {
|
||||
test("User can't see one client", async ({ page }) => {
|
||||
test("User can't see a restricted launchable client", async ({ page }) => {
|
||||
await authUtil.changeUser(page, 'craig');
|
||||
const notVisibleClient = oidcClients.immich;
|
||||
|
||||
await page.goto('/settings/apps');
|
||||
|
||||
const cards = page.getByTestId('authorized-oidc-client-card');
|
||||
|
||||
await expect(cards).toHaveCount(6);
|
||||
|
||||
const cardTexts = await cards.allTextContents();
|
||||
expect(cardTexts.some((text) => text.includes(notVisibleClient.name))).toBe(false);
|
||||
await expect(page.getByRole('article')).toHaveCount(1);
|
||||
await expect(page.getByRole('article', { name: oidcClients.nextcloud.name })).toBeVisible();
|
||||
await expect(page.getByRole('article', { name: oidcClients.immich.name })).toHaveCount(0);
|
||||
});
|
||||
test('User can see all clients', async ({ page }) => {
|
||||
|
||||
test('User can see every accessible launchable client', async ({ page }) => {
|
||||
await page.goto('/settings/apps');
|
||||
const cards = page.getByTestId('authorized-oidc-client-card');
|
||||
await expect(cards).toHaveCount(7);
|
||||
|
||||
await expect(page.getByRole('article')).toHaveCount(2);
|
||||
await expect(page.getByRole('article', { name: oidcClients.nextcloud.name })).toBeVisible();
|
||||
await expect(page.getByRole('article', { name: oidcClients.immich.name })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test('Revoke authorized client', async ({ page }) => {
|
||||
test('Show and revoke a hidden authorized client in the app grid', async ({ page }) => {
|
||||
const client = oidcClients.tailscale;
|
||||
|
||||
await page.goto('/settings/apps');
|
||||
|
||||
const card = page.getByTestId('authorized-oidc-client-card').filter({ hasText: client.name });
|
||||
const appCards = page.getByRole('article');
|
||||
const clientCard = page.getByRole('article', { name: client.name });
|
||||
await expect(clientCard).toHaveCount(0);
|
||||
|
||||
card.getByRole('button', { name: 'Toggle menu' }).click();
|
||||
await page.getByRole('button', { name: /Show all apps/ }).click();
|
||||
|
||||
await expect(appCards).toHaveCount(4);
|
||||
await expect(clientCard).toBeVisible();
|
||||
await expect(page.getByRole('main').getByRole('separator')).toBeVisible();
|
||||
await expect(clientCard.getByRole('link', { name: 'Launch' })).toHaveCount(0);
|
||||
await expect(clientCard.getByRole('button', { name: 'Revoke' })).toHaveCount(0);
|
||||
await clientCard.getByRole('button', { name: 'Toggle menu' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Revoke' }).click();
|
||||
await page.getByRole('button', { name: 'Revoke' }).click();
|
||||
await page.getByRole('alertdialog').getByRole('button', { name: 'Revoke' }).click();
|
||||
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText(
|
||||
`The access to ${client.name} has been successfully revoked.`
|
||||
);
|
||||
|
||||
// The ... ago text should be gone as there is no last access anymore
|
||||
await expect(card).not.toContainText('ago');
|
||||
await expect(
|
||||
page.getByText(`The access to ${client.name} has been successfully revoked.`, { exact: true })
|
||||
).toBeVisible();
|
||||
await expect(clientCard).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('Launch authorized client', async ({ page }) => {
|
||||
@@ -69,11 +77,11 @@ test('Launch authorized client', async ({ page }) => {
|
||||
|
||||
await page.goto('/settings/apps');
|
||||
|
||||
const card1 = page.getByTestId('authorized-oidc-client-card').first();
|
||||
await expect(card1.getByRole('button', { name: 'Launch' })).toBeDisabled();
|
||||
const appCards = page.getByRole('article');
|
||||
await expect(appCards.getByRole('link', { name: 'Launch' })).toHaveCount(2);
|
||||
|
||||
const card2 = page.getByTestId('authorized-oidc-client-card').nth(1);
|
||||
await expect(card2.getByRole('link', { name: 'Launch' })).toHaveAttribute(
|
||||
const card = page.getByRole('article', { name: client.name });
|
||||
await expect(card.getByRole('link', { name: 'Launch' })).toHaveAttribute(
|
||||
'href',
|
||||
client.launchURL
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user