mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-12-16 10:06:42 +03:00
Compare commits
22 Commits
9142bd6040
...
dd02e2d88c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd02e2d88c | ||
|
|
c368ceb110 | ||
|
|
c7fd610c6e | ||
|
|
ef25c39c92 | ||
|
|
49181dbc18 | ||
|
|
ef569afa87 | ||
|
|
946c9e7cc0 | ||
|
|
53286a49e9 | ||
|
|
5a1d4257ec | ||
|
|
eec9eefa7b | ||
|
|
f1f1188ac5 | ||
|
|
a741440b81 | ||
|
|
e61c9e20bd | ||
|
|
4e1b3faafd | ||
|
|
ee468ac9ad | ||
|
|
cb58031c1f | ||
|
|
194f875669 | ||
|
|
1c969f3250 | ||
|
|
7577483009 | ||
|
|
3e80b0d5a6 | ||
|
|
dfd837cfb0 | ||
|
|
152e585206 |
@ -123,7 +123,7 @@ auth:
|
||||
# Optional: Refresh time in seconds (default: shown below)
|
||||
# When the session is going to expire in less time than this setting,
|
||||
# it will be refreshed back to the session_length.
|
||||
refresh_time: 43200 # 12 hours
|
||||
refresh_time: 1800 # 30 minutes
|
||||
# Optional: Rate limiting for login failures to help prevent brute force
|
||||
# login attacks (default: shown below)
|
||||
# See the docs for more information on valid values
|
||||
|
||||
@ -55,8 +55,8 @@ def require_admin_by_default():
|
||||
"/auth",
|
||||
"/auth/first_time_login",
|
||||
"/login",
|
||||
# Authenticated user endpoints (allow_any_authenticated)
|
||||
"/logout",
|
||||
# Authenticated user endpoints (allow_any_authenticated)
|
||||
"/profile",
|
||||
# Public info endpoints (allow_public)
|
||||
"/",
|
||||
@ -311,7 +311,10 @@ def get_jwt_secret() -> str:
|
||||
)
|
||||
jwt_secret = secrets.token_hex(64)
|
||||
try:
|
||||
with open(jwt_secret_file, "w") as f:
|
||||
fd = os.open(
|
||||
jwt_secret_file, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600
|
||||
)
|
||||
with os.fdopen(fd, "w") as f:
|
||||
f.write(str(jwt_secret))
|
||||
except Exception:
|
||||
logger.warning(
|
||||
@ -356,9 +359,35 @@ def verify_password(password, password_hash):
|
||||
return secrets.compare_digest(password_hash, compare_hash)
|
||||
|
||||
|
||||
def validate_password_strength(password: str) -> tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Validate password strength.
|
||||
|
||||
Returns a tuple of (is_valid, error_message).
|
||||
"""
|
||||
if not password:
|
||||
return False, "Password cannot be empty"
|
||||
|
||||
if len(password) < 8:
|
||||
return False, "Password must be at least 8 characters long"
|
||||
|
||||
if not any(c.isupper() for c in password):
|
||||
return False, "Password must contain at least one uppercase letter"
|
||||
|
||||
if not any(c.isdigit() for c in password):
|
||||
return False, "Password must contain at least one digit"
|
||||
|
||||
if not any(c in '!@#$%^&*(),.?":{}|<>' for c in password):
|
||||
return False, "Password must contain at least one special character"
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
def create_encoded_jwt(user, role, expiration, secret):
|
||||
return jwt.encode(
|
||||
{"alg": "HS256"}, {"sub": user, "role": role, "exp": expiration}, secret
|
||||
{"alg": "HS256"},
|
||||
{"sub": user, "role": role, "exp": expiration, "iat": int(time.time())},
|
||||
secret,
|
||||
)
|
||||
|
||||
|
||||
@ -619,13 +648,27 @@ def auth(request: Request):
|
||||
return fail_response
|
||||
|
||||
# if the jwt cookie is expiring soon
|
||||
elif jwt_source == "cookie" and expiration - JWT_REFRESH <= current_time:
|
||||
if jwt_source == "cookie" and expiration - JWT_REFRESH <= current_time:
|
||||
logger.debug("jwt token expiring soon, refreshing cookie")
|
||||
# ensure the user hasn't been deleted
|
||||
|
||||
# Check if password has been changed since token was issued
|
||||
# If so, force re-login by rejecting the refresh
|
||||
try:
|
||||
User.get_by_id(user)
|
||||
user_obj = User.get_by_id(user)
|
||||
if user_obj.password_changed_at is not None:
|
||||
token_iat = int(token.claims.get("iat", 0))
|
||||
password_changed_timestamp = int(
|
||||
user_obj.password_changed_at.timestamp()
|
||||
)
|
||||
if token_iat < password_changed_timestamp:
|
||||
logger.debug(
|
||||
"jwt token issued before password change, rejecting refresh"
|
||||
)
|
||||
return fail_response
|
||||
except DoesNotExist:
|
||||
logger.debug("user not found")
|
||||
return fail_response
|
||||
|
||||
new_expiration = current_time + JWT_SESSION_LENGTH
|
||||
new_encoded_jwt = create_encoded_jwt(
|
||||
user, role, new_expiration, request.app.jwt_token
|
||||
@ -660,7 +703,7 @@ def profile(request: Request):
|
||||
)
|
||||
|
||||
|
||||
@router.get("/logout", dependencies=[Depends(allow_any_authenticated())])
|
||||
@router.get("/logout", dependencies=[Depends(allow_public())])
|
||||
def logout(request: Request):
|
||||
auth_config: AuthConfig = request.app.frigate_config.auth
|
||||
response = RedirectResponse("/login", status_code=303)
|
||||
@ -782,10 +825,63 @@ async def update_password(
|
||||
|
||||
HASH_ITERATIONS = request.app.frigate_config.auth.hash_iterations
|
||||
|
||||
password_hash = hash_password(body.password, iterations=HASH_ITERATIONS)
|
||||
User.set_by_id(username, {User.password_hash: password_hash})
|
||||
try:
|
||||
user = User.get_by_id(username)
|
||||
except DoesNotExist:
|
||||
return JSONResponse(content={"message": "User not found"}, status_code=404)
|
||||
|
||||
return JSONResponse(content={"success": True})
|
||||
# Require old_password when:
|
||||
# 1. Non-admin user is changing another user's password (admin only action)
|
||||
# 2. Any user is changing their own password
|
||||
is_changing_own_password = current_username == username
|
||||
is_non_admin = current_role != "admin"
|
||||
|
||||
if is_changing_own_password or is_non_admin:
|
||||
if not body.old_password:
|
||||
return JSONResponse(
|
||||
content={"message": "Current password is required"},
|
||||
status_code=400,
|
||||
)
|
||||
if not verify_password(body.old_password, user.password_hash):
|
||||
return JSONResponse(
|
||||
content={"message": "Current password is incorrect"},
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
# Validate new password strength
|
||||
is_valid, error_message = validate_password_strength(body.password)
|
||||
if not is_valid:
|
||||
return JSONResponse(
|
||||
content={"message": error_message},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
password_hash = hash_password(body.password, iterations=HASH_ITERATIONS)
|
||||
User.update(
|
||||
{
|
||||
User.password_hash: password_hash,
|
||||
User.password_changed_at: datetime.now(),
|
||||
}
|
||||
).where(User.username == username).execute()
|
||||
|
||||
response = JSONResponse(content={"success": True})
|
||||
|
||||
# If user changed their own password, issue a new JWT to keep them logged in
|
||||
if current_username == username:
|
||||
JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name
|
||||
JWT_COOKIE_SECURE = request.app.frigate_config.auth.cookie_secure
|
||||
JWT_SESSION_LENGTH = request.app.frigate_config.auth.session_length
|
||||
|
||||
expiration = int(time.time()) + JWT_SESSION_LENGTH
|
||||
encoded_jwt = create_encoded_jwt(
|
||||
username, current_role, expiration, request.app.jwt_token
|
||||
)
|
||||
# Set new JWT cookie on response
|
||||
set_jwt_cookie(
|
||||
response, JWT_COOKIE_NAME, encoded_jwt, expiration, JWT_COOKIE_SECURE
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.put(
|
||||
|
||||
@ -11,6 +11,7 @@ class AppConfigSetBody(BaseModel):
|
||||
|
||||
class AppPutPasswordBody(BaseModel):
|
||||
password: str
|
||||
old_password: Optional[str] = None
|
||||
|
||||
|
||||
class AppPostUsersBody(BaseModel):
|
||||
|
||||
@ -20,7 +20,7 @@ class AuthConfig(FrigateBaseModel):
|
||||
default=86400, title="Session length for jwt session tokens", ge=60
|
||||
)
|
||||
refresh_time: int = Field(
|
||||
default=43200,
|
||||
default=1800,
|
||||
title="Refresh the session if it is going to expire in this many seconds",
|
||||
ge=30,
|
||||
)
|
||||
|
||||
@ -133,6 +133,7 @@ class User(Model):
|
||||
default="admin",
|
||||
)
|
||||
password_hash = CharField(null=False, max_length=120)
|
||||
password_changed_at = DateTimeField(null=True)
|
||||
notification_tokens = JSONField()
|
||||
|
||||
@classmethod
|
||||
|
||||
@ -54,7 +54,9 @@ def migrate(migrator, database, fake=False, **kwargs):
|
||||
|
||||
# Migrate existing has_been_reviewed data to UserReviewStatus for all users
|
||||
def migrate_data():
|
||||
all_users = list(User.select())
|
||||
# Use raw SQL to avoid ORM issues with columns that don't exist yet
|
||||
cursor = database.execute_sql('SELECT "username" FROM "user"')
|
||||
all_users = cursor.fetchall()
|
||||
if not all_users:
|
||||
return
|
||||
|
||||
@ -63,7 +65,7 @@ def migrate(migrator, database, fake=False, **kwargs):
|
||||
)
|
||||
reviewed_segment_ids = [row[0] for row in cursor.fetchall()]
|
||||
# also migrate for anonymous (unauthenticated users)
|
||||
usernames = [user.username for user in all_users] + ["anonymous"]
|
||||
usernames = [user[0] for user in all_users] + ["anonymous"]
|
||||
|
||||
for segment_id in reviewed_segment_ids:
|
||||
for username in usernames:
|
||||
|
||||
42
migrations/032_add_password_changed_at.py
Normal file
42
migrations/032_add_password_changed_at.py
Normal file
@ -0,0 +1,42 @@
|
||||
"""Peewee migrations -- 032_add_password_changed_at.py.
|
||||
|
||||
Some examples (model - class or model name)::
|
||||
|
||||
> Model = migrator.orm['model_name'] # Return model in current state by name
|
||||
|
||||
> migrator.sql(sql) # Run custom SQL
|
||||
> migrator.python(func, *args, **kwargs) # Run python code
|
||||
> migrator.create_model(Model) # Create a model (could be used as decorator)
|
||||
> migrator.remove_model(model, cascade=True) # Remove a model
|
||||
> migrator.add_fields(model, **fields) # Add fields to a model
|
||||
> migrator.change_fields(model, **fields) # Change fields
|
||||
> migrator.remove_fields(model, *field_names, cascade=True)
|
||||
> migrator.rename_field(model, old_field_name, new_field_name)
|
||||
> migrator.rename_table(model, new_table_name)
|
||||
> migrator.add_index(model, *col_names, unique=False)
|
||||
> migrator.drop_index(model, *col_names)
|
||||
> migrator.add_not_null(model, *field_names)
|
||||
> migrator.drop_not_null(model, *field_names)
|
||||
> migrator.add_default(model, field_name, default)
|
||||
|
||||
"""
|
||||
|
||||
import peewee as pw
|
||||
|
||||
SQL = pw.SQL
|
||||
|
||||
|
||||
def migrate(migrator, database, fake=False, **kwargs):
|
||||
migrator.sql(
|
||||
"""
|
||||
ALTER TABLE user ADD COLUMN password_changed_at DATETIME NULL
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def rollback(migrator, database, fake=False, **kwargs):
|
||||
migrator.sql(
|
||||
"""
|
||||
ALTER TABLE user DROP COLUMN password_changed_at
|
||||
"""
|
||||
)
|
||||
@ -185,5 +185,6 @@
|
||||
"noNewImages": "Sense noves imatges per entrenar. Classifica més imatges primer.",
|
||||
"modelNotReady": "El model no está preparat per entrenar",
|
||||
"noChanges": "No hi ha canvis al conjunt de dades des de l'última formació."
|
||||
}
|
||||
},
|
||||
"none": "Cap"
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"documentTitle": "Klassifikationsmodelle",
|
||||
"documentTitle": "Klassifizierungsmodelle - Fregatte",
|
||||
"details": {
|
||||
"scoreInfo": "Die Punktzahl gibt die durchschnittliche Konfidenz aller Erkennungen dieses Objekts wieder."
|
||||
},
|
||||
@ -180,5 +180,6 @@
|
||||
"description": "Es wird empfohlen für alle Zustände Beispiele auszuwählen. Das Modell wird erst trainiert, wenn für alle Zustände Bilder vorhanden sind. Fahren Sie fort und verwenden Sie die Ansicht „Aktuelle Klassifizierungen“, um Bilder für die fehlenden Zustände zu klassifizieren. Trainieren Sie anschließend das Modell."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"none": "Keiner"
|
||||
}
|
||||
|
||||
@ -276,7 +276,7 @@
|
||||
"millisecondsToOffset": "Millisekunden, um Erkennungs-Anmerkungen zu verschieben. <em>Standard: 0</em>",
|
||||
"tips": "Verringere den Wert, wenn die Videowiedergabe den Boxen und Wegpunkten voraus ist, und erhöhe den Wert, wenn die Videowiedergabe hinter ihnen zurückbleibt. Dieser Wert kann negativ sein.",
|
||||
"toast": {
|
||||
"success": "Der Anmerkungs-Offset für {{camera}} wurde in der Konfigurationsdatei gespeichert. Starte Frigate neu, um Ihre Änderungen zu übernehmen."
|
||||
"success": "Der Anmerkungs-Offset für {{camera}} wurde in der Konfigurationsdatei gespeichert."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
"camera": "Kameraeinstellungen - Frigate",
|
||||
"masksAndZones": "Masken- und Zoneneditor – Frigate",
|
||||
"object": "Debug - Frigate",
|
||||
"general": "UI Einstellungen – Frigate",
|
||||
"general": "UI-Einstellungen – Frigate",
|
||||
"frigatePlus": "Frigate+ Einstellungen – Frigate",
|
||||
"classification": "Klassifizierungseinstellungen – Frigate",
|
||||
"motionTuner": "Bewegungserkennungs-Optimierer – Frigate",
|
||||
@ -298,7 +298,7 @@
|
||||
"zones": {
|
||||
"edit": "Zone bearbeiten",
|
||||
"toast": {
|
||||
"success": "Die Zone ({{zoneName}}) wurde gespeichert. Starten Sie Frigate neu, um die Änderungen zu übernehmen."
|
||||
"success": "Die Zone ({{zoneName}}) wurde gespeichert."
|
||||
},
|
||||
"desc": {
|
||||
"documentation": "Dokumentation",
|
||||
@ -361,8 +361,8 @@
|
||||
"clickDrawPolygon": "Klicke, um ein Polygon auf dem Bild zu zeichnen.",
|
||||
"toast": {
|
||||
"success": {
|
||||
"noName": "Bewegungsmaske wurde gespeichert. Starte Frigate neu, um die Änderungen zu übernehmen.",
|
||||
"title": "{{polygonName}} wurde gespeichert. Starte Frigate neu, um die Änderungen zu übernehmen."
|
||||
"noName": "Bewegungsmaske wurde gespeichert.",
|
||||
"title": "{{polygonName}} wurde gespeichert."
|
||||
}
|
||||
},
|
||||
"add": "Neue Bewegungsmaske",
|
||||
@ -382,8 +382,8 @@
|
||||
"documentTitle": "Objektmaske bearbeiten – Frigate",
|
||||
"toast": {
|
||||
"success": {
|
||||
"noName": "Objektmaske wurde gespeichert. Starte Frigate neu, um die Änderungen zu übernehmen.",
|
||||
"title": "{{polygonName}} wurde gespeichert. Starte Frigate neu, um die Änderungen zu übernehmen."
|
||||
"noName": "Objektmaske wurde gespeichert.",
|
||||
"title": "{{polygonName}} wurde gespeichert."
|
||||
}
|
||||
},
|
||||
"desc": {
|
||||
|
||||
@ -712,6 +712,8 @@
|
||||
"password": {
|
||||
"title": "Password",
|
||||
"placeholder": "Enter password",
|
||||
"show": "Show password",
|
||||
"hide": "Hide password",
|
||||
"confirm": {
|
||||
"title": "Confirm Password",
|
||||
"placeholder": "Confirm Password"
|
||||
@ -723,6 +725,13 @@
|
||||
"strong": "Strong",
|
||||
"veryStrong": "Very Strong"
|
||||
},
|
||||
"requirements": {
|
||||
"title": "Password requirements:",
|
||||
"length": "At least 8 characters",
|
||||
"uppercase": "At least one uppercase letter",
|
||||
"digit": "At least one digit",
|
||||
"special": "At least one special character (!@#$%^&*(),.?\":{}|<>)"
|
||||
},
|
||||
"match": "Passwords match",
|
||||
"notMatch": "Passwords don't match"
|
||||
},
|
||||
@ -733,6 +742,10 @@
|
||||
"placeholder": "Re-enter new password"
|
||||
}
|
||||
},
|
||||
"currentPassword": {
|
||||
"title": "Current Password",
|
||||
"placeholder": "Enter your current password"
|
||||
},
|
||||
"usernameIsRequired": "Username is required",
|
||||
"passwordIsRequired": "Password is required"
|
||||
},
|
||||
@ -750,9 +763,13 @@
|
||||
"passwordSetting": {
|
||||
"cannotBeEmpty": "Password cannot be empty",
|
||||
"doNotMatch": "Passwords do not match",
|
||||
"currentPasswordRequired": "Current password is required",
|
||||
"incorrectCurrentPassword": "Current password is incorrect",
|
||||
"passwordVerificationFailed": "Failed to verify password",
|
||||
"updatePassword": "Update Password for {{username}}",
|
||||
"setPassword": "Set Password",
|
||||
"desc": "Create a strong password to secure this account."
|
||||
"desc": "Create a strong password to secure this account.",
|
||||
"multiDeviceWarning": "Any other devices where you are logged in will be required to re-login within {{refresh_time}}. You can also force all users to re-authenticate immediately by rotating your JWT secret."
|
||||
},
|
||||
"changeRole": {
|
||||
"title": "Change User Role",
|
||||
|
||||
@ -185,7 +185,7 @@
|
||||
},
|
||||
"label": "Mode sombre"
|
||||
},
|
||||
"review": "Événements",
|
||||
"review": "Activités",
|
||||
"explore": "Explorer",
|
||||
"export": "Exporter",
|
||||
"user": {
|
||||
@ -247,7 +247,7 @@
|
||||
"title": "Rôle",
|
||||
"viewer": "Observateur",
|
||||
"admin": "Administrateur",
|
||||
"desc": "Les administrateurs ont un accès complet à toutes les fonctionnalités de l'interface Frigate. Les observateurs sont limités à la consultation des caméras, des événements, et à l'historique des enregistrements dans l'interface."
|
||||
"desc": "Les administrateurs ont un accès complet à toutes les fonctionnalités de l'interface Frigate. Les observateurs sont limités à la consultation des caméras, des activités, et à l'historique des enregistrements dans l'interface."
|
||||
},
|
||||
"pagination": {
|
||||
"next": {
|
||||
|
||||
@ -110,19 +110,19 @@
|
||||
"recording": {
|
||||
"confirmDelete": {
|
||||
"desc": {
|
||||
"selected": "Êtes-vous sûr(e) de vouloir supprimer toutes les vidéos enregistrées associées à cet événement ? <br /><br />Maintenez la touche <em>Maj</em> enfoncée pour éviter cette boîte de dialogue à l'avenir."
|
||||
"selected": "Êtes-vous sûr(e) de vouloir supprimer toutes les vidéos enregistrées associées à cette activité ? <br /><br />Maintenez la touche <em>Maj</em> enfoncée pour éviter cette boîte de dialogue à l'avenir."
|
||||
},
|
||||
"title": "Confirmer la suppression",
|
||||
"toast": {
|
||||
"success": "Les vidéos associées aux événements sélectionnés ont été supprimées.",
|
||||
"success": "Les vidéos associées aux activités sélectionnées ont été supprimées.",
|
||||
"error": "Échec de la suppression : {{error}}"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"export": "Exporter",
|
||||
"markAsReviewed": "Marquer comme vérifié",
|
||||
"markAsReviewed": "Marquer comme traité",
|
||||
"deleteNow": "Supprimer maintenant",
|
||||
"markAsUnreviewed": "Marquer comme non vérifié"
|
||||
"markAsUnreviewed": "Marquer comme non traité"
|
||||
}
|
||||
},
|
||||
"imagePicker": {
|
||||
|
||||
@ -83,7 +83,7 @@
|
||||
}
|
||||
},
|
||||
"review": {
|
||||
"showReviewed": "Afficher les éléments vérifiés"
|
||||
"showReviewed": "Afficher les activités traitées"
|
||||
},
|
||||
"cameras": {
|
||||
"label": "Filtre des caméras",
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
"downloadVideo": {
|
||||
"label": "Télécharger la vidéo",
|
||||
"toast": {
|
||||
"success": "Le téléchargement de la vidéo de votre événement a commencé."
|
||||
"success": "Le téléchargement de la vidéo a commencé."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"documentTitle": "Modèles de classification",
|
||||
"documentTitle": "Modèles de classification - Frigate",
|
||||
"button": {
|
||||
"deleteClassificationAttempts": "Supprimer les images de classification",
|
||||
"renameCategory": "Renommer la classe",
|
||||
@ -156,7 +156,7 @@
|
||||
"modelCreated": "Modèle créé avec succès. Utilisez la vue Classifications récentes pour ajouter des images pour les états manquants, puis entraînez le modèle.",
|
||||
"missingStatesWarning": {
|
||||
"title": "Exemples d'états manquants",
|
||||
"description": "Vous n'avez pas sélectionné d'exemples pour tous les états. L'entraînement ne pourra débuter que lorsque chaque état disposera d'images. Continuez, puis utilisez la vue Classifications récentes pour classer les images manquantes et lancer l'entraînement."
|
||||
"description": "Pour des résultats optimaux, il est recommandé de sélectionner des exemples pour tous les états. Vous pouvez continuer sans cette étape, mais le modèle ne sera entraîné que lorsque chaque état disposera d'images. Continuez, puis utilisez la vue Classifications récentes pour classer les images manquantes et lancer l'entraînement."
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -185,5 +185,6 @@
|
||||
"noNewImages": "Aucune nouvelle image pour l'entraînement. Veuillez d'abord classifier plus d'images dans le jeu de données.",
|
||||
"modelNotReady": "Le modèle n'est pas prêt pour l'entraînement.",
|
||||
"noChanges": "Aucune modification du jeu de données depuis le dernier entraînement"
|
||||
}
|
||||
},
|
||||
"none": "Aucun"
|
||||
}
|
||||
|
||||
@ -7,8 +7,8 @@
|
||||
"alerts": "Alertes",
|
||||
"allCameras": "Toutes les caméras",
|
||||
"empty": {
|
||||
"alert": "Il n'y a aucune alerte à examiner.",
|
||||
"detection": "Il n'y a aucune détection à examiner.",
|
||||
"alert": "Aucune alerte à traiter",
|
||||
"detection": "Aucune détection à traiter",
|
||||
"motion": "Aucune donnée de mouvement trouvée"
|
||||
},
|
||||
"timeline": "Chronologie",
|
||||
@ -17,7 +17,7 @@
|
||||
"aria": "Sélectionner les événements",
|
||||
"noFoundForTimePeriod": "Aucun événement n'a été trouvé pour cette plage de temps."
|
||||
},
|
||||
"documentTitle": "Événements - Frigate",
|
||||
"documentTitle": "Activités - Frigate",
|
||||
"recordings": {
|
||||
"documentTitle": "Enregistrements - Frigate"
|
||||
},
|
||||
@ -25,13 +25,13 @@
|
||||
"last24Hours": "Dernières 24 heures"
|
||||
},
|
||||
"timeline.aria": "Sélectionner une chronologie",
|
||||
"markAsReviewed": "Marquer comme vérifié",
|
||||
"markAsReviewed": "Marquer comme traitê",
|
||||
"newReviewItems": {
|
||||
"button": "Nouveaux événements à examiner",
|
||||
"label": "Afficher les nouveaux événements"
|
||||
"button": "Nouvelles activités à traiter",
|
||||
"label": "Afficher les nouvelles activités"
|
||||
},
|
||||
"camera": "Caméra",
|
||||
"markTheseItemsAsReviewed": "Marquer ces éléments comme vérifiés",
|
||||
"markTheseItemsAsReviewed": "Marquer ces activités comme traitées",
|
||||
"selected": "{{count}} sélectionné(s)",
|
||||
"selected_other": "{{count}} sélectionné(s)",
|
||||
"selected_one": "{{count}} sélectionné(s)",
|
||||
@ -39,7 +39,7 @@
|
||||
"suspiciousActivity": "Activité suspecte",
|
||||
"threateningActivity": "Activité menaçante",
|
||||
"detail": {
|
||||
"noDataFound": "Aucun détail à examiner",
|
||||
"noDataFound": "Aucun détail à traiter",
|
||||
"aria": "Activer/désactiver la vue détaillée",
|
||||
"trackedObject_one": "{{count}} objet",
|
||||
"trackedObject_other": "{{count}} objets",
|
||||
@ -48,7 +48,7 @@
|
||||
"settings": "Paramètres de la vue Détail",
|
||||
"alwaysExpandActive": {
|
||||
"title": "Toujours développer l'élément actif",
|
||||
"desc": "Toujours développer les détails de l'objet de l'événement actif si disponibles"
|
||||
"desc": "Toujours développer les détails de l'objet pour l'activité en cours"
|
||||
}
|
||||
},
|
||||
"objectTrack": {
|
||||
@ -58,6 +58,6 @@
|
||||
"zoomIn": "Zoom avant",
|
||||
"zoomOut": "Zoom arrière",
|
||||
"normalActivity": "Normal",
|
||||
"needsReview": "Nécessite une revue",
|
||||
"needsReview": "À traiter",
|
||||
"securityConcern": "Problème de sécurité"
|
||||
}
|
||||
|
||||
@ -32,9 +32,9 @@
|
||||
"details": {
|
||||
"timestamp": "Horodatage",
|
||||
"item": {
|
||||
"title": "Détails de l'événement",
|
||||
"title": "Détails de l'activité",
|
||||
"button": {
|
||||
"share": "Partager cet événement",
|
||||
"share": "Partager cette activité",
|
||||
"viewInExplore": "Afficher dans Explorer"
|
||||
},
|
||||
"toast": {
|
||||
@ -52,12 +52,12 @@
|
||||
}
|
||||
},
|
||||
"tips": {
|
||||
"mismatch_one": "{{count}} objet indisponible a été détecté et intégré dans cet événement. Cet objet n'a pas été qualifié comme une alerte ou une détection, ou a déjà été nettoyé / supprimé.",
|
||||
"mismatch_many": "{{count}} objets indisponibles ont été détectés et intégrés dans cet événement. Ces objets n'ont pas été qualifiés comme une alerte ou une détection, ou ont déjà été nettoyés / supprimés.",
|
||||
"mismatch_other": "{{count}} objets indisponibles ont été détectés et intégrés dans cet événement. Ces objets n'ont pas été qualifiés comme une alerte ou une détection, ou ont déjà été nettoyés / supprimés.",
|
||||
"mismatch_one": "{{count}} objet indisponible a été détecté et intégré dans cette activité. Cet objet n'a pas été qualifié comme une alerte ou une détection, ou a déjà été nettoyé / supprimé.",
|
||||
"mismatch_many": "{{count}} objets indisponibles ont été détectés et intégrés dans cette activité. Ces objets n'ont pas été qualifiés comme une alerte ou une détection, ou ont déjà été nettoyés / supprimés.",
|
||||
"mismatch_other": "{{count}} objets indisponibles ont été détectés et intégrés dans cette activité. Ces objets n'ont pas été qualifiés comme une alerte ou une détection, ou ont déjà été nettoyés / supprimés.",
|
||||
"hasMissingObjects": "Ajustez votre configuration si vous souhaitez que Frigate enregistre les objets suivis pour les étiquettes suivantes : <em>{{objects}}</em>"
|
||||
},
|
||||
"desc": "Détails de l'événement"
|
||||
"desc": "Détails de l'activité"
|
||||
},
|
||||
"label": "Étiquette",
|
||||
"editSubLabel": {
|
||||
@ -277,7 +277,7 @@
|
||||
"millisecondsToOffset": "Millisecondes de décalage pour les annotations de détection. <em>Par défaut : 0</em>",
|
||||
"tips": "Diminuez la valeur si la lecture vidéo est en avance sur les cadres de détection et les points de tracé, et augmentez-la si la lecture vidéo est en retard sur ceux-ci. Cette valeur peut être négative.",
|
||||
"toast": {
|
||||
"success": "Le décalage des annotations pour {{camera}} a été sauvegardé dans le fichier de configuration. Redémarrez Frigate pour appliquer vos modifications."
|
||||
"success": "Le décalage des annotations pour {{camera}} a été sauvegardé dans le fichier de configuration."
|
||||
},
|
||||
"label": "Décalage d'annotation"
|
||||
},
|
||||
|
||||
@ -12,7 +12,7 @@
|
||||
"notifications": "Paramètres de notification - Frigate",
|
||||
"enrichments": "Paramètres d'enrichissements - Frigate",
|
||||
"cameraManagement": "Gestion des caméras - Frigate",
|
||||
"cameraReview": "Paramètres des événements de caméra - Frigate"
|
||||
"cameraReview": "Paramètres des activités - Frigate"
|
||||
},
|
||||
"menu": {
|
||||
"ui": "Interface utilisateur",
|
||||
@ -28,7 +28,7 @@
|
||||
"triggers": "Déclencheurs",
|
||||
"roles": "Rôles",
|
||||
"cameraManagement": "Gestion",
|
||||
"cameraReview": "Événements"
|
||||
"cameraReview": "Activités"
|
||||
},
|
||||
"dialog": {
|
||||
"unsavedChanges": {
|
||||
@ -395,7 +395,7 @@
|
||||
"name": {
|
||||
"title": "Nom",
|
||||
"inputPlaceHolder": "Saisissez un nom.",
|
||||
"tips": "Le nom doit comporter au moins 2 caractères, dont une lettre, et ne doit pas être le nom d'une caméra ou d'une autre zone."
|
||||
"tips": "Le nom doit comporter au moins 2 caractères, dont une lettre, et ne doit pas être le nom d'une caméra ou d'une autre zone sur cette caméra."
|
||||
},
|
||||
"loiteringTime": {
|
||||
"desc": "Définit une durée minimale en secondes pendant laquelle l'objet doit rester dans la zone pour qu'elle s'active. <em>Par défaut : 0</em>",
|
||||
@ -429,7 +429,7 @@
|
||||
"title": "Inertie"
|
||||
},
|
||||
"toast": {
|
||||
"success": "La zone ({{zoneName}}) a été enregistrée. Redémarrez Frigate pour appliquer les modifications."
|
||||
"success": "La zone ({{zoneName}}) a été enregistrée."
|
||||
},
|
||||
"objects": {
|
||||
"title": "Objets",
|
||||
@ -457,8 +457,8 @@
|
||||
"clickDrawPolygon": "Cliquer pour dessiner un polygone sur l'image.",
|
||||
"toast": {
|
||||
"success": {
|
||||
"title": "{{polygonName}} a été enregistré. Redémarrez Frigate pour appliquer les modifications.",
|
||||
"noName": "Le masque de mouvement a été enregistré. Redémarrez Frigate pour appliquer les modifications."
|
||||
"title": "{{polygonName}} a été enregistré.",
|
||||
"noName": "Le masque de mouvement a été enregistré."
|
||||
}
|
||||
},
|
||||
"desc": {
|
||||
@ -482,8 +482,8 @@
|
||||
},
|
||||
"toast": {
|
||||
"success": {
|
||||
"noName": "Le masque d'objet a été enregistré. Redémarrez Frigate pour appliquer les modifications.",
|
||||
"title": "{{polygonName}} a été enregistré. Redémarrez Frigate pour appliquer les modifications."
|
||||
"noName": "Le masque d'objet a été enregistré.",
|
||||
"title": "{{polygonName}} a été enregistré."
|
||||
}
|
||||
},
|
||||
"point_one": "{{count}} point",
|
||||
@ -720,7 +720,7 @@
|
||||
},
|
||||
"label": "Taille du modèle"
|
||||
},
|
||||
"desc": "La recherche sémantique de Frigate vous permet de retrouver les objets suivis dans vos événements en utilisant soit l'image elle-même, soit une description textuelle définie par l'utilisateur, soit une description générée automatiquement."
|
||||
"desc": "La recherche sémantique de Frigate vous permet de retrouver les objets suivis dans vos activités en utilisant soit l'image elle-même, soit une description textuelle définie par l'utilisateur, soit une description générée automatiquement."
|
||||
},
|
||||
"unsavedChanges": "Modifications non enregistrées des paramètres d'enrichissements",
|
||||
"faceRecognition": {
|
||||
@ -1256,17 +1256,17 @@
|
||||
}
|
||||
},
|
||||
"cameraReview": {
|
||||
"title": "Paramètres des événements de la caméra",
|
||||
"title": "Paramètres des activités caméra",
|
||||
"object_descriptions": {
|
||||
"title": "Descriptions d'objets par l'IA générative",
|
||||
"desc": "Active ou désactive temporairement les descriptions d'objets générées par l'IA générative pour cette caméra. Lorsque cette option est désactivée, aucune description par l'IA n'est générée pour les objets suivis sur cette caméra."
|
||||
},
|
||||
"review_descriptions": {
|
||||
"title": "Descriptions des événements par l'IA générative",
|
||||
"desc": "Active ou désactive temporairement les descriptions par l'IA générative pour cette caméra. Lorsque cette option est désactivée, aucune description par l'IA ne sera générée pour les événements de cette caméra."
|
||||
"title": "Descriptions des activités par l'IA générative",
|
||||
"desc": "Active ou désactive temporairement les descriptions par l'IA générative pour cette caméra. Lorsque cette option est désactivée, aucune description par l'IA ne sera générée pour les activités sur cette caméra."
|
||||
},
|
||||
"review": {
|
||||
"title": "Événements",
|
||||
"title": "Activités",
|
||||
"desc": "Active ou désactive temporairement les alertes et les détections pour cette caméra jusqu'au redémarrage de Frigate. Lorsque cette option est désactivée, aucun nouvel événement n'est généré. ",
|
||||
"alerts": "Alertes ",
|
||||
"detections": "Détections "
|
||||
|
||||
@ -186,9 +186,9 @@
|
||||
"face_recognition": "Reconnaissance faciale",
|
||||
"text_embedding": "Vitesse d'embedding de visage",
|
||||
"yolov9_plate_detection_speed": "Vitesse de détection de plaques d'immatriculation YOLOv9",
|
||||
"review_description": "Description de la revue",
|
||||
"review_description_speed": "Vitesse de la description de la revue",
|
||||
"review_description_events_per_second": "Description de la revue",
|
||||
"review_description": "Description de l'activité",
|
||||
"review_description_speed": "Vitesse de description des activités",
|
||||
"review_description_events_per_second": "Description de l'activité",
|
||||
"object_description": "Description de l'objet",
|
||||
"object_description_speed": "Vitesse de la description d'objet",
|
||||
"object_description_events_per_second": "Description de l'objet"
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"documentTitle": "Klassifiseringsmodeller",
|
||||
"documentTitle": "Klassifiseringsmodeller - Frigate",
|
||||
"button": {
|
||||
"deleteClassificationAttempts": "Slett klassifiseringsbilder",
|
||||
"renameCategory": "Omdøp klasse",
|
||||
@ -177,5 +177,6 @@
|
||||
"descriptionState": "Rediger klassene for denne tilstandsklassifiseringsmodellen. Endringer vil kreve at modellen trenes på nytt.",
|
||||
"descriptionObject": "Rediger objekttypen og klassifiseringstypen for denne objektklassifiseringsmodellen.",
|
||||
"stateClassesInfo": "Merk: Endring av tilstandsklasser krever at modellen trenes på nytt med de oppdaterte klassene."
|
||||
}
|
||||
},
|
||||
"none": "Ingen"
|
||||
}
|
||||
|
||||
@ -180,5 +180,6 @@
|
||||
"noNewImages": "Geen nieuwe afbeeldingen om te trainen. Classificeer eerst meer afbeeldingen in de dataset.",
|
||||
"modelNotReady": "Model is niet klaar voor training",
|
||||
"noChanges": "Geen wijzigingen in de dataset sinds de laatste training."
|
||||
}
|
||||
},
|
||||
"none": "Geen herkenning"
|
||||
}
|
||||
|
||||
@ -32,7 +32,7 @@
|
||||
"sheep": "koyun",
|
||||
"train": "tren",
|
||||
"hair_dryer": "saç kurutma makinesi",
|
||||
"babbling": "aguşlama",
|
||||
"babbling": "Agulama",
|
||||
"snicker": "kıkırdama",
|
||||
"sigh": "iç çekme",
|
||||
"bellow": "haykırma",
|
||||
@ -425,5 +425,79 @@
|
||||
"radio": "radyo",
|
||||
"field_recording": "alan kaydı",
|
||||
"scream": "çığlık",
|
||||
"jingle_bell": "küçük çan"
|
||||
"jingle_bell": "küçük çan",
|
||||
"sodeling": "Jodel (Yodeling)",
|
||||
"chird": "Cıvıltı",
|
||||
"change_ringing": "Sıralı Çan Çalma",
|
||||
"shofar": "Şofar",
|
||||
"liquid": "Sıvı",
|
||||
"splash": "Su Sıçraması",
|
||||
"slosh": "Çalkalanma",
|
||||
"squish": "Vıcıklama (Islak Ezilme)",
|
||||
"drip": "Damlama",
|
||||
"pour": "Dökülme",
|
||||
"trickle": "Şırıldama / İnce Akış",
|
||||
"gush": "Fışkırma",
|
||||
"fill": "Doldurma",
|
||||
"spray": "Püskürtme / Sprey",
|
||||
"pump": "Pompalama",
|
||||
"stir": "Karıştırma",
|
||||
"boiling": "Kaynama",
|
||||
"sonar": "Sonar Sesi",
|
||||
"arrow": "Ok Sesi",
|
||||
"whoosh": "Hışırtı (Hızlı Geçiş Sesi)",
|
||||
"thump": "Küt Sesi (Boğuk)",
|
||||
"thunk": "Tok Ses",
|
||||
"electronic_tuner": "Elektronik Akort Cihazı",
|
||||
"effects_unit": "Efekt Ünitesi",
|
||||
"chorus_effect": "Chorus (Koro) Efekti",
|
||||
"basketball_bounce": "Basketbol Topu Sektirme",
|
||||
"bang": "Gümleme / Patlama",
|
||||
"slap": "Tokat / Şaplak",
|
||||
"whack": "Sert Vuruş / Kütletme",
|
||||
"smash": "Parçalanma",
|
||||
"breaking": "Kırılma",
|
||||
"bouncing": "Sekme / Zıplama",
|
||||
"whip": "Kırbaç",
|
||||
"flap": "Kanat Çırpma / Pırpır Etme",
|
||||
"scratch": "Tırmalama / Cızırtı",
|
||||
"scrape": "Kazıma / Sürtünme",
|
||||
"rub": "Ovma / Sürtme",
|
||||
"roll": "Yuvarlanma",
|
||||
"crushing": "Ezilme (Kuru/Sert)",
|
||||
"crumpling": "Buruşturma",
|
||||
"tearing": "Yırtılma",
|
||||
"beep": "Bip Sesi",
|
||||
"ping": "Ping Sesi (Çınlama)",
|
||||
"ding": "Ding (Zil Sesi)",
|
||||
"clang": "Çangırtı (Metalik)",
|
||||
"squeal": "Ciyaklama / Acı Gıcırtı",
|
||||
"creak": "Gıcırdama (Tahta/Kapı)",
|
||||
"rustle": "Hışırtı (Kağıt/Yaprak)",
|
||||
"whir": "Vızıltı (Motor/Pervane)",
|
||||
"clatter": "Takırtı",
|
||||
"sizzle": "Cızırdayarak Kızarma",
|
||||
"clicking": "Tıklama",
|
||||
"clickety_clack": "Takır Tukur Sesi",
|
||||
"rumble": "Gürleme / Gümbürtü",
|
||||
"plop": "Lup Sesi (Suya düşme)",
|
||||
"hum": "Uğultu / Mırıldanma",
|
||||
"zing": "Vınlama",
|
||||
"boing": "Boing (Yay Sesi)",
|
||||
"crunch": "Kıtırdatma / Çıtırdatma",
|
||||
"sine_wave": "Sinüs Dalgası",
|
||||
"harmonic": "Harmonik",
|
||||
"chirp_tone": "Cıvıltı Tonu (Sinyal)",
|
||||
"pulse": "Darbe / Pulse",
|
||||
"inside": "İç Mekan",
|
||||
"outside": "Dış Mekan",
|
||||
"reverberation": "Yankılanım (Reverb)",
|
||||
"echo": "Yankı",
|
||||
"noise": "Gürültü",
|
||||
"mains_hum": "Şebeke Uğultusu (Elektrik)",
|
||||
"distortion": "Bozulma / Distorsiyon",
|
||||
"sidetone": "Yan Ton",
|
||||
"cacophony": "Kakofoni (Ses Kargaşası)",
|
||||
"throbbing": "Zonklama",
|
||||
"vibration": "Titreşim"
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"documentTitle": "Sınıflandırma Modelleri",
|
||||
"documentTitle": "Sınıflandırma Modelleri - Frigate",
|
||||
"details": {
|
||||
"scoreInfo": "Skor, modelin nesneyi tespit ettiği tüm durumlar için ortalama güven düzeyini gösterir."
|
||||
},
|
||||
@ -180,5 +180,6 @@
|
||||
"description": "En iyi sonuçlar için tavsiye edilir: Tüm durumlar (state) için örnekler seçin. Tüm durumlar için örnek seçmeden devam edebilirsiniz, ancak model, tüm durumlara ait görüntüler eklenene kadar eğitilmeyecektir. Devam ettikten sonra, eksik durumlar için görüntüleri sınıflandırmak ve ardından modeli eğitmek için Son Sınıflandırmalar (Recent Classifications) görünümünü kullanın."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"none": "Yok"
|
||||
}
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
"viewInExplore": "Keşfet'te Görüntüle"
|
||||
},
|
||||
"tips": {
|
||||
"hasMissingObjects": "Eğer Frigate'in <em>{{objects}}</em> etiketine sahip nesneleri kaydetmesini istiyorsanız yapılandırmanızı buna göre ayarlayın.",
|
||||
"hasMissingObjects": "Eğer Frigate'in <em>{{objects}}</em> etiketine sahip nesneleri kaydetmesini istiyorsanız yapılandırmanızı buna göre ayarlayın",
|
||||
"mismatch_one": "Tespit edilmiş olan bir nesne bu İncele öğesine dahil edildi. Bu nesne Alarm veya Tespit olarak derecelendirilemedi veya çoktan silindi/temizlendi.",
|
||||
"mismatch_other": "Tespit edilmiş olan {{count}} adet nesne bu İncele öğesine dahil edildi. Bu nesneler Alarm veya Tespit olarak derecelendirilemedi veya çoktan silindi/temizlendi."
|
||||
},
|
||||
@ -278,7 +278,7 @@
|
||||
"millisecondsToOffset": "Algılama anotasyonlarının kaydırılacağı milisaniye değeri. <em>Varsayılan: 0</em>",
|
||||
"tips": "Videonun oynatımı kutulardan ve yol noktalarından öndeyse değeri düşürün; geride kalıyorsa değeri artırın. Bu değer negatif olabilir.",
|
||||
"toast": {
|
||||
"success": "{{camera}} için anotasyon kaydırması yapılandırma dosyasına kaydedildi. Değişikliklerin uygulanması için Frigate’i yeniden başlatın."
|
||||
"success": "{{camera}} için anotasyon zaman kaydırması yapılandırma dosyasına kaydedildi."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@ -250,7 +250,8 @@
|
||||
"hasIllegalCharacter": "Alan adı geçersiz karakterler içeriyor.",
|
||||
"mustNotBeSameWithCamera": "Alan adı kamera adıyla aynı olmamalıdır.",
|
||||
"alreadyExists": "Bu kamera için bu ada sahip bir alan zaten mevcut.",
|
||||
"mustNotContainPeriod": "Alan adı nokta içermemelidir."
|
||||
"mustNotContainPeriod": "Alan adı nokta içermemelidir.",
|
||||
"mustHaveAtLeastOneLetter": "Bölge adı en az bir harf içermelidir."
|
||||
}
|
||||
},
|
||||
"distance": {
|
||||
@ -306,7 +307,7 @@
|
||||
"name": {
|
||||
"inputPlaceHolder": "Bir isim girin…",
|
||||
"title": "İsim",
|
||||
"tips": "Ad en az 2 karakter olmalı ve bir kamera veya başka bir bölgenin adı olmamalıdır."
|
||||
"tips": "Ad en az 2 karakter olmalı, en az bir harf içermeli ve bu kameradaki bir kamera adıyla veya başka bir bölge adıyla aynı olmamalıdır."
|
||||
},
|
||||
"inertia": {
|
||||
"title": "Eylemsizlik",
|
||||
@ -342,7 +343,7 @@
|
||||
"title": "Hız Alt Sınırı ({{unit}})"
|
||||
},
|
||||
"toast": {
|
||||
"success": "Alan ({{zoneName}}) kaydedildi. Değişiklikleri uygulamak için Frigate'i yeniden başlatın."
|
||||
"success": "Bölge ({{zoneName}}) kaydedildi."
|
||||
},
|
||||
"allObjects": "Bütün Nesneler"
|
||||
},
|
||||
@ -362,8 +363,8 @@
|
||||
},
|
||||
"toast": {
|
||||
"success": {
|
||||
"title": "{{polygonName}} kaydedildi. Değişiklikleri uygulamak için Frigate'i yeniden başlatın.",
|
||||
"noName": "Hareket Maskesi kaydedildi. Değişiklikleri uygulamak için Frigate'i yeniden başlatın."
|
||||
"title": "{{polygonName}} kaydedildi.",
|
||||
"noName": "Hareket Maskesi kaydedildi."
|
||||
}
|
||||
},
|
||||
"desc": {
|
||||
@ -391,8 +392,8 @@
|
||||
"edit": "Nesne Maskesini Düzenle",
|
||||
"toast": {
|
||||
"success": {
|
||||
"noName": "Nesne Maskesi kaydedildi. Değişiklikleri uygulamak için Frigate'i yeniden başlatın.",
|
||||
"title": "{{polygonName}} kaydedildi. Değişiklikleri uygulamak için Frigate'i yeniden başlatın."
|
||||
"noName": "Nesne Maskesi kaydedildi.",
|
||||
"title": "{{polygonName}} kaydedildi."
|
||||
}
|
||||
},
|
||||
"documentTitle": "Nesne Maskesini Düzenle - Frigate",
|
||||
@ -576,7 +577,8 @@
|
||||
"intro": "Bu kullanıcı için bir rol seçin:",
|
||||
"admin": "Yönetici",
|
||||
"viewer": "Görüntüleyici",
|
||||
"viewerDesc": "Yalnızca Canlı, İncele, Keşfet ve Dışa Aktar'a girebilir."
|
||||
"viewerDesc": "Yalnızca Canlı, İncele, Keşfet ve Dışa Aktar'a girebilir.",
|
||||
"customDesc": "Belirli kamera erişimine sahip özel rol."
|
||||
},
|
||||
"select": "Bir rol seçin"
|
||||
}
|
||||
@ -750,25 +752,28 @@
|
||||
"form": {
|
||||
"name": {
|
||||
"error": {
|
||||
"invalidCharacters": "İsim yalnızca harf, rakam, alt çizgi veya tire içerebilir.",
|
||||
"minLength": "Bu isim en az iki karakterden oluşmalıdır.",
|
||||
"invalidCharacters": "Alan yalnızca harf, rakam, alt çizgi ve tire içerebilir.",
|
||||
"minLength": "Alan en az 2 karakter uzunluğunda olmalıdır.",
|
||||
"alreadyExists": "Bu kamerada aynı isimle bir tetik zaten mevcut."
|
||||
},
|
||||
"title": "İsim",
|
||||
"placeholder": "Tetik için bir isim girin"
|
||||
"placeholder": "Bu tetikleyiciye ad verin",
|
||||
"description": "Bu tetikleyiciyi tanımlamak için benzersiz bir ad veya açıklama girin"
|
||||
},
|
||||
"enabled": {
|
||||
"description": "Bu tetiği açın veya kapatın"
|
||||
},
|
||||
"type": {
|
||||
"title": "Tetik Türü",
|
||||
"placeholder": "Tetik türünü seçin"
|
||||
"placeholder": "Tetik türünü seçin",
|
||||
"description": "Benzer izlenen nesne açıklaması algılandığında tetiklenir",
|
||||
"thumbnail": "Benzer izlenen nesne küçük resmi algılandığında tetiklenir"
|
||||
},
|
||||
"content": {
|
||||
"title": "İçerik",
|
||||
"imagePlaceholder": "Bir resim seçin",
|
||||
"imagePlaceholder": "Bir küçük resim seçin",
|
||||
"textPlaceholder": "Metin içeriği girin",
|
||||
"imageDesc": "Benzer bir resim tespit edildiğinde tetiklenilmesi için bir resim seçin.",
|
||||
"imageDesc": "Yalnızca en son 100 küçük resim görüntülenir. İstediğiniz küçük resmi bulamazsanız, lütfen Keşfet bölümündeki önceki nesneleri inceleyin ve oradaki menüden bir tetikleyici ayarlayın.",
|
||||
"textDesc": "Benzer bir takip edilen nesne açıklaması algılandığında bu eylemi tetiklemek için metin girin.",
|
||||
"error": {
|
||||
"required": "İçerik gereklidir."
|
||||
@ -779,11 +784,12 @@
|
||||
"error": {
|
||||
"min": "Tetik eşiği 0 ile 1 arasında olmalıdır",
|
||||
"max": "Tetik eşiği 0 ile 1 arasında olmalıdır"
|
||||
}
|
||||
},
|
||||
"desc": "Bu tetikleyici için benzerlik eşiğini ayarlayın. Daha yüksek bir eşik, tetiği tetiklemek için daha yakın bir eşleşme gerektiği anlamına gelir."
|
||||
},
|
||||
"actions": {
|
||||
"title": "Eylemler",
|
||||
"desc": "Varsayılan olarak Frigate bütün tetkler için MQTT'ye bir mesaj atar. İsterseniz yapılacak ek bir eylem de belirleyebilirsiniz.",
|
||||
"desc": "Varsayılan olarak, Frigate tüm tetikleyiciler için bir MQTT mesajı gönderir. Alt etiketler, tetikleyici adını nesne etiketine ekler. Nitelikler, izlenen nesne meta verilerinde ayrı olarak depolanan aranabilir meta verilerdir.",
|
||||
"error": {
|
||||
"min": "En az bir eylem seçilmelidir."
|
||||
}
|
||||
@ -804,7 +810,7 @@
|
||||
},
|
||||
"documentTitle": "Tetikler",
|
||||
"management": {
|
||||
"title": "Tetik Yönetimi",
|
||||
"title": "Tetikleyiciler",
|
||||
"desc": "{{camera}} için tetikleri yönetin. Seçtiğiniz takip edilen nesneye benzer küçük resimlerde tetiklemek için küçük resmi kullanın veya belirlediğiniz metne benzer açıklamalar çıkması durumunda tetiklemek için ise açıklama seçeneğini kullanın."
|
||||
},
|
||||
"addTrigger": "Tetik Ekle",
|
||||
@ -825,7 +831,9 @@
|
||||
},
|
||||
"actions": {
|
||||
"alert": "Alarm Olarak İşaretle",
|
||||
"notification": "Bildirim Gönder"
|
||||
"notification": "Bildirim Gönder",
|
||||
"sub_label": "Alt Etiket Ekle",
|
||||
"attribute": "Özellik Ekle"
|
||||
},
|
||||
"toast": {
|
||||
"success": {
|
||||
@ -838,6 +846,27 @@
|
||||
"updateTriggerFailed": "Tetik güncellenemedi: {{errorMessage}}",
|
||||
"deleteTriggerFailed": "Tetik silinemedi: {{errorMessage}}"
|
||||
}
|
||||
},
|
||||
"semanticSearch": {
|
||||
"title": "Anlamsal Arama devre dışı bırakıldı",
|
||||
"desc": "Tetikleyicileri kullanmak için Anlamsal Arama'nın etkinleştirilmesi gerekir."
|
||||
},
|
||||
"wizard": {
|
||||
"title": "Tetikleyici Oluştur",
|
||||
"step1": {
|
||||
"description": "Tetikleyiciniz için temel ayarları yapılandırın."
|
||||
},
|
||||
"step2": {
|
||||
"description": "Bu eylemi tetikleyecek içeriği ayarlayın."
|
||||
},
|
||||
"step3": {
|
||||
"description": "Bu tetikleyici için eşik değerini ve eylemleri yapılandırın."
|
||||
},
|
||||
"steps": {
|
||||
"nameAndType": "Ad ve Tür",
|
||||
"configureData": "Verileri Yapılandır",
|
||||
"thresholdAndActions": "Eşik ve Eylemler"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cameraWizard": {
|
||||
@ -885,7 +914,285 @@
|
||||
"onvifPort": "ONVIF Portu",
|
||||
"probeMode": "Kamerayı tara",
|
||||
"manualMode": "Manuel seçim",
|
||||
"detectionMethodDescription": "Kamera akış URL’lerini bulmak için kamerayı ONVIF ile tarayın (destekleniyorsa) veya ön tanımlı URL’leri kullanmak için kamera markasını manuel olarak seçin. Özel bir RTSP URL’si girmek için manuel yöntemi seçin ve “Diğer”i işaretleyin."
|
||||
"detectionMethodDescription": "Kamera akış URL’lerini bulmak için kamerayı ONVIF ile tarayın (destekleniyorsa) veya ön tanımlı URL’leri kullanmak için kamera markasını manuel olarak seçin. Özel bir RTSP URL’si girmek için manuel yöntemi seçin ve “Diğer”i işaretleyin.",
|
||||
"onvifPortDescription": "ONVIF'i destekleyen kameralarda bu genellikle 80 veya 8080'dir.",
|
||||
"useDigestAuth": "Digest kimlik doğrulamasını kullan",
|
||||
"errors": {
|
||||
"nameRequired": "Kamera adı gerekli",
|
||||
"nameLength": "Kamera adı 64 karakter veya daha az olmalıdır",
|
||||
"invalidCharacters": "Kamera adı geçersiz karakterler içeriyor",
|
||||
"nameExists": "Kamera adı zaten mevcut",
|
||||
"customUrlRtspRequired": "Özel URL'ler \"rtsp://\" ile başlamalıdır. RTSP olmayan kamera akışları için manuel yapılandırma gereklidir.",
|
||||
"brandOrCustomUrlRequired": "Bir kamera markası seçip host/IP adresi girin ya da özel bir URL kullanmak için ‘Diğer’ seçeneğini tercih edin"
|
||||
},
|
||||
"useDigestAuthDescription": "ONVIF için HTTP digest kimlik doğrulamasını kullanın. Bazı kameralar, standart yönetici kullanıcısı yerine özel bir ONVIF kullanıcı adı/şifresi gerektirebilir."
|
||||
},
|
||||
"step2": {
|
||||
"description": "Mevcut akışları bulmak için kamerayı tarayın veya seçtiğiniz algılama yöntemine göre manuel ayarları yapılandırın.",
|
||||
"testSuccess": "Bağlantı testi başarılı!",
|
||||
"testFailed": "Bağlantı testi başarısız oldu. Lütfen tüm alanları kontrol edip tekrar deneyin.",
|
||||
"testFailedTitle": "Test Başarısız",
|
||||
"streamDetails": "Akış Ayrıntıları",
|
||||
"probing": "Kamera taranıyor...",
|
||||
"retry": "Yeniden dene",
|
||||
"testing": {
|
||||
"probingMetadata": "Kamera meta verileri inceleniyor...",
|
||||
"fetchingSnapshot": "Kamera anlık görüntüsü alınıyor..."
|
||||
},
|
||||
"probeFailed": "Kamerayı tarama başarısız oldu: {{error}}",
|
||||
"probingDevice": "Cihaz taranıyor…",
|
||||
"probeSuccessful": "Tarama başarılı",
|
||||
"probeError": "Tarama hatası",
|
||||
"probeNoSuccess": "Tarama başarısız",
|
||||
"deviceInfo": "Cihaz Bilgileri",
|
||||
"manufacturer": "Üretici",
|
||||
"model": "Modeli",
|
||||
"firmware": "Donanım yazılımı",
|
||||
"profiles": "Profiller",
|
||||
"ptzSupport": "PTZ Desteği",
|
||||
"autotrackingSupport": "Otomatik Takip Desteği",
|
||||
"presets": "Ön ayarlar",
|
||||
"rtspCandidates": "RTSP Yayınları",
|
||||
"rtspCandidatesDescription": "Kamera taramasından aşağıdaki RTSP URL'leri bulundu. Akış meta verilerini görüntülemek için bağlantıyı test edin.",
|
||||
"noRtspCandidates": "Kameradan RTSP URL'si bulunamadı. Kimlik bilgileriniz yanlış olabilir veya kamera ONVIF'i veya RTSP URL'lerini almak için kullanılan yöntemi desteklemiyor olabilir. Geri dönün ve RTSP URL'sini manuel olarak girin.",
|
||||
"candidateStreamTitle": "Yayın {{number}}",
|
||||
"useCandidate": "Kullan",
|
||||
"uriCopy": "Kopyala",
|
||||
"uriCopied": "URI panoya kopyalandı",
|
||||
"testConnection": "Bağlantıyı Test Et",
|
||||
"toggleUriView": "Tam URI görünümünü değiştirmek için tıklayın",
|
||||
"connected": "Bağlandı",
|
||||
"notConnected": "Bağlı Değil",
|
||||
"errors": {
|
||||
"hostRequired": "Host/IP adresi gereklidir"
|
||||
}
|
||||
},
|
||||
"step3": {
|
||||
"description": "Akış rollerini yapılandırın ve kameranız için ek akışlar ekleyin.",
|
||||
"streamsTitle": "Kamera Yayınları",
|
||||
"addStream": "Yayın Ekle",
|
||||
"addAnotherStream": "Başka Bir Yayın Ekle",
|
||||
"streamTitle": "Yayın {{number}}",
|
||||
"streamUrl": "Yayın URL'si",
|
||||
"streamUrlPlaceholder": "rtsp://kullanıcıadı:şifre@host:port/path",
|
||||
"selectStream": "Bir yayın seçin",
|
||||
"searchCandidates": "Yayınları arayın...",
|
||||
"noStreamFound": "Yayın bulunamadı",
|
||||
"url": "URL",
|
||||
"resolution": "Çözünürlük",
|
||||
"selectResolution": "Çözünürlüğü seçin",
|
||||
"quality": "Kalite",
|
||||
"selectQuality": "Kaliteyi seçin",
|
||||
"roles": "Roller",
|
||||
"roleLabels": {
|
||||
"detect": "Nesne Algılama",
|
||||
"record": "Kayıt",
|
||||
"audio": "Ses"
|
||||
},
|
||||
"testStream": "Bağlantıyı Test Et",
|
||||
"testSuccess": "Yayın testi başarılı!",
|
||||
"testFailed": "Yayın testi başarısız oldu",
|
||||
"testFailedTitle": "Test Başarısız",
|
||||
"connected": "Bağlı",
|
||||
"notConnected": "Bağlı Değil",
|
||||
"featuresTitle": "Özellikler",
|
||||
"go2rtc": "Kameraya olan bağlantıları azaltın",
|
||||
"detectRoleWarning": "Devam edebilmek için en az bir akışın \"algılama\" rolüne sahip olması gerekir.",
|
||||
"rolesPopover": {
|
||||
"title": "Yayın Rolleri",
|
||||
"detect": "Nesne tespiti için ana besleme.",
|
||||
"record": "Yapılandırma ayarlarına göre video akışının bölümlerini kaydeder.",
|
||||
"audio": "Ses tabanlı algılama için besleme."
|
||||
},
|
||||
"featuresPopover": {
|
||||
"title": "Yayın Özellikleri",
|
||||
"description": "Kameranıza olan bağlantıları azaltmak için go2rtc yeniden akışını kullanın."
|
||||
}
|
||||
},
|
||||
"step4": {
|
||||
"disconnectStream": "Bağlantıyı kes",
|
||||
"estimatedBandwidth": "Tahmini Bant Genişliği",
|
||||
"roles": "Roller",
|
||||
"ffmpegModule": "Yayın uyumluluk modunu kullan",
|
||||
"ffmpegModuleDescription": "Yayın birkaç denemeden sonra yüklenmezse, bunu etkinleştirmeyi deneyin. Etkinleştirildiğinde, Frigate go2rtc ile ffmpeg modülünü kullanacaktır. Bu, bazı kamera yayınları ile daha iyi uyumluluk sağlayabilir.",
|
||||
"none": "Hiçbiri",
|
||||
"error": "Hata",
|
||||
"description": "Yeni kameranızı kaydetmeden önce son doğrulama ve analiz. Kaydetmeden önce her akışı bağlayın.",
|
||||
"validationTitle": "Yayın Doğrulaması",
|
||||
"connectAllStreams": "Tüm Yayınları Bağla",
|
||||
"reconnectionSuccess": "Yeniden bağlantı başarılı.",
|
||||
"reconnectionPartial": "Bazı yayınlara yeniden bağlanılamadı.",
|
||||
"streamUnavailable": "Yayın önizlemesi kullanılamıyor",
|
||||
"reload": "Yeniden yükle",
|
||||
"connecting": "Bağlanıyor...",
|
||||
"streamTitle": "Yayın {{number}}",
|
||||
"valid": "Geçerli",
|
||||
"failed": "Başarısız",
|
||||
"notTested": "Test edilmedi",
|
||||
"connectStream": "Bağlan",
|
||||
"connectingStream": "Bağlanıyor",
|
||||
"streamValidated": "{{number}} yayını başarıyla doğrulandı",
|
||||
"streamValidationFailed": "Yayın {{number}} doğrulaması başarısız oldu",
|
||||
"saveAndApply": "Yeni Kamerayı Kaydet",
|
||||
"saveError": "Geçersiz yapılandırma. Lütfen ayarlarınızı kontrol edin.",
|
||||
"issues": {
|
||||
"title": "Yayın Doğrulaması",
|
||||
"videoCodecGood": "Video kodeği {{codec}}.",
|
||||
"audioCodecGood": "Ses kodeği {{codec}}.",
|
||||
"resolutionHigh": "{{resolution}} çözünürlüğü kaynak kullanımının artmasına neden olabilir.",
|
||||
"resolutionLow": "{{resolution}} çözünürlüğü, küçük nesnelerin güvenilir bir şekilde algılanması için çok düşük olabilir.",
|
||||
"noAudioWarning": "Bu yayın için ses algılanmadı, kayıtlarda ses bulunmayacak.",
|
||||
"audioCodecRecordError": "Kayıtlarda sesi desteklemek için AAC ses kodeği gereklidir.",
|
||||
"audioCodecRequired": "Ses algılamayı desteklemek için bir ses akışı gereklidir.",
|
||||
"restreamingWarning": "Kayıt akışı için kameraya olan bağlantıları azaltmak CPU kullanımını bir miktar artırabilir.",
|
||||
"brands": {
|
||||
"reolink-rtsp": "Reolink RTSP önerilmez. Kameranın donanım yazılımı ayarlarında HTTP'yi etkinleştirin ve sihirbazı yeniden başlatın."
|
||||
},
|
||||
"dahua": {
|
||||
"substreamWarning": "Alt akış 1 düşük çözünürlüğe kilitlenmiştir. Birçok Dahua / Amcrest / EmpireTech kamera, kamera ayarlarında etkinleştirilmesi gereken ek alt akışları destekler. Mevcutsa, bu akışları kontrol edip kullanmanız önerilir."
|
||||
},
|
||||
"hikvision": {
|
||||
"substreamWarning": "Alt akış 1 düşük çözünürlüğe kilitlendi. Birçok Hikvision kamera, kamera ayarlarında etkinleştirilmesi gereken ek alt akışları destekler. Mevcutsa, bu akışları kontrol edip kullanmanız önerilir."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cameraManagement": {
|
||||
"title": "Kameraları Yönet",
|
||||
"addCamera": "Yeni Kamera Ekle",
|
||||
"editCamera": "Kamerayı Düzenle:",
|
||||
"selectCamera": "Bir Kamera Seçin",
|
||||
"backToSettings": "Kamera Ayarlarına Dön",
|
||||
"streams": {
|
||||
"title": "Kameraları Etkinleştir / Devre Dışı Bırak",
|
||||
"desc": "Frigate yeniden başlatılana kadar bir kamerayı geçici olarak devre dışı bırakın. Bir kamerayı devre dışı bırakmak, Frigate'in bu kameranın akışlarını işlemesini tamamen durdurur. Algılama, kayıt ve hata ayıklama kullanılamaz.<br /> <em>Not: Bu, go2rtc yeniden akışlarını devre dışı bırakmaz.</em>"
|
||||
},
|
||||
"cameraConfig": {
|
||||
"add": "Kamera Ekle",
|
||||
"edit": "Kamerayı Düzenle",
|
||||
"description": "Yayınlar ve roller dahil olmak üzere kamera ayarlarını yapılandırın.",
|
||||
"name": "Kamera Adı",
|
||||
"nameRequired": "Kamera adı gerekli",
|
||||
"nameLength": "Kamera adı 64 karakterden az olmalıdır.",
|
||||
"namePlaceholder": "örneğin, ön_kapı veya Arka Bahçe Genel Bakışı",
|
||||
"enabled": "Etkinleştirilmiş",
|
||||
"ffmpeg": {
|
||||
"inputs": "Giriş Yayınları",
|
||||
"path": "Yayın Yolu",
|
||||
"pathRequired": "Yayın yolu gereklidir",
|
||||
"pathPlaceholder": "rtsp://...",
|
||||
"roles": "Roller",
|
||||
"rolesRequired": "En az bir rol gereklidir",
|
||||
"rolesUnique": "Her rol (ses, algılama, kayıt) yalnızca bir akışa atanabilir",
|
||||
"addInput": "Giriş Yayını Ekle",
|
||||
"removeInput": "Giriş Yayınını Kaldır",
|
||||
"inputsRequired": "En az bir giriş yayını gereklidir"
|
||||
},
|
||||
"go2rtcStreams": "go2rtc Yayınları",
|
||||
"streamUrls": "Yayın URL'leri",
|
||||
"addUrl": "URL ekle",
|
||||
"addGo2rtcStream": "go2rtc Yayını Ekle",
|
||||
"toast": {
|
||||
"success": "Kamera {{cameraName}} başarıyla kaydedildi"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cameraReview": {
|
||||
"title": "Kamera İnceleme Ayarları",
|
||||
"object_descriptions": {
|
||||
"title": "Üretken Yapay Zeka Nesne Açıklamaları",
|
||||
"desc": "Bu kamera için Yapay Zeka Nesne Tanımlamalarını geçici olarak etkinleştirin/devre dışı bırakın. Devre dışı bırakıldığında, bu kameradaki izlenen nesneler için Yapay Zeka tarafından oluşturulan tanımlar istenmeyecektir."
|
||||
},
|
||||
"review_descriptions": {
|
||||
"title": "Üretken Yapay Zeka İnceleme Açıklamaları",
|
||||
"desc": "Bu kamera için Yapay Zeka Üretici İnceleme açıklamalarını geçici olarak etkinleştirin/devre dışı bırakın. Devre dışı bırakıldığında, bu kameradaki inceleme öğeleri için Yapay Zeka tarafından oluşturulan açıklamalar istenmeyecektir."
|
||||
},
|
||||
"review": {
|
||||
"title": "İncele",
|
||||
"desc": "Frigate yeniden başlatılana kadar bu kamera için uyarıları ve algılamaları geçici olarak etkinleştirin/devre dışı bırakın. Devre dışı bırakıldığında, yeni inceleme öğeleri oluşturulmaz. ",
|
||||
"alerts": "Uyarılar ",
|
||||
"detections": "Tespitler "
|
||||
},
|
||||
"reviewClassification": {
|
||||
"title": "Sınıflandırmayı İncele",
|
||||
"desc": "Frigate, inceleme öğelerini Uyarılar ve Algılamalar olarak kategorilere ayırır. Varsayılan olarak, tüm <em>kişi</em> ve <em>araba</em> nesneleri Uyarı olarak kabul edilir. İnceleme öğelerinizin kategorilendirmesini, bunlar için gerekli bölgeleri yapılandırarak iyileştirebilirsiniz.",
|
||||
"noDefinedZones": "Bu kamera için herhangi bir bölge tanımlanmamıştır.",
|
||||
"objectAlertsTips": "{{cameraName}} üzerindeki tüm {{alertsLabels}} nesneleri Uyarılar olarak gösterilecektir.",
|
||||
"zoneObjectAlertsTips": "{{cameraName}} üzerinde, {{zone}} bölgesinde tespit edilen tüm {{alertsLabels}} nesneleri Uyarılar olarak gösterilecektir.",
|
||||
"objectDetectionsTips": "{{cameraName}} üzerinde kategorize edilmemiş tüm {{detectionsLabels}} nesneleri, hangi bölgede olursa olsun Tespitler olarak gösterilecektir.",
|
||||
"zoneObjectDetectionsTips": {
|
||||
"text": "{{cameraName}} üzerindeki {{zone}} bölgesinde kategorize edilmemiş tüm {{detectionsLabels}} nesneleri, Tespitler olarak gösterilecektir.",
|
||||
"notSelectDetections": "{{cameraName}} üzerinde {{zone}} bölgesinde tespit edilen ve Uyarı olarak kategorize edilmemiş tüm {{detectionsLabels}} nesneleri, hangi bölgede olurlarsa olsunlar Tespitler olarak gösterilecektir.",
|
||||
"regardlessOfZoneObjectDetectionsTips": "{{cameraName}} üzerinde kategorize edilmemiş tüm {{detectionsLabels}} nesneleri, bulundukları bölgeden bağımsız olarak Tespitler (Detections) olarak gösterilecektir."
|
||||
},
|
||||
"unsavedChanges": "{{camera}} için Kaydedilmemiş İnceleme Sınıflandırması ayarları",
|
||||
"selectAlertsZones": "Uyarılar için bölgeleri seçin",
|
||||
"selectDetectionsZones": "Tespitler için bölgeleri seçin",
|
||||
"limitDetections": "Tespitleri belirli bölgelerle sınırlayın",
|
||||
"toast": {
|
||||
"success": "Sınıflandırma yapılandırması kaydedildi. Değişiklikleri uygulamak için Frigate'i yeniden başlatın."
|
||||
}
|
||||
}
|
||||
},
|
||||
"roles": {
|
||||
"management": {
|
||||
"title": "İzleyici Rol Yönetimi",
|
||||
"desc": "Bu Frigate örneği için özel görüntüleyici rollerini ve kamera erişim izinlerini yönetin."
|
||||
},
|
||||
"addRole": "Rol Ekle",
|
||||
"table": {
|
||||
"role": "Rol",
|
||||
"cameras": "Kameralar",
|
||||
"actions": "Eylemler",
|
||||
"noRoles": "Özel rol bulunamadı.",
|
||||
"editCameras": "Kameraları Düzenle",
|
||||
"deleteRole": "Rolü Sil"
|
||||
},
|
||||
"toast": {
|
||||
"success": {
|
||||
"createRole": "{{role}} rolü başarıyla oluşturuldu",
|
||||
"updateCameras": "{{role}} rolü için kameralar güncellendi",
|
||||
"deleteRole": "{{role}} rolü başarıyla silindi",
|
||||
"userRolesUpdated_one": "Bu role atanan {{count}} kullanıcı, tüm kameralara erişimi olan 'görüntüleyici' olarak güncellendi.",
|
||||
"userRolesUpdated_other": "Bu role atanan {{count}} kullanıcı, tüm kameralara erişimi olan 'görüntüleyici' olarak güncellendi."
|
||||
},
|
||||
"error": {
|
||||
"createRoleFailed": "Rol oluşturulamadı: {{errorMessage}}",
|
||||
"updateCamerasFailed": "Kameralar güncellenemedi: {{errorMessage}}",
|
||||
"deleteRoleFailed": "Rol silinemedi: {{errorMessage}}",
|
||||
"userUpdateFailed": "Kullanıcı rolleri güncellenemedi: {{errorMessage}}"
|
||||
}
|
||||
},
|
||||
"dialog": {
|
||||
"createRole": {
|
||||
"title": "Yeni Rol Oluştur",
|
||||
"desc": "Yeni bir rol ekleyin ve kamera erişim izinlerini belirtin."
|
||||
},
|
||||
"editCameras": {
|
||||
"title": "Rol Kameralarını Düzenle",
|
||||
"desc": "<strong>{{role}}</strong> rolü için kamera erişimini güncelleyin."
|
||||
},
|
||||
"deleteRole": {
|
||||
"title": "Rolü Sil",
|
||||
"desc": "Bu işlem geri alınamaz. Bu işlem, rolü kalıcı olarak silecek ve bu role sahip tüm kullanıcıları 'izleyici' rolüne atayacaktır. Bu rol, izleyiciye tüm kameralara erişim sağlayacaktır.",
|
||||
"warn": "<strong>{{role}}</strong> rolünü silmek istediğinizden emin misiniz?",
|
||||
"deleting": "Siliniyor..."
|
||||
},
|
||||
"form": {
|
||||
"role": {
|
||||
"title": "Rol Adı",
|
||||
"placeholder": "Rol adını girin",
|
||||
"desc": "Sadece harf, rakam, nokta ve alt çizgi kullanılabilir.",
|
||||
"roleIsRequired": "Rol adı gereklidir",
|
||||
"roleOnlyInclude": "Rol adı yalnızca harf, sayı veya _ içerebilir",
|
||||
"roleExists": "Bu isimde bir rol zaten mevcut."
|
||||
},
|
||||
"cameras": {
|
||||
"title": "Kameralar",
|
||||
"desc": "Bu rolün erişebileceği kameraları seçin. En az bir kamera gereklidir.",
|
||||
"required": "En az bir kamera seçilmelidir."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"documentTitle": "Моделі класифікації",
|
||||
"documentTitle": "Моделі класифікації - Frigate",
|
||||
"button": {
|
||||
"deleteClassificationAttempts": "Видалити зображення класифікації",
|
||||
"renameCategory": "Перейменувати клас",
|
||||
@ -185,5 +185,6 @@
|
||||
"noNewImages": "Немає нових зображень для навчання. Спочатку класифікуйте більше зображень у наборі даних.",
|
||||
"modelNotReady": "Модель не готова до навчання",
|
||||
"noChanges": "З моменту останнього навчання в наборі даних не було змін."
|
||||
}
|
||||
},
|
||||
"none": "Жоден"
|
||||
}
|
||||
|
||||
@ -283,7 +283,7 @@
|
||||
"millisecondsToOffset": "Мілісекунди для зміщення виявлених анотацій. <em>За замовчуванням: 0</em>",
|
||||
"tips": "Зменште значення, якщо відтворення відео відбувається попереду блоків та точок шляху, і збільште значення, якщо відтворення відео відбувається позаду них. Це значення може бути від’ємним.",
|
||||
"toast": {
|
||||
"success": "Зміщення анотації для {{camera}} збережено у файлі конфігурації. Перезапустіть Frigate, щоб застосувати зміни."
|
||||
"success": "Зміщення анотації для {{camera}} було збережено у файлі конфігурації."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@ -142,8 +142,8 @@
|
||||
"edit": "Редагувати маску руху",
|
||||
"toast": {
|
||||
"success": {
|
||||
"title": "{{polygonName}} збережено. Перезапустіть Frigate, щоб застосувати зміни.",
|
||||
"noName": "Маску руху збережено. Перезапустіть Frigate, щоб застосувати зміни."
|
||||
"title": "{{polygonName}} збережено.",
|
||||
"noName": "Маску руху збережено."
|
||||
}
|
||||
},
|
||||
"label": "Маска руху",
|
||||
@ -207,7 +207,7 @@
|
||||
"desc": "Список об'єктів, що належать до цієї зони."
|
||||
},
|
||||
"toast": {
|
||||
"success": "Зону ({{zoneName}}) збережено. Перезапустіть Frigate, щоб застосувати зміни."
|
||||
"success": "Зону ({{zoneName}}) збережено."
|
||||
}
|
||||
},
|
||||
"objectMasks": {
|
||||
@ -230,8 +230,8 @@
|
||||
},
|
||||
"toast": {
|
||||
"success": {
|
||||
"title": "{{polygonName}} збережено. Перезапустіть Frigate, щоб застосувати зміни.",
|
||||
"noName": "Маску об'єкта збережено. Перезапустіть Frigate, щоб застосувати зміни."
|
||||
"title": "{{polygonName}} збережено.",
|
||||
"noName": "Маску об'єкта збережено."
|
||||
}
|
||||
},
|
||||
"label": "Маски об'єктів"
|
||||
@ -558,7 +558,7 @@
|
||||
"classification": "Налаштування класифікації – Фрегат",
|
||||
"masksAndZones": "Редактор масок та зон – Фрегат",
|
||||
"motionTuner": "Тюнер руху - Фрегат",
|
||||
"general": "Налаштування інтерфейсу користувача - Frigate",
|
||||
"general": "Основна Статус – Frigate",
|
||||
"frigatePlus": "Налаштування Frigate+ – Frigate",
|
||||
"enrichments": "Налаштуваннях збагачення – Frigate",
|
||||
"cameraManagement": "Керування камерами - Frigate",
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"documentTitle": "分类模型",
|
||||
"documentTitle": "分类模型 - Frigate",
|
||||
"button": {
|
||||
"deleteClassificationAttempts": "删除分类图片",
|
||||
"renameCategory": "重命名类别",
|
||||
@ -175,5 +175,6 @@
|
||||
"noNewImages": "没有新的图片可用于训练。请先对数据集中的更多图片进行分类。",
|
||||
"noChanges": "自上次训练以来,数据集未作任何更改。",
|
||||
"modelNotReady": "模型尚未准备好进行训练"
|
||||
}
|
||||
},
|
||||
"none": "无标签"
|
||||
}
|
||||
|
||||
@ -279,7 +279,7 @@
|
||||
"millisecondsToOffset": "用于偏移检测标记的毫秒数。<em> 默认值:0</em>",
|
||||
"tips": "提示:假设有一段人从左向右走的事件录制,如果事件时间轴中的边框始终在人的左侧(即后方),则应该减小偏移值;反之,如果边框始终领先于人物,则应增大偏移值。",
|
||||
"toast": {
|
||||
"success": "{{camera}} 的标记偏移量已保存。请重启 Frigate 以应用更改。"
|
||||
"success": "{{camera}} 的标记偏移量已保存。"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@ -344,7 +344,7 @@
|
||||
}
|
||||
},
|
||||
"toast": {
|
||||
"success": "区域 ({{zoneName}}) 已保存。请重启 Frigate 以应用更改。"
|
||||
"success": "区域 ({{zoneName}}) 已保存。"
|
||||
}
|
||||
},
|
||||
"motionMasks": {
|
||||
@ -369,8 +369,8 @@
|
||||
},
|
||||
"toast": {
|
||||
"success": {
|
||||
"title": "{{polygonName}} 已保存。请重启 Frigate 以应用更改。",
|
||||
"noName": "画面变动遮罩已保存。请重启 Frigate 以应用更改。"
|
||||
"title": "{{polygonName}} 已保存。",
|
||||
"noName": "画面变动遮罩已保存。"
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -393,8 +393,8 @@
|
||||
},
|
||||
"toast": {
|
||||
"success": {
|
||||
"title": "{{polygonName}} 已保存。请重启 Frigate 以应用更改。",
|
||||
"noName": "目标遮罩已保存。请重启 Frigate 以应用更改。"
|
||||
"title": "{{polygonName}} 已保存。",
|
||||
"noName": "目标遮罩已保存。"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@ -42,19 +42,27 @@ export default function AccountSettings({ className }: AccountSettingsProps) {
|
||||
const logoutUrl = config?.proxy?.logout_url || `${baseUrl}api/logout`;
|
||||
|
||||
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
|
||||
const [passwordError, setPasswordError] = useState<string | null>(null);
|
||||
const [isPasswordLoading, setIsPasswordLoading] = useState(false);
|
||||
|
||||
const Container = isDesktop ? DropdownMenu : Drawer;
|
||||
const Trigger = isDesktop ? DropdownMenuTrigger : DrawerTrigger;
|
||||
const Content = isDesktop ? DropdownMenuContent : DrawerContent;
|
||||
const MenuItem = isDesktop ? DropdownMenuItem : DrawerClose;
|
||||
|
||||
const handlePasswordSave = async (password: string) => {
|
||||
const handlePasswordSave = async (password: string, oldPassword?: string) => {
|
||||
if (!profile?.username || profile.username === "anonymous") return;
|
||||
setIsPasswordLoading(true);
|
||||
axios
|
||||
.put(`users/${profile.username}/password`, { password })
|
||||
.put(`users/${profile.username}/password`, {
|
||||
password,
|
||||
old_password: oldPassword,
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.status === 200) {
|
||||
setPasswordDialogOpen(false);
|
||||
setPasswordError(null);
|
||||
setIsPasswordLoading(false);
|
||||
toast.success(t("users.toast.success.updatePassword"), {
|
||||
position: "top-center",
|
||||
});
|
||||
@ -65,14 +73,10 @@ export default function AccountSettings({ className }: AccountSettingsProps) {
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(
|
||||
t("users.toast.error.setPasswordFailed", {
|
||||
errorMessage,
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
|
||||
// Keep dialog open and show error
|
||||
setPasswordError(errorMessage);
|
||||
setIsPasswordLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
@ -154,8 +158,13 @@ export default function AccountSettings({ className }: AccountSettingsProps) {
|
||||
<SetPasswordDialog
|
||||
show={passwordDialogOpen}
|
||||
onSave={handlePasswordSave}
|
||||
onCancel={() => setPasswordDialogOpen(false)}
|
||||
onCancel={() => {
|
||||
setPasswordDialogOpen(false);
|
||||
setPasswordError(null);
|
||||
}}
|
||||
initialError={passwordError}
|
||||
username={profile?.username}
|
||||
isLoading={isPasswordLoading}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
|
||||
@ -116,13 +116,22 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
||||
const SubItemContent = isDesktop ? DropdownMenuSubContent : DialogContent;
|
||||
const Portal = isDesktop ? DropdownMenuPortal : DialogPortal;
|
||||
|
||||
const handlePasswordSave = async (password: string) => {
|
||||
const [passwordError, setPasswordError] = useState<string | null>(null);
|
||||
const [isPasswordLoading, setIsPasswordLoading] = useState(false);
|
||||
|
||||
const handlePasswordSave = async (password: string, oldPassword?: string) => {
|
||||
if (!profile?.username || profile.username === "anonymous") return;
|
||||
setIsPasswordLoading(true);
|
||||
axios
|
||||
.put(`users/${profile.username}/password`, { password })
|
||||
.put(`users/${profile.username}/password`, {
|
||||
password,
|
||||
old_password: oldPassword,
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.status === 200) {
|
||||
setPasswordDialogOpen(false);
|
||||
setPasswordError(null);
|
||||
setIsPasswordLoading(false);
|
||||
toast.success(
|
||||
t("users.toast.success.updatePassword", {
|
||||
ns: "views/settings",
|
||||
@ -138,15 +147,10 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(
|
||||
t("users.toast.error.setPasswordFailed", {
|
||||
ns: "views/settings",
|
||||
errorMessage,
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
|
||||
// Keep dialog open and show error
|
||||
setPasswordError(errorMessage);
|
||||
setIsPasswordLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
@ -554,8 +558,13 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
||||
<SetPasswordDialog
|
||||
show={passwordDialogOpen}
|
||||
onSave={handlePasswordSave}
|
||||
onCancel={() => setPasswordDialogOpen(false)}
|
||||
onCancel={() => {
|
||||
setPasswordDialogOpen(false);
|
||||
setPasswordError(null);
|
||||
}}
|
||||
initialError={passwordError}
|
||||
username={profile?.username}
|
||||
isLoading={isPasswordLoading}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "../ui/button";
|
||||
import { Input } from "../ui/input";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@ -11,71 +9,187 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../ui/dialog";
|
||||
|
||||
import { Label } from "../ui/label";
|
||||
import { LuCheck, LuX } from "react-icons/lu";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "../ui/form";
|
||||
import { LuCheck, LuX, LuEye, LuEyeOff, LuExternalLink } from "react-icons/lu";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||
import useSWR from "swr";
|
||||
import { formatSecondsToDuration } from "@/utils/dateUtil";
|
||||
import ActivityIndicator from "../indicators/activity-indicator";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
type SetPasswordProps = {
|
||||
show: boolean;
|
||||
onSave: (password: string) => void;
|
||||
onSave: (password: string, oldPassword?: string) => void;
|
||||
onCancel: () => void;
|
||||
initialError?: string | null;
|
||||
username?: string;
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
export default function SetPasswordDialog({
|
||||
show,
|
||||
onSave,
|
||||
onCancel,
|
||||
initialError,
|
||||
username,
|
||||
isLoading = false,
|
||||
}: SetPasswordProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const [password, setPassword] = useState<string>("");
|
||||
const [confirmPassword, setConfirmPassword] = useState<string>("");
|
||||
const [passwordStrength, setPasswordStrength] = useState<number>(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { t } = useTranslation(["views/settings", "common"]);
|
||||
const { getLocaleDocUrl } = useDocDomain();
|
||||
|
||||
// Reset state when dialog opens/closes
|
||||
useEffect(() => {
|
||||
if (show) {
|
||||
setPassword("");
|
||||
setConfirmPassword("");
|
||||
setError(null);
|
||||
}
|
||||
}, [show]);
|
||||
const { data: config } = useSWR("config");
|
||||
const refreshSeconds: number | undefined =
|
||||
config?.auth?.refresh_time ?? undefined;
|
||||
const refreshTimeLabel = refreshSeconds
|
||||
? formatSecondsToDuration(refreshSeconds)
|
||||
: "30 minutes";
|
||||
|
||||
// Simple password strength calculation
|
||||
useEffect(() => {
|
||||
if (!password) {
|
||||
setPasswordStrength(0);
|
||||
return;
|
||||
// visibility toggles for password fields
|
||||
const [showOldPassword, setShowOldPassword] = useState<boolean>(false);
|
||||
const [showPasswordVisible, setShowPasswordVisible] =
|
||||
useState<boolean>(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] =
|
||||
useState<boolean>(false);
|
||||
|
||||
// Create form schema with conditional old password requirement
|
||||
const formSchema = useMemo(() => {
|
||||
const baseSchema = {
|
||||
password: z
|
||||
.string()
|
||||
.min(8, t("users.dialog.form.password.requirements.length"))
|
||||
.regex(/[A-Z]/, t("users.dialog.form.password.requirements.uppercase"))
|
||||
.regex(/\d/, t("users.dialog.form.password.requirements.digit"))
|
||||
.regex(
|
||||
/[!@#$%^&*(),.?":{}|<>]/,
|
||||
t("users.dialog.form.password.requirements.special"),
|
||||
),
|
||||
confirmPassword: z.string(),
|
||||
};
|
||||
|
||||
if (username) {
|
||||
return z
|
||||
.object({
|
||||
oldPassword: z
|
||||
.string()
|
||||
.min(1, t("users.dialog.passwordSetting.currentPasswordRequired")),
|
||||
...baseSchema,
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: t("users.dialog.passwordSetting.doNotMatch"),
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
} else {
|
||||
return z
|
||||
.object(baseSchema)
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: t("users.dialog.passwordSetting.doNotMatch"),
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
}
|
||||
}, [username, t]);
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
const defaultValues = username
|
||||
? {
|
||||
oldPassword: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
}
|
||||
: {
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
};
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
mode: "onChange",
|
||||
defaultValues: defaultValues as FormValues,
|
||||
});
|
||||
|
||||
const password = form.watch("password");
|
||||
const confirmPassword = form.watch("confirmPassword");
|
||||
|
||||
// Password strength calculation
|
||||
const passwordStrength = useMemo(() => {
|
||||
if (!password) return 0;
|
||||
|
||||
let strength = 0;
|
||||
// Length check
|
||||
if (password.length >= 8) strength += 1;
|
||||
// Contains number
|
||||
if (/\d/.test(password)) strength += 1;
|
||||
// Contains special char
|
||||
if (/[!@#$%^&*(),.?":{}|<>]/.test(password)) strength += 1;
|
||||
// Contains uppercase
|
||||
if (/[A-Z]/.test(password)) strength += 1;
|
||||
|
||||
setPasswordStrength(strength);
|
||||
return strength;
|
||||
}, [password]);
|
||||
|
||||
const handleSave = () => {
|
||||
if (!password) {
|
||||
setError(t("users.dialog.passwordSetting.cannotBeEmpty"));
|
||||
return;
|
||||
}
|
||||
const requirements = useMemo(
|
||||
() => ({
|
||||
length: password?.length >= 8,
|
||||
uppercase: /[A-Z]/.test(password || ""),
|
||||
digit: /\d/.test(password || ""),
|
||||
special: /[!@#$%^&*(),.?":{}|<>]/.test(password || ""),
|
||||
}),
|
||||
[password],
|
||||
);
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError(t("users.dialog.passwordSetting.doNotMatch"));
|
||||
return;
|
||||
// Reset form and visibility toggles when dialog opens/closes
|
||||
useEffect(() => {
|
||||
if (show) {
|
||||
form.reset();
|
||||
setShowOldPassword(false);
|
||||
setShowPasswordVisible(false);
|
||||
setShowConfirmPassword(false);
|
||||
}
|
||||
}, [show, form]);
|
||||
|
||||
onSave(password);
|
||||
// Handle backend errors
|
||||
useEffect(() => {
|
||||
if (show && initialError) {
|
||||
const errorMsg = String(initialError);
|
||||
// Check if the error is about incorrect current password
|
||||
if (
|
||||
errorMsg.toLowerCase().includes("current password is incorrect") ||
|
||||
errorMsg.toLowerCase().includes("current password incorrect")
|
||||
) {
|
||||
if (username) {
|
||||
form.setError("oldPassword" as keyof FormValues, {
|
||||
type: "manual",
|
||||
message: t("users.dialog.passwordSetting.incorrectCurrentPassword"),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// For other errors, show as form-level error
|
||||
form.setError("root", {
|
||||
type: "manual",
|
||||
message: errorMsg,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [show, initialError, form, t, username]);
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
const oldPassword =
|
||||
"oldPassword" in values
|
||||
? (
|
||||
values as {
|
||||
oldPassword: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
).oldPassword
|
||||
: undefined;
|
||||
onSave(values.password, oldPassword);
|
||||
};
|
||||
|
||||
const getStrengthLabel = () => {
|
||||
@ -112,113 +226,333 @@ export default function SetPasswordDialog({
|
||||
<DialogDescription>
|
||||
{t("users.dialog.passwordSetting.desc")}
|
||||
</DialogDescription>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("users.dialog.passwordSetting.multiDeviceWarning", {
|
||||
refresh_time: refreshTimeLabel,
|
||||
ns: "views/settings",
|
||||
})}
|
||||
</p>
|
||||
<p className="text-sm text-primary-variant">
|
||||
<a
|
||||
href={getLocaleDocUrl(
|
||||
"configuration/authentication#jwt-token-secret",
|
||||
)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center text-primary"
|
||||
>
|
||||
{t("readTheDocumentation", { ns: "common" })}
|
||||
<LuExternalLink className="ml-2 size-3" />
|
||||
</a>
|
||||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 pt-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">
|
||||
{t("users.dialog.form.newPassword.title")}
|
||||
</Label>
|
||||
<Input
|
||||
id="password"
|
||||
className="h-10"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => {
|
||||
setPassword(event.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
placeholder={t("users.dialog.form.newPassword.placeholder")}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
{/* Password strength indicator */}
|
||||
{password && (
|
||||
<div className="mt-2 space-y-1">
|
||||
<div className="flex h-1.5 w-full overflow-hidden rounded-full bg-secondary-foreground">
|
||||
<div
|
||||
className={`${getStrengthColor()} transition-all duration-300`}
|
||||
style={{ width: `${(passwordStrength / 3) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("users.dialog.form.password.strength.title")}
|
||||
<span className="font-medium">{getStrengthLabel()}</span>
|
||||
</p>
|
||||
</div>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4 pt-4"
|
||||
>
|
||||
{username && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={"oldPassword" as keyof FormValues}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("users.dialog.form.currentPassword.title")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Input
|
||||
{...field}
|
||||
type={showOldPassword ? "text" : "password"}
|
||||
placeholder={t(
|
||||
"users.dialog.form.currentPassword.placeholder",
|
||||
)}
|
||||
className="h-10 pr-10"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
tabIndex={-1}
|
||||
aria-label={
|
||||
showOldPassword
|
||||
? t("users.dialog.form.password.hide", {
|
||||
ns: "views/settings",
|
||||
})
|
||||
: t("users.dialog.form.password.show", {
|
||||
ns: "views/settings",
|
||||
})
|
||||
}
|
||||
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
||||
onClick={() => setShowOldPassword(!showOldPassword)}
|
||||
>
|
||||
{showOldPassword ? (
|
||||
<LuEyeOff className="size-4" />
|
||||
) : (
|
||||
<LuEye className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm-password">
|
||||
{t("users.dialog.form.password.confirm.title")}
|
||||
</Label>
|
||||
<Input
|
||||
id="confirm-password"
|
||||
className="h-10"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(event) => {
|
||||
setConfirmPassword(event.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
placeholder={t(
|
||||
"users.dialog.form.newPassword.confirm.placeholder",
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("users.dialog.form.newPassword.title")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Input
|
||||
{...field}
|
||||
type={showPasswordVisible ? "text" : "password"}
|
||||
placeholder={t(
|
||||
"users.dialog.form.newPassword.placeholder",
|
||||
)}
|
||||
className="h-10 pr-10"
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
tabIndex={-1}
|
||||
aria-label={
|
||||
showPasswordVisible
|
||||
? t("users.dialog.form.password.hide", {
|
||||
ns: "views/settings",
|
||||
})
|
||||
: t("users.dialog.form.password.show", {
|
||||
ns: "views/settings",
|
||||
})
|
||||
}
|
||||
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
||||
onClick={() =>
|
||||
setShowPasswordVisible(!showPasswordVisible)
|
||||
}
|
||||
>
|
||||
{showPasswordVisible ? (
|
||||
<LuEyeOff className="size-4" />
|
||||
) : (
|
||||
<LuEye className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
|
||||
{password && (
|
||||
<div className="mt-2 space-y-2">
|
||||
<div className="flex h-1.5 w-full overflow-hidden rounded-full bg-secondary-foreground">
|
||||
<div
|
||||
className={`${getStrengthColor()} transition-all duration-300`}
|
||||
style={{ width: `${(passwordStrength / 4) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("users.dialog.form.password.strength.title")}
|
||||
<span className="font-medium">
|
||||
{getStrengthLabel()}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div className="space-y-1 rounded-md bg-muted/50 p-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
{t("users.dialog.form.password.requirements.title")}
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
<li className="flex items-center gap-2 text-xs">
|
||||
{requirements.length ? (
|
||||
<LuCheck className="size-3.5 text-green-500" />
|
||||
) : (
|
||||
<LuX className="size-3.5 text-red-500" />
|
||||
)}
|
||||
<span
|
||||
className={
|
||||
requirements.length
|
||||
? "text-green-600"
|
||||
: "text-red-600"
|
||||
}
|
||||
>
|
||||
{t(
|
||||
"users.dialog.form.password.requirements.length",
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2 text-xs">
|
||||
{requirements.uppercase ? (
|
||||
<LuCheck className="size-3.5 text-green-500" />
|
||||
) : (
|
||||
<LuX className="size-3.5 text-red-500" />
|
||||
)}
|
||||
<span
|
||||
className={
|
||||
requirements.uppercase
|
||||
? "text-green-600"
|
||||
: "text-red-600"
|
||||
}
|
||||
>
|
||||
{t(
|
||||
"users.dialog.form.password.requirements.uppercase",
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2 text-xs">
|
||||
{requirements.digit ? (
|
||||
<LuCheck className="size-3.5 text-green-500" />
|
||||
) : (
|
||||
<LuX className="size-3.5 text-red-500" />
|
||||
)}
|
||||
<span
|
||||
className={
|
||||
requirements.digit
|
||||
? "text-green-600"
|
||||
: "text-red-600"
|
||||
}
|
||||
>
|
||||
{t(
|
||||
"users.dialog.form.password.requirements.digit",
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2 text-xs">
|
||||
{requirements.special ? (
|
||||
<LuCheck className="size-3.5 text-green-500" />
|
||||
) : (
|
||||
<LuX className="size-3.5 text-red-500" />
|
||||
)}
|
||||
<span
|
||||
className={
|
||||
requirements.special
|
||||
? "text-green-600"
|
||||
: "text-red-600"
|
||||
}
|
||||
>
|
||||
{t(
|
||||
"users.dialog.form.password.requirements.special",
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Password match indicator */}
|
||||
{password && confirmPassword && (
|
||||
<div className="mt-1 flex items-center gap-1.5 text-xs">
|
||||
{password === confirmPassword ? (
|
||||
<>
|
||||
<LuCheck className="size-3.5 text-green-500" />
|
||||
<span className="text-green-600">
|
||||
{t("users.dialog.form.password.match")}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LuX className="size-3.5 text-red-500" />
|
||||
<span className="text-red-600">
|
||||
{t("users.dialog.form.password.notMatch")}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="confirmPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("users.dialog.form.password.confirm.title")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Input
|
||||
{...field}
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
placeholder={t(
|
||||
"users.dialog.form.newPassword.confirm.placeholder",
|
||||
)}
|
||||
className="h-10 pr-10"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
tabIndex={-1}
|
||||
aria-label={
|
||||
showConfirmPassword
|
||||
? t("users.dialog.form.password.hide", {
|
||||
ns: "views/settings",
|
||||
})
|
||||
: t("users.dialog.form.password.show", {
|
||||
ns: "views/settings",
|
||||
})
|
||||
}
|
||||
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
||||
onClick={() =>
|
||||
setShowConfirmPassword(!showConfirmPassword)
|
||||
}
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<LuEyeOff className="size-4" />
|
||||
) : (
|
||||
<LuEye className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
|
||||
{password &&
|
||||
confirmPassword &&
|
||||
password === confirmPassword && (
|
||||
<div className="mt-1 flex items-center gap-1.5 text-xs">
|
||||
<LuCheck className="size-3.5 text-green-500" />
|
||||
<span className="text-green-600">
|
||||
{t("users.dialog.form.password.match")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{form.formState.errors.root && (
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{form.formState.errors.root.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<div className="flex flex-1 flex-col justify-end">
|
||||
<div className="flex flex-row gap-2 pt-5">
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
aria-label={t("button.save", { ns: "common" })}
|
||||
className="flex flex-1"
|
||||
onClick={handleSave}
|
||||
disabled={!password || password !== confirmPassword}
|
||||
>
|
||||
{t("button.save", { ns: "common" })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
<DialogFooter className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<div className="flex flex-1 flex-col justify-end">
|
||||
<div className="flex flex-row gap-2 pt-5">
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
aria-label={t("button.save", { ns: "common" })}
|
||||
className="flex flex-1"
|
||||
type="submit"
|
||||
disabled={isLoading || !form.formState.isValid}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator />
|
||||
<span>{t("button.saving", { ns: "common" })}</span>
|
||||
</div>
|
||||
) : (
|
||||
t("button.save", { ns: "common" })
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@ -57,6 +57,8 @@ export default function AuthenticationView({
|
||||
const [showCreateRole, setShowCreateRole] = useState(false);
|
||||
const [showEditRole, setShowEditRole] = useState(false);
|
||||
const [showDeleteRole, setShowDeleteRole] = useState(false);
|
||||
const [passwordError, setPasswordError] = useState<string | null>(null);
|
||||
const [isPasswordLoading, setIsPasswordLoading] = useState(false);
|
||||
|
||||
const [selectedUser, setSelectedUser] = useState<string>();
|
||||
const [selectedUserRole, setSelectedUserRole] = useState<string>();
|
||||
@ -70,12 +72,15 @@ export default function AuthenticationView({
|
||||
}, [t]);
|
||||
|
||||
const onSavePassword = useCallback(
|
||||
(user: string, password: string) => {
|
||||
(user: string, password: string, oldPassword?: string) => {
|
||||
setIsPasswordLoading(true);
|
||||
axios
|
||||
.put(`users/${user}/password`, { password })
|
||||
.put(`users/${user}/password`, { password, old_password: oldPassword })
|
||||
.then((response) => {
|
||||
if (response.status === 200) {
|
||||
setShowSetPassword(false);
|
||||
setPasswordError(null);
|
||||
setIsPasswordLoading(false);
|
||||
toast.success(t("users.toast.success.updatePassword"), {
|
||||
position: "top-center",
|
||||
});
|
||||
@ -86,14 +91,10 @@ export default function AuthenticationView({
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(
|
||||
t("users.toast.error.setPasswordFailed", {
|
||||
errorMessage,
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
|
||||
// Keep dialog open and show error
|
||||
setPasswordError(errorMessage);
|
||||
setIsPasswordLoading(false);
|
||||
});
|
||||
},
|
||||
[t],
|
||||
@ -563,8 +564,15 @@ export default function AuthenticationView({
|
||||
</div>
|
||||
<SetPasswordDialog
|
||||
show={showSetPassword}
|
||||
onCancel={() => setShowSetPassword(false)}
|
||||
onSave={(password) => onSavePassword(selectedUser!, password)}
|
||||
onCancel={() => {
|
||||
setShowSetPassword(false);
|
||||
setPasswordError(null);
|
||||
}}
|
||||
initialError={passwordError}
|
||||
onSave={(password, oldPassword) =>
|
||||
onSavePassword(selectedUser!, password, oldPassword)
|
||||
}
|
||||
isLoading={isPasswordLoading}
|
||||
/>
|
||||
<DeleteUserDialog
|
||||
show={showDelete}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user