mirror of
https://github.com/suitenumerique/meet.git
synced 2026-11-13 13:59:19 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6fbbe13b80 | |||
| af04e7732a | |||
| 0ef95ece91 | |||
| 799100f15f | |||
| eb345610a7 | |||
| 48d144877a | |||
| 5b6ed14a31 | |||
| 6f8f5d0386 | |||
| 25122deba2 |
@@ -10,8 +10,11 @@ and this project adheres to
|
||||
|
||||
### Added
|
||||
|
||||
- 🌐(i18n) add Portuguese language support
|
||||
- ✨(frontend) add jittered exponential backoff to LiveKit reconnections
|
||||
- ✨(frontend) tell participants when a recording is interrupted
|
||||
- ✨(backend) show the last login of users in the admin
|
||||
- ✨(backend) filter users sharing an email in the admin
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -25,6 +28,9 @@ and this project adheres to
|
||||
- 🔒️(backend) issue room access tokens via POST rooms/{id}/join/ only
|
||||
- 🔒️(frontend) send the display name in the join request body
|
||||
- 🔥(backend) drop is_administrable from unregistered room payloads
|
||||
- 🍱(frontend) add Portuguese glyphs to Marianne subsetted fonts
|
||||
- ♻️(backend) match emails in a single normalized form
|
||||
- ⚡️(backend) index user emails for exact lookups
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -37,6 +43,9 @@ and this project adheres to
|
||||
- 🔒️(agents) upgrade python to 3.14.10 to fix CVE-2026-109518
|
||||
- 🔒️(docker) upgrade musl-utils to 1.2.6-r3 to fix CVE-2026-107891
|
||||
- ♿️(frontend) close the invite dialog with the Escape key #1916
|
||||
- 🔒️(docker) move frontend to nginx/nginx-unprivileged image
|
||||
- 🐛(frontend) accept meeting links with query params in join dialog
|
||||
- 🐛(backend) allow muting when everyone_can_mute is stored as null #1942
|
||||
|
||||
## [1.37.0] - 2026-11-01
|
||||
|
||||
|
||||
+12
@@ -45,6 +45,18 @@ parse as a UUID, yet to normalize to an existing room's id, matched no registere
|
||||
unregistered one. The token minted for that unregistered room was valid for the registered room: anyone could join a
|
||||
trusted or restricted room without being admitted through the lobby.
|
||||
|
||||
### User emails matched in a single normalized form
|
||||
|
||||
User emails are now stored stripped, with their ASCII letters lowercased, and compared exactly. A migration normalizes
|
||||
the stored emails and stores blank ones as NULL. Other letters keep their case.
|
||||
|
||||
- With `OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION` disabled (the default), a new `sub` whose email is already used by
|
||||
another user is now refused, by the OIDC login and by the external API, instead of getting a second account. To keep
|
||||
creating user with a duplicate mail, set the new `OIDC_ALLOW_DUPLICATE_EMAILS=True`.
|
||||
- Known limitation: provisional users still have a unique constraint comparing lowercased emails,
|
||||
non-ASCII letters included. Provisioning an email that differs from another provisional user's only by the case of a
|
||||
non-ASCII letter (e.g. `É` and `é`) returns a 409.
|
||||
|
||||
## v1.37.0
|
||||
|
||||
### Crisp support chat replaced by the La Suite support form
|
||||
|
||||
@@ -58,19 +58,13 @@ RUN npx webpack --mode production
|
||||
|
||||
|
||||
# ---- Front-end image ----
|
||||
FROM nginxinc/nginx-unprivileged:1.30.6-alpine3.24 AS frontend-production
|
||||
FROM nginx/nginx-unprivileged:1.30.6-alpine3.24 AS frontend-production
|
||||
|
||||
USER root
|
||||
RUN apk upgrade --no-cache libexpat && \
|
||||
apk add --no-cache --upgrade 'pcre2>=10.49-r0' 'tiff>=4.7.2-r0' \
|
||||
'libx11>=1.8.14-r0' 'ca-certificates>=20261020-r0' \
|
||||
'brotli-libs>=1.2.1-r0' 'musl-utils>=1.2.6-r3' && \
|
||||
apk del curl && \
|
||||
RUN apk del curl && \
|
||||
rm -f /usr/share/nginx/html/50x.html
|
||||
USER nginx
|
||||
|
||||
USER nginx
|
||||
|
||||
# Un-privileged user running the application
|
||||
ARG DOCKER_USER
|
||||
USER ${DOCKER_USER}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -16,7 +16,8 @@ Authentication relies on [django-lasuite](https://github.com/suitenumerique/djan
|
||||
| OIDC_VERIFY_SSL | Verify SSL certificates when contacting the OIDC provider | `true` |
|
||||
| OIDC_USE_NONCE | Use `nonce` to prevent replay attacks | `true` |
|
||||
| OIDC_STORE_ID_TOKEN | Store the ID token returned by the OIDC provider (useful for backend validation) | `true` |
|
||||
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to identifying users by email if `sub` claim does not match. Enable only if emails are unique. | `false` |
|
||||
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to identifying users by email if `sub` claim does not match. Cannot be enabled together with `OIDC_ALLOW_DUPLICATE_EMAILS`. | `false` |
|
||||
| OIDC_ALLOW_DUPLICATE_EMAILS | When the email fallback is disabled, create a new user for an unknown `sub` even if its email is already used by another user. Otherwise, the login is refused. Emails are compared once stripped, with their ASCII letters lowercased. | `false` |
|
||||
| **Endpoints** | | |
|
||||
| OIDC_OP_JWKS_ENDPOINT | URL to retrieve JSON Web Key Sets (for token verification) | — |
|
||||
| OIDC_OP_AUTHORIZATION_ENDPOINT | URL for authorization requests | — |
|
||||
|
||||
@@ -379,7 +379,8 @@ These are the environmental options available on meet backend.
|
||||
| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | Celery broker options | {} |
|
||||
| OIDC_CREATE_USER | Create OIDC user if not exists | true |
|
||||
| OIDC_VERIFY_SSL | Verify SSL for OIDC | true |
|
||||
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to email for identification | false |
|
||||
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to email for identification, cannot be enabled with OIDC_ALLOW_DUPLICATE_EMAILS | false |
|
||||
| OIDC_ALLOW_DUPLICATE_EMAILS | Without the email fallback, create a user for a new sub whose email is already used | false |
|
||||
| OIDC_RP_SIGN_ALGO | Token verification algorithm used by OIDC | RS256 |
|
||||
| OIDC_RP_CLIENT_ID | OIDC client ID | meet |
|
||||
| OIDC_RP_CLIENT_SECRET | OIDC client secret | |
|
||||
|
||||
@@ -4,6 +4,7 @@ from django import forms
|
||||
from django.contrib import admin, messages
|
||||
from django.contrib.auth import admin as auth_admin
|
||||
from django.db import transaction
|
||||
from django.db.models import Exists, OuterRef
|
||||
from django.utils.html import format_html
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
@@ -46,6 +47,26 @@ class FileInline(admin.TabularInline):
|
||||
return super().get_queryset(request).filter(hard_deleted_at__isnull=True)
|
||||
|
||||
|
||||
class DuplicateEmailFilter(admin.SimpleListFilter):
|
||||
"""Filter the users whose stored email is shared with another user."""
|
||||
|
||||
title = _("duplicate email")
|
||||
parameter_name = "duplicate_email"
|
||||
|
||||
def lookups(self, request, model_admin):
|
||||
"""Return the filter options."""
|
||||
return [("yes", _("Yes"))]
|
||||
|
||||
def queryset(self, request, queryset):
|
||||
"""Keep the users sharing their email with another user."""
|
||||
if self.value() != "yes":
|
||||
return queryset
|
||||
others = models.User.objects.filter(email=OuterRef("email")).exclude(
|
||||
pk=OuterRef("pk")
|
||||
)
|
||||
return queryset.filter(Exists(others))
|
||||
|
||||
|
||||
@admin.register(models.User)
|
||||
class UserAdmin(auth_admin.UserAdmin):
|
||||
"""Admin class for the User model"""
|
||||
@@ -89,7 +110,10 @@ class UserAdmin(auth_admin.UserAdmin):
|
||||
),
|
||||
},
|
||||
),
|
||||
(_("Important dates"), {"fields": ("created_at", "updated_at")}),
|
||||
(
|
||||
_("Important dates"),
|
||||
{"fields": ("created_at", "updated_at", "last_login")},
|
||||
),
|
||||
)
|
||||
add_fieldsets = (
|
||||
(
|
||||
@@ -112,9 +136,16 @@ class UserAdmin(auth_admin.UserAdmin):
|
||||
"is_superuser",
|
||||
"is_device",
|
||||
"created_at",
|
||||
"last_login",
|
||||
"updated_at",
|
||||
)
|
||||
list_filter = ("is_staff", "is_superuser", "is_device", "is_active")
|
||||
list_filter = (
|
||||
"is_staff",
|
||||
"is_superuser",
|
||||
"is_device",
|
||||
"is_active",
|
||||
DuplicateEmailFilter,
|
||||
)
|
||||
ordering = (
|
||||
"is_active",
|
||||
"-is_superuser",
|
||||
@@ -131,9 +162,16 @@ class UserAdmin(auth_admin.UserAdmin):
|
||||
"short_name",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"last_login",
|
||||
)
|
||||
search_fields = ("id", "sub", "admin_email", "email", "full_name")
|
||||
|
||||
def get_ordering(self, request):
|
||||
"""Group the users sharing an email when filtering on duplicates."""
|
||||
if DuplicateEmailFilter.parameter_name in request.GET:
|
||||
return ("email", "created_at")
|
||||
return super().get_ordering(request)
|
||||
|
||||
|
||||
@admin.register(models.File)
|
||||
class FileAdmin(admin.ModelAdmin):
|
||||
|
||||
@@ -137,7 +137,7 @@ class CanMuteParticipant(permissions.BasePermission):
|
||||
- Admins and owners can always mute.
|
||||
- When `everyone_can_mute` is enabled on the room, any participant
|
||||
currently in the room (proven by a valid LiveKit token for that room)
|
||||
can mute.
|
||||
can mute. It is enabled unless explicitly set to `False`.
|
||||
"""
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
@@ -149,8 +149,7 @@ class CanMuteParticipant(permissions.BasePermission):
|
||||
if not is_livekit_token_auth and obj.is_administrator_or_owner(request.user):
|
||||
return True
|
||||
|
||||
everyone_can_mute = obj.configuration.get("everyone_can_mute", True)
|
||||
if not everyone_can_mute:
|
||||
if obj.configuration.get("everyone_can_mute") is False:
|
||||
return False
|
||||
|
||||
if not is_livekit_token_auth:
|
||||
|
||||
@@ -13,7 +13,7 @@ from lasuite.oidc_login.backends import (
|
||||
)
|
||||
from rest_framework.authentication import SessionAuthentication
|
||||
|
||||
from core.models import User
|
||||
from core.models import DuplicateEmailError, User
|
||||
from core.validators import sub_validator
|
||||
|
||||
|
||||
@@ -83,18 +83,9 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
||||
) from err
|
||||
|
||||
try:
|
||||
return User.objects.get(sub=sub)
|
||||
except User.DoesNotExist:
|
||||
if email and settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
|
||||
try:
|
||||
return User.objects.get(email__iexact=email)
|
||||
except User.DoesNotExist:
|
||||
pass
|
||||
except User.MultipleObjectsReturned as e:
|
||||
raise SuspiciousOperation(
|
||||
"Multiple user accounts share a common email."
|
||||
) from e
|
||||
return None
|
||||
return User.objects.get_user_by_sub_or_email(sub, email)
|
||||
except DuplicateEmailError as err:
|
||||
raise SuspiciousOperation(err.message) from err
|
||||
|
||||
|
||||
class SessionAuthenticationWith401(SessionAuthentication):
|
||||
|
||||
@@ -20,6 +20,7 @@ from rest_framework import authentication, exceptions
|
||||
from core import audit, auditing
|
||||
from core.models import Application, ApplicationScope
|
||||
from core.services import jwt_token
|
||||
from core.utils import normalize_email
|
||||
from core.validators import sub_validator
|
||||
|
||||
User = get_user_model()
|
||||
@@ -63,6 +64,10 @@ def get_or_create_user_by_sub(sub, email=None, *, auth_method, client_id=None):
|
||||
Shared by the resource server backends, so that tokens introspected with the
|
||||
OIDC provider or with Menshen resolve users the same way.
|
||||
|
||||
Users are never identified by email here, but a user to create is refused
|
||||
when its normalized email is already stored, unless
|
||||
OIDC_ALLOW_DUPLICATE_EMAILS is set.
|
||||
|
||||
Args:
|
||||
sub: Subject identifier from the introspected token
|
||||
email: Email to store on a newly created user
|
||||
@@ -90,12 +95,10 @@ def get_or_create_user_by_sub(sub, email=None, *, auth_method, client_id=None):
|
||||
if not settings.OIDC_CREATE_USER:
|
||||
return None
|
||||
|
||||
# Users are never identified by email here. But a second account with the
|
||||
# same email (e.g. provisionnal account) would break the lookups by email:
|
||||
# refuse it.
|
||||
normalized_email = normalize_email(email)
|
||||
existing_user = (
|
||||
User.objects.filter(email__iexact=email).first()
|
||||
if settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION and email
|
||||
User.objects.filter(email=normalized_email).first()
|
||||
if normalized_email and not settings.OIDC_ALLOW_DUPLICATE_EMAILS
|
||||
else None
|
||||
)
|
||||
if existing_user is not None:
|
||||
|
||||
@@ -6,7 +6,6 @@ from django.contrib.auth import get_user_model
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
from django.db.models import Count
|
||||
from django.db.models.functions import Lower
|
||||
|
||||
from core.models import File, RecordingAccess, ResourceAccess, RoleChoices
|
||||
|
||||
@@ -21,14 +20,15 @@ ROLE_PRIORITY = {
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Merge duplicate users sharing the same email (case-insensitive) into the
|
||||
most recently created one.
|
||||
Merge duplicate users sharing the same email into the most recently
|
||||
created one.
|
||||
|
||||
Emails are compared case-insensitively, so 'John@Example.com' and
|
||||
'john@example.com' are treated as duplicates. The KEPT user is the most
|
||||
recently created. All room memberships, recording accesses and files are
|
||||
transferred to it. When a conflict exists, the higher-privilege role wins.
|
||||
Stale users are then deleted.
|
||||
Emails are stored normalized (stripped, ASCII letters lowercased) and are
|
||||
compared exactly, so 'John@Example.com' and 'john@example.com' are both
|
||||
stored as 'john@example.com' and treated as duplicates. The KEPT user is
|
||||
the most recently created. All room memberships, recording accesses and
|
||||
files are transferred to it. When a conflict exists, the higher-privilege
|
||||
role wins. Stale users are then deleted.
|
||||
Each email group is processed inside a single database transaction.
|
||||
"""
|
||||
|
||||
@@ -60,16 +60,13 @@ class Command(BaseCommand):
|
||||
users_qs = users_qs.filter(email__icontains=email_filter)
|
||||
self.stdout.write(f"[INFO] Filtering emails containing '{email_filter}'.\n")
|
||||
|
||||
# Group emails case-insensitively so 'John@X.com' and 'john@x.com'
|
||||
# are detected as duplicates of each other.
|
||||
duplicate_emails = (
|
||||
users_qs.exclude(email__isnull=True)
|
||||
.exclude(email="")
|
||||
.annotate(email_lower=Lower("email"))
|
||||
.values("email_lower")
|
||||
.values("email")
|
||||
.annotate(cnt=Count("id"))
|
||||
.filter(cnt__gt=1)
|
||||
.values_list("email_lower", flat=True)
|
||||
.values_list("email", flat=True)
|
||||
)
|
||||
|
||||
if not duplicate_emails:
|
||||
@@ -85,12 +82,9 @@ class Command(BaseCommand):
|
||||
failed_emails = []
|
||||
|
||||
for email in duplicate_emails:
|
||||
# Case-insensitive lookup to fetch every casing variant of the email.
|
||||
# Secondary sort by id ensures a stable, deterministic order when
|
||||
# created_at timestamps are equal (common in tests and bulk imports).
|
||||
users = list(
|
||||
User.objects.filter(email__iexact=email).order_by("created_at", "id")
|
||||
)
|
||||
users = list(User.objects.filter(email=email).order_by("created_at", "id"))
|
||||
kept_user = users[-1]
|
||||
stale_users = users[:-1]
|
||||
|
||||
@@ -130,10 +124,6 @@ class Command(BaseCommand):
|
||||
failed_emails.append(email)
|
||||
self.stderr.write(f"[ERROR] Failed to merge '{email}': {exc}")
|
||||
|
||||
if not kept_user.email.islower():
|
||||
kept_user.email = kept_user.email.lower()
|
||||
kept_user.save(update_fields=["email"])
|
||||
|
||||
if failed_emails:
|
||||
raise CommandError(
|
||||
f"Failed to merge {len(failed_emails)} email group(s): {', '.join(failed_emails)}"
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Store user emails stripped and ASCII-lowercased, and blank ones as NULL."""
|
||||
|
||||
import string
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
BATCH_SIZE = 500
|
||||
|
||||
_ASCII_LOWERCASE_TABLE = str.maketrans(string.ascii_uppercase, string.ascii_lowercase)
|
||||
|
||||
|
||||
def normalize_email(email):
|
||||
"""Strip an email and lowercase its ASCII letters only."""
|
||||
return (email or "").strip().translate(_ASCII_LOWERCASE_TABLE)
|
||||
|
||||
|
||||
def forwards(apps, schema_editor):
|
||||
"""Normalize the user emails, writing the changed rows in batches."""
|
||||
User = apps.get_model("core", "User")
|
||||
batch = []
|
||||
for user_id, email in User.objects.values_list("id", "email").iterator(
|
||||
chunk_size=BATCH_SIZE
|
||||
):
|
||||
normalized = normalize_email(email) or None
|
||||
if normalized == email:
|
||||
continue
|
||||
batch.append(User(id=user_id, email=normalized))
|
||||
if len(batch) == BATCH_SIZE:
|
||||
User.objects.bulk_update(batch, ["email"])
|
||||
batch = []
|
||||
if batch:
|
||||
User.objects.bulk_update(batch, ["email"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
atomic = False
|
||||
|
||||
dependencies = [
|
||||
("core", "0029_alter_application_client_secret_sha256"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(forwards, migrations.RunPython.noop, elidable=True),
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 5.2.17 on 2026-11-10 16:28
|
||||
|
||||
import core.models
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0030_normalize_user_emails'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelManagers(
|
||||
name='user',
|
||||
managers=[
|
||||
('objects', core.models.UserManager()),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,26 @@
|
||||
# Generated by Django 5.2.17 on 2026-11-10 17:00
|
||||
|
||||
from django.contrib.postgres.operations import (
|
||||
AddIndexConcurrently,
|
||||
RemoveIndexConcurrently,
|
||||
)
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
atomic = False
|
||||
|
||||
dependencies = [
|
||||
('core', '0031_alter_user_managers'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
AddIndexConcurrently(
|
||||
model_name="user",
|
||||
index=models.Index(fields=["email"], name="user_email_idx"),
|
||||
),
|
||||
RemoveIndexConcurrently(
|
||||
model_name="user",
|
||||
name="user_email_upper_idx",
|
||||
),
|
||||
]
|
||||
@@ -115,6 +115,16 @@ class RoomAccessLevel(models.TextChoices):
|
||||
RESTRICTED = "restricted", _("Restricted Access")
|
||||
|
||||
|
||||
class DuplicateEmailError(Exception):
|
||||
"""Raised when an email is already associated with a pre-existing user."""
|
||||
|
||||
def __init__(self, message=None, email=None):
|
||||
"""Set message and email to describe the exception."""
|
||||
self.message = message
|
||||
self.email = email
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
class BaseModel(models.Model):
|
||||
"""
|
||||
Serves as an abstract base model for other models, ensuring that records are validated
|
||||
@@ -152,6 +162,36 @@ class BaseModel(models.Model):
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class UserManager(auth_models.UserManager):
|
||||
"""Custom manager for User model with additional methods."""
|
||||
|
||||
def get_user_by_sub_or_email(self, sub, email):
|
||||
"""Fetch existing user by sub or email."""
|
||||
try:
|
||||
return self.get(sub=sub)
|
||||
except self.model.DoesNotExist as err:
|
||||
if not email:
|
||||
return None
|
||||
|
||||
normalized_email = utils.normalize_email(email)
|
||||
if settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
|
||||
users = self.filter(email=normalized_email).order_by("-is_active")[:2]
|
||||
if len(users) == 2 and users[1].is_active:
|
||||
raise DuplicateEmailError(
|
||||
"Several active users are associated with this email."
|
||||
) from err
|
||||
return users[0] if users else None
|
||||
if (
|
||||
not settings.OIDC_ALLOW_DUPLICATE_EMAILS
|
||||
and self.filter(email=normalized_email).exists()
|
||||
):
|
||||
raise DuplicateEmailError(
|
||||
"We couldn't find a user with this sub but the email is already "
|
||||
"associated with a registered user."
|
||||
) from err
|
||||
return None
|
||||
|
||||
|
||||
class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
"""User model to work with OIDC only authentication."""
|
||||
|
||||
@@ -229,7 +269,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
),
|
||||
)
|
||||
|
||||
objects = auth_models.UserManager()
|
||||
objects = UserManager()
|
||||
|
||||
USERNAME_FIELD = "admin_email"
|
||||
REQUIRED_FIELDS = []
|
||||
@@ -247,12 +287,21 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
)
|
||||
]
|
||||
indexes = [
|
||||
models.Index(models.functions.Upper("email"), name="user_email_upper_idx"),
|
||||
models.Index(fields=["email"], name="user_email_idx"),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.email or self.admin_email or str(self.id)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Store the email normalized, and a blank one as NULL, even on a partial save."""
|
||||
email = utils.normalize_email(self.email) or None
|
||||
update_fields = kwargs.get("update_fields")
|
||||
if update_fields is not None and email != self.email:
|
||||
kwargs["update_fields"] = {*update_fields, "email"}
|
||||
self.email = email
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def email_user(self, subject, message, from_email=None, **kwargs):
|
||||
"""Email this user."""
|
||||
if not self.email:
|
||||
|
||||
@@ -5,6 +5,7 @@ from django.core.exceptions import SuspiciousOperation, ValidationError
|
||||
from django.db import IntegrityError
|
||||
|
||||
from core import audit, auditing, models
|
||||
from core.utils import normalize_email
|
||||
|
||||
|
||||
class ProvisionalUserError(Exception):
|
||||
@@ -42,7 +43,7 @@ class ProvisionalUserService:
|
||||
def _get_by_email(self, email: str) -> models.User | None:
|
||||
"""Return the user with this email, or None if not found."""
|
||||
try:
|
||||
return models.User.objects.get(email__iexact=email)
|
||||
return models.User.objects.get(email=normalize_email(email))
|
||||
except models.User.DoesNotExist:
|
||||
return None
|
||||
except models.User.MultipleObjectsReturned as e:
|
||||
|
||||
@@ -296,17 +296,26 @@ def test_finds_user_by_sub(django_assert_num_queries):
|
||||
|
||||
|
||||
def test_finds_user_when_email_fallback_disabled(django_assert_num_queries, settings):
|
||||
"""Should not return a user when not found by sub, and email fallback is disabled."""
|
||||
"""Should refuse a sub that is not found when its email is already used, and email
|
||||
fallback is disabled."""
|
||||
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
|
||||
settings.OIDC_ALLOW_DUPLICATE_EMAILS = False
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory(email="foo@mail.com")
|
||||
|
||||
with django_assert_num_queries(1):
|
||||
user = klass.get_existing_user("wrong-sub", db_user.email)
|
||||
|
||||
assert user is None
|
||||
with (
|
||||
django_assert_num_queries(2),
|
||||
pytest.raises(
|
||||
SuspiciousOperation,
|
||||
match=(
|
||||
"We couldn't find a user with this sub but the email is already "
|
||||
"associated with a registered user."
|
||||
),
|
||||
),
|
||||
):
|
||||
klass.get_existing_user("wrong-sub", db_user.email)
|
||||
|
||||
|
||||
def test_finds_user_when_email_is_none(django_assert_num_queries, settings):
|
||||
@@ -340,7 +349,7 @@ def test_finds_user_by_email(django_assert_num_queries, settings):
|
||||
|
||||
|
||||
def test_finds_user_case_insensitive_email(django_assert_num_queries, settings):
|
||||
"""Should match email case-insensitively when falling back to email."""
|
||||
"""Should match email ignoring ASCII case when falling back to email."""
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
@@ -353,7 +362,7 @@ def test_finds_user_case_insensitive_email(django_assert_num_queries, settings):
|
||||
|
||||
|
||||
def test_finds_user_multiple_users_same_email(django_assert_num_queries, settings):
|
||||
"""Should handle multiple users with same email appropriately."""
|
||||
"""Should refuse to pick one of several active users sharing the email."""
|
||||
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
||||
|
||||
@@ -366,25 +375,178 @@ def test_finds_user_multiple_users_same_email(django_assert_num_queries, setting
|
||||
django_assert_num_queries(2),
|
||||
pytest.raises(
|
||||
SuspiciousOperation,
|
||||
match="Multiple user accounts share a common email.",
|
||||
match="Several active users are associated with this email.",
|
||||
),
|
||||
):
|
||||
klass.get_existing_user("wrong-sub", email)
|
||||
|
||||
|
||||
def test_finds_user_whitespace_email(django_assert_num_queries, settings):
|
||||
"""Should not match emails with whitespace."""
|
||||
"""Should match emails with surrounding whitespace once stripped."""
|
||||
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
||||
settings.OIDC_CREATE_USER = False
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
UserFactory(email="foo@mail.com")
|
||||
db_user = UserFactory(email="foo@mail.com")
|
||||
|
||||
with django_assert_num_queries(2):
|
||||
user = klass.get_existing_user("wrong-sub", " foo@mail.com ")
|
||||
|
||||
assert user is None
|
||||
assert user == db_user
|
||||
|
||||
|
||||
def test_authentication_getter_existing_user_via_email_case_insensitive(
|
||||
monkeypatch, settings
|
||||
):
|
||||
"""
|
||||
If an existing user doesn't match the sub but matches the email with another
|
||||
ASCII case, the user should be returned and its email should stay normalized.
|
||||
"""
|
||||
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory(email="john.doe@example.com")
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123", "email": "JOHN.DOE@EXAMPLE.COM"}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user == db_user
|
||||
db_user.refresh_from_db()
|
||||
assert db_user.email == "john.doe@example.com"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"email",
|
||||
[
|
||||
"dısk@example.com",
|
||||
"diſk@example.com",
|
||||
"disK@example.com",
|
||||
],
|
||||
ids=["dotless-i", "long-s", "kelvin-sign"],
|
||||
)
|
||||
@pytest.mark.parametrize("fallback", [True, False])
|
||||
def test_authentication_getter_existing_user_via_email_non_ascii(
|
||||
monkeypatch, settings, fallback, email
|
||||
):
|
||||
"""
|
||||
If an existing user doesn't match the sub and the email only matches its email
|
||||
through Unicode case folding, a new user should be created and the existing user
|
||||
should be left untouched.
|
||||
"""
|
||||
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = fallback
|
||||
settings.OIDC_ALLOW_DUPLICATE_EMAILS = False
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory(email="disk@example.com")
|
||||
db_sub = db_user.sub
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "other-sub", "email": email}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user != db_user
|
||||
assert user.sub == "other-sub"
|
||||
assert user.email == email
|
||||
assert models.User.objects.count() == 2
|
||||
db_user.refresh_from_db()
|
||||
assert db_user.sub == db_sub
|
||||
assert db_user.email == "disk@example.com"
|
||||
|
||||
|
||||
def test_authentication_getter_existing_inactive_user_via_email(monkeypatch, settings):
|
||||
"""
|
||||
If the only user matching the email is inactive, the login should be refused
|
||||
instead of creating another user.
|
||||
"""
|
||||
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
UserFactory(email="john.doe@example.com", is_active=False)
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123", "email": "John.Doe@example.com"}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with pytest.raises(SuspiciousOperation, match="User account is disabled"):
|
||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
def test_authentication_getter_no_fallback_to_email_no_duplicate(monkeypatch, settings):
|
||||
"""
|
||||
When the email fallback is disabled, an unknown sub whose email is already used,
|
||||
in another ASCII case, should be refused instead of creating a second user.
|
||||
"""
|
||||
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
|
||||
settings.OIDC_ALLOW_DUPLICATE_EMAILS = False
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory(email="john.doe@example.com")
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123", "email": "JOHN.DOE@EXAMPLE.COM"}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with pytest.raises(
|
||||
SuspiciousOperation,
|
||||
match=(
|
||||
"We couldn't find a user with this sub but the email is already "
|
||||
"associated with a registered user."
|
||||
),
|
||||
):
|
||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||
|
||||
assert models.User.objects.count() == 1
|
||||
db_user.refresh_from_db()
|
||||
assert db_user.sub != "123"
|
||||
|
||||
|
||||
def test_authentication_getter_no_fallback_to_email_allow_duplicate(
|
||||
monkeypatch, settings
|
||||
):
|
||||
"""
|
||||
When the email fallback is disabled and duplicate emails are allowed, an unknown
|
||||
sub whose email is already used should get a new user.
|
||||
"""
|
||||
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
|
||||
settings.OIDC_ALLOW_DUPLICATE_EMAILS = True
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory(email="john.doe@example.com")
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123", "email": "JOHN.DOE@EXAMPLE.COM"}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user != db_user
|
||||
assert user.sub == "123"
|
||||
assert user.email == "john.doe@example.com"
|
||||
assert models.User.objects.count() == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
||||
@@ -19,6 +19,13 @@ pytestmark = pytest.mark.django_db
|
||||
# pylint: disable=W0613
|
||||
|
||||
|
||||
def _user_with_stored_email(email):
|
||||
"""Create a user storing the email as is, like the rows predating the normalization."""
|
||||
user = UserFactory()
|
||||
User.objects.filter(pk=user.pk).update(email=email)
|
||||
return user
|
||||
|
||||
|
||||
def test_merge_no_duplicates_does_nothing():
|
||||
"""Command should do nothing when no duplicate users exist."""
|
||||
user = UserFactory(email="unique@example.com")
|
||||
@@ -36,24 +43,30 @@ def test_merge_keeps_most_recently_created_user():
|
||||
assert User.objects.filter(id=user2.id).exists()
|
||||
|
||||
|
||||
def test_merge_user_case_insensitive():
|
||||
"""Emails differing only by case should be treated as duplicates and merged,
|
||||
keeping the most recently created user."""
|
||||
user1 = UserFactory(email="Dup@example.com")
|
||||
user2 = UserFactory(email="dup@example.com")
|
||||
@pytest.mark.parametrize(
|
||||
"lookalike_email",
|
||||
[
|
||||
"dısk@example.com",
|
||||
"diſk@example.com",
|
||||
"disK@example.com",
|
||||
],
|
||||
ids=["dotless-i", "long-s", "kelvin-sign"],
|
||||
)
|
||||
def test_merge_does_not_merge_lookalike_emails(lookalike_email):
|
||||
"""A user whose email only matches the duplicates' email through Unicode case
|
||||
folding should not be merged with them, even when created last."""
|
||||
user1 = UserFactory(email="disk@example.com")
|
||||
user2 = UserFactory(email="disk@example.com")
|
||||
ra = UserResourceAccessFactory(user=user1)
|
||||
lookalike = UserFactory(email=lookalike_email)
|
||||
call_command("merge_duplicate_users")
|
||||
|
||||
assert not User.objects.filter(id=user1.id).exists()
|
||||
assert User.objects.filter(id=user2.id).exists()
|
||||
|
||||
user3 = UserFactory(email="joe@example.com")
|
||||
user4 = UserFactory(email="Joe@example.com")
|
||||
call_command("merge_duplicate_users")
|
||||
assert not User.objects.filter(id=user3.id).exists()
|
||||
assert User.objects.filter(id=user4.id).exists()
|
||||
|
||||
user4.refresh_from_db()
|
||||
assert user4.email.islower()
|
||||
ra.refresh_from_db()
|
||||
assert ra.user == user2
|
||||
lookalike.refresh_from_db()
|
||||
assert lookalike.email == lookalike_email
|
||||
assert not ResourceAccess.objects.filter(user=lookalike).exists()
|
||||
|
||||
|
||||
def test_merge_deletes_all_stale_users():
|
||||
@@ -409,10 +422,11 @@ def test_merge_does_not_merge_users_with_null_email():
|
||||
assert User.objects.filter(id=user2.id).exists()
|
||||
|
||||
|
||||
def test_merge_does_not_merge_users_with_blank_email():
|
||||
"""Users with empty-string email must never be merged together, even if multiple exist."""
|
||||
user1 = UserFactory(email="")
|
||||
user2 = UserFactory(email="")
|
||||
def test_merge_does_not_merge_users_with_legacy_blank_email():
|
||||
"""Users with a legacy empty-string email must never be merged together, even if
|
||||
multiple exist."""
|
||||
user1 = _user_with_stored_email("")
|
||||
user2 = _user_with_stored_email("")
|
||||
call_command("merge_duplicate_users")
|
||||
assert User.objects.filter(id=user1.id).exists()
|
||||
assert User.objects.filter(id=user2.id).exists()
|
||||
@@ -474,7 +488,7 @@ def test_merge_email_filter_no_match_does_nothing():
|
||||
|
||||
def test_merge_email_filter_is_case_insensitive():
|
||||
"""Command should match emails case-insensitively when filtering."""
|
||||
UserFactory(email="user1@Example.com")
|
||||
UserFactory(email="user1@Example.com")
|
||||
call_command("merge_duplicate_users", email_filter="@example.com")
|
||||
UserFactory(email="user1@example.com")
|
||||
UserFactory(email="user1@example.com")
|
||||
call_command("merge_duplicate_users", email_filter="@Example.COM")
|
||||
assert User.objects.filter(email="user1@example.com").count() == 1
|
||||
|
||||
@@ -166,6 +166,30 @@ def test_mute_participant_everyone_can_mute_disabled_blocks_non_admin(
|
||||
mock_livekit_client.room.mute_published_track.assert_not_called()
|
||||
|
||||
|
||||
def test_mute_participant_everyone_can_mute_null_allows_non_admin(
|
||||
mock_livekit_client,
|
||||
):
|
||||
"""Should treat a stored null everyone_can_mute as unset, hence enabled."""
|
||||
client = APIClient()
|
||||
room = RoomFactory(configuration={"everyone_can_mute": None})
|
||||
|
||||
user = AnonymousUser()
|
||||
token = utils.generate_token(str(room.id), user)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data == {"status": "success"}
|
||||
|
||||
mock_livekit_client.room.mute_published_track.assert_called_once()
|
||||
|
||||
|
||||
def test_mute_participant_everyone_can_mute_disabled_allows_admin(mock_livekit_client):
|
||||
"""Should allow admins and owners to mute when everyone_can_mute is False."""
|
||||
client = APIClient()
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import json
|
||||
from unittest import mock
|
||||
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.db import IntegrityError
|
||||
|
||||
import pytest
|
||||
@@ -38,6 +39,20 @@ def test_get_or_create_existing_user_is_not_audited(audit_events):
|
||||
assert not find_events(audit_events, "user.provision")
|
||||
|
||||
|
||||
def test_get_or_create_several_users_same_normalized_email(audit_events):
|
||||
"""Several users sharing the normalized email should be refused, not picked."""
|
||||
UserFactory(email="john.doe@example.com")
|
||||
UserFactory(email="John.Doe@example.com")
|
||||
|
||||
with pytest.raises(
|
||||
SuspiciousOperation, match="Multiple user accounts share a common email."
|
||||
):
|
||||
ProvisionalUserService().get_or_create(" JOHN.DOE@example.com", "my-client")
|
||||
|
||||
assert User.objects.count() == 2
|
||||
assert not find_events(audit_events, "user.provision")
|
||||
|
||||
|
||||
def test_get_or_create_creation_is_audited(audit_events):
|
||||
"""A created user is identified by its id, without sub nor email."""
|
||||
user, created = ProvisionalUserService().get_or_create(
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Tests for the user admin class."""
|
||||
|
||||
import pytest
|
||||
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def static_storage_without_manifest(settings):
|
||||
"""Render the admin pages without collecting the static files first."""
|
||||
settings.STORAGES = {
|
||||
**settings.STORAGES,
|
||||
"staticfiles": {
|
||||
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _admin_client(client):
|
||||
"""Log a superuser in the client."""
|
||||
client.force_login(factories.UserFactory(is_staff=True, is_superuser=True))
|
||||
return client
|
||||
|
||||
|
||||
def test_admin_users_duplicate_email_filter(client):
|
||||
"""The filter should keep the users sharing their stored email, grouped by email
|
||||
and ordered by creation, but not the users whose email only matches through
|
||||
Unicode case folding."""
|
||||
first_jim = factories.UserFactory(email="jim@example.com")
|
||||
first_ann = factories.UserFactory(email="ann@example.com")
|
||||
second_jim = factories.UserFactory(email="JIM@example.com")
|
||||
second_ann = factories.UserFactory(email="ann@example.com")
|
||||
factories.UserFactory(email="jım@example.com")
|
||||
factories.UserFactory(email="bob@example.com")
|
||||
factories.UserFactory(email=None)
|
||||
factories.UserFactory(email=None)
|
||||
|
||||
response = _admin_client(client).get("/admin/core/user/?duplicate_email=yes")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert [user.pk for user in response.context["cl"].result_list] == [
|
||||
first_ann.pk,
|
||||
second_ann.pk,
|
||||
first_jim.pk,
|
||||
second_jim.pk,
|
||||
]
|
||||
|
||||
|
||||
def test_admin_users_last_login(client):
|
||||
"""The last login of users should be shown in the list and on the change page."""
|
||||
user = factories.UserFactory()
|
||||
admin_client = _admin_client(client)
|
||||
|
||||
changelist = admin_client.get("/admin/core/user/")
|
||||
change = admin_client.get(f"/admin/core/user/{user.pk}/change/")
|
||||
|
||||
assert changelist.status_code == 200
|
||||
assert "column-last_login" in changelist.content.decode()
|
||||
assert change.status_code == 200
|
||||
assert "field-last_login" in change.content.decode()
|
||||
@@ -280,19 +280,22 @@ def test_menshen_unknown_user_not_created(settings):
|
||||
|
||||
|
||||
@responses.activate
|
||||
@pytest.mark.parametrize("fallback", [True, False])
|
||||
@pytest.mark.parametrize("existing_sub", [None, "other-sub"])
|
||||
def test_menshen_unknown_user_email_already_used(settings, existing_sub):
|
||||
def test_menshen_unknown_user_email_already_used(settings, existing_sub, fallback):
|
||||
"""An unknown sub should not create a second account with an email already
|
||||
used, by a provisional user or a user with another sub, when
|
||||
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION=True."""
|
||||
used, by a provisional user or a user with another sub, whether
|
||||
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION is set or not."""
|
||||
|
||||
settings.OIDC_CREATE_USER = True
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = fallback
|
||||
|
||||
existing_user = UserFactory(sub=existing_sub, email="User@Example.com")
|
||||
existing_user = UserFactory(sub=existing_sub, email="user@example.com")
|
||||
|
||||
responses.add(
|
||||
responses.POST, INTROSPECTION_ENDPOINT, json=_introspection("new-sub")
|
||||
responses.POST,
|
||||
INTROSPECTION_ENDPOINT,
|
||||
json=_introspection("new-sub", email=" User@Example.COM "),
|
||||
)
|
||||
|
||||
response = _create_room()
|
||||
@@ -305,6 +308,43 @@ def test_menshen_unknown_user_email_already_used(settings, existing_sub):
|
||||
assert not Room.objects.exists()
|
||||
|
||||
|
||||
@responses.activate
|
||||
@pytest.mark.parametrize("existing_sub", [None, "other-sub"])
|
||||
def test_menshen_unknown_user_email_already_used_allowed(
|
||||
audit_events, settings, existing_sub
|
||||
):
|
||||
"""An unknown sub should get its own account, even with an email already used,
|
||||
when OIDC_ALLOW_DUPLICATE_EMAILS=True."""
|
||||
|
||||
settings.OIDC_CREATE_USER = True
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
|
||||
settings.OIDC_ALLOW_DUPLICATE_EMAILS = True
|
||||
|
||||
existing_user = UserFactory(sub=existing_sub, email="user@example.com")
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
INTROSPECTION_ENDPOINT,
|
||||
json=_introspection("new-sub", email=" User@Example.COM "),
|
||||
)
|
||||
|
||||
response = _create_room()
|
||||
|
||||
assert response.status_code == 201
|
||||
assert User.objects.count() == 2
|
||||
user = User.objects.get(sub="new-sub")
|
||||
assert user != existing_user
|
||||
assert user.email == "user@example.com"
|
||||
existing_user.refresh_from_db()
|
||||
assert existing_user.sub == existing_sub
|
||||
|
||||
[provision] = find_events(audit_events, "user.provision")
|
||||
|
||||
assert provision["event"]["outcome"] == "success"
|
||||
assert provision["lasuite"]["outcome"] == "success"
|
||||
assert provision["entity"]["target"]["id"] == str(user.pk)
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_menshen_is_audited_as_application(audit_events):
|
||||
"""A call with a Menshen token is made by the client Menshen reports, acting for
|
||||
@@ -382,10 +422,12 @@ def test_menshen_unknown_user_email_already_used_is_audited(audit_events, settin
|
||||
settings.OIDC_CREATE_USER = True
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
||||
|
||||
existing_user = UserFactory(sub=None, email="User@Example.com")
|
||||
existing_user = UserFactory(sub=None, email="user@example.com")
|
||||
|
||||
responses.add(
|
||||
responses.POST, INTROSPECTION_ENDPOINT, json=_introspection("new-sub")
|
||||
responses.POST,
|
||||
INTROSPECTION_ENDPOINT,
|
||||
json=_introspection("new-sub", email=" User@Example.COM "),
|
||||
)
|
||||
|
||||
response = _create_room()
|
||||
|
||||
@@ -4,11 +4,12 @@ Unit tests for the User model
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.contrib.auth.models import update_last_login
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
import pytest
|
||||
|
||||
from core import factories
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -117,12 +118,13 @@ def test_models_users_email_unique_when_sub_is_null():
|
||||
|
||||
|
||||
def test_models_users_email_unique_case_insensitive_when_sub_is_null():
|
||||
"""Email uniqueness should be case-insensitive among users with no sub (pending users)."""
|
||||
factories.UserFactory(sub=None, email="Test@example.com")
|
||||
"""Email uniqueness among users with no sub (pending users) should also ignore the
|
||||
case of non-ASCII letters, which the email normalization keeps."""
|
||||
factories.UserFactory(sub=None, email="test@éxample.com")
|
||||
with pytest.raises(
|
||||
ValidationError, match="Constraint “unique_email_when_sub_is_null” is violated."
|
||||
):
|
||||
factories.UserFactory(sub=None, email="test@example.com")
|
||||
factories.UserFactory(sub=None, email="test@Éxample.com")
|
||||
|
||||
|
||||
def test_models_users_email_not_unique_when_sub_is_set():
|
||||
@@ -175,3 +177,54 @@ def test_models_users_sub_blank_is_accepted():
|
||||
"""sub='' passes validation because blank=True; null is preferred but not enforced."""
|
||||
user = factories.UserFactory.build(sub="")
|
||||
user.full_clean()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"email, expected_email",
|
||||
[
|
||||
("John.Doe@Example.COM", "john.doe@example.com"),
|
||||
(" john.doe@example.com ", "john.doe@example.com"),
|
||||
],
|
||||
)
|
||||
def test_models_users_email_normalized_on_save(email, expected_email):
|
||||
"""The email of a user should be stripped and have its ASCII letters only lowercased
|
||||
when saved."""
|
||||
user = factories.UserFactory(email=email)
|
||||
user.refresh_from_db()
|
||||
assert user.email == expected_email
|
||||
|
||||
|
||||
def test_models_users_email_normalized_on_login():
|
||||
"""The last login update at login should store a legacy email normalized."""
|
||||
user = factories.UserFactory()
|
||||
models.User.objects.filter(pk=user.pk).update(email="John.Doe@Example.COM")
|
||||
user.refresh_from_db()
|
||||
|
||||
update_last_login(None, user)
|
||||
|
||||
user.refresh_from_db()
|
||||
assert user.last_login is not None
|
||||
assert user.email == "john.doe@example.com"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("email", ["", " "])
|
||||
def test_models_users_email_blank_stored_as_null(email):
|
||||
"""An empty or whitespace-only email should be stored as NULL."""
|
||||
user = factories.UserFactory(email=email)
|
||||
user.refresh_from_db()
|
||||
assert user.email is None
|
||||
|
||||
|
||||
def test_models_users_get_user_by_sub_or_email_active_preferred(settings):
|
||||
"""An active user should be preferred over an inactive one sharing its email."""
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
||||
factories.UserFactory(email="john.doe@example.com", is_active=False)
|
||||
active_user = factories.UserFactory(email="John.Doe@example.com")
|
||||
factories.UserFactory(email="john.doe@example.com", is_active=False)
|
||||
|
||||
assert (
|
||||
models.User.objects.get_user_by_sub_or_email(
|
||||
"unknown-sub", "JOHN.DOE@example.com"
|
||||
)
|
||||
== active_user
|
||||
)
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Unit tests for the OIDC_ALLOW_DUPLICATE_EMAILS setting."""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from meet.settings import Test
|
||||
|
||||
|
||||
def test_oidc_allow_duplicate_emails_with_email_fallback_is_refused():
|
||||
"""Users identified by email cannot share one, so both settings stop the loading."""
|
||||
with (
|
||||
mock.patch.object(Test, "OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION", True),
|
||||
mock.patch.object(Test, "OIDC_ALLOW_DUPLICATE_EMAILS", True),
|
||||
pytest.raises(ValueError, match="OIDC_ALLOW_DUPLICATE_EMAILS"),
|
||||
):
|
||||
Test.post_setup()
|
||||
@@ -27,6 +27,7 @@ from core.utils import (
|
||||
NotificationError,
|
||||
create_livekit_client,
|
||||
generate_token,
|
||||
normalize_email,
|
||||
notify_participants,
|
||||
)
|
||||
|
||||
@@ -420,3 +421,14 @@ def test_notify_participants_success(mock_create_livekit_client):
|
||||
|
||||
# Verify aclose was called
|
||||
mock_api_instance.aclose.assert_called_once()
|
||||
|
||||
|
||||
def test_normalize_email():
|
||||
"""The "normalize_email" function should strip and lowercase ASCII letters only."""
|
||||
assert normalize_email(" John.Doe@Example.COM ") == "john.doe@example.com"
|
||||
assert normalize_email("Dısk@Example.com") == "dısk@example.com"
|
||||
assert normalize_email("Diſk@Example.com") == "diſk@example.com"
|
||||
assert normalize_email("DisK@Example.com") == "disK@example.com"
|
||||
assert normalize_email("Élise@Example.com") == "Élise@example.com"
|
||||
assert normalize_email("") == ""
|
||||
assert normalize_email(None) == ""
|
||||
|
||||
@@ -345,6 +345,17 @@ def is_uuid(value: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
_ASCII_LOWERCASE_TABLE = str.maketrans(string.ascii_uppercase, string.ascii_lowercase)
|
||||
|
||||
|
||||
def normalize_email(email):
|
||||
"""Strip an email and lowercase its ASCII letters only.
|
||||
|
||||
Emails are stored in this form and compared exactly.
|
||||
"""
|
||||
return (email or "").strip().translate(_ASCII_LOWERCASE_TABLE)
|
||||
|
||||
|
||||
def detect_mimetype(file_buffer: bytes, filename: str | None = None) -> str:
|
||||
"""
|
||||
Detect MIME type using multiple methods for better accuracy.
|
||||
|
||||
@@ -708,3 +708,7 @@ msgstr "Deutsch"
|
||||
#: meet/settings.py:242
|
||||
msgid "Spanish"
|
||||
msgstr "Spanisch"
|
||||
|
||||
#: meet/settings.py:243
|
||||
msgid "Portuguese"
|
||||
msgstr "Portugiesisch"
|
||||
|
||||
@@ -703,3 +703,7 @@ msgstr "German"
|
||||
#: meet/settings.py:242
|
||||
msgid "Spanish"
|
||||
msgstr "Spanish"
|
||||
|
||||
#: meet/settings.py:243
|
||||
msgid "Portuguese"
|
||||
msgstr "Portuguese"
|
||||
|
||||
@@ -705,3 +705,7 @@ msgstr "Alemán"
|
||||
#: meet/settings.py:242
|
||||
msgid "Spanish"
|
||||
msgstr "Español"
|
||||
|
||||
#: meet/settings.py:243
|
||||
msgid "Portuguese"
|
||||
msgstr "Portugués"
|
||||
|
||||
@@ -709,3 +709,7 @@ msgstr "Allemand"
|
||||
#: meet/settings.py:242
|
||||
msgid "Spanish"
|
||||
msgstr "Espagnol"
|
||||
|
||||
#: meet/settings.py:243
|
||||
msgid "Portuguese"
|
||||
msgstr "Portugais"
|
||||
|
||||
@@ -703,3 +703,7 @@ msgstr "Duits"
|
||||
#: meet/settings.py:242
|
||||
msgid "Spanish"
|
||||
msgstr "Spaans"
|
||||
|
||||
#: meet/settings.py:243
|
||||
msgid "Portuguese"
|
||||
msgstr "Portugees"
|
||||
|
||||
@@ -0,0 +1,699 @@
|
||||
# Portuguese (Brazil) translation for La Suite numérique Meet.
|
||||
# Copyright (C) 2026 La Suite numérique
|
||||
# This file is distributed under the same license as the Meet package.
|
||||
# Contributed by Alan Turing Laboratory (ATLab) - Federal University of Ceará (UFC), Brazil.
|
||||
# Railton Vasconcelos <railton.vasconcelos14@gmail.com>, 2026.
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-meet\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-09-02 22:52+0000\n"
|
||||
"PO-Revision-Date: 2026-11-07 21:00-0300\n"
|
||||
"Last-Translator: Railton Vasconcelos <railton.vasconcelos14@gmail.com> (ATLab - UFC)\n"
|
||||
"Language-Team: Alan Turing Laboratory (ATLab) - Federal University of Ceará (UFC)\n"
|
||||
"Language: pt_BR\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
|
||||
|
||||
#: core/admin.py:67
|
||||
msgid "Personal info"
|
||||
msgstr "Informações pessoais"
|
||||
|
||||
#: core/admin.py:80
|
||||
msgid "Permissions"
|
||||
msgstr "Permissões"
|
||||
|
||||
#: core/admin.py:92
|
||||
msgid "Important dates"
|
||||
msgstr "Datas importantes"
|
||||
|
||||
#: core/admin.py:206
|
||||
msgid "Content"
|
||||
msgstr "Conteúdo"
|
||||
|
||||
#: core/admin.py:217
|
||||
msgid "Deletion"
|
||||
msgstr "Exclusão"
|
||||
|
||||
#: core/admin.py:226
|
||||
msgid "Derived info"
|
||||
msgstr "Informações derivadas"
|
||||
|
||||
#: core/admin.py:237
|
||||
msgid "Timestamps"
|
||||
msgstr "Carimbos de data e hora"
|
||||
|
||||
#: core/admin.py:240
|
||||
msgid "File preview"
|
||||
msgstr "Pré-visualização do arquivo"
|
||||
|
||||
#: core/admin.py:300 core/admin.py:450
|
||||
msgid "No owner"
|
||||
msgstr "Sem proprietário"
|
||||
|
||||
#: core/admin.py:303 core/admin.py:453
|
||||
msgid "Multiple owners"
|
||||
msgstr "Vários proprietários"
|
||||
|
||||
#: core/admin.py:316
|
||||
msgid "Resend notification to external service"
|
||||
msgstr "Reenviar notificação ao serviço externo"
|
||||
|
||||
#: core/admin.py:339
|
||||
#, python-format
|
||||
msgid "Failed to notify for recording %(id)s"
|
||||
msgstr "Falha ao notificar sobre a gravação %(id)s"
|
||||
|
||||
#: core/admin.py:347
|
||||
#, python-format
|
||||
msgid "Failed to notify for recording %(id)s: %(error)s"
|
||||
msgstr "Falha ao notificar sobre a gravação %(id)s: %(error)s"
|
||||
|
||||
#: core/admin.py:355
|
||||
#, python-format
|
||||
msgid "Successfully sent notifications for %(count)s recording(s)."
|
||||
msgstr "Notificações enviadas com sucesso para %(count)s gravação(ões)."
|
||||
|
||||
#: core/admin.py:363
|
||||
#, python-format
|
||||
msgid "Skipped %(count)s expired recording(s)."
|
||||
msgstr "%(count)s gravação(ões) expirada(s) ignorada(s)."
|
||||
|
||||
#: core/admin.py:368
|
||||
msgid "Mark selected recordings as 'Failed to Stop'"
|
||||
msgstr "Marcar gravações selecionadas como 'Falha ao parar'"
|
||||
|
||||
#: core/admin.py:386
|
||||
#, python-format
|
||||
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
|
||||
msgstr "%(count)s gravação(ões) marcada(s) com sucesso como 'Falha ao parar'."
|
||||
|
||||
#: core/admin.py:394
|
||||
#, python-format
|
||||
msgid "Skipped %(count)s recording(s) with an ineligible status."
|
||||
msgstr "%(count)s gravação(ões) com status inelegível ignorada(s)."
|
||||
|
||||
#: core/admin.py:517
|
||||
msgid "No scopes"
|
||||
msgstr "Sem escopos"
|
||||
|
||||
#: core/admin.py:519
|
||||
msgid "Scopes"
|
||||
msgstr "Escopos"
|
||||
|
||||
#: core/api/filters.py:25
|
||||
msgid "Creator is me"
|
||||
msgstr "Criado por mim"
|
||||
|
||||
#: core/api/serializers.py:108
|
||||
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||
msgstr "Você deve ser administrador ou proprietário de uma sala para adicionar acessos a ela."
|
||||
|
||||
#: core/api/serializers.py:560
|
||||
msgid "This file extension is not allowed."
|
||||
msgstr "Esta extensão de arquivo não é permitida."
|
||||
|
||||
#: core/api/viewsets.py:1268
|
||||
msgid "You have reached the maximum number of files for this type."
|
||||
msgstr "Você atingiu o número máximo de arquivos para este tipo."
|
||||
|
||||
#: core/models.py:38
|
||||
msgid "Member"
|
||||
msgstr "Membro"
|
||||
|
||||
#: core/models.py:39
|
||||
msgid "Administrator"
|
||||
msgstr "Administrador"
|
||||
|
||||
#: core/models.py:40
|
||||
msgid "Owner"
|
||||
msgstr "Proprietário"
|
||||
|
||||
#: core/models.py:56
|
||||
msgid "Initiated"
|
||||
msgstr "Iniciada"
|
||||
|
||||
#: core/models.py:57
|
||||
msgid "Active"
|
||||
msgstr "Ativa"
|
||||
|
||||
#: core/models.py:58
|
||||
msgid "Stopped"
|
||||
msgstr "Parada"
|
||||
|
||||
#: core/models.py:59
|
||||
msgid "Saved"
|
||||
msgstr "Salva"
|
||||
|
||||
#: core/models.py:60
|
||||
msgid "Aborted"
|
||||
msgstr "Cancelada"
|
||||
|
||||
#: core/models.py:61
|
||||
msgid "Failed to Start"
|
||||
msgstr "Falha ao iniciar"
|
||||
|
||||
#: core/models.py:62
|
||||
msgid "Failed to Stop"
|
||||
msgstr "Falha ao parar"
|
||||
|
||||
#: core/models.py:63
|
||||
msgid "Notification succeeded"
|
||||
msgstr "Notificação enviada com sucesso"
|
||||
|
||||
#: core/models.py:66
|
||||
msgid "External process successful"
|
||||
msgstr "Processo externo concluído com sucesso"
|
||||
|
||||
#: core/models.py:68
|
||||
msgid "External process failed"
|
||||
msgstr "Falha no processo externo"
|
||||
|
||||
#: core/models.py:97
|
||||
msgid "SCREEN_RECORDING"
|
||||
msgstr "GRAVAÇÃO_DE_TELA"
|
||||
|
||||
#: core/models.py:98
|
||||
msgid "TRANSCRIPT"
|
||||
msgstr "TRANSCRIÇÃO"
|
||||
|
||||
#: core/models.py:104
|
||||
msgid "Public Access"
|
||||
msgstr "Acesso público"
|
||||
|
||||
#: core/models.py:105
|
||||
msgid "Trusted Access"
|
||||
msgstr "Acesso confiável"
|
||||
|
||||
#: core/models.py:106
|
||||
msgid "Restricted Access"
|
||||
msgstr "Acesso restrito"
|
||||
|
||||
#: core/models.py:118
|
||||
msgid "id"
|
||||
msgstr "id"
|
||||
|
||||
#: core/models.py:119
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "chave primária do registro como UUID"
|
||||
|
||||
#: core/models.py:125
|
||||
msgid "created on"
|
||||
msgstr "criado em"
|
||||
|
||||
#: core/models.py:126
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "data e hora em que o registro foi criado"
|
||||
|
||||
#: core/models.py:131
|
||||
msgid "updated on"
|
||||
msgstr "atualizado em"
|
||||
|
||||
#: core/models.py:132
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr "data e hora da última atualização do registro"
|
||||
|
||||
#: core/models.py:150
|
||||
msgid "sub"
|
||||
msgstr "sub"
|
||||
|
||||
#: core/models.py:152
|
||||
msgid ""
|
||||
"Optional for pending users; required upon account activation. 255 characters "
|
||||
"or fewer. Printable ASCII characters only."
|
||||
msgstr ""
|
||||
"Opcional para usuários pendentes; obrigatório na ativação da conta. Até 255 "
|
||||
"caracteres. Apenas caracteres ASCII imprimíveis."
|
||||
|
||||
#: core/validators.py:22
|
||||
msgid "Enter a valid sub. This value should be printable ASCII only."
|
||||
msgstr "Insira um sub válido. Este valor deve conter apenas caracteres ASCII imprimíveis."
|
||||
|
||||
#: core/models.py:161
|
||||
msgid "identity email address"
|
||||
msgstr "endereço de e-mail de identidade"
|
||||
|
||||
#: core/models.py:166
|
||||
msgid "admin email address"
|
||||
msgstr "endereço de e-mail administrativo"
|
||||
|
||||
#: core/models.py:168
|
||||
msgid "full name"
|
||||
msgstr "nome completo"
|
||||
|
||||
#: core/models.py:170
|
||||
msgid "short name"
|
||||
msgstr "nome curto"
|
||||
|
||||
#: core/models.py:176
|
||||
msgid "language"
|
||||
msgstr "idioma"
|
||||
|
||||
#: core/models.py:177
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "O idioma no qual o usuário deseja visualizar a interface."
|
||||
|
||||
#: core/models.py:183
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "O fuso horário no qual o usuário deseja visualizar os horários."
|
||||
|
||||
#: core/models.py:190
|
||||
msgid "default room access level"
|
||||
msgstr "nível de acesso padrão da sala"
|
||||
|
||||
#: core/models.py:192
|
||||
msgid ""
|
||||
"Access level applied by default to new rooms created by this user. When "
|
||||
"empty, the instance default is used."
|
||||
msgstr ""
|
||||
"Nível de acesso aplicado por padrão às novas salas criadas por este usuário. "
|
||||
"Quando vazio, o padrão da instância é utilizado."
|
||||
|
||||
#: core/models.py:199
|
||||
msgid "default room configuration"
|
||||
msgstr "configuração padrão da sala"
|
||||
|
||||
#: core/models.py:201
|
||||
msgid "Configurations applied by default to new rooms created by this user."
|
||||
msgstr "Configurações aplicadas por padrão às novas salas criadas por este usuário."
|
||||
|
||||
#: core/models.py:205
|
||||
msgid "device"
|
||||
msgstr "dispositivo"
|
||||
|
||||
#: core/models.py:207
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Define se o usuário é um dispositivo ou um usuário real."
|
||||
|
||||
#: core/models.py:210
|
||||
msgid "staff status"
|
||||
msgstr "status da equipe"
|
||||
|
||||
#: core/models.py:212
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Define se o usuário pode acessar este painel de administração."
|
||||
|
||||
#: core/models.py:215
|
||||
msgid "active"
|
||||
msgstr "ativo"
|
||||
|
||||
#: core/models.py:218
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
msgstr ""
|
||||
"Define se este usuário deve ser tratado como ativo. Desmarque esta opção em "
|
||||
"vez de excluir contas."
|
||||
|
||||
#: core/models.py:231
|
||||
msgid "user"
|
||||
msgstr "usuário"
|
||||
|
||||
#: core/models.py:232
|
||||
msgid "users"
|
||||
msgstr "usuários"
|
||||
|
||||
#: core/models.py:298
|
||||
msgid "Resource"
|
||||
msgstr "Recurso"
|
||||
|
||||
#: core/models.py:299
|
||||
msgid "Resources"
|
||||
msgstr "Recursos"
|
||||
|
||||
#: core/models.py:357
|
||||
msgid "Resource access"
|
||||
msgstr "Acesso a recurso"
|
||||
|
||||
#: core/models.py:358
|
||||
msgid "Resource accesses"
|
||||
msgstr "Acessos a recursos"
|
||||
|
||||
#: core/models.py:364
|
||||
msgid "Resource access with this User and Resource already exists."
|
||||
msgstr "Já existe um acesso para este usuário e este recurso."
|
||||
|
||||
#: core/models.py:421
|
||||
msgid "Visio room configuration"
|
||||
msgstr "Configuração da sala de videoconferência"
|
||||
|
||||
#: core/models.py:422
|
||||
msgid "Values for Visio parameters to configure the room."
|
||||
msgstr "Valores dos parâmetros de videoconferência para configurar a sala."
|
||||
|
||||
#: core/models.py:429
|
||||
msgid "Room PIN code"
|
||||
msgstr "Código PIN da sala"
|
||||
|
||||
#: core/models.py:430
|
||||
msgid "Unique n-digit code that identifies this room in telephony mode."
|
||||
msgstr "Código único de n dígitos que identifica esta sala no modo telefonia."
|
||||
|
||||
#: core/models.py:436 core/models.py:597
|
||||
msgid "Room"
|
||||
msgstr "Sala"
|
||||
|
||||
#: core/models.py:437
|
||||
msgid "Rooms"
|
||||
msgstr "Salas"
|
||||
|
||||
#: core/models.py:608
|
||||
msgid "Worker ID"
|
||||
msgstr "ID do worker"
|
||||
|
||||
#: core/models.py:610
|
||||
msgid ""
|
||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||
"the worker stops, allowing for easy tracking."
|
||||
msgstr ""
|
||||
"Insira um identificador para a gravação do worker. Este ID é mantido mesmo "
|
||||
"quando o worker para, facilitando o rastreamento."
|
||||
|
||||
#: core/models.py:618
|
||||
msgid "Recording mode"
|
||||
msgstr "Modo de gravação"
|
||||
|
||||
#: core/models.py:619
|
||||
msgid "Defines the mode of recording being called."
|
||||
msgstr "Define o modo de gravação utilizado."
|
||||
|
||||
#: core/models.py:624 core/models.py:625
|
||||
msgid "Recording options"
|
||||
msgstr "Opções de gravação"
|
||||
|
||||
#: core/models.py:632
|
||||
msgid "External Process ID"
|
||||
msgstr "ID do processo externo"
|
||||
|
||||
#: core/models.py:633
|
||||
msgid "ID of the external process associated with the recording."
|
||||
msgstr "ID do processo externo associado à gravação."
|
||||
|
||||
#: core/models.py:639
|
||||
msgid "Recording"
|
||||
msgstr "Gravação"
|
||||
|
||||
#: core/models.py:640
|
||||
msgid "Recordings"
|
||||
msgstr "Gravações"
|
||||
|
||||
#: core/models.py:750
|
||||
msgid "Recording/user relation"
|
||||
msgstr "Relação gravação/usuário"
|
||||
|
||||
#: core/models.py:751
|
||||
msgid "Recording/user relations"
|
||||
msgstr "Relações gravação/usuário"
|
||||
|
||||
#: core/models.py:757
|
||||
msgid "This user is already in this recording."
|
||||
msgstr "Este usuário já está nesta gravação."
|
||||
|
||||
#: core/models.py:763
|
||||
msgid "This team is already in this recording."
|
||||
msgstr "Esta equipe já está nesta gravação."
|
||||
|
||||
#: core/models.py:769
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Deve ser definido o usuário ou a equipe, mas não ambos."
|
||||
|
||||
#: core/models.py:786
|
||||
msgid "Create rooms"
|
||||
msgstr "Criar salas"
|
||||
|
||||
#: core/models.py:787
|
||||
msgid "List rooms"
|
||||
msgstr "Listar salas"
|
||||
|
||||
#: core/models.py:788
|
||||
msgid "Retrieve room details"
|
||||
msgstr "Obter detalhes da sala"
|
||||
|
||||
#: core/models.py:789
|
||||
msgid "Update rooms"
|
||||
msgstr "Atualizar salas"
|
||||
|
||||
#: core/models.py:790
|
||||
msgid "Delete rooms"
|
||||
msgstr "Excluir salas"
|
||||
|
||||
#: core/models.py:803
|
||||
msgid "Application name"
|
||||
msgstr "Nome da aplicação"
|
||||
|
||||
#: core/models.py:804
|
||||
msgid "Descriptive name for this application."
|
||||
msgstr "Nome descritivo para esta aplicação."
|
||||
|
||||
#: core/models.py:814
|
||||
msgid "Hashed on Save. Copy it now if this is a new secret."
|
||||
msgstr "Criptografado ao salvar. Copie agora se este for um novo segredo."
|
||||
|
||||
#: core/models.py:825
|
||||
msgid "Application"
|
||||
msgstr "Aplicação"
|
||||
|
||||
#: core/models.py:826
|
||||
msgid "Applications"
|
||||
msgstr "Aplicações"
|
||||
|
||||
#: core/models.py:849
|
||||
msgid "Enter a valid domain"
|
||||
msgstr "Insira um domínio válido"
|
||||
|
||||
#: core/models.py:852
|
||||
msgid "Domain"
|
||||
msgstr "Domínio"
|
||||
|
||||
#: core/models.py:853
|
||||
msgid "Email domain this application can act on behalf of."
|
||||
msgstr "Domínio de e-mail em nome do qual esta aplicação pode atuar."
|
||||
|
||||
#: core/models.py:865
|
||||
msgid "Application domain"
|
||||
msgstr "Domínio da aplicação"
|
||||
|
||||
#: core/models.py:866
|
||||
msgid "Application domains"
|
||||
msgstr "Domínios da aplicação"
|
||||
|
||||
#: core/models.py:884
|
||||
msgid "Pending"
|
||||
msgstr "Pendente"
|
||||
|
||||
#: core/models.py:885
|
||||
msgid "Analyzing"
|
||||
msgstr "Analisando"
|
||||
|
||||
#: core/models.py:892
|
||||
msgid "Ready"
|
||||
msgstr "Pronto"
|
||||
|
||||
#: core/models.py:898
|
||||
msgid "Background image"
|
||||
msgstr "Imagem de fundo"
|
||||
|
||||
#: core/models.py:910
|
||||
msgid "title"
|
||||
msgstr "título"
|
||||
|
||||
#: core/models.py:934
|
||||
msgid "Malware detection info when the analysis status is unsafe."
|
||||
msgstr "Informações de detecção de malware quando o status da análise não for seguro."
|
||||
|
||||
#: core/models.py:939
|
||||
msgid "File"
|
||||
msgstr "Arquivo"
|
||||
|
||||
#: core/models.py:940
|
||||
msgid "Files"
|
||||
msgstr "Arquivos"
|
||||
|
||||
#: core/models.py:1060
|
||||
msgid "This file is already hard deleted."
|
||||
msgstr "Este arquivo já foi excluído permanentemente."
|
||||
|
||||
#: core/models.py:1070
|
||||
msgid "To hard delete a file, it must first be soft deleted."
|
||||
msgstr "Para excluir permanentemente um arquivo, ele deve primeiro ser marcado para exclusão."
|
||||
|
||||
#: core/recording/event/notification.py:124
|
||||
msgid "Your recording is ready"
|
||||
msgstr "Sua gravação está pronta"
|
||||
|
||||
#: core/recording/event/notification.py:195
|
||||
msgid "Transcription"
|
||||
msgstr "Transcrição"
|
||||
|
||||
#: core/recording/event/notification.py:206
|
||||
#, python-brace-format
|
||||
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
|
||||
msgstr "Reunião \"{room}\" em {room_recording_date} às {room_recording_time}"
|
||||
|
||||
#: core/services/invitation.py:44
|
||||
#, python-brace-format
|
||||
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
||||
msgstr "Videochamada em andamento: {sender.email} está aguardando você se conectar"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:151
|
||||
#: core/templates/mail/html/screen_recording.html:151
|
||||
#: core/templates/mail/text/invitation.txt:4
|
||||
#: core/templates/mail/text/screen_recording.txt:4
|
||||
msgid "Logo email"
|
||||
msgstr "Logotipo do e-mail"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:181
|
||||
#: core/templates/mail/text/invitation.txt:6
|
||||
msgid "invites you to join an ongoing video call"
|
||||
msgstr "convida você para participar de uma videochamada em andamento"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:192
|
||||
#: core/templates/mail/text/invitation.txt:8
|
||||
msgid "JOIN THE CALL"
|
||||
msgstr "ENTRAR NA CHAMADA"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:219
|
||||
#: core/templates/mail/text/invitation.txt:14
|
||||
msgid ""
|
||||
"If you can't click the button, copy and paste the URL into your browser to "
|
||||
"join the call."
|
||||
msgstr ""
|
||||
"Se não conseguir clicar no botão, copie e cole a URL no seu navegador para "
|
||||
"entrar na chamada."
|
||||
|
||||
#: core/templates/mail/html/invitation.html:227
|
||||
#: core/templates/mail/text/invitation.txt:16
|
||||
msgid "Tips for a better experience:"
|
||||
msgstr "Dicas para uma melhor experiência:"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:229
|
||||
#: core/templates/mail/text/invitation.txt:18
|
||||
msgid "Use Chrome or Firefox for better call quality"
|
||||
msgstr "Use o Chrome ou o Firefox para melhor qualidade de chamada"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:230
|
||||
#: core/templates/mail/text/invitation.txt:19
|
||||
msgid "Test your microphone and camera before joining"
|
||||
msgstr "Teste seu microfone e sua câmera antes de entrar"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:231
|
||||
#: core/templates/mail/text/invitation.txt:20
|
||||
msgid "Make sure you have a stable internet connection"
|
||||
msgstr "Certifique-se de ter uma conexão de internet estável"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:240
|
||||
#: core/templates/mail/html/screen_recording.html:237
|
||||
#: core/templates/mail/text/invitation.txt:22
|
||||
#: core/templates/mail/text/screen_recording.txt:24
|
||||
#, python-format
|
||||
msgid " Thank you for using %(brandname)s. "
|
||||
msgstr " Obrigado por usar o %(brandname)s. "
|
||||
|
||||
#: core/templates/mail/html/invitation.html:271
|
||||
#, python-format
|
||||
msgid ""
|
||||
"This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
|
||||
msgstr ""
|
||||
"Este e-mail foi enviado para %(email)s por <a href=\"%(href)s\">%(name)s</a>"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:180
|
||||
#: core/templates/mail/text/screen_recording.txt:7
|
||||
msgid "Your recording is ready!"
|
||||
msgstr "Sua gravação está pronta!"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:187
|
||||
#: core/templates/mail/text/screen_recording.txt:9
|
||||
#, python-format
|
||||
msgid ""
|
||||
" Your recording of \"%(room_name)s\" on %(recording_date)s at "
|
||||
"%(recording_time)s is now ready to download. "
|
||||
msgstr ""
|
||||
" Sua gravação de \"%(room_name)s\" em %(recording_date)s às "
|
||||
"%(recording_time)s já está pronta para download. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:187
|
||||
#: core/templates/mail/text/screen_recording.txt:9
|
||||
#, python-format
|
||||
msgid " The recording will expire in %(days)s days. "
|
||||
msgstr " A gravação expirará em %(days)s dias. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:192
|
||||
#: core/templates/mail/text/screen_recording.txt:10
|
||||
msgid ""
|
||||
" Sharing the recording via link is not yet available. Only organizers can "
|
||||
"download it. "
|
||||
msgstr ""
|
||||
" O compartilhamento da gravação por link ainda não está disponível. Apenas "
|
||||
"os organizadores podem baixá-la. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:198
|
||||
#: core/templates/mail/text/screen_recording.txt:12
|
||||
msgid "To keep this recording permanently:"
|
||||
msgstr "Para manter esta gravação permanentemente:"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:200
|
||||
#, python-format
|
||||
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||
msgstr "Clique no link \"<a href=\"%(link)s\">Abrir</a>\" abaixo "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:201
|
||||
#: core/templates/mail/text/screen_recording.txt:15
|
||||
msgid "Use the \"Download\" button in the interface "
|
||||
msgstr "Use o botão \"Baixar\" na interface "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:202
|
||||
#: core/templates/mail/text/screen_recording.txt:16
|
||||
msgid "Save the file to your preferred location"
|
||||
msgstr "Salve o arquivo no local de sua preferência"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:213
|
||||
#: core/templates/mail/text/screen_recording.txt:18
|
||||
msgid "Open"
|
||||
msgstr "Abrir"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:222
|
||||
#: core/templates/mail/text/screen_recording.txt:20
|
||||
#, python-format
|
||||
msgid ""
|
||||
" If you have any questions or need assistance, please contact our support "
|
||||
"team at %(support_email)s. "
|
||||
msgstr ""
|
||||
" Se tiver alguma dúvida ou precisar de ajuda, entre em contato com nossa "
|
||||
"equipe de suporte em %(support_email)s. "
|
||||
|
||||
#: core/templates/mail/text/invitation.txt:24
|
||||
#, python-format
|
||||
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
|
||||
msgstr "Este e-mail foi enviado para %(email)s por %(name)s [%(href)s]"
|
||||
|
||||
#: core/templates/mail/text/screen_recording.txt:14
|
||||
#, python-format
|
||||
msgid "Click the \"Open [%(link)s]\" link below "
|
||||
msgstr "Clique no link \"Abrir [%(link)s]\" abaixo "
|
||||
|
||||
#: meet/settings.py:238
|
||||
msgid "English"
|
||||
msgstr "Inglês"
|
||||
|
||||
#: meet/settings.py:239
|
||||
msgid "French"
|
||||
msgstr "Francês"
|
||||
|
||||
#: meet/settings.py:240
|
||||
msgid "Dutch"
|
||||
msgstr "Holandês"
|
||||
|
||||
#: meet/settings.py:241
|
||||
msgid "German"
|
||||
msgstr "Alemão"
|
||||
|
||||
#: meet/settings.py:242
|
||||
msgid "Spanish"
|
||||
msgstr "Espanhol"
|
||||
|
||||
#: meet/settings.py:243
|
||||
msgid "Portuguese"
|
||||
msgstr "Português"
|
||||
@@ -302,6 +302,7 @@ class Base(Configuration):
|
||||
("nl-nl", _("Dutch")),
|
||||
("de-de", _("German")),
|
||||
("es-es", _("Spanish")),
|
||||
("pt-br", _("Portuguese")),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -616,6 +617,14 @@ class Base(Configuration):
|
||||
environ_name="OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION",
|
||||
environ_prefix=None,
|
||||
)
|
||||
# WARNING: Enabling this setting allows multiple user accounts to share the same email
|
||||
# address. This may cause security issues and is not recommended for production use when
|
||||
# email is activated as fallback for identification
|
||||
OIDC_ALLOW_DUPLICATE_EMAILS = values.BooleanValue(
|
||||
default=False,
|
||||
environ_name="OIDC_ALLOW_DUPLICATE_EMAILS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
OIDC_USER_SUB_FIELD_IMMUTABLE = values.BooleanValue(
|
||||
default=True, environ_name="OIDC_USER_SUB_FIELD_IMMUTABLE", environ_prefix=None
|
||||
)
|
||||
@@ -1656,6 +1665,15 @@ class Base(Configuration):
|
||||
if cls.LIVEKIT_API_TIMEOUT_SECONDS < 1:
|
||||
raise ValueError("LIVEKIT_API_TIMEOUT_SECONDS must be at least 1")
|
||||
|
||||
if (
|
||||
cls.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION
|
||||
and cls.OIDC_ALLOW_DUPLICATE_EMAILS
|
||||
):
|
||||
raise ValueError(
|
||||
"Both OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION and "
|
||||
"OIDC_ALLOW_DUPLICATE_EMAILS cannot be set to True simultaneously."
|
||||
)
|
||||
|
||||
if (
|
||||
cls.SUMMARY_SERVICE_VERSION == 1
|
||||
and cls.SUMMARY_SERVICE_ENDPOINT is not None
|
||||
|
||||
@@ -47,14 +47,10 @@ ENV VITE_MEDIA_BASE_URL=${VITE_MEDIA_BASE_URL}
|
||||
RUN npm run build
|
||||
|
||||
# ---- Front-end image ----
|
||||
FROM nginxinc/nginx-unprivileged:1.30.6-alpine3.24 AS frontend-production
|
||||
FROM nginx/nginx-unprivileged:1.30.6-alpine3.24 AS frontend-production
|
||||
|
||||
USER root
|
||||
RUN apk upgrade --no-cache libexpat && \
|
||||
apk add --no-cache --upgrade 'pcre2>=10.49-r0' 'tiff>=4.7.2-r0' \
|
||||
'libx11>=1.8.14-r0' 'ca-certificates>=20261020-r0' \
|
||||
'brotli-libs>=1.2.1-r0' 'musl-utils>=1.2.6-r3' && \
|
||||
apk del curl && \
|
||||
RUN apk del curl && \
|
||||
rm -f /usr/share/nginx/html/50x.html
|
||||
USER nginx
|
||||
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
"input": ["src/**/*.{ts,tsx}", "!src/styled-system/**/*", "!src/**/*.d.ts"],
|
||||
"output": "src/locales/$LOCALE/$NAMESPACE.json",
|
||||
"createOldCatalogs": false,
|
||||
"locales": ["en", "fr", "de", "nl", "es"],
|
||||
"locales": ["en", "fr", "de", "nl", "es", "pt"],
|
||||
"sort": true
|
||||
}
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Field, Ul, H, P, Form, Dialog } from '@/primitives'
|
||||
import { navigateTo } from '@/navigation/navigateTo'
|
||||
import { isRoomValid } from '@/features/rooms'
|
||||
import { extractRoomId } from '@/features/rooms'
|
||||
|
||||
export const JoinMeetingDialog = () => {
|
||||
const { t } = useTranslation('home')
|
||||
|
||||
const handleSubmit = (data: { roomId?: FormDataEntryValue }) => {
|
||||
const roomId = (data.roomId as string)
|
||||
.trim()
|
||||
.replace(`${window.location.origin}/`, '')
|
||||
navigateTo('room', roomId)
|
||||
const extracted = extractRoomId(data.roomId as string)
|
||||
if (!extracted) return
|
||||
navigateTo('room', `${extracted.roomId}${extracted.suffix}`)
|
||||
}
|
||||
|
||||
const validateRoomId = (value: string) => {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return null
|
||||
return !isRoomValid(trimmed) ? (
|
||||
return !extractRoomId(trimmed) ? (
|
||||
<>
|
||||
<p>{t('joinInputError')}</p>
|
||||
<Ul>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export {
|
||||
roomIdPattern,
|
||||
isRoomValid,
|
||||
extractRoomId,
|
||||
flexibleRoomIdPattern,
|
||||
} from './utils/isRoomValid'
|
||||
export { generateRoomId } from './utils/generateRoomId'
|
||||
|
||||
@@ -1,5 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { normalizeRoomId } from './isRoomValid'
|
||||
import { extractRoomId, normalizeRoomId } from './isRoomValid'
|
||||
|
||||
describe('extractRoomId', () => {
|
||||
const origin = 'https://meet.example.com'
|
||||
|
||||
const plain = { roomId: 'abc-defg-hij', suffix: '' }
|
||||
|
||||
it('accepts a bare room id', () => {
|
||||
expect(extractRoomId('abc-defg-hij', origin)).toEqual(plain)
|
||||
expect(extractRoomId(' abc-defg-hij ', origin)).toEqual(plain)
|
||||
})
|
||||
|
||||
it('accepts a meeting URL on this origin', () => {
|
||||
expect(extractRoomId(`${origin}/abc-defg-hij`, origin)).toEqual(plain)
|
||||
expect(extractRoomId(`${origin}/abc-defg-hij/`, origin)).toEqual(plain)
|
||||
})
|
||||
|
||||
it('keeps query strings and fragments in the suffix', () => {
|
||||
expect(
|
||||
extractRoomId(`${origin}/abc-defg-hij?from=outlook-addin`, origin)
|
||||
).toEqual({ roomId: 'abc-defg-hij', suffix: '?from=outlook-addin' })
|
||||
expect(
|
||||
extractRoomId(
|
||||
`${origin}/abc-defg-hij/?utm_source=mail&utm_medium=email#x`,
|
||||
origin
|
||||
)
|
||||
).toEqual({
|
||||
roomId: 'abc-defg-hij',
|
||||
suffix: '?utm_source=mail&utm_medium=email#x',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects other origins and invalid ids', () => {
|
||||
expect(
|
||||
extractRoomId('https://evil.example/abc-defg-hij', origin)
|
||||
).toBeNull()
|
||||
expect(extractRoomId(`${origin}/not-a-room`, origin)).toBeNull()
|
||||
expect(extractRoomId(`${origin}/abc-defg-hij/extra`, origin)).toBeNull()
|
||||
expect(extractRoomId('abc-defg-hij?from=x', origin)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeRoomId', () => {
|
||||
it('lowercases and re-inserts the hyphens of a ten-letter id', () => {
|
||||
|
||||
@@ -10,6 +10,28 @@ export const isRoomValid = (roomIdOrUrl: string) =>
|
||||
roomRegex.test(roomIdOrUrl) ||
|
||||
new RegExp(`^${window.location.origin}/${roomIdPattern}$`).test(roomIdOrUrl)
|
||||
|
||||
export const extractRoomId = (
|
||||
input: string,
|
||||
origin: string = window.location.origin
|
||||
): { roomId: string; suffix: string } | null => {
|
||||
const value = input.trim()
|
||||
if (roomRegex.test(value)) return { roomId: value, suffix: '' }
|
||||
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(value)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (url.origin !== origin) return null
|
||||
|
||||
const path = url.pathname.slice(1)
|
||||
const roomId = path.endsWith('/') ? path.slice(0, -1) : path
|
||||
return roomRegex.test(roomId)
|
||||
? { roomId, suffix: `${url.search}${url.hash}` }
|
||||
: null
|
||||
}
|
||||
|
||||
export const normalizeRoomId = (roomId: string) => {
|
||||
const cleanId = roomId.toLowerCase().replace(/-/g, '')
|
||||
if (cleanId.length === 10) {
|
||||
|
||||
@@ -15,7 +15,7 @@ i18n
|
||||
.use(initReactI18next)
|
||||
.use(LanguageDetector)
|
||||
.init({
|
||||
supportedLngs: ['en', 'fr', 'nl', 'de', 'es'],
|
||||
supportedLngs: ['en', 'fr', 'nl', 'de', 'es', 'pt'],
|
||||
fallbackLng,
|
||||
ns: i18nDefaultNamespace,
|
||||
detection: {
|
||||
|
||||
@@ -6,6 +6,7 @@ const languageLabels: Record<string, string> = {
|
||||
de: 'Deutsch',
|
||||
nl: 'Nederlands',
|
||||
es: 'Español',
|
||||
pt: 'Português',
|
||||
}
|
||||
|
||||
export const useLanguageLabels = () => {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"accessibility": {
|
||||
"title": "Acessibilidade",
|
||||
"introduction": "O <i>Visio</i> está comprometido em tornar seus serviços digitais acessíveis, em conformidade com o Artigo 47 da Lei nº 2005-102, de 11 de fevereiro de 2005.",
|
||||
"declaration": {
|
||||
"title": "Declaração de Acessibilidade",
|
||||
"date": "Estabelecida em 4 de dezembro de 2024."
|
||||
},
|
||||
"scope": "Esta declaração de acessibilidade aplica-se ao site visio.numerique.gouv.fr",
|
||||
"complianceStatus": {
|
||||
"title": "Status de Conformidade",
|
||||
"body": "O site visio.numerique.gouv.fr não está em conformidade com o RGAA 4.1. O site ainda não foi auditado. No entanto, a equipe se esforça para criar um site acessível a todos, seguindo as recomendações do RGAA."
|
||||
},
|
||||
"improvement": {
|
||||
"title": "Melhoria e Contato",
|
||||
"body": "Se você não conseguir acessar um conteúdo ou serviço, pode entrar em contato com o responsável por lasuite.numerique.gouv.fr para ser direcionado a uma alternativa acessível ou obter o conteúdo de outra forma.",
|
||||
"contact": {
|
||||
"email": "E-mail: visio@numerique.gouv.fr",
|
||||
"address": "Endereço: DINUM, 20 avenue de Ségur 75007 Paris"
|
||||
},
|
||||
"response": "Tentamos responder em até 2 dias úteis."
|
||||
},
|
||||
"recourse": {
|
||||
"title": "Meios de Recurso",
|
||||
"introduction": "Este procedimento deve ser utilizado no seguinte caso: você relatou ao responsável pelo site um problema de acessibilidade que o impede de acessar um conteúdo ou um dos serviços do portal e não recebeu uma resposta satisfatória.",
|
||||
"options": {
|
||||
"intro": "Você pode:",
|
||||
"option1": "Enviar uma mensagem ao Defensor dos Direitos",
|
||||
"option2": "Entrar em contato com o delegado do Defensor dos Direitos em sua região",
|
||||
"option3": "Enviar uma carta pelo correio (gratuito, não precisa de selo): </br> Défenseur des droits Libre réponse 71120 75342 Paris CEDEX 07"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"title": "Testar sua configuração",
|
||||
"runTest": "Iniciar teste",
|
||||
"runAgain": "Testar novamente",
|
||||
"cancel": "Cancelar",
|
||||
"detailsFor": "Detalhes de {{step}}",
|
||||
"downloadReport": "Baixar relatório",
|
||||
"homeLink": "Testar sua configuração",
|
||||
"progress": "{{done}}/{{total}}",
|
||||
"progressLabel": "Progresso do teste de conexão",
|
||||
"groups": {
|
||||
"local": "Navegador e dispositivos",
|
||||
"network": "Conectividade com o servidor"
|
||||
},
|
||||
"steps": {
|
||||
"browser": "Navegador",
|
||||
"microphone": "Microfone",
|
||||
"camera": "Câmera",
|
||||
"devices": "Dispositivos de mídia",
|
||||
"websocket": "WebSocket",
|
||||
"webrtc": "WebRTC",
|
||||
"turn": "TURN",
|
||||
"reconnect": "Reconexão",
|
||||
"selectedCandidate": "Rota selecionada",
|
||||
"publishAudio": "Publicação de áudio",
|
||||
"publishVideo": "Publicação de vídeo"
|
||||
},
|
||||
"status": {
|
||||
"pending": "Pendente",
|
||||
"running": "Em execução…",
|
||||
"success": "Aprovado",
|
||||
"warning": "Não ideal",
|
||||
"failed": "Falhou",
|
||||
"skipped": "Ignorado"
|
||||
},
|
||||
"counts": {
|
||||
"passed": "aprovados",
|
||||
"warnings": "não ideais",
|
||||
"failed": "falharam",
|
||||
"skipped": "ignorados"
|
||||
},
|
||||
"summary": {
|
||||
"idle": "Pronto para testar",
|
||||
"idleHint": "O teste leva cerca de um minuto. Sua câmera e microfone são usados apenas durante a execução.",
|
||||
"running": "Testando…",
|
||||
"runningHint": "Mantenha esta página aberta até que todas as verificações terminem.",
|
||||
"passed": "Tudo funciona perfeitamente",
|
||||
"passedHint": "Seu navegador, seus dispositivos e sua rede estão prontos para uma reunião.",
|
||||
"partial": "Parcialmente testado",
|
||||
"partialHint": "Algumas verificações foram ignoradas. Permita o acesso à sua câmera e ao microfone para testá-los.",
|
||||
"warning": "Conexão abaixo do ideal",
|
||||
"warningHint": "Você pode participar das reuniões, mas a qualidade de vídeo e áudio pode ser reduzida devido às configurações da sua rede. Sua equipe de TI pode melhorar isso.",
|
||||
"warningDocLink": "Requisitos de rede para sua equipe de TI",
|
||||
"warningDocLinkAriaLabel": "Abrir os requisitos de rede para sua equipe de TI - abre em uma nova janela",
|
||||
"failed_one": "{{count}} verificação falhou",
|
||||
"failed_other": "{{count}} verificações falharam",
|
||||
"failedHint": "Abra as verificações que falharam abaixo para ver os detalhes e envie o relatório para sua equipe de TI."
|
||||
},
|
||||
"help": {
|
||||
"firewall": "Se os testes de rede falharem, verifique as permissões do navegador e as regras de filtragem de rede (WebRTC, WebSocket, TURN) com sua equipe de TI."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"backToHome": "Voltar para a tela inicial",
|
||||
"cancel": "Cancelar",
|
||||
"closeDialog": "Fechar diálogo",
|
||||
"error": {
|
||||
"heading": "Ocorreu um erro ao carregar a página"
|
||||
},
|
||||
"feedback": {
|
||||
"context": "Sua opinião ajuda a melhorar o produto!",
|
||||
"cta": "Compartilhar sua opinião - nova aba"
|
||||
},
|
||||
"forbidden": {
|
||||
"heading": "Você não tem permissão para ver esta página"
|
||||
},
|
||||
"loading": "Carregando…",
|
||||
"loggedInUserTooltip": "Conectado como ",
|
||||
"login": {
|
||||
"buttonLabel": "Entrar",
|
||||
"proconnectButtonLabel": "Entrar com ProConnect",
|
||||
"proconnectLinkLabel": "O que é ProConnect? - nova aba",
|
||||
"proconnectLink": "O que é ProConnect?"
|
||||
},
|
||||
"logout": "Sair",
|
||||
"notFound": {
|
||||
"heading": "Verifique o código da reunião",
|
||||
"body": "Verifique se você digitou corretamente o código da reunião na URL. Exemplo:"
|
||||
},
|
||||
"selected": "selecionado",
|
||||
"submit": "OK",
|
||||
"support": {
|
||||
"buttonLabel": "Contatar suporte",
|
||||
"openError": "Não foi possível abrir o formulário de contato. Tente novamente mais tarde.",
|
||||
"sent": {
|
||||
"title": "Mensagem enviada",
|
||||
"body": "A equipe de suporte recebeu sua mensagem.",
|
||||
"close": "Fechar"
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"links": {
|
||||
"legifrance": "legifrance.gouv.fr",
|
||||
"infogouv": "info.gouv.fr",
|
||||
"servicepublic": "service-public.fr",
|
||||
"datagouv": "data.gouv.fr",
|
||||
"legalsTerms": "Aviso Legal",
|
||||
"data": "Dados Pessoais e Cookies",
|
||||
"accessibility": "Acessibilidade: não conforme",
|
||||
"connectionTest": "Testar sua configuração",
|
||||
"ariaLabel": "nova janela",
|
||||
"codeAnnotation": "Nosso código é aberto e disponível neste",
|
||||
"code": "Repositório de Código Aberto",
|
||||
"technicalDetails": "Aviso Técnico",
|
||||
"termsOfService": "Termos de serviço"
|
||||
},
|
||||
"mentions": "Salvo indicação em contrário, os conteúdos deste site estão disponíveis sob",
|
||||
"license": "licença etalab 2.0"
|
||||
},
|
||||
"loginHint": {
|
||||
"title": "Entre com sua conta",
|
||||
"body": "Em vez de esperar, entre com sua conta.",
|
||||
"button": {
|
||||
"ariaLabel": "Fechar a sugestão",
|
||||
"label": "OK"
|
||||
}
|
||||
},
|
||||
"skipLink": "Ir para o conteúdo principal",
|
||||
"clipboardContent": {
|
||||
"url": "Para entrar na videoconferência, clique neste link: {{roomUrl}}",
|
||||
"numberAndPin": "Para entrar por telefone, ligue para {{phoneNumber}} e digite o código: {{pinCode}}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"createMeeting": "Criar uma reunião",
|
||||
"heading": "Videoconferência Simples e Segura",
|
||||
"intro": "Comunique-se e trabalhe com facilidade, sem comprometer sua soberania",
|
||||
"joinInputError": "Use um link ou código de reunião. Exemplos:",
|
||||
"joinInputExample": "URL ou código de 10 letras",
|
||||
"joinInputLabel": "Link da reunião",
|
||||
"joinInputSubmit": "Entrar na reunião",
|
||||
"joinMeeting": "Entrar em uma reunião",
|
||||
"joinMeetingTipContent": "Você pode entrar em uma reunião colando o link completo na barra de endereços do navegador.",
|
||||
"joinMeetingTipHeading": "Você sabia?",
|
||||
"loginToCreateMeeting": "Faça login para criar uma reunião",
|
||||
"moreLinkLabel": "Saiba mais sobre {{appTitle}} - nova aba",
|
||||
"moreLink": "Saiba mais",
|
||||
"moreAbout": "sobre {{appTitle}}",
|
||||
"connectionTestLink": "Testar sua configuração",
|
||||
"createMenu": {
|
||||
"laterOption": "Criar uma reunião para depois",
|
||||
"instantOption": "Iniciar uma reunião instantânea"
|
||||
},
|
||||
"laterMeetingDialog": {
|
||||
"heading": "Seus dados de conexão",
|
||||
"description": "Compartilhe essas informações com os convidados. Eles poderão entrar na reunião sem precisar se autenticar. Esta reunião é permanente e pode ser reutilizada.",
|
||||
"permissions": "Pessoas com este link não precisam de sua permissão para entrar nesta reunião.",
|
||||
"copy": "Copiar informações",
|
||||
"copied": "Informações copiadas para a área de transferência",
|
||||
"copyUrl": "Copiar o link da reunião",
|
||||
"phone": {
|
||||
"call": "Ligar:",
|
||||
"pinCode": "Código:"
|
||||
}
|
||||
},
|
||||
"introSlider": {
|
||||
"previous": {
|
||||
"label": "Slide anterior",
|
||||
"labelWithPosition": "Slide anterior ({{current}} de {{total}})",
|
||||
"tooltip": "Slide anterior"
|
||||
},
|
||||
"next": {
|
||||
"label": "Próximo slide",
|
||||
"labelWithPosition": "Próximo slide ({{current}} de {{total}})",
|
||||
"tooltip": "Próximo slide"
|
||||
},
|
||||
"beta": {
|
||||
"text": "Participar do beta",
|
||||
"tooltip": "Preencher o formulário"
|
||||
},
|
||||
"slide1": {
|
||||
"title": "Mude para a simplicidade. Experimente agora!",
|
||||
"body": "Descubra uma solução intuitiva e acessível, projetada para todos os agentes públicos, seus parceiros e muito mais."
|
||||
},
|
||||
"slide2": {
|
||||
"title": "Faça chamadas em grupo sem limites",
|
||||
"body": "Reuniões sem limite de tempo, com comunicação fluida e de alta qualidade, independentemente do tamanho do grupo."
|
||||
},
|
||||
"slide3": {
|
||||
"title": "Transforme suas reuniões com IA",
|
||||
"body": "Obtenha transcrições precisas e acionáveis para aumentar sua produtividade. Recurso em beta — experimente agora!"
|
||||
},
|
||||
"carouselLabel": "Apresentação de slides introdutória",
|
||||
"slidePosition": "Slide {{current}} de {{total}}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"title": "Aviso Legal",
|
||||
"creator": {
|
||||
"title": "Editor",
|
||||
"body": "O serviço Visio é publicado pela Direção Interministerial do Digital do Estado (DINUM), localizada em:",
|
||||
"contact": {
|
||||
"title": "Informações de Contato",
|
||||
"address": "20 avenue de Ségur",
|
||||
"city": "75007 Paris",
|
||||
"phone": "Tel. da recepção: 01.71.21.01.70",
|
||||
"siret": "SIRET: 12000101100010 (secretaria-geral do governo)",
|
||||
"siren": "SIREN: 120 001 011"
|
||||
}
|
||||
},
|
||||
"director": {
|
||||
"title": "Diretor de Publicação",
|
||||
"body": "A diretora de publicação é Madame Stéphanie Schaer, Diretora Interministerial do Digital."
|
||||
},
|
||||
"hosting": {
|
||||
"title": "Hospedagem",
|
||||
"body": "O serviço é hospedado pela Outscale West-1 CloudGouv SecNumCloud, localizada na França."
|
||||
},
|
||||
"accessibility": {
|
||||
"title": "Acessibilidade",
|
||||
"body": "A conformidade com os padrões de acessibilidade digital é um objetivo futuro. O site ainda não foi auditado. No entanto, a equipe se esforça para criar um site acessível a todos, seguindo as recomendações do RGAA.",
|
||||
"more": "Para mais informações: ",
|
||||
"link": "link para a página de acessibilidade",
|
||||
"status": "Status de acessibilidade: não conforme"
|
||||
},
|
||||
"reuse": {
|
||||
"title": "Reutilização de Conteúdo e Links",
|
||||
"body1": "Salvo menção explícita de propriedade intelectual detida por terceiros, os conteúdos deste site são disponibilizados sob a ",
|
||||
"license": "licença aberta Etalab 2.0",
|
||||
"body2": "Você é livre, em especial, para reproduzir, copiar, modificar, extrair, transformar, comunicar, distribuir, redistribuir, publicar, transmitir e explorar os conteúdos, desde que mencione a fonte, a data de sua última atualização e não induza terceiros a erro quanto às informações neles contidas.",
|
||||
"body3": "Qualquer site público ou privado está autorizado a estabelecer, sem autorização prévia, um link (inclusive link direto) para as informações divulgadas neste site."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"defaultName": "Um colaborador",
|
||||
"joined": {
|
||||
"description": "{{name}} entrou na sala"
|
||||
},
|
||||
"raised": {
|
||||
"description": "{{name}} levantou a mão.",
|
||||
"cta": "Abrir lista de espera"
|
||||
},
|
||||
"muted": "{{name}} silenciou seu microfone. Nenhum participante pode ouvi-lo.",
|
||||
"openChat": "Abrir chat",
|
||||
"chatMessageReceived": "{{name}} diz: {{message}}",
|
||||
"lowerHand": {
|
||||
"auto": "Parece que você começou a falar, então sua mão será abaixada.",
|
||||
"dismiss": "Manter mão levantada"
|
||||
},
|
||||
"autoMuteLargeRoom": {
|
||||
"auto": "Você entrou em uma reunião grande. Seu microfone foi silenciado automaticamente.",
|
||||
"dismiss": "Ativar microfone"
|
||||
},
|
||||
"reaction": {
|
||||
"description": "{{name}} reagiu com {{emoji}}"
|
||||
},
|
||||
"permissionsRemoved": {
|
||||
"camera": "O anfitrião desativou o vídeo para todos os participantes.",
|
||||
"microphone": "O anfitrião desativou o microfone para todos os participantes.",
|
||||
"screen_share": "O anfitrião desativou o compartilhamento de tela para todos os participantes."
|
||||
},
|
||||
"waitingParticipants": {
|
||||
"one": "Uma pessoa quer entrar nesta chamada.",
|
||||
"several": "Várias pessoas querem entrar nesta chamada.",
|
||||
"open": "Abrir",
|
||||
"accept": "Aceitar"
|
||||
},
|
||||
"transcript": {
|
||||
"waitingForTracks": "A transcrição está aguardando um microfone. Ative seu microfone para que a transcrição possa começar.",
|
||||
"started": "{{name}} iniciou a transcrição da reunião.",
|
||||
"stopped": "{{name}} parou a transcrição da reunião.",
|
||||
"limitReached": "A transcrição excedeu a duração máxima permitida e será salva automaticamente.",
|
||||
"interrupted": "A transcrição foi interrompida inesperadamente; a parte já registrada será salva.",
|
||||
"failed": "A transcrição foi interrompida inesperadamente e não pôde ser salva.",
|
||||
"aborted": "Não foi possível iniciar a transcrição.",
|
||||
"requested": "{{name}} quer iniciar a transcrição da reunião."
|
||||
},
|
||||
"screenRecording": {
|
||||
"waitingForTracks": "A gravação está aguardando áudio ou vídeo. Ative seu microfone ou câmera, ou compartilhe sua tela, para que a gravação possa começar.",
|
||||
"started": "{{name}} iniciou a gravação da reunião.",
|
||||
"stopped": "{{name}} parou a gravação da reunião.",
|
||||
"limitReached": "A gravação excedeu a duração máxima permitida e será salva automaticamente.",
|
||||
"interrupted": "A gravação foi interrompida inesperadamente; a parte já gravada será salva.",
|
||||
"failed": "A gravação foi interrompida inesperadamente e não pôde ser salva.",
|
||||
"aborted": "Não foi possível iniciar a gravação.",
|
||||
"requested": "{{name}} quer iniciar a gravação da reunião."
|
||||
},
|
||||
"recordingSave": {
|
||||
"transcript": {
|
||||
"message": "Sua gravação está sendo salva! Enviaremos a transcrição para <strong>{{email}}</strong> assim que estiver pronta.",
|
||||
"default": "Sua gravação está sendo salva! Enviaremos a transcrição para seu email assim que estiver pronta."
|
||||
},
|
||||
"screenRecording": {
|
||||
"message": "Sua gravação está sendo salva! Enviaremos uma notificação para <strong>{{email}}</strong> assim que estiver pronta.",
|
||||
"default": "Sua gravação está sendo salva! Enviaremos uma notificação para seu email assim que estiver pronta."
|
||||
}
|
||||
},
|
||||
"roleChanged": {
|
||||
"body": "Seu papel mudou. Agora você é {{role}}.",
|
||||
"roles": {
|
||||
"owner": "Anfitrião",
|
||||
"administrator": "Co-anfitrião",
|
||||
"member": "Membro"
|
||||
}
|
||||
},
|
||||
"openMenu": "Abrir menu"
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"error": {
|
||||
"title": "Gravação indisponível",
|
||||
"body": "A gravação está indisponível ou pode ter sido excluída. Apenas o anfitrião da reunião pode acessá-la. Sinta-se à vontade para contatá-lo se necessário."
|
||||
},
|
||||
"expired": {
|
||||
"title": "Gravação expirada",
|
||||
"body": "Esta gravação foi excluída em {{date}}."
|
||||
},
|
||||
"authentication": {
|
||||
"title": "Autenticação necessária",
|
||||
"body": "Por favor, faça login para acessar esta gravação."
|
||||
},
|
||||
"unsaved": {
|
||||
"title": "Download Indisponível",
|
||||
"body": "Esta gravação ainda não está pronta para download. Tente novamente mais tarde."
|
||||
},
|
||||
"success": {
|
||||
"title": "Sua gravação está pronta!",
|
||||
"body": "Gravação da reunião {{room}} de {{created_at}}.",
|
||||
"expiration": "Atenção, esta gravação expirará após {{expiration_days}} dia(s).",
|
||||
"button": "Baixar",
|
||||
"warning": {
|
||||
"title": "Compartilhamento de link desativado",
|
||||
"body": "O compartilhamento da gravação via link ainda não está disponível. Apenas anfitriões podem baixá-la."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,875 @@
|
||||
{
|
||||
"feedback": {
|
||||
"heading": {
|
||||
"normal": "Você saiu da reunião",
|
||||
"duplicateIdentity": "Você entrou na reunião de outro dispositivo",
|
||||
"participantRemoved": "Você foi removido da reunião por um anfitrião",
|
||||
"roomDeleted": "Esta reunião foi excluída",
|
||||
"connectionLost": "A conexão com a reunião foi perdida",
|
||||
"connectionFailed": "Não foi possível entrar na reunião"
|
||||
},
|
||||
"home": "Voltar para o início",
|
||||
"back": "Reentrar na reunião"
|
||||
},
|
||||
"selectDevice": {
|
||||
"loading": "Carregando…",
|
||||
"select": "Selecione um valor",
|
||||
"permissionsNeeded": "Permissão necessária",
|
||||
"deviceNotFound": {
|
||||
"videoinput": "Nenhuma câmera detectada. Verifique se está conectada corretamente.",
|
||||
"audioinput": "Nenhum microfone detectado. Verifique se está conectado corretamente."
|
||||
},
|
||||
"deviceInUse": {
|
||||
"videoinput": "Câmera indisponível: provavelmente está sendo usada por outro aplicativo ou guia do navegador.",
|
||||
"audioinput": "Microfone indisponível: provavelmente está sendo usado por outro aplicativo ou guia do navegador."
|
||||
},
|
||||
"settings": {
|
||||
"audio": "Configurações de áudio",
|
||||
"video": "Configurações de vídeo"
|
||||
},
|
||||
"effects": "Planos de fundo e Efeitos",
|
||||
"videoinput": {
|
||||
"choose": "Selecionar câmera",
|
||||
"permissionsNeeded": "Selecionar câmera - permissão necessária",
|
||||
"disable": "Desativar câmera",
|
||||
"enable": "Ativar câmera",
|
||||
"turnedOff": "Câmera desligada",
|
||||
"turnedOn": "Câmera ligada",
|
||||
"label": "Câmera",
|
||||
"placeholder": "Ative a câmera para ver a pré-visualização"
|
||||
},
|
||||
"audioinput": {
|
||||
"choose": "Selecionar microfone",
|
||||
"level": "Nível de entrada do microfone",
|
||||
"muteTest": "Ative seu microfone para testá-lo",
|
||||
"permissionsNeeded": "Selecionar microfone - permissão necessária",
|
||||
"disable": "Desativar microfone",
|
||||
"enable": "Ativar microfone",
|
||||
"turnedOff": "Microfone desligado",
|
||||
"turnedOn": "Microfone ligado",
|
||||
"label": "Microfone"
|
||||
},
|
||||
"audiooutput": {
|
||||
"choose": "Selecionar alto-falante",
|
||||
"permissionsNeeded": "Selecionar alto-falante - permissão necessária",
|
||||
"test": "Testar alto-falantes",
|
||||
"testing": "Reproduzindo som de teste…"
|
||||
}
|
||||
},
|
||||
"join": {
|
||||
"effects": {
|
||||
"description": "Aplicar planos de fundo e efeitos",
|
||||
"title": "Efeitos",
|
||||
"subTitle": "Configure os efeitos da sua câmera."
|
||||
},
|
||||
"heading": "Entrar na reunião?",
|
||||
"joinLabel": "Entrar",
|
||||
"joinMeeting": "Entrar na reunião",
|
||||
"toggleOff": "Clique para desligar",
|
||||
"toggleOn": "Clique para ligar",
|
||||
"usernameHint": "Visível para outros participantes",
|
||||
"usernameLabel": "Seu nome",
|
||||
"errors": {
|
||||
"joinFailed": "Não foi possível entrar na reunião. Tente novamente.",
|
||||
"usernameEmpty": "Seu nome não pode estar vazio"
|
||||
},
|
||||
"cameraDisabled": "Câmera desativada.",
|
||||
"cameraNotFound": "Nenhuma câmera detectada. Verifique se está conectada corretamente.",
|
||||
"cameraInUse": "Sua câmera está indisponível. Provavelmente está sendo usada por outro aplicativo ou guia do navegador.",
|
||||
"cameraStarting": "Câmera iniciando…",
|
||||
"cameraNotGranted": "Deseja que os outros possam ver você durante a reunião?",
|
||||
"cameraAndMicNotGranted": "Deseja que os outros possam ver e ouvir você durante a reunião?",
|
||||
"permissionsButton": {
|
||||
"cameraAndMicNotGranted": "Permitir acesso à câmera e microfone",
|
||||
"cameraNotGranted": "Permitir acesso à câmera"
|
||||
},
|
||||
"videoPreview": {
|
||||
"enabled": "Pré-visualização de vídeo ativada",
|
||||
"disabled": "Pré-visualização de vídeo desativada"
|
||||
},
|
||||
"waiting": {
|
||||
"title": "Solicitando entrada...",
|
||||
"body": "Você poderá entrar nesta chamada quando alguém autorizar"
|
||||
},
|
||||
"denied": {
|
||||
"title": "Você não pode entrar nesta chamada",
|
||||
"body": "Sua solicitação de entrada foi negada."
|
||||
},
|
||||
"timeoutInvite": {
|
||||
"title": "Você não pode entrar nesta chamada",
|
||||
"body": "Ninguém respondeu à sua solicitação"
|
||||
},
|
||||
"deleted": {
|
||||
"title": "Você não pode entrar nesta chamada",
|
||||
"body": "Esta reunião foi excluída."
|
||||
}
|
||||
},
|
||||
"leaveRoomPrompt": "Isso fará você sair da reunião.",
|
||||
"shareDialog": {
|
||||
"copy": "Copiar informações",
|
||||
"copyUrl": "Copiar link",
|
||||
"copied": "Informações copiadas para a área de transferência",
|
||||
"heading": "Sua reunião está pronta",
|
||||
"description": "Compartilhe este link com as pessoas que deseja convidar para a reunião.",
|
||||
"permissions": "Pessoas com este link não precisam de sua permissão para entrar nesta reunião.",
|
||||
"closeDialog": "Fechar diálogo",
|
||||
"phone": {
|
||||
"call": "Ligar:",
|
||||
"pinCode": "Código:"
|
||||
}
|
||||
},
|
||||
"pagination": {
|
||||
"label": "Páginas de participantes",
|
||||
"count": "{{currentPage}} de {{totalPageCount}}",
|
||||
"next": "próxima página",
|
||||
"previous": "página anterior"
|
||||
},
|
||||
"mediaErrorDialog": {
|
||||
"DeviceInUse": {
|
||||
"title": {
|
||||
"audioinput": "Falha ao ativar microfone",
|
||||
"videoinput": "Falha ao ativar câmera"
|
||||
},
|
||||
"body": {
|
||||
"audioinput": "Seu microfone já está em uso por outra aba ou aplicativo, o que impede sua ativação nesta chamada. Se desejar, feche a outra aba ou aplicativo para usá-lo aqui.",
|
||||
"videoinput": "Sua câmera já está em uso por outra aba ou aplicativo, o que impede sua ativação nesta chamada. Se desejar, feche a outra aba ou aplicativo para usá-la aqui."
|
||||
}
|
||||
},
|
||||
"NotFound": {
|
||||
"title": {
|
||||
"audioinput": "Nenhum microfone detectado",
|
||||
"videoinput": "Nenhuma câmera detectada"
|
||||
},
|
||||
"body": {
|
||||
"audioinput": "Nenhum microfone foi detectado no seu dispositivo. Verifique se está conectado corretamente e se não está desativado por um botão físico de mudo, depois tente novamente.",
|
||||
"videoinput": "Nenhuma câmera foi detectada no seu dispositivo. Verifique se está conectada corretamente e se não está bloqueada por uma tampa ou botão físico, depois tente novamente."
|
||||
}
|
||||
},
|
||||
"close": "OK"
|
||||
},
|
||||
"permissionErrorDialog": {
|
||||
"heading": {
|
||||
"camera": "{{appTitle}} não tem permissão para usar sua câmera",
|
||||
"microphone": "{{appTitle}} não tem permissão para usar seu microfone",
|
||||
"cameraAndMicrophone": "{{appTitle}} não tem permissão para usar seu microfone ou câmera",
|
||||
"default": "{{appTitle}} não tem permissão para usar certas funcionalidades"
|
||||
},
|
||||
"body": {
|
||||
"openMenu": {
|
||||
"others": "Clique no ícone de configurações ICON_PLACEHOLDER na barra de endereços do navegador",
|
||||
"safari": "Clique no menu 'Safari' e abra 'Configurações para {{appDomain}}'."
|
||||
},
|
||||
"details": {
|
||||
"camera": "Permita o acesso à câmera",
|
||||
"microphone": "Permita o acesso ao microfone",
|
||||
"cameraAndMicrophone": "Permita o acesso à câmera e ao microfone",
|
||||
"default": "Ative as permissões necessárias."
|
||||
}
|
||||
}
|
||||
},
|
||||
"systemPermissionDialog": {
|
||||
"heading": {
|
||||
"camera": "Permitir que o navegador use sua câmera",
|
||||
"microphone": "Permitir que o navegador use seu microfone",
|
||||
"cameraAndMicrophone": "Permitir que o navegador use sua câmera e seu microfone"
|
||||
},
|
||||
"device": {
|
||||
"camera": "câmera",
|
||||
"microphone": "microfone",
|
||||
"cameraAndMicrophone": "câmera e microfone"
|
||||
},
|
||||
"intro": "Seu navegador já tem permissão, mas as configurações do sistema do computador estão bloqueando o acesso à sua {{device}}.",
|
||||
"steps": {
|
||||
"macos": {
|
||||
"1": "Nos Ajustes do Sistema, abra “Privacidade e Segurança”.",
|
||||
"2": "Permita que o navegador acesse sua {{device}}."
|
||||
},
|
||||
"windows": {
|
||||
"1": "Nas Configurações do Windows, abra “Privacidade e segurança”.",
|
||||
"2": "Ative o acesso a {{device}} e permita que aplicativos da área de trabalho o utilizem."
|
||||
},
|
||||
"android": {
|
||||
"1": "Nas Configurações do Android, abra “Apps” e selecione seu navegador.",
|
||||
"2": "Em “Permissões”, permita o acesso a {{device}} e recarregue esta página."
|
||||
},
|
||||
"other": {
|
||||
"1": "Nas configurações de privacidade do sistema, permita que o navegador acesse sua {{device}}.",
|
||||
"2": "Depois, tente novamente."
|
||||
}
|
||||
},
|
||||
"openSettings": "Abrir configurações do sistema"
|
||||
},
|
||||
"silentMic": {
|
||||
"tooltip": "Seu microfone está ativado, mas nenhum som está sendo detectado. Clique para saber mais.",
|
||||
"dialog": {
|
||||
"title": "Nenhum som detectado",
|
||||
"intro": "Seu microfone está ativado, mas não detectamos nenhum som há algum tempo. Verifique o seguinte:",
|
||||
"causes": {
|
||||
"system": "As configurações de privacidade do seu sistema podem estar bloqueando o acesso ao microfone.",
|
||||
"hardware": "Um botão de mudo pode estar ativado, ou seu microfone pode estar desconectado ou com defeito.",
|
||||
"wrongDevice": "O microfone incorreto pode estar selecionado. Tente escolher outro no menu de microfone."
|
||||
},
|
||||
"hint": "Fale novamente após verificar essas configurações. Este aviso desaparecerá assim que o som for detectado.",
|
||||
"close": "Entendi",
|
||||
"discard": "Desativar detecção de microfone silencioso"
|
||||
}
|
||||
},
|
||||
"permissionsButton": {
|
||||
"tooltip": "Mais informações",
|
||||
"ariaLabel": "Problema de permissões. Mostrar mais informações"
|
||||
},
|
||||
"error": {
|
||||
"createRoom": {
|
||||
"heading": "Autenticação Necessária",
|
||||
"body": "Esta sala ainda não foi criada. Por favor, autentique-se para criá-la ou aguarde um usuário autenticado fazer isso."
|
||||
},
|
||||
"deletedRoom": {
|
||||
"heading": "Reunião excluída",
|
||||
"body": "Esta reunião foi excluída e não é mais possível entrar nela."
|
||||
},
|
||||
"screenShare": {
|
||||
"title": "Não foi possível compartilhar sua tela",
|
||||
"ariaLabel": "Não foi possível compartilhar sua tela",
|
||||
"message": "Seu navegador pode não ter permissão para gravar a tela no seu computador.",
|
||||
"settingsInstructions": "Acesse",
|
||||
"settingsLabel": {
|
||||
"macos": "Ajustes do Sistema",
|
||||
"windows": "Configurações de privacidade do Windows"
|
||||
},
|
||||
"helpLinkText": "Para saber mais, veja",
|
||||
"helpLinkLabel": "Problema de apresentação",
|
||||
"closeButton": "Fechar",
|
||||
"newTab": "Nova janela"
|
||||
}
|
||||
},
|
||||
"isIdleDisconnectModal": {
|
||||
"title": "Você ainda está aí?",
|
||||
"body": "Você é o único participante. Esta chamada será encerrada em {{duration}}. Deseja continuar?",
|
||||
"settingsPrefix": "Para parar de ver esta mensagem, vá em",
|
||||
"settingsLink": "Configurações > Geral",
|
||||
"settingsSuffix": ".",
|
||||
"stayButton": "Continuar a chamada",
|
||||
"leaveButton": "Sair agora",
|
||||
"countdownAnnouncement": "Chamada encerra em {{duration}}."
|
||||
},
|
||||
"controls": {
|
||||
"region": "Controles da chamada",
|
||||
"microphone": "Microfone",
|
||||
"camera": "Câmera",
|
||||
"chat": {
|
||||
"open": "Fechar o chat",
|
||||
"closed": "Abrir o chat",
|
||||
"input": {
|
||||
"textArea": {
|
||||
"label": "Digite uma mensagem",
|
||||
"placeholder": "Digite uma mensagem"
|
||||
},
|
||||
"button": {
|
||||
"label": "Enviar mensagem"
|
||||
}
|
||||
}
|
||||
},
|
||||
"info": {
|
||||
"open": "Ocultar informações",
|
||||
"closed": "Mostrar informações"
|
||||
},
|
||||
"hand": {
|
||||
"raise": "Levantar a mão",
|
||||
"lower": "Abaixar a mão"
|
||||
},
|
||||
"subtitles": {
|
||||
"closed": "Mostrar legendas",
|
||||
"open": "Ocultar legendas"
|
||||
},
|
||||
"screenShare": {
|
||||
"start": "Compartilhar tela",
|
||||
"stop": "Parar compartilhamento de tela"
|
||||
},
|
||||
"leave": "Sair",
|
||||
"participants": {
|
||||
"open": "Ocultar todos",
|
||||
"closed": "Ver todos",
|
||||
"count_one": "{{count}} participante",
|
||||
"count_other": "{{count}} participantes"
|
||||
},
|
||||
"tools": {
|
||||
"open": "Ocultar mais ferramentas",
|
||||
"closed": "Mostrar mais ferramentas"
|
||||
},
|
||||
"admin": {
|
||||
"open": "Ocultar admin",
|
||||
"closed": "Abrir admin"
|
||||
},
|
||||
"moreOptions": "Mais opções",
|
||||
"reactions": {
|
||||
"button": "Enviar reação",
|
||||
"toolbar": "Enviar reação",
|
||||
"announce": "{{name}} : {{emoji}}",
|
||||
"you": "você",
|
||||
"emojis": {
|
||||
"thumbs-up": "joinha",
|
||||
"thumbs-down": "negativo",
|
||||
"clapping-hands": "palmas",
|
||||
"red-heart": "coração vermelho",
|
||||
"face-with-tears-of-joy": "rindo às lágrimas",
|
||||
"face-with-open-mouth": "rosto surpreso",
|
||||
"party-popper": "festa",
|
||||
"folded-hands": "mãos unidas"
|
||||
}
|
||||
}
|
||||
},
|
||||
"pictureInPicture": {
|
||||
"placeholder": {
|
||||
"title": "Sua chamada está em outra janela.",
|
||||
"description": "O modo Picture-in-Picture permite que você continue conectado à chamada enquanto realiza outras tarefas.",
|
||||
"bringBack": "Trazer a chamada de volta aqui"
|
||||
},
|
||||
"stage": "Participantes",
|
||||
"controlBar": "Controles da reunião",
|
||||
"title": "Reunião em picture-in-picture"
|
||||
},
|
||||
"options": {
|
||||
"buttonLabel": "Mais Opções",
|
||||
"items": {
|
||||
"feedback": "Nos dê sua opinião",
|
||||
"settings": "Configurações",
|
||||
"screenRecording": "Gravação de tela",
|
||||
"transcript": "Transcrição",
|
||||
"username": "Atualizar seu nome",
|
||||
"effects": "Planos de fundo e Efeitos",
|
||||
"switchCamera": "Trocar câmera",
|
||||
"pictureInPicture": {
|
||||
"enter": "Picture-in-picture",
|
||||
"exit": "Fechar picture-in-picture"
|
||||
},
|
||||
"fullscreen": {
|
||||
"enter": "Tela cheia",
|
||||
"exit": "Sair do modo tela cheia"
|
||||
},
|
||||
"support": "Contatar suporte",
|
||||
"documentation": "Documentação"
|
||||
}
|
||||
},
|
||||
"effects": {
|
||||
"activateCamera": "Sua câmera está desativada. Escolha uma opção para ativá-la.",
|
||||
"cameraDisabled": "Sua câmera está desativada.",
|
||||
"notAvailable": "Efeitos de vídeo estarão disponíveis em breve no seu navegador. Estamos trabalhando nisso! Enquanto isso, use o Google Chrome para melhor desempenho ou Firefox :(",
|
||||
"heading": "Desfoque",
|
||||
"clear": "Desativar efeito",
|
||||
"blur": {
|
||||
"title": "Desfoque de fundo",
|
||||
"status": {
|
||||
"none": "Fundo limpo.",
|
||||
"light": "Fundo levemente desfocado.",
|
||||
"strong": "Fundo fortemente desfocado."
|
||||
},
|
||||
"light": {
|
||||
"apply": "Desfocar levemente o fundo",
|
||||
"clear": "Desativar desfoque"
|
||||
},
|
||||
"normal": {
|
||||
"apply": "Desfocar o fundo",
|
||||
"clear": "Desativar desfoque"
|
||||
}
|
||||
},
|
||||
"virtual": {
|
||||
"title": "Planos de fundo virtuais",
|
||||
"selectedLabel": "Plano de fundo aplicado:",
|
||||
"apply": "Substituir seu plano de fundo:",
|
||||
"personal": {
|
||||
"title": "Meus planos de fundo",
|
||||
"selectedLabel": "Desmarcar plano de fundo (está atualmente aplicado).",
|
||||
"apply": "Usar como plano de fundo.",
|
||||
"selectFileTooltip": "Selecione um arquivo de imagem para usar como plano de fundo pessoal",
|
||||
"notLoggedInWarning": "Você não está logado, os planos de fundo pessoais não serão salvos entre reuniões.",
|
||||
"warningUploadDisabled": "Planos de fundo pessoais não são salvos entre reuniões no momento.",
|
||||
"uploadLimitReached": "Você não pode enviar mais planos de fundo pessoais.",
|
||||
"uploadInProgress": "Imagem sendo enviada…",
|
||||
"deleteLabel": "Excluir plano de fundo.",
|
||||
"errors": {
|
||||
"close": "Fechar",
|
||||
"file_too_large": {
|
||||
"title": "Arquivo muito grande",
|
||||
"description": "O arquivo é muito grande. Escolha um arquivo menor que {{maxSize, number}} MB."
|
||||
},
|
||||
"invalid_file_type": {
|
||||
"title": "Tipo de arquivo inválido",
|
||||
"description": "O tipo de arquivo não é suportado. Escolha um arquivo {{allowedExtension, list(type: 'disjunction')}}."
|
||||
}
|
||||
}
|
||||
},
|
||||
"presets": {
|
||||
"title": "Sugestões",
|
||||
"descriptions": {
|
||||
"0": "Móvel de madeira canelada",
|
||||
"1": "Sala de reuniões",
|
||||
"2": "Loft com divisória de vidro preto",
|
||||
"3": "Sala de jantar",
|
||||
"4": "Prateleiras de madeira",
|
||||
"5": "Escada de madeira",
|
||||
"6": "Biblioteca cinza",
|
||||
"7": "Balcão de café"
|
||||
}
|
||||
}
|
||||
},
|
||||
"faceLandmarks": {
|
||||
"title": "Efeitos Visuais",
|
||||
"glasses": {
|
||||
"apply": "Adicionar Óculos",
|
||||
"clear": "Remover Óculos"
|
||||
},
|
||||
"french": {
|
||||
"apply": "Adicionar toque francês",
|
||||
"clear": "Remover toque francês"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sidePanel": {
|
||||
"ariaLabel": "Painel lateral - {{title}}",
|
||||
"backToTools": "Voltar às ferramentas da reunião",
|
||||
"heading": {
|
||||
"participants": "Participantes",
|
||||
"effects": "Planos de fundo e Efeitos",
|
||||
"chat": "Mensagens no chat",
|
||||
"transcript": "Transcrever",
|
||||
"screenRecording": "Gravar",
|
||||
"admin": "Configurações do anfitrião",
|
||||
"tools": "Mais ferramentas",
|
||||
"info": "Informações da reunião"
|
||||
},
|
||||
"content": {
|
||||
"participants": "participantes",
|
||||
"effects": "Planos de fundo e Efeitos",
|
||||
"chat": "mensagens",
|
||||
"transcript": "transcrever",
|
||||
"screenRecording": "gravar",
|
||||
"admin": "configurações do anfitrião",
|
||||
"tools": "mais ferramentas",
|
||||
"info": "informações da reunião"
|
||||
},
|
||||
"closeButton": "Ocultar {{content}}",
|
||||
"escapeHint": "Pressione Escape para fechar"
|
||||
},
|
||||
"chat": {
|
||||
"disclaimer": "As mensagens são visíveis apenas para os participantes no momento em que são enviadas. Todas as mensagens são excluídas ao final da chamada.",
|
||||
"messagesLog": "Mensagens do chat"
|
||||
},
|
||||
"moreTools": {
|
||||
"body": "Acesse mais ferramentas para melhorar suas reuniões.",
|
||||
"linkAriaLabel": "Abrir documentação sobre ferramentas - abre em nova janela",
|
||||
"moreLink": "Abrir documentação",
|
||||
"tools": {
|
||||
"transcript": {
|
||||
"title": "Transcrever",
|
||||
"body": "Transforme reuniões em texto."
|
||||
},
|
||||
"screenRecording": {
|
||||
"title": "Gravar",
|
||||
"body": "Salve reuniões como vídeo."
|
||||
}
|
||||
}
|
||||
},
|
||||
"info": {
|
||||
"roomInformation": {
|
||||
"title": "Informações de Conexão",
|
||||
"button": {
|
||||
"ariaLabel": "Copiar as informações da sua reunião",
|
||||
"copy": "Copiar informações",
|
||||
"copied": "Informações copiadas"
|
||||
},
|
||||
"phone": {
|
||||
"call": "Ligar:",
|
||||
"pinCode": "Código:"
|
||||
}
|
||||
}
|
||||
},
|
||||
"transcript": {
|
||||
"heading": "Transcreva sua reunião com o Assistente de IA",
|
||||
"body": "Transcreva até {{max_duration}} de reunião.",
|
||||
"bodyWithoutMaxDuration": "Transcreva sua reunião sem limites.",
|
||||
"details": {
|
||||
"receiver": "A transcrição será enviada ao anfitrião e co-anfitriões.",
|
||||
"destination": "Um novo documento será criado em",
|
||||
"destinationUnknown": "Um novo documento será criado",
|
||||
"language": "Idioma da reunião:",
|
||||
"recording": "Também iniciar uma gravação"
|
||||
},
|
||||
"button": {
|
||||
"start": "Iniciar transcrição da reunião",
|
||||
"stop": "Parar transcrição da reunião",
|
||||
"saving": "Salvando…",
|
||||
"starting": "Iniciando…",
|
||||
"anotherModeStarted": "Gravação de tela em andamento. Troque de painel e pare-a."
|
||||
},
|
||||
"linkMore": "Abrir documentação",
|
||||
"linkAriaLabel": "Abrir documentação sobre transcrição - abre em nova janela",
|
||||
"notAdminOrOwner": {
|
||||
"heading": "Acesso Restrito",
|
||||
"body": "Por motivos de segurança, apenas o criador da reunião ou um admin pode iniciar uma transcrição (beta).",
|
||||
"linkMore": "Abrir documentação",
|
||||
"linkAriaLabel": "Abrir documentação sobre acesso à transcrição - abre em nova janela",
|
||||
"dividerLabel": "OU",
|
||||
"login": {
|
||||
"heading": "Login Necessário",
|
||||
"body": "Apenas o criador da reunião ou um admin pode iniciar uma transcrição. Faça login para verificar suas permissões."
|
||||
},
|
||||
"request": {
|
||||
"heading": "Solicitar Transcrição",
|
||||
"body": "O anfitrião será notificado e poderá ativar a transcrição para você.",
|
||||
"buttonLabel": "Solicitar"
|
||||
}
|
||||
},
|
||||
"notLoggedIn": {
|
||||
"heading": "Faça login para transcrever",
|
||||
"body": "Nesta reunião, qualquer participante conectado pode iniciar uma transcrição (beta).",
|
||||
"linkMore": "Abrir documentação",
|
||||
"linkAriaLabel": "Abrir documentação sobre acesso à transcrição - abre em uma nova janela",
|
||||
"dividerLabel": "OU",
|
||||
"login": {
|
||||
"heading": "Login necessário",
|
||||
"body": "Faça login e volte a este painel para iniciar a transcrição."
|
||||
},
|
||||
"request": {
|
||||
"heading": "Solicitar transcrição",
|
||||
"body": "O anfitrião será notificado e poderá ativar a transcrição para você.",
|
||||
"buttonLabel": "Solicitar"
|
||||
}
|
||||
},
|
||||
"premium": {
|
||||
"heading": "Recurso avançado",
|
||||
"body": "Este recurso não está disponível para você. Entre em contato com o suporte para mais informações.",
|
||||
"linkMore": "Abrir documentação",
|
||||
"linkAriaLabel": "Abrir documentação sobre acesso avançado - abre em nova janela",
|
||||
"dividerLabel": "OU",
|
||||
"login": {
|
||||
"heading": "Você não está logado!",
|
||||
"body": "Você precisa estar logado para usar este recurso. Faça login e tente novamente."
|
||||
},
|
||||
"request": {
|
||||
"heading": "Solicitar Transcrição",
|
||||
"body": "O anfitrião será notificado e poderá ativar a transcrição para você.",
|
||||
"buttonLabel": "Solicitar"
|
||||
}
|
||||
}
|
||||
},
|
||||
"screenRecording": {
|
||||
"heading": "Grave esta chamada para depois",
|
||||
"body": "Grave até {{max_duration}} de reunião.",
|
||||
"bodyWithoutMaxDuration": "Grave sua reunião sem limite.",
|
||||
"linkMore": "Abrir documentação",
|
||||
"linkAriaLabel": "Abrir documentação sobre gravação - abre em nova janela",
|
||||
"details": {
|
||||
"receiver": "A gravação será enviada ao anfitrião e co-anfitriões.",
|
||||
"destination": "Esta gravação será armazenada temporariamente em nossos servidores",
|
||||
"loading": "Iniciando gravação",
|
||||
"linkMore": "Saiba mais",
|
||||
"transcription": "Transcrever esta gravação",
|
||||
"quality": "Qualidade de vídeo ajustável.",
|
||||
"qualityEdit": "Alterar"
|
||||
},
|
||||
"button": {
|
||||
"start": "Iniciar gravação da reunião",
|
||||
"stop": "Parar gravação da reunião",
|
||||
"saving": "Salvando…",
|
||||
"starting": "Iniciando…",
|
||||
"anotherModeStarted": "Transcrição em andamento. <br/> Troque de painel e pare-a."
|
||||
},
|
||||
"notAdminOrOwner": {
|
||||
"heading": "Acesso Restrito",
|
||||
"body": "Por motivos de segurança, apenas o criador da reunião ou um admin pode iniciar uma gravação (beta).",
|
||||
"linkMore": "Abrir documentação",
|
||||
"linkAriaLabel": "Abrir documentação sobre acesso à gravação - abre em nova janela",
|
||||
"dividerLabel": "OU",
|
||||
"login": {
|
||||
"heading": "Login Necessário",
|
||||
"body": "Apenas o criador da reunião ou um admin pode iniciar a gravação. Faça login para verificar suas permissões."
|
||||
},
|
||||
"request": {
|
||||
"heading": "Solicitar Gravação",
|
||||
"body": "O anfitrião será notificado e poderá ativar a gravação para você.",
|
||||
"buttonLabel": "Solicitar"
|
||||
}
|
||||
},
|
||||
"notLoggedIn": {
|
||||
"heading": "Faça login para gravar",
|
||||
"body": "Nesta reunião, qualquer participante conectado pode iniciar uma gravação (beta).",
|
||||
"linkMore": "Abrir documentação",
|
||||
"linkAriaLabel": "Abrir documentação sobre acesso à gravação - abre em uma nova janela",
|
||||
"dividerLabel": "OU",
|
||||
"login": {
|
||||
"heading": "Login necessário",
|
||||
"body": "Faça login e volte a este painel para iniciar a gravação."
|
||||
},
|
||||
"request": {
|
||||
"heading": "Solicitar gravação",
|
||||
"body": "O anfitrião será notificado e poderá ativar a gravação para você.",
|
||||
"buttonLabel": "Solicitar"
|
||||
}
|
||||
},
|
||||
"premium": {
|
||||
"heading": "Recurso avançado",
|
||||
"body": "Este recurso não está disponível para você. Entre em contato com o suporte para mais informações.",
|
||||
"linkMore": "Abrir documentação",
|
||||
"linkAriaLabel": "Abrir documentação sobre acesso avançado - abre em nova janela",
|
||||
"dividerLabel": "OU",
|
||||
"login": {
|
||||
"heading": "Você não está logado!",
|
||||
"body": "Você precisa estar logado para usar este recurso. Faça login e tente novamente."
|
||||
},
|
||||
"request": {
|
||||
"heading": "Solicitar Gravação",
|
||||
"body": "O anfitrião será notificado e poderá ativar a gravação para você.",
|
||||
"buttonLabel": "Solicitar"
|
||||
}
|
||||
},
|
||||
"durationMessage": "(limitado a {{max_duration}}) "
|
||||
},
|
||||
"errorRecordingAlertDialog": {
|
||||
"title": "Falha na Gravação",
|
||||
"body": {
|
||||
"stop": "Não foi possível parar a gravação. Tente novamente em instantes.",
|
||||
"start": "Não foi possível iniciar a gravação. Tente novamente em instantes."
|
||||
},
|
||||
"button": "OK"
|
||||
},
|
||||
"admin": {
|
||||
"description": "Estas configurações de anfitrião permitem que você mantenha o controle da reunião. Apenas anfitriões podem acessar estes controles.",
|
||||
"access": {
|
||||
"title": "Acesso à sala",
|
||||
"description": "Estas configurações também se aplicarão a futuras ocorrências desta reunião.",
|
||||
"type": "Tipos de acesso à reunião",
|
||||
"levels": {
|
||||
"public": {
|
||||
"label": "Aberta",
|
||||
"description": "Ninguém precisa solicitar para entrar na reunião."
|
||||
},
|
||||
"trusted": {
|
||||
"label": "Aberta para pessoas confiáveis",
|
||||
"description": "Pessoas autenticadas não precisam solicitar para entrar na reunião."
|
||||
},
|
||||
"restricted": {
|
||||
"label": "Restrita",
|
||||
"description": "Pessoas que não foram convidadas para a reunião devem solicitar para entrar."
|
||||
}
|
||||
}
|
||||
},
|
||||
"moderation": {
|
||||
"title": "Moderação da Reunião",
|
||||
"description": "Estas configurações restringem as ações que os participantes podem realizar durante a reunião.",
|
||||
"microphone": {
|
||||
"label": "Ligar microfone",
|
||||
"description": ""
|
||||
},
|
||||
"camera": {
|
||||
"label": "Ativar vídeo",
|
||||
"description": ""
|
||||
},
|
||||
"screenshare": {
|
||||
"label": "Compartilhar tela",
|
||||
"description": "Desativar esta opção impedirá que participantes compartilhem sua tela, e qualquer compartilhamento em andamento será interrompido imediatamente."
|
||||
},
|
||||
"mute": {
|
||||
"label": "Silenciar outros",
|
||||
"description": "Quando desativado, participantes não poderão mais silenciar outros participantes."
|
||||
}
|
||||
}
|
||||
},
|
||||
"rating": {
|
||||
"submit": "Enviar",
|
||||
"question": "O que você achou da qualidade da sua chamada?",
|
||||
"levels": {
|
||||
"min": "muito ruim",
|
||||
"max": "excelente"
|
||||
}
|
||||
},
|
||||
"openFeedback": {
|
||||
"question": "O que podemos fazer para melhorar?",
|
||||
"placeholder": "Descreva seus bugs ou compartilhe suas sugestões…",
|
||||
"submit": "Enviar",
|
||||
"skip": "Pular"
|
||||
},
|
||||
"confirmationMessage": {
|
||||
"heading": "Obrigado pelo seu envio",
|
||||
"body": "Nossa equipe de produto analisa cuidadosamente seu feedback. Retornaremos o mais breve possível."
|
||||
},
|
||||
"authenticationMessage": {
|
||||
"heading": "Como podemos entrar em contato?",
|
||||
"placeholder": "Seu email",
|
||||
"submit": "Enviar",
|
||||
"ignore": "Ignorar"
|
||||
},
|
||||
"participants": {
|
||||
"subheading": "Na sala",
|
||||
"you": "Você",
|
||||
"unknown": "Participante desconhecido",
|
||||
"host": "Anfitrião",
|
||||
"cohost": "Co-anfitrião",
|
||||
"member": "Membro",
|
||||
"contributors": "Colaboradores",
|
||||
"collapsable": {
|
||||
"open": "Abrir lista {{name}}",
|
||||
"close": "Fechar lista {{name}}"
|
||||
},
|
||||
"unauthenticated": {
|
||||
"badge": "Usuário não autenticado"
|
||||
},
|
||||
"muteYourself": "Fechar seu microfone",
|
||||
"muteParticipant": "Fechar o microfone de {{name}}",
|
||||
"muteParticipantAlert": {
|
||||
"heading": "Silenciar {{name}}",
|
||||
"description": "Silenciar {{name}} para todos os participantes? {{name}} será o único que poderá reativar seu microfone.",
|
||||
"confirm": "Silenciar",
|
||||
"cancel": "Cancelar"
|
||||
},
|
||||
"raisedHands": "Mãos levantadas",
|
||||
"lowerParticipantHand": "Abaixar a mão de {{name}}",
|
||||
"lowerParticipantsHand": "Abaixar todas as mãos",
|
||||
"muteParticipants": "Silenciar todos os microfones",
|
||||
"waiting": {
|
||||
"title": "Sala de espera",
|
||||
"accept": {
|
||||
"button": "Aceitar",
|
||||
"label": "Aceitar {{name}} na reunião",
|
||||
"all": "Aceitar todos"
|
||||
},
|
||||
"deny": {
|
||||
"button": "Negar",
|
||||
"label": "Negar {{name}} na reunião",
|
||||
"all": "Negar todos"
|
||||
}
|
||||
},
|
||||
"moreOptions": "Mais opções"
|
||||
},
|
||||
"participantMenu": {
|
||||
"remove": {
|
||||
"label": "Remover da chamada",
|
||||
"ariaLabel": "Remover {{name}} da chamada"
|
||||
},
|
||||
"unpin": {
|
||||
"label": "Desafixar",
|
||||
"ariaLabel": "Desafixar {{name}}"
|
||||
},
|
||||
"pin": {
|
||||
"label": "Fixar",
|
||||
"ariaLabel": "Fixar {{name}}"
|
||||
},
|
||||
"promote": {
|
||||
"label": "Tornar co-anfitrião",
|
||||
"ariaLabel": "Tornar {{name}} co-anfitrião"
|
||||
},
|
||||
"demote": {
|
||||
"label": "Remover papel de co-anfitrião",
|
||||
"ariaLabel": "Remover papel de co-anfitrião de {{name}}"
|
||||
}
|
||||
},
|
||||
"pinAnnouncements": {
|
||||
"pin": "O vídeo de {{name}} está fixado.",
|
||||
"unpin": "O vídeo de {{name}} não está mais fixado.",
|
||||
"self": {
|
||||
"pin": "Seu vídeo está fixado.",
|
||||
"unpin": "Seu vídeo não está mais fixado."
|
||||
}
|
||||
},
|
||||
"recordingStateToast": {
|
||||
"transcript": {
|
||||
"started": "Transcrevendo",
|
||||
"starting": "Transcrição iniciando",
|
||||
"stopping": "Transcrição parando"
|
||||
},
|
||||
"screen_recording": {
|
||||
"started": "Gravação em andamento",
|
||||
"starting": "Iniciando gravação",
|
||||
"stopping": "Parando gravação"
|
||||
},
|
||||
"any": {
|
||||
"started": "Gravação em andamento"
|
||||
},
|
||||
"limitReachedAlert": {
|
||||
"title": "Limite de gravação excedido",
|
||||
"description": "A gravação excedeu a duração máxima permitida{{duration_message}}. Será salva automaticamente.",
|
||||
"durationMessage": " de {{duration}}",
|
||||
"button": "OK"
|
||||
}
|
||||
},
|
||||
"participantTileFocus": {
|
||||
"containerLabel": "Opções para {{name}}",
|
||||
"toolbarHint": "{{shortcut}}: acessar atalhos diretamente.",
|
||||
"pin": {
|
||||
"enable": "Fixar",
|
||||
"disable": "Desafixar"
|
||||
},
|
||||
"effects": "Aplicar planos de fundo e efeitos",
|
||||
"muteParticipant": "Silenciar {{name}}",
|
||||
"fullScreen": "Tela cheia"
|
||||
},
|
||||
"screenShareZoom": {
|
||||
"toolbarLabel": "Controles de zoom do compartilhamento de tela",
|
||||
"zoomIn": "Aumentar zoom",
|
||||
"zoomInWithShortcut": "Aumentar zoom ({{shortcut}})",
|
||||
"zoomOut": "Diminuir zoom",
|
||||
"zoomOutWithShortcut": "Diminuir zoom ({{shortcut}})",
|
||||
"wheelShortcut": "Control mais roda do mouse",
|
||||
"wheelShortcutMac": "⌘+roda do mouse",
|
||||
"wheelShortcutHint": "Atalho: Control mais roda do mouse para aumentar ou diminuir o zoom.",
|
||||
"wheelShortcutHintMac": "Atalho: Command mais roda do mouse para aumentar ou diminuir o zoom.",
|
||||
"fitToWindow": "Ajustar à janela",
|
||||
"fullScreen": "Tela cheia",
|
||||
"exitFullScreen": "Sair da tela cheia",
|
||||
"currentZoomLevel": "Zoom {{level}} %",
|
||||
"panHint": "Zoom {{level}} %. Arraste com o mouse ou volte o foco para o compartilhamento de tela e use as setas do teclado para navegar pela imagem.",
|
||||
"fullScreenEntered": "Tela cheia ativada",
|
||||
"fullScreenExited": "Tela cheia desativada"
|
||||
},
|
||||
"shortcutsPanel": {
|
||||
"title": "Atalhos de teclado",
|
||||
"categories": {
|
||||
"navigation": "Navegação",
|
||||
"media": "Mídia",
|
||||
"interaction": "Interação"
|
||||
},
|
||||
"actions": {
|
||||
"open-shortcuts": "Abrir ajuda de atalhos",
|
||||
"focus-toolbar": "Focar barra inferior",
|
||||
"focus-pagination": "Focar na paginação de participantes",
|
||||
"toggle-microphone": "Alternar microfone",
|
||||
"toggle-camera": "Alternar câmera",
|
||||
"push-to-talk": "Pressionar para falar (segure para ativar)",
|
||||
"reaction": "Painel de reações",
|
||||
"fullscreen": "Alternar tela cheia",
|
||||
"recording": "Alternar painel de gravação",
|
||||
"raise-hand": "Levantar ou abaixar a mão",
|
||||
"toggle-chat": "Alternar chat",
|
||||
"toggle-participants": "Alternar participantes",
|
||||
"open-shortcuts-settings": "Abrir configurações de atalhos",
|
||||
"zoom-in": "Aumentar zoom na tela compartilhada",
|
||||
"zoom-out": "Diminuir zoom na tela compartilhada",
|
||||
"zoom-reset": "Redefinir o zoom da tela compartilhada",
|
||||
"zoom-pan": "Mover pela tela compartilhada com zoom"
|
||||
},
|
||||
"sr": {
|
||||
"control": "Control",
|
||||
"command": "Command",
|
||||
"alt": "Alt",
|
||||
"option": "Option",
|
||||
"shift": "Shift",
|
||||
"plus": "mais",
|
||||
"hold": "Segure {{key}}",
|
||||
"arrows": "Teclas de seta",
|
||||
"plusKey": "Tecla de mais",
|
||||
"minusKey": "Tecla de menos",
|
||||
"noShortcut": "Sem atalho"
|
||||
},
|
||||
"visual": {
|
||||
"hold": "Segure {{key}}",
|
||||
"arrows": "↑ ↓ ← →"
|
||||
}
|
||||
},
|
||||
"fullScreenWarning": {
|
||||
"message": "Para evitar exibição em loop infinito, não compartilhe sua tela inteira. Em vez disso, compartilhe uma aba ou outra janela.",
|
||||
"stop": "Parar apresentação",
|
||||
"ignore": "Ignorar"
|
||||
},
|
||||
"participantTile": {
|
||||
"screenShare": "Tela de {{name}}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"createMeeting": {
|
||||
"createButton": "Criar",
|
||||
"joinButton": "Entrar com o Visio",
|
||||
"copyLinkTooltip": "Copiar link",
|
||||
"resetLabel": "Redefinir",
|
||||
"participantLimit": "Até 150 participantes.",
|
||||
"popupBlocked": "O pop-up foi bloqueado. Por favor, permita pop-ups para este site.",
|
||||
"settingsTooltip": "Configurações da reunião"
|
||||
},
|
||||
"roomSettings": {
|
||||
"title": "Configurações da reunião",
|
||||
"description": "Configurar a reunião {{roomSlug}}.",
|
||||
"notAllowed": "Você não tem permissão para modificar as configurações desta reunião.",
|
||||
"error": "Não foi possível carregar as configurações da reunião.",
|
||||
"closeButton": "Fechar"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
{
|
||||
"account": {
|
||||
"currentlyLoggedAs": "Você está conectado como <0>{{user}}</0>",
|
||||
"heading": "Conta",
|
||||
"youAreNotLoggedIn": "Você não está conectado.",
|
||||
"nameLabel": "Seu Nome",
|
||||
"authentication": "Autenticação",
|
||||
"nameError": "Seu nome não pode estar vazio"
|
||||
},
|
||||
"preferences": {
|
||||
"title": "Preferências",
|
||||
"idleDisconnectModal": {
|
||||
"label": "Sair de chamadas sem participantes",
|
||||
"description": "Sai automaticamente de uma chamada após alguns minutos se nenhum outro participante entrar"
|
||||
},
|
||||
"autoMuteLargeRoom": {
|
||||
"label": "Silenciar meu microfone automaticamente",
|
||||
"description": "Silencia automaticamente seu microfone ao entrar em uma reunião grande."
|
||||
}
|
||||
},
|
||||
"audio": {
|
||||
"microphone": {
|
||||
"heading": "Microfone",
|
||||
"label": "Selecione sua entrada de áudio",
|
||||
"disabled": "Microfone desativado"
|
||||
},
|
||||
"noiseReduction": {
|
||||
"label": "Otimização avançada de voz",
|
||||
"heading": "Filtros de áudio",
|
||||
"ariaLabel": {
|
||||
"enable": "Ativar redução de ruído",
|
||||
"disable": "Desativar redução de ruído"
|
||||
}
|
||||
},
|
||||
"speakers": {
|
||||
"heading": "Alto-falantes",
|
||||
"label": "Selecione sua saída de áudio",
|
||||
"test": "Testar",
|
||||
"ongoingTest": "Testando som…",
|
||||
"safariWarning": "A seleção de alto-falante ainda não está disponível no Safari devido a limitações do navegador."
|
||||
},
|
||||
"permissionsRequired": "Permissões necessárias"
|
||||
},
|
||||
"video": {
|
||||
"camera": {
|
||||
"heading": "Câmera",
|
||||
"label": "Selecione sua entrada de vídeo",
|
||||
"disabled": "Câmera desativada",
|
||||
"previewAriaLabel": {
|
||||
"enabled": "Pré-visualização de vídeo ativada",
|
||||
"disabled": "Pré-visualização de vídeo desativada"
|
||||
}
|
||||
},
|
||||
"resolution": {
|
||||
"heading": "Resolução",
|
||||
"publish": {
|
||||
"label": "Selecione sua resolução de envio (máx.)",
|
||||
"items": {
|
||||
"veryHigh": "Definição muito alta",
|
||||
"high": "Alta definição",
|
||||
"medium": "Definição padrão",
|
||||
"low": "Baixa definição"
|
||||
}
|
||||
},
|
||||
"subscribe": {
|
||||
"label": "Selecione sua resolução de recepção (máx.)",
|
||||
"items": {
|
||||
"high": "Alta definição (automática)",
|
||||
"medium": "Definição padrão",
|
||||
"low": "Baixa definição"
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissionsRequired": "Permissões necessárias"
|
||||
},
|
||||
"transcription": {
|
||||
"heading": "Transcrição",
|
||||
"language": {
|
||||
"label": "Idioma da reunião",
|
||||
"options": {
|
||||
"french": "Francês (fr)",
|
||||
"english": "Inglês (en)",
|
||||
"dutch": "Holandês (nl)",
|
||||
"german": "Alemão (de)",
|
||||
"auto": "Automático"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"heading": "Notificações sonoras",
|
||||
"label": "notificações sonoras para",
|
||||
"actions": {
|
||||
"disable": "Desativar",
|
||||
"enable": "Ativar"
|
||||
},
|
||||
"items": {
|
||||
"participantJoined": {
|
||||
"label": "Participante entrou"
|
||||
},
|
||||
"handRaised": {
|
||||
"label": "Mão levantada"
|
||||
},
|
||||
"messageReceived": {
|
||||
"label": "Mensagem recebida"
|
||||
},
|
||||
"participantWaiting": {
|
||||
"label": "Participante aguardando"
|
||||
}
|
||||
}
|
||||
},
|
||||
"shortcuts": {
|
||||
"listLabel": "Atalhos de teclado",
|
||||
"columnAction": "Ação",
|
||||
"columnShortcut": "Atalho"
|
||||
},
|
||||
"dialog": {
|
||||
"heading": "Configurações"
|
||||
},
|
||||
"language": {
|
||||
"heading": "Idioma",
|
||||
"label": "Idioma"
|
||||
},
|
||||
"settingsButtonLabel": "Configurações",
|
||||
"accessibility": {
|
||||
"announceReactions": {
|
||||
"label": "Anunciar reações em voz alta"
|
||||
},
|
||||
"font": {
|
||||
"label": "Fonte de exibição",
|
||||
"description": "Personalize a fonte usada na interface para melhorar seu conforto de leitura.",
|
||||
"options": {
|
||||
"default": "Padrão",
|
||||
"lexend": "Lexend (legibilidade melhorada)",
|
||||
"atkinson-hyperlegible": "Atkinson Hyperlegible (baixa visão)",
|
||||
"opendyslexic": "OpenDyslexic (dislexia)"
|
||||
}
|
||||
},
|
||||
"captions": {
|
||||
"heading": "Legendas",
|
||||
"textSize": {
|
||||
"label": "Tamanho do texto das legendas",
|
||||
"options": {
|
||||
"small": "Pequeno",
|
||||
"medium": "Médio",
|
||||
"large": "Grande"
|
||||
}
|
||||
},
|
||||
"fontColor": {
|
||||
"label": "Cor da fonte",
|
||||
"options": {
|
||||
"default": "Padrão",
|
||||
"white": "Branco",
|
||||
"black": "Preto",
|
||||
"blue": "Azul",
|
||||
"green": "Verde",
|
||||
"red": "Vermelho",
|
||||
"yellow": "Amarelo",
|
||||
"cyan": "Ciano",
|
||||
"magenta": "Magenta"
|
||||
}
|
||||
},
|
||||
"backgroundColor": {
|
||||
"label": "Cor de fundo",
|
||||
"options": {
|
||||
"default": "Padrão",
|
||||
"white": "Branco",
|
||||
"black": "Preto",
|
||||
"blue": "Azul",
|
||||
"green": "Verde",
|
||||
"red": "Vermelho",
|
||||
"yellow": "Amarelo",
|
||||
"cyan": "Ciano",
|
||||
"magenta": "Magenta"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
"account": "Perfil",
|
||||
"audio": "Áudio",
|
||||
"video": "Vídeo",
|
||||
"general": "Geral",
|
||||
"notifications": "Notificações",
|
||||
"accessibility": "Acessibilidade",
|
||||
"transcription": "Transcrição",
|
||||
"shortcuts": "Atalhos",
|
||||
"rooms": "Salas",
|
||||
"recording": "Gravação"
|
||||
},
|
||||
"roomDefaults": {
|
||||
"heading": "Configurações padrão de sala",
|
||||
"description": "Escolha as configurações aplicadas por padrão às novas salas que você criar. Você ainda pode alterá-las para cada reunião nas configurações de anfitrião."
|
||||
},
|
||||
"recording": {
|
||||
"heading": "Qualidade da gravação",
|
||||
"locked": "Essas configurações não podem ser alteradas enquanto uma gravação estiver iniciando ou em andamento.",
|
||||
"profile": {
|
||||
"label": "Tipo de conteúdo",
|
||||
"options": {
|
||||
"talking_heads": {
|
||||
"label": "Pessoas falando",
|
||||
"description": "Otimizado para pessoas falando na câmera com slides ocasionais. Menores arquivos."
|
||||
},
|
||||
"text": {
|
||||
"label": "Apresentações e texto",
|
||||
"description": "Mantém o texto nítido ao compartilhar slides, documentos ou código."
|
||||
},
|
||||
"mixed": {
|
||||
"label": "Conteúdo misto",
|
||||
"description": "Equilibrado para uma combinação de câmeras, compartilhamento de tela e movimento moderado."
|
||||
},
|
||||
"full": {
|
||||
"label": "Qualidade máxima",
|
||||
"description": "Maior fidelidade e movimento mais suave. Maiores arquivos."
|
||||
}
|
||||
}
|
||||
},
|
||||
"resolution": {
|
||||
"label": "Resolução",
|
||||
"description": "Resoluções mais altas geram arquivos maiores.",
|
||||
"options": {
|
||||
"540p": "540p (qHD)",
|
||||
"720p": "720p (HD)",
|
||||
"1080p": "1080p (Full HD)"
|
||||
}
|
||||
},
|
||||
"defaultOption": "Padrão"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
{
|
||||
"title": "Termos de Uso do Visio",
|
||||
"articles": {
|
||||
"article1": {
|
||||
"title": "1. Escopo",
|
||||
"content": "Este documento define os termos de uso do Visio (doravante “Visio” ou “o aplicativo”), um aplicativo de videoconferência projetado especificamente para administrações estatais e órgãos sob sua supervisão."
|
||||
},
|
||||
"article2": {
|
||||
"title": "2. Objetivo do aplicativo",
|
||||
"content": "O Visio ajuda a promover a cooperação entre equipes de administrações estatais e órgãos sob sua supervisão, apoia o trabalho remoto e facilita a organização de reuniões, conferências ou sessões de treinamento.",
|
||||
"paragraphs": [
|
||||
"O Visio é desenvolvido e operado pela Direção Interministerial do Digital (DINUM).",
|
||||
"O aplicativo é disponibilizado pela DINUM às administrações estatais e órgãos sob sua supervisão, para uso por seus agentes, parceiros e prestadores de serviços convidados.",
|
||||
"Qualquer uso do Visio deve estar em conformidade com estes termos de uso e, quando aplicável, com a política de uso da respectiva administração supervisora."
|
||||
]
|
||||
},
|
||||
"article3": {
|
||||
"title": "3. Definições",
|
||||
"paragraphs": [
|
||||
"O Visio é o produto cujo uso é descrito nestes termos e apresentado na seguinte página: https://visio.numerique.gouv.fr.",
|
||||
"Usuários do Visio são quaisquer pessoas que utilizem o Visio para fins de comunicação por videoconferência, sejam elas agentes, parceiros ou prestadores de serviços das administrações pertinentes. A administração supervisora é a administração estatal ou órgão sob sua supervisão que disponibiliza o Visio aos seus agentes, parceiros ou prestadores de serviços.",
|
||||
"Organizadores de sala são os usuários responsáveis por uma sala de videoconferência após se autenticarem via ProConnect.",
|
||||
"A administração supervisora é a administração integrante do sistema de informação e comunicação do Estado que disponibiliza o Visio aos seus agentes, parceiros ou prestadores de serviços."
|
||||
]
|
||||
},
|
||||
"article4": {
|
||||
"title": "4. Acesso ao serviço",
|
||||
"definition": "O Visio é disponibilizado aos usuários por sua administração supervisora. Os organizadores podem então compartilhar um link concedendo acesso à sala com qualquer pessoa, sob sua responsabilidade."
|
||||
},
|
||||
"article5": {
|
||||
"title": "5. Funcionalidades",
|
||||
"sections": {
|
||||
"section1": {
|
||||
"title": "5.1. Funcionalidades disponíveis para organizadores",
|
||||
"content": "O Visio permite que agentes de administrações estatais e órgãos sob sua supervisão criem uma sala de videoconferência a partir de uma estação de trabalho padrão, vinculada à sua conta ProConnect.",
|
||||
"paragraph1": "Eles podem então compartilhar o link da reunião com outras pessoas, usuários, pertencentes ou não à administração.",
|
||||
"paragraph2": "Para esta finalidade, cada organizador de sala pode:",
|
||||
"capabilities": [
|
||||
"Proteger o acesso à sala com uma sala de espera;",
|
||||
"Autorizar a entrada de cada usuário na sala;",
|
||||
"Silenciar microfones e câmeras dos usuários;",
|
||||
"Remover um usuário da sala;",
|
||||
"Utilizar a ferramenta de transcrição de reunião após obter o consentimento dos usuários, sob sua responsabilidade."
|
||||
],
|
||||
"paragraph3": "Os agentes devem entrar em contato com sua administração supervisora para garantir o enquadramento adequado para o uso do Visio, especialmente em reuniões sujeitas a requisitos elevados de confidencialidade.",
|
||||
"paragraph4": "A ferramenta de transcrição automática pode ser utilizada pelos organizadores a partir do menu de configurações da sala. Os organizadores são responsáveis por garantir o consentimento dos usuários que participam da reunião transcrita. O idioma da transcrição pode ser alterado no mesmo menu. Ao final da reunião, um link é enviado ao organizador para acessar a transcrição armazenada na ferramenta chamada Docs, operada pela DINUM."
|
||||
},
|
||||
"section2": {
|
||||
"title": "5.2. Funcionalidades disponíveis para usuários",
|
||||
"content": "O Visio permite que qualquer usuário, seja agente público ou não, participe de qualquer sala do Visio para a qual tenha sido convidado por um organizador e usufrua de suas funcionalidades.",
|
||||
"paragraph": "Para esta finalidade, cada usuário pode:",
|
||||
"capabilities": [
|
||||
"Fornecer um nome de exibição ao entrar na sala;",
|
||||
"Comunicar-se remotamente por áudio e/ou vídeo;",
|
||||
"Compartilhar sua tela;",
|
||||
"Trocar mensagens publicamente durante a sessão com todos os usuários na sala;",
|
||||
"Acessar informações técnicas sobre a sala e recursos de acessibilidade;",
|
||||
"Ativar ou desativar sua câmera e/ou microfone a qualquer momento;",
|
||||
"Proteger a privacidade do seu ambiente quando a câmera estiver ligada, selecionando e ativando um plano de fundo antes de entrar na sala ou durante a videoconferência."
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"article6": {
|
||||
"title": "6. Compromissos e responsabilidades dos usuários",
|
||||
"sections": {
|
||||
"section1": {
|
||||
"title": "6.1 Usos permitidos",
|
||||
"paragraphs": [
|
||||
"O Visio é fornecido para facilitar trocas profissionais entre agentes de administrações estatais e órgãos sob sua supervisão e colaboradores externos.",
|
||||
"Os usuários são responsáveis pelas imagens e sons que disponibilizam na sala de videoconferência. Essa responsabilidade aplica-se tanto ao que sua câmera ou microfone possa transmitir quanto ao que seu dispositivo possa tornar acessível a terceiros. Para tanto, os usuários devem utilizar o aplicativo em ambiente profissional e não devem divulgar a terceiros informações protegidas por sigilo profissional (por exemplo: uso de filtros de privacidade, fones de ouvido, etc.).",
|
||||
"Os usuários são responsáveis pelos dados ou conteúdos que inserem no serviço de mensagens da sala, devendo garantir que apenas mensagens apropriadas sejam enviadas."
|
||||
]
|
||||
},
|
||||
"section2": {
|
||||
"title": "6.2 Usos proibidos",
|
||||
"paragraphs": [
|
||||
"Os usuários comprometem-se a não inserir no serviço de mensagens da sala do Visio, nem tornar acessível por imagem ou som, qualquer conteúdo ou informação que viole as leis e regulamentos aplicáveis."
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"article7": {
|
||||
"title": "7. Compromissos e responsabilidades da DINUM",
|
||||
"sections": {
|
||||
"section1": {
|
||||
"title": "7.1. Segurança do aplicativo",
|
||||
"paragraphs": [
|
||||
"A DINUM compromete-se a proteger o Visio, em especial adotando todas as medidas necessárias para garantir a segurança e a confidencialidade das comunicações.",
|
||||
"O aplicativo é homologado nas condições previstas pelo Decreto nº 2010-112 alterado."
|
||||
]
|
||||
},
|
||||
"section2": {
|
||||
"title": "7.2. Acesso ao aplicativo",
|
||||
"paragraphs": [
|
||||
"A DINUM compromete-se a fornecer os meios necessários e razoáveis para garantir o acesso contínuo ao aplicativo.",
|
||||
"O aplicativo está disponível 24 horas por dia, 7 dias por semana, exceto durante períodos de manutenção.",
|
||||
"A DINUM busca atingir uma taxa anual de disponibilidade do aplicativo de 99,5%, excluindo manutenções programadas. Em caso de incidente ou manutenção, busca um tempo de restabelecimento de 72 horas fora do horário comercial.",
|
||||
"No âmbito da manutenção do ambiente de execução do aplicativo, a DINUM reserva-se o direito de suspender temporariamente o funcionamento do aplicativo. Sempre que possível, os usuários serão informados com pelo menos 48 horas de antecedência. Em situações de emergência, essa suspensão poderá ocorrer sem aviso prévio.",
|
||||
"Essas interrupções excepcionais podem ser necessárias, por exemplo, para operações de gerenciamento de dados, atualizações em produção ou mudanças arquiteturais.",
|
||||
"A indisponibilidade do aplicativo não confere aos usuários direito a qualquer forma de compensação."
|
||||
]
|
||||
},
|
||||
"section3": {
|
||||
"title": "7.3. Gestão de suporte",
|
||||
"paragraphs": [
|
||||
"A DINUM fornece suporte de primeiro nível aos usuários, exclusivamente sobre aspectos técnicos do aplicativo.",
|
||||
"Esse suporte pode ser acionado por meio do formulário de contato online.",
|
||||
"O prazo-meta de resposta às solicitações é de dois dias úteis a partir do envio do e-mail; esse prazo pode variar conforme a complexidade e o volume de solicitações a serem processadas."
|
||||
]
|
||||
},
|
||||
"section4": {
|
||||
"title": "7.4. Monitoramento do uso do aplicativo",
|
||||
"paragraphs": [
|
||||
"A DINUM reserva-se o direito de suspender ou excluir a conta de um usuário que tenha descumprido estes termos de uso, sem prejuízo de eventuais ações de responsabilidade civil ou penal cabíveis contra o interessado. As informações poderão ser transmitidas à administração supervisora do usuário, responsável pela disponibilização do aplicativo.",
|
||||
"A suspensão ou revogação de uma ou mais contas não gera direito a indenização de qualquer natureza."
|
||||
]
|
||||
},
|
||||
"section5": {
|
||||
"title": "7.5. Código Aberto e Licenças",
|
||||
"content": "O código-fonte do serviço é aberto e está disponível aqui: https://github.com/suitenumerique/meet. O conteúdo fornecido pela DINUM é licenciado sob a Licença Aberta 2.0 (Licence Ouverte 2.0), com exceção de logotipos e representações iconográficas ou fotográficas, que podem ser regidos por licenças próprias."
|
||||
}
|
||||
}
|
||||
},
|
||||
"article8": {
|
||||
"title": "8. Alterações nos termos de uso",
|
||||
"content": "Os termos destas condições de uso podem ser modificados ou complementados a qualquer momento, sem aviso prévio, em função de alterações realizadas no serviço, evolução da legislação ou por qualquer outro motivo considerado necessário.\nEssas alterações e atualizações vinculam o usuário, que deve, portanto, consultar regularmente esta seção para verificar os termos vigentes."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
// Map frontend language codes to backend language codes
|
||||
|
||||
export type BackendLanguage = 'en-us' | 'fr-fr' | 'nl-nl' | 'de-de' | 'es-es'
|
||||
export type FrontendLanguage = 'en' | 'fr' | 'nl' | 'de' | 'es'
|
||||
export type BackendLanguage =
|
||||
| 'en-us'
|
||||
| 'fr-fr'
|
||||
| 'nl-nl'
|
||||
| 'de-de'
|
||||
| 'es-es'
|
||||
| 'pt-br'
|
||||
export type FrontendLanguage = 'en' | 'fr' | 'nl' | 'de' | 'es' | 'pt'
|
||||
|
||||
const frontendToBackendMap: Record<FrontendLanguage, BackendLanguage> = {
|
||||
en: 'en-us',
|
||||
@@ -9,6 +15,7 @@ const frontendToBackendMap: Record<FrontendLanguage, BackendLanguage> = {
|
||||
nl: 'nl-nl',
|
||||
de: 'de-de',
|
||||
es: 'es-es',
|
||||
pt: 'pt-br',
|
||||
}
|
||||
|
||||
export const convertToBackendLanguage = (
|
||||
|
||||
Reference in New Issue
Block a user