diff --git a/backend/internal/controller/oidc_controller.go b/backend/internal/controller/oidc_controller.go index 6f457321..2f8a3ea3 100644 --- a/backend/internal/controller/oidc_controller.go +++ b/backend/internal/controller/oidc_controller.go @@ -55,10 +55,12 @@ func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi group.POST("/oidc/device/verify", authMiddleware.WithAdminNotRequired().Add(), oc.verifyDeviceCodeHandler) 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/:id/clients", authMiddleware.Add(), oc.listAuthorizedClientsHandler) + group.GET("/oidc/users/me/authorized-clients", authMiddleware.WithAdminNotRequired().Add(), oc.listOwnAuthorizedClientsHandler) + 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[direction] query string false "Sort direction (asc or desc)" default("asc") // @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) { userID := c.GetString("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[direction] query string false "Sort direction (asc or desc)" default("asc") // @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) { userID := c.Param("id") oc.listAuthorizedClients(c, userID) @@ -713,7 +715,7 @@ func (oc *OidcController) listAuthorizedClients(c *gin.Context, userID string) { // @Tags OIDC // @Param clientId path string true "Client ID to revoke authorization for" // @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) { clientID := c.Param("clientId") @@ -728,6 +730,37 @@ func (oc *OidcController) revokeOwnClientAuthorizationHandler(c *gin.Context) { 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) { userCode := c.Query("code") if userCode == "" { diff --git a/backend/internal/dto/oidc_dto.go b/backend/internal/dto/oidc_dto.go index ab9b937a..fdd907a7 100644 --- a/backend/internal/dto/oidc_dto.go +++ b/backend/internal/dto/oidc_dto.go @@ -159,3 +159,8 @@ type OidcClientPreviewDto struct { AccessToken map[string]any `json:"accessToken"` UserInfo map[string]any `json:"userInfo"` } + +type AccessibleOidcClientDto struct { + OidcClientMetaDataDto + LastUsedAt *datatype.DateTime `json:"lastUsedAt"` +} diff --git a/backend/internal/model/oidc.go b/backend/internal/model/oidc.go index 5becca05..0b9f825c 100644 --- a/backend/internal/model/oidc.go +++ b/backend/internal/model/oidc.go @@ -51,9 +51,10 @@ type OidcClient struct { Credentials OidcClientCredentials LaunchURL *string - AllowedUserGroups []UserGroup `gorm:"many2many:oidc_clients_allowed_user_groups;"` - CreatedByID string - CreatedBy User + AllowedUserGroups []UserGroup `gorm:"many2many:oidc_clients_allowed_user_groups;"` + CreatedByID string + CreatedBy User + UserAuthorizedOidcClients []UserAuthorizedOidcClient `gorm:"foreignKey:ClientID;references:ID"` } type OidcRefreshToken struct { diff --git a/backend/internal/service/oidc_service.go b/backend/internal/service/oidc_service.go index be3407ce..ceebd113 100644 --- a/backend/internal/service/oidc_service.go +++ b/backend/internal/service/oidc_service.go @@ -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 - isValidSortDirection := sortedPaginationRequest.Sort.Direction == "asc" || sortedPaginationRequest.Sort.Direction == "desc" - if sortedPaginationRequest.Sort.Column == "allowedUserGroupsCount" && isValidSortDirection { + if sortedPaginationRequest.Sort.Column == "allowedUserGroupsCount" && utils.IsValidSortDirection(sortedPaginationRequest.Sort.Direction) { 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"). Group("oidc_clients.id"). @@ -1336,6 +1335,81 @@ func (s *OidcService) RevokeAuthorizedClient(ctx context.Context, userID string, 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) { refreshToken, err := utils.GenerateRandomAlphanumericString(40) if err != nil { diff --git a/backend/internal/service/user_group_service.go b/backend/internal/service/user_group_service.go index a6aedbc5..433c5fe7 100644 --- a/backend/internal/service/user_group_service.go +++ b/backend/internal/service/user_group_service.go @@ -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 - isValidSortDirection := sortedPaginationRequest.Sort.Direction == "asc" || sortedPaginationRequest.Sort.Direction == "desc" - if sortedPaginationRequest.Sort.Column == "userCount" && isValidSortDirection { + if sortedPaginationRequest.Sort.Column == "userCount" && utils.IsValidSortDirection(sortedPaginationRequest.Sort.Direction) { 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"). Group("user_groups.id"). diff --git a/backend/internal/utils/paging_util.go b/backend/internal/utils/paging_util.go index 4ccd0741..d69e1bca 100644 --- a/backend/internal/utils/paging_util.go +++ b/backend/internal/utils/paging_util.go @@ -3,6 +3,7 @@ package utils import ( "reflect" "strconv" + "strings" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -35,9 +36,7 @@ func PaginateAndSort(sortedPaginationRequest SortedPaginationRequest, query *gor sortField, sortFieldFound := reflect.TypeOf(result).Elem().Elem().FieldByName(capitalizedSortColumn) isSortable, _ := strconv.ParseBool(sortField.Tag.Get("sortable")) - if sort.Direction == "" || (sort.Direction != "asc" && sort.Direction != "desc") { - sort.Direction = "asc" - } + sort.Direction = NormalizeSortDirection(sort.Direction) if sortFieldFound && isSortable { columnName := CamelCaseToSnakeCase(sort.Column) @@ -85,3 +84,16 @@ func Paginate(page int, pageSize int, query *gorm.DB, result interface{}) (Pagin ItemsPerPage: pageSize, }, 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" +} diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 5b1c5c16..ad385218 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -429,5 +429,6 @@ "client_name_description": "The name of the client that shows in the Pocket ID UI.", "revoke_access": "Revoke Access", "revoke_access_description": "Revoke access to {clientName}. {clientName} 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" } diff --git a/frontend/package.json b/frontend/package.json index 9a620923..5940ced2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,6 +18,7 @@ "@tailwindcss/vite": "^4.1.11", "axios": "^1.11.0", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "jose": "^5.10.0", "qrcode": "^1.5.4", "sveltekit-superforms": "^2.27.1", diff --git a/frontend/src/lib/services/oidc-service.ts b/frontend/src/lib/services/oidc-service.ts index 2794ea40..c7d5b798 100644 --- a/frontend/src/lib/services/oidc-service.ts +++ b/frontend/src/lib/services/oidc-service.ts @@ -1,5 +1,5 @@ import type { - AuthorizedOidcClient, + AccessibleOidcClient, AuthorizeResponse, OidcClient, OidcClientCreate, @@ -115,18 +115,12 @@ class OidcService extends APIService { return response.data; } - async listAuthorizedClients(options?: SearchPaginationSortRequest) { + async listOwnAccessibleClients(options?: SearchPaginationSortRequest) { const res = await this.api.get('/oidc/users/me/clients', { params: options }); - return res.data as Paginated; - } - async listAuthorizedClientsForUser(userId: string, options?: SearchPaginationSortRequest) { - const res = await this.api.get(`/oidc/users/${userId}/clients`, { - params: options - }); - return res.data as Paginated; + return res.data as Paginated; } async revokeOwnAuthorizedClient(clientId: string) { diff --git a/frontend/src/lib/stores/user-store.ts b/frontend/src/lib/stores/user-store.ts index b0de4690..51b2bc8f 100644 --- a/frontend/src/lib/stores/user-store.ts +++ b/frontend/src/lib/stores/user-store.ts @@ -4,9 +4,9 @@ import { writable } from 'svelte/store'; const userStore = writable(null); -const setUser = (user: User) => { +const setUser = async (user: User) => { if (user.locale) { - setLocale(user.locale, false); + await setLocale(user.locale, false); } userStore.set(user); }; diff --git a/frontend/src/lib/types/oidc.type.ts b/frontend/src/lib/types/oidc.type.ts index 6430bf4e..bad54330 100644 --- a/frontend/src/lib/types/oidc.type.ts +++ b/frontend/src/lib/types/oidc.type.ts @@ -53,7 +53,6 @@ export type AuthorizeResponse = { issuer: string; }; -export type AuthorizedOidcClient = { - scope: string; - client: OidcClientMetaData; +export type AccessibleOidcClient = OidcClientMetaData & { + lastUsedAt: Date | null; }; diff --git a/frontend/src/lib/utils/locale.util.ts b/frontend/src/lib/utils/locale.util.ts index 467ff143..efa1ddff 100644 --- a/frontend/src/lib/utils/locale.util.ts +++ b/frontend/src/lib/utils/locale.util.ts @@ -1,10 +1,26 @@ import { setLocale as setParaglideLocale, type Locale } from '$lib/paraglide/runtime'; +import { setDefaultOptions } from 'date-fns'; import { z } from 'zod/v4'; -export function setLocale(locale: Locale, reload = true) { - import(`../../../node_modules/zod/v4/locales/${locale}.js`) - .then((zodLocale) => z.config(zodLocale.default())) - .finally(() => { - setParaglideLocale(locale, { reload }); +export async function setLocale(locale: Locale, reload = true) { + const [zodResult, dateFnsResult] = await Promise.allSettled([ + import(`../../../node_modules/zod/v4/locales/${locale}.js`), + import(`../../../node_modules/date-fns/locale/${locale}.js`) + ]); + + 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); + } } diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 428f14f7..84dcc32f 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -6,8 +6,6 @@ import Header from '$lib/components/header/header.svelte'; import { Toaster } from '$lib/components/ui/sonner'; 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 { ModeWatcher } from 'mode-watcher'; import type { Snippet } from 'svelte'; @@ -28,14 +26,6 @@ if (redirectPath) { goto(redirectPath); } - - if (user) { - userStore.setUser(user); - } - - if (appConfig) { - appConfigStore.set(appConfig); - } {#if !appConfig} diff --git a/frontend/src/routes/+layout.ts b/frontend/src/routes/+layout.ts index 1fb83573..48016f73 100644 --- a/frontend/src/routes/+layout.ts +++ b/frontend/src/routes/+layout.ts @@ -1,5 +1,7 @@ import AppConfigService from '$lib/services/app-config-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'; export const ssr = false; @@ -19,6 +21,14 @@ export const load: LayoutLoad = async () => { const [user, appConfig] = await Promise.all([userPromise, appConfigPromise]); + if (user) { + await userStore.setUser(user); + } + + if (appConfig) { + appConfigStore.set(appConfig); + } + return { user, appConfig diff --git a/frontend/src/routes/authorize/+page.svelte b/frontend/src/routes/authorize/+page.svelte index fcbc08ef..37787768 100644 --- a/frontend/src/routes/authorize/+page.svelte +++ b/frontend/src/routes/authorize/+page.svelte @@ -44,7 +44,7 @@ const loginOptions = await webauthnService.getLoginOptions(); const authResponse = await startAuthentication({ optionsJSON: loginOptions }); const user = await webauthnService.finishLogin(authResponse); - userStore.setUser(user); + await userStore.setUser(user); } if (!authorizationConfirmed) { diff --git a/frontend/src/routes/device/+page.svelte b/frontend/src/routes/device/+page.svelte index 5e4fc81f..999bed61 100644 --- a/frontend/src/routes/device/+page.svelte +++ b/frontend/src/routes/device/+page.svelte @@ -45,7 +45,7 @@ const loginOptions = await webauthnService.getLoginOptions(); const authResponse = await startAuthentication({ optionsJSON: loginOptions }); const user = await webauthnService.finishLogin(authResponse); - userStore.setUser(user); + await userStore.setUser(user); } const info = await oidcService.getDeviceCodeInfo(userCode); diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte index d3ebfc63..68133a13 100644 --- a/frontend/src/routes/login/+page.svelte +++ b/frontend/src/routes/login/+page.svelte @@ -23,7 +23,7 @@ const authResponse = await startAuthentication({ optionsJSON: loginOptions }); const user = await webauthnService.finishLogin(authResponse); - userStore.setUser(user); + await userStore.setUser(user); goto('/settings'); } catch (e) { error = getWebauthnErrorMessage(e); diff --git a/frontend/src/routes/login/alternative/code/+page.svelte b/frontend/src/routes/login/alternative/code/+page.svelte index b4953e11..c0ba5c29 100644 --- a/frontend/src/routes/login/alternative/code/+page.svelte +++ b/frontend/src/routes/login/alternative/code/+page.svelte @@ -23,7 +23,7 @@ isLoading = true; try { const user = await userService.exchangeOneTimeAccessToken(code); - userStore.setUser(user); + await userStore.setUser(user); try { goto(data.redirect); diff --git a/frontend/src/routes/settings/account/locale-picker.svelte b/frontend/src/routes/settings/account/locale-picker.svelte index 4f3cedd7..639b032c 100644 --- a/frontend/src/routes/settings/account/locale-picker.svelte +++ b/frontend/src/routes/settings/account/locale-picker.svelte @@ -31,7 +31,7 @@ ...$userStore!, locale }); - setLocale(locale); + await setLocale(locale); } diff --git a/frontend/src/routes/settings/apps/+page.svelte b/frontend/src/routes/settings/apps/+page.svelte index e61976c8..8da5d47a 100644 --- a/frontend/src/routes/settings/apps/+page.svelte +++ b/frontend/src/routes/settings/apps/+page.svelte @@ -3,25 +3,25 @@ import * as Pagination from '$lib/components/ui/pagination'; import { m } from '$lib/paraglide/messages'; 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 { axiosErrorToast } from '$lib/utils/error-util'; import { LayoutDashboard } from '@lucide/svelte'; 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 authorizedClients: Paginated = $state(data.authorizedClients); + let clients: Paginated = $state(data.clients); let requestOptions: SearchPaginationSortRequest = $state(data.appRequestOptions); const oidcService = new OIDCService(); async function onRefresh(options: SearchPaginationSortRequest) { - authorizedClients = await oidcService.listAuthorizedClients(options); + clients = await oidcService.listOwnAccessibleClients(options); } async function onPageChange(page: number) { - requestOptions.pagination = { limit: authorizedClients.pagination.itemsPerPage, page }; + requestOptions.pagination = { limit: clients.pagination.itemsPerPage, page }; onRefresh(requestOptions); } @@ -64,7 +64,7 @@ - {#if authorizedClients.data.length === 0} + {#if clients.data.length === 0}

@@ -76,20 +76,23 @@

{:else}
-
- {#each authorizedClients.data as authorizedClient} - +
+ {#each clients.data as client} + {/each}
- {#if authorizedClients.pagination.totalPages > 1} + {#if clients.pagination.totalPages > 1}
{#snippet children({ pages })} @@ -101,7 +104,7 @@ {page.value} diff --git a/frontend/src/routes/settings/apps/+page.ts b/frontend/src/routes/settings/apps/+page.ts index 675892d4..85a61ffd 100644 --- a/frontend/src/routes/settings/apps/+page.ts +++ b/frontend/src/routes/settings/apps/+page.ts @@ -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 }; }; diff --git a/frontend/src/routes/settings/apps/authorized-oidc-client-card.svelte b/frontend/src/routes/settings/apps/authorized-oidc-client-card.svelte index 9627f80d..01be3d9d 100644 --- a/frontend/src/routes/settings/apps/authorized-oidc-client-card.svelte +++ b/frontend/src/routes/settings/apps/authorized-oidc-client-card.svelte @@ -4,23 +4,26 @@ import { Button } from '$lib/components/ui/button'; import * as Card from '$lib/components/ui/card'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu'; + import * as Tooltip from '$lib/components/ui/tooltip'; import { m } from '$lib/paraglide/messages'; 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 { LucideBan, LucideEllipsisVertical, LucideExternalLink, + LucideLogIn, LucidePencil } from '@lucide/svelte'; + import { formatDistanceToNow } from 'date-fns'; import { mode } from 'mode-watcher'; let { - authorizedClient, + client, onRevoke }: { - authorizedClient: AuthorizedOidcClient; + client: AccessibleOidcClient; onRevoke: (client: OidcClientMetaData) => Promise; } = $props(); @@ -28,7 +31,7 @@ @@ -36,60 +39,84 @@

- {authorizedClient.client.name} + {client.name}

- {#if authorizedClient.client.launchURL} + {#if client.launchURL}

- {new URL(authorizedClient.client.launchURL).hostname} + {new URL(client.launchURL).hostname}

{/if}
-
- - - - {m.toggle_menu()} - - - {#if $userStore?.isAdmin} - goto(`/settings/admin/oidc-clients/${authorizedClient.client.id}`)} - > {m.edit()} - {/if} - onRevoke(authorizedClient.client)} - >{m.revoke()} - - -
+ {#if $userStore?.isAdmin || client.lastUsedAt} +
+ + + + {m.toggle_menu()} + + + {#if $userStore?.isAdmin} + goto(`/settings/admin/oidc-clients/${client.id}`)} + > {m.edit()} + {/if} + {#if client.lastUsedAt} + onRevoke(client)} + >{m.revoke()} + {/if} + + +
+ {/if}
-
+
+ {#if client.lastUsedAt} + + + +

+ + {formatDistanceToNow(client.lastUsedAt, { addSuffix: true })} +

+
+ {m.last_signed_in_ago({ + time: formatDistanceToNow(client.lastUsedAt) + })} +
+ {:else} +
+ {/if}