Compare commits

..

10 Commits

Author SHA1 Message Date
Elias Schneider
24889f9ebc release: 1.1.0 2025-05-28 11:30:09 +02:00
Elias Schneider
e0ec607198 feat: add daily heartbeat request for counting Pocket ID instances (#578) 2025-05-28 11:19:45 +02:00
Maxim Baz
d29fca155e ci/cd: tag container images with v{major} (#577) 2025-05-27 14:01:49 +02:00
Elias Schneider
e2e26b53b3 chore(translations): update translations via Crowdin (#575) 2025-05-26 11:07:40 +02:00
github-actions[bot]
948efbd9c1 chore: update AAGUIDs (#576)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2025-05-26 11:06:41 +02:00
Elias Schneider
f03b80f9d7 fix: run user group count inside a transaction 2025-05-25 22:24:28 +02:00
Kyle Mendell
38d7ee4432 feat: show allowed group count on oidc client list (#567)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-05-25 19:22:25 +00:00
Kyle Mendell
f66e8e8b44 fix: use ldapAttributeUserUsername for finding group members (#565)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-05-25 20:37:17 +02:00
Elias Schneider
ee133dbceb chore(translations): update translations via Crowdin (#573) 2025-05-25 20:14:46 +02:00
Elias Schneider
68e4b67bd2 feat: require user verification for passkey sign in 2025-05-25 17:09:05 +02:00
37 changed files with 275 additions and 87 deletions

View File

@@ -52,6 +52,7 @@ jobs:
tags: |
type=semver,pattern={{version}},prefix=v
type=semver,pattern={{major}}.{{minor}},prefix=v
type=semver,pattern={{major}},prefix=v
- name: Install frontend dependencies
working-directory: frontend

View File

@@ -1 +1 @@
1.0.0
1.1.0

View File

@@ -1,3 +1,18 @@
## [](https://github.com/pocket-id/pocket-id/compare/v1.0.0...v) (2025-05-28)
### Features
* add daily heartbeat request for counting Pocket ID instances ([#578](https://github.com/pocket-id/pocket-id/issues/578)) ([e0ec607](https://github.com/pocket-id/pocket-id/commit/e0ec60719883c0230f1a16611b943d6f6f637157))
* require user verification for passkey sign in ([68e4b67](https://github.com/pocket-id/pocket-id/commit/68e4b67bd212e31ecc20277bfd293c94bf7f3642))
* show allowed group count on oidc client list ([#567](https://github.com/pocket-id/pocket-id/issues/567)) ([38d7ee4](https://github.com/pocket-id/pocket-id/commit/38d7ee4432e0dacc2cfbabad4bfd9336b8b84079))
### Bug Fixes
* run user group count inside a transaction ([f03b80f](https://github.com/pocket-id/pocket-id/commit/f03b80f9d7f2529d8cef23ca6a742a914a4ec883))
* use ldapAttributeUserUsername for finding group members ([#565](https://github.com/pocket-id/pocket-id/issues/565)) ([f66e8e8](https://github.com/pocket-id/pocket-id/commit/f66e8e8b4478c66ed1ada9168a272b33dbf494d0))
## [](https://github.com/pocket-id/pocket-id/compare/v0.53.0...v) (2025-05-24)

View File

@@ -17,6 +17,7 @@ require (
github.com/go-webauthn/webauthn v0.11.2
github.com/golang-migrate/migrate/v4 v4.18.2
github.com/google/uuid v1.6.0
github.com/hashicorp/go-uuid v1.0.3
github.com/joho/godotenv v1.5.1
github.com/lestrrat-go/jwx/v3 v3.0.0-beta1
github.com/mileusna/useragent v1.3.5

View File

@@ -40,7 +40,7 @@ func Bootstrap() error {
if err != nil {
return fmt.Errorf("failed to create job scheduler: %w", err)
}
err = registerScheduledJobs(ctx, db, svc, scheduler)
err = registerScheduledJobs(ctx, db, svc, httpClient, scheduler)
if err != nil {
return fmt.Errorf("failed to register scheduled jobs: %w", err)
}
@@ -48,7 +48,7 @@ func Bootstrap() error {
// Init the router
router := initRouter(db, svc)
// Run all background serivces
// Run all background services
// This call blocks until the context is canceled
err = utils.
NewServiceRunner(router, scheduler.Run).

View File

@@ -3,13 +3,14 @@ package bootstrap
import (
"context"
"fmt"
"net/http"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/job"
)
func registerScheduledJobs(ctx context.Context, db *gorm.DB, svc *services, scheduler *job.Scheduler) error {
func registerScheduledJobs(ctx context.Context, db *gorm.DB, svc *services, httpClient *http.Client, scheduler *job.Scheduler) error {
err := scheduler.RegisterLdapJobs(ctx, svc.ldapService, svc.appConfigService)
if err != nil {
return fmt.Errorf("failed to register LDAP jobs in scheduler: %w", err)
@@ -30,6 +31,10 @@ func registerScheduledJobs(ctx context.Context, db *gorm.DB, svc *services, sche
if err != nil {
return fmt.Errorf("failed to register API key expiration jobs in scheduler: %w", err)
}
err = scheduler.RegisterAnalyticsJob(ctx, svc.appConfigService, httpClient)
if err != nil {
return fmt.Errorf("failed to register analytics job in scheduler: %w", err)
}
return nil
}

View File

@@ -39,6 +39,7 @@ type EnvConfigSchema struct {
MetricsEnabled bool `env:"METRICS_ENABLED"`
TracingEnabled bool `env:"TRACING_ENABLED"`
TrustProxy bool `env:"TRUST_PROXY"`
AnalyticsDisabled bool `env:"ANALYTICS_DISABLED"`
}
var EnvConfig = &EnvConfigSchema{
@@ -57,6 +58,7 @@ var EnvConfig = &EnvConfigSchema{
MetricsEnabled: false,
TracingEnabled: false,
TrustProxy: false,
AnalyticsDisabled: false,
}
func init() {

View File

@@ -377,7 +377,7 @@ func (oc *OidcController) getClientHandler(c *gin.Context) {
// @Param limit query int false "Number of items per page" default(10)
// @Param sort_column query string false "Column to sort by" default("name")
// @Param sort_direction query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.OidcClientDto]
// @Success 200 {object} dto.Paginated[dto.OidcClientWithAllowedGroupsCountDto]
// @Security BearerAuth
// @Router /api/oidc/clients [get]
func (oc *OidcController) listClientsHandler(c *gin.Context) {
@@ -394,13 +394,23 @@ func (oc *OidcController) listClientsHandler(c *gin.Context) {
return
}
var clientsDto []dto.OidcClientDto
if err := dto.MapStructList(clients, &clientsDto); err != nil {
_ = c.Error(err)
return
// Map the user groups to DTOs
var clientsDto = make([]dto.OidcClientWithAllowedGroupsCountDto, len(clients))
for i, client := range clients {
var clientDto dto.OidcClientWithAllowedGroupsCountDto
if err := dto.MapStruct(client, &clientDto); err != nil {
_ = c.Error(err)
return
}
clientDto.AllowedUserGroupsCount, err = oc.oidcService.GetAllowedGroupsCountOfClient(c, client.ID)
if err != nil {
_ = c.Error(err)
return
}
clientsDto[i] = clientDto
}
c.JSON(http.StatusOK, dto.Paginated[dto.OidcClientDto]{
c.JSON(http.StatusOK, dto.Paginated[dto.OidcClientWithAllowedGroupsCountDto]{
Data: clientsDto,
Pagination: pagination,
})

View File

@@ -19,6 +19,11 @@ type OidcClientWithAllowedUserGroupsDto struct {
AllowedUserGroups []UserGroupDtoWithUserCount `json:"allowedUserGroups"`
}
type OidcClientWithAllowedGroupsCountDto struct {
OidcClientDto
AllowedUserGroupsCount int64 `json:"allowedUserGroupsCount"`
}
type OidcClientCreateDto struct {
Name string `json:"name" binding:"required,max=50"`
CallbackURLs []string `json:"callbackURLs" binding:"required"`

View File

@@ -0,0 +1,61 @@
package job
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
const heartbeatUrl = "https://analytics.pocket-id.org/heartbeat"
func (s *Scheduler) RegisterAnalyticsJob(ctx context.Context, appConfig *service.AppConfigService, httpClient *http.Client) error {
jobs := &AnalyticsJob{appConfig: appConfig, httpClient: httpClient}
return s.registerJob(ctx, "SendHeartbeat", "0 0 * * *", jobs.sendHeartbeat, true)
}
type AnalyticsJob struct {
appConfig *service.AppConfigService
httpClient *http.Client
}
// sendHeartbeat sends a heartbeat to the analytics service
func (j *AnalyticsJob) sendHeartbeat(ctx context.Context) error {
// Skip if analytics are disabled or not in production environment
if common.EnvConfig.AnalyticsDisabled || common.EnvConfig.AppEnv != "production" {
return nil
}
body := struct {
Version string `json:"version"`
InstanceID string `json:"instance_id"`
}{
Version: common.Version,
InstanceID: j.appConfig.GetDbConfig().InstanceID.Value,
}
bodyBytes, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("failed to marshal heartbeat body: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, heartbeatUrl, bytes.NewBuffer(bodyBytes))
if err != nil {
return fmt.Errorf("failed to create heartbeat request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := j.httpClient.Do(req)
if err != nil {
return fmt.Errorf("failed to send heartbeat request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("heartbeat request failed with status code: %d", resp.StatusCode)
}
return nil
}

View File

@@ -18,7 +18,7 @@ func (s *Scheduler) RegisterApiKeyExpiryJob(ctx context.Context, apiKeyService *
appConfigService: appConfigService,
}
return s.registerJob(ctx, "ExpiredApiKeyEmailJob", "0 0 * * *", jobs.checkAndNotifyExpiringApiKeys)
return s.registerJob(ctx, "ExpiredApiKeyEmailJob", "0 0 * * *", jobs.checkAndNotifyExpiringApiKeys, false)
}
func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) error {

View File

@@ -15,11 +15,11 @@ func (s *Scheduler) RegisterDbCleanupJobs(ctx context.Context, db *gorm.DB) erro
jobs := &DbCleanupJobs{db: db}
return errors.Join(
s.registerJob(ctx, "ClearWebauthnSessions", "0 3 * * *", jobs.clearWebauthnSessions),
s.registerJob(ctx, "ClearOneTimeAccessTokens", "0 3 * * *", jobs.clearOneTimeAccessTokens),
s.registerJob(ctx, "ClearOidcAuthorizationCodes", "0 3 * * *", jobs.clearOidcAuthorizationCodes),
s.registerJob(ctx, "ClearOidcRefreshTokens", "0 3 * * *", jobs.clearOidcRefreshTokens),
s.registerJob(ctx, "ClearAuditLogs", "0 3 * * *", jobs.clearAuditLogs),
s.registerJob(ctx, "ClearWebauthnSessions", "0 3 * * *", jobs.clearWebauthnSessions, false),
s.registerJob(ctx, "ClearOneTimeAccessTokens", "0 3 * * *", jobs.clearOneTimeAccessTokens, false),
s.registerJob(ctx, "ClearOidcAuthorizationCodes", "0 3 * * *", jobs.clearOidcAuthorizationCodes, false),
s.registerJob(ctx, "ClearOidcRefreshTokens", "0 3 * * *", jobs.clearOidcRefreshTokens, false),
s.registerJob(ctx, "ClearAuditLogs", "0 3 * * *", jobs.clearAuditLogs, false),
)
}

View File

@@ -17,7 +17,7 @@ import (
func (s *Scheduler) RegisterFileCleanupJobs(ctx context.Context, db *gorm.DB) error {
jobs := &FileCleanupJobs{db: db}
return s.registerJob(ctx, "ClearUnusedDefaultProfilePictures", "0 2 * * 0", jobs.clearUnusedDefaultProfilePictures)
return s.registerJob(ctx, "ClearUnusedDefaultProfilePictures", "0 2 * * 0", jobs.clearUnusedDefaultProfilePictures, false)
}
type FileCleanupJobs struct {

View File

@@ -2,8 +2,6 @@ package job
import (
"context"
"log"
"time"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
@@ -22,22 +20,7 @@ func (s *Scheduler) RegisterGeoLiteUpdateJobs(ctx context.Context, geoLiteServic
jobs := &GeoLiteUpdateJobs{geoLiteService: geoLiteService}
// Register the job to run every day, at 5 minutes past midnight
err := s.registerJob(ctx, "UpdateGeoLiteDB", "5 * */1 * *", jobs.updateGoeLiteDB)
if err != nil {
return err
}
// Run the job immediately on startup, with a 1s delay
go func() {
time.Sleep(time.Second)
err = jobs.updateGoeLiteDB(ctx)
if err != nil {
// Log the error only, but don't return it
log.Printf("Failed to Update GeoLite database: %v", err)
}
}()
return nil
return s.registerJob(ctx, "UpdateGeoLiteDB", "5 * */1 * *", jobs.updateGoeLiteDB, true)
}
func (j *GeoLiteUpdateJobs) updateGoeLiteDB(ctx context.Context) error {

View File

@@ -2,7 +2,6 @@ package job
import (
"context"
"log"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
@@ -16,19 +15,7 @@ func (s *Scheduler) RegisterLdapJobs(ctx context.Context, ldapService *service.L
jobs := &LdapJobs{ldapService: ldapService, appConfigService: appConfigService}
// Register the job to run every hour
err := s.registerJob(ctx, "SyncLdap", "0 * * * *", jobs.syncLdap)
if err != nil {
return err
}
// Run the job immediately on startup
err = jobs.syncLdap(ctx)
if err != nil {
// Log the error only, but don't return it
log.Printf("Failed to sync LDAP: %v", err)
}
return nil
return s.registerJob(ctx, "SyncLdap", "0 * * * *", jobs.syncLdap, true)
}
func (j *LdapJobs) syncLdap(ctx context.Context) error {

View File

@@ -43,10 +43,8 @@ func (s *Scheduler) Run(ctx context.Context) error {
return nil
}
func (s *Scheduler) registerJob(ctx context.Context, name string, interval string, job func(ctx context.Context) error) error {
_, err := s.scheduler.NewJob(
gocron.CronJob(interval, false),
gocron.NewTask(job),
func (s *Scheduler) registerJob(ctx context.Context, name string, interval string, job func(ctx context.Context) error, runImmediately bool) error {
jobOptions := []gocron.JobOption{
gocron.WithContext(ctx),
gocron.WithEventListeners(
gocron.AfterJobRuns(func(jobID uuid.UUID, jobName string) {
@@ -56,6 +54,16 @@ func (s *Scheduler) registerJob(ctx context.Context, name string, interval strin
log.Printf("Job %q failed with error: %v", name, err)
}),
),
}
if runImmediately {
jobOptions = append(jobOptions, gocron.JobOption(gocron.WithStartImmediately()))
}
_, err := s.scheduler.NewJob(
gocron.CronJob(interval, false),
gocron.NewTask(job),
jobOptions...,
)
if err != nil {

View File

@@ -41,6 +41,7 @@ type AppConfig struct {
BackgroundImageType AppConfigVariable `key:"backgroundImageType,internal"` // Internal
LogoLightImageType AppConfigVariable `key:"logoLightImageType,internal"` // Internal
LogoDarkImageType AppConfigVariable `key:"logoDarkImageType,internal"` // Internal
InstanceID AppConfigVariable `key:"instanceId,internal"` // Internal
// Email
SmtpHost AppConfigVariable `key:"smtpHost"`
SmtpPort AppConfigVariable `key:"smtpPort"`

View File

@@ -103,7 +103,7 @@ func TestAppConfigStructMatchesUpdateDto(t *testing.T) {
// Verify every AppConfig field has a matching DTO field with the same name
for fieldName, keyName := range appConfigFields {
if strings.HasSuffix(fieldName, "ImageType") {
if strings.HasSuffix(fieldName, "ImageType") || keyName == "instanceId" {
// Skip internal fields that shouldn't be in the DTO
continue
}

View File

@@ -13,6 +13,8 @@ import (
"sync/atomic"
"time"
"github.com/hashicorp/go-uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@@ -37,6 +39,11 @@ func NewAppConfigService(initCtx context.Context, db *gorm.DB) *AppConfigService
log.Fatalf("Failed to initialize app config service: %v", err)
}
err = service.initInstanceID(initCtx)
if err != nil {
log.Fatalf("Failed to initialize instance ID: %v", err)
}
return service
}
@@ -65,6 +72,7 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
BackgroundImageType: model.AppConfigVariable{Value: "jpg"},
LogoLightImageType: model.AppConfigVariable{Value: "svg"},
LogoDarkImageType: model.AppConfigVariable{Value: "svg"},
InstanceID: model.AppConfigVariable{Value: ""},
// Email
SmtpHost: model.AppConfigVariable{},
SmtpPort: model.AppConfigVariable{},
@@ -440,3 +448,23 @@ func (s *AppConfigService) loadDbConfigInternal(ctx context.Context, tx *gorm.DB
return dest, nil
}
func (s *AppConfigService) initInstanceID(ctx context.Context) error {
// Check if the instance ID is already set
instanceID := s.GetDbConfig().InstanceID.Value
if instanceID != "" {
return nil
}
newInstanceID, err := uuid.GenerateUUID()
if err != nil {
return fmt.Errorf("failed to generate new instance ID: %w", err)
}
err = s.UpdateAppConfigValues(ctx, "instanceId", newInstanceID)
if err != nil {
return fmt.Errorf("failed to update instance ID in the database: %w", err)
}
return nil
}

View File

@@ -148,22 +148,44 @@ func (s *LdapService) SyncGroups(ctx context.Context, tx *gorm.DB, client *ldap.
groupMembers := value.GetAttributeValues(dbConfig.LdapAttributeGroupMember.Value)
membersUserId := make([]string, 0, len(groupMembers))
for _, member := range groupMembers {
ldapId := getDNProperty("uid", member)
if ldapId == "" {
continue
username := getDNProperty(dbConfig.LdapAttributeUserUsername.Value, member)
// If username extraction fails, try to query LDAP directly for the user
if username == "" {
// Query LDAP to get the user by their DN
userSearchReq := ldap.NewSearchRequest(
member,
ldap.ScopeBaseObject,
0, 0, 0, false,
"(objectClass=*)",
[]string{dbConfig.LdapAttributeUserUsername.Value, dbConfig.LdapAttributeUserUniqueIdentifier.Value},
[]ldap.Control{},
)
userResult, err := client.Search(userSearchReq)
if err != nil || len(userResult.Entries) == 0 {
log.Printf("Could not resolve group member DN '%s': %v", member, err)
continue
}
username = userResult.Entries[0].GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value)
if username == "" {
log.Printf("Could not extract username from group member DN '%s'", member)
continue
}
}
var databaseUser model.User
err = tx.
WithContext(ctx).
Where("username = ? AND ldap_id IS NOT NULL", ldapId).
Where("username = ? AND ldap_id IS NOT NULL", username).
First(&databaseUser).
Error
if errors.Is(err, gorm.ErrRecordNotFound) {
// The user collides with a non-LDAP user, so we skip it
continue
} else if err != nil {
return fmt.Errorf("failed to query for existing user '%s': %w", ldapId, err)
return fmt.Errorf("failed to query for existing user '%s': %w", username, err)
}
membersUserId = append(membersUserId, databaseUser.ID)
@@ -305,7 +327,7 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
// Check if user is admin by checking if they are in the admin group
isAdmin := false
for _, group := range value.GetAttributeValues("memberOf") {
if getDNProperty("cn", group) == dbConfig.LdapAttributeAdminGroup.Value {
if getDNProperty(dbConfig.LdapAttributeGroupName.Value, group) == dbConfig.LdapAttributeAdminGroup.Value {
isAdmin = true
break
}

View File

@@ -512,24 +512,32 @@ func (s *OidcService) getClientInternal(ctx context.Context, clientID string, tx
return client, nil
}
func (s *OidcService) ListClients(ctx context.Context, searchTerm string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.OidcClient, utils.PaginationResponse, error) {
func (s *OidcService) ListClients(ctx context.Context, name string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.OidcClient, utils.PaginationResponse, error) {
var clients []model.OidcClient
query := s.db.
WithContext(ctx).
Preload("CreatedBy").
Model(&model.OidcClient{})
if searchTerm != "" {
searchPattern := "%" + searchTerm + "%"
query = query.Where("name LIKE ?", searchPattern)
if name != "" {
query = query.Where("name LIKE ?", "%"+name+"%")
}
pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &clients)
if err != nil {
return nil, utils.PaginationResponse{}, err
// 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 {
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").
Order("COUNT(oidc_clients_allowed_user_groups.oidc_client_id) " + sortedPaginationRequest.Sort.Direction)
response, err := utils.Paginate(sortedPaginationRequest.Pagination.Page, sortedPaginationRequest.Pagination.Limit, query, &clients)
return clients, response, err
}
return clients, pagination, nil
response, err := utils.PaginateAndSort(sortedPaginationRequest, query, &clients)
return clients, response, err
}
func (s *OidcService) CreateClient(ctx context.Context, input dto.OidcClientCreateDto, userID string) (model.OidcClient, error) {
@@ -1166,6 +1174,23 @@ func (s *OidcService) GetDeviceCodeInfo(ctx context.Context, userCode string, us
}, nil
}
func (s *OidcService) GetAllowedGroupsCountOfClient(ctx context.Context, id string) (int64, error) {
// We only perform select queries here, so we can rollback in all cases
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
var client model.OidcClient
err := tx.WithContext(ctx).Where("id = ?", id).First(&client).Error
if err != nil {
return 0, err
}
count := tx.WithContext(ctx).Model(&client).Association("AllowedUserGroups").Count()
return count, nil
}
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 {

View File

@@ -29,6 +29,9 @@ func NewWebAuthnService(db *gorm.DB, jwtService *JwtService, auditLogService *Au
RPDisplayName: appConfigService.GetDbConfig().AppName.Value,
RPID: utils.GetHostnameFromURL(common.EnvConfig.AppURL),
RPOrigins: []string{common.EnvConfig.AppURL},
AuthenticatorSelection: protocol.AuthenticatorSelection{
UserVerification: protocol.VerificationRequired,
},
Timeouts: webauthn.TimeoutsConfig{
Login: webauthn.TimeoutConfig{
Enforce: true,

File diff suppressed because one or more lines are too long

View File

@@ -346,5 +346,7 @@
"authorize_device": "Autorizovat zařízení",
"the_device_has_been_authorized": "Zařízení bylo autorizováno.",
"enter_code_displayed_in_previous_step": "Zadejte kód, který byl zobrazen v předchozím kroku.",
"authorize": "Autorizovat"
"authorize": "Autorizovat",
"oidc_allowed_group_count": "Allowed Group Count",
"unrestricted": "Unrestricted"
}

View File

@@ -346,5 +346,7 @@
"authorize_device": "Gerät autorisieren",
"the_device_has_been_authorized": "Das Gerät wurde autorisiert.",
"enter_code_displayed_in_previous_step": "Gib den Code ein, der im vorherigen Schritt angezeigt wurde.",
"authorize": "Autorisieren"
"authorize": "Autorisieren",
"oidc_allowed_group_count": "Allowed Group Count",
"unrestricted": "Unrestricted"
}

View File

@@ -346,5 +346,7 @@
"authorize_device": "Authorize Device",
"the_device_has_been_authorized": "The device has been authorized.",
"enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.",
"authorize": "Authorize"
"authorize": "Authorize",
"oidc_allowed_group_count": "Allowed Group Count",
"unrestricted": "Unrestricted"
}

View File

@@ -346,5 +346,7 @@
"authorize_device": "Authorize Device",
"the_device_has_been_authorized": "The device has been authorized.",
"enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.",
"authorize": "Authorize"
"authorize": "Authorize",
"oidc_allowed_group_count": "Allowed Group Count",
"unrestricted": "Unrestricted"
}

View File

@@ -346,5 +346,7 @@
"authorize_device": "Autorizza Dispositivo",
"the_device_has_been_authorized": "Il dispositivo è stato autorizzato.",
"enter_code_displayed_in_previous_step": "Inserisci il codice visualizzato nel passaggio precedente.",
"authorize": "Autorizza"
"authorize": "Autorizza",
"oidc_allowed_group_count": "Allowed Group Count",
"unrestricted": "Unrestricted"
}

View File

@@ -346,5 +346,7 @@
"authorize_device": "Authorize Device",
"the_device_has_been_authorized": "The device has been authorized.",
"enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.",
"authorize": "Authorize"
"authorize": "Authorize",
"oidc_allowed_group_count": "Allowed Group Count",
"unrestricted": "Unrestricted"
}

View File

@@ -279,7 +279,7 @@
"public_clients_description": "Klienci publiczni nie mają tajnego klucza. Są zaprojektowane dla aplikacji mobilnych, webowych i natywnych, gdzie tajne klucze nie mogą być bezpiecznie przechowywane.",
"pkce": "PKCE",
"public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "Wymiana kodu publicznego klucza to funkcja zabezpieczająca, która zapobiega atakom CSRF i przechwytywaniu kodu autoryzacyjnego.",
"name_logo": "{name} logo",
"name_logo": "Logo {name}",
"change_logo": "Zmień logo",
"upload_logo": "Prześlij logo",
"remove_logo": "Usuń logo",
@@ -305,7 +305,7 @@
"show_more_details": "Pokaż więcej szczegółów",
"allowed_user_groups": "Dozwolone grupy użytkowników",
"add_user_groups_to_this_client_to_restrict_access_to_users_in_these_groups": "Dodaj grupy użytkowników do tego klienta, aby ograniczyć dostęp do użytkowników w tych grupach. Jeśli nie wybrano żadnych grup użytkowników, wszyscy użytkownicy będą mieli dostęp do tego klienta.",
"favicon": "Favicon",
"favicon": "Ikona ulubionych",
"light_mode_logo": "Logo w trybie jasnym",
"dark_mode_logo": "Logo w trybie ciemnym",
"background_image": "Obraz tła",
@@ -346,5 +346,7 @@
"authorize_device": "Autoryzuj urządzenie",
"the_device_has_been_authorized": "Urządzenie zostało autoryzowane.",
"enter_code_displayed_in_previous_step": "Wprowadź kod wyświetlony w poprzednim kroku.",
"authorize": "Autoryzuj"
"authorize": "Autoryzuj",
"oidc_allowed_group_count": "Allowed Group Count",
"unrestricted": "Unrestricted"
}

View File

@@ -346,5 +346,7 @@
"authorize_device": "Authorize Device",
"the_device_has_been_authorized": "The device has been authorized.",
"enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.",
"authorize": "Authorize"
"authorize": "Authorize",
"oidc_allowed_group_count": "Allowed Group Count",
"unrestricted": "Unrestricted"
}

View File

@@ -346,5 +346,7 @@
"authorize_device": "Authorize Device",
"the_device_has_been_authorized": "The device has been authorized.",
"enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.",
"authorize": "Authorize"
"authorize": "Authorize",
"oidc_allowed_group_count": "Allowed Group Count",
"unrestricted": "Unrestricted"
}

View File

@@ -346,5 +346,7 @@
"authorize_device": "Авторизовать устройство",
"the_device_has_been_authorized": "Устройство авторизовано.",
"enter_code_displayed_in_previous_step": "Введите код, который был отображен на предыдущем шаге.",
"authorize": "Авторизируйте"
"authorize": "Авторизируйте",
"oidc_allowed_group_count": "Allowed Group Count",
"unrestricted": "Unrestricted"
}

View File

@@ -1,6 +1,6 @@
{
"name": "pocket-id-frontend",
"version": "1.0.0",
"version": "1.1.0",
"private": true,
"type": "module",
"scripts": {

View File

@@ -1,10 +1,11 @@
import type {
AuthorizeResponse,
OidcDeviceCodeInfo,
OidcClient,
OidcClientCreate,
OidcClientMetaData,
OidcClientWithAllowedUserGroups
OidcClientWithAllowedUserGroups,
OidcClientWithAllowedUserGroupsCount,
OidcDeviceCodeInfo
} from '$lib/types/oidc.type';
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
import APIService from './api-service';
@@ -43,7 +44,7 @@ class OidcService extends APIService {
const res = await this.api.get('/oidc/clients', {
params: options
});
return res.data as Paginated<OidcClient>;
return res.data as Paginated<OidcClientWithAllowedUserGroupsCount>;
}
async createClient(client: OidcClientCreate) {

View File

@@ -17,6 +17,10 @@ export type OidcClientWithAllowedUserGroups = OidcClient & {
allowedUserGroups: UserGroup[];
};
export type OidcClientWithAllowedUserGroupsCount = OidcClient & {
allowedUserGroupsCount: number;
};
export type OidcClientCreate = Omit<OidcClient, 'id' | 'logoURL' | 'hasLogo'>;
export type OidcClientCreateWithLogo = OidcClientCreate & {

View File

@@ -5,7 +5,7 @@
import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import OIDCService from '$lib/services/oidc-service';
import type { OidcClient } from '$lib/types/oidc.type';
import type { OidcClient, OidcClientWithAllowedUserGroupsCount } from '$lib/types/oidc.type';
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucidePencil, LucideTrash } from '@lucide/svelte';
@@ -15,7 +15,7 @@
clients = $bindable(),
requestOptions
}: {
clients: Paginated<OidcClient>;
clients: Paginated<OidcClientWithAllowedUserGroupsCount>;
requestOptions: SearchPaginationSortRequest;
} = $props();
@@ -49,6 +49,7 @@
columns={[
{ label: m.logo() },
{ label: m.name(), sortColumn: 'name' },
{ label: m.oidc_allowed_group_count(), sortColumn: 'allowedUserGroupsCount' },
{ label: m.actions(), hidden: true }
]}
>
@@ -67,6 +68,11 @@
{/if}
</Table.Cell>
<Table.Cell class="font-medium">{item.name}</Table.Cell>
<Table.Cell class="font-medium"
>{item.allowedUserGroupsCount > 0
? item.allowedUserGroupsCount
: m.unrestricted()}</Table.Cell
>
<Table.Cell class="flex justify-end gap-1">
<Button
href="/settings/admin/oidc-clients/{item.id}"