diff --git a/bin/quickstart.sh b/bin/quickstart.sh index fd16554c..d07ce18b 100755 --- a/bin/quickstart.sh +++ b/bin/quickstart.sh @@ -1,97 +1,106 @@ #!/bin/bash set -e function die() { echo "$1" exit 1 } rpm -qv composer >/dev/null 2>&1 || \ test ! -z "$(which composer 2>/dev/null)" || \ die "Is composer installed?" rpm -qv docker-compose >/dev/null 2>&1 || \ test ! -z "$(which docker-compose 2>/dev/null)" || \ die "Is docker-compose installed?" rpm -qv npm >/dev/null 2>&1 || \ test ! -z "$(which npm 2>/dev/null)" || \ die "Is npm installed?" rpm -qv php >/dev/null 2>&1 || \ test ! -z "$(which php 2>/dev/null)" || \ die "Is php installed?" rpm -qv php-ldap >/dev/null 2>&1 || \ test ! -z "$(php --ini | grep ldap)" || \ die "Is php-ldap installed?" rpm -qv php-mysqlnd >/dev/null 2>&1 || \ test ! -z "$(php --ini | grep mysql)" || \ die "Is php-mysqlnd installed?" test ! -z "$(php --modules | grep swoole)" || \ die "Is swoole installed?" base_dir=$(dirname $(dirname $0)) docker pull docker.io/kolab/centos7:latest docker-compose down --remove-orphans docker-compose build pushd ${base_dir}/src/ -if [ ! -f ".env" ]; then - cp .env.example .env -fi +# Always reset .env with .env.example +cp .env.example .env if [ -f ".env.local" ]; then # Ensure there's a line ending echo "" >> .env cat .env.local >> .env fi popd bin/regen-certs docker-compose up -d coturn kolab mariadb meet pdns-sql proxy redis pushd ${base_dir}/src/ rm -rf vendor/ composer.lock php -dmemory_limit=-1 /bin/composer install npm install find bootstrap/cache/ -type f ! -name ".gitignore" -delete ./artisan key:generate ./artisan clear-compiled ./artisan cache:clear ./artisan horizon:install ./artisan passport:keys --force +if [ ! -f storage/oauth-public.key -o ! -f storage/oauth-private.key ]; then + ./artisan passport:keys --force +fi + +cat >> .env << EOF +PASSPORT_PRIVATE_KEY="$(cat storage/oauth-private.key)" +PASSPORT_PUBLIC_KEY="$(cat storage/oauth-public.key)" +EOF + + if [ ! -z "$(rpm -qv chromium 2>/dev/null)" ]; then chver=$(rpmquery --queryformat="%{VERSION}" chromium | awk -F'.' '{print $1}') ./artisan dusk:chrome-driver ${chver} fi if [ ! -f 'resources/countries.php' ]; then ./artisan data:countries fi npm run dev popd docker-compose up -d worker nginx pushd ${base_dir}/src/ rm -rf database/database.sqlite ./artisan db:ping --wait php -dmemory_limit=512M ./artisan migrate:refresh --seed ./artisan data:import ./artisan swoole:http stop >/dev/null 2>&1 || : ./artisan swoole:http start popd diff --git a/docker-compose.yml b/docker-compose.yml index bf2a68d3..f386189c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,205 +1,200 @@ version: '3' services: coturn: container_name: kolab-coturn environment: - DB_NAME=${OPENVIDU_COTURN_REDIS_DATABASE} - DB_PASSWORD=${OPENVIDU_COTURN_REDIS_PASSWORD} - REDIS_IP=${OPENVIDU_COTURN_REDIS_IP} - TURN_PUBLIC_IP=${OPENVIDU_COTURN_IP} - TURN_LISTEN_PORT=3478 hostname: sturn.mgmt.com image: openvidu/openvidu-coturn:1.0.0 network_mode: host restart: on-failure tty: true kolab: build: context: ./docker/kolab/ container_name: kolab depends_on: - mariadb extra_hosts: - "kolab.mgmt.com:127.0.0.1" environment: - DB_HOST=${DB_HOST} - DB_ROOT_PASSWORD=Welcome2KolabSystems healthcheck: interval: 10s test: test -f /tmp/kolab-init.done timeout: 5s retries: 30 hostname: kolab.mgmt.com image: kolab network_mode: host tmpfs: - /run - /tmp - /var/run - /var/tmp tty: true volumes: - /etc/letsencrypt/:/etc/letsencrypt/:ro - ./docker/certs/ca.cert:/etc/pki/tls/certs/ca.cert:ro - ./docker/certs/ca.cert:/etc/pki/ca-trust/source/anchors/ca.cert:ro - ./docker/certs/kolab.hosted.com.cert:/etc/pki/tls/certs/kolab.hosted.com.cert - ./docker/certs/kolab.hosted.com.key:/etc/pki/tls/certs/kolab.hosted.com.key - ./docker/certs/kolab.mgmt.com.cert:/etc/pki/tls/certs/kolab.mgmt.com.cert - ./docker/certs/kolab.mgmt.com.key:/etc/pki/tls/certs/kolab.mgmt.com.key - ./docker/kolab/utils:/root/utils:ro - ./src/.env:/.dockerenv:ro - /sys/fs/cgroup:/sys/fs/cgroup:ro # kurento-media-server: # build: # context: ./docker/kurento-media-server/ # container_name: kolab-kurento-media-server # environment: # - GST_DEBUG=3,Kurento*:4,kms*:4,sdp*:4,webrtc*:4,*rtpendpoint:4,rtp*handler:4,rtpsynchronizer:4,agnosticbin:4 # hostname: kurento-media-server.hosted.com # image: apheleia/kurento-media-server:6.15.0 # network_mode: host mariadb: container_name: kolab-mariadb environment: MYSQL_ROOT_PASSWORD: Welcome2KolabSystems TZ: "+02:00" healthcheck: interval: 10s test: test -e /var/run/mysqld/mysqld.sock timeout: 5s retries: 30 image: mariadb network_mode: host nginx: build: context: ./docker/nginx/ args: - NGINX_AUTH_WEBHOOK: ${APP_DOMAIN}/api/webhooks/nginx + APP_WEBSITE_DOMAIN: ${APP_WEBSITE_DOMAIN:?err} container_name: kolab-nginx - depends_on: - kolab: - condition: service_healthy hostname: nginx.hosted.com image: kolab-nginx network_mode: host tmpfs: - /run - /tmp - /var/run - /var/tmp tty: true volumes: - - /etc/letsencrypt/:/etc/letsencrypt/:ro - ./docker/certs/imap.hosted.com.cert:/etc/pki/tls/certs/imap.hosted.com.cert - ./docker/certs/imap.hosted.com.key:/etc/pki/tls/private/imap.hosted.com.key - - /sys/fs/cgroup:/sys/fs/cgroup:ro # openvidu: # build: # context: ./docker/openvidu/ # container_name: kolab-openvidu # depends_on: # - kurento-media-server # environment: # - APP_DOMAIN=${APP_DOMAIN} # - CERTIFICATE_TYPE=letsencrypt # - COTURN_IP=${OPENVIDU_COTURN_IP} # - COTURN_REDIS_DBNAME=${OPENVIDU_COTURN_REDIS_DATABASE} # - COTURN_REDIS_PASSWORD=${OPENVIDU_COTURN_REDIS_PASSWORD} # - COTURN_REDIS_IP=${OPENVIDU_COTURN_REDIS_IP} # - DOMAIN_OR_PUBLIC_IP=${OPENVIDU_PUBLIC_IP} # - SERVER_PORT=${OPENVIDU_SERVER_PORT} # - KMS_STUN_IP=${OPENVIDU_COTURN_IP} # - KMS_STUN_PORT=3478 # - KMS_URIS=["ws://localhost:8888/kurento", "ws://localhost:8889/kurento"] # - OPENVIDU_SECRET=${OPENVIDU_API_PASSWORD} # - OPENVIDU_WEBHOOK=${OPENVIDU_WEBHOOK} # - OPENVIDU_WEBHOOK_ENDPOINT=${OPENVIDU_WEBHOOK_ENDPOINT} # - SERVER_SSL_ENABLED=false # hostname: openvidu.hosted.com # image: apheleia/openvidu:2.18.0 # network_mode: host # tmpfs: # - /run # - /tmp # - /var/run # - /var/tmp # tty: true # volumes: # - /etc/letsencrypt/:/etc/letsencrypt/:ro pdns-sql: build: context: ./docker/pdns-sql/ container_name: kolab-pdns-sql depends_on: - mariadb hostname: pdns-sql image: apheleia/kolab-pdns-sql network_mode: host tmpfs: - /run - /tmp - /var/run - /var/tmp tty: true volumes: - /sys/fs/cgroup:/sys/fs/cgroup:ro proxy: build: context: ./docker/proxy/ container_name: kolab-proxy hostname: kanarip.internet-box.ch image: kolab-proxy network_mode: host tmpfs: - /run - /tmp - /var/run - /var/tmp tty: true volumes: - ./docker/certs/:/etc/certs/:ro - /etc/letsencrypt/:/etc/letsencrypt/:ro - /sys/fs/cgroup:/sys/fs/cgroup:ro redis: build: context: ./docker/redis/ container_name: kolab-redis hostname: redis image: redis network_mode: host volumes: - ./docker/redis/redis.conf:/usr/local/etc/redis/redis.conf:ro swoole: build: context: ./docker/swoole/ container_name: kolab-swoole image: apheleia/swoole:4.6.x worker: build: context: ./docker/worker/ container_name: kolab-worker depends_on: - kolab hostname: worker image: kolab-worker network_mode: host tmpfs: - /run - /tmp - /var/run - /var/tmp tty: true volumes: - ./src:/home/worker/src.orig:ro - /sys/fs/cgroup:/sys/fs/cgroup:ro meet: build: context: ./docker/meet/ network_mode: host container_name: kolab-meet volumes: - /etc/letsencrypt/:/etc/letsencrypt/:ro - ./meet/server:/src/meet/:ro - ./docker/meet/build/node_modules:/root/node_modules - ./docker/certs/kolab.hosted.com.cert:/etc/pki/tls/certs/kolab.hosted.com.cert - ./docker/certs/kolab.hosted.com.key:/etc/pki/tls/certs/kolab.hosted.com.key diff --git a/docker/nginx/Dockerfile b/docker/nginx/Dockerfile index 55f55686..3a4459f4 100644 --- a/docker/nginx/Dockerfile +++ b/docker/nginx/Dockerfile @@ -1,54 +1,25 @@ -FROM fedora:31 +FROM fedora:34 MAINTAINER Jeroen van Meeuwen ENV container docker -ENV SYSTEMD_PAGER='' - -ARG NGINX_AUTH_WEBHOOK RUN dnf -y install \ --setopt 'tsflags=nodocs' \ - bash-completion \ - bind-utils \ - certbot \ - curl \ - dhcp-client \ - git \ - iproute \ - iptraf-ng \ - iputils \ - less \ - lsof \ - mtr \ - net-tools \ - NetworkManager \ - NetworkManager-tui \ - network-scripts \ nginx \ - nginx-mod-mail \ - nmap-ncat \ - openssh-clients \ - openssh-server \ - procps-ng \ - python3-certbot-nginx \ - strace \ - systemd-udev \ - tcpdump \ - telnet \ - traceroute \ - vim-enhanced \ - wget && \ + nginx-mod-mail && \ dnf clean all -RUN sed -i -r -e 's/^SELINUX=.*$/SELINUX=permissive/g' /etc/selinux/config 2>/dev/null || : - COPY nginx.conf /etc/nginx/nginx.conf -RUN sed -i -r -e "s|^.*auth_http.*$| auth_http $NGINX_AUTH_WEBHOOK;|g" /etc/nginx/nginx.conf +ARG APP_WEBSITE_DOMAIN +RUN sed -i -r -e "s|^.*auth_http_header.*$| auth_http_header Host services.$APP_WEBSITE_DOMAIN;|g" /etc/nginx/nginx.conf + +# Forward request logs to Docker log collector +RUN ln -sf /dev/stdout /var/log/nginx/access.log \ + && ln -sf /dev/stderr /var/log/nginx/error.log -RUN systemctl enable nginx +STOPSIGNAL SIGTERM -CMD ["/lib/systemd/systemd", "--system"] -ENTRYPOINT "/lib/systemd/systemd" +CMD ["nginx", "-g", "daemon off;"] EXPOSE 110/tcp 143/tcp 993/tcp 995/tcp diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf index ca6d7a9d..cc288816 100644 --- a/docker/nginx/nginx.conf +++ b/docker/nginx/nginx.conf @@ -1,72 +1,73 @@ user nginx; worker_processes auto; error_log /var/log/nginx/error.log debug; pid /run/nginx.pid; # Load dynamic modules. See /usr/share/doc/nginx/README.dynamic. include /usr/share/nginx/modules/*.conf; events { worker_connections 1024; } mail { - server_name imap.hosted.com; - auth_http 127.0.0.1:8000/api/webhooks/nginx; + server_name imap.hosted.com; + auth_http 127.0.0.1:8000/api/webhooks/nginx; + auth_http_header Host 127.0.0.1; proxy_pass_error_message on; server { listen 143; protocol imap; proxy on; starttls on; ssl_certificate /etc/pki/tls/certs/imap.hosted.com.cert; ssl_certificate_key /etc/pki/tls/private/imap.hosted.com.key; ssl_protocols TLSv1 TLSv1.1 TLSv1.2; ssl_ciphers HIGH:!aNULL:!MD5; } server { listen 465 ssl; protocol smtp; proxy on; ssl_certificate /etc/pki/tls/certs/imap.hosted.com.cert; ssl_certificate_key /etc/pki/tls/private/imap.hosted.com.key; ssl_protocols TLSv1 TLSv1.1 TLSv1.2; ssl_ciphers HIGH:!aNULL:!MD5; } server { listen 587; protocol smtp; proxy on; starttls on; ssl_certificate /etc/pki/tls/certs/imap.hosted.com.cert; ssl_certificate_key /etc/pki/tls/private/imap.hosted.com.key; ssl_protocols TLSv1 TLSv1.1 TLSv1.2; ssl_ciphers HIGH:!aNULL:!MD5; } server { listen 993 ssl; protocol imap; proxy on; ssl_certificate /etc/pki/tls/certs/imap.hosted.com.cert; ssl_certificate_key /etc/pki/tls/private/imap.hosted.com.key; ssl_protocols TLSv1 TLSv1.1 TLSv1.2; ssl_ciphers HIGH:!aNULL:!MD5; } } diff --git a/src/.env.example b/src/.env.example index f645da43..cadae2fd 100644 --- a/src/.env.example +++ b/src/.env.example @@ -1,162 +1,167 @@ APP_NAME=Kolab APP_ENV=local APP_KEY= APP_DEBUG=true APP_URL=http://127.0.0.1:8000 #APP_PASSPHRASE= APP_PUBLIC_URL= APP_DOMAIN=kolabnow.com +APP_WEBSITE_DOMAIN=kolabnow.com APP_THEME=default APP_TENANT_ID=5 APP_LOCALE=en -APP_LOCALES=en,de +APP_LOCALES= APP_WITH_ADMIN=1 APP_WITH_RESELLER=1 APP_WITH_SERVICES=1 ASSET_URL=http://127.0.0.1:8000 WEBMAIL_URL=/apps SUPPORT_URL=/support SUPPORT_EMAIL= LOG_CHANNEL=stack LOG_SLOW_REQUESTS=5 DB_CONNECTION=mysql DB_DATABASE=kolabdev DB_HOST=127.0.0.1 DB_PASSWORD=kolab DB_PORT=3306 DB_USERNAME=kolabdev BROADCAST_DRIVER=redis CACHE_DRIVER=redis QUEUE_CONNECTION=redis SESSION_DRIVER=file SESSION_LIFETIME=120 OPENEXCHANGERATES_API_KEY="from openexchangerates.org" MFA_DSN=mysql://roundcube:Welcome2KolabSystems@127.0.0.1/roundcube MFA_TOTP_DIGITS=6 MFA_TOTP_INTERVAL=30 MFA_TOTP_DIGEST=sha1 IMAP_URI=ssl://127.0.0.1:11993 IMAP_ADMIN_LOGIN=cyrus-admin IMAP_ADMIN_PASSWORD=Welcome2KolabSystems IMAP_VERIFY_HOST=false IMAP_VERIFY_PEER=false LDAP_BASE_DN="dc=mgmt,dc=com" LDAP_DOMAIN_BASE_DN="ou=Domains,dc=mgmt,dc=com" LDAP_HOSTS=127.0.0.1 LDAP_PORT=389 LDAP_SERVICE_BIND_DN="uid=kolab-service,ou=Special Users,dc=mgmt,dc=com" LDAP_SERVICE_BIND_PW="Welcome2KolabSystems" LDAP_USE_SSL=false LDAP_USE_TLS=false # Administrative LDAP_ADMIN_BIND_DN="cn=Directory Manager" LDAP_ADMIN_BIND_PW="Welcome2KolabSystems" LDAP_ADMIN_ROOT_DN="dc=mgmt,dc=com" # Hosted (public registration) LDAP_HOSTED_BIND_DN="uid=hosted-kolab-service,ou=Special Users,dc=mgmt,dc=com" LDAP_HOSTED_BIND_PW="Welcome2KolabSystems" LDAP_HOSTED_ROOT_DN="dc=hosted,dc=com" OPENVIDU_API_PASSWORD=MY_SECRET OPENVIDU_API_URL=http://localhost:8080/api/ OPENVIDU_API_USERNAME=OPENVIDUAPP OPENVIDU_API_VERIFY_TLS=true OPENVIDU_COTURN_IP=127.0.0.1 OPENVIDU_COTURN_REDIS_DATABASE=2 OPENVIDU_COTURN_REDIS_IP=127.0.0.1 OPENVIDU_COTURN_REDIS_PASSWORD=turn # Used as COTURN_IP, TURN_PUBLIC_IP, for KMS_TURN_URL OPENVIDU_PUBLIC_IP=127.0.0.1 OPENVIDU_PUBLIC_PORT=3478 OPENVIDU_SERVER_PORT=8080 OPENVIDU_WEBHOOK=true OPENVIDU_WEBHOOK_ENDPOINT=http://127.0.0.1:8000/webhooks/meet/openvidu # "CDR" events, see https://docs.openvidu.io/en/2.13.0/reference-docs/openvidu-server-cdr/ #OPENVIDU_WEBHOOK_EVENTS=[sessionCreated,sessionDestroyed,participantJoined,participantLeft,webrtcConnectionCreated,webrtcConnectionDestroyed,recordingStatusChanged,filterEventDispatched,mediaNodeStatusChanged] #OPENVIDU_WEBHOOK_HEADERS=[\"Authorization:\ Basic\ SOMETHING\"] PGP_ENABLED= PGP_BINARY= PGP_AGENT= PGP_GPGCONF= PGP_LENGTH= REDIS_HOST=127.0.0.1 REDIS_PASSWORD=null REDIS_PORT=6379 SWOOLE_HOT_RELOAD_ENABLE=true SWOOLE_HTTP_ACCESS_LOG=true SWOOLE_HTTP_HOST=127.0.0.1 SWOOLE_HTTP_PORT=8000 SWOOLE_HTTP_REACTOR_NUM=1 SWOOLE_HTTP_WEBSOCKET=true SWOOLE_HTTP_WORKER_NUM=1 SWOOLE_OB_OUTPUT=true PAYMENT_PROVIDER= MOLLIE_KEY= STRIPE_KEY= STRIPE_PUBLIC_KEY= STRIPE_WEBHOOK_SECRET= MAIL_DRIVER=smtp MAIL_HOST=smtp.mailtrap.io MAIL_PORT=2525 MAIL_USERNAME=null MAIL_PASSWORD=null MAIL_ENCRYPTION=null MAIL_FROM_ADDRESS="noreply@example.com" MAIL_FROM_NAME="Example.com" MAIL_REPLYTO_ADDRESS="replyto@example.com" MAIL_REPLYTO_NAME=null DNS_TTL=3600 DNS_SPF="v=spf1 mx -all" DNS_STATIC="%s. MX 10 ext-mx01.mykolab.com." DNS_COPY_FROM=null AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= AWS_DEFAULT_REGION=us-east-1 AWS_BUCKET= PUSHER_APP_ID= PUSHER_APP_KEY= PUSHER_APP_SECRET= PUSHER_APP_CLUSTER=mt1 MIX_ASSET_PATH='/' MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}" MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" -PROXY_OAUTH_CLIENT_ID=1 -PROXY_OAUTH_CLIENT_SECRET=JF4pL68ucLuMupaOviTeG8EJeQpjtZtcGLp4f0dq +# Generate with ./artisan passport:client --password +#PASSPORT_PROXY_OAUTH_CLIENT_ID= +#PASSPORT_PROXY_OAUTH_CLIENT_SECRET= + +PASSPORT_PRIVATE_KEY= +PASSPORT_PUBLIC_KEY= COMPANY_NAME= COMPANY_ADDRESS= COMPANY_DETAILS= COMPANY_EMAIL= COMPANY_LOGO= COMPANY_FOOTER= VAT_COUNTRIES=CH,LI VAT_RATE=7.7 KB_ACCOUNT_DELETE= KB_ACCOUNT_SUSPENDED= diff --git a/src/app/AuthAttempt.php b/src/app/AuthAttempt.php index 6ffc76e9..eb0a3ad6 100644 --- a/src/app/AuthAttempt.php +++ b/src/app/AuthAttempt.php @@ -1,185 +1,197 @@ 'datetime', 'last_seen' => 'datetime' ]; + public $incrementing = false; + protected $keyType = 'string'; + /** * Prepare a date for array / JSON serialization. * * Required to not omit timezone and match the format of update_at/created_at timestamps. * * @param \DateTimeInterface $date * @return string */ - protected function serializeDate(\DateTimeInterface $date) + protected function serializeDate(\DateTimeInterface $date): string { return Carbon::instance($date)->toIso8601ZuluString('microseconds'); } /** * Returns true if the authentication attempt is accepted. * * @return bool */ - public function isAccepted() + public function isAccepted(): bool { - if ($this->status == 'ACCEPTED' && Carbon::now() < $this->expires_at) { + if ($this->status == self::STATUS_ACCEPTED && Carbon::now() < $this->expires_at) { return true; } return false; } /** * Returns true if the authentication attempt is denied. * * @return bool */ - public function isDenied() + public function isDenied(): bool { - return ($this->status == 'DENIED'); + return ($this->status == self::STATUS_DENIED); } /** * Accept the authentication attempt. */ - public function accept() + public function accept($reason = AuthAttempt::REASON_NONE) { $this->expires_at = Carbon::now()->addHours(8); - $this->status = "ACCEPTED"; - $this->reason = ''; + $this->status = self::STATUS_ACCEPTED; + $this->reason = $reason; + $this->save(); } /** * Deny the authentication attempt. */ - public function deny() + public function deny($reason = AuthAttempt::REASON_NONE) { - $this->status = "DENIED"; - $this->reason = ''; + $this->status = self::STATUS_DENIED; + $this->reason = $reason; + $this->save(); } /** * Notify the user of this authentication attempt. * * @return bool false if there was no means to notify */ - public function notify() + public function notify(): bool { return \App\CompanionApp::notifyUser($this->user_id, ['token' => $this->id]); } /** * Notify the user and wait for a confirmation. */ private function notifyAndWait() { if (!$this->notify()) { //FIXME if the webclient can confirm too we don't need to abort here. \Log::warning("There is no 2fa device to notify."); return false; } \Log::debug("Authentication attempt: {$this->id}"); $confirmationTimeout = 120; $timeout = Carbon::now()->addSeconds($confirmationTimeout); - $authAttempt = $this; do { - if ($authAttempt->isDenied()) { - \Log::debug("The authentication attempt was denied {$authAttempt->id}"); + if ($this->isDenied()) { + \Log::debug("The authentication attempt was denied {$this->id}"); return false; } - if ($authAttempt->isAccepted()) { - \Log::debug("The authentication attempt was accepted {$authAttempt->id}"); + if ($this->isAccepted()) { + \Log::debug("The authentication attempt was accepted {$this->id}"); return true; } if ($timeout < Carbon::now()) { - \Log::debug("The authentication attempt timed-out: {$authAttempt->id}"); + \Log::debug("The authentication attempt timed-out: {$this->id}"); return false; } sleep(2); - $authAttempt = $authAttempt->fresh(); + $this->refresh(); } while (true); } /** - * Record an authentication attempt + * Record a new authentication attempt or update an existing one. + * + * @param \App\User $user The user attempting to authenticate. + * @param string $clientIP The ip the authentication attempt is coming from. + * + * @return \App\AuthAttempt */ public static function recordAuthAttempt(\App\User $user, $clientIP) { $authAttempt = \App\AuthAttempt::where('ip', $clientIP)->where('user_id', $user->id)->first(); if (!$authAttempt) { $authAttempt = new \App\AuthAttempt(); $authAttempt->ip = $clientIP; $authAttempt->user_id = $user->id; } $authAttempt->last_seen = Carbon::now(); $authAttempt->save(); return $authAttempt; } /** * Trigger a notification if necessary and wait for confirmation. * - * @return bool + * @return bool Returns true if the attempt is accepted on confirmation */ - public function waitFor2FA() + public function waitFor2FA(): bool { if ($this->isAccepted()) { return true; } if ($this->isDenied()) { return false; } if (!$this->notifyAndWait()) { return false; } - // Ensure the authAttempt is now accepted - $freshAttempt = $this->fresh(); - return $freshAttempt->isAccepted(); + return $this->isAccepted(); } } diff --git a/src/app/CompanionApp.php b/src/app/CompanionApp.php index 04dae164..1720223b 100644 --- a/src/app/CompanionApp.php +++ b/src/app/CompanionApp.php @@ -1,82 +1,83 @@ $deviceIds, - 'data' => $data - ]; - - $headers = array( - 'Content-Type:application/json', - "Authorization:key={$apiKey}" + $client = new \GuzzleHttp\Client( + [ + 'verify' => \config('firebase.api_verify_tls') + ] + ); + $response = $client->request( + 'POST', + \config('firebase.api_url'), + [ + 'headers' => [ + 'Authorization' => "key={$apiKey}", + ], + 'json' => [ + 'registration_ids' => $deviceIds, + 'data' => $data + ] + ] ); - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, $url); - curl_setopt($ch, CURLOPT_POST, true); - curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0); - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); - curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($fields)); - $result = curl_exec($ch); - if ($result === false) { - throw new \Exception('FCM Send Error: ' . curl_error($ch)); + + if ($response->getStatusCode() != 200) { + throw new \Exception('FCM Send Error: ' . $response->getStatusCode()); } - curl_close($ch); - return $result; + return true; } /** * Send a notification to a user. * + * @throws \Exception on notification failure * @return bool true if a notification has been sent */ - public static function notifyUser($userId, $data) + public static function notifyUser($userId, $data): bool { $notificationTokens = \App\CompanionApp::where('user_id', $userId) ->where('mfa_enabled', true) - ->get() - ->map(function ($app) { - return $app->notification_token; - }) + ->pluck('notification_token') ->all(); if (empty($notificationTokens)) { \Log::debug("There is no 2fa device to notify."); return false; } self::pushFirebaseNotification($notificationTokens, $data); return true; } } diff --git a/src/app/Console/Commands/AuthAttempt/DeleteCommand.php b/src/app/Console/Commands/AuthAttempt/DeleteCommand.php new file mode 100644 index 00000000..a8c18622 --- /dev/null +++ b/src/app/Console/Commands/AuthAttempt/DeleteCommand.php @@ -0,0 +1,15 @@ +each( + function ($authAttempt) { + $this->info($authAttempt->toJson(JSON_PRETTY_PRINT)); + } + ); + } +} diff --git a/src/app/Console/Commands/AuthAttempt/PurgeCommand.php b/src/app/Console/Commands/AuthAttempt/PurgeCommand.php new file mode 100644 index 00000000..82033860 --- /dev/null +++ b/src/app/Console/Commands/AuthAttempt/PurgeCommand.php @@ -0,0 +1,36 @@ +subDays(30); + AuthAttempt::where('updated_at', '<', $cutoff) + ->delete(); + } +} diff --git a/src/app/Http/Controllers/API/AuthController.php b/src/app/Http/Controllers/API/AuthController.php index bae8d86f..f785702c 100644 --- a/src/app/Http/Controllers/API/AuthController.php +++ b/src/app/Http/Controllers/API/AuthController.php @@ -1,171 +1,174 @@ user(); $response = V4\UsersController::userResponse($user); if (!empty(request()->input('refresh'))) { return $this->refreshAndRespond(request(), $response); } return response()->json($response); } /** * Helper method for other controllers with user auto-logon * functionality * * @param \App\User $user User model object * @param string $password Plain text password * @param string|null $secondFactor Second factor code if available */ public static function logonResponse(User $user, string $password, string $secondFactor = null) { $proxyRequest = Request::create('/oauth/token', 'POST', [ 'username' => $user->email, 'password' => $password, 'grant_type' => 'password', - 'client_id' => config('auth.proxy.client_id'), - 'client_secret' => config('auth.proxy.client_secret'), + 'client_id' => \config('auth.proxy.client_id'), + 'client_secret' => \config('auth.proxy.client_secret'), 'scopes' => '[*]', 'secondfactor' => $secondFactor ]); $tokenResponse = app()->handle($proxyRequest); $response = V4\UsersController::userResponse($user); $response['status'] = 'success'; + return self::respondWithToken($tokenResponse, $response); } /** * Get an oauth token via given credentials. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse */ public function login(Request $request) { // TODO: Redirect to dashboard if authenticated. $v = Validator::make( $request->all(), [ 'email' => 'required|min:2', 'password' => 'required|min:4', ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } $user = \App\User::where('email', $request->email)->first(); + if (!$user) { return response()->json(['status' => 'error', 'message' => __('auth.failed')], 401); } + return self::logonResponse($user, $request->password, $request->secondfactor); } /** * Log the user out (Invalidate the token) * * @return \Illuminate\Http\JsonResponse */ public function logout() { $tokenId = Auth::user()->token()->id; $tokenRepository = app(TokenRepository::class); $refreshTokenRepository = app(RefreshTokenRepository::class); // Revoke an access token... $tokenRepository->revokeAccessToken($tokenId); // Revoke all of the token's refresh tokens... $refreshTokenRepository->revokeRefreshTokensByAccessTokenId($tokenId); return response()->json([ 'status' => 'success', 'message' => __('auth.logoutsuccess') ]); } /** * Refresh a token. * * @return \Illuminate\Http\JsonResponse */ public function refresh(Request $request) { return self::refreshAndRespond($request); } /** * Refresh the token and respond with it. * * @param \Illuminate\Http\Request $request The API request. * @param array $response Additional response data * * @return \Illuminate\Http\JsonResponse */ protected static function refreshAndRespond(Request $request, array $response = []) { $proxyRequest = Request::create('/oauth/token', 'POST', [ 'grant_type' => 'refresh_token', 'refresh_token' => $request->refresh_token, - 'client_id' => config('auth.proxy.client_id'), - 'client_secret' => config('auth.proxy.client_secret'), + 'client_id' => \config('auth.proxy.client_id'), + 'client_secret' => \config('auth.proxy.client_secret'), ]); $tokenResponse = app()->handle($proxyRequest); return self::respondWithToken($tokenResponse, $response); } /** * Get the token array structure. * * @param \Illuminate\Http\JsonResponse $tokenResponse The response containing the token. * @param array $response Additional response data * * @return \Illuminate\Http\JsonResponse */ protected static function respondWithToken($tokenResponse, array $response = []) { $data = json_decode($tokenResponse->getContent()); if ($tokenResponse->getStatusCode() != 200) { - if (isset($data->error) && $data->error == 'secondfactor') { - $errors = ['secondfactor' => $data['error_description']]; + if (isset($data->error) && $data->error == 'secondfactor' && isset($data->error_description)) { + $errors = ['secondfactor' => $data->error_description]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } return response()->json(['status' => 'error', 'message' => __('auth.failed')], 401); } $response['access_token'] = $data->access_token; $response['refresh_token'] = $data->refresh_token; $response['token_type'] = 'bearer'; $response['expires_in'] = $data->expires_in; return response()->json($response); } } diff --git a/src/app/Http/Controllers/API/V4/AuthAttemptsController.php b/src/app/Http/Controllers/API/V4/AuthAttemptsController.php index 22a172cd..ba885e4a 100644 --- a/src/app/Http/Controllers/API/V4/AuthAttemptsController.php +++ b/src/app/Http/Controllers/API/V4/AuthAttemptsController.php @@ -1,97 +1,120 @@ errorResponse(404); + } - $user = Auth::guard()->user(); + $user = $this->guard()->user(); if ($user->id != $authAttempt->user_id) { return $this->errorResponse(403); } \Log::debug("Confirm on {$authAttempt->id}"); $authAttempt->accept(); - $authAttempt->save(); - return response("", 200); + return response()->json([], 200); } + /** + * Deny the authentication attempt. + * + * @param string $id Id of AuthAttempt attempt + * + * @return \Illuminate\Http\JsonResponse + */ public function deny($id) { - $authAttempt = AuthAttempt::findOrFail($id); + $authAttempt = AuthAttempt::find($id); + if (!$authAttempt) { + return $this->errorResponse(404); + } - $user = Auth::guard()->user(); + $user = $this->guard()->user(); if ($user->id != $authAttempt->user_id) { return $this->errorResponse(403); } \Log::debug("Deny on {$authAttempt->id}"); $authAttempt->deny(); - $authAttempt->save(); - return response("", 200); + return response()->json([], 200); } + /** + * Return details of authentication attempt. + * + * @param string $id Id of AuthAttempt attempt + * + * @return \Illuminate\Http\JsonResponse + */ public function details($id) { - $authAttempt = AuthAttempt::findOrFail($id); - $user = Auth::guard()->user(); + $authAttempt = AuthAttempt::find($id); + if (!$authAttempt) { + return $this->errorResponse(404); + } - \Log::debug("Getting details {$authAttempt->user_id} {$user->id}"); + $user = $this->guard()->user(); if ($user->id != $authAttempt->user_id) { return $this->errorResponse(403); } - \Log::debug("Details on {$authAttempt->id}"); return response()->json([ 'status' => 'success', 'username' => $user->email, - 'ip' => $authAttempt->ip, - 'timestamp' => $authAttempt->updated_at, 'country' => \App\Utils::countryForIP($authAttempt->ip), 'entry' => $authAttempt->toArray() ]); } /** * Listing of client authAttempts. * * All authAttempt attempts from the current user * * @return \Illuminate\Http\JsonResponse */ public function index(Request $request) { - $user = Auth::guard()->user(); + $user = $this->guard()->user(); $pageSize = 10; $page = intval($request->input('page')) ?: 1; $hasMore = false; $result = \App\AuthAttempt::where('user_id', $user->id) ->orderBy('updated_at', 'desc') ->limit($pageSize + 1) ->offset($pageSize * ($page - 1)) ->get(); if (count($result) > $pageSize) { $result->pop(); $hasMore = true; } $result = $result->map(function ($authAttempt) { return $authAttempt->toArray(); }); return response()->json($result); } } diff --git a/src/app/Http/Controllers/API/V4/CompanionAppsController.php b/src/app/Http/Controllers/API/V4/CompanionAppsController.php index 5d53d808..70e7ecc5 100644 --- a/src/app/Http/Controllers/API/V4/CompanionAppsController.php +++ b/src/app/Http/Controllers/API/V4/CompanionAppsController.php @@ -1,43 +1,58 @@ user(); - if (!$user) { - throw new \Exception("Authentication required."); + $user = $this->guard()->user(); + + $v = Validator::make( + $request->all(), + [ + 'notificationToken' => 'required|min:4|max:512', + 'deviceId' => 'required|min:4|max:64', + ] + ); + + if ($v->fails()) { + return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } + $notificationToken = $request->notificationToken; $deviceId = $request->deviceId; \Log::info("Registering app. Notification token: {$notificationToken} Device id: {$deviceId}"); $app = \App\CompanionApp::where('device_id', $deviceId)->first(); if (!$app) { $app = new \App\CompanionApp(); $app->user_id = $user->id; $app->device_id = $deviceId; $app->mfa_enabled = true; + } else { + //FIXME this allows a user to probe for another users deviceId + if ($app->user_id != $user->id) { + \Log::warning("User mismatch on device registration. Expected {$user->id} but found {$app->user_id}"); + return $this->errorResponse(403); + } } $app->notification_token = $notificationToken; $app->save(); - $result['status'] = 'success'; - return response()->json($result); + return response()->json(['status' => 'success']); } } diff --git a/src/app/Http/Controllers/API/V4/NGINXController.php b/src/app/Http/Controllers/API/V4/NGINXController.php new file mode 100644 index 00000000..82fa08de --- /dev/null +++ b/src/app/Http/Controllers/API/V4/NGINXController.php @@ -0,0 +1,203 @@ + + * I suppose that's not necessary given that we have the information avialable in the headers? + * + * @param \Illuminate\Http\Request $request The API request. + * + * @return \Illuminate\Http\Response The response + */ + public function authenticate(Request $request) + { + /** + * Auth-Login-Attempt: 1 + * Auth-Method: plain + * Auth-Pass: simple123 + * Auth-Protocol: imap + * Auth-Ssl: on + * Auth-User: john@kolab.org + * Client-Ip: 127.0.0.1 + * Host: 127.0.0.1 + * + * Auth-SSL: on + * Auth-SSL-Verify: SUCCESS + * Auth-SSL-Subject: /CN=example.com + * Auth-SSL-Issuer: /CN=example.com + * Auth-SSL-Serial: C07AD56B846B5BFF + * Auth-SSL-Fingerprint: 29d6a80a123d13355ed16b4b04605e29cb55a5ad + */ + + \Log::debug("Authentication attempt"); + \Log::debug($request->headers); + + $login = $request->headers->get('Auth-User', null); + + if (empty($login)) { + return $this->byebye($request, "Empty login"); + } + + // validate password, otherwise bye bye + $password = $request->headers->get('Auth-Pass', null); + + if (empty($password)) { + return $this->byebye($request, "Empty password"); + } + + $clientIP = $request->headers->get('Client-Ip', null); + + if (empty($clientIP)) { + return $this->byebye($request, "No client ip"); + } + + // validate user exists, otherwise bye bye + $user = \App\User::where('email', $login)->first(); + + if (!$user) { + return $this->byebye($request, "User not found"); + } + + // TODO: validate the user's domain is A-OK (active, confirmed, not suspended, ldapready) + // TODO: validate the user is A-OK (active, not suspended, ldapready, imapready) + + if (!Hash::check($password, $user->password)) { + $attempt = \App\AuthAttempt::recordAuthAttempt($user, $clientIP); + // Avoid setting a password failure reason if we previously accepted the location. + if (!$attempt->isAccepted()) { + $attempt->reason = \App\AuthAttempt::REASON_PASSWORD; + $attempt->save(); + $attempt->notify(); + } + \Log::info("Failed authentication attempt due to password mismatch for user: {$login}"); + return $this->byebye($request, "Password mismatch"); + } + + // validate country of origin against restrictions, otherwise bye bye + $countryCodes = json_decode($user->getSetting('limit_geo', "[]")); + + \Log::debug("Countries for {$user->email}: " . var_export($countryCodes, true)); + + if (!empty($countryCodes)) { + $country = \App\Utils::countryForIP($clientIP); + if (!in_array($country, $countryCodes)) { + \Log::info( + "Failed authentication attempt due to country code mismatch ({$country}) for user: {$login}" + ); + $attempt = \App\AuthAttempt::recordAuthAttempt($user, $clientIP); + $attempt->deny(\App\AuthAttempt::REASON_GEOLOCATION); + $attempt->notify(); + return $this->byebye($request, "Country code mismatch"); + } + } + + // TODO: Apply some sort of limit for Auth-Login-Attempt -- docs say it is the number of + // attempts over the same authAttempt. + + // Check 2fa + if ($user->getSetting('2fa_enabled', false)) { + $authAttempt = \App\AuthAttempt::recordAuthAttempt($user, $clientIP); + if (!$authAttempt->waitFor2FA()) { + return $this->byebye($request, "2fa failed"); + } + } + + // All checks passed + switch ($request->headers->get('Auth-Protocol')) { + case "imap": + return $this->authenticateIMAP($request, $user->getSetting('guam_enabled', false), $password); + case "smtp": + return $this->authenticateSMTP($request, $password); + default: + return $this->byebye($request, "unknown protocol in request"); + } + } + + /** + * Create an imap authentication response. + * + * @param \Illuminate\Http\Request $request The API request. + * @param bool $prefGuam Wether or not guam is enabled. + * @param string $password The password to include in the response. + * + * @return \Illuminate\Http\Response The response + */ + private function authenticateIMAP(Request $request, $prefGuam, $password) + { + if ($prefGuam) { + $port = \config('imap.guam_port'); + } else { + $port = \config('imap.imap_port'); + } + + $response = response("")->withHeaders( + [ + "Auth-Status" => "OK", + "Auth-Server" => \config('imap.host'), + "Auth-Port" => $port, + "Auth-Pass" => $password + ] + ); + + \Log::debug("Response with headers:\n{$response->headers}"); + + return $response; + } + + /** + * Create an smtp authentication response. + * + * @param \Illuminate\Http\Request $request The API request. + * @param string $password The password to include in the response. + * + * @return \Illuminate\Http\Response The response + */ + private function authenticateSMTP(Request $request, $password) + { + $response = response("")->withHeaders( + [ + "Auth-Status" => "OK", + "Auth-Server" => \config('smtp.host'), + "Auth-Port" => \config('smtp.port'), + "Auth-Pass" => $password + ] + ); + + \Log::debug("Response with headers:\n{$response->headers}"); + + return $response; + } + + /** + * Create a failed-authentication response. + * + * @param \Illuminate\Http\Request $request The API request. + * @param string $reason The reason for the failure. + * + * @return \Illuminate\Http\Response The response + */ + private function byebye(Request $request, $reason = null) + { + \Log::debug("Byebye: {$reason}"); + $response = response("")->withHeaders( + [ + "Auth-Status" => "authentication failure", + "Auth-Wait" => 3 + ] + ); + + \Log::debug("Response with headers:\n{$response->headers}"); + + return $response; + } +} diff --git a/src/app/Http/Controllers/ContentController.php b/src/app/Http/Controllers/ContentController.php index e86d07b6..93df377e 100644 --- a/src/app/Http/Controllers/ContentController.php +++ b/src/app/Http/Controllers/ContentController.php @@ -1,173 +1,173 @@ with('env', \App\Utils::uiEnv()); } /** * Get the list of FAQ entries for the specified page * * @param string $page Page path * * @return \Illuminate\Http\JsonResponse JSON response */ public function faqContent(string $page) { if (empty($page)) { return $this->errorResponse(404); } $faq = []; $theme_name = \config('app.theme'); $theme_file = resource_path("themes/{$theme_name}/theme.json"); if (file_exists($theme_file)) { $theme = json_decode(file_get_contents($theme_file), true); if (json_last_error() != JSON_ERROR_NONE) { \Log::error("Failed to parse $theme_file: " . json_last_error_msg()); } elseif (!empty($theme['faq']) && !empty($theme['faq'][$page])) { $faq = $theme['faq'][$page]; } // TODO: Support pages with variables, e.g. users/ } // Localization if (!empty($faq)) { self::loadLocale($theme_name); foreach ($faq as $idx => $item) { if (!empty($item['label'])) { $faq[$idx]['title'] = \trans('theme::faq.' . $item['label']); } } } return response()->json(['status' => 'success', 'faq' => $faq]); } /** * Returns list of enabled locales * * @return array List of two-letter language codes */ public static function locales(): array { if ($locales = \env('APP_LOCALES')) { return preg_split('/\s*,\s*/', strtolower(trim($locales))); } - return ['en', 'de']; + return ['en', 'de', 'fr']; } /** * Get menu definition from the theme * * @return array */ public static function menu(): array { $theme_name = \config('app.theme'); $theme_file = resource_path("themes/{$theme_name}/theme.json"); $menu = []; if (file_exists($theme_file)) { $theme = json_decode(file_get_contents($theme_file), true); if (json_last_error() != JSON_ERROR_NONE) { \Log::error("Failed to parse $theme_file: " . json_last_error_msg()); } elseif (!empty($theme['menu'])) { $menu = $theme['menu']; } } // TODO: These 2-3 lines could become a utility function somewhere $req_domain = preg_replace('/:[0-9]+$/', '', request()->getHttpHost()); $sys_domain = \config('app.domain'); $isAdmin = $req_domain == "admin.$sys_domain"; $filter = function ($item) use ($isAdmin) { if ($isAdmin && empty($item['admin'])) { return false; } if (!$isAdmin && !empty($item['admin']) && $item['admin'] === 'only') { return false; } return true; }; $menu = array_values(array_filter($menu, $filter)); // Load localization files for all supported languages $lang_path = resource_path("themes/{$theme_name}/lang"); $locales = []; foreach (self::locales() as $lang) { $file = "{$lang_path}/{$lang}/menu.php"; if (file_exists($file)) { $locales[$lang] = include $file; } } foreach ($menu as $idx => $item) { // Handle menu localization if (!empty($item['label'])) { $label = $item['label']; foreach ($locales as $lang => $labels) { if (!empty($labels[$label])) { $item["title-{$lang}"] = $labels[$label]; } } } // Unset properties that we don't need on the client side unset($item['admin'], $item['label']); $menu[$idx] = $item; } return $menu; } /** * Register localization files from the theme. * * @param string $theme Theme name */ protected static function loadLocale(string $theme): void { $path = resource_path(sprintf('themes/%s/lang', $theme)); \app('translator')->addNamespace('theme', $path); } } diff --git a/src/app/Http/Middleware/Locale.php b/src/app/Http/Middleware/Locale.php index bf791954..e4ffb14d 100644 --- a/src/app/Http/Middleware/Locale.php +++ b/src/app/Http/Middleware/Locale.php @@ -1,61 +1,69 @@ cookie('language')) && in_array($cookie, $enabledLanguages) && ($cookie == $default || file_exists("$langDir/$cookie")) ) { $lang = $cookie; } // If there's no cookie select try the browser languages if (!$lang) { $preferences = array_map( function ($lang) { return preg_replace('/[^a-z].*$/', '', strtolower($lang)); }, $request->getLanguages() ); foreach ($preferences as $pref) { if ( !empty($pref) && in_array($pref, $enabledLanguages) && ($pref == $default || file_exists("$langDir/$pref")) ) { $lang = $pref; break; } } } - if ($lang != $default) { + if (!$lang) { + $lang = $default; + } + + if (!app()->isLocale($lang)) { app()->setLocale($lang); } return $next($request); } } diff --git a/src/app/Observers/AuthAttemptObserver.php b/src/app/Observers/AuthAttemptObserver.php new file mode 100644 index 00000000..5a07428b --- /dev/null +++ b/src/app/Observers/AuthAttemptObserver.php @@ -0,0 +1,31 @@ +{$authAttempt->getKeyName()} = $allegedly_unique; + break; + } + } + } +} diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php index 73539e90..5dcecd4d 100644 --- a/src/app/Providers/AppServiceProvider.php +++ b/src/app/Providers/AppServiceProvider.php @@ -1,165 +1,166 @@ format('Y-m-d h:i:s'); } return $entry; }, $array); return implode(', ', $serialized); } /** * Bootstrap any application services. * * @return void */ public function boot() { + \App\AuthAttempt::observe(\App\Observers\AuthAttemptObserver::class); \App\Discount::observe(\App\Observers\DiscountObserver::class); \App\Domain::observe(\App\Observers\DomainObserver::class); \App\Entitlement::observe(\App\Observers\EntitlementObserver::class); \App\Group::observe(\App\Observers\GroupObserver::class); \App\OpenVidu\Connection::observe(\App\Observers\OpenVidu\ConnectionObserver::class); \App\Package::observe(\App\Observers\PackageObserver::class); \App\PackageSku::observe(\App\Observers\PackageSkuObserver::class); \App\Plan::observe(\App\Observers\PlanObserver::class); \App\PlanPackage::observe(\App\Observers\PlanPackageObserver::class); \App\SignupCode::observe(\App\Observers\SignupCodeObserver::class); \App\SignupInvitation::observe(\App\Observers\SignupInvitationObserver::class); \App\Sku::observe(\App\Observers\SkuObserver::class); \App\Transaction::observe(\App\Observers\TransactionObserver::class); \App\User::observe(\App\Observers\UserObserver::class); \App\UserAlias::observe(\App\Observers\UserAliasObserver::class); \App\UserSetting::observe(\App\Observers\UserSettingObserver::class); \App\VerificationCode::observe(\App\Observers\VerificationCodeObserver::class); \App\Wallet::observe(\App\Observers\WalletObserver::class); \App\PowerDNS\Domain::observe(\App\Observers\PowerDNS\DomainObserver::class); \App\PowerDNS\Record::observe(\App\Observers\PowerDNS\RecordObserver::class); Schema::defaultStringLength(191); // Log SQL queries in debug mode if (\config('app.debug')) { DB::listen(function ($query) { \Log::debug( sprintf( '[SQL] %s [%s]: %.4f sec.', $query->sql, self::serializeSQLBindings($query->bindings), $query->time / 1000 ) ); }); } // Register some template helpers Blade::directive( 'theme_asset', function ($path) { $path = trim($path, '/\'"'); return ""; } ); Builder::macro( 'withEnvTenantContext', function (string $table = null) { $tenantId = \config('app.tenant_id'); if ($tenantId) { /** @var Builder $this */ return $this->where(($table ? "$table." : "") . "tenant_id", $tenantId); } /** @var Builder $this */ return $this->whereNull(($table ? "$table." : "") . "tenant_id"); } ); Builder::macro( 'withObjectTenantContext', function (object $object, string $table = null) { $tenantId = $object->tenant_id; if ($tenantId) { /** @var Builder $this */ return $this->where(($table ? "$table." : "") . "tenant_id", $tenantId); } /** @var Builder $this */ return $this->whereNull(($table ? "$table." : "") . "tenant_id"); } ); Builder::macro( 'withSubjectTenantContext', function (string $table = null) { if ($user = auth()->user()) { $tenantId = $user->tenant_id; } else { $tenantId = \config('app.tenant_id'); } if ($tenantId) { /** @var Builder $this */ return $this->where(($table ? "$table." : "") . "tenant_id", $tenantId); } /** @var Builder $this */ return $this->whereNull(($table ? "$table." : "") . "tenant_id"); } ); // Query builder 'whereLike' mocro Builder::macro( 'whereLike', function (string $column, string $search, int $mode = 0) { $search = addcslashes($search, '%_'); switch ($mode) { case 2: $search .= '%'; break; case 1: $search = '%' . $search; break; default: $search = '%' . $search . '%'; } /** @var Builder $this */ return $this->where($column, 'like', $search); } ); } } diff --git a/src/composer.json b/src/composer.json index a30d6bf5..fa399f1f 100644 --- a/src/composer.json +++ b/src/composer.json @@ -1,85 +1,86 @@ { "name": "laravel/laravel", "type": "project", "description": "The Laravel Framework.", "keywords": [ "framework", "laravel" ], "license": "MIT", "repositories": [ { "type": "vcs", "url": "https://git.kolab.org/diffusion/PNL/php-net_ldap3.git" } ], "require": { "php": "^7.3", "barryvdh/laravel-dompdf": "^0.8.6", "doctrine/dbal": "^2.13", "dyrynda/laravel-nullable-fields": "*", "fideloper/proxy": "^4.0", "guzzlehttp/guzzle": "^7.3", "kolab/net_ldap3": "dev-master", "laravel/framework": "6.*", "laravel/horizon": "^3", "laravel/passport": "^9", "laravel/tinker": "^2.4", "mlocati/spf-lib": "^3.0", "mollie/laravel-mollie": "^2.9", + "moontoast/math": "^1.2", "morrislaptop/laravel-queue-clear": "^1.2", "pear/crypt_gpg": "dev-master", "silviolleite/laravelpwa": "^2.0", "spatie/laravel-translatable": "^4.2", "spomky-labs/otphp": "~4.0.0", "stripe/stripe-php": "^7.29", "swooletw/laravel-swoole": "^2.6" }, "require-dev": { "beyondcode/laravel-er-diagram-generator": "^1.3", "code-lts/doctum": "^5.1", "kirschbaum-development/mail-intercept": "^0.2.4", "laravel/dusk": "~6.15.0", "nunomaduro/larastan": "^0.7", "phpstan/phpstan": "^0.12", "phpunit/phpunit": "^9" }, "config": { "optimize-autoloader": true, "preferred-install": "dist", "sort-packages": true }, "extra": { "laravel": { "dont-discover": [] } }, "autoload": { "psr-4": { "App\\": "app/" }, "classmap": [ "database/seeds", "include" ] }, "autoload-dev": { "psr-4": { "Tests\\": "tests/" } }, "minimum-stability": "dev", "prefer-stable": true, "scripts": { "post-autoload-dump": [ "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", "@php artisan package:discover --ansi" ], "post-root-package-install": [ "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"" ], "post-create-project-cmd": [ "@php artisan key:generate --ansi" ] } } diff --git a/src/config/auth.php b/src/config/auth.php index ddecc528..d72f2dc1 100644 --- a/src/config/auth.php +++ b/src/config/auth.php @@ -1,123 +1,123 @@ [ 'guard' => 'api', 'passwords' => 'users', ], /* |-------------------------------------------------------------------------- | Authentication Guards |-------------------------------------------------------------------------- | | Next, you may define every authentication guard for your application. | Of course, a great default configuration has been defined for you | here which uses session storage and the Eloquent user provider. | | All authentication drivers have a user provider. This defines how the | users are actually retrieved out of your database or other storage | mechanisms used by this application to persist your user's data. | | Supported: "session", "token" | */ 'guards' => [ 'web' => [ 'driver' => 'session', 'provider' => 'users', ], 'api' => [ 'driver' => 'passport', 'provider' => 'users', ], ], /* |-------------------------------------------------------------------------- | User Providers |-------------------------------------------------------------------------- | | All authentication drivers have a user provider. This defines how the | users are actually retrieved out of your database or other storage | mechanisms used by this application to persist your user's data. | | If you have multiple user tables or models you may configure multiple | sources which represent each model / table. These sources may then | be assigned to any extra authentication guards you have defined. | | Supported: "database", "eloquent" | */ 'providers' => [ 'users' => [ 'driver' => 'ldap', 'model' => App\User::class, ], // 'users' => [ // 'driver' => 'database', // 'table' => 'users', // ], ], /* |-------------------------------------------------------------------------- | Resetting Passwords |-------------------------------------------------------------------------- | | You may specify multiple password reset configurations if you have more | than one user table or model in the application and you want to have | separate password reset settings based on the specific user types. | | The expire time is the number of minutes that the reset token should be | considered valid. This security feature keeps tokens short-lived so | they have less time to be guessed. You may change this as needed. | */ 'passwords' => [ 'users' => [ 'provider' => 'users', 'table' => 'password_resets', 'expire' => 60, ], ], /* |-------------------------------------------------------------------------- | OAuth Proxy Authentication |-------------------------------------------------------------------------- | | If you are planning to use your application to self-authenticate as a | proxy, you can define the client and grant type to use here. This is | sometimes the case when a trusted Single Page Application doesn't | use a backend to send the authentication request, but instead | relies on the API to handle proxying the request to itself. | */ 'proxy' => [ - 'client_id' => env('PROXY_OAUTH_CLIENT_ID'), - 'client_secret' => env('PROXY_OAUTH_CLIENT_SECRET'), + 'client_id' => env('PASSPORT_PROXY_OAUTH_CLIENT_ID'), + 'client_secret' => env('PASSPORT_PROXY_OAUTH_CLIENT_SECRET'), ], 'token_expiry_minutes' => env('OAUTH_TOKEN_EXPIRY', 60), 'refresh_token_expiry_minutes' => env('OAUTH_REFRESH_TOKEN_EXPIRY', 30 * 24 * 60), ]; diff --git a/src/config/firebase.php b/src/config/firebase.php index e856f8e1..9c842771 100644 --- a/src/config/firebase.php +++ b/src/config/firebase.php @@ -1,6 +1,7 @@ Project Settings -> CLOUD MESSAGING -> Server key*/ 'api_key' => env('FIREBASE_API_KEY'), 'api_url' => env('FIREBASE_API_URL', 'https://fcm.googleapis.com/fcm/send'), + 'api_verify_tls' => (bool) env('FIREBASE_API_VERIFY_TLS', true) ]; diff --git a/src/config/imap.php b/src/config/imap.php index 11bb7bcf..c0ee8dc0 100644 --- a/src/config/imap.php +++ b/src/config/imap.php @@ -1,14 +1,12 @@ env('IMAP_URI', '127.0.0.1'), 'admin_login' => env('IMAP_ADMIN_LOGIN', 'cyrus-admin'), 'admin_password' => env('IMAP_ADMIN_PASSWORD', null), 'verify_peer' => env('IMAP_VERIFY_PEER', true), 'verify_host' => env('IMAP_VERIFY_HOST', true), 'host' => env('IMAP_HOST', '127.0.0.1'), - 'guam_tls_port' => env('IMAP_GUAM_TLS_PORT', 9993), + 'imap_port' => env('IMAP_PORT', 12143), 'guam_port' => env('IMAP_GUAM_PORT', 9143), - 'tls_port' => env('IMAP_TLS_PORT', 11993), - 'port' => env('IMAP_PORT', 12143), ]; diff --git a/src/config/passport.php b/src/config/passport.php new file mode 100644 index 00000000..d0fd84ef --- /dev/null +++ b/src/config/passport.php @@ -0,0 +1,66 @@ + env('PASSPORT_PRIVATE_KEY'), + + 'public_key' => env('PASSPORT_PUBLIC_KEY'), + + /* + |-------------------------------------------------------------------------- + | Client UUIDs + |-------------------------------------------------------------------------- + | + | By default, Passport uses auto-incrementing primary keys when assigning + | IDs to clients. However, if Passport is installed using the provided + | --uuids switch, this will be set to "true" and UUIDs will be used. + | + */ + + 'client_uuids' => true, + + /* + |-------------------------------------------------------------------------- + | Personal Access Client + |-------------------------------------------------------------------------- + | + | If you enable client hashing, you should set the personal access client + | ID and unhashed secret within your environment file. The values will + | get used while issuing fresh personal access tokens to your users. + | + */ + + 'personal_access_client' => [ + 'id' => env('PASSPORT_PERSONAL_ACCESS_CLIENT_ID'), + 'secret' => env('PASSPORT_PERSONAL_ACCESS_CLIENT_SECRET'), + ], + + /* + |-------------------------------------------------------------------------- + | Passport Storage Driver + |-------------------------------------------------------------------------- + | + | This configuration value allows you to customize the storage options + | for Passport, such as the database connection that should be used + | by Passport's internal database models which store tokens, etc. + | + */ + + 'storage' => [ + 'database' => [ + 'connection' => env('DB_CONNECTION', 'mysql'), + ], + ], + +]; diff --git a/src/config/swoole_http.php b/src/config/swoole_http.php index 2431c1c7..73befcf2 100644 --- a/src/config/swoole_http.php +++ b/src/config/swoole_http.php @@ -1,141 +1,141 @@ [ 'host' => env('SWOOLE_HTTP_HOST', '127.0.0.1'), 'port' => env('SWOOLE_HTTP_PORT', '1215'), 'public_path' => base_path('public'), // Determine if to use swoole to respond request for static files 'handle_static_files' => env('SWOOLE_HANDLE_STATIC', true), 'access_log' => env('SWOOLE_HTTP_ACCESS_LOG', false), // You must add --enable-openssl while compiling Swoole // Put `SWOOLE_SOCK_TCP | SWOOLE_SSL` if you want to enable SSL 'socket_type' => SWOOLE_SOCK_TCP, 'process_type' => SWOOLE_PROCESS, 'options' => [ 'pid_file' => env('SWOOLE_HTTP_PID_FILE', base_path('storage/logs/swoole_http.pid')), 'log_file' => env('SWOOLE_HTTP_LOG_FILE', base_path('storage/logs/swoole_http.log')), 'daemonize' => env('SWOOLE_HTTP_DAEMONIZE', false), // Normally this value should be 1~4 times larger according to your cpu cores. 'reactor_num' => env('SWOOLE_HTTP_REACTOR_NUM', swoole_cpu_num()), 'worker_num' => env('SWOOLE_HTTP_WORKER_NUM', swoole_cpu_num()), 'task_worker_num' => env('SWOOLE_HTTP_TASK_WORKER_NUM', swoole_cpu_num()), // The data to receive can't be larger than buffer_output_size. 'package_max_length' => 20 * 1024 * 1024, // The data to send can't be larger than buffer_output_size. 'buffer_output_size' => 10 * 1024 * 1024, // Max buffer size for socket connections 'socket_buffer_size' => 128 * 1024 * 1024, // Worker will restart after processing this number of requests 'max_request' => 3000, // Enable coroutine send 'send_yield' => true, // You must add --enable-openssl while compiling Swoole 'ssl_cert_file' => null, 'ssl_key_file' => null, ], ], /* |-------------------------------------------------------------------------- | Enable to turn on websocket server. |-------------------------------------------------------------------------- */ 'websocket' => [ 'enabled' => env('SWOOLE_HTTP_WEBSOCKET', false), ], /* |-------------------------------------------------------------------------- | Hot reload configuration |-------------------------------------------------------------------------- */ 'hot_reload' => [ 'enabled' => env('SWOOLE_HOT_RELOAD_ENABLE', false), 'recursively' => env('SWOOLE_HOT_RELOAD_RECURSIVELY', true), 'directory' => env('SWOOLE_HOT_RELOAD_DIRECTORY', base_path()), 'log' => env('SWOOLE_HOT_RELOAD_LOG', true), 'filter' => env('SWOOLE_HOT_RELOAD_FILTER', '.php'), ], /* |-------------------------------------------------------------------------- | Console output will be transferred to response content if enabled. |-------------------------------------------------------------------------- */ 'ob_output' => env('SWOOLE_OB_OUTPUT', true), /* |-------------------------------------------------------------------------- | Pre-resolved instances here will be resolved when sandbox created. |-------------------------------------------------------------------------- */ 'pre_resolved' => [ 'view', 'files', 'session', 'session.store', 'routes', 'db', 'db.factory', 'cache', 'cache.store', 'config', 'cookie', 'encrypter', 'hash', 'router', 'translator', 'url', 'log', ], /* |-------------------------------------------------------------------------- | Instances here will be cleared on every request. |-------------------------------------------------------------------------- */ 'instances' => [ - 'auth', + 'auth', 'translator' ], /* |-------------------------------------------------------------------------- | Providers here will be registered on every request. |-------------------------------------------------------------------------- */ 'providers' => [ Illuminate\Pagination\PaginationServiceProvider::class, App\Providers\AuthServiceProvider::class, //Without this passport will sort of work, //but PassportServiceProvider will not contain a valid app instance. App\Providers\PassportServiceProvider::class, ], /* |-------------------------------------------------------------------------- | Resetters for sandbox app. |-------------------------------------------------------------------------- */ 'resetters' => [ SwooleTW\Http\Server\Resetters\ResetConfig::class, SwooleTW\Http\Server\Resetters\ResetSession::class, SwooleTW\Http\Server\Resetters\ResetCookie::class, SwooleTW\Http\Server\Resetters\ClearInstances::class, SwooleTW\Http\Server\Resetters\BindRequest::class, SwooleTW\Http\Server\Resetters\RebindKernelContainer::class, SwooleTW\Http\Server\Resetters\RebindRouterContainer::class, SwooleTW\Http\Server\Resetters\RebindViewContainer::class, SwooleTW\Http\Server\Resetters\ResetProviders::class, ], /* |-------------------------------------------------------------------------- | Define your swoole tables here. | | @see https://www.swoole.co.uk/docs/modules/swoole-table |-------------------------------------------------------------------------- */ 'tables' => [ // 'table_name' => [ // 'size' => 1024, // 'columns' => [ // ['name' => 'column_name', 'type' => Table::TYPE_STRING, 'size' => 1024], // ] // ], ], ]; diff --git a/src/database/migrations/2021_03_25_144555_create_auth_attempts_table.php b/src/database/migrations/2021_03_25_144555_create_auth_attempts_table.php index f76420fb..009fa335 100644 --- a/src/database/migrations/2021_03_25_144555_create_auth_attempts_table.php +++ b/src/database/migrations/2021_03_25_144555_create_auth_attempts_table.php @@ -1,45 +1,46 @@ bigIncrements('id'); - $table->bigInteger('user_id')->index(); + $table->uuid('id')->primary(); + $table->bigInteger('user_id'); $table->string('ip', 36); $table->string('status', 36)->default('NEW'); $table->string('reason', 36)->nullable(); $table->datetime('expires_at')->nullable(); $table->datetime('last_seen')->nullable(); $table->timestamps(); - $table->index(['user_id', 'ip']); + $table->index('updated_at'); + $table->unique(['user_id', 'ip']); $table->foreign('user_id') ->references('id')->on('users') ->onDelete('cascade') ->onUpdate('cascade'); }); } /** * Reverse the migrations. * * @return void */ public function down() { Schema::dropIfExists('auth_attempts'); } } diff --git a/src/database/migrations/2021_04_28_090011_create_oauth_tables.php b/src/database/migrations/2021_04_28_090011_create_oauth_tables.php index c3485b8c..109098de 100644 --- a/src/database/migrations/2021_04_28_090011_create_oauth_tables.php +++ b/src/database/migrations/2021_04_28_090011_create_oauth_tables.php @@ -1,92 +1,122 @@ bigIncrements('id'); - $table->bigInteger('user_id')->nullable()->index(); - $table->string('name'); - $table->string('secret', 100)->nullable(); - $table->string('provider')->nullable(); - $table->text('redirect'); - $table->boolean('personal_access_client'); - $table->boolean('password_client'); - $table->boolean('revoked'); - $table->timestamps(); + Schema::create( + 'oauth_clients', + function (Blueprint $table) { + $table->uuid('id')->primary(); + $table->bigInteger('user_id')->nullable()->index(); + $table->string('name'); + $table->string('secret', 100)->nullable(); + $table->string('provider')->nullable(); + $table->text('redirect'); + $table->boolean('personal_access_client'); + $table->boolean('password_client'); + $table->boolean('revoked'); + $table->timestamps(); - $table->foreign('user_id') - ->references('id')->on('users') - ->onDelete('cascade') - ->onUpdate('cascade'); - }); + $table->foreign('user_id') + ->references('id')->on('users') + ->onDelete('cascade') + ->onUpdate('cascade'); + } + ); - Schema::create('oauth_personal_access_clients', function (Blueprint $table) { - $table->bigIncrements('id'); - $table->bigInteger('client_id'); - $table->timestamps(); - }); + Schema::create( + 'oauth_personal_access_clients', + function (Blueprint $table) { + $table->bigIncrements('id'); + $table->uuid('client_id'); + $table->timestamps(); - Schema::create('oauth_auth_codes', function (Blueprint $table) { - $table->string('id', 100)->primary(); - $table->bigInteger('user_id')->index(); - $table->bigInteger('client_id'); - $table->text('scopes')->nullable(); - $table->boolean('revoked'); - $table->dateTime('expires_at')->nullable(); + $table->foreign('client_id') + ->references('id')->on('oauth_clients') + ->onDelete('cascade') + ->onUpdate('cascade'); + } + ); - $table->foreign('user_id') - ->references('id')->on('users') - ->onDelete('cascade') - ->onUpdate('cascade'); - }); + Schema::create( + 'oauth_auth_codes', + function (Blueprint $table) { + $table->string('id', 100)->primary(); + $table->bigInteger('user_id')->index(); + $table->uuid('client_id'); + $table->text('scopes')->nullable(); + $table->boolean('revoked'); + $table->dateTime('expires_at')->nullable(); - Schema::create('oauth_access_tokens', function (Blueprint $table) { - $table->string('id', 100)->primary(); - $table->bigInteger('user_id')->nullable()->index(); - $table->bigInteger('client_id'); - $table->string('name')->nullable(); - $table->text('scopes')->nullable(); - $table->boolean('revoked'); - $table->timestamps(); - $table->dateTime('expires_at')->nullable(); + $table->foreign('user_id') + ->references('id')->on('users') + ->onDelete('cascade') + ->onUpdate('cascade'); - $table->foreign('user_id') - ->references('id')->on('users') - ->onDelete('cascade') - ->onUpdate('cascade'); - }); + $table->foreign('client_id') + ->references('id')->on('oauth_clients') + ->onDelete('cascade') + ->onUpdate('cascade'); + } + ); - Schema::create('oauth_refresh_tokens', function (Blueprint $table) { - $table->string('id', 100)->primary(); - $table->string('access_token_id', 100)->index(); - $table->boolean('revoked'); - $table->dateTime('expires_at')->nullable(); - }); + Schema::create( + 'oauth_access_tokens', + function (Blueprint $table) { + $table->string('id', 100)->primary(); + $table->bigInteger('user_id')->nullable()->index(); + $table->uuid('client_id'); + $table->string('name')->nullable(); + $table->text('scopes')->nullable(); + $table->boolean('revoked'); + $table->timestamps(); + $table->dateTime('expires_at')->nullable(); + + $table->foreign('user_id') + ->references('id')->on('users') + ->onDelete('cascade') + ->onUpdate('cascade'); + + $table->foreign('client_id') + ->references('id')->on('oauth_clients') + ->onDelete('cascade') + ->onUpdate('cascade'); + } + ); + + Schema::create( + 'oauth_refresh_tokens', + function (Blueprint $table) { + $table->string('id', 100)->primary(); + $table->string('access_token_id', 100)->index(); + $table->boolean('revoked'); + $table->dateTime('expires_at')->nullable(); + } + ); } /** * Reverse the migrations. * * @return void */ public function down() { Schema::dropIfExists('oauth_auth_codes'); Schema::dropIfExists('oauth_refresh_tokens'); Schema::dropIfExists('oauth_access_tokens'); Schema::dropIfExists('oauth_personal_access_clients'); Schema::dropIfExists('oauth_clients'); } } diff --git a/src/database/migrations/2021_05_05_134357_create_companion_apps_table.php b/src/database/migrations/2021_05_05_134357_create_companion_apps_table.php index 2b9973bb..f15d94ae 100644 --- a/src/database/migrations/2021_05_05_134357_create_companion_apps_table.php +++ b/src/database/migrations/2021_05_05_134357_create_companion_apps_table.php @@ -1,42 +1,45 @@ bigIncrements('id'); - $table->bigInteger('user_id')->index(); - $table->string('notification_token')->nullable(); - $table->string('device_id', 100); + $table->bigInteger('user_id'); + // Seems to grow over time, no clear specification. + // Typically below 200 bytes, but some mention up to 350 bytes. + $table->string('notification_token', 512)->nullable(); + // 16 byte for android, 36 for ios. May change over tyme + $table->string('device_id', 64); $table->string('name')->nullable(); $table->boolean('mfa_enabled'); $table->timestamps(); $table->foreign('user_id') ->references('id')->on('users') ->onDelete('cascade') ->onUpdate('cascade'); }); } /** * Reverse the migrations. * * @return void */ public function down() { Schema::dropIfExists('companion_apps'); } } diff --git a/src/database/seeds/local/OauthClientSeeder.php b/src/database/seeds/local/OauthClientSeeder.php index ee009fb4..6e9550c4 100644 --- a/src/database/seeds/local/OauthClientSeeder.php +++ b/src/database/seeds/local/OauthClientSeeder.php @@ -1,32 +1,34 @@ forceFill([ 'user_id' => null, 'name' => "Kolab Password Grant Client", - 'secret' => 'JF4pL68ucLuMupaOviTeG8EJeQpjtZtcGLp4f0dq', + 'secret' => \config('auth.proxy.client_secret'), 'provider' => 'users', - 'redirect' => 'http://localhost', + 'redirect' => 'https://' . \config('app.website_domain'), 'personal_access_client' => 0, 'password_client' => 1, 'revoked' => false, ]); + $client->id = \config('auth.proxy.client_id'); + $client->save(); } } diff --git a/src/database/seeds/local/UserSeeder.php b/src/database/seeds/local/UserSeeder.php index 51259b0a..15ed22c9 100644 --- a/src/database/seeds/local/UserSeeder.php +++ b/src/database/seeds/local/UserSeeder.php @@ -1,205 +1,208 @@ 'kolab.org', 'status' => Domain::STATUS_NEW + Domain::STATUS_ACTIVE + Domain::STATUS_CONFIRMED + Domain::STATUS_VERIFIED, 'type' => Domain::TYPE_EXTERNAL ] ); $john = User::create( [ 'email' => 'john@kolab.org', 'password' => \App\Utils::generatePassphrase() ] ); $john->setSettings( [ 'first_name' => 'John', 'last_name' => 'Doe', 'currency' => 'USD', 'country' => 'US', 'billing_address' => "601 13th Street NW\nSuite 900 South\nWashington, DC 20005", 'external_email' => 'john.doe.external@gmail.com', 'organization' => 'Kolab Developers', 'phone' => '+1 509-248-1111', // 'limit_geo' => json_encode(["CH"]), 'guam_enabled' => false, '2fa_enabled' => true ] ); $john->setAliases(['john.doe@kolab.org']); $wallet = $john->wallets->first(); $packageDomain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $packageKolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $packageLite = \App\Package::withEnvTenantContext()->where('title', 'lite')->first(); $domain->assignPackage($packageDomain, $john); $john->assignPackage($packageKolab); $jack = User::create( [ 'email' => 'jack@kolab.org', 'password' => \App\Utils::generatePassphrase() ] ); $jack->setSettings( [ 'first_name' => 'Jack', 'last_name' => 'Daniels', 'currency' => 'USD', 'country' => 'US' ] ); $jack->setAliases(['jack.daniels@kolab.org']); $john->assignPackage($packageKolab, $jack); foreach ($john->entitlements as $entitlement) { $entitlement->created_at = Carbon::now()->subMonthsWithoutOverflow(1); $entitlement->updated_at = Carbon::now()->subMonthsWithoutOverflow(1); $entitlement->save(); } $ned = User::create( [ 'email' => 'ned@kolab.org', 'password' => \App\Utils::generatePassphrase() ] ); $ned->setSettings( [ 'first_name' => 'Edward', 'last_name' => 'Flanders', 'currency' => 'USD', - 'country' => 'US' + 'country' => 'US', + // 'limit_geo' => json_encode(["CH"]), + 'guam_enabled' => false, + '2fa_enabled' => true ] ); $john->assignPackage($packageKolab, $ned); $ned->assignSku(\App\Sku::withEnvTenantContext()->where('title', 'activesync')->first(), 1); // Ned is a controller on Jack's wallet $john->wallets()->first()->addController($ned); // Ned is also our 2FA test user $sku2fa = Sku::withEnvTenantContext()->where('title', '2fa')->first(); $ned->assignSku($sku2fa); try { SecondFactor::seed('ned@kolab.org'); } catch (\Exception $e) { // meh } $joe = User::create( [ 'email' => 'joe@kolab.org', 'password' => \App\Utils::generatePassphrase() ] ); $john->assignPackage($packageLite, $joe); //$john->assignSku(Sku::firstOrCreate(['title' => 'beta'])); //$john->assignSku(Sku::firstOrCreate(['title' => 'meet'])); $joe->setAliases(['joe.monster@kolab.org']); $jeroen = User::create( [ 'email' => 'jeroen@jeroen.jeroen', 'password' => \App\Utils::generatePassphrase() ] ); $jeroen->role = 'admin'; $jeroen->save(); $reseller = User::create( [ 'email' => 'reseller@' . \config('app.domain'), 'password' => \App\Utils::generatePassphrase() ] ); $reseller->role = 'reseller'; $reseller->save(); $reseller->assignPackage($packageKolab); // for tenants that are not the configured tenant id $tenants = \App\Tenant::where('id', '!=', \config('app.tenant_id'))->get(); foreach ($tenants as $tenant) { $domain = Domain::where('tenant_id', $tenant->id)->first(); $packageKolab = \App\Package::where( [ 'title' => 'kolab', 'tenant_id' => $tenant->id ] )->first(); if ($domain) { $reseller = User::create( [ 'email' => 'reseller@' . $domain->namespace, 'password' => \App\Utils::generatePassphrase() ] ); $reseller->role = 'reseller'; $reseller->tenant_id = $tenant->id; $reseller->save(); $reseller->assignPackage($packageKolab); $user = User::create( [ 'email' => 'user@' . $domain->namespace, 'password' => \App\Utils::generatePassphrase() ] ); $user->tenant_id = $tenant->id; $user->save(); $user->assignPackage($packageKolab); } } } } diff --git a/src/phpstan.neon b/src/phpstan.neon index 4cb7f813..3c10190b 100644 --- a/src/phpstan.neon +++ b/src/phpstan.neon @@ -1,16 +1,15 @@ includes: - ./vendor/nunomaduro/larastan/extension.neon parameters: ignoreErrors: - - '#Access to an undefined property Illuminate\\Contracts\\Auth\\Authenticatable#' - '#Access to an undefined property [a-zA-Z\\]+::\$pivot#' - '#Call to an undefined [a-zA-Z0-9<>\\ ]+::withEnvTenantContext\(\)#' - '#Call to an undefined [a-zA-Z0-9<>\\ ]+::withObjectTenantContext\(\)#' - '#Call to an undefined [a-zA-Z0-9<>\\ ]+::withSubjectTenantContext\(\)#' - '#Call to an undefined method Tests\\Browser::#' level: 4 parallel: processTimeout: 300.0 paths: - app/ - tests/ diff --git a/src/resources/js/app.js b/src/resources/js/app.js index 14bebaea..cd6e4b94 100644 --- a/src/resources/js/app.js +++ b/src/resources/js/app.js @@ -1,525 +1,525 @@ /** * First we will load all of this project's JavaScript dependencies which * includes Vue and other libraries. It is a great starting point when * building robust, powerful web applications using Vue and Laravel. */ require('./bootstrap') import AppComponent from '../vue/App' import MenuComponent from '../vue/Widgets/Menu' import SupportForm from '../vue/Widgets/SupportForm' import store from './store' import { Tab } from 'bootstrap' import { loadLangAsync, i18n } from './locale' const loader = '
Loading
' let isLoading = 0 // Lock the UI with the 'loading...' element const startLoading = () => { isLoading++ let loading = $('#app > .app-loader').removeClass('fadeOut') if (!loading.length) { $('#app').append($(loader)) } } // Hide "loading" overlay const stopLoading = () => { if (isLoading > 0) { $('#app > .app-loader').addClass('fadeOut') isLoading--; } } let loadingRoute // Note: This has to be before the app is created // Note: You cannot use app inside of the function window.router.beforeEach((to, from, next) => { // check if the route requires authentication and user is not logged in if (to.meta.requiresAuth && !store.state.isLoggedIn) { // remember the original request, to use after login store.state.afterLogin = to; // redirect to login page next({ name: 'login' }) return } if (to.meta.loading) { startLoading() loadingRoute = to.name } next() }) window.router.afterEach((to, from) => { if (to.name && loadingRoute === to.name) { stopLoading() loadingRoute = null } // When changing a page remove old: // - error page // - modal backdrop $('#error-page,.modal-backdrop.show').remove() $('body').css('padding', 0) // remove padding added by unclosed modal }) const app = new Vue({ components: { AppComponent, MenuComponent, }, i18n, store, router: window.router, data() { return { isUser: !window.isAdmin && !window.isReseller, appName: window.config['app.name'], appUrl: window.config['app.url'], themeDir: '/themes/' + window.config['app.theme'] } }, methods: { // Clear (bootstrap) form validation state clearFormValidation(form) { $(form).find('.is-invalid').removeClass('is-invalid') $(form).find('.invalid-feedback').remove() }, hasPermission(type) { const authInfo = store.state.authInfo const key = 'enable' + type.charAt(0).toUpperCase() + type.slice(1) return !!(authInfo && authInfo.statusInfo[key]) }, hasRoute(name) { return this.$router.resolve({ name: name }).resolved.matched.length > 0 }, hasSKU(name) { const authInfo = store.state.authInfo return authInfo.statusInfo.skus && authInfo.statusInfo.skus.indexOf(name) != -1 }, isController(wallet_id) { if (wallet_id && store.state.authInfo) { let i for (i = 0; i < store.state.authInfo.wallets.length; i++) { if (wallet_id == store.state.authInfo.wallets[i].id) { return true } } for (i = 0; i < store.state.authInfo.accounts.length; i++) { if (wallet_id == store.state.authInfo.accounts[i].id) { return true } } } return false }, // Set user state to "logged in" loginUser(response, dashboard, update) { if (!update) { store.commit('logoutUser') // destroy old state data store.commit('loginUser') } localStorage.setItem('token', response.access_token) localStorage.setItem('refreshToken', response.refresh_token) axios.defaults.headers.common.Authorization = 'Bearer ' + response.access_token if (response.email) { store.state.authInfo = response } if (dashboard !== false) { this.$router.push(store.state.afterLogin || { name: 'dashboard' }) } store.state.afterLogin = null // Refresh the token before it expires let timeout = response.expires_in || 0 // We'll refresh 60 seconds before the token expires if (timeout > 60) { timeout -= 60 } // TODO: We probably should try a few times in case of an error // TODO: We probably should prevent axios from doing any requests // while the token is being refreshed this.refreshTimeout = setTimeout(() => { axios.post('/api/auth/refresh', {'refresh_token': response.refresh_token}).then(response => { this.loginUser(response.data, false, true) }) }, timeout * 1000) }, // Set user state to "not logged in" logoutUser(redirect) { store.commit('logoutUser') localStorage.setItem('token', '') localStorage.setItem('refreshToken', '') delete axios.defaults.headers.common.Authorization if (redirect !== false) { this.$router.push({ name: 'login' }) } clearTimeout(this.refreshTimeout) }, logo(mode) { let src = this.appUrl + this.themeDir + '/images/logo_' + (mode || 'header') + '.png' return `${this.appName}` }, // Display "loading" overlay inside of the specified element addLoader(elem, small = true, style = null) { if (style) { $(elem).css(style) } else { $(elem).css('position', 'relative') } $(elem).append(small ? $(loader).addClass('small') : $(loader)) }, // Remove loader element added in addLoader() removeLoader(elem) { $(elem).find('.app-loader').remove() }, startLoading, stopLoading, isLoading() { return isLoading > 0 }, tab(e) { e.preventDefault() new Tab(e.target).show() }, errorPage(code, msg, hint) { // Until https://github.com/vuejs/vue-router/issues/977 is implemented // we can't really use router to display error page as it has two side // effects: it changes the URL and adds the error page to browser history. // For now we'll be replacing current view with error page "manually". if (!msg) msg = this.$te('error.' + code) ? this.$t('error.' + code) : this.$t('error.unknown') if (!hint) hint = '' const error_page = '
' + `
${code}
${msg}
${hint}
` + '
' $('#error-page').remove() $('#app').append(error_page) app.updateBodyClass('error') }, errorHandler(error) { this.stopLoading() if (!error.response) { // TODO: probably network connection error } else if (error.response.status === 401) { // Remember requested route to come back to it after log in if (this.$route.meta.requiresAuth) { store.state.afterLogin = this.$route this.logoutUser() } else { this.logoutUser(false) } } else { this.errorPage(error.response.status, error.response.statusText) } }, downloadFile(url) { // TODO: This might not be a best way for big files as the content // will be stored (temporarily) in browser memory // TODO: This method does not show the download progress in the browser // but it could be implemented in the UI, axios has 'progress' property axios.get(url, { responseType: 'blob' }) .then(response => { const link = document.createElement('a') const contentDisposition = response.headers['content-disposition'] let filename = 'unknown' if (contentDisposition) { const match = contentDisposition.match(/filename="(.+)"/); if (match.length === 2) { filename = match[1]; } } link.href = window.URL.createObjectURL(response.data) link.download = filename link.click() }) }, price(price, currency) { // TODO: Set locale argument according to the currently used locale return ((price || 0) / 100).toLocaleString('de-DE', { style: 'currency', currency: currency || 'CHF' }) }, priceLabel(cost, discount) { let index = '' if (discount) { cost = Math.floor(cost * ((100 - discount) / 100)) index = '\u00B9' } - return this.price(cost) + '/month' + index + return this.price(cost) + '/' + this.$t('wallet.month') + index }, clickRecord(event) { if (!/^(a|button|svg|path)$/i.test(event.target.nodeName)) { $(event.target).closest('tr').find('a').trigger('click') } }, domainStatusClass(domain) { if (domain.isDeleted) { return 'text-muted' } if (domain.isSuspended) { return 'text-warning' } if (!domain.isVerified || !domain.isLdapReady || !domain.isConfirmed) { return 'text-danger' } return 'text-success' }, domainStatusText(domain) { if (domain.isDeleted) { return this.$t('status.deleted') } if (domain.isSuspended) { return this.$t('status.suspended') } if (!domain.isVerified || !domain.isLdapReady || !domain.isConfirmed) { return this.$t('status.notready') } return this.$t('status.active') }, distlistStatusClass(list) { if (list.isDeleted) { return 'text-muted' } if (list.isSuspended) { return 'text-warning' } if (!list.isLdapReady) { return 'text-danger' } return 'text-success' }, distlistStatusText(list) { if (list.isDeleted) { return this.$t('status.deleted') } if (list.isSuspended) { return this.$t('status.suspended') } if (!list.isLdapReady) { return this.$t('status.notready') } return this.$t('status.active') }, pageName(path) { let page = this.$route.path // check if it is a "menu page", find the page name // otherwise we'll use the real path as page name window.config.menu.every(item => { if (item.location == page && item.page) { page = item.page return false } }) page = page.replace(/^\//, '') return page ? page : '404' }, supportDialog(container) { let dialog = $('#support-dialog')[0] if (!dialog) { // FIXME: Find a nicer way of doing this SupportForm.i18n = i18n let form = new Vue(SupportForm) form.$mount($('
').appendTo(container)[0]) form.$root = this form.$toast = this.$toast dialog = form.$el } dialog.__vue__.showDialog() }, userStatusClass(user) { if (user.isDeleted) { return 'text-muted' } if (user.isSuspended) { return 'text-warning' } if (!user.isImapReady || !user.isLdapReady) { return 'text-danger' } return 'text-success' }, userStatusText(user) { if (user.isDeleted) { return this.$t('status.deleted') } if (user.isSuspended) { return this.$t('status.suspended') } if (!user.isImapReady || !user.isLdapReady) { return this.$t('status.notready') } return this.$t('status.active') }, updateBodyClass(name) { // Add 'class' attribute to the body, different for each page // so, we can apply page-specific styles document.body.className = 'page-' + (name || this.pageName()).replace(/\/.*$/, '') } } }) // Fetch the locale file and the start the app loadLangAsync().then(() => app.$mount('#app')) // Add a axios request interceptor window.axios.interceptors.request.use( config => { // This is the only way I found to change configuration options // on a running application. We need this for browser testing. config.headers['X-Test-Payment-Provider'] = window.config.paymentProvider return config }, error => { // Do something with request error return Promise.reject(error) } ) // Add a axios response interceptor for general/validation error handler window.axios.interceptors.response.use( response => { if (response.config.onFinish) { response.config.onFinish() } return response }, error => { let error_msg let status = error.response ? error.response.status : 200 // Do not display the error in a toast message, pass the error as-is if (error.config.ignoreErrors) { return Promise.reject(error) } if (error.config.onFinish) { error.config.onFinish() } if (error.response && status == 422) { error_msg = "Form validation error" const modal = $('div.modal.show') $(modal.length ? modal : 'form').each((i, form) => { form = $(form) $.each(error.response.data.errors || {}, (idx, msg) => { const input_name = (form.data('validation-prefix') || form.find('form').first().data('validation-prefix') || '') + idx let input = form.find('#' + input_name) if (!input.length) { input = form.find('[name="' + input_name + '"]'); } if (input.length) { // Create an error message // API responses can use a string, array or object let msg_text = '' if (typeof(msg) !== 'string') { $.each(msg, (index, str) => { msg_text += str + ' ' }) } else { msg_text = msg } let feedback = $('
').text(msg_text) if (input.is('.list-input')) { // List input widget let controls = input.children(':not(:first-child)') if (!controls.length && typeof msg == 'string') { // this is an empty list (the main input only) // and the error message is not an array input.find('.main-input').addClass('is-invalid') } else { controls.each((index, element) => { if (msg[index]) { $(element).find('input').addClass('is-invalid') } }) } input.addClass('is-invalid').next('.invalid-feedback').remove() input.after(feedback) } else { // Standard form element input.addClass('is-invalid') input.parent().find('.invalid-feedback').remove() input.parent().append(feedback) } } }) form.find('.is-invalid:not(.listinput-widget)').first().focus() }) } else if (error.response && error.response.data) { error_msg = error.response.data.message } else { error_msg = error.request ? error.request.statusText : error.message } app.$toast.error(error_msg || app.$t('error.server')) // Pass the error as-is return Promise.reject(error) } ) diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php index e80e8b5a..92486eaf 100644 --- a/src/resources/lang/en/ui.php +++ b/src/resources/lang/en/ui.php @@ -1,420 +1,421 @@ [ 'faq' => "FAQ", ], 'btn' => [ 'add' => "Add", 'accept' => "Accept", 'back' => "Back", 'cancel' => "Cancel", 'close' => "Close", 'continue' => "Continue", 'delete' => "Delete", 'deny' => "Deny", 'download' => "Download", 'edit' => "Edit", 'file' => "Choose file...", 'moreinfo' => "More information", 'refresh' => "Refresh", 'reset' => "Reset", 'resend' => "Resend", 'save' => "Save", 'search' => "Search", 'signup' => "Sign Up", 'submit' => "Submit", 'suspend' => "Suspend", 'unsuspend' => "Unsuspend", 'verify' => "Verify", ], 'dashboard' => [ 'beta' => "beta", 'distlists' => "Distribution lists", 'chat' => "Video chat", 'domains' => "Domains", 'invitations' => "Invitations", 'profile' => "Your profile", 'users' => "User accounts", 'wallet' => "Wallet", 'webmail' => "Webmail", 'stats' => "Stats", ], 'distlist' => [ 'list-title' => "Distribution list | Distribution lists", 'create' => "Create list", 'delete' => "Delete list", 'email' => "Email", 'list-empty' => "There are no distribution lists in this account.", 'new' => "New distribution list", 'recipients' => "Recipients", ], 'domain' => [ 'dns-verify' => "Domain DNS verification sample:", 'dns-config' => "Domain DNS configuration sample:", 'namespace' => "Namespace", 'spf-whitelist' => "SPF Whitelist", 'spf-whitelist-text' => "The Sender Policy Framework allows a sender domain to disclose, through DNS, " . "which systems are allowed to send emails with an envelope sender address within said domain.", 'spf-whitelist-ex' => "Here you can specify a list of allowed servers, for example: .ess.barracuda.com.", 'verify' => "Domain verification", 'verify-intro' => "In order to confirm that you're the actual holder of the domain, we need to run a verification process before finally activating it for email delivery.", 'verify-dns' => "The domain must have one of the following entries in DNS:", 'verify-dns-txt' => "TXT entry with value:", 'verify-dns-cname' => "or CNAME entry:", 'verify-outro' => "When this is done press the button below to start the verification.", 'verify-sample' => "Here's a sample zone file for your domain:", 'config' => "Domain configuration", 'config-intro' => "In order to let {app} receive email traffic for your domain you need to adjust the DNS settings, more precisely the MX entries, accordingly.", 'config-sample' => "Edit your domain's zone file and replace existing MX entries with the following values:", 'config-hint' => "If you don't know how to set DNS entries for your domain, please contact the registration service where you registered the domain or your web hosting provider.", ], 'error' => [ '400' => "Bad request", '401' => "Unauthorized", '403' => "Access denied", '404' => "Not found", '405' => "Method not allowed", '500' => "Internal server error", 'unknown' => "Unknown Error", 'server' => "Server Error", ], 'form' => [ 'amount' => "Amount", 'code' => "Confirmation Code", 'config' => "Configuration", 'date' => "Date", 'description' => "Description", 'details' => "Details", 'disabled' => "disabled", 'domain' => "Domain", 'email' => "Email Address", 'enabled' => "enabled", 'firstname' => "First Name", 'general' => "General", 'lastname' => "Last Name", 'none' => "none", 'or' => "or", 'password' => "Password", 'password-confirm' => "Confirm Password", 'phone' => "Phone", 'settings' => "Settings", 'status' => "Status", 'surname' => "Surname", 'user' => "User", 'primary-email' => "Primary Email", 'id' => "ID", 'created' => "Created", 'deleted' => "Deleted", ], 'invitation' => [ 'create' => "Create invite(s)", 'create-title' => "Invite for a signup", 'create-email' => "Enter an email address of the person you want to invite.", 'create-csv' => "To send multiple invitations at once, provide a CSV (comma separated) file, or alternatively a plain-text file, containing one email address per line.", 'empty-list' => "There are no invitations in the database.", 'title' => "Signup invitations", 'search' => "Email address or domain", 'send' => "Send invite(s)", 'status-completed' => "User signed up", 'status-failed' => "Sending failed", 'status-sent' => "Sent", 'status-new' => "Not sent yet", ], 'lang' => [ 'en' => "English", 'de' => "German", 'fr' => "French", 'it' => "Italian", ], 'login' => [ '2fa' => "Second factor code", '2fa_desc' => "Second factor code is optional for users with no 2-Factor Authentication setup.", 'forgot_password' => "Forgot password?", 'header' => "Please sign in", 'sign_in' => "Sign in", 'webmail' => "Webmail" ], 'meet' => [ 'title' => "Voice & Video Conferencing", 'welcome' => "Welcome to our beta program for Voice & Video Conferencing.", 'url' => "You have a room of your own at the URL below. This room is only open when you yourself are in attendance. Use this URL to invite people to join you.", 'notice' => "This is a work in progress and more features will be added over time. Current features include:", 'sharing' => "Screen Sharing", 'sharing-text' => "Share your screen for presentations or show-and-tell.", 'security' => "Room Security", 'security-text' => "Increase the room security by setting a password that attendees will need to know" . " before they can enter, or lock the door so attendees will have to knock, and a moderator can accept or deny those requests.", 'qa' => "Raise Hand (Q&A)", 'qa-text' => "Silent audience members can raise their hand to facilitate a Question & Answer session with the panel members.", 'moderation' => "Moderator Delegation", 'moderation-text' => "Delegate moderator authority for the session, so that a speaker is not needlessly" . " interrupted with attendees knocking and other moderator duties.", 'eject' => "Eject Attendees", 'eject-text' => "Eject attendees from the session in order to force them to reconnect, or address policy" . " violations. Click the user icon for effective dismissal.", 'silent' => "Silent Audience Members", 'silent-text' => "For a webinar-style session, configure the room to force all new attendees to be silent audience members.", 'interpreters' => "Language Specific Audio Channels", 'interpreters-text' => "Designate a participant to interpret the original audio to a target language, for sessions" . " with multi-lingual attendees. The interpreter is expected to be able to relay the original audio, and override it.", 'beta-notice' => "Keep in mind that this is still in beta and might come with some issues." . " Should you encounter any on your way, let us know by contacting support.", // Room options dialog 'options' => "Room options", 'password' => "Password", 'password-none' => "none", 'password-clear' => "Clear password", 'password-set' => "Set password", 'password-text' => "You can add a password to your meeting. Participants will have to provide the password before they are allowed to join the meeting.", 'lock' => "Locked room", 'lock-text' => "When the room is locked participants have to be approved by a moderator before they could join the meeting.", 'nomedia' => "Subscribers only", 'nomedia-text' => "Forces all participants to join as subscribers (with camera and microphone turned off)." . " Moderators will be able to promote them to publishers throughout the session.", // Room menu 'partcnt' => "Number of participants", 'menu-audio-mute' => "Mute audio", 'menu-audio-unmute' => "Unmute audio", 'menu-video-mute' => "Mute video", 'menu-video-unmute' => "Unmute video", 'menu-screen' => "Share screen", 'menu-hand-lower' => "Lower hand", 'menu-hand-raise' => "Raise hand", 'menu-channel' => "Interpreted language channel", 'menu-chat' => "Chat", 'menu-fullscreen' => "Full screen", 'menu-fullscreen-exit' => "Exit full screen", 'menu-leave' => "Leave session", // Room setup screen 'setup-title' => "Set up your session", 'mic' => "Microphone", 'cam' => "Camera", 'nick' => "Nickname", 'nick-placeholder' => "Your name", 'join' => "JOIN", 'joinnow' => "JOIN NOW", 'imaowner' => "I'm the owner", // Room 'qa' => "Q & A", 'leave-title' => "Room closed", 'leave-body' => "The session has been closed by the room owner.", 'media-title' => "Media setup", 'join-request' => "Join request", 'join-requested' => "{user} requested to join.", // Status messages 'status-init' => "Checking the room...", 'status-323' => "The room is closed. Please, wait for the owner to start the session.", 'status-324' => "The room is closed. It will be open for others after you join.", 'status-325' => "The room is ready. Please, provide a valid password.", 'status-326' => "The room is locked. Please, enter your name and try again.", 'status-327' => "Waiting for permission to join the room.", 'status-404' => "The room does not exist.", 'status-429' => "Too many requests. Please, wait.", 'status-500' => "Failed to connect to the room. Server error.", // Other menus 'media-setup' => "Media setup", 'perm' => "Permissions", 'perm-av' => "Audio & Video publishing", 'perm-mod' => "Moderation", 'lang-int' => "Language interpreter", 'menu-options' => "Options", ], 'menu' => [ 'cockpit' => "Cockpit", 'login' => "Login", 'logout' => "Logout", 'signup' => "Signup", 'toggle' => "Toggle navigation", ], 'msg' => [ 'initializing' => "Initializing...", 'loading' => "Loading...", 'loading-failed' => "Failed to load data.", 'notfound' => "Resource not found.", 'info' => "Information", 'error' => "Error", 'warning' => "Warning", 'success' => "Success", ], 'nav' => [ 'more' => "Load more", 'step' => "Step {i}/{n}", ], 'password' => [ 'reset' => "Password Reset", 'reset-step1' => "Enter your email address to reset your password.", 'reset-step1-hint' => "You may need to check your spam folder or unblock {email}.", 'reset-step2' => "We sent out a confirmation code to your external email address." . " Enter the code we sent you, or click the link in the message.", ], 'signup' => [ 'email' => "Existing Email Address", 'login' => "Login", 'title' => "Sign Up", 'step1' => "Sign up to start your free month.", 'step2' => "We sent out a confirmation code to your email address. Enter the code we sent you, or click the link in the message.", 'step3' => "Create your Kolab identity (you can choose additional addresses later).", 'voucher' => "Voucher Code", ], 'status' => [ 'prepare-account' => "We are preparing your account.", 'prepare-domain' => "We are preparing the domain.", 'prepare-distlist' => "We are preparing the distribution list.", 'prepare-user' => "We are preparing the user account.", 'prepare-hint' => "Some features may be missing or readonly at the moment.", 'prepare-refresh' => "The process never ends? Press the \"Refresh\" button, please.", 'ready-account' => "Your account is almost ready.", 'ready-domain' => "The domain is almost ready.", 'ready-distlist' => "The distribution list is almost ready.", 'ready-user' => "The user account is almost ready.", 'verify' => "Verify your domain to finish the setup process.", 'verify-domain' => "Verify domain", 'deleted' => "Deleted", 'suspended' => "Suspended", 'notready' => "Not Ready", 'active' => "Active", ], 'support' => [ 'title' => "Contact Support", 'id' => "Customer number or email address you have with us", 'id-pl' => "e.g. 12345678 or john@kolab.org", 'id-hint' => "Leave blank if you are not a customer yet", 'name' => "Name", 'name-pl' => "how we should call you in our reply", 'email' => "Working email address", 'email-pl' => "make sure we can reach you at this address", 'summary' => "Issue Summary", 'summary-pl' => "one sentence that summarizes your issue", 'expl' => "Issue Explanation", ], 'user' => [ '2fa-hint1' => "This will remove 2-Factor Authentication entitlement as well as the user-configured factors.", '2fa-hint2' => "Please, make sure to confirm the user identity properly.", 'address' => "Address", 'aliases' => "Aliases", 'aliases-email' => "Email Aliases", 'aliases-none' => "This user has no email aliases.", 'add-bonus' => "Add bonus", 'add-bonus-title' => "Add a bonus to the wallet", 'add-penalty' => "Add penalty", 'add-penalty-title' => "Add a penalty to the wallet", 'auto-payment' => "Auto-payment", 'auto-payment-text' => "Fill up by {amount} when under {balance} using {method}", 'country' => "Country", 'create' => "Create user", 'custno' => "Customer No.", 'delete' => "Delete user", 'delete-account' => "Delete this account?", 'delete-email' => "Delete {email}", 'delete-text' => "Do you really want to delete this user permanently?" . " This will delete all account data and withdraw the permission to access the email account." . " Please note that this action cannot be undone.", 'discount' => "Discount", 'discount-hint' => "applied discount", 'discount-title' => "Account discount", 'distlists' => "Distribution lists", 'distlists-none' => "There are no distribution lists in this account.", 'domains' => "Domains", 'domains-none' => "There are no domains in this account.", 'ext-email' => "External Email", 'finances' => "Finances", 'greylisting' => "Greylisting", 'greylisting-text' => "Greylisting is a method of defending users against spam. Any incoming mail from an unrecognized sender " . "is temporarily rejected. The originating server should try again after a delay. " . "This time the email will be accepted. Spammers usually do not reattempt mail delivery.", 'list-title' => "User accounts", 'managed-by' => "Managed by", 'new' => "New user account", 'org' => "Organization", 'package' => "Package", 'price' => "Price", 'profile-title' => "Your profile", 'profile-delete' => "Delete account", 'profile-delete-title' => "Delete this account?", 'profile-delete-text1' => "This will delete the account as well as all domains, users and aliases associated with this account.", 'profile-delete-warning' => "This operation is irreversible", 'profile-delete-text2' => "As you will not be able to recover anything after this point, please make sure that you have migrated all data before proceeding.", 'profile-delete-support' => "As we always strive to improve, we would like to ask for 2 minutes of your time. " . "The best tool for improvement is feedback from users, and we would like to ask " . "for a few words about your reasons for leaving our service. Please send your feedback to {email}.", 'profile-delete-contact' => "Also feel free to contact {app} Support with any questions or concerns that you may have in this context.", 'reset-2fa' => "Reset 2-Factor Auth", 'reset-2fa-title' => "2-Factor Authentication Reset", 'title' => "User account", 'search-pl' => "User ID, email or domain", 'skureq' => "{sku} requires {list}.", 'subscription' => "Subscription", 'subscriptions' => "Subscriptions", 'subscriptions-none' => "This user has no subscriptions.", 'users' => "Users", 'users-none' => "There are no users in this account.", ], 'wallet' => [ 'add-credit' => "Add credit", 'auto-payment-cancel' => "Cancel auto-payment", 'auto-payment-change' => "Change auto-payment", 'auto-payment-failed' => "The setup of automatic payments failed. Restart the process to enable automatic top-ups.", 'auto-payment-hint' => "Here is how it works: Every time your account runs low, we will charge your preferred payment method for an amount you choose." . " You can cancel or change the auto-payment option at any time.", 'auto-payment-setup' => "Set up auto-payment", 'auto-payment-disabled' => "The configured auto-payment has been disabled. Top up your wallet or raise the auto-payment amount.", 'auto-payment-info' => "Auto-payment is set to fill up your account by {amount} every time your account balance gets under {balance}.", 'auto-payment-inprogress' => "The setup of the automatic payment is still in progress.", 'auto-payment-next' => "Next, you will be redirected to the checkout page, where you can provide your credit card details.", 'auto-payment-disabled-next' => "The auto-payment is disabled. Immediately after you submit new settings we'll enable it and attempt to top up your wallet.", 'auto-payment-update' => "Update auto-payment", 'banktransfer-hint' => "Please note that a bank transfer can take several days to complete.", 'currency-conv' => "Here is how it works: You specify the amount by which you want to top up your wallet in {wc}." . " We will then convert this to {pc}, and on the next page you will be provided with the bank-details to transfer the amount in {pc}.", 'fill-up' => "Fill up by", 'history' => "History", + 'month' => "month", 'noperm' => "Only account owners can access a wallet.", 'payment-amount-hint' => "Choose the amount by which you want to top up your wallet.", 'payment-method' => "Method of payment: {method}", 'payment-warning' => "You will be charged for {price}.", 'pending-payments' => "Pending Payments", 'pending-payments-warning' => "You have payments that are still in progress. See the \"Pending Payments\" tab below.", 'pending-payments-none' => "There are no pending payments for this account.", 'receipts' => "Receipts", 'receipts-hint' => "Here you can download receipts (in PDF format) for payments in specified period. Select the period and press the Download button.", 'receipts-none' => "There are no receipts for payments in this account. Please, note that you can download receipts after the month ends.", 'title' => "Account balance", 'top-up' => "Top up your wallet", 'transactions' => "Transactions", 'transactions-none' => "There are no transactions for this account.", 'when-below' => "when account balance is below", ], ]; diff --git a/src/resources/lang/fr/app.php b/src/resources/lang/fr/app.php new file mode 100644 index 00000000..f7b2b3c4 --- /dev/null +++ b/src/resources/lang/fr/app.php @@ -0,0 +1,81 @@ + "L'auto-paiement a été supprimé.", + 'mandate-update-success' => "L'auto-paiement a été mis-à-jour.", + + 'planbutton' => "Choisir :plan", + 'siteuser' => "Utilisateur du :site", + 'domain-setconfig-success' => "Les paramètres du domaine sont mis à jour avec succès.", + 'user-setconfig-success' => "Les paramètres d'utilisateur sont mis à jour avec succès.", + + 'process-async' => "Le processus d'installation a été poussé. Veuillez patienter.", + 'process-user-new' => "Enregistrement d'un utilisateur...", + 'process-user-ldap-ready' => "Création d'un utilisateur...", + 'process-user-imap-ready' => "Création d'une boîte aux lettres...", + 'process-distlist-new' => "Enregistrement d'une liste de distribution...", + 'process-distlist-ldap-ready' => "Création d'une liste de distribution...", + 'process-domain-new' => "Enregistrement d'un domaine personnalisé...", + 'process-domain-ldap-ready' => "Création d'un domaine personnalisé...", + 'process-domain-verified' => "Vérification d'un domaine personnalisé...", + 'process-domain-confirmed' => "vérification de la propriété d'un domaine personnalisé...", + 'process-success' => "Le processus d'installation s'est terminé avec succès.", + 'process-error-user-ldap-ready' => "Échec de créar un utilisateur.", + 'process-error-user-imap-ready' => "Échec de la vérification de l'existence d'une boîte aux lettres.", + 'process-error-domain-ldap-ready' => "Échec de créer un domaine.", + 'process-error-domain-verified' => "Échec de vérifier un domaine.", + 'process-error-domain-confirmed' => "Échec de la vérification de la propriété d'un domaine.", + 'process-distlist-new' => "Enregistrement d'une liste de distribution...", + 'process-distlist-ldap-ready' => "Création d'une liste de distribution...", + 'process-error-distlist-ldap-ready' => "Échec de créer une liste de distrubion.", + + 'distlist-update-success' => "Liste de distribution mis-à-jour avec succès.", + 'distlist-create-success' => "Liste de distribution créer avec succès.", + 'distlist-delete-success' => "Liste de distribution suppriméee avec succès.", + 'distlist-suspend-success' => "Liste de distribution à été suspendue avec succès.", + 'distlist-unsuspend-success' => "Liste de distribution à été débloquée avec succès.", + + 'domain-verify-success' => "Domaine vérifié avec succès.", + 'domain-verify-error' => "Vérification de propriété de domaine à échoué.", + 'domain-suspend-success' => "Domaine suspendue avec succès.", + 'domain-unsuspend-success' => "Domaine debloqué avec succès.", + + 'user-update-success' => "Mis-à-jour des données de l'utilsateur effectué avec succès.", + 'user-create-success' => "Utilisateur a été crée avec succès.", + 'user-delete-success' => "Utilisateur a été supprimé avec succès.", + 'user-suspend-success' => "Utilisateur a été suspendu avec succès.", + 'user-unsuspend-success' => "Utilisateur a été debloqué avec succès.", + 'user-reset-2fa-success' => "Réinstallation de l'authentification à 2-Facteur avec succès.", + + 'search-foundxdomains' => "Les domaines :x ont été trouvés.", + 'search-foundxgroups' => "Les listes de distribution :x ont été trouvées.", + 'search-foundxusers' => "Les comptes d'utilisateurs :x ont été trouvés.", + + 'signup-invitations-created' => "L'invitation à été crée.|:count nombre d'invitations ont été crée.", + 'signup-invitations-csv-empty' => "Aucune adresses email valides ont été trouvées dans le fichier téléchargé.", + 'signup-invitations-csv-invalid-email' => "Une adresse email invalide a été trouvée (:email) on line :line.", + 'signup-invitation-delete-success' => "Invitation supprimée avec succès.", + 'signup-invitation-resend-success' => "Invitation ajoutée à la file d'attente d'envoi avec succès.", + + 'support-request-success' => "Demande de soutien soumise avec succès.", + 'support-request-error' => "La soumission de demande de soutien a échoué.", + + 'wallet-award-success' => "Le bonus a été ajouté au portefeuille avec succès.", + 'wallet-penalty-success' => "La pénalité a été ajoutée au portefeuille avec succès.", + 'wallet-update-success' => "Portefeuille d'utilisateur a été mis-à-jour avec succès.", + + 'wallet-notice-date' => "Avec vos abonnements actuels, le solde de votre compte durera jusqu'à environ :date (:days).", + 'wallet-notice-nocredit' => "Votre crédit a été epuisé, veuillez recharger immédiatement votre solde.", + 'wallet-notice-today' => "Votre reste crédit sera épuisé aujourd'hui, veuillez recharger immédiatement.", + 'wallet-notice-trial' => "Vous êtes dans votre période d'essai gratuite.", + 'wallet-notice-trial-end' => "Vous approchez de la fin de votre période d'essai gratuite, veuillez recharger pour continuer.", +]; diff --git a/src/resources/lang/fr/auth.php b/src/resources/lang/fr/auth.php new file mode 100644 index 00000000..32488ee4 --- /dev/null +++ b/src/resources/lang/fr/auth.php @@ -0,0 +1,20 @@ + "Nom d'utilisateur et mot de passe invalide.", + 'throttle' => "Trop de tentatives de connexion. Veuillez ré-essayer dans :seconds secondes.", + 'logoutsuccess' => "Déconnecté avec succès.", + +]; \ No newline at end of file diff --git a/src/resources/lang/fr/documents.php b/src/resources/lang/fr/documents.php new file mode 100644 index 00000000..aeeaf5ea --- /dev/null +++ b/src/resources/lang/fr/documents.php @@ -0,0 +1,40 @@ + "ID de Compte", + 'amount' => "Montant", + 'customer-no' => "No° de Client.", + 'date' => "Date", + 'description' => "Description", + 'period' => "Période", + 'total' => "Total", + + 'month1' => "Janvier", + 'month2' => "Février", + 'month3' => "Mars", + 'month4' => "Avril", + 'month5' => "Mai", + 'month6' => "Juin", + 'month7' => "Juillet", + 'month8' => "Août", + 'month9' => "Septembre", + 'month10' => "Octobre", + 'month11' => "Novembre", + 'month12' => "Décembre", + + 'receipt-filename' => ":site Receipt for :id", + 'receipt-title' => "Reçu pour :month :year", + 'receipt-item-desc' => ":site Services", + 'receipt-refund' => "Remboursement", + 'receipt-chargeback' => "Refacturation", + + 'subtotal' => "Sous-Total", + 'vat' => "VAT (:rate%)", +]; diff --git a/src/resources/lang/fr/mail.php b/src/resources/lang/fr/mail.php new file mode 100644 index 00000000..268eba96 --- /dev/null +++ b/src/resources/lang/fr/mail.php @@ -0,0 +1,90 @@ + "Salut :name,", + 'footer1' => "Meilleures salutations,", + 'footer2' => "Votre :site Équipe", + + 'more-info-html' => "Cliquez ici pour plus d'information.", + 'more-info-text' => "Cliquez :href pour plus d'information.", + + 'negativebalance-subject' => ":site Paiement Requis", + 'negativebalance-body' => "C'est une notification pour vous informer que votre :site le solde du compte est en négatif et nécessite votre attention." + . " Veillez à mettre en place un auto-paiement pour éviter de tel avertissement comme celui-ci dans le future.", + 'negativebalance-body-ext' => "Régler votre compte pour le maintenir en fontion:", + + 'negativebalancereminder-subject' => ":site Rappel de Paiement", + 'negativebalancereminder-body' => "Vous n'avez peut-être pas rendu compte que vous êtes en retard avec votre paiement pour :site compte." + . " Veillez à mettre en place un auto-paiement pour éviter de tel avertissement comme celui-ci dans le future.", + 'negativebalancereminder-body-ext' => "Régler votre compte pour le maintenir en fontion:", + 'negativebalancereminder-body-warning' => "Soyez conscient que votre compte sera suspendu si le" + . " solde de votre compte n'est réglé avant le :date.", + + 'negativebalancesuspended-subject' => ":site Compte Suspendu", + 'negativebalancesuspended-body' => "Votre :site compte a été suspendu à la suite d'un solde négatif pendant trop longtemps." + . " Veillez nvisager de mettre en place un auto-paiement pour éviter de tel avertissement comme celui-ci dans le future.", + 'negativebalancesuspended-body-ext' => "Régler votre compte pour le maintenir en fontion:", + 'negativebalancesuspended-body-warning' => "Veuillez vous assurer que votre compte et toutes ses données seront supprimés" + . " si le solde de votre compte n'est pas réglé avant le :date.", + + 'negativebalancebeforedelete-subject' => ":site Dernier Avertissement", + 'negativebalancebeforedelete-body' => "Ceci-ci est le dernier rappel pour régler votre :site solde de compte." + . " votre compte et toutes ses données seront supprimés si le solde de votre compte nest pas régler avant le :date.", + 'negativebalancebeforedelete-body-ext' => "Régler votre compte immédiatement:", + + 'passwordreset-subject' => ":site Réinitialisation du mot de passe", + 'passwordreset-body1' => "Quelqu'un a récemment demandé de changer votre :site mot de passe.", + 'passwordreset-body2' => "Si vous êtes dans ce cas, veuillez utiliser ce code de vérification pour terminer le processus:", + 'passwordreset-body3' => "Vous pourrez également cliquer sur le lien ci-dessous:", + 'passwordreset-body4' => "si vous n'avez pas fait une telle demande, vous pouvez soit ignorer ce message, soit prendre contact avec nous au sujet de cet incident.", + + 'paymentmandatedisabled-subject' => ":site Problème d'auto-paiement", + 'paymentmandatedisabled-body' => "Votre :site solde du compte est négatif" + . " et le montant configuré pour le rechargement automatique du solde ne suffit pas" + . " le coût des abonnements consommés.", + 'paymentmandatedisabled-body-ext' => "En vous facturant plusieurs fois le même monant dans un court laps de temps" + . " peut entraîner des problêmes avec le fournisseur du service de paiement." + . " Pour éviter tout problème, nous avons suspendu l'auto-paiement pour votre compte." + . " Pour resourdre le problème,veuillez vous connecter aux paramètres de votre compte et modifier le montant d'auto-paiement.", + + 'paymentfailure-subject' => ":site Paiement Echoué", + 'paymentfailure-body' => "Un problème est survenu avec l'auto-paiement pour votre :site account.\n" + . "Nous avons tenté de vous facturer via votre méthode de paiement choisie, mais le chargement n'a pas été effectué.", + 'paymentfailure-body-ext' => "Pour éviter tout problème supplémentaire, nous avons suspendu l'auto-paiement sur votre compte." + . " Pour resourdre le problème,veuillez vous connecter aux paramètres de votre compte au", + 'paymentfailure-body-rest' => "Vous y trouverez la possibilité de payer manuellement votre compte et" + . " de modifier vos paramètres d'auto-paiement.", + + 'paymentsuccess-subject' => ":site Paiement Effectué", + 'paymentsuccess-body' => "L'auto-paiement pour votre :site le compte s'est exécuté sans problème. " + . "Vous pouvez contrôler le solde de votre nouveau compte et obtenir plus de détails ici:", + + 'support' => "Cas particulier? Il y a un probléme avec une charge?\n" + . ":site Le support reste à votre disposition.", + + 'signupcode-subject' => ":site Enregistrement", + 'signupcode-body1' => "Voici votre code de vérification pour le :site registration process:", + 'signupcode-body2' => "Vous pouvez également continuer avec le processus d'enregistrement en cliquant sur le lien ci-dessous:", + + 'signupinvitation-subject' => ":site Invitation", + 'signupinvitation-header' => "Salut,", + 'signupinvitation-body1' => "Vous êtes invité à joindre :site. Cliquez sur le lien ci-dessous pour vous inscrire.", + 'signupinvitation-body2' => "", + + 'suspendeddebtor-subject' => ":site Compte Suspendu", + 'suspendeddebtor-body' => "Vous êtes en retard avec le paiement de votre :site compte" + . " pour plus de :days jours. Votre compte est suspendu.", + 'suspendeddebtor-middle' => "Réglez immédiatement pour réactiver votre compte.", + 'suspendeddebtor-cancel' => "Vous ne souhaitez plus être notre client?" + . " Voici la démarche à suivre pour annuler votre compte:", + +]; diff --git a/src/resources/lang/fr/meet.php b/src/resources/lang/fr/meet.php new file mode 100644 index 00000000..a544a396 --- /dev/null +++ b/src/resources/lang/fr/meet.php @@ -0,0 +1,30 @@ + 'La connexion n´existe pas.', + 'connection-dismiss-error' => 'Échec du rejet de la connexion.', + 'room-not-found' => 'La salle n´existe pas.', + 'room-setconfig-success' => 'La configuration de la salle a été actualisée avec succès.', + 'room-unsupported-option-error' => 'Option de configuration de la salle invalide.', + 'session-not-found' => 'La session n\'existe pas.', + 'session-create-error' => 'Échec de la création de la session.', + 'session-join-error' => 'Échec de se joindre à la session.', + 'session-close-error' => 'Échec de fermer la session.', + 'session-close-success' => 'La session a été terminée avec succès.', + 'session-password-error' => 'Échec de se joindre à la session. Mot de pas invalide.', + 'session-request-accept-error' => 'Echec d\'accepter la demande d\'adhésion', + 'session-request-deny-error' => 'Echec de refuser la demande d\'adhésion.', + 'session-room-locked-error' => 'Échec de se joindre à la session. Salle verrouillée.', +]; diff --git a/src/resources/lang/fr/transactions.php b/src/resources/lang/fr/transactions.php new file mode 100644 index 00000000..df4210b4 --- /dev/null +++ b/src/resources/lang/fr/transactions.php @@ -0,0 +1,26 @@ + ':user_email a créé :sku_title pour :object', + 'entitlement-billed' => ':sku_title for :object est facturé à :amount', + 'entitlement-deleted' => ':user_email supprimé :sku_title pour :object', + + 'entitlement-created-short' => 'Ajoutée :sku_title pour :object', + 'entitlement-billed-short' => 'Facturé :sku_title pour :object', + 'entitlement-deleted-short' => 'Supprimé :sku_title pour :object', + + 'wallet-award' => 'bonus de :amount attribué à :wallet; :description', + 'wallet-chargeback' => ':amount été refacturé par :wallet', + 'wallet-credit' => ':amount a été ajouté au solde de :wallet', + 'wallet-debit' => ':amount a été déduit du solde de :wallet', + 'wallet-penalty' => 'Le solde de :wallet été réduit de :amount; :description', + 'wallet-refund' => ':amount a été remboursé sur le solde de :wallet', + 'wallet-refund' => ':amount a été remboursé par :wallet', + + 'wallet-award-short' => 'Prime: :description', + 'wallet-chargeback-short' => 'Rétrofacturation', + 'wallet-credit-short' => 'Paiement', + 'wallet-debit-short' => 'Déduction', + 'wallet-penalty-short' => 'Charger: :description', + 'wallet-refund-short' => 'Remboursement: :description', +]; diff --git a/src/resources/lang/fr/ui.php b/src/resources/lang/fr/ui.php new file mode 100644 index 00000000..b504323e --- /dev/null +++ b/src/resources/lang/fr/ui.php @@ -0,0 +1,421 @@ + [ + 'faq' => "FAQ", + ], + + 'btn' => [ + 'add' => "Ajouter", + 'accept' => "Accepter", + 'back' => "Back", + 'cancel' => "Annuler", + 'close' => "Fermer", + 'continue' => "Continuer", + 'delete' => "Supprimer", + 'deny' => "Refuser", + 'download' => "Télécharger", + 'edit' => "Modifier", + 'file' => "Choisir le ficher...", + 'moreinfo' => "Plus d'information", + 'refresh' => "Actualiser", + 'reset' => "Réinitialiser", + 'resend' => "Envoyer à nouveau", + 'save' => "Sauvegarder", + 'search' => "Chercher", + 'signup' => "S'inscrire", + 'submit' => "Soumettre", + 'suspend' => "Suspendre", + 'unsuspend' => "Débloquer", + 'verify' => "Vérifier", + ], + + 'dashboard' => [ + 'beta' => "bêta", + 'distlists' => "Listes de distribution", + 'chat' => "Chat Vidéo", + 'domains' => "Domaines", + 'invitations' => "Invitations", + 'profile' => "Votre profil", + 'users' => "D'utilisateurs", + 'wallet' => "Portefeuille", + 'webmail' => "Webmail", + 'stats' => "Statistiques", + ], + + 'distlist' => [ + 'list-title' => "Liste de distribution | Listes de Distribution", + 'create' => "Créer une liste", + 'delete' => "Suprimmer une list", + 'email' => "Courriel", + 'list-empty' => "il n'y a pas de listes de distribution dans ce compte.", + 'new' => "Nouvelle liste de distribution", + 'recipients' => "Destinataires", + ], + + 'domain' => [ + 'dns-verify' => "Exemple de vérification du DNS d'un domaine:", + 'dns-config' => "Exemple de configuration du DNS d'un domaine:", + 'namespace' => "Espace de noms", + 'verify' => "Vérification du domaine", + 'verify-intro' => "Afin de confirmer que vous êtes bien le titulaire du domaine, nous devons exécuter un processus de vérification avant de l'activer définitivement pour la livraison d'e-mails.", + 'verify-dns' => "Le domaine doit avoir l'une des entrées suivantes dans le DNS:", + 'verify-dns-txt' => "Entrée TXT avec valeur:", + 'verify-dns-cname' => "ou entrée CNAME:", + 'verify-outro' => "Lorsque cela est fait, appuyez sur le bouton ci-dessous pour lancer la vérification.", + 'verify-sample' => "Voici un fichier de zone simple pour votre domaine:", + 'config' => "Configuration du domaine", + 'config-intro' => "Afin de permettre à {app} de recevoir le trafic de messagerie pour votre domaine, vous devez ajuster les paramètres DNS, plus précisément les entrées MX, en conséquence.", + 'config-sample' => "Modifiez le fichier de zone de votre domaine et remplacez les entrées MX existantes par les valeurs suivantes:", + 'config-hint' => "Si vous ne savez pas comment définir les entrées DNS pour votre domaine, veuillez contacter le service d'enregistrement auprès duquel vous avez enregistré le domaine ou votre fournisseur d'hébergement Web.", + 'spf-whitelist' => "SPF Whitelist", + 'spf-whitelist-text' => "Le Sender Policy Framework permet à un domaine expéditeur de dévoiler, par le biais de DNS," + . " quels systèmes sont autorisés à envoyer des e-mails avec une adresse d'expéditeur d'enveloppe dans le domaine en question.", + 'spf-whitelist-ex' => "Vous pouvez ici spécifier une liste de serveurs autorisés, par exemple: .ess.barracuda.com.", + ], + + 'error' => [ + '400' => "Mauvaide demande", + '401' => "Non autorisé", + '403' => "Accès refusé", + '404' => "Pas trouvé", + '405' => "Méthode non autorisée", + '500' => "Erreur de serveur interne", + 'unknown' => "Erreur inconnu", + 'server' => "Erreur de serveur", + ], + + 'form' => [ + 'amount' => "Montant", + 'code' => "Le code de confirmation", + 'config' => "Configuration", + 'date' => "Date", + 'description' => "Description", + 'details' => "Détails", + 'domain' => "Domaine", + 'email' => "Adresse e-mail", + 'firstname' => "Prénom", + 'lastname' => "Nom de famille", + 'none' => "aucun", + 'or' => "ou", + 'password' => "Mot de passe", + 'password-confirm' => "Confirmer le mot de passe", + 'phone' => "Téléphone", + 'status' => "État", + 'surname' => "Nom de famille", + 'user' => "Utilisateur", + 'primary-email' => "Email principal", + 'id' => "ID", + 'created' => "Créé", + 'deleted' => "Supprimé", + 'disabled' => "Désactivé", + 'enabled' => "Activé", + 'general' => "Général", + 'settings' => "Paramètres", + ], + + 'invitation' => [ + 'create' => "Créez des invitation(s)", + 'create-title' => "Invitation à une inscription", + 'create-email' => "Saisissez l'adresse électronique de la personne que vous souhaitez inviter.", + 'create-csv' => "Pour envoyer plusieurs invitations à la fois, fournissez un fichier CSV (séparé par des virgules) ou un fichier en texte brut, contenant une adresse e-mail par ligne.", + 'empty-list' => "Il y a aucune invitation dans la mémoire de données.", + 'title' => "Invitation d'inscription", + 'search' => "Adresse E-mail ou domaine", + 'send' => "Envoyer invitation(s)", + 'status-completed' => "Utilisateur s'est inscrit", + 'status-failed' => "L'envoi a échoué", + 'status-sent' => "Envoyé", + 'status-new' => "Pas encore envoyé", + ], + + 'lang' => [ + 'en' => "Anglais", + 'de' => "Allemand", + 'fr' => "Français", + 'it' => "Italien", + ], + + 'login' => [ + '2fa' => "Code du 2ème facteur", + '2fa_desc' => "Le code du 2ème facteur est facultatif pour les utilisateurs qui n'ont pas configuré l'authentification à deux facteurs.", + 'forgot_password' => "Mot de passe oublié?", + 'header' => "Veuillez vous connecter", + 'sign_in' => "Se connecter", + 'webmail' => "Webmail" + ], + + 'meet' => [ + 'title' => "Voix et vidéo-conférence", + 'welcome' => "Bienvenue dans notre programme bêta pour les conférences vocales et vidéo.", + 'url' => "Vous disposez d'une salle avec l'URL ci-dessous. Cette salle ouvre uniquement quand vous y êtes vous-même. Utilisez cette URL pour inviter des personnes à vous rejoindre.", + 'notice' => "Il s'agit d'un travail en évolution et d'autres fonctions seront ajoutées au fil du temps. Les fonctions actuelles sont les suivantes:", + 'sharing' => "Partage d'écran", + 'sharing-text' => "Partagez votre écran pour des présentations ou des exposés.", + 'security' => "sécurité de chambre", + 'security-text' => "Renforcez la sécurité de la salle en définissant un mot de passe que les participants devront connaître." + . " avant de pouvoir entrer, ou verrouiller la porte afin que les participants doivent frapper, et un modérateur peut accepter ou refuser ces demandes.", + 'qa' => "Lever la main (Q&A)", + 'qa-text' => "Les membres du public silencieux peuvent lever la main pour animer une séance de questions-réponses avec les membres du panel.", + 'moderation' => "Délégation des Modérateurs", + 'moderation-text' => "Déléguer l'autorité du modérateur pour la séance, afin qu'un orateur ne soit pas inutilement" + . " interrompu par l'arrivée des participants et d'autres tâches du modérateur.", + 'eject' => "Éjecter les participants", + 'eject-text' => "Éjectez les participants de la session afin de les obliger à se reconnecter ou de remédier aux violations des règles." + . " Cliquez sur l'icône de l'utilisateur pour un renvoi effectif.", + 'silent' => "Membres du Public en Silence", + 'silent-text' => "Pour une séance de type webinaire, configurez la salle pour obliger tous les nouveaux participants à être des spectateurs silencieux.", + 'interpreters' => "Canaux d'Audio Spécifiques de Langues", + 'interpreters-text' => "Désignez un participant pour interpréter l'audio original dans une langue cible, pour les sessions avec des participants multilingues." + . " L'interprète doit être capable de relayer l'audio original et de le remplacer.", + 'beta-notice' => "Rappelez-vous qu'il s'agit d'une version bêta et pourrait entraîner des problèmes." + . " Au cas où vous rencontreriez des problèmes, n'hésitez pas à nous en faire part en contactant le support.", + + // Room options dialog + 'options' => "Options de salle", + 'password' => "Mot de passe", + 'password-none' => "aucun", + 'password-clear' => "Effacer mot de passe", + 'password-set' => "Définir le mot de passe", + 'password-text' => "Vous pouvez ajouter un mot de passe à votre session. Les participants devront fournir le mot de passe avant d'être autorisés à rejoindre la session.", + 'lock' => "Salle verrouillée", + 'lock-text' => "Lorsque la salle est verrouillée, les participants doivent être approuvés par un modérateur avant de pouvoir rejoindre la réunion.", + 'nomedia' => "Réservé aux abonnés", + 'nomedia-text' => "Force tous les participants à se joindre en tant qu'abonnés (avec caméra et microphone désactivés)" + . "Les modérateurs pourront les promouvoir en tant qu'éditeurs tout au long de la session.", + + // Room menu + 'partcnt' => "Nombres de participants", + 'menu-audio-mute' => "Désactiver le son", + 'menu-audio-unmute' => "Activer le son", + 'menu-video-mute' => "Désactiver la vidéo", + 'menu-video-unmute' => "Activer la vidéo", + 'menu-screen' => "Partager l'écran", + 'menu-hand-lower' => "Baisser la main", + 'menu-hand-raise' => "Lever la main", + 'menu-channel' => "Canal de langue interprétée", + 'menu-chat' => "Le Chat", + 'menu-fullscreen' => "Plein écran", + 'menu-fullscreen-exit' => "Sortir en plein écran", + 'menu-leave' => "Quitter la session", + + // Room setup screen + 'setup-title' => "Préparez votre session", + 'mic' => "Microphone", + 'cam' => "Caméra", + 'nick' => "Surnom", + 'nick-placeholder' => "Votre nom", + 'join' => "JOINDRE", + 'joinnow' => "JOINDRE MAINTENANT", + 'imaowner' => "Je suis le propriétaire", + + // Room + 'qa' => "Q & A", + 'leave-title' => "Salle fermée", + 'leave-body' => "La session a été fermée par le propriétaire de la salle.", + 'media-title' => "Configuration des médias", + 'join-request' => "Demande de rejoindre", + 'join-requested' => "{user} demandé à rejoindre.", + + // Status messages + 'status-init' => "Vérification de la salle...", + 'status-323' => "La salle est fermée. Veuillez attendre le démarrage de la session par le propriétaire.", + 'status-324' => "La salle est fermée. Elle sera ouverte aux autres participants après votre adhésion.", + 'status-325' => "La salle est prête. Veuillez entrer un mot de passe valide.", + 'status-326' => "La salle est fermée. Veuillez entrer votre nom et réessayer.", + 'status-327' => "En attendant la permission de joindre la salle.", + 'status-404' => "La salle n'existe pas.", + 'status-429' => "Trop de demande. Veuillez, patienter.", + 'status-500' => "La connexion à la salle a échoué. Erreur de serveur.", + + // Other menus + 'media-setup' => "configuration des médias", + 'perm' => "Permissions", + 'perm-av' => "Publication d'audio et vidéo", + 'perm-mod' => "Modération", + 'lang-int' => "Interprète de langue", + 'menu-options' => "Options", + ], + + 'menu' => [ + 'cockpit' => "Cockpit", + 'login' => "Connecter", + 'logout' => "Deconnecter", + 'signup' => "S'inscrire", + 'toggle' => "Basculer la navigation", + ], + + 'msg' => [ + 'initializing' => "Initialisation...", + 'loading' => "Chargement...", + 'loading-failed' => "Échec du chargement des données.", + 'notfound' => "Resource introuvable.", + 'info' => "Information", + 'error' => "Erreur", + 'warning' => "Avertissement", + 'success' => "Succès", + ], + + 'nav' => [ + 'more' => "Charger plus", + 'step' => "Étape {i}/{n}", + ], + + 'password' => [ + 'reset' => "Réinitialiser le mot de passe", + 'reset-step1' => "Entrez votre adresse e-mail pour réinitialiser votre mot de passe.", + 'reset-step1-hint' => "Veuillez vérifier votre dossier de spam ou débloquer {email}.", + 'reset-step2' => "Nous avons envoyé un code de confirmation à votre adresse e-mail externe." + . " Entrez le code que nous vous avons envoyé, ou cliquez sur le lien dans le message.", + ], + + 'signup' => [ + 'email' => "Adresse e-mail actuelle", + 'login' => "connecter", + 'title' => "S'inscrire", + 'step1' => "Inscrivez-vous pour commencer votre mois gratuit.", + 'step2' => "Nous avons envoyé un code de confirmation à votre adresse e-mail. Entrez le code que nous vous avons envoyé, ou cliquez sur le lien dans le message.", + 'step3' => "Créez votre identité Kolab (vous pourrez choisir des adresses supplémentaires plus tard).", + 'voucher' => "Coupon Code", + ], + + 'status' => [ + 'prepare-account' => "Votre compte est en cours de préparation.", + 'prepare-domain' => "Le domain est en cours de préparation.", + 'prepare-distlist' => "La liste de distribution est en cours de préparation.", + 'prepare-user' => "Le compte d'utilisateur est en cours de préparation.", + 'prepare-hint' => "Certaines fonctionnalités peuvent être manquantes ou en lecture seule pour le moment.", + 'prepare-refresh' => "Le processus ne se termine jamais? Appuyez sur le bouton \"Refresh\", s'il vous plaît.", + 'ready-account' => "Votre compte est presque prêt.", + 'ready-domain' => "Le domaine est presque prêt.", + 'ready-distlist' => "La liste de distribution est presque prête.", + 'ready-user' => "Le compte d'utilisateur est presque prêt.", + 'verify' => "Veuillez vérifier votre domaine pour terminer le processus de configuration.", + 'verify-domain' => "Vérifier domaine", + 'deleted' => "Supprimé", + 'suspended' => "Suspendu", + 'notready' => "Pas Prêt", + 'active' => "Actif", + ], + + 'support' => [ + 'title' => "Contacter Support", + 'id' => "Numéro de client ou adresse é-mail que vous avez chez nous.", + 'id-pl' => "e.g. 12345678 ou john@kolab.org", + 'id-hint' => "Laissez vide si vous n'êtes pas encore client", + 'name' => "Nom", + 'name-pl' => "comment nous devons vous adresser dans notre réponse", + 'email' => "adresse e-mail qui fonctionne", + 'email-pl' => "assurez-vous que nous pouvons vous atteindre à cette adresse", + 'summary' => "Résumé du problème", + 'summary-pl' => "une phrase qui résume votre situation", + 'expl' => "Analyse du problème", + ], + + 'user' => [ + '2fa-hint1' => "Cela éliminera le droit à l'authentification à 2-Facteurs ainsi que les éléments configurés par l'utilisateur.", + '2fa-hint2' => "Veuillez vous assurer que l'identité de l'utilisateur est correctement confirmée.", + 'address' => "Adresse", + 'aliases' => "Alias", + 'aliases-email' => "Alias E-mail", + 'aliases-none' => "Cet utilisateur n'aucune alias e-mail.", + 'add-bonus' => "Ajouter un bonus", + 'add-bonus-title' => "Ajouter un bonus au portefeuille", + 'add-penalty' => "Ajouter une pénalité", + 'add-penalty-title' => "Ajouter une pénalité au portefeuille", + 'auto-payment' => "Auto-paiement", + 'auto-payment-text' => "Recharger par {amount} quand le montant est inférieur à {balance} utilisant {method}", + 'country' => "Pays", + 'create' => "Créer un utilisateur", + 'custno' => "No. de Client.", + 'delete' => "Supprimer Utilisateur", + 'delete-email' => "Supprimer {email}", + 'delete-text' => "Voulez-vous vraiment supprimer cet utilisateur de façon permanente?" + . " Cela supprimera toutes les données du compte et retirera la permission d'accéder au compte d'e-email." + . " Veuillez noter que cette action ne peut pas être révoquée.", + 'discount' => "Rabais", + 'discount-hint' => "rabais appliqué", + 'discount-title' => "Rabais de compte", + 'distlists' => "Listes de Distribution", + 'distlists-none' => "Il y a aucune liste de distribution dans ce compte.", + 'domains' => "Domaines", + 'domains-none' => "Il y a pas de domaines dans ce compte.", + 'ext-email' => "E-mail externe", + 'finances' => "Finances", + 'greylisting' => "Greylisting", + 'greylisting-text' => "La greylisting est une méthode de défense des utilisateurs contre le spam." + . " Tout e-mail entrant provenant d'un expéditeur non reconnu est temporairement rejeté." + . " Le serveur d'origine doit réessayer après un délai cette fois-ci, le mail sera accepté." + . " Les spammeurs ne réessayent généralement pas de remettre le mail.", + 'list-title' => "Comptes d'utilisateur", + 'managed-by' => "Géré par", + 'new' => "Nouveau compte d'utilisateur", + 'org' => "Organisation", + 'package' => "Paquet", + 'price' => "Prix", + 'profile-title' => "Votre profile", + 'profile-delete' => "Supprimer compte", + 'profile-delete-title' => "Supprimer ce compte?", + 'profile-delete-text1' => "Cela supprimera le compte ainsi que tous les domaines, utilisateurs et alias associés à ce compte.", + 'profile-delete-warning' => "Cette opération est irrévocable", + 'profile-delete-text2' => "Comme vous ne pourrez plus rien récupérer après ce point, assurez-vous d'avoir migré toutes les données avant de poursuivre.", + 'profile-delete-support' => "Étant donné que nous nous attachons à toujours nous améliorer, nous aimerions vous demander 2 minutes de votre temps. " + . "Le meilleur moyen de nous améliorer est le feedback des utilisateurs, et nous voudrions vous demander" + . "quelques mots sur les raisons pour lesquelles vous avez quitté notre service. Veuillez envoyer vos commentaires au {email}.", + 'profile-delete-contact' => "Par ailleurs, n'hésitez pas à contacter le support de {app} pour toute question ou souci que vous pourriez avoir dans ce contexte.", + 'reset-2fa' => "Réinitialiser l'authentification à 2-Facteurs.", + 'reset-2fa-title' => "Réinitialisation de l'Authentification à 2-Facteurs", + 'title' => "Compte d'utilisateur", + 'search-pl' => "ID utilisateur,e-mail ou domamine", + 'skureq' => "{sku} demande {list}.", + 'subscription' => "Subscription", + 'subscriptions' => "Subscriptions", + 'subscriptions-none' => "Cet utilisateur n'a pas de subscriptions.", + 'users' => "Utilisateurs", + 'users-none' => "Il n'y a aucun utilisateur dans ce compte.", + ], + + 'wallet' => [ + 'add-credit' => "Ajouter un crédit", + 'auto-payment-cancel' => "Annuler l'auto-paiement", + 'auto-payment-change' => "Changer l'auto-paiement", + 'auto-payment-failed' => "La configuration des paiements automatiques a échoué. Redémarrer le processus pour activer les top-ups automatiques.", + 'auto-payment-hint' => "Cela fonctionne de la manière suivante: Chaque fois que votre compte est épuisé, nous débiterons votre méthode de paiement préférée d'un montant que vous aurez défini." + . " Vous pouvez annuler ou modifier l'option de paiement automatique à tout moment.", + 'auto-payment-setup' => "configurer l'auto-paiement", + 'auto-payment-disabled' => "L'auto-paiement configuré a été désactivé. Rechargez votre porte-monnaie ou augmentez le montant d'auto-paiement.", + 'auto-payment-info' => "L'auto-paiement est set pour recharger votre compte par {amount} lorsque le solde de votre compte devient inférieur à {balance}.", + 'auto-payment-inprogress' => "La configuration d'auto-paiement est toujours en cours.", + 'auto-payment-next' => "Ensuite, vous serez redirigé vers la page de paiement, où vous pourrez fournir les coordonnées de votre carte de crédit.", + 'auto-payment-disabled-next' => "L'auto-paiement est désactivé. Dès que vous aurez soumis de nouveaux paramètres, nous l'activerons et essaierons de recharger votre portefeuille.", + 'auto-payment-update' => "Mise à jour de l'auto-paiement.", + 'banktransfer-hint' => "Veuillez noter qu'un virement bancaire peut nécessiter plusieurs jours avant d'être effectué.", + 'currency-conv' => "Le principe est le suivant: Vous spécifiez le montant dont vous voulez recharger votre portefeuille en {wc}." + . " Nous convertirons ensuite ce montant en {pc}, et sur la page suivante, vous obtiendrez les coordonnées bancaires pour transférer le montant en {pc}.", + 'fill-up' => "Recharger par", + 'history' => "Histoire", + 'month' => "mois", + 'noperm' => "Seuls les propriétaires de compte peuvent accéder à un portefeuille.", + 'payment-amount-hint' => "Choisissez le montant dont vous voulez recharger votre portefeuille.", + 'payment-method' => "Mode de paiement: {method}", + 'payment-warning' => "Vous serez facturé pour {price}.", + 'pending-payments' => "Paiements en attente", + 'pending-payments-warning' => "Vous avez des paiements qui sont encore en cours. Voir l'onglet \"Paiements en attente\" ci-dessous.", + 'pending-payments-none' => "Il y a aucun paiement en attente pour ce compte.", + 'receipts' => "Reçus", + 'receipts-hint' => "Vous pouvez télécharger ici les reçus (au format PDF) pour les paiements de la période spécifiée. Sélectionnez la période et appuyez sur le bouton Télécharger.", + 'receipts-none' => "Il y a aucun reçu pour les paiements de ce compte. Veuillez noter que vous pouvez télécharger les reçus après la fin du mois.", + 'title' => "Solde du compte", + 'top-up' => "Rechargez votre portefeuille", + 'transactions' => "Transactions", + 'transactions-none' => "Il y a aucun transaction pour ce compte.", + 'when-below' => "lorsque le solde du compte est inférieur à", + ], +]; diff --git a/src/resources/lang/fr/validation.php b/src/resources/lang/fr/validation.php new file mode 100644 index 00000000..f1280fa0 --- /dev/null +++ b/src/resources/lang/fr/validation.php @@ -0,0 +1,187 @@ + 'Le champ :attribute doit être accepté.', + 'active_url' => 'Le champ :attribute n\'est pas une URL valide.', + 'after' => 'Le champ :attribute doit être une date postérieure au :date.', + 'after_or_equal' => 'Le champ :attribute doit être une date postérieure ou égale au :date.', + 'alpha' => 'Le champ :attribute doit contenir uniquement des lettres.', + 'alpha_dash' => 'Le champ :attribute doit contenir uniquement des lettres, des chiffres et des tirets.', + 'alpha_num' => 'Le champ :attribute doit contenir uniquement des chiffres et des lettres.', + 'array' => 'Le champ :attribute doit être un tableau.', + 'attached' => ':attribute est déjà attaché(e).', + 'before' => 'Le champ :attribute doit être une date antérieure au :date.', + 'before_or_equal' => 'Le champ :attribute doit être une date antérieure ou égale au :date.', + 'between' => [ + 'array' => 'Le tableau :attribute doit contenir entre :min et :max éléments.', + 'file' => 'La taille du fichier de :attribute doit être comprise entre :min et :max kilo-octets.', + 'numeric' => 'La valeur de :attribute doit être comprise entre :min et :max.', + 'string' => 'Le texte :attribute doit contenir entre :min et :max caractères.', + ], + 'boolean' => 'Le champ :attribute doit être vrai ou faux.', + 'confirmed' => 'Le champ de confirmation :attribute ne correspond pas.', + 'current_password' => 'Le mot de passe est incorrect.', + 'date' => 'Le champ :attribute n\'est pas une date valide.', + 'date_equals' => 'Le champ :attribute doit être une date égale à :date.', + 'date_format' => 'Le champ :attribute ne correspond pas au format :format.', + 'different' => 'Les champs :attribute et :other doivent être différents.', + 'digits' => 'Le champ :attribute doit contenir :digits chiffres.', + 'digits_between' => 'Le champ :attribute doit contenir entre :min et :max chiffres.', + 'dimensions' => 'La taille de l\'image :attribute n\'est pas conforme.', + 'distinct' => 'Le champ :attribute a une valeur en double.', + 'email' => 'Le champ :attribute doit être une adresse email valide.', + 'ends_with' => 'Le champ :attribute doit se terminer par une des valeurs suivantes : :values', + 'exists' => 'Le champ :attribute sélectionné est invalide.', + 'file' => 'Le champ :attribute doit être un fichier.', + 'filled' => 'Le champ :attribute doit avoir une valeur.', + 'gt' => [ + 'array' => 'Le tableau :attribute doit contenir plus de :value éléments.', + 'file' => 'La taille du fichier de :attribute doit être supérieure à :value kilo-octets.', + 'numeric' => 'La valeur de :attribute doit être supérieure à :value.', + 'string' => 'Le texte :attribute doit contenir plus de :value caractères.', + ], + 'gte' => [ + 'array' => 'Le tableau :attribute doit contenir au moins :value éléments.', + 'file' => 'La taille du fichier de :attribute doit être supérieure ou égale à :value kilo-octets.', + 'numeric' => 'La valeur de :attribute doit être supérieure ou égale à :value.', + 'string' => 'Le texte :attribute doit contenir au moins :value caractères.', + ], + 'image' => 'Le champ :attribute doit être une image.', + 'in' => 'Le champ :attribute est invalide.', + 'in_array' => 'Le champ :attribute n\'existe pas dans :other.', + 'integer' => 'Le champ :attribute doit être un entier.', + 'ip' => 'Le champ :attribute doit être une adresse IP valide.', + 'ipv4' => 'Le champ :attribute doit être une adresse IPv4 valide.', + 'ipv6' => 'Le champ :attribute doit être une adresse IPv6 valide.', + 'json' => 'Le champ :attribute doit être un document JSON valide.', + 'lt' => [ + 'array' => 'Le tableau :attribute doit contenir moins de :value éléments.', + 'file' => 'La taille du fichier de :attribute doit être inférieure à :value kilo-octets.', + 'numeric' => 'La valeur de :attribute doit être inférieure à :value.', + 'string' => 'Le texte :attribute doit contenir moins de :value caractères.', + ], + 'lte' => [ + 'array' => 'Le tableau :attribute doit contenir au plus :value éléments.', + 'file' => 'La taille du fichier de :attribute doit être inférieure ou égale à :value kilo-octets.', + 'numeric' => 'La valeur de :attribute doit être inférieure ou égale à :value.', + 'string' => 'Le texte :attribute doit contenir au plus :value caractères.', + ], + 'max' => [ + 'array' => 'Le tableau :attribute ne peut contenir plus de :max éléments.', + 'file' => 'La taille du fichier de :attribute ne peut pas dépasser :max kilo-octets.', + 'numeric' => 'La valeur de :attribute ne peut être supérieure à :max.', + 'string' => 'Le texte de :attribute ne peut contenir plus de :max caractères.', + ], + 'mimes' => 'Le champ :attribute doit être un fichier de type : :values.', + 'mimetypes' => 'Le champ :attribute doit être un fichier de type : :values.', + 'min' => [ + 'array' => 'Le tableau :attribute doit contenir au moins :min éléments.', + 'file' => 'La taille du fichier de :attribute doit être supérieure à :min kilo-octets.', + 'numeric' => 'La valeur de :attribute doit être supérieure ou égale à :min.', + 'string' => 'Le texte :attribute doit contenir au moins :min caractères.', + ], + 'multiple_of' => 'La valeur de :attribute doit être un multiple de :value', + 'not_in' => 'Le champ :attribute sélectionné n\'est pas valide.', + 'not_regex' => 'Le format du champ :attribute n\'est pas valide.', + 'numeric' => 'Le champ :attribute doit contenir un nombre.', + 'password' => 'Le mot de passe est incorrect', + 'present' => 'Le champ :attribute doit être présent.', + 'prohibited' => 'Le champ :attribute est interdit.', + 'prohibited_if' => 'Le champ :attribute est interdit quand :other a la valeur :value.', + 'prohibited_unless' => 'Le champ :attribute est interdit à moins que :other est l\'une des valeurs :values.', + 'regex' => 'Le format du champ :attribute est invalide.', + 'relatable' => ':attribute n\'est sans doute pas associé(e) avec cette donnée.', + 'required' => 'Le champ :attribute est obligatoire.', + 'required_if' => 'Le champ :attribute est obligatoire quand la valeur de :other est :value.', + 'required_unless' => 'Le champ :attribute est obligatoire sauf si :other est :values.', + 'required_with' => 'Le champ :attribute est obligatoire quand :values est présent.', + 'required_with_all' => 'Le champ :attribute est obligatoire quand :values sont présents.', + 'required_without' => 'Le champ :attribute est obligatoire quand :values n\'est pas présent.', + 'required_without_all' => 'Le champ :attribute est requis quand aucun de :values n\'est présent.', + 'same' => 'Les champs :attribute et :other doivent être identiques.', + 'size' => [ + 'array' => 'Le tableau :attribute doit contenir :size éléments.', + 'file' => 'La taille du fichier de :attribute doit être de :size kilo-octets.', + 'numeric' => 'La valeur de :attribute doit être :size.', + 'string' => 'Le texte de :attribute doit contenir :size caractères.', + ], + 'starts_with' => 'Le champ :attribute doit commencer avec une des valeurs suivantes : :values', + 'string' => 'Le champ :attribute doit être une chaîne de caractères.', + 'timezone' => 'Le champ :attribute doit être un fuseau horaire valide.', + 'unique' => 'La valeur du champ :attribute est déjà utilisée.', + 'uploaded' => 'Le fichier du champ :attribute n\'a pu être téléversé.', + 'url' => 'Le format de l\'URL de :attribute n\'est pas valide.', + 'uuid' => 'Le champ :attribute doit être un UUID valide', + 'custom' => [ + 'attribute-name' => [ + 'rule-name' => 'custom-message', + ], + ], + 'attributes' => [ + 'address' => 'adresse', + 'age' => 'âge', + 'available' => 'disponible', + 'city' => 'ville', + 'content' => 'contenu', + 'country' => 'pays', + 'current_password' => 'mot de passe actuel', + 'date' => 'date', + 'day' => 'jour', + 'description' => 'description', + 'email' => 'adresse email', + 'excerpt' => 'extrait', + 'first_name' => 'prénom', + 'gender' => 'genre', + 'hour' => 'heure', + 'last_name' => 'nom', + 'minute' => 'minute', + 'mobile' => 'portable', + 'month' => 'mois', + 'name' => 'nom', + 'password' => 'mot de passe', + 'password_confirmation' => 'confirmation du mot de passe', + 'phone' => 'téléphone', + 'second' => 'seconde', + 'sex' => 'sexe', + 'size' => 'taille', + 'time' => 'heure', + 'title' => 'titre', + 'username' => 'nom d\'utilisateur', + 'year' => 'année', + ], + '2fareq' => "Le code du second facteur est requis.", + '2fainvalid' => "Le code du deuxième facteur n'est pas valideSecond factor code is invalid.", + 'emailinvalid' => "L'adresse e-mail spécifiée est invalide.", + 'domaininvalid' => "Le domaine spécifié n'est pas valide.", + 'domainnotavailable' => "Le domaine spécifié n'est pas disponible.", + 'logininvalid' => "Le login spécifié est invalide.", + 'loginexists' => "Le login spécifié n'est pas disponible.", + 'domainexists' => "Le domaine spécifié n'est pas disponible.", + 'noemailorphone' => "Le texte spécifié n'est pas un e-mail valide ni un numéro de téléphone.", + 'packageinvalid' => "Le paquet sélectionné est invalide.", + 'packagerequired' => "Le paquet est requis.", + 'usernotexists' => "Impossible de trouver l'utilisateur.", + 'voucherinvalid' => "Le code du coupon est invalide ou a expiré.", + 'noextemail' => "Cet utilisateur ne possède pas d'adresse e-mail externe.", + 'entryinvalid' => "L'attribut :attribute est invalide.", + 'entryexists' => "L'attribut :attribute n'est pas disponible.", + 'minamount' => "Le montant minimum pour un paiement unitaire est :amount.", + 'minamountdebt' => "Le montant indiqué ne couvre pas le solde du compte.", + 'notalocaluser' => "L'adresse e-mail indiquée n'existe pas.", + 'memberislist' => "Le destinataire ne peut pas être le même que l'adresse de la liste.", + 'listmembersrequired' => "Au moins un destinataire est requis.", + 'spf-entry-invalid' => "Le format de l'entrée est invalide. Un nom de domaine débutant par un point est attendu.", + 'invalid-config-parameter' => "Le paramètre de configuration demandé est inconnu.", + +]; diff --git a/src/resources/themes/default/lang/fr/faq.php b/src/resources/themes/default/lang/fr/faq.php new file mode 100644 index 00000000..875855ce --- /dev/null +++ b/src/resources/themes/default/lang/fr/faq.php @@ -0,0 +1,9 @@ + "Est-il possible de convertir un compte individuel en compte de groupe?", + 'storage' => "Combien d'espace de stockage est fourni avec mon compte?", + 'tos' => "quelles sont vos conditions de service?", + +]; diff --git a/src/resources/themes/default/lang/fr/menu.php b/src/resources/themes/default/lang/fr/menu.php new file mode 100644 index 00000000..7939b1c8 --- /dev/null +++ b/src/resources/themes/default/lang/fr/menu.php @@ -0,0 +1,10 @@ + "Blog", + 'explore' => "Explorer", + 'support' => "Support", + 'tos' => "Conditions de Service", + +]; diff --git a/src/resources/themes/default/lang/fr/support.php b/src/resources/themes/default/lang/fr/support.php new file mode 100644 index 00000000..dd9c9341 --- /dev/null +++ b/src/resources/themes/default/lang/fr/support.php @@ -0,0 +1,13 @@ + "Contacter Support", + 'text1' => "Notre équipe de support technique est là pour vous aider si vous rencontrez des difficultés." + . " Vous ne devriez pas avoir à parler à des machines ou à naviguer dans des menus vocaux," + . " mais plutôt à des êtres humains qui vous répondent personnellement.", + 'text2' => "Cette aide est déjà intégrée dans votre souscription, il n'y a donc aucun coût supplémentaire pour vous." + . " Si vous rencontrez des problèmes avec votre compte :site, ou si vous avez des questions" + . " sur notre produit avant de vous inscrire, veuillez nous contacter.", + +]; diff --git a/src/routes/api.php b/src/routes/api.php index 7b72d2f0..5cc43e20 100644 --- a/src/routes/api.php +++ b/src/routes/api.php @@ -1,246 +1,247 @@ 'api', 'prefix' => $prefix . 'api/auth' ], function ($router) { Route::post('login', 'API\AuthController@login'); Route::group( ['middleware' => 'auth:api'], function ($router) { Route::get('info', 'API\AuthController@info'); Route::post('info', 'API\AuthController@info'); Route::post('logout', 'API\AuthController@logout'); Route::post('refresh', 'API\AuthController@refresh'); } ); } ); Route::group( [ 'domain' => \config('app.website_domain'), 'middleware' => 'api', 'prefix' => $prefix . 'api/auth' ], function ($router) { Route::post('password-reset/init', 'API\PasswordResetController@init'); Route::post('password-reset/verify', 'API\PasswordResetController@verify'); Route::post('password-reset', 'API\PasswordResetController@reset'); Route::post('signup/init', 'API\SignupController@init'); Route::get('signup/invitations/{id}', 'API\SignupController@invitation'); Route::get('signup/plans', 'API\SignupController@plans'); Route::post('signup/verify', 'API\SignupController@verify'); Route::post('signup', 'API\SignupController@signup'); } ); Route::group( [ 'domain' => \config('app.website_domain'), 'middleware' => 'auth:api', 'prefix' => $prefix . 'api/v4' ], function () { Route::post('companion/register', 'API\V4\CompanionAppsController@register'); Route::post('auth-attempts/{id}/confirm', 'API\V4\AuthAttemptsController@confirm'); Route::post('auth-attempts/{id}/deny', 'API\V4\AuthAttemptsController@deny'); Route::get('auth-attempts/{id}/details', 'API\V4\AuthAttemptsController@details'); Route::get('auth-attempts', 'API\V4\AuthAttemptsController@index'); Route::apiResource('domains', API\V4\DomainsController::class); Route::get('domains/{id}/confirm', 'API\V4\DomainsController@confirm'); Route::get('domains/{id}/status', 'API\V4\DomainsController@status'); Route::post('domains/{id}/config', 'API\V4\DomainsController@setConfig'); Route::apiResource('groups', API\V4\GroupsController::class); Route::get('groups/{id}/status', 'API\V4\GroupsController@status'); Route::apiResource('packages', API\V4\PackagesController::class); Route::apiResource('skus', API\V4\SkusController::class); Route::apiResource('users', API\V4\UsersController::class); Route::post('users/{id}/config', 'API\V4\UsersController@setConfig'); Route::get('users/{id}/skus', 'API\V4\SkusController@userSkus'); Route::get('users/{id}/status', 'API\V4\UsersController@status'); Route::apiResource('wallets', API\V4\WalletsController::class); Route::get('wallets/{id}/transactions', 'API\V4\WalletsController@transactions'); Route::get('wallets/{id}/receipts', 'API\V4\WalletsController@receipts'); Route::get('wallets/{id}/receipts/{receipt}', 'API\V4\WalletsController@receiptDownload'); Route::post('payments', 'API\V4\PaymentsController@store'); //Route::delete('payments', 'API\V4\PaymentsController@cancel'); Route::get('payments/mandate', 'API\V4\PaymentsController@mandate'); Route::post('payments/mandate', 'API\V4\PaymentsController@mandateCreate'); Route::put('payments/mandate', 'API\V4\PaymentsController@mandateUpdate'); Route::delete('payments/mandate', 'API\V4\PaymentsController@mandateDelete'); Route::get('payments/methods', 'API\V4\PaymentsController@paymentMethods'); Route::get('payments/pending', 'API\V4\PaymentsController@payments'); Route::get('payments/has-pending', 'API\V4\PaymentsController@hasPayments'); Route::get('openvidu/rooms', 'API\V4\OpenViduController@index'); Route::post('openvidu/rooms/{id}/close', 'API\V4\OpenViduController@closeRoom'); Route::post('openvidu/rooms/{id}/config', 'API\V4\OpenViduController@setRoomConfig'); // FIXME: I'm not sure about this one, should we use DELETE request maybe? Route::post('openvidu/rooms/{id}/connections/{conn}/dismiss', 'API\V4\OpenViduController@dismissConnection'); Route::put('openvidu/rooms/{id}/connections/{conn}', 'API\V4\OpenViduController@updateConnection'); Route::post('openvidu/rooms/{id}/request/{reqid}/accept', 'API\V4\OpenViduController@acceptJoinRequest'); Route::post('openvidu/rooms/{id}/request/{reqid}/deny', 'API\V4\OpenViduController@denyJoinRequest'); } ); // Note: In Laravel 7.x we could just use withoutMiddleware() instead of a separate group Route::group( [ 'domain' => \config('app.website_domain'), 'prefix' => $prefix . 'api/v4' ], function () { Route::post('openvidu/rooms/{id}', 'API\V4\OpenViduController@joinRoom'); Route::post('openvidu/rooms/{id}/connections', 'API\V4\OpenViduController@createConnection'); // FIXME: I'm not sure about this one, should we use DELETE request maybe? Route::post('openvidu/rooms/{id}/connections/{conn}/dismiss', 'API\V4\OpenViduController@dismissConnection'); Route::put('openvidu/rooms/{id}/connections/{conn}', 'API\V4\OpenViduController@updateConnection'); Route::post('openvidu/rooms/{id}/request/{reqid}/accept', 'API\V4\OpenViduController@acceptJoinRequest'); Route::post('openvidu/rooms/{id}/request/{reqid}/deny', 'API\V4\OpenViduController@denyJoinRequest'); } ); Route::group( [ 'domain' => \config('app.website_domain'), 'middleware' => 'api', 'prefix' => $prefix . 'api/v4' ], function ($router) { Route::post('support/request', 'API\V4\SupportController@request'); } ); Route::group( [ 'domain' => \config('app.website_domain'), 'prefix' => $prefix . 'api/webhooks' ], function () { Route::post('payment/{provider}', 'API\V4\PaymentsController@webhook'); Route::post('meet/openvidu', 'API\V4\OpenViduController@webhook'); Route::get('nginx', 'API\NGINXController@authenticate'); } ); if (\config('app.with_services')) { Route::group( [ 'domain' => 'services.' . \config('app.website_domain'), - 'prefix' => $prefix . 'api/webhooks/policy' + 'prefix' => $prefix . 'api/webhooks' ], function () { - Route::post('greylist', 'API\V4\PolicyController@greylist'); - Route::post('ratelimit', 'API\V4\PolicyController@ratelimit'); - Route::post('spf', 'API\V4\PolicyController@senderPolicyFramework'); + Route::get('nginx', 'API\V4\NGINXController@authenticate'); + Route::post('policy/greylist', 'API\V4\PolicyController@greylist'); + Route::post('policy/ratelimit', 'API\V4\PolicyController@ratelimit'); + Route::post('policy/spf', 'API\V4\PolicyController@senderPolicyFramework'); } ); } if (\config('app.with_admin')) { Route::group( [ 'domain' => 'admin.' . \config('app.website_domain'), 'middleware' => ['auth:api', 'admin'], 'prefix' => $prefix . 'api/v4', ], function () { Route::apiResource('domains', API\V4\Admin\DomainsController::class); Route::post('domains/{id}/suspend', 'API\V4\Admin\DomainsController@suspend'); Route::post('domains/{id}/unsuspend', 'API\V4\Admin\DomainsController@unsuspend'); Route::apiResource('groups', API\V4\Admin\GroupsController::class); Route::post('groups/{id}/suspend', 'API\V4\Admin\GroupsController@suspend'); Route::post('groups/{id}/unsuspend', 'API\V4\Admin\GroupsController@unsuspend'); Route::apiResource('skus', API\V4\Admin\SkusController::class); Route::apiResource('users', API\V4\Admin\UsersController::class); Route::get('users/{id}/discounts', 'API\V4\Reseller\DiscountsController@userDiscounts'); Route::post('users/{id}/reset2FA', 'API\V4\Admin\UsersController@reset2FA'); Route::get('users/{id}/skus', 'API\V4\Admin\SkusController@userSkus'); Route::post('users/{id}/suspend', 'API\V4\Admin\UsersController@suspend'); Route::post('users/{id}/unsuspend', 'API\V4\Admin\UsersController@unsuspend'); Route::apiResource('wallets', API\V4\Admin\WalletsController::class); Route::post('wallets/{id}/one-off', 'API\V4\Admin\WalletsController@oneOff'); Route::get('wallets/{id}/transactions', 'API\V4\Admin\WalletsController@transactions'); Route::get('stats/chart/{chart}', 'API\V4\Admin\StatsController@chart'); } ); } if (\config('app.with_reseller')) { Route::group( [ 'domain' => 'reseller.' . \config('app.website_domain'), 'middleware' => ['auth:api', 'reseller'], 'prefix' => $prefix . 'api/v4', ], function () { Route::apiResource('domains', API\V4\Reseller\DomainsController::class); Route::post('domains/{id}/suspend', 'API\V4\Reseller\DomainsController@suspend'); Route::post('domains/{id}/unsuspend', 'API\V4\Reseller\DomainsController@unsuspend'); Route::apiResource('groups', API\V4\Reseller\GroupsController::class); Route::post('groups/{id}/suspend', 'API\V4\Reseller\GroupsController@suspend'); Route::post('groups/{id}/unsuspend', 'API\V4\Reseller\GroupsController@unsuspend'); Route::apiResource('invitations', API\V4\Reseller\InvitationsController::class); Route::post('invitations/{id}/resend', 'API\V4\Reseller\InvitationsController@resend'); Route::post('payments', 'API\V4\Reseller\PaymentsController@store'); Route::get('payments/mandate', 'API\V4\Reseller\PaymentsController@mandate'); Route::post('payments/mandate', 'API\V4\Reseller\PaymentsController@mandateCreate'); Route::put('payments/mandate', 'API\V4\Reseller\PaymentsController@mandateUpdate'); Route::delete('payments/mandate', 'API\V4\Reseller\PaymentsController@mandateDelete'); Route::get('payments/methods', 'API\V4\Reseller\PaymentsController@paymentMethods'); Route::get('payments/pending', 'API\V4\Reseller\PaymentsController@payments'); Route::get('payments/has-pending', 'API\V4\Reseller\PaymentsController@hasPayments'); Route::apiResource('skus', API\V4\Reseller\SkusController::class); Route::apiResource('users', API\V4\Reseller\UsersController::class); Route::get('users/{id}/discounts', 'API\V4\Reseller\DiscountsController@userDiscounts'); Route::post('users/{id}/reset2FA', 'API\V4\Reseller\UsersController@reset2FA'); Route::get('users/{id}/skus', 'API\V4\Reseller\SkusController@userSkus'); Route::post('users/{id}/suspend', 'API\V4\Reseller\UsersController@suspend'); Route::post('users/{id}/unsuspend', 'API\V4\Reseller\UsersController@unsuspend'); Route::apiResource('wallets', API\V4\Reseller\WalletsController::class); Route::post('wallets/{id}/one-off', 'API\V4\Reseller\WalletsController@oneOff'); Route::get('wallets/{id}/receipts', 'API\V4\Reseller\WalletsController@receipts'); Route::get('wallets/{id}/receipts/{receipt}', 'API\V4\Reseller\WalletsController@receiptDownload'); Route::get('wallets/{id}/transactions', 'API\V4\Reseller\WalletsController@transactions'); Route::get('stats/chart/{chart}', 'API\V4\Reseller\StatsController@chart'); } ); } diff --git a/src/tests/Browser/LogonTest.php b/src/tests/Browser/LogonTest.php index 6f7b6aef..472f8db7 100644 --- a/src/tests/Browser/LogonTest.php +++ b/src/tests/Browser/LogonTest.php @@ -1,289 +1,291 @@ browse(function (Browser $browser) { $browser->visit(new Home()) ->within(new Menu(), function ($browser) { $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'login', 'lang']) ->assertSeeIn('#footer-copyright', '@ Apheleia IT AG, ' . date('Y')); }); if ($browser->isDesktop()) { $browser->within(new Menu('footer'), function ($browser) { $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'tos', 'login']); }); } else { $browser->assertMissing('#footer-menu .navbar-nav'); } $browser->assertSeeLink('Forgot password?') ->assertSeeLink('Webmail'); }); } /** * Test language menu, and language change */ public function testLocales(): void { $this->browse(function (Browser $browser) { if (!$browser->isDesktop()) { $this->markTestIncomplete(); } $browser->visit(new Home()) // ->plainCookie('language', '') ->within(new Menu(), function ($browser) { $browser->assertSeeIn('@lang', 'EN') ->click('@lang'); }) // Switch English -> German ->whenAvailable('nav .dropdown-menu', function (Browser $browser) { - $browser->assertElementsCount('a', 2) + $browser->assertElementsCount('a', 3) ->assertSeeIn('a:nth-child(1)', 'EN - English') ->assertSeeIn('a:nth-child(2)', 'DE - German') + ->assertSeeIn('a:nth-child(3)', 'FR - French') ->click('a:nth-child(2)'); }) ->waitUntilMissing('nav .dropdown-menu') ->within(new Menu(), function ($browser) { $browser->assertSeeIn('@lang', 'DE'); }) ->waitForTextIn('#header-menu .link-login', 'EINLOGGEN') ->assertSeeIn('#footer-menu .link-login', 'Einloggen') ->assertSeeIn('@logon-button', 'Anmelden') // refresh the page to see if it uses the lang previously set ->refresh() ->waitForTextIn('#header-menu .link-login', 'EINLOGGEN') ->assertSeeIn('#footer-menu .link-login', 'Einloggen') ->assertSeeIn('@logon-button', 'Anmelden') ->within(new Menu(), function ($browser) { $browser->assertSeeIn('@lang', 'DE') ->click('@lang'); }) // Switch German -> English ->whenAvailable('nav .dropdown-menu', function (Browser $browser) { - $browser->click('a:nth-child(1)'); + $browser->assertSeeIn('a:nth-child(1)', 'Englisch') + ->click('a:nth-child(1)'); }) ->waitUntilMissing('nav .dropdown-menu') ->within(new Menu(), function ($browser) { $browser->assertSeeIn('@lang', 'EN'); }) ->waitForTextIn('#header-menu .link-login', 'LOGIN'); }); } /** * Test redirect to /login if user is unauthenticated */ public function testRequiredAuth(): void { $this->browse(function (Browser $browser) { $browser->visit('/dashboard'); // Checks if we're really on the login page $browser->waitForLocation('/login') ->on(new Home()); }); } /** * Logon with wrong password/user test */ public function testLogonWrongCredentials(): void { $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('john@kolab.org', 'wrong'); // Error message $browser->assertToast(Toast::TYPE_ERROR, 'Invalid username or password.'); // Checks if we're still on the logon page $browser->on(new Home()); }); } /** * Successful logon test */ public function testLogonSuccessful(): void { $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) // Checks if we're really on Dashboard page ->on(new Dashboard()) ->assertVisible('@links a.link-profile') ->assertVisible('@links a.link-domains') ->assertVisible('@links a.link-users') ->assertVisible('@links a.link-wallet') ->assertVisible('@links a.link-webmail') ->within(new Menu(), function ($browser) { $browser->assertMenuItems(['explore', 'blog', 'support', 'dashboard', 'logout', 'lang']); }); if ($browser->isDesktop()) { $browser->within(new Menu('footer'), function ($browser) { $browser->assertMenuItems(['explore', 'blog', 'support', 'tos', 'dashboard', 'logout']); }); } else { $browser->assertMissing('#footer-menu .navbar-nav'); } $browser->assertUser('john@kolab.org'); // Assert no "Account status" for this account $browser->assertMissing('@status'); // Goto /domains and assert that the link on logo element // leads to the dashboard $browser->visit('/domains') ->waitForText('Domains') ->click('a.navbar-brand') ->on(new Dashboard()); // Test that visiting '/' with logged in user does not open logon form // but "redirects" to the dashboard $browser->visit('/') ->waitForLocation('/dashboard') ->on(new Dashboard()); }); } /** * Logout test * * @depends testLogonSuccessful */ public function testLogout(): void { $this->browse(function (Browser $browser) { $browser->on(new Dashboard()); // Click the Logout button $browser->within(new Menu(), function ($browser) { $browser->clickMenuItem('logout'); }); // We expect the logon page $browser->waitForLocation('/login') ->on(new Home()); // with default menu $browser->within(new Menu(), function ($browser) { $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'login', 'lang']); }); // Success toast message $browser->assertToast(Toast::TYPE_SUCCESS, 'Successfully logged out'); }); } /** * Logout by URL test */ public function testLogoutByURL(): void { $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('john@kolab.org', 'simple123', true); // Checks if we're really on Dashboard page $browser->on(new Dashboard()); // Use /logout url, and expect the logon page $browser->visit('/logout') ->waitForLocation('/login') ->on(new Home()); // with default menu $browser->within(new Menu(), function ($browser) { $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'login', 'lang']); }); // Success toast message $browser->assertToast(Toast::TYPE_SUCCESS, 'Successfully logged out'); }); } /** * Test 2-Factor Authentication * * @depends testLogoutByURL */ public function test2FA(): void { $this->browse(function (Browser $browser) { // Test missing 2fa code $browser->on(new Home()) ->type('@email-input', 'ned@kolab.org') ->type('@password-input', 'simple123') ->press('form button') ->waitFor('@second-factor-input.is-invalid + .invalid-feedback') ->assertSeeIn( '@second-factor-input.is-invalid + .invalid-feedback', 'Second factor code is required.' ) ->assertFocused('@second-factor-input') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); // Test invalid code $browser->type('@second-factor-input', '123456') ->press('form button') ->waitUntilMissing('@second-factor-input.is-invalid') ->waitFor('@second-factor-input.is-invalid + .invalid-feedback') ->assertSeeIn( '@second-factor-input.is-invalid + .invalid-feedback', 'Second factor code is invalid.' ) ->assertFocused('@second-factor-input') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); $code = \App\Auth\SecondFactor::code('ned@kolab.org'); // Test valid (TOTP) code $browser->type('@second-factor-input', $code) ->press('form button') ->waitUntilMissing('@second-factor-input.is-invalid') ->waitForLocation('/dashboard') ->on(new Dashboard()); }); } /** * Test redirect to the requested page after logon * * @depends test2FA */ public function testAfterLogonRedirect(): void { $this->browse(function (Browser $browser) { // User is logged in $browser->visit(new UserProfile()); // Test redirect if the token is invalid $browser->script("localStorage.setItem('token', '123')"); $browser->refresh() ->on(new Home()) ->submitLogon('john@kolab.org', 'simple123', false) ->waitForLocation('/profile'); }); } } diff --git a/src/tests/Feature/AuthAttemptTest.php b/src/tests/Feature/AuthAttemptTest.php new file mode 100644 index 00000000..0b096afb --- /dev/null +++ b/src/tests/Feature/AuthAttemptTest.php @@ -0,0 +1,40 @@ +deleteTestUser('jane@kolabnow.com'); + } + + public function tearDown(): void + { + $this->deleteTestUser('jane@kolabnow.com'); + + parent::tearDown(); + } + + public function testRecord(): void + { + $user = $this->getTestUser('jane@kolabnow.com'); + $authAttempt = \App\AuthAttempt::recordAuthAttempt($user, "10.0.0.1"); + $this->assertEquals($authAttempt->user_id, $user->id); + $this->assertEquals($authAttempt->ip, "10.0.0.1"); + $authAttempt->refresh(); + $this->assertEquals($authAttempt->status, "NEW"); + + $authAttempt2 = \App\AuthAttempt::recordAuthAttempt($user, "10.0.0.1"); + $this->assertEquals($authAttempt->id, $authAttempt2->id); + + $authAttempt3 = \App\AuthAttempt::recordAuthAttempt($user, "10.0.0.2"); + $this->assertNotEquals($authAttempt->id, $authAttempt3->id); + } +} diff --git a/src/tests/Feature/Console/AuthAttempt/DeleteTest.php b/src/tests/Feature/Console/AuthAttempt/DeleteTest.php new file mode 100644 index 00000000..3b21021b --- /dev/null +++ b/src/tests/Feature/Console/AuthAttempt/DeleteTest.php @@ -0,0 +1,47 @@ +getTestUser('john@kolab.org'); + $authAttempt = AuthAttempt::recordAuthAttempt($user, "10.0.0.1"); + $code = \Artisan::call("authattempt:delete {$authAttempt->id}"); + $output = trim(\Artisan::output()); + $this->assertSame(0, $code); + $this->assertTrue(!AuthAttempt::find($authAttempt->id)); + + // AuthAttempt not existing + $code = \Artisan::call("authattempt:delete 999"); + $output = trim(\Artisan::output()); + $this->assertSame(1, $code); + $this->assertSame("No such authattempt 999", $output); + } +} diff --git a/src/tests/Feature/Console/AuthAttempt/ListTest.php b/src/tests/Feature/Console/AuthAttempt/ListTest.php new file mode 100644 index 00000000..6b7f4c62 --- /dev/null +++ b/src/tests/Feature/Console/AuthAttempt/ListTest.php @@ -0,0 +1,51 @@ +assertSame(0, $code); + $this->assertSame('', $output); + + $user = $this->getTestUser('john@kolab.org'); + $authAttempt = AuthAttempt::recordAuthAttempt($user, "10.0.0.1"); + //For up-to date timestamps and whatnot + $authAttempt->refresh(); + + $code = \Artisan::call("authattempt:list"); + $output = trim(\Artisan::output()); + $this->assertSame(0, $code); + + $this->assertSame($authAttempt->toJson(JSON_PRETTY_PRINT), $output); + } +} diff --git a/src/tests/Feature/Console/AuthAttempt/PurgeTest.php b/src/tests/Feature/Console/AuthAttempt/PurgeTest.php new file mode 100644 index 00000000..8b28ff86 --- /dev/null +++ b/src/tests/Feature/Console/AuthAttempt/PurgeTest.php @@ -0,0 +1,57 @@ +subDays(30); + + $user = $this->getTestUser('john@kolab.org'); + + $authAttempt1 = AuthAttempt::recordAuthAttempt($user, "10.0.0.1"); + $authAttempt1->refresh(); + $authAttempt1->updated_at = $cutoff->copy()->addDays(1); + $authAttempt1->save(['timestamps' => false]); + + $authAttempt2 = AuthAttempt::recordAuthAttempt($user, "10.0.0.2"); + $authAttempt2->refresh(); + $authAttempt2->updated_at = $cutoff->copy()->subDays(1); + $authAttempt2->save(['timestamps' => false]); + + $code = \Artisan::call('authattempt:purge'); + $this->assertSame(0, $code); + + $list = AuthAttempt::all(); + $this->assertCount(1, $list); + $this->assertSame($authAttempt1->id, $list[0]->id); + } +} diff --git a/src/tests/Feature/Controller/AuthAttemptsTest.php b/src/tests/Feature/Controller/AuthAttemptsTest.php index 5f6f82cd..ad6cae79 100644 --- a/src/tests/Feature/Controller/AuthAttemptsTest.php +++ b/src/tests/Feature/Controller/AuthAttemptsTest.php @@ -1,103 +1,131 @@ deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestDomain('userscontroller.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestDomain('userscontroller.com'); parent::tearDown(); } - public function testRecord(): void + /** + * Test cofirm (POST /api/v4/auth-attempts//confirm) + */ + public function testAccept(): void { $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $authAttempt = \App\AuthAttempt::recordAuthAttempt($user, "10.0.0.1"); - $this->assertEquals($authAttempt->user_id, $user->id); - $this->assertEquals($authAttempt->ip, "10.0.0.1"); + + $response = $this->actingAs($user)->post("api/v4/auth-attempts/{$authAttempt->id}/confirm"); + $response->assertStatus(200); $authAttempt->refresh(); - $this->assertEquals($authAttempt->status, "NEW"); + $this->assertTrue($authAttempt->isAccepted()); - $authAttempt2 = \App\AuthAttempt::recordAuthAttempt($user, "10.0.0.1"); - $this->assertEquals($authAttempt->id, $authAttempt2->id); + // wrong user + $user2 = $this->getTestUser('UsersControllerTest2@userscontroller.com'); + $response = $this->actingAs($user2)->post("api/v4/auth-attempts/{$authAttempt->id}/confirm"); + $response->assertStatus(403); - $authAttempt3 = \App\AuthAttempt::recordAuthAttempt($user, "10.0.0.2"); - $this->assertNotEquals($authAttempt->id, $authAttempt3->id); + // wrong id + $response = $this->actingAs($user)->post("api/v4/auth-attempts/9999/confirm"); + $response->assertStatus(404); } - public function testAcceptDeny(): void + /** + * Test deny (POST /api/v4/auth-attempts//deny) + */ + public function testDeny(): void { $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $authAttempt = \App\AuthAttempt::recordAuthAttempt($user, "10.0.0.1"); - $response = $this->actingAs($user)->post("api/v4/auth-attempts/{$authAttempt->id}/confirm"); - $response->assertStatus(200); - $authAttempt->refresh(); - $this->assertTrue($authAttempt->isAccepted()); - $response = $this->actingAs($user)->post("api/v4/auth-attempts/{$authAttempt->id}/deny"); $response->assertStatus(200); $authAttempt->refresh(); $this->assertTrue($authAttempt->isDenied()); + + // wrong user + $user2 = $this->getTestUser('UsersControllerTest2@userscontroller.com'); + $response = $this->actingAs($user2)->post("api/v4/auth-attempts/{$authAttempt->id}/deny"); + $response->assertStatus(403); + + // wrong id + $response = $this->actingAs($user)->post("api/v4/auth-attempts/9999/deny"); + $response->assertStatus(404); } + /** + * Test details (GET /api/v4/auth-attempts//details) + */ public function testDetails(): void { $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $authAttempt = \App\AuthAttempt::recordAuthAttempt($user, "10.0.0.1"); $response = $this->actingAs($user)->get("api/v4/auth-attempts/{$authAttempt->id}/details"); $response->assertStatus(200); $json = $response->json(); $authAttempt->refresh(); $this->assertEquals($user->email, $json['username']); - $this->assertEquals($authAttempt->ip, $json['ip']); - $this->assertEquals(json_encode($authAttempt->updated_at), "\"" . $json['timestamp'] . "\""); + $this->assertEquals($authAttempt->ip, $json['entry']['ip']); + $this->assertEquals(json_encode($authAttempt->updated_at), "\"" . $json['entry']['updated_at'] . "\""); $this->assertEquals("CH", $json['country']); + + // wrong user + $user2 = $this->getTestUser('UsersControllerTest2@userscontroller.com'); + $response = $this->actingAs($user2)->get("api/v4/auth-attempts/{$authAttempt->id}/details"); + $response->assertStatus(403); + + // wrong id + $response = $this->actingAs($user)->get("api/v4/auth-attempts/9999/details"); + $response->assertStatus(404); } + /** + * Test list (GET /api/v4/auth-attempts) + */ public function testList(): void { $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $authAttempt = \App\AuthAttempt::recordAuthAttempt($user, "10.0.0.1"); $authAttempt2 = \App\AuthAttempt::recordAuthAttempt($user, "10.0.0.2"); $response = $this->actingAs($user)->get("api/v4/auth-attempts"); $response->assertStatus(200); $json = $response->json(); - /* var_export($json); */ - - $this->assertEquals(count($json), 2); - $this->assertEquals($json[0]['id'], $authAttempt->id); - $this->assertEquals($json[1]['id'], $authAttempt2->id); + $this->assertCount(2, $json); + $this->assertTrue(in_array($json[0]['id'], [$authAttempt->id, $authAttempt2->id])); + $this->assertTrue(in_array($json[1]['id'], [$authAttempt->id, $authAttempt2->id])); + $this->assertTrue($json[0]['id'] != $json[1]['id']); } } diff --git a/src/tests/Feature/Controller/CompanionAppsTest.php b/src/tests/Feature/Controller/CompanionAppsTest.php new file mode 100644 index 00000000..08eb45be --- /dev/null +++ b/src/tests/Feature/Controller/CompanionAppsTest.php @@ -0,0 +1,82 @@ +deleteTestUser('UsersControllerTest1@userscontroller.com'); + $this->deleteTestDomain('userscontroller.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); + $this->deleteTestDomain('userscontroller.com'); + + parent::tearDown(); + } + + /** + * Test registering the app + */ + public function testRegister(): void + { + $user = $this->getTestUser('CompanionAppsTest1@userscontroller.com'); + + $notificationToken = "notificationToken"; + $deviceId = "deviceId"; + + $response = $this->actingAs($user)->post( + "api/v4/companion/register", + ['notificationToken' => $notificationToken, 'deviceId' => $deviceId] + ); + + $response->assertStatus(200); + + $companionApp = \App\CompanionApp::where('device_id', $deviceId)->first(); + $this->assertTrue($companionApp != null); + $this->assertEquals($deviceId, $companionApp->device_id); + $this->assertEquals($notificationToken, $companionApp->notification_token); + + // Test a token update + $notificationToken = "notificationToken2"; + $response = $this->actingAs($user)->post( + "api/v4/companion/register", + ['notificationToken' => $notificationToken, 'deviceId' => $deviceId] + ); + + $response->assertStatus(200); + + $companionApp->refresh(); + $this->assertEquals($notificationToken, $companionApp->notification_token); + + // Failing input valdiation + $response = $this->actingAs($user)->post( + "api/v4/companion/register", + [] + ); + $response->assertStatus(422); + + // Other users device + $user2 = $this->getTestUser('CompanionAppsTest2@userscontroller.com'); + $response = $this->actingAs($user2)->post( + "api/v4/companion/register", + ['notificationToken' => $notificationToken, 'deviceId' => $deviceId] + ); + $response->assertStatus(403); + } +} diff --git a/src/tests/Feature/Controller/NGINXTest.php b/src/tests/Feature/Controller/NGINXTest.php index 02f8f382..90b5ed37 100644 --- a/src/tests/Feature/Controller/NGINXTest.php +++ b/src/tests/Feature/Controller/NGINXTest.php @@ -1,119 +1,169 @@ getTestUser('john@kolab.org'); \App\CompanionApp::where('user_id', $john->id)->delete(); \App\AuthAttempt::where('user_id', $john->id)->delete(); $john->setSettings( [ // 'limit_geo' => json_encode(["CH"]), 'guam_enabled' => false, '2fa_enabled' => false ] ); + + $this->useServicesUrl(); } + /** + * {@inheritDoc} + */ public function tearDown(): void { $john = $this->getTestUser('john@kolab.org'); \App\CompanionApp::where('user_id', $john->id)->delete(); \App\AuthAttempt::where('user_id', $john->id)->delete(); $john->setSettings( [ // 'limit_geo' => json_encode(["CH"]), 'guam_enabled' => false, '2fa_enabled' => false ] ); parent::tearDown(); } /** * Test the webhook */ public function testNGINXWebhook(): void { $john = $this->getTestUser('john@kolab.org'); - $response = $this->actingAs($john)->get("api/webhooks/nginx"); + $response = $this->get("api/webhooks/nginx"); $response->assertStatus(200); - $response->assertHeader('auth-status', 'NO'); + $response->assertHeader('auth-status', 'authentication failure'); + $pass = \App\Utils::generatePassphrase(); $headers = [ 'Auth-Login-Attempt' => '1', 'Auth-Method' => 'plain', - 'Auth-Pass' => 'simple123', + 'Auth-Pass' => $pass, 'Auth-Protocol' => 'imap', 'Auth-Ssl' => 'on', 'Auth-User' => 'john@kolab.org', 'Client-Ip' => '127.0.0.1', 'Host' => '127.0.0.1', 'Auth-SSL' => 'on', 'Auth-SSL-Verify' => 'SUCCESS', 'Auth-SSL-Subject' => '/CN=example.com', 'Auth-SSL-Issuer' => '/CN=example.com', 'Auth-SSL-Serial' => 'C07AD56B846B5BFF', 'Auth-SSL-Fingerprint' => '29d6a80a123d13355ed16b4b04605e29cb55a5ad' ]; // Pass - $response = $this->actingAs($john)->withHeaders($headers)->get("api/webhooks/nginx"); + $response = $this->withHeaders($headers)->get("api/webhooks/nginx"); $response->assertStatus(200); $response->assertHeader('auth-status', 'OK'); - $response->assertHeader('auth-port', '11993'); + $response->assertHeader('auth-port', '12143'); // Invalid Password $modifiedHeaders = $headers; $modifiedHeaders['Auth-Pass'] = "Invalid"; - $response = $this->actingAs($john)->withHeaders($modifiedHeaders)->get("api/webhooks/nginx"); + $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx"); + $response->assertStatus(200); + $response->assertHeader('auth-status', 'authentication failure'); + + // Empty Password + $modifiedHeaders = $headers; + $modifiedHeaders['Auth-Pass'] = ""; + $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx"); + $response->assertStatus(200); + $response->assertHeader('auth-status', 'authentication failure'); + + // Empty User + $modifiedHeaders = $headers; + $modifiedHeaders['Auth-User'] = ""; + $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx"); + $response->assertStatus(200); + $response->assertHeader('auth-status', 'authentication failure'); + + // Invalid User + $modifiedHeaders = $headers; + $modifiedHeaders['Auth-User'] = "foo@kolab.org"; + $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx"); + $response->assertStatus(200); + $response->assertHeader('auth-status', 'authentication failure'); + + // Empty Ip + $modifiedHeaders = $headers; + $modifiedHeaders['Client-Ip'] = ""; + $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx"); $response->assertStatus(200); - $response->assertHeader('auth-status', 'NO'); + $response->assertHeader('auth-status', 'authentication failure'); + // SMTP Auth Protocol + $modifiedHeaders = $headers; + $modifiedHeaders['Auth-Protocol'] = "smtp"; + $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx"); + $response->assertStatus(200); + $response->assertHeader('auth-status', 'OK'); + $response->assertHeader('auth-server', '127.0.0.1'); + $response->assertHeader('auth-port', '10465'); + $response->assertHeader('auth-pass', $pass); + + // Empty Auth Protocol + $modifiedHeaders = $headers; + $modifiedHeaders['Auth-Protocol'] = ""; + $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx"); + $response->assertStatus(200); + $response->assertHeader('auth-status', 'authentication failure'); // Guam $john->setSettings( [ 'guam_enabled' => true, ] ); - $response = $this->actingAs($john)->withHeaders($headers)->get("api/webhooks/nginx"); + $response = $this->withHeaders($headers)->get("api/webhooks/nginx"); $response->assertStatus(200); $response->assertHeader('auth-status', 'OK'); - $response->assertHeader('auth-port', '9993'); + $response->assertHeader('auth-server', '127.0.0.1'); + $response->assertHeader('auth-port', '9143'); // 2-FA without device $john->setSettings( [ '2fa_enabled' => true, ] ); \App\CompanionApp::where('user_id', $john->id)->delete(); - $response = $this->actingAs($john)->withHeaders($headers)->get("api/webhooks/nginx"); + $response = $this->withHeaders($headers)->get("api/webhooks/nginx"); $response->assertStatus(200); - $response->assertHeader('auth-status', 'NO'); + $response->assertHeader('auth-status', 'authentication failure'); // 2-FA with accepted auth attempt $authAttempt = \App\AuthAttempt::recordAuthAttempt($john, "127.0.0.1"); $authAttempt->accept(); - $authAttempt->save(); - $response = $this->actingAs($john)->withHeaders($headers)->get("api/webhooks/nginx"); + $response = $this->withHeaders($headers)->get("api/webhooks/nginx"); $response->assertStatus(200); $response->assertHeader('auth-status', 'OK'); } } diff --git a/src/tests/Feature/Controller/Reseller/DomainsTest.php b/src/tests/Feature/Controller/Reseller/DomainsTest.php index 0e483406..78066c18 100644 --- a/src/tests/Feature/Controller/Reseller/DomainsTest.php +++ b/src/tests/Feature/Controller/Reseller/DomainsTest.php @@ -1,286 +1,286 @@ deleteTestDomain('domainscontroller.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestDomain('domainscontroller.com'); parent::tearDown(); } /** * Test domain confirm request */ public function testConfirm(): void { $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $domain = $this->getTestDomain('domainscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); // THe end-point exists on the users controller, but not reseller's $response = $this->actingAs($reseller1)->get("api/v4/domains/{$domain->id}/confirm"); $response->assertStatus(404); } /** * Test domains searching (/api/v4/domains) */ public function testIndex(): void { $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); // Non-admin user $response = $this->actingAs($user)->get("api/v4/domains"); $response->assertStatus(403); // Admin user $response = $this->actingAs($admin)->get("api/v4/domains"); $response->assertStatus(403); // Search with no matches expected $response = $this->actingAs($reseller1)->get("api/v4/domains?search=abcd12.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); // Search by a domain name $response = $this->actingAs($reseller1)->get("api/v4/domains?search=kolab.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame('kolab.org', $json['list'][0]['namespace']); // Search by owner $response = $this->actingAs($reseller1)->get("api/v4/domains?owner={$user->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame('kolab.org', $json['list'][0]['namespace']); // Search by owner (Ned is a controller on John's wallets, // here we expect only domains assigned to Ned's wallet(s)) $ned = $this->getTestUser('ned@kolab.org'); $response = $this->actingAs($reseller1)->get("api/v4/domains?owner={$ned->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertCount(0, $json['list']); // Test unauth access to other tenant's domains $response = $this->actingAs($reseller2)->get("api/v4/domains?search=kolab.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); $response = $this->actingAs($reseller2)->get("api/v4/domains?owner={$user->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); } /** * Test fetching domain info */ public function testShow(): void { $sku_domain = Sku::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $user = $this->getTestUser('test1@domainscontroller.com'); $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); $domain = $this->getTestDomain('domainscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); Entitlement::create([ 'wallet_id' => $user->wallets()->first()->id, 'sku_id' => $sku_domain->id, 'entitleable_id' => $domain->id, 'entitleable_type' => Domain::class ]); // Unauthorized access (user) $response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}"); $response->assertStatus(403); // Unauthorized access (admin) $response = $this->actingAs($admin)->get("api/v4/domains/{$domain->id}"); $response->assertStatus(403); // Unauthorized access (tenant != env-tenant) $response = $this->actingAs($reseller2)->get("api/v4/domains/{$domain->id}"); $response->assertStatus(404); $response = $this->actingAs($reseller1)->get("api/v4/domains/{$domain->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals($domain->id, $json['id']); $this->assertEquals($domain->namespace, $json['namespace']); $this->assertEquals($domain->status, $json['status']); $this->assertEquals($domain->type, $json['type']); // Note: Other properties are being tested in the user controller tests } /** * Test fetching domain status (GET /api/v4/domains//status) */ public function testStatus(): void { $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $domain = $this->getTestDomain('kolab.org'); // This end-point does not exist for resellers $response = $this->actingAs($reseller1)->get("/api/v4/domains/{$domain->id}/status"); $response->assertStatus(404); } /** * Test domain suspending (POST /api/v4/domains//suspend) */ public function testSuspend(): void { Queue::fake(); // disable jobs $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); - \config(['app.tenant_id' => 2]); + \config(['app.tenant_id' => $reseller2->tenant_id]); $domain = $this->getTestDomain('domainscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); $user = $this->getTestUser('test@domainscontroller.com'); // Test unauthorized access to the reseller API (user) $response = $this->actingAs($user)->post("/api/v4/domains/{$domain->id}/suspend", []); $response->assertStatus(403); $this->assertFalse($domain->fresh()->isSuspended()); // Test unauthorized access to the reseller API (admin) $response = $this->actingAs($admin)->post("/api/v4/domains/{$domain->id}/suspend", []); $response->assertStatus(403); $this->assertFalse($domain->fresh()->isSuspended()); // Test unauthorized access to the reseller API (reseller in another tenant) $response = $this->actingAs($reseller1)->post("/api/v4/domains/{$domain->id}/suspend", []); $response->assertStatus(404); $this->assertFalse($domain->fresh()->isSuspended()); // Test suspending the domain $response = $this->actingAs($reseller2)->post("/api/v4/domains/{$domain->id}/suspend", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("Domain suspended successfully.", $json['message']); $this->assertCount(2, $json); $this->assertTrue($domain->fresh()->isSuspended()); } /** * Test user un-suspending (POST /api/v4/users//unsuspend) */ public function testUnsuspend(): void { Queue::fake(); // disable jobs $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); - \config(['app.tenant_id' => 2]); + \config(['app.tenant_id' => $reseller2->tenant_id]); $domain = $this->getTestDomain('domainscontroller.com', [ 'status' => Domain::STATUS_NEW | Domain::STATUS_SUSPENDED, 'type' => Domain::TYPE_EXTERNAL, ]); $user = $this->getTestUser('test@domainscontroller.com'); // Test unauthorized access to reseller API (user) $response = $this->actingAs($user)->post("/api/v4/domains/{$domain->id}/unsuspend", []); $response->assertStatus(403); $this->assertTrue($domain->fresh()->isSuspended()); // Test unauthorized access to reseller API (admin) $response = $this->actingAs($admin)->post("/api/v4/domains/{$domain->id}/unsuspend", []); $response->assertStatus(403); $this->assertTrue($domain->fresh()->isSuspended()); // Test unauthorized access to reseller API (another tenant) $response = $this->actingAs($reseller1)->post("/api/v4/domains/{$domain->id}/unsuspend", []); $response->assertStatus(404); $this->assertTrue($domain->fresh()->isSuspended()); // Test suspending the user $response = $this->actingAs($reseller2)->post("/api/v4/domains/{$domain->id}/unsuspend", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("Domain unsuspended successfully.", $json['message']); $this->assertCount(2, $json); $this->assertFalse($domain->fresh()->isSuspended()); } }