1
0
mirror of https://github.com/pocket-id/pocket-id.git synced 2026-02-04 15:04:43 +00:00

feat: display all accessible oidc clients in the dashboard (#832)

Co-authored-by: Kyle Mendell <ksm@ofkm.us>
This commit is contained in:
Elias Schneider
2025-08-17 22:47:34 +02:00
committed by GitHub
parent 3fa2f9a162
commit 3188e92257
28 changed files with 306 additions and 110 deletions

View File

@@ -55,10 +55,12 @@ func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
group.POST("/oidc/device/verify", authMiddleware.WithAdminNotRequired().Add(), oc.verifyDeviceCodeHandler) group.POST("/oidc/device/verify", authMiddleware.WithAdminNotRequired().Add(), oc.verifyDeviceCodeHandler)
group.GET("/oidc/device/info", authMiddleware.WithAdminNotRequired().Add(), oc.getDeviceCodeInfoHandler) group.GET("/oidc/device/info", authMiddleware.WithAdminNotRequired().Add(), oc.getDeviceCodeInfoHandler)
group.GET("/oidc/users/me/clients", authMiddleware.WithAdminNotRequired().Add(), oc.listOwnAuthorizedClientsHandler) group.GET("/oidc/users/me/authorized-clients", authMiddleware.WithAdminNotRequired().Add(), oc.listOwnAuthorizedClientsHandler)
group.GET("/oidc/users/:id/clients", authMiddleware.Add(), oc.listAuthorizedClientsHandler) group.GET("/oidc/users/:id/authorized-clients", authMiddleware.Add(), oc.listAuthorizedClientsHandler)
group.DELETE("/oidc/users/me/clients/:clientId", authMiddleware.WithAdminNotRequired().Add(), oc.revokeOwnClientAuthorizationHandler) group.DELETE("/oidc/users/me/authorized-clients/:clientId", authMiddleware.WithAdminNotRequired().Add(), oc.revokeOwnClientAuthorizationHandler)
group.GET("/oidc/users/me/clients", authMiddleware.WithAdminNotRequired().Add(), oc.listOwnAccessibleClientsHandler)
} }
@@ -660,7 +662,7 @@ func (oc *OidcController) deviceAuthorizationHandler(c *gin.Context) {
// @Param sort[column] query string false "Column to sort by" // @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc") // @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.AuthorizedOidcClientDto] // @Success 200 {object} dto.Paginated[dto.AuthorizedOidcClientDto]
// @Router /api/oidc/users/me/clients [get] // @Router /api/oidc/users/me/authorized-clients [get]
func (oc *OidcController) listOwnAuthorizedClientsHandler(c *gin.Context) { func (oc *OidcController) listOwnAuthorizedClientsHandler(c *gin.Context) {
userID := c.GetString("userID") userID := c.GetString("userID")
oc.listAuthorizedClients(c, userID) oc.listAuthorizedClients(c, userID)
@@ -676,7 +678,7 @@ func (oc *OidcController) listOwnAuthorizedClientsHandler(c *gin.Context) {
// @Param sort[column] query string false "Column to sort by" // @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc") // @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.AuthorizedOidcClientDto] // @Success 200 {object} dto.Paginated[dto.AuthorizedOidcClientDto]
// @Router /api/oidc/users/{id}/clients [get] // @Router /api/oidc/users/{id}/authorized-clients [get]
func (oc *OidcController) listAuthorizedClientsHandler(c *gin.Context) { func (oc *OidcController) listAuthorizedClientsHandler(c *gin.Context) {
userID := c.Param("id") userID := c.Param("id")
oc.listAuthorizedClients(c, userID) oc.listAuthorizedClients(c, userID)
@@ -713,7 +715,7 @@ func (oc *OidcController) listAuthorizedClients(c *gin.Context, userID string) {
// @Tags OIDC // @Tags OIDC
// @Param clientId path string true "Client ID to revoke authorization for" // @Param clientId path string true "Client ID to revoke authorization for"
// @Success 204 "No Content" // @Success 204 "No Content"
// @Router /api/oidc/users/me/clients/{clientId} [delete] // @Router /api/oidc/users/me/authorized-clients/{clientId} [delete]
func (oc *OidcController) revokeOwnClientAuthorizationHandler(c *gin.Context) { func (oc *OidcController) revokeOwnClientAuthorizationHandler(c *gin.Context) {
clientID := c.Param("clientId") clientID := c.Param("clientId")
@@ -728,6 +730,37 @@ func (oc *OidcController) revokeOwnClientAuthorizationHandler(c *gin.Context) {
c.Status(http.StatusNoContent) c.Status(http.StatusNoContent)
} }
// listOwnAccessibleClientsHandler godoc
// @Summary List accessible OIDC clients for current user
// @Description Get a list of OIDC clients that the current user can access
// @Tags OIDC
// @Param pagination[page] query int false "Page number for pagination" default(1)
// @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")
// @Success 200 {object} dto.Paginated[dto.AccessibleOidcClientDto]
// @Router /api/oidc/users/me/clients [get]
func (oc *OidcController) listOwnAccessibleClientsHandler(c *gin.Context) {
userID := c.GetString("userID")
var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
_ = c.Error(err)
return
}
clients, pagination, err := oc.oidcService.ListAccessibleOidcClients(c.Request.Context(), userID, sortedPaginationRequest)
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, dto.Paginated[dto.AccessibleOidcClientDto]{
Data: clients,
Pagination: pagination,
})
}
func (oc *OidcController) verifyDeviceCodeHandler(c *gin.Context) { func (oc *OidcController) verifyDeviceCodeHandler(c *gin.Context) {
userCode := c.Query("code") userCode := c.Query("code")
if userCode == "" { if userCode == "" {

View File

@@ -159,3 +159,8 @@ type OidcClientPreviewDto struct {
AccessToken map[string]any `json:"accessToken"` AccessToken map[string]any `json:"accessToken"`
UserInfo map[string]any `json:"userInfo"` UserInfo map[string]any `json:"userInfo"`
} }
type AccessibleOidcClientDto struct {
OidcClientMetaDataDto
LastUsedAt *datatype.DateTime `json:"lastUsedAt"`
}

View File

@@ -51,9 +51,10 @@ type OidcClient struct {
Credentials OidcClientCredentials Credentials OidcClientCredentials
LaunchURL *string LaunchURL *string
AllowedUserGroups []UserGroup `gorm:"many2many:oidc_clients_allowed_user_groups;"` AllowedUserGroups []UserGroup `gorm:"many2many:oidc_clients_allowed_user_groups;"`
CreatedByID string CreatedByID string
CreatedBy User CreatedBy User
UserAuthorizedOidcClients []UserAuthorizedOidcClient `gorm:"foreignKey:ClientID;references:ID"`
} }
type OidcRefreshToken struct { type OidcRefreshToken struct {

View File

@@ -641,8 +641,7 @@ func (s *OidcService) ListClients(ctx context.Context, name string, sortedPagina
} }
// As allowedUserGroupsCount is not a column, we need to manually sort it // As allowedUserGroupsCount is not a column, we need to manually sort it
isValidSortDirection := sortedPaginationRequest.Sort.Direction == "asc" || sortedPaginationRequest.Sort.Direction == "desc" if sortedPaginationRequest.Sort.Column == "allowedUserGroupsCount" && utils.IsValidSortDirection(sortedPaginationRequest.Sort.Direction) {
if sortedPaginationRequest.Sort.Column == "allowedUserGroupsCount" && isValidSortDirection {
query = query.Select("oidc_clients.*, COUNT(oidc_clients_allowed_user_groups.oidc_client_id)"). query = query.Select("oidc_clients.*, COUNT(oidc_clients_allowed_user_groups.oidc_client_id)").
Joins("LEFT JOIN oidc_clients_allowed_user_groups ON oidc_clients.id = oidc_clients_allowed_user_groups.oidc_client_id"). Joins("LEFT JOIN oidc_clients_allowed_user_groups ON oidc_clients.id = oidc_clients_allowed_user_groups.oidc_client_id").
Group("oidc_clients.id"). Group("oidc_clients.id").
@@ -1336,6 +1335,81 @@ func (s *OidcService) RevokeAuthorizedClient(ctx context.Context, userID string,
return nil return nil
} }
func (s *OidcService) ListAccessibleOidcClients(ctx context.Context, userID string, sortedPaginationRequest utils.SortedPaginationRequest) ([]dto.AccessibleOidcClientDto, utils.PaginationResponse, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
var user model.User
err := tx.
WithContext(ctx).
Preload("UserGroups").
First(&user, "id = ?", userID).
Error
if err != nil {
return nil, utils.PaginationResponse{}, err
}
userGroupIDs := make([]string, len(user.UserGroups))
for i, group := range user.UserGroups {
userGroupIDs[i] = group.ID
}
// Build the query for accessible clients
query := tx.
WithContext(ctx).
Model(&model.OidcClient{}).
Preload("UserAuthorizedOidcClients", "user_id = ?", userID).
Distinct()
// If user has no groups, only return clients with no allowed user groups
if len(userGroupIDs) == 0 {
query = query.
Joins("LEFT JOIN oidc_clients_allowed_user_groups ON oidc_clients.id = oidc_clients_allowed_user_groups.oidc_client_id").
Where("oidc_clients_allowed_user_groups.oidc_client_id IS NULL")
} else {
// Return clients with no allowed user groups OR clients where user is in allowed groups
query = query.
Joins("LEFT JOIN oidc_clients_allowed_user_groups ON oidc_clients.id = oidc_clients_allowed_user_groups.oidc_client_id").
Where("oidc_clients_allowed_user_groups.oidc_client_id IS NULL OR oidc_clients_allowed_user_groups.user_group_id IN (?)", userGroupIDs)
}
var clients []model.OidcClient
// Handle custom sorting for lastUsedAt column
var response utils.PaginationResponse
if sortedPaginationRequest.Sort.Column == "lastUsedAt" && utils.IsValidSortDirection(sortedPaginationRequest.Sort.Direction) {
query = query.
Joins("LEFT JOIN user_authorized_oidc_clients ON oidc_clients.id = user_authorized_oidc_clients.client_id AND user_authorized_oidc_clients.user_id = ?", userID).
Order("user_authorized_oidc_clients.last_used_at " + sortedPaginationRequest.Sort.Direction)
}
response, err = utils.PaginateAndSort(sortedPaginationRequest, query, &clients)
if err != nil {
return nil, utils.PaginationResponse{}, err
}
dtos := make([]dto.AccessibleOidcClientDto, len(clients))
for i, client := range clients {
var lastUsedAt *datatype.DateTime
if len(client.UserAuthorizedOidcClients) > 0 {
lastUsedAt = &client.UserAuthorizedOidcClients[0].LastUsedAt
}
dtos[i] = dto.AccessibleOidcClientDto{
OidcClientMetaDataDto: dto.OidcClientMetaDataDto{
ID: client.ID,
Name: client.Name,
LaunchURL: client.LaunchURL,
HasLogo: client.HasLogo,
},
LastUsedAt: lastUsedAt,
}
}
return dtos, response, err
}
func (s *OidcService) createRefreshToken(ctx context.Context, clientID string, userID string, scope string, tx *gorm.DB) (string, error) { func (s *OidcService) createRefreshToken(ctx context.Context, clientID string, userID string, scope string, tx *gorm.DB) (string, error) {
refreshToken, err := utils.GenerateRandomAlphanumericString(40) refreshToken, err := utils.GenerateRandomAlphanumericString(40)
if err != nil { if err != nil {

View File

@@ -32,8 +32,7 @@ func (s *UserGroupService) List(ctx context.Context, name string, sortedPaginati
} }
// As userCount is not a column we need to manually sort it // As userCount is not a column we need to manually sort it
isValidSortDirection := sortedPaginationRequest.Sort.Direction == "asc" || sortedPaginationRequest.Sort.Direction == "desc" if sortedPaginationRequest.Sort.Column == "userCount" && utils.IsValidSortDirection(sortedPaginationRequest.Sort.Direction) {
if sortedPaginationRequest.Sort.Column == "userCount" && isValidSortDirection {
query = query.Select("user_groups.*, COUNT(user_groups_users.user_id)"). query = query.Select("user_groups.*, COUNT(user_groups_users.user_id)").
Joins("LEFT JOIN user_groups_users ON user_groups.id = user_groups_users.user_group_id"). Joins("LEFT JOIN user_groups_users ON user_groups.id = user_groups_users.user_group_id").
Group("user_groups.id"). Group("user_groups.id").

View File

@@ -3,6 +3,7 @@ package utils
import ( import (
"reflect" "reflect"
"strconv" "strconv"
"strings"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
@@ -35,9 +36,7 @@ func PaginateAndSort(sortedPaginationRequest SortedPaginationRequest, query *gor
sortField, sortFieldFound := reflect.TypeOf(result).Elem().Elem().FieldByName(capitalizedSortColumn) sortField, sortFieldFound := reflect.TypeOf(result).Elem().Elem().FieldByName(capitalizedSortColumn)
isSortable, _ := strconv.ParseBool(sortField.Tag.Get("sortable")) isSortable, _ := strconv.ParseBool(sortField.Tag.Get("sortable"))
if sort.Direction == "" || (sort.Direction != "asc" && sort.Direction != "desc") { sort.Direction = NormalizeSortDirection(sort.Direction)
sort.Direction = "asc"
}
if sortFieldFound && isSortable { if sortFieldFound && isSortable {
columnName := CamelCaseToSnakeCase(sort.Column) columnName := CamelCaseToSnakeCase(sort.Column)
@@ -85,3 +84,16 @@ func Paginate(page int, pageSize int, query *gorm.DB, result interface{}) (Pagin
ItemsPerPage: pageSize, ItemsPerPage: pageSize,
}, nil }, nil
} }
func NormalizeSortDirection(direction string) string {
d := strings.ToLower(strings.TrimSpace(direction))
if d != "asc" && d != "desc" {
return "asc"
}
return d
}
func IsValidSortDirection(direction string) bool {
d := strings.ToLower(strings.TrimSpace(direction))
return d == "asc" || d == "desc"
}

View File

@@ -429,5 +429,6 @@
"client_name_description": "The name of the client that shows in the Pocket ID UI.", "client_name_description": "The name of the client that shows in the Pocket ID UI.",
"revoke_access": "Revoke Access", "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_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." "revoke_access_successful": "The access to {clientName} has been successfully revoked.",
"last_signed_in_ago": "Last signed in {time} ago"
} }

View File

@@ -18,6 +18,7 @@
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.1.11",
"axios": "^1.11.0", "axios": "^1.11.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0",
"jose": "^5.10.0", "jose": "^5.10.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"sveltekit-superforms": "^2.27.1", "sveltekit-superforms": "^2.27.1",

View File

@@ -1,5 +1,5 @@
import type { import type {
AuthorizedOidcClient, AccessibleOidcClient,
AuthorizeResponse, AuthorizeResponse,
OidcClient, OidcClient,
OidcClientCreate, OidcClientCreate,
@@ -115,18 +115,12 @@ class OidcService extends APIService {
return response.data; return response.data;
} }
async listAuthorizedClients(options?: SearchPaginationSortRequest) { async listOwnAccessibleClients(options?: SearchPaginationSortRequest) {
const res = await this.api.get('/oidc/users/me/clients', { const res = await this.api.get('/oidc/users/me/clients', {
params: options params: options
}); });
return res.data as Paginated<AuthorizedOidcClient>;
}
async listAuthorizedClientsForUser(userId: string, options?: SearchPaginationSortRequest) { return res.data as Paginated<AccessibleOidcClient>;
const res = await this.api.get(`/oidc/users/${userId}/clients`, {
params: options
});
return res.data as Paginated<AuthorizedOidcClient>;
} }
async revokeOwnAuthorizedClient(clientId: string) { async revokeOwnAuthorizedClient(clientId: string) {

View File

@@ -4,9 +4,9 @@ import { writable } from 'svelte/store';
const userStore = writable<User | null>(null); const userStore = writable<User | null>(null);
const setUser = (user: User) => { const setUser = async (user: User) => {
if (user.locale) { if (user.locale) {
setLocale(user.locale, false); await setLocale(user.locale, false);
} }
userStore.set(user); userStore.set(user);
}; };

View File

@@ -53,7 +53,6 @@ export type AuthorizeResponse = {
issuer: string; issuer: string;
}; };
export type AuthorizedOidcClient = { export type AccessibleOidcClient = OidcClientMetaData & {
scope: string; lastUsedAt: Date | null;
client: OidcClientMetaData;
}; };

View File

@@ -1,10 +1,26 @@
import { setLocale as setParaglideLocale, type Locale } from '$lib/paraglide/runtime'; import { setLocale as setParaglideLocale, type Locale } from '$lib/paraglide/runtime';
import { setDefaultOptions } from 'date-fns';
import { z } from 'zod/v4'; import { z } from 'zod/v4';
export function setLocale(locale: Locale, reload = true) { export async function setLocale(locale: Locale, reload = true) {
import(`../../../node_modules/zod/v4/locales/${locale}.js`) const [zodResult, dateFnsResult] = await Promise.allSettled([
.then((zodLocale) => z.config(zodLocale.default())) import(`../../../node_modules/zod/v4/locales/${locale}.js`),
.finally(() => { import(`../../../node_modules/date-fns/locale/${locale}.js`)
setParaglideLocale(locale, { reload }); ]);
if (zodResult.status === 'fulfilled') {
z.config(zodResult.value.default());
} else {
console.warn(`Failed to load zod locale for ${locale}:`, zodResult.reason);
}
setParaglideLocale(locale, { reload });
if (dateFnsResult.status === 'fulfilled') {
setDefaultOptions({
locale: dateFnsResult.value.default
}); });
} else {
console.warn(`Failed to load date-fns locale for ${locale}:`, dateFnsResult.reason);
}
} }

View File

@@ -6,8 +6,6 @@
import Header from '$lib/components/header/header.svelte'; import Header from '$lib/components/header/header.svelte';
import { Toaster } from '$lib/components/ui/sonner'; import { Toaster } from '$lib/components/ui/sonner';
import { m } from '$lib/paraglide/messages'; import { m } from '$lib/paraglide/messages';
import appConfigStore from '$lib/stores/application-configuration-store';
import userStore from '$lib/stores/user-store';
import { getAuthRedirectPath } from '$lib/utils/redirection-util'; import { getAuthRedirectPath } from '$lib/utils/redirection-util';
import { ModeWatcher } from 'mode-watcher'; import { ModeWatcher } from 'mode-watcher';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
@@ -28,14 +26,6 @@
if (redirectPath) { if (redirectPath) {
goto(redirectPath); goto(redirectPath);
} }
if (user) {
userStore.setUser(user);
}
if (appConfig) {
appConfigStore.set(appConfig);
}
</script> </script>
{#if !appConfig} {#if !appConfig}

View File

@@ -1,5 +1,7 @@
import AppConfigService from '$lib/services/app-config-service'; import AppConfigService from '$lib/services/app-config-service';
import UserService from '$lib/services/user-service'; import UserService from '$lib/services/user-service';
import appConfigStore from '$lib/stores/application-configuration-store';
import userStore from '$lib/stores/user-store';
import type { LayoutLoad } from './$types'; import type { LayoutLoad } from './$types';
export const ssr = false; export const ssr = false;
@@ -19,6 +21,14 @@ export const load: LayoutLoad = async () => {
const [user, appConfig] = await Promise.all([userPromise, appConfigPromise]); const [user, appConfig] = await Promise.all([userPromise, appConfigPromise]);
if (user) {
await userStore.setUser(user);
}
if (appConfig) {
appConfigStore.set(appConfig);
}
return { return {
user, user,
appConfig appConfig

View File

@@ -44,7 +44,7 @@
const loginOptions = await webauthnService.getLoginOptions(); const loginOptions = await webauthnService.getLoginOptions();
const authResponse = await startAuthentication({ optionsJSON: loginOptions }); const authResponse = await startAuthentication({ optionsJSON: loginOptions });
const user = await webauthnService.finishLogin(authResponse); const user = await webauthnService.finishLogin(authResponse);
userStore.setUser(user); await userStore.setUser(user);
} }
if (!authorizationConfirmed) { if (!authorizationConfirmed) {

View File

@@ -45,7 +45,7 @@
const loginOptions = await webauthnService.getLoginOptions(); const loginOptions = await webauthnService.getLoginOptions();
const authResponse = await startAuthentication({ optionsJSON: loginOptions }); const authResponse = await startAuthentication({ optionsJSON: loginOptions });
const user = await webauthnService.finishLogin(authResponse); const user = await webauthnService.finishLogin(authResponse);
userStore.setUser(user); await userStore.setUser(user);
} }
const info = await oidcService.getDeviceCodeInfo(userCode); const info = await oidcService.getDeviceCodeInfo(userCode);

View File

@@ -23,7 +23,7 @@
const authResponse = await startAuthentication({ optionsJSON: loginOptions }); const authResponse = await startAuthentication({ optionsJSON: loginOptions });
const user = await webauthnService.finishLogin(authResponse); const user = await webauthnService.finishLogin(authResponse);
userStore.setUser(user); await userStore.setUser(user);
goto('/settings'); goto('/settings');
} catch (e) { } catch (e) {
error = getWebauthnErrorMessage(e); error = getWebauthnErrorMessage(e);

View File

@@ -23,7 +23,7 @@
isLoading = true; isLoading = true;
try { try {
const user = await userService.exchangeOneTimeAccessToken(code); const user = await userService.exchangeOneTimeAccessToken(code);
userStore.setUser(user); await userStore.setUser(user);
try { try {
goto(data.redirect); goto(data.redirect);

View File

@@ -31,7 +31,7 @@
...$userStore!, ...$userStore!,
locale locale
}); });
setLocale(locale); await setLocale(locale);
} }
</script> </script>

View File

@@ -3,25 +3,25 @@
import * as Pagination from '$lib/components/ui/pagination'; import * as Pagination from '$lib/components/ui/pagination';
import { m } from '$lib/paraglide/messages'; import { m } from '$lib/paraglide/messages';
import OIDCService from '$lib/services/oidc-service'; import OIDCService from '$lib/services/oidc-service';
import type { AuthorizedOidcClient, OidcClientMetaData } from '$lib/types/oidc.type'; import type { AccessibleOidcClient, OidcClientMetaData } from '$lib/types/oidc.type';
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type'; import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
import { axiosErrorToast } from '$lib/utils/error-util'; import { axiosErrorToast } from '$lib/utils/error-util';
import { LayoutDashboard } from '@lucide/svelte'; import { LayoutDashboard } from '@lucide/svelte';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { default as AuthorizedOidcClientCard } from './authorized-oidc-client-card.svelte'; import AuthorizedOidcClientCard from './authorized-oidc-client-card.svelte';
let { data } = $props(); let { data } = $props();
let authorizedClients: Paginated<AuthorizedOidcClient> = $state(data.authorizedClients); let clients: Paginated<AccessibleOidcClient> = $state(data.clients);
let requestOptions: SearchPaginationSortRequest = $state(data.appRequestOptions); let requestOptions: SearchPaginationSortRequest = $state(data.appRequestOptions);
const oidcService = new OIDCService(); const oidcService = new OIDCService();
async function onRefresh(options: SearchPaginationSortRequest) { async function onRefresh(options: SearchPaginationSortRequest) {
authorizedClients = await oidcService.listAuthorizedClients(options); clients = await oidcService.listOwnAccessibleClients(options);
} }
async function onPageChange(page: number) { async function onPageChange(page: number) {
requestOptions.pagination = { limit: authorizedClients.pagination.itemsPerPage, page }; requestOptions.pagination = { limit: clients.pagination.itemsPerPage, page };
onRefresh(requestOptions); onRefresh(requestOptions);
} }
@@ -64,7 +64,7 @@
</h1> </h1>
</div> </div>
{#if authorizedClients.data.length === 0} {#if clients.data.length === 0}
<div class="py-16 text-center"> <div class="py-16 text-center">
<LayoutDashboard class="text-muted-foreground mx-auto mb-4 size-16" /> <LayoutDashboard class="text-muted-foreground mx-auto mb-4 size-16" />
<h3 class="text-muted-foreground mb-2 text-lg font-medium"> <h3 class="text-muted-foreground mb-2 text-lg font-medium">
@@ -76,20 +76,23 @@
</div> </div>
{:else} {:else}
<div class="space-y-8"> <div class="space-y-8">
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4"> <div
{#each authorizedClients.data as authorizedClient} class="grid gap-3"
<AuthorizedOidcClientCard {authorizedClient} onRevoke={revokeAuthorizedClient} /> style="grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr));"
>
{#each clients.data as client}
<AuthorizedOidcClientCard {client} onRevoke={revokeAuthorizedClient} />
{/each} {/each}
</div> </div>
{#if authorizedClients.pagination.totalPages > 1} {#if clients.pagination.totalPages > 1}
<div class="border-border flex items-center justify-center border-t pt-3"> <div class="border-border flex items-center justify-center border-t pt-3">
<Pagination.Root <Pagination.Root
class="mx-0 w-auto" class="mx-0 w-auto"
count={authorizedClients.pagination.totalItems} count={clients.pagination.totalItems}
perPage={authorizedClients.pagination.itemsPerPage} perPage={clients.pagination.itemsPerPage}
{onPageChange} {onPageChange}
page={authorizedClients.pagination.currentPage} page={clients.pagination.currentPage}
> >
{#snippet children({ pages })} {#snippet children({ pages })}
<Pagination.Content class="flex justify-center"> <Pagination.Content class="flex justify-center">
@@ -101,7 +104,7 @@
<Pagination.Item> <Pagination.Item>
<Pagination.Link <Pagination.Link
{page} {page}
isActive={authorizedClients.pagination.currentPage === page.value} isActive={clients.pagination.currentPage === page.value}
> >
{page.value} {page.value}
</Pagination.Link> </Pagination.Link>

View File

@@ -16,7 +16,7 @@ export const load: PageLoad = async () => {
} }
}; };
const authorizedClients = await oidcService.listAuthorizedClients(appRequestOptions); const clients = await oidcService.listOwnAccessibleClients(appRequestOptions);
return { authorizedClients, appRequestOptions }; return { clients, appRequestOptions };
}; };

View File

@@ -4,23 +4,26 @@
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
import { m } from '$lib/paraglide/messages'; import { m } from '$lib/paraglide/messages';
import userStore from '$lib/stores/user-store'; import userStore from '$lib/stores/user-store';
import type { AuthorizedOidcClient, OidcClientMetaData } from '$lib/types/oidc.type'; import type { AccessibleOidcClient, OidcClientMetaData } from '$lib/types/oidc.type';
import { cachedApplicationLogo, cachedOidcClientLogo } from '$lib/utils/cached-image-util'; import { cachedApplicationLogo, cachedOidcClientLogo } from '$lib/utils/cached-image-util';
import { import {
LucideBan, LucideBan,
LucideEllipsisVertical, LucideEllipsisVertical,
LucideExternalLink, LucideExternalLink,
LucideLogIn,
LucidePencil LucidePencil
} from '@lucide/svelte'; } from '@lucide/svelte';
import { formatDistanceToNow } from 'date-fns';
import { mode } from 'mode-watcher'; import { mode } from 'mode-watcher';
let { let {
authorizedClient, client,
onRevoke onRevoke
}: { }: {
authorizedClient: AuthorizedOidcClient; client: AccessibleOidcClient;
onRevoke: (client: OidcClientMetaData) => Promise<void>; onRevoke: (client: OidcClientMetaData) => Promise<void>;
} = $props(); } = $props();
@@ -28,7 +31,7 @@
</script> </script>
<Card.Root <Card.Root
class="border-muted group h-[140px] p-5 transition-all duration-200 hover:shadow-md" class="border-muted group relative h-[140px] p-5 transition-all duration-200 hover:shadow-md"
data-testid="authorized-oidc-client-card" data-testid="authorized-oidc-client-card"
> >
<Card.Content class=" p-0"> <Card.Content class=" p-0">
@@ -36,60 +39,84 @@
<div class="aspect-square h-[56px]"> <div class="aspect-square h-[56px]">
<ImageBox <ImageBox
class="grow rounded-lg object-contain" class="grow rounded-lg object-contain"
src={authorizedClient.client.hasLogo src={client.hasLogo
? cachedOidcClientLogo.getUrl(authorizedClient.client.id) ? cachedOidcClientLogo.getUrl(client.id)
: cachedApplicationLogo.getUrl(isLightMode)} : cachedApplicationLogo.getUrl(isLightMode)}
alt={m.name_logo({ name: authorizedClient.client.name })} alt={m.name_logo({ name: client.name })}
/> />
</div> </div>
<div class="flex w-full justify-between gap-3"> <div class="flex w-full justify-between gap-3">
<div> <div>
<div class="mb-1 flex items-start gap-2"> <div class="mb-1 flex items-start gap-2">
<h3 <h3
class="text-foreground line-clamp-2 leading-tight font-semibold break-words break-all text-ellipsis" class="text-foreground line-clamp-2 text-ellipsis break-words break-all font-semibold leading-tight"
> >
{authorizedClient.client.name} {client.name}
</h3> </h3>
</div> </div>
{#if authorizedClient.client.launchURL} {#if client.launchURL}
<p <p
class="text-muted-foreground line-clamp-1 text-xs break-words break-all text-ellipsis" class="text-muted-foreground line-clamp-1 text-ellipsis break-words break-all text-xs"
> >
{new URL(authorizedClient.client.launchURL).hostname} {new URL(client.launchURL).hostname}
</p> </p>
{/if} {/if}
</div> </div>
<div> {#if $userStore?.isAdmin || client.lastUsedAt}
<DropdownMenu.Root> <div>
<DropdownMenu.Trigger> <DropdownMenu.Root>
<LucideEllipsisVertical class="size-4" /> <DropdownMenu.Trigger>
<span class="sr-only">{m.toggle_menu()}</span> <LucideEllipsisVertical class="size-4" />
</DropdownMenu.Trigger> <span class="sr-only">{m.toggle_menu()}</span>
<DropdownMenu.Content align="end"> </DropdownMenu.Trigger>
{#if $userStore?.isAdmin} <DropdownMenu.Content align="end">
<DropdownMenu.Item {#if $userStore?.isAdmin}
onclick={() => goto(`/settings/admin/oidc-clients/${authorizedClient.client.id}`)} <DropdownMenu.Item
><LucidePencil class="mr-2 size-4" /> {m.edit()}</DropdownMenu.Item onclick={() => goto(`/settings/admin/oidc-clients/${client.id}`)}
> ><LucidePencil class="mr-2 size-4" /> {m.edit()}</DropdownMenu.Item
{/if} >
<DropdownMenu.Item {/if}
class="text-red-500 focus:!text-red-700" {#if client.lastUsedAt}
onclick={() => onRevoke(authorizedClient.client)} <DropdownMenu.Item
><LucideBan class="mr-2 size-4" />{m.revoke()}</DropdownMenu.Item class="text-red-500 focus:!text-red-700"
> onclick={() => onRevoke(client)}
</DropdownMenu.Content> ><LucideBan class="mr-2 size-4" />{m.revoke()}</DropdownMenu.Item
</DropdownMenu.Root> >
</div> {/if}
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
{/if}
</div> </div>
</div> </div>
<div class="mt-2 flex justify-end"> <div class="mt-2 flex items-end justify-between">
{#if client.lastUsedAt}
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger>
<p class="text-muted-foreground flex items-center text-xs">
<LucideLogIn class="mr-1 size-3" />
{formatDistanceToNow(client.lastUsedAt, { addSuffix: true })}
</p>
</Tooltip.Trigger>
<Tooltip.Content
>{m.last_signed_in_ago({
time: formatDistanceToNow(client.lastUsedAt)
})}</Tooltip.Content
>
</Tooltip.Root></Tooltip.Provider
>
{:else}
<div></div>
{/if}
<Button <Button
href={authorizedClient.client.launchURL} href={client.launchURL}
target="_blank" target="_blank"
size="sm" size="sm"
class="h-8 text-xs" class="h-8 text-xs"
disabled={!authorizedClient.client.launchURL} rel="noopener noreferrer"
disabled={!client.launchURL}
> >
{m.launch()} {m.launch()}
<LucideExternalLink class="ml-1 size-3" /> <LucideExternalLink class="ml-1 size-3" />

View File

@@ -32,7 +32,7 @@
return false; return false;
} }
userStore.setUser(result.data); await userStore.setUser(result.data);
isLoading = false; isLoading = false;
goto('/signup/add-passkey'); goto('/signup/add-passkey');

View File

@@ -30,7 +30,7 @@
return false; return false;
} }
userStore.setUser(result.data); await userStore.setUser(result.data);
isLoading = false; isLoading = false;
goto('/signup/add-passkey'); goto('/signup/add-passkey');

8
pnpm-lock.yaml generated
View File

@@ -22,6 +22,9 @@ importers:
clsx: clsx:
specifier: ^2.1.1 specifier: ^2.1.1
version: 2.1.1 version: 2.1.1
date-fns:
specifier: ^4.1.0
version: 4.1.0
jose: jose:
specifier: ^5.10.0 specifier: ^5.10.0
version: 5.10.0 version: 5.10.0
@@ -977,6 +980,9 @@ packages:
engines: {node: '>=4'} engines: {node: '>=4'}
hasBin: true hasBin: true
date-fns@4.1.0:
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
dayjs@1.11.13: dayjs@1.11.13:
resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==} resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==}
@@ -2919,6 +2925,8 @@ snapshots:
cssesc@3.0.0: {} cssesc@3.0.0: {}
date-fns@4.1.0: {}
dayjs@1.11.13: dayjs@1.11.13:
optional: true optional: true

View File

@@ -1,16 +1,17 @@
import test, { expect } from '@playwright/test'; import test, { expect } from '@playwright/test';
import authUtil from 'utils/auth.util';
import { oidcClients } from '../data'; import { oidcClients } from '../data';
import { cleanupBackend } from '../utils/cleanup.util'; import { cleanupBackend } from '../utils/cleanup.util';
test.beforeEach(() => cleanupBackend()); test.beforeEach(() => cleanupBackend());
test('Dashboard shows all authorized clients in the correct order', async ({ page }) => { test('Dashboard shows all clients in the correct order', async ({ page }) => {
const client1 = oidcClients.tailscale; const client1 = oidcClients.tailscale;
const client2 = oidcClients.nextcloud; const client2 = oidcClients.nextcloud;
await page.goto('/settings/apps'); await page.goto('/settings/apps');
await expect(page.getByTestId('authorized-oidc-client-card')).toHaveCount(2); await expect(page.getByTestId('authorized-oidc-client-card')).toHaveCount(4);
// Should be first // Should be first
const card1 = page.getByTestId('authorized-oidc-client-card').first(); const card1 = page.getByTestId('authorized-oidc-client-card').first();
@@ -22,6 +23,20 @@ test('Dashboard shows all authorized clients in the correct order', async ({ pag
await expect(card2.getByText(new URL(client2.launchURL).hostname)).toBeVisible(); await expect(card2.getByText(new URL(client2.launchURL).hostname)).toBeVisible();
}); });
test('Dashboard shows only clients where user has access', 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(3);
const cardTexts = await cards.allTextContents();
expect(cardTexts.some((text) => text.includes(notVisibleClient.name))).toBe(false);
});
test('Revoke authorized client', async ({ page }) => { test('Revoke authorized client', async ({ page }) => {
const client = oidcClients.tailscale; const client = oidcClients.tailscale;
@@ -40,7 +55,7 @@ test('Revoke authorized client', async ({ page }) => {
`The access to ${client.name} has been successfully revoked.` `The access to ${client.name} has been successfully revoked.`
); );
await expect(page.getByTestId('authorized-oidc-client-card')).toHaveCount(1); await expect(page.getByTestId('authorized-oidc-client-card')).toHaveCount(4);
}); });
test('Launch authorized client', async ({ page }) => { test('Launch authorized client', async ({ page }) => {

View File

@@ -9,4 +9,12 @@ async function authenticate(page: Page) {
await page.getByRole('button', { name: 'Authenticate' }).click(); await page.getByRole('button', { name: 'Authenticate' }).click();
} }
export default { authenticate }; async function changeUser(page: Page, username: keyof typeof passkeyUtil.passkeys) {
await page.context().clearCookies();
await page.goto('/login');
await (await passkeyUtil.init(page)).addPasskey(username);
await page.getByRole('button', { name: 'Authenticate' }).click();
}
export default { authenticate, changeUser };

View File

@@ -67,4 +67,4 @@ async function addPasskey(
}); });
} }
export default { init }; export default { init, passkeys };