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:
@@ -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 == "" {
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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").
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
...$userStore!,
|
...$userStore!,
|
||||||
locale
|
locale
|
||||||
});
|
});
|
||||||
setLocale(locale);
|
await setLocale(locale);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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
8
pnpm-lock.yaml
generated
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -67,4 +67,4 @@ async function addPasskey(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default { init };
|
export default { init, passkeys };
|
||||||
|
|||||||
Reference in New Issue
Block a user