Compare commits

...

11 Commits

Author SHA1 Message Date
Elias Schneider
d478e2a93d fix normal one time access token creation 2025-12-10 16:36:32 +01:00
Elias Schneider
f4222ff924 satisfy linter 2025-12-10 16:17:39 +01:00
Elias Schneider
85dc6cec6a add error message 2025-12-10 16:08:54 +01:00
Elias Schneider
80301be617 fix path in cookie 2025-12-10 16:08:44 +01:00
Elias Schneider
716c8114f7 add db migrations 2025-12-10 16:08:27 +01:00
Elias Schneider
73da7776ab fix: restrict email one time sign in to same browser 2025-12-10 15:47:33 +01:00
github-actions[bot]
177ada10ba chore: update AAGUIDs (#1140)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2025-12-07 19:41:51 -06:00
Elias Schneider
91b0d74c43 feat: add HTTP HEAD method support (#1135) 2025-12-05 11:17:13 +01:00
Sebastian
3a1dd3168e fix(translations): update image format message to include WEBP (#1133) 2025-12-04 07:58:03 +00:00
Elias Schneider
25f67bd25a tests: fix api key e2e test 2025-12-03 10:51:19 +01:00
Elias Schneider
e3483a9c78 chore(translations): update translations via Crowdin (#1129) 2025-12-02 15:17:58 -06:00
35 changed files with 160 additions and 65 deletions

View File

@@ -63,6 +63,7 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
rateLimitMiddleware := middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 60)
// Setup global middleware
r.Use(middleware.HeadMiddleware())
r.Use(middleware.NewCacheControlMiddleware().Add())
r.Use(middleware.NewCorsMiddleware().Add())
r.Use(middleware.NewCspMiddleware().Add())
@@ -111,7 +112,17 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
srv := &http.Server{
MaxHeaderBytes: 1 << 20,
ReadHeaderTimeout: 10 * time.Second,
Handler: r,
Handler: http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
// HEAD requests don't get matched by Gin routes, so we convert them to GET
// middleware.HeadMiddleware will convert them back to HEAD later
if req.Method == http.MethodHead {
req.Method = http.MethodGet
ctx := context.WithValue(req.Context(), middleware.IsHeadRequestCtxKey{}, true)
req = req.WithContext(ctx)
}
r.ServeHTTP(w, req)
}),
}
// Set up the listener

View File

@@ -51,7 +51,7 @@ var oneTimeAccessTokenCmd = &cobra.Command{
}
// Create a new access token that expires in 1 hour
oneTimeAccessToken, txErr = service.NewOneTimeAccessToken(user.ID, time.Hour)
oneTimeAccessToken, txErr = service.NewOneTimeAccessToken(user.ID, time.Hour, false)
if txErr != nil {
return fmt.Errorf("failed to generate access token: %w", txErr)
}

View File

@@ -38,6 +38,13 @@ type TokenInvalidOrExpiredError struct{}
func (e *TokenInvalidOrExpiredError) Error() string { return "token is invalid or expired" }
func (e *TokenInvalidOrExpiredError) HttpStatusCode() int { return 400 }
type DeviceCodeInvalid struct{}
func (e *DeviceCodeInvalid) Error() string {
return "one time access code must be used on the device it was generated for"
}
func (e *DeviceCodeInvalid) HttpStatusCode() int { return 400 }
type TokenInvalidError struct{}
func (e *TokenInvalidError) Error() string {

View File

@@ -391,12 +391,13 @@ func (uc *UserController) RequestOneTimeAccessEmailAsUnauthenticatedUserHandler(
return
}
err := uc.userService.RequestOneTimeAccessEmailAsUnauthenticatedUser(c.Request.Context(), input.Email, input.RedirectPath)
deviceToken, err := uc.userService.RequestOneTimeAccessEmailAsUnauthenticatedUser(c.Request.Context(), input.Email, input.RedirectPath)
if err != nil {
_ = c.Error(err)
return
}
cookie.AddDeviceTokenCookie(c, deviceToken)
c.Status(http.StatusNoContent)
}
@@ -440,7 +441,8 @@ func (uc *UserController) RequestOneTimeAccessEmailAsAdminHandler(c *gin.Context
// @Success 200 {object} dto.UserDto
// @Router /api/one-time-access-token/{token} [post]
func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
user, token, err := uc.userService.ExchangeOneTimeAccessToken(c.Request.Context(), c.Param("token"), c.ClientIP(), c.Request.UserAgent())
deviceToken, _ := c.Cookie(cookie.DeviceTokenCookieName)
user, token, err := uc.userService.ExchangeOneTimeAccessToken(c.Request.Context(), c.Param("token"), deviceToken, c.ClientIP(), c.Request.UserAgent())
if err != nil {
_ = c.Error(err)
return

View File

@@ -0,0 +1,40 @@
package middleware
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
type IsHeadRequestCtxKey struct{}
type headWriter struct {
gin.ResponseWriter
size int
}
func (w *headWriter) Write(b []byte) (int, error) {
w.size += len(b)
return w.size, nil
}
func HeadMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// Only process if it's a HEAD request
if c.Request.Context().Value(IsHeadRequestCtxKey{}) != true {
c.Next()
return
}
// Replace the ResponseWriter with our headWriter to swallow the body
hw := &headWriter{ResponseWriter: c.Writer}
c.Writer = hw
c.Next()
c.Writer.Header().Set("Content-Length", strconv.Itoa(hw.size))
c.Request.Method = http.MethodHead
}
}

View File

@@ -87,8 +87,9 @@ func (u User) Initials() string {
type OneTimeAccessToken struct {
Base
Token string
ExpiresAt datatype.DateTime
Token string
DeviceToken *string
ExpiresAt datatype.DateTime
UserID string
User User

View File

@@ -432,28 +432,36 @@ func (s *UserService) RequestOneTimeAccessEmailAsAdmin(ctx context.Context, user
return &common.OneTimeAccessDisabledError{}
}
return s.requestOneTimeAccessEmailInternal(ctx, userID, "", ttl)
_, err := s.requestOneTimeAccessEmailInternal(ctx, userID, "", ttl, true)
return err
}
func (s *UserService) RequestOneTimeAccessEmailAsUnauthenticatedUser(ctx context.Context, userID, redirectPath string) error {
func (s *UserService) RequestOneTimeAccessEmailAsUnauthenticatedUser(ctx context.Context, userID, redirectPath string) (string, error) {
isDisabled := !s.appConfigService.GetDbConfig().EmailOneTimeAccessAsUnauthenticatedEnabled.IsTrue()
if isDisabled {
return &common.OneTimeAccessDisabledError{}
return "", &common.OneTimeAccessDisabledError{}
}
var userId string
err := s.db.Model(&model.User{}).Select("id").Where("email = ?", userID).First(&userId).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
// Do not return error if user not found to prevent email enumeration
return nil
return "", nil
} else if err != nil {
return err
return "", err
}
return s.requestOneTimeAccessEmailInternal(ctx, userId, redirectPath, 15*time.Minute)
deviceToken, err := s.requestOneTimeAccessEmailInternal(ctx, userId, redirectPath, 15*time.Minute, true)
if err != nil {
return "", err
} else if deviceToken == nil {
return "", errors.New("device token expected but not returned")
}
return *deviceToken, nil
}
func (s *UserService) requestOneTimeAccessEmailInternal(ctx context.Context, userID, redirectPath string, ttl time.Duration) error {
func (s *UserService) requestOneTimeAccessEmailInternal(ctx context.Context, userID, redirectPath string, ttl time.Duration, withDeviceToken bool) (*string, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
@@ -461,21 +469,20 @@ func (s *UserService) requestOneTimeAccessEmailInternal(ctx context.Context, use
user, err := s.GetUser(ctx, userID)
if err != nil {
return err
return nil, err
}
if user.Email == nil {
return &common.UserEmailNotSetError{}
return nil, &common.UserEmailNotSetError{}
}
oneTimeAccessToken, err := s.createOneTimeAccessTokenInternal(ctx, user.ID, ttl, tx)
oneTimeAccessToken, deviceToken, err := s.createOneTimeAccessTokenInternal(ctx, user.ID, ttl, withDeviceToken, tx)
if err != nil {
return err
return nil, err
}
err = tx.Commit().Error
if err != nil {
return err
return nil, err
}
// We use a background context here as this is running in a goroutine
@@ -508,28 +515,29 @@ func (s *UserService) requestOneTimeAccessEmailInternal(ctx context.Context, use
}
}()
return nil
return deviceToken, nil
}
func (s *UserService) CreateOneTimeAccessToken(ctx context.Context, userID string, ttl time.Duration) (string, error) {
return s.createOneTimeAccessTokenInternal(ctx, userID, ttl, s.db)
func (s *UserService) CreateOneTimeAccessToken(ctx context.Context, userID string, ttl time.Duration) (token string, err error) {
token, _, err = s.createOneTimeAccessTokenInternal(ctx, userID, ttl, false, s.db)
return token, err
}
func (s *UserService) createOneTimeAccessTokenInternal(ctx context.Context, userID string, ttl time.Duration, tx *gorm.DB) (string, error) {
oneTimeAccessToken, err := NewOneTimeAccessToken(userID, ttl)
func (s *UserService) createOneTimeAccessTokenInternal(ctx context.Context, userID string, ttl time.Duration, withDeviceToken bool, tx *gorm.DB) (token string, deviceToken *string, err error) {
oneTimeAccessToken, err := NewOneTimeAccessToken(userID, ttl, withDeviceToken)
if err != nil {
return "", err
return "", nil, err
}
err = tx.WithContext(ctx).Create(oneTimeAccessToken).Error
if err != nil {
return "", err
return "", nil, err
}
return oneTimeAccessToken.Token, nil
return oneTimeAccessToken.Token, oneTimeAccessToken.DeviceToken, nil
}
func (s *UserService) ExchangeOneTimeAccessToken(ctx context.Context, token string, ipAddress, userAgent string) (model.User, string, error) {
func (s *UserService) ExchangeOneTimeAccessToken(ctx context.Context, token, deviceToken, ipAddress, userAgent string) (model.User, string, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
@@ -549,6 +557,10 @@ func (s *UserService) ExchangeOneTimeAccessToken(ctx context.Context, token stri
}
return model.User{}, "", err
}
if oneTimeAccessToken.DeviceToken != nil && deviceToken != *oneTimeAccessToken.DeviceToken {
return model.User{}, "", &common.DeviceCodeInvalid{}
}
accessToken, err := s.jwtService.GenerateAccessToken(oneTimeAccessToken.User)
if err != nil {
return model.User{}, "", err
@@ -818,23 +830,33 @@ func (s *UserService) DeleteSignupToken(ctx context.Context, tokenID string) err
return s.db.WithContext(ctx).Delete(&model.SignupToken{}, "id = ?", tokenID).Error
}
func NewOneTimeAccessToken(userID string, ttl time.Duration) (*model.OneTimeAccessToken, error) {
func NewOneTimeAccessToken(userID string, ttl time.Duration, withDeviceToken bool) (*model.OneTimeAccessToken, error) {
// If expires at is less than 15 minutes, use a 6-character token instead of 16
tokenLength := 16
if ttl <= 15*time.Minute {
tokenLength = 6
}
randomString, err := utils.GenerateRandomAlphanumericString(tokenLength)
token, err := utils.GenerateRandomAlphanumericString(tokenLength)
if err != nil {
return nil, err
}
var deviceToken *string
if withDeviceToken {
dt, err := utils.GenerateRandomAlphanumericString(16)
if err != nil {
return nil, err
}
deviceToken = &dt
}
now := time.Now().Round(time.Second)
o := &model.OneTimeAccessToken{
UserID: userID,
ExpiresAt: datatype.DateTime(now.Add(ttl)),
Token: randomString,
UserID: userID,
ExpiresAt: datatype.DateTime(now.Add(ttl)),
Token: token,
DeviceToken: deviceToken,
}
return o, nil

View File

@@ -1,6 +1,8 @@
package cookie
import (
"time"
"github.com/gin-gonic/gin"
)
@@ -11,3 +13,7 @@ func AddAccessTokenCookie(c *gin.Context, maxAgeInSeconds int, token string) {
func AddSessionIdCookie(c *gin.Context, maxAgeInSeconds int, sessionID string) {
c.SetCookie(SessionIdCookieName, sessionID, maxAgeInSeconds, "/", "", true, true)
}
func AddDeviceTokenCookie(c *gin.Context, deviceToken string) {
c.SetCookie(DeviceTokenCookieName, deviceToken, int(15*time.Minute.Seconds()), "/api/one-time-access-token", "", true, true)
}

View File

@@ -8,10 +8,12 @@ import (
var AccessTokenCookieName = "__Host-access_token"
var SessionIdCookieName = "__Host-session"
var DeviceTokenCookieName = "__Host-device_token" //nolint:gosec
func init() {
if strings.HasPrefix(common.EnvConfig.AppURL, "http://") {
AccessTokenCookieName = "access_token"
SessionIdCookieName = "session"
DeviceTokenCookieName = "device_token"
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
ALTER TABLE one_time_access_tokens DROP COLUMN device_token;

View File

@@ -0,0 +1 @@
ALTER TABLE one_time_access_tokens ADD COLUMN device_token VARCHAR(16);

View File

@@ -0,0 +1 @@
ALTER TABLE one_time_access_tokens DROP COLUMN device_token;

View File

@@ -0,0 +1 @@
ALTER TABLE one_time_access_tokens ADD COLUMN device_token TEXT;

View File

@@ -14,7 +14,7 @@
"profile_picture": "Profilový obrázek",
"profile_picture_is_managed_by_ldap_server": "Profilový obrázek je spravován LDAP serverem a nelze jej zde změnit.",
"click_profile_picture_to_upload_custom": "Klikněte na profilový obrázek pro nahrání vlastního ze souborů.",
"image_should_be_in_format": "Obrázek by měl být ve formátu PNG nebo JPEG.",
"image_should_be_in_format": "Obrázek by měl být ve formátu PNG, JPEG nebo WEBP.",
"items_per_page": "Položek na stránku",
"no_items_found": "Nenalezeny žádné položky",
"select_items": "Vyberte položky...",

View File

@@ -14,7 +14,7 @@
"profile_picture": "Profilbillede",
"profile_picture_is_managed_by_ldap_server": "Profilbilledet administreres af LDAP-serveren og kan ikke ændres her.",
"click_profile_picture_to_upload_custom": "Klik på profilbilledet for at uploade et brugerdefineret billede fra dine filer.",
"image_should_be_in_format": "Billedet skal være i PNG eller JPEG-format.",
"image_should_be_in_format": "Billedet skal være i PNG, JPEG eller WEBP-format.",
"items_per_page": "Emner pr. side",
"no_items_found": "Ingen emner fundet",
"select_items": "Vælg emner...",

View File

@@ -14,7 +14,7 @@
"profile_picture": "Profilbild",
"profile_picture_is_managed_by_ldap_server": "Das Profilbild wird vom LDAP-Server verwaltet und kann hier nicht geändert werden.",
"click_profile_picture_to_upload_custom": "Klicke auf das Profilbild, um ein benutzerdefiniertes Bild aus deinen Dateien hochzuladen.",
"image_should_be_in_format": "Das Bild sollte im PNG- oder JPEG-Format vorliegen.",
"image_should_be_in_format": "Das Bild sollte im PNG-, JPEG- oder WEBP-Format vorliegen.",
"items_per_page": "Einträge pro Seite",
"no_items_found": "Keine Einträge gefunden",
"select_items": "Elemente auswählen...",
@@ -443,7 +443,7 @@
"client_launch_url_description": "Die URL, die geöffnet wird, wenn jemand die App von der Seite „Meine Apps“ startet.",
"client_name_description": "Der Name des Clients, der in der Pocket ID-Benutzeroberfläche angezeigt wird.",
"revoke_access": "Zugriff widerrufen",
"revoke_access_description": "Zugriff widerrufen <b>{clientName}</b>. <b>{clientName}</b> kann nicht mehr auf deine Kontoinfos zugreifen.",
"revoke_access_description": "Zugriff auf <b>{clientName}</b> widerrufen. <b>{clientName}</b> kann nicht mehr auf deine Kontoinformationen zugreifen.",
"revoke_access_successful": "Der Zugriff auf „ {clientName} “ wurde erfolgreich gesperrt.",
"last_signed_in_ago": "Zuletzt angemeldet vor {time} Stunden",
"invalid_client_id": "Die Kunden-ID darf nur Buchstaben, Zahlen, Unterstriche und Bindestriche haben.",

View File

@@ -14,7 +14,7 @@
"profile_picture": "Profile Picture",
"profile_picture_is_managed_by_ldap_server": "The profile picture is managed by the LDAP server and cannot be changed here.",
"click_profile_picture_to_upload_custom": "Click on the profile picture to upload a custom one from your files.",
"image_should_be_in_format": "The image should be in PNG or JPEG format.",
"image_should_be_in_format": "The image should be in PNG, JPEG or WEBP format.",
"items_per_page": "Items per page",
"no_items_found": "No items found",
"select_items": "Select items...",

View File

@@ -14,7 +14,7 @@
"profile_picture": "Foto de perfil",
"profile_picture_is_managed_by_ldap_server": "La imagen de perfil es administrada por el servidor LDAP y no puede ser cambiada aquí.",
"click_profile_picture_to_upload_custom": "Haga clic en la imagen de perfil para subir una personalizada desde sus archivos.",
"image_should_be_in_format": "La imagen debe ser en formato PNG o JPEG.",
"image_should_be_in_format": "La imagen debe ser en formato PNG, JPEG o WEBP.",
"items_per_page": "Elementos por página",
"no_items_found": "No se encontraron elementos",
"select_items": "Seleccionar elementos...",

View File

@@ -14,7 +14,7 @@
"profile_picture": "Profiilikuva",
"profile_picture_is_managed_by_ldap_server": "Profiilikuva hallitaan LDAP-palvelimella, eikä sitä voi muuttaa tässä.",
"click_profile_picture_to_upload_custom": "Napsauta profiilikuvaa ladataksesi kuvan tiedostoistasi.",
"image_should_be_in_format": "Kuvan tulee olla PNG- tai JPEG-muodossa.",
"image_should_be_in_format": "Kuvan tulee olla PNG-, JPEG- tai WEBP-muodossa.",
"items_per_page": "Kohteita per sivu",
"no_items_found": "Kohteita ei löytynyt",
"select_items": "Valitse kohteet...",

View File

@@ -14,7 +14,7 @@
"profile_picture": "Photo de profil",
"profile_picture_is_managed_by_ldap_server": "La photo de profil est gérée par le serveur LDAP et ne peut pas être modifiée ici.",
"click_profile_picture_to_upload_custom": "Cliquez sur la photo de profil pour télécharger une photo depuis votre ordinateur.",
"image_should_be_in_format": "L'image doit être au format PNG ou JPEG.",
"image_should_be_in_format": "L'image doit être au format PNG, JPEG ou WEBP.",
"items_per_page": "Éléments par page",
"no_items_found": "Aucune donnée trouvée",
"select_items": "Sélectionner des éléments...",

View File

@@ -14,7 +14,7 @@
"profile_picture": "Immagine del profilo",
"profile_picture_is_managed_by_ldap_server": "L'immagine del profilo è gestita dal server LDAP e non può essere modificata qui.",
"click_profile_picture_to_upload_custom": "Clicca sull'immagine del profilo per caricarne una personalizzata dai tuoi file.",
"image_should_be_in_format": "L'immagine deve essere in formato PNG o JPEG.",
"image_should_be_in_format": "L'immagine deve essere in formato PNG, JPEG o WEBP.",
"items_per_page": "Elementi per pagina",
"no_items_found": "Nessun elemento trovato",
"select_items": "Scegli gli articoli...",

View File

@@ -14,7 +14,7 @@
"profile_picture": "プロフィール画像",
"profile_picture_is_managed_by_ldap_server": "プロフィール画像はLDAPサーバーによって管理されており、ここでは変更できません。",
"click_profile_picture_to_upload_custom": "プロフィール画像をクリックして、ファイルからカスタム画像をアップロードします。",
"image_should_be_in_format": "画像はPNGまたはJPEG形式である必要があります。",
"image_should_be_in_format": "画像はPNGJPEG、またはWEBP形式である必要があります。",
"items_per_page": "ページあたりの表示件数",
"no_items_found": "項目が見つかりません",
"select_items": "項目を選択…",

View File

@@ -14,7 +14,7 @@
"profile_picture": "프로필 사진",
"profile_picture_is_managed_by_ldap_server": "프로필 사진이 LDAP 서버에서 관리되어 여기에서 변경할 수 없습니다.",
"click_profile_picture_to_upload_custom": "프로필 사진을 클릭하여 파일에서 사용자 정의 사진을 업로드하세요.",
"image_should_be_in_format": "이미지는 PNG 또는 JPEG 형식이어야 합니다.",
"image_should_be_in_format": "이미지는 PNG, JPEG 또는 WEBP 형식이어야 합니다.",
"items_per_page": "페이지당 항목",
"no_items_found": "항목 없음",
"select_items": "항목을 선택하세요...",

View File

@@ -14,7 +14,7 @@
"profile_picture": "Profielfoto",
"profile_picture_is_managed_by_ldap_server": "De profielfoto wordt beheerd door de LDAP-server en kan hier niet worden gewijzigd.",
"click_profile_picture_to_upload_custom": "Klik op de profielfoto om een aangepaste foto uit je bestanden te uploaden.",
"image_should_be_in_format": "De afbeelding moet in PNG- of JPEG-formaat zijn.",
"image_should_be_in_format": "De afbeelding moet in PNG-, JPEG- of WEBP-formaat zijn.",
"items_per_page": "Aantal per pagina",
"no_items_found": "Geen items gevonden",
"select_items": "Kies items...",

View File

@@ -14,7 +14,7 @@
"profile_picture": "Zdjęcie profilowe",
"profile_picture_is_managed_by_ldap_server": "Zdjęcie profilowe jest zarządzane przez serwer LDAP i nie można go tutaj zmienić.",
"click_profile_picture_to_upload_custom": "Kliknij zdjęcie profilowe, aby przesłać własne z plików.",
"image_should_be_in_format": "Obraz powinien być w formacie PNG lub JPEG.",
"image_should_be_in_format": "Obraz powinien być w formacie PNG, JPEG lub WEBP.",
"items_per_page": "Elementów na stronę",
"no_items_found": "Nie znaleziono żadnych elementów",
"select_items": "Wybierz elementy...",

View File

@@ -14,7 +14,7 @@
"profile_picture": "Foto de Perfil",
"profile_picture_is_managed_by_ldap_server": "A foto de perfil é gerenciada pelo servidor LDAP e não pode ser alterada aqui.",
"click_profile_picture_to_upload_custom": "Clique na foto de perfil para enviar uma imagem personalizada dos seus arquivos.",
"image_should_be_in_format": "A imagem deve estar no formato PNG ou JPEG.",
"image_should_be_in_format": "A imagem deve estar no formato PNG, JPEG ou WEBP.",
"items_per_page": "Itens por página",
"no_items_found": "Nada foi encontrado",
"select_items": "Selecione os itens...",

View File

@@ -14,7 +14,7 @@
"profile_picture": "Изображение профиля",
"profile_picture_is_managed_by_ldap_server": "Изображение профиля управляется сервером LDAP и не может быть изменено здесь.",
"click_profile_picture_to_upload_custom": "Нажмите на изображение профиля, чтобы загрузить его из ваших файлов.",
"image_should_be_in_format": "Изображение должно быть в формате PNG или JPEG.",
"image_should_be_in_format": "Изображение должно быть в формате PNG, JPEG или WEBP.",
"items_per_page": "Элементов на странице",
"no_items_found": "Элементы не найдены",
"select_items": "Выбрать элементы...",
@@ -155,7 +155,7 @@
"are_you_sure_you_want_to_revoke_the_api_key_apikeyname": "Вы уверены, что хотите отозвать ключ API \"{apiKeyName}\"? Любые интеграции, использующие этот ключ, перестанут работать.",
"last_used": "Последнее использование",
"actions": "Действия",
"images_updated_successfully": "Изображения обновились, но может занять пару минут.",
"images_updated_successfully": "Изображения успешно обновлены. Это может занять пару минут для обновления.",
"general": "Общее",
"configure_smtp_to_send_emails": "Включить уведомления пользователей по электронной почте при обнаружении логина с нового устройства или локации.",
"ldap": "LDAP",
@@ -331,10 +331,10 @@
"token_sign_in": "Вход с помощью токена",
"client_authorization": "Авторизация клиента",
"new_client_authorization": "Авторизация нового клиента",
"device_code_authorization": "Авторизация кода устройства",
"new_device_code_authorization": "Авторизация нового кода устройства",
"passkey_added": "Добавлен пароль",
"passkey_removed": "Удален ключ доступа",
"device_code_authorization": "Авторизация через код устройства",
"new_device_code_authorization": "Новая авторизация через код устройства",
"passkey_added": "Пасскей добавлен",
"passkey_removed": "Пасскей удален",
"disable_animations": "Отключить анимации",
"turn_off_ui_animations": "Отключить все анимации в интерфейсе.",
"user_disabled": "Учетная запись отключена",
@@ -467,7 +467,7 @@
"reauthentication": "Повторная аутентификация",
"clear_filters": "Сбросить фильтры",
"default_profile_picture": "Изображение профиля по умолчанию",
"light": "Свет",
"dark": "Темный",
"system": "Система"
"light": "Светлая",
"dark": "Темная",
"system": "Системная"
}

View File

@@ -14,7 +14,7 @@
"profile_picture": "Profilbild",
"profile_picture_is_managed_by_ldap_server": "Profilbilden hanteras av LDAP-servern och kan inte ändras här.",
"click_profile_picture_to_upload_custom": "Klicka på profilbilden för att ladda upp en anpassad bild från dina filer.",
"image_should_be_in_format": "Bilden ska vara i PNG- eller JPEG-format.",
"image_should_be_in_format": "Bilden ska vara i PNG-, JPEG- eller WEBP-format.",
"items_per_page": "Objekt per sida",
"no_items_found": "Inga objekt hittades",
"select_items": "Välj objekt...",

View File

@@ -14,7 +14,7 @@
"profile_picture": "Profil resmi",
"profile_picture_is_managed_by_ldap_server": "Profil resmi LDAP sunucusu tarafından yönetilmektedir ve burada değiştirilemez.",
"click_profile_picture_to_upload_custom": "Özel bir resim yüklemek için profil resmine tıklayın.",
"image_should_be_in_format": "Resim PNG veya JPEG formatında olmalıdır.",
"image_should_be_in_format": "Resim PNG, JPEG veya WEBP formatında olmalıdır.",
"items_per_page": "Sayfa başına öğe sayısı",
"no_items_found": "Hiçbir öğe bulunamadı",
"select_items": "Öğeleri seçin...",

View File

@@ -14,7 +14,7 @@
"profile_picture": "Фотографія профілю",
"profile_picture_is_managed_by_ldap_server": "Фотографія профілю управляється сервером LDAP і не може бути змінена тут.",
"click_profile_picture_to_upload_custom": "Натисніть на зображення профілю, щоб завантажити власне зображення.",
"image_should_be_in_format": "Зображення повинно бути у форматі PNG або JPEG.",
"image_should_be_in_format": "Зображення повинно бути у форматі PNG, JPEG або WEBP.",
"items_per_page": "Елементів на сторінці",
"no_items_found": "Нічого не знайдено",
"select_items": "Виберіть елементи...",

View File

@@ -14,7 +14,7 @@
"profile_picture": "Ảnh đại diện",
"profile_picture_is_managed_by_ldap_server": "Hình đại diện được quản lý bởi máy chủ LDAP và không thể thay đổi tại đây.",
"click_profile_picture_to_upload_custom": "Nhấp vào hình ảnh hồ sơ để tải lên hình ảnh tùy chỉnh.",
"image_should_be_in_format": "Hình ảnh phải ở định dạng PNG hoặc JPEG.",
"image_should_be_in_format": "Hình ảnh phải ở định dạng PNG, JPEG hoặc WEBP.",
"items_per_page": "Số kết quả mỗi trang",
"no_items_found": "Không tìm thấy kết quả nào",
"select_items": "Chọn các mục...",

View File

@@ -14,7 +14,7 @@
"profile_picture": "头像",
"profile_picture_is_managed_by_ldap_server": "头像由 LDAP 服务器管理,无法在此处更改。",
"click_profile_picture_to_upload_custom": "点击头像来从文件中上传您的自定义头像。",
"image_should_be_in_format": "图片应为 PNG 或 JPEG 格式。",
"image_should_be_in_format": "图片应为 PNG、JPEG 或 WEBP 格式。",
"items_per_page": "每页条数",
"no_items_found": "这里暂时空空如也",
"select_items": "选择项目……",

View File

@@ -14,7 +14,7 @@
"profile_picture": "個人資料圖片",
"profile_picture_is_managed_by_ldap_server": "這張個人資料圖片是由 LDAP 伺服器管理,無法在此變更。",
"click_profile_picture_to_upload_custom": "點擊個人資料圖片,從您的檔案中上傳自訂圖片。",
"image_should_be_in_format": "圖片應為 PNG 或 JPEG 格式。",
"image_should_be_in_format": "圖片應為 PNG、JPEG 或 WEBP 格式。",
"items_per_page": "每頁項目數",
"no_items_found": "找不到任何項目",
"select_items": "選擇項目...",

View File

@@ -22,7 +22,7 @@ test.describe('API Key Management', () => {
await page.getByRole('button', { name: 'Select a date' }).click();
await page.getByLabel('Select year').click();
// Select the next year
await page.getByText((currentDate.getFullYear() + 1).toString()).click();
await page.getByRole('option', { name: (currentDate.getFullYear() + 1).toString() }).click();
// Select the first day of the month
await page
.getByRole('button', { name: /([A-Z][a-z]+), ([A-Z][a-z]+) 1, (\d{4})/ })
@@ -62,7 +62,7 @@ test.describe('API Key Management', () => {
await page.getByRole('menuitem', { name: 'Revoke' }).click();
await page.getByRole('button', { name: 'Revoke' }).click();
// Verify success message
await expect(page.locator('[data-type="success"]')).toHaveText('API key revoked successfully');