diff --git a/src/.env.example b/src/.env.example index ecf69e41..8fe0d817 100644 --- a/src/.env.example +++ b/src/.env.example @@ -1,176 +1,180 @@ 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= APP_WITH_ADMIN=1 APP_WITH_RESELLER=1 APP_WITH_SERVICES=1 APP_HEADER_CSP="connect-src 'self'; child-src 'self'; font-src 'self'; form-action 'self' data:; frame-ancestors 'self'; img-src blob: data: 'self' *; media-src 'self'; object-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-eval' 'unsafe-inline'; default-src 'self';" APP_HEADER_XFO=sameorigin SIGNUP_LIMIT_EMAIL=0 SIGNUP_LIMIT_IP=0 ASSET_URL=http://127.0.0.1:8000 WEBMAIL_URL=/apps SUPPORT_URL=/support SUPPORT_EMAIL= LOG_CHANNEL=stack LOG_SLOW_REQUESTS=5 LOG_DEPRECATIONS_CHANNEL=null LOG_LEVEL=debug 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= # Set these to IP addresses you serve WOAT with. # Have the domain owner point _woat. NS RRs refer to ns0{1,2}. WOAT_NS1=ns01.domain.tld WOAT_NS2=ns02.domain.tld REDIS_HOST=127.0.0.1 REDIS_PASSWORD=null REDIS_PORT=6379 OCTANE_HTTP_HOST=127.0.0.1 PAYMENT_PROVIDER= MOLLIE_KEY= STRIPE_KEY= STRIPE_PUBLIC_KEY= STRIPE_WEBHOOK_SECRET= MAIL_MAILER=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= AWS_USE_PATH_STYLE_ENDPOINT=false 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}" # Generate with ./artisan passport:client --password #PASSPORT_PROXY_OAUTH_CLIENT_ID= #PASSPORT_PROXY_OAUTH_CLIENT_SECRET= +# Generate with ./artisan passport:client --password +#PASSPORT_COMPANIONAPP_OAUTH_CLIENT_ID= +#PASSPORT_COMPANIONAPP_OAUTH_CLIENT_SECRET= + PASSPORT_PRIVATE_KEY= PASSPORT_PUBLIC_KEY= PASSWORD_POLICY= 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/Http/Controllers/API/V4/CompanionAppsController.php b/src/app/Http/Controllers/API/V4/CompanionAppsController.php index 70e7ecc5..540c3a25 100644 --- a/src/app/Http/Controllers/API/V4/CompanionAppsController.php +++ b/src/app/Http/Controllers/API/V4/CompanionAppsController.php @@ -1,58 +1,204 @@ guard()->user(); $v = Validator::make( $request->all(), [ 'notificationToken' => 'required|min:4|max:512', 'deviceId' => 'required|min:4|max:64', + 'name' => 'required|max:512', ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } $notificationToken = $request->notificationToken; $deviceId = $request->deviceId; + $name = $request->name; - \Log::info("Registering app. Notification token: {$notificationToken} Device id: {$deviceId}"); + \Log::info("Registering app. Notification token: {$notificationToken} Device id: {$deviceId} Name: {$name}"); $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; + $app->name = $name; } 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(); return response()->json(['status' => 'success']); } + + + /** + * Generate a QR-code image for a string + * + * @param string $data data to encode + * + * @return string + */ + private static function generateQRCode($data) + { + $renderer_style = new BaconQrCode\Renderer\RendererStyle\RendererStyle(300, 1); + $renderer_image = new BaconQrCode\Renderer\Image\SvgImageBackEnd(); + $renderer = new BaconQrCode\Renderer\ImageRenderer($renderer_style, $renderer_image); + $writer = new BaconQrCode\Writer($renderer); + + return 'data:image/svg+xml;base64,' . base64_encode($writer->writeString($data)); + } + + /** + * Revoke all companion app devices. + * + * @return \Illuminate\Http\JsonResponse The response + */ + public function revokeAll() + { + $user = $this->guard()->user(); + \App\CompanionApp::where('user_id', $user->id)->delete(); + + // Revoke all companion app tokens + $clientIdentifier = \App\Tenant::getConfig($user->tenant_id, 'auth.companion_app.client_id'); + $tokens = Token::where('user_id', $user->id)->where('client_id', $clientIdentifier)->get(); + + $tokenRepository = app(TokenRepository::class); + $refreshTokenRepository = app(RefreshTokenRepository::class); + + foreach ($tokens as $token) { + $tokenRepository->revokeAccessToken($token->id); + $refreshTokenRepository->revokeRefreshTokensByAccessTokenId($token->id); + } + + return response()->json([ + 'status' => 'success', + 'message' => \trans("app.companion-deleteall-success"), + ]); + } + + /** + * List devices. + * + * @return \Illuminate\Http\JsonResponse + */ + public function index() + { + $user = $this->guard()->user(); + $search = trim(request()->input('search')); + $page = intval(request()->input('page')) ?: 1; + $pageSize = 20; + $hasMore = false; + + $result = \App\CompanionApp::where('user_id', $user->id); + + $result = $result->orderBy('created_at') + ->limit($pageSize + 1) + ->offset($pageSize * ($page - 1)) + ->get(); + + if (count($result) > $pageSize) { + $result->pop(); + $hasMore = true; + } + + // Process the result + $result = $result->map( + function ($device) { + return $device->toArray(); + } + ); + + $result = [ + 'list' => $result, + 'count' => count($result), + 'hasMore' => $hasMore, + ]; + + return response()->json($result); + } + + /** + * Get the information about the specified companion app. + * + * @param string $id CompanionApp identifier + * + * @return \Illuminate\Http\JsonResponse + */ + public function show($id) + { + $result = \App\CompanionApp::find($id); + if (!$result) { + return $this->errorResponse(404); + } + + $user = $this->guard()->user(); + if ($user->id != $result->user_id) { + return $this->errorResponse(403); + } + + return response()->json($result->toArray()); + } + + /** + * Retrieve the pairing information encoded into a qrcode image. + * + * @return \Illuminate\Http\JsonResponse + */ + public function pairing() + { + $user = $this->guard()->user(); + + $clientIdentifier = \App\Tenant::getConfig($user->tenant_id, 'auth.companion_app.client_id'); + $clientSecret = \App\Tenant::getConfig($user->tenant_id, 'auth.companion_app.client_secret'); + if (empty($clientIdentifier) || empty($clientSecret)) { + \Log::warning("Empty client identifier or secret. Can't generate qr-code."); + return $this->errorResponse(500); + } + + $response['qrcode'] = self::generateQRCode( + json_encode([ + "serverUrl" => Utils::serviceUrl('', $user->tenant_id), + "clientIdentifier" => \App\Tenant::getConfig($user->tenant_id, 'auth.companion_app.client_id'), + "clientSecret" => \App\Tenant::getConfig($user->tenant_id, 'auth.companion_app.client_secret'), + "username" => $user->email + ]) + ); + + return response()->json($response); + } } diff --git a/src/app/Http/Controllers/API/V4/NGINXController.php b/src/app/Http/Controllers/API/V4/NGINXController.php index 622e8fb2..3ac357a9 100644 --- a/src/app/Http/Controllers/API/V4/NGINXController.php +++ b/src/app/Http/Controllers/API/V4/NGINXController.php @@ -1,290 +1,290 @@ first(); if (!$user) { throw new \Exception("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(); } throw new \Exception("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(); throw new \Exception("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)) { + if (\App\CompanionApp::where('user_id', $user->id)->exists()) { $authAttempt = \App\AuthAttempt::recordAuthAttempt($user, $clientIP); if (!$authAttempt->waitFor2FA()) { throw new \Exception("2fa failed"); } } return $user; } /** * Convert domain.tld\username into username@domain for activesync * * @param string $username The original username. * * @return string The username in canonical form */ private function normalizeUsername($username) { $usernameParts = explode("\\", $username); if (count($usernameParts) == 2) { $username = $usernameParts[1]; if (!strpos($username, '@') && !empty($usernameParts[0])) { $username .= '@' . $usernameParts[0]; } } return $username; } /** * Authentication request from the ngx_http_auth_request_module * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\Response The response */ public function httpauth(Request $request) { /** Php-Auth-Pw: simple123 Php-Auth-User: john@kolab.org Sec-Fetch-Dest: document Sec-Fetch-Mode: navigate Sec-Fetch-Site: cross-site Sec-Gpc: 1 Upgrade-Insecure-Requests: 1 User-Agent: Mozilla/5.0 (X11; Fedora; Linux x86_64; rv:93.0) Gecko/20100101 Firefox/93.0 X-Forwarded-For: 31.10.153.58 X-Forwarded-Proto: https X-Original-Uri: /iRony/ X-Real-Ip: 31.10.153.58 */ \Log::debug("Authentication attempt\n{$request->headers}"); $username = $this->normalizeUsername($request->headers->get('Php-Auth-User', "")); $password = $request->headers->get('Php-Auth-Pw', null); if (empty($password)) { \Log::debug("Authentication attempt failed: Empty password provided."); return response("", 401); } try { $this->authorizeRequest( $username, $password, $request->headers->get('X-Real-Ip', null), ); } catch (\Exception $e) { \Log::debug("Authentication attempt failed: {$e->getMessage()}"); return response("", 403); } \Log::debug("Authentication attempt succeeded"); return response(""); } /** * Authentication request. * * @todo: Separate IMAP(+STARTTLS) from IMAPS, same for SMTP/submission. => * 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\n{$request->headers}"); $password = $request->headers->get('Auth-Pass', null); try { $user = $this->authorizeRequest( $request->headers->get('Auth-User', null), $password, $request->headers->get('Client-Ip', null), ); } catch (\Exception $e) { return $this->byebye($request, $e->getMessage()); } // 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/API/V4/UsersController.php b/src/app/Http/Controllers/API/V4/UsersController.php index 669acd64..ed7566af 100644 --- a/src/app/Http/Controllers/API/V4/UsersController.php +++ b/src/app/Http/Controllers/API/V4/UsersController.php @@ -1,762 +1,763 @@ guard()->user(); $search = trim(request()->input('search')); $page = intval(request()->input('page')) ?: 1; $pageSize = 20; $hasMore = false; $result = $user->users(); // Search by user email, alias or name if (strlen($search) > 0) { // thanks to cloning we skip some extra queries in $user->users() $allUsers1 = clone $result; $allUsers2 = clone $result; $result->whereLike('email', $search) ->union( $allUsers1->join('user_aliases', 'users.id', '=', 'user_aliases.user_id') ->whereLike('alias', $search) ) ->union( $allUsers2->join('user_settings', 'users.id', '=', 'user_settings.user_id') ->whereLike('value', $search) ->whereIn('key', ['first_name', 'last_name']) ); } $result = $result->orderBy('email') ->limit($pageSize + 1) ->offset($pageSize * ($page - 1)) ->get(); if (count($result) > $pageSize) { $result->pop(); $hasMore = true; } // Process the result $result = $result->map( function ($user) { return $this->objectToClient($user); } ); $result = [ 'list' => $result, 'count' => count($result), 'hasMore' => $hasMore, ]; return response()->json($result); } /** * Display information on the user account specified by $id. * * @param string $id The account to show information for. * * @return \Illuminate\Http\JsonResponse */ public function show($id) { $user = User::find($id); if (!$this->checkTenant($user)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($user)) { return $this->errorResponse(403); } $response = $this->userResponse($user); $response['skus'] = \App\Entitlement::objectEntitlementsSummary($user); $response['config'] = $user->getConfig(); $response['aliases'] = $user->aliases()->pluck('alias')->all(); $code = $user->verificationcodes()->where('active', true) ->where('expires_at', '>', \Carbon\Carbon::now()) ->first(); if ($code) { $response['passwordLinkCode'] = $code->short_code . '-' . $code->code; } return response()->json($response); } /** * User status (extended) information * * @param \App\User $user User object * * @return array Status information */ public static function statusInfo($user): array { $process = self::processStateInfo( $user, [ 'user-new' => true, 'user-ldap-ready' => $user->isLdapReady(), 'user-imap-ready' => $user->isImapReady(), ] ); // Check if the user is a controller of his wallet $isController = $user->canDelete($user); $hasCustomDomain = $user->wallet()->entitlements() ->where('entitleable_type', Domain::class) ->count() > 0; // Get user's entitlements titles $skus = $user->entitlements()->select('skus.title') ->join('skus', 'skus.id', '=', 'entitlements.sku_id') ->get() ->pluck('title') ->sort() ->unique() ->values() ->all(); $result = [ 'skus' => $skus, // TODO: This will change when we enable all users to create domains 'enableDomains' => $isController && $hasCustomDomain, // TODO: Make 'enableDistlists' working for wallet controllers that aren't account owners 'enableDistlists' => $isController && $hasCustomDomain && in_array('beta-distlists', $skus), // TODO: Make 'enableFolders' working for wallet controllers that aren't account owners 'enableFolders' => $isController && $hasCustomDomain && in_array('beta-shared-folders', $skus), // TODO: Make 'enableResources' working for wallet controllers that aren't account owners 'enableResources' => $isController && $hasCustomDomain && in_array('beta-resources', $skus), 'enableSettings' => $isController, 'enableUsers' => $isController, 'enableWallets' => $isController, + 'enableCompanionapps' => $isController && in_array('beta', $skus), ]; return array_merge($process, $result); } /** * Create a new user record. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function store(Request $request) { $current_user = $this->guard()->user(); $owner = $current_user->walletOwner(); if ($owner->id != $current_user->id) { return $this->errorResponse(403); } $this->deleteBeforeCreate = null; if ($error_response = $this->validateUserRequest($request, null, $settings)) { return $error_response; } if (empty($request->package) || !($package = \App\Package::withEnvTenantContext()->find($request->package))) { $errors = ['package' => \trans('validation.packagerequired')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } if ($package->isDomain()) { $errors = ['package' => \trans('validation.packageinvalid')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } DB::beginTransaction(); // @phpstan-ignore-next-line if ($this->deleteBeforeCreate) { $this->deleteBeforeCreate->forceDelete(); } // Create user record $user = User::create([ 'email' => $request->email, 'password' => $request->password, ]); $this->activatePassCode($user); $owner->assignPackage($package, $user); if (!empty($settings)) { $user->setSettings($settings); } if (!empty($request->aliases)) { $user->setAliases($request->aliases); } DB::commit(); return response()->json([ 'status' => 'success', 'message' => \trans('app.user-create-success'), ]); } /** * Update user data. * * @param \Illuminate\Http\Request $request The API request. * @param string $id User identifier * * @return \Illuminate\Http\JsonResponse The response */ public function update(Request $request, $id) { $user = User::withEnvTenantContext()->find($id); if (empty($user)) { return $this->errorResponse(404); } $current_user = $this->guard()->user(); // TODO: Decide what attributes a user can change on his own profile if (!$current_user->canUpdate($user)) { return $this->errorResponse(403); } if ($error_response = $this->validateUserRequest($request, $user, $settings)) { return $error_response; } // Entitlements, only controller can do that if ($request->skus !== null && !$current_user->canDelete($user)) { return $this->errorResponse(422, "You have no permission to change entitlements"); } DB::beginTransaction(); $this->updateEntitlements($user, $request->skus); if (!empty($settings)) { $user->setSettings($settings); } if (!empty($request->password)) { $user->password = $request->password; $user->save(); } $this->activatePassCode($user); if (isset($request->aliases)) { $user->setAliases($request->aliases); } DB::commit(); $response = [ 'status' => 'success', 'message' => \trans('app.user-update-success'), ]; // For self-update refresh the statusInfo in the UI if ($user->id == $current_user->id) { $response['statusInfo'] = self::statusInfo($user); } return response()->json($response); } /** * Update user entitlements. * * @param \App\User $user The user * @param array $rSkus List of SKU IDs requested for the user in the form [id=>qty] */ protected function updateEntitlements(User $user, $rSkus) { if (!is_array($rSkus)) { return; } // list of skus, [id=>obj] $skus = Sku::withEnvTenantContext()->get()->mapWithKeys( function ($sku) { return [$sku->id => $sku]; } ); // existing entitlement's SKUs $eSkus = []; $user->entitlements()->groupBy('sku_id') ->selectRaw('count(*) as total, sku_id')->each( function ($e) use (&$eSkus) { $eSkus[$e->sku_id] = $e->total; } ); foreach ($skus as $skuID => $sku) { $e = array_key_exists($skuID, $eSkus) ? $eSkus[$skuID] : 0; $r = array_key_exists($skuID, $rSkus) ? $rSkus[$skuID] : 0; if ($sku->handler_class == \App\Handlers\Mailbox::class) { if ($r != 1) { throw new \Exception("Invalid quantity of mailboxes"); } } if ($e > $r) { // remove those entitled more than existing $user->removeSku($sku, ($e - $r)); } elseif ($e < $r) { // add those requested more than entitled $user->assignSku($sku, ($r - $e)); } } } /** * Create a response data array for specified user. * * @param \App\User $user User object * * @return array Response data */ public static function userResponse(User $user): array { $response = array_merge($user->toArray(), self::objectState($user)); // Settings $response['settings'] = []; foreach ($user->settings()->whereIn('key', self::USER_SETTINGS)->get() as $item) { $response['settings'][$item->key] = $item->value; } // Status info $response['statusInfo'] = self::statusInfo($user); // Add more info to the wallet object output $map_func = function ($wallet) use ($user) { $result = $wallet->toArray(); if ($wallet->discount) { $result['discount'] = $wallet->discount->discount; $result['discount_description'] = $wallet->discount->description; } if ($wallet->user_id != $user->id) { $result['user_email'] = $wallet->owner->email; } $provider = \App\Providers\PaymentProvider::factory($wallet); $result['provider'] = $provider->name(); return $result; }; // Information about wallets and accounts for access checks $response['wallets'] = $user->wallets->map($map_func)->toArray(); $response['accounts'] = $user->accounts->map($map_func)->toArray(); $response['wallet'] = $map_func($user->wallet()); return $response; } /** * Prepare user statuses for the UI * * @param \App\User $user User object * * @return array Statuses array */ protected static function objectState($user): array { $state = parent::objectState($user); $state['isAccountDegraded'] = $user->isDegraded(true); return $state; } /** * Validate user input * * @param \Illuminate\Http\Request $request The API request. * @param \App\User|null $user User identifier * @param array $settings User settings (from the request) * * @return \Illuminate\Http\JsonResponse|null The error response on error */ protected function validateUserRequest(Request $request, $user, &$settings = []) { $rules = [ 'external_email' => 'nullable|email', 'phone' => 'string|nullable|max:64|regex:/^[0-9+() -]+$/', 'first_name' => 'string|nullable|max:128', 'last_name' => 'string|nullable|max:128', 'organization' => 'string|nullable|max:512', 'billing_address' => 'string|nullable|max:1024', 'country' => 'string|nullable|alpha|size:2', 'currency' => 'string|nullable|alpha|size:3', 'aliases' => 'array|nullable', ]; $controller = ($user ?: $this->guard()->user())->walletOwner(); // Handle generated password reset code if ($code = $request->input('passwordLinkCode')) { // Accept - input if (strpos($code, '-')) { $code = explode('-', $code)[1]; } $this->passCode = $this->guard()->user()->verificationcodes() ->where('code', $code)->where('active', false)->first(); // Generate a password for a new user with password reset link // FIXME: Should/can we have a user with no password set? if ($this->passCode && empty($user)) { $request->password = $request->password_confirmation = Str::random(16); $ignorePassword = true; } } if (empty($user) || !empty($request->password) || !empty($request->password_confirmation)) { if (empty($ignorePassword)) { $rules['password'] = ['required', 'confirmed', new Password($controller)]; } } $errors = []; // Validate input $v = Validator::make($request->all(), $rules); if ($v->fails()) { $errors = $v->errors()->toArray(); } // For new user validate email address if (empty($user)) { $email = $request->email; if (empty($email)) { $errors['email'] = \trans('validation.required', ['attribute' => 'email']); } elseif ($error = self::validateEmail($email, $controller, $this->deleteBeforeCreate)) { $errors['email'] = $error; } } // Validate aliases input if (isset($request->aliases)) { $aliases = []; $existing_aliases = $user ? $user->aliases()->get()->pluck('alias')->toArray() : []; foreach ($request->aliases as $idx => $alias) { if (is_string($alias) && !empty($alias)) { // Alias cannot be the same as the email address (new user) if (!empty($email) && Str::lower($alias) == Str::lower($email)) { continue; } // validate new aliases if ( !in_array($alias, $existing_aliases) && ($error = self::validateAlias($alias, $controller)) ) { if (!isset($errors['aliases'])) { $errors['aliases'] = []; } $errors['aliases'][$idx] = $error; continue; } $aliases[] = $alias; } } $request->aliases = $aliases; } if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } // Update user settings $settings = $request->only(array_keys($rules)); unset($settings['password'], $settings['aliases'], $settings['email']); return null; } /** * Execute (synchronously) specified step in a user setup process. * * @param \App\User $user User object * @param string $step Step identifier (as in self::statusInfo()) * * @return bool|null True if the execution succeeded, False if not, Null when * the job has been sent to the worker (result unknown) */ public static function execProcessStep(User $user, string $step): ?bool { try { if (strpos($step, 'domain-') === 0) { list ($local, $domain) = explode('@', $user->email); $domain = Domain::where('namespace', $domain)->first(); return DomainsController::execProcessStep($domain, $step); } switch ($step) { case 'user-ldap-ready': // User not in LDAP, create it $job = new \App\Jobs\User\CreateJob($user->id); $job->handle(); $user->refresh(); return $user->isLdapReady(); case 'user-imap-ready': // User not in IMAP? Verify again // Do it synchronously if the imap admin credentials are available // otherwise let the worker do the job if (!\config('imap.admin_password')) { \App\Jobs\User\VerifyJob::dispatch($user->id); return null; } $job = new \App\Jobs\User\VerifyJob($user->id); $job->handle(); $user->refresh(); return $user->isImapReady(); } } catch (\Exception $e) { \Log::error($e); } return false; } /** * Email address validation for use as a user mailbox (login). * * @param string $email Email address * @param \App\User $user The account owner * @param null|\App\User|\App\Group $deleted Filled with an instance of a deleted user or group * with the specified email address, if exists * * @return ?string Error message on validation error */ public static function validateEmail(string $email, \App\User $user, &$deleted = null): ?string { $deleted = null; if (strpos($email, '@') === false) { return \trans('validation.entryinvalid', ['attribute' => 'email']); } list($login, $domain) = explode('@', Str::lower($email)); if (strlen($login) === 0 || strlen($domain) === 0) { return \trans('validation.entryinvalid', ['attribute' => 'email']); } // Check if domain exists $domain = Domain::withObjectTenantContext($user)->where('namespace', $domain)->first(); if (empty($domain)) { return \trans('validation.domaininvalid'); } // Validate login part alone $v = Validator::make( ['email' => $login], ['email' => ['required', new UserEmailLocal(!$domain->isPublic())]] ); if ($v->fails()) { return $v->errors()->toArray()['email'][0]; } // Check if it is one of domains available to the user if (!$domain->isPublic() && $user->id != $domain->walletOwner()->id) { return \trans('validation.entryexists', ['attribute' => 'domain']); } // Check if a user/group/resource/shared folder with specified address already exists if ( ($existing = User::emailExists($email, true)) || ($existing = \App\Group::emailExists($email, true)) || ($existing = \App\Resource::emailExists($email, true)) || ($existing = \App\SharedFolder::emailExists($email, true)) ) { // If this is a deleted user/group/resource/folder in the same custom domain // we'll force delete it before creating the target user if (!$domain->isPublic() && $existing->trashed()) { $deleted = $existing; } else { return \trans('validation.entryexists', ['attribute' => 'email']); } } // Check if an alias with specified address already exists. if (User::aliasExists($email) || \App\SharedFolder::aliasExists($email)) { return \trans('validation.entryexists', ['attribute' => 'email']); } return null; } /** * Email address validation for use as an alias. * * @param string $email Email address * @param \App\User $user The account owner * * @return ?string Error message on validation error */ public static function validateAlias(string $email, \App\User $user): ?string { if (strpos($email, '@') === false) { return \trans('validation.entryinvalid', ['attribute' => 'alias']); } list($login, $domain) = explode('@', Str::lower($email)); if (strlen($login) === 0 || strlen($domain) === 0) { return \trans('validation.entryinvalid', ['attribute' => 'alias']); } // Check if domain exists $domain = Domain::withObjectTenantContext($user)->where('namespace', $domain)->first(); if (empty($domain)) { return \trans('validation.domaininvalid'); } // Validate login part alone $v = Validator::make( ['alias' => $login], ['alias' => ['required', new UserEmailLocal(!$domain->isPublic())]] ); if ($v->fails()) { return $v->errors()->toArray()['alias'][0]; } // Check if it is one of domains available to the user if (!$domain->isPublic() && $user->id != $domain->walletOwner()->id) { return \trans('validation.entryexists', ['attribute' => 'domain']); } // Check if a user with specified address already exists if ($existing_user = User::emailExists($email, true)) { // Allow an alias in a custom domain to an address that was a user before if ($domain->isPublic() || !$existing_user->trashed()) { return \trans('validation.entryexists', ['attribute' => 'alias']); } } // Check if a group/resource/shared folder with specified address already exists if ( \App\Group::emailExists($email) || \App\Resource::emailExists($email) || \App\SharedFolder::emailExists($email) ) { return \trans('validation.entryexists', ['attribute' => 'alias']); } // Check if an alias with specified address already exists if (User::aliasExists($email) || \App\SharedFolder::aliasExists($email)) { // Allow assigning the same alias to a user in the same group account, // but only for non-public domains if ($domain->isPublic()) { return \trans('validation.entryexists', ['attribute' => 'alias']); } } return null; } /** * Activate password reset code (if set), and assign it to a user. * * @param \App\User $user The user */ protected function activatePassCode(User $user): void { // Activate the password reset code if ($this->passCode) { $this->passCode->user_id = $user->id; $this->passCode->active = true; $this->passCode->save(); } } } diff --git a/src/composer.json b/src/composer.json index 2f91f889..9c1fada2 100644 --- a/src/composer.json +++ b/src/composer.json @@ -1,86 +1,87 @@ { "name": "kolab/kolab4", "type": "project", "description": "Kolab 4", "keywords": [ "framework", "laravel" ], "license": "MIT", "repositories": [ { "type": "vcs", "url": "https://git.kolab.org/diffusion/PNL/php-net_ldap3.git" } ], "require": { "php": "^8.0", + "bacon/bacon-qr-code": "^2.0", "barryvdh/laravel-dompdf": "^1.0.0", "doctrine/dbal": "^3.3.2", "dyrynda/laravel-nullable-fields": "^4.2.0", "guzzlehttp/guzzle": "^7.4.1", "kolab/net_ldap3": "dev-master", "laravel/framework": "^9.2", "laravel/horizon": "^5.9", "laravel/octane": "^1.2", "laravel/passport": "^10.3", "laravel/tinker": "^2.7", "mlocati/spf-lib": "^3.1", "mollie/laravel-mollie": "^2.19", "moontoast/math": "^1.2", "pear/crypt_gpg": "^1.6.6", "predis/predis": "^1.1.10", "spatie/laravel-translatable": "^5.2", "spomky-labs/otphp": "~10.0.0", "stripe/stripe-php": "^7.29" }, "require-dev": { "code-lts/doctum": "^5.5.1", "laravel/dusk": "~6.22.0", "nunomaduro/larastan": "^2.0", "phpstan/phpstan": "^1.4", "phpunit/phpunit": "^9", "squizlabs/php_codesniffer": "^3.6" }, "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": "stable", "prefer-stable": true, "scripts": { "post-autoload-dump": [ "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", "@php artisan package:discover --ansi" ], "post-update-cmd": [ "@php artisan vendor:publish --tag=laravel-assets --ansi --force" ], "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 6dcaae0f..e515469b 100644 --- a/src/config/auth.php +++ b/src/config/auth.php @@ -1,123 +1,128 @@ [ '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' => 'eloquent', '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('PASSPORT_PROXY_OAUTH_CLIENT_ID'), 'client_secret' => env('PASSPORT_PROXY_OAUTH_CLIENT_SECRET'), ], + 'companion_app' => [ + 'client_id' => env('PASSPORT_COMPANIONAPP_OAUTH_CLIENT_ID'), + 'client_secret' => env('PASSPORT_COMPANIONAPP_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/database/seeds/local/OauthClientSeeder.php b/src/database/seeds/local/OauthClientSeeder.php index 6e9550c4..d51998de 100644 --- a/src/database/seeds/local/OauthClientSeeder.php +++ b/src/database/seeds/local/OauthClientSeeder.php @@ -1,34 +1,49 @@ forceFill([ 'user_id' => null, 'name' => "Kolab Password Grant Client", 'secret' => \config('auth.proxy.client_secret'), 'provider' => 'users', 'redirect' => 'https://' . \config('app.website_domain'), 'personal_access_client' => 0, 'password_client' => 1, 'revoked' => false, ]); $client->id = \config('auth.proxy.client_id'); $client->save(); + + $companionAppClient = Passport::client()->forceFill([ + 'user_id' => null, + 'name' => "CompanionApp Password Grant Client", + 'secret' => \config('auth.companion_app.client_secret'), + 'provider' => 'users', + 'redirect' => 'https://' . \config('app.website_domain'), + 'personal_access_client' => 0, + 'password_client' => 1, + 'revoked' => false, + ]); + + $companionAppClient->id = \config('auth.companion_app.client_id'); + + $companionAppClient->save(); } } diff --git a/src/database/seeds/local/UserSeeder.php b/src/database/seeds/local/UserSeeder.php index d6104adf..4503adbf 100644 --- a/src/database/seeds/local/UserSeeder.php +++ b/src/database/seeds/local/UserSeeder.php @@ -1,217 +1,216 @@ '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', ] ); $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', // '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']); // This only exists so the user create job doesn't fail because the domain is not found Domain::create( [ 'namespace' => 'jeroen.jeroen', 'status' => Domain::STATUS_NEW + Domain::STATUS_ACTIVE + Domain::STATUS_CONFIRMED + Domain::STATUS_VERIFIED, 'type' => Domain::TYPE_EXTERNAL ] ); $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/resources/js/fontawesome.js b/src/resources/js/fontawesome.js index 70a078b7..5e3bbf1b 100644 --- a/src/resources/js/fontawesome.js +++ b/src/resources/js/fontawesome.js @@ -1,76 +1,78 @@ import { library } from '@fortawesome/fontawesome-svg-core' import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' //import { } from '@fortawesome/free-brands-svg-icons' import { faCheckSquare, faClipboard, faCreditCard, faSquare, } from '@fortawesome/free-regular-svg-icons' import { faCheck, faCheckCircle, faCog, faComments, faDownload, faEnvelope, faFolderOpen, faGlobe, faUniversity, faExclamationCircle, faInfoCircle, - faLock, faKey, + faLock, + faMobile, faPlus, faSearch, faSignInAlt, faSlidersH, faSyncAlt, faTrashAlt, faUser, faUserCog, faUserFriends, faUsers, faWallet } from '@fortawesome/free-solid-svg-icons' import { faPaypal } from '@fortawesome/free-brands-svg-icons' // Register only these icons we need library.add( faCheck, faCheckCircle, faCheckSquare, faClipboard, faCog, faComments, faCreditCard, faPaypal, faUniversity, faDownload, faEnvelope, faExclamationCircle, faFolderOpen, faGlobe, faInfoCircle, - faLock, faKey, + faLock, + faMobile, faPlus, faSearch, faSignInAlt, faSlidersH, faSquare, faSyncAlt, faTrashAlt, faUser, faUserCog, faUserFriends, faUsers, faWallet ) export default FontAwesomeIcon diff --git a/src/resources/js/user/routes.js b/src/resources/js/user/routes.js index e47b8801..cf8613e0 100644 --- a/src/resources/js/user/routes.js +++ b/src/resources/js/user/routes.js @@ -1,165 +1,172 @@ import LoginComponent from '../../vue/Login' import LogoutComponent from '../../vue/Logout' import PageComponent from '../../vue/Page' import PasswordResetComponent from '../../vue/PasswordReset' import SignupComponent from '../../vue/Signup' // Here's a list of lazy-loaded components // Note: you can pack multiple components into the same chunk, webpackChunkName // is also used to get a sensible file name instead of numbers +const CompanionAppComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/CompanionApp') const DashboardComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Dashboard') const DistlistInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Distlist/Info') const DistlistListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Distlist/List') const DomainInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Domain/Info') const DomainListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Domain/List') const MeetComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Rooms') const ResourceInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Resource/Info') const ResourceListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Resource/List') const SettingsComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Settings') const SharedFolderInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/SharedFolder/Info') const SharedFolderListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/SharedFolder/List') const UserInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/User/Info') const UserListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/User/List') const UserProfileComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/User/Profile') const UserProfileDeleteComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/User/ProfileDelete') const WalletComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Wallet') const RoomComponent = () => import(/* webpackChunkName: "../user/meet" */ '../../vue/Meet/Room.vue') const routes = [ { path: '/dashboard', name: 'dashboard', component: DashboardComponent, meta: { requiresAuth: true } }, { path: '/distlist/:list', name: 'distlist', component: DistlistInfoComponent, meta: { requiresAuth: true, perm: 'distlists' } }, { path: '/distlists', name: 'distlists', component: DistlistListComponent, meta: { requiresAuth: true, perm: 'distlists' } }, + { + path: '/companion', + name: 'companion', + component: CompanionAppComponent, + meta: { requiresAuth: true, perm: 'companionapps' } + }, { path: '/domain/:domain', name: 'domain', component: DomainInfoComponent, meta: { requiresAuth: true, perm: 'domains' } }, { path: '/domains', name: 'domains', component: DomainListComponent, meta: { requiresAuth: true, perm: 'domains' } }, { path: '/login', name: 'login', component: LoginComponent }, { path: '/logout', name: 'logout', component: LogoutComponent }, { path: '/password-reset/:code?', name: 'password-reset', component: PasswordResetComponent }, { path: '/profile', name: 'profile', component: UserProfileComponent, meta: { requiresAuth: true } }, { path: '/profile/delete', name: 'profile-delete', component: UserProfileDeleteComponent, meta: { requiresAuth: true } }, { path: '/resource/:resource', name: 'resource', component: ResourceInfoComponent, meta: { requiresAuth: true, perm: 'resources' } }, { path: '/resources', name: 'resources', component: ResourceListComponent, meta: { requiresAuth: true, perm: 'resources' } }, { component: RoomComponent, name: 'room', path: '/meet/:room', meta: { loading: true } }, { path: '/rooms', name: 'rooms', component: MeetComponent, meta: { requiresAuth: true } }, { path: '/settings', name: 'settings', component: SettingsComponent, meta: { requiresAuth: true, perm: 'settings' } }, { path: '/shared-folder/:folder', name: 'shared-folder', component: SharedFolderInfoComponent, meta: { requiresAuth: true, perm: 'folders' } }, { path: '/shared-folders', name: 'shared-folders', component: SharedFolderListComponent, meta: { requiresAuth: true, perm: 'folders' } }, { path: '/signup/invite/:param', name: 'signup-invite', component: SignupComponent }, { path: '/signup/:param?', alias: '/signup/voucher/:param', name: 'signup', component: SignupComponent }, { path: '/user/:user', name: 'user', component: UserInfoComponent, meta: { requiresAuth: true, perm: 'users' } }, { path: '/users', name: 'users', component: UserListComponent, meta: { requiresAuth: true, perm: 'users' } }, { path: '/wallet', name: 'wallet', component: WalletComponent, meta: { requiresAuth: true, perm: 'wallets' } }, { name: '404', path: '*', component: PageComponent } ] export default routes diff --git a/src/resources/lang/en/app.php b/src/resources/lang/en/app.php index a2ce8f17..512daf2c 100644 --- a/src/resources/lang/en/app.php +++ b/src/resources/lang/en/app.php @@ -1,126 +1,128 @@ 'Created', 'chart-deleted' => 'Deleted', 'chart-average' => 'average', 'chart-allusers' => 'All Users - last year', 'chart-discounts' => 'Discounts', 'chart-vouchers' => 'Vouchers', 'chart-income' => 'Income in :currency - last 8 weeks', 'chart-users' => 'Users - last 8 weeks', + 'companion-deleteall-success' => 'All companion apps have been removed.', + 'mandate-delete-success' => 'The auto-payment has been removed.', 'mandate-update-success' => 'The auto-payment has been updated.', 'planbutton' => 'Choose :plan', 'process-async' => 'Setup process has been pushed. Please wait.', 'process-user-new' => 'Registering a user...', 'process-user-ldap-ready' => 'Creating a user...', 'process-user-imap-ready' => 'Creating a mailbox...', 'process-domain-new' => 'Registering a custom domain...', 'process-domain-ldap-ready' => 'Creating a custom domain...', 'process-domain-verified' => 'Verifying a custom domain...', 'process-domain-confirmed' => 'Verifying an ownership of a custom domain...', 'process-success' => 'Setup process finished successfully.', 'process-error-distlist-ldap-ready' => 'Failed to create a distribution list.', 'process-error-domain-ldap-ready' => 'Failed to create a domain.', 'process-error-domain-verified' => 'Failed to verify a domain.', 'process-error-domain-confirmed' => 'Failed to verify an ownership of a domain.', 'process-error-resource-imap-ready' => 'Failed to verify that a shared folder exists.', 'process-error-resource-ldap-ready' => 'Failed to create a resource.', 'process-error-shared-folder-imap-ready' => 'Failed to verify that a shared folder exists.', 'process-error-shared-folder-ldap-ready' => 'Failed to create a shared folder.', 'process-error-user-ldap-ready' => 'Failed to create a user.', 'process-error-user-imap-ready' => 'Failed to verify that a mailbox exists.', 'process-distlist-new' => 'Registering a distribution list...', 'process-distlist-ldap-ready' => 'Creating a distribution list...', 'process-resource-new' => 'Registering a resource...', 'process-resource-imap-ready' => 'Creating a shared folder...', 'process-resource-ldap-ready' => 'Creating a resource...', 'process-shared-folder-new' => 'Registering a shared folder...', 'process-shared-folder-imap-ready' => 'Creating a shared folder...', 'process-shared-folder-ldap-ready' => 'Creating a shared folder...', 'distlist-update-success' => 'Distribution list updated successfully.', 'distlist-create-success' => 'Distribution list created successfully.', 'distlist-delete-success' => 'Distribution list deleted successfully.', 'distlist-suspend-success' => 'Distribution list suspended successfully.', 'distlist-unsuspend-success' => 'Distribution list unsuspended successfully.', 'distlist-setconfig-success' => 'Distribution list settings updated successfully.', 'domain-create-success' => 'Domain created successfully.', 'domain-delete-success' => 'Domain deleted successfully.', 'domain-notempty-error' => 'Unable to delete a domain with assigned users or other objects.', 'domain-verify-success' => 'Domain verified successfully.', 'domain-verify-error' => 'Domain ownership verification failed.', 'domain-suspend-success' => 'Domain suspended successfully.', 'domain-unsuspend-success' => 'Domain unsuspended successfully.', 'domain-setconfig-success' => 'Domain settings updated successfully.', 'resource-update-success' => 'Resource updated successfully.', 'resource-create-success' => 'Resource created successfully.', 'resource-delete-success' => 'Resource deleted successfully.', 'resource-setconfig-success' => 'Resource settings updated successfully.', 'shared-folder-update-success' => 'Shared folder updated successfully.', 'shared-folder-create-success' => 'Shared folder created successfully.', 'shared-folder-delete-success' => 'Shared folder deleted successfully.', 'shared-folder-setconfig-success' => 'Shared folder settings updated successfully.', 'user-update-success' => 'User data updated successfully.', 'user-create-success' => 'User created successfully.', 'user-delete-success' => 'User deleted successfully.', 'user-suspend-success' => 'User suspended successfully.', 'user-unsuspend-success' => 'User unsuspended successfully.', 'user-reset-2fa-success' => '2-Factor authentication reset successfully.', 'user-setconfig-success' => 'User settings updated successfully.', 'user-set-sku-success' => 'The subscription added successfully.', 'user-set-sku-already-exists' => 'The subscription already exists.', 'search-foundxdomains' => ':x domains have been found.', 'search-foundxdistlists' => ':x distribution lists have been found.', 'search-foundxresources' => ':x resources have been found.', 'search-foundxsharedfolders' => ':x shared folders have been found.', 'search-foundxusers' => ':x user accounts have been found.', 'signup-invitations-created' => 'The invitation has been created.|:count invitations has been created.', 'signup-invitations-csv-empty' => 'Failed to find any valid email addresses in the uploaded file.', 'signup-invitations-csv-invalid-email' => 'Found an invalid email address (:email) on line :line.', 'signup-invitation-delete-success' => 'Invitation deleted successfully.', 'signup-invitation-resend-success' => 'Invitation added to the sending queue successfully.', 'support-request-success' => 'Support request submitted successfully.', 'support-request-error' => 'Failed to submit the support request.', 'siteuser' => ':site User', 'wallet-award-success' => 'The bonus has been added to the wallet successfully.', 'wallet-penalty-success' => 'The penalty has been added to the wallet successfully.', 'wallet-update-success' => 'User wallet updated successfully.', 'password-reset-code-delete-success' => 'Password reset code deleted successfully.', 'password-rule-min' => 'Minimum password length: :param characters', 'password-rule-max' => 'Maximum password length: :param characters', 'password-rule-lower' => 'Password contains a lower-case character', 'password-rule-upper' => 'Password contains an upper-case character', 'password-rule-digit' => 'Password contains a digit', 'password-rule-special' => 'Password contains a special character', 'password-rule-last' => 'Password cannot be the same as the last :param passwords', 'wallet-notice-date' => 'With your current subscriptions your account balance will last until about :date (:days).', 'wallet-notice-nocredit' => 'You are out of credit, top up your balance now.', 'wallet-notice-today' => 'You will run out of credit today, top up your balance now.', 'wallet-notice-trial' => 'You are in your free trial period.', 'wallet-notice-trial-end' => 'Your free trial is about to end, top up to continue.', ]; diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php index 1c49d027..7b67cb12 100644 --- a/src/resources/lang/en/ui.php +++ b/src/resources/lang/en/ui.php @@ -1,493 +1,510 @@ [ 'faq' => "FAQ", ], 'btn' => [ 'add' => "Add", 'accept' => "Accept", 'back' => "Back", 'cancel' => "Cancel", 'close' => "Close", 'continue' => "Continue", 'copy' => "Copy", '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", ], + 'companion' => [ + 'title' => "Companion App", + 'name' => "Name", + 'description' => "Use the Companion App on your mobile phone for advanced two factor authentication.", + 'pair-new' => "Pair new device", + 'paired' => "Paired devices", + 'pairing-instructions' => "Pair a new device using the following QR-Code:", + 'deviceid' => "Device ID", + 'nodevices' => "There are currently no devices", + 'delete' => "Remove devices", + 'remove-devices' => "Remove Devices", + 'remove-devices-text' => "Do you really want to remove all devices permanently?" + . " Please note that this action cannot be undone, and you can only remove all devices together." + . " You may pair devices you would like to keep individually again.", + ], + 'dashboard' => [ 'beta' => "beta", 'distlists' => "Distribution lists", 'chat' => "Video chat", + 'companion' => "Companion app", 'domains' => "Domains", 'invitations' => "Invitations", 'profile' => "Your profile", 'resources' => "Resources", 'settings' => "Settings", 'shared-folders' => "Shared folders", '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.", 'name' => "Name", 'new' => "New distribution list", 'recipients' => "Recipients", 'sender-policy' => "Sender Access List", 'sender-policy-text' => "With this list you can specify who can send mail to the distribution list." . " You can put a complete email address (jane@kolab.org), domain (kolab.org) or suffix (.org) that the sender email address is compared to." . " If the list is empty, mail from anyone is allowed.", ], 'domain' => [ 'delete' => "Delete domain", 'delete-domain' => "Delete {domain}", 'delete-text' => "Do you really want to delete this domain permanently?" . " This is only possible if there are no users, aliases or other objects in this domain." . " Please note that this action cannot be undone.", '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.", 'create' => "Create domain", 'new' => "New domain", ], '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' => "Form validation error", ], 'form' => [ 'acl' => "Access rights", 'acl-full' => "All", 'acl-read-only' => "Read-only", 'acl-read-write' => "Read-write", 'amount' => "Amount", 'anyone' => "Anyone", 'code' => "Confirmation Code", 'config' => "Configuration", 'date' => "Date", 'description' => "Description", 'details' => "Details", 'disabled' => "disabled", 'domain' => "Domain", 'email' => "Email Address", 'emails' => "Email Addresses", 'enabled' => "enabled", 'firstname' => "First Name", 'general' => "General", 'lastname' => "Last Name", 'name' => "Name", 'none' => "none", 'or' => "or", 'password' => "Password", 'password-confirm' => "Confirm Password", 'phone' => "Phone", 'settings' => "Settings", 'shared-folder' => "Shared Folder", 'status' => "Status", 'surname' => "Surname", 'type' => "Type", '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-title' => "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' => [ 'link-invalid' => "The password reset code is expired or invalid.", '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.", ], 'resource' => [ 'create' => "Create resource", 'delete' => "Delete resource", 'invitation-policy' => "Invitation policy", 'invitation-policy-text' => "Event invitations for a resource are normally accepted automatically" . " if there is no conflicting event on the requested time slot. Invitation policy allows" . " for rejecting such requests or to require a manual acceptance from a specified user.", 'ipolicy-manual' => "Manual (tentative)", 'ipolicy-accept' => "Accept", 'ipolicy-reject' => "Reject", 'list-title' => "Resource | Resources", 'list-empty' => "There are no resources in this account.", 'new' => "New resource", ], 'shf' => [ 'aliases-none' => "This shared folder has no email aliases.", 'create' => "Create folder", 'delete' => "Delete folder", 'acl-text' => "Defines user permissions to access the shared folder.", 'list-title' => "Shared folder | Shared folders", 'list-empty' => "There are no shared folders in this account.", 'new' => "New shared folder", 'type-mail' => "Mail", 'type-event' => "Calendar", 'type-contact' => "Address Book", 'type-task' => "Tasks", 'type-note' => "Notes", 'type-file' => "Files", ], '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-resource' => "We are preparing the resource.", 'prepare-shared-folder' => "We are preparing the shared folder.", '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-resource' => "The resource is almost ready.", 'ready-shared-folder' => "The shared-folder is almost ready.", 'ready-user' => "The user account is almost ready.", 'verify' => "Verify your domain to finish the setup process.", 'verify-domain' => "Verify domain", 'degraded' => "Degraded", '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.", 'add-beta' => "Enable beta program", '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.", 'degraded-warning' => "The account is degraded. Some features have been disabled.", 'degraded-hint' => "Please, make a payment.", '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", '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", 'pass-input' => "Enter password", 'pass-link' => "Set via link", 'pass-link-label' => "Link:", 'pass-link-hint' => "Press Submit to activate the link", 'passwordpolicy' => "Password Policy", '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", 'resources' => "Resources", 'title' => "User account", 'search' => "User email address or name", '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/vue/CompanionApp.vue b/src/resources/vue/CompanionApp.vue new file mode 100644 index 00000000..eb89bab5 --- /dev/null +++ b/src/resources/vue/CompanionApp.vue @@ -0,0 +1,78 @@ + + + diff --git a/src/resources/vue/Dashboard.vue b/src/resources/vue/Dashboard.vue index 0d60e8b8..860d3770 100644 --- a/src/resources/vue/Dashboard.vue +++ b/src/resources/vue/Dashboard.vue @@ -1,80 +1,84 @@ diff --git a/src/resources/vue/Widgets/CompanionappList.vue b/src/resources/vue/Widgets/CompanionappList.vue new file mode 100644 index 00000000..9aaeb047 --- /dev/null +++ b/src/resources/vue/Widgets/CompanionappList.vue @@ -0,0 +1,81 @@ + + + diff --git a/src/routes/api.php b/src/routes/api.php index c5c59b80..d7c51eed 100644 --- a/src/routes/api.php +++ b/src/routes/api.php @@ -1,258 +1,263 @@ 'api', 'prefix' => 'auth' ], function () { Route::post('login', [API\AuthController::class, 'login']); Route::group( ['middleware' => 'auth:api'], function () { Route::get('info', [API\AuthController::class, 'info']); Route::post('info', [API\AuthController::class, 'info']); Route::post('logout', [API\AuthController::class, 'logout']); Route::post('refresh', [API\AuthController::class, 'refresh']); } ); } ); Route::group( [ 'domain' => \config('app.website_domain'), 'middleware' => 'api', 'prefix' => 'auth' ], function () { Route::post('password-policy/check', [API\PasswordPolicyController::class, 'check']); Route::post('password-reset/init', [API\PasswordResetController::class, 'init']); Route::post('password-reset/verify', [API\PasswordResetController::class, 'verify']); Route::post('password-reset', [API\PasswordResetController::class, 'reset']); Route::post('signup/init', [API\SignupController::class, 'init']); Route::get('signup/invitations/{id}', [API\SignupController::class, 'invitation']); Route::get('signup/plans', [API\SignupController::class, 'plans']); Route::post('signup/verify', [API\SignupController::class, 'verify']); Route::post('signup', [API\SignupController::class, 'signup']); } ); Route::group( [ 'domain' => \config('app.website_domain'), 'middleware' => 'auth:api', 'prefix' => 'v4' ], function () { Route::post('companion/register', [API\V4\CompanionAppsController::class, 'register']); Route::post('auth-attempts/{id}/confirm', [API\V4\AuthAttemptsController::class, 'confirm']); Route::post('auth-attempts/{id}/deny', [API\V4\AuthAttemptsController::class, 'deny']); Route::get('auth-attempts/{id}/details', [API\V4\AuthAttemptsController::class, 'details']); Route::get('auth-attempts', [API\V4\AuthAttemptsController::class, 'index']); + Route::get('companion/pairing', [API\V4\CompanionAppsController::class, 'pairing']); + Route::apiResource('companion', API\V4\CompanionAppsController::class); + Route::post('companion/register', [API\V4\CompanionAppsController::class, 'register']); + Route::post('companion/revoke', [API\V4\CompanionAppsController::class, 'revokeAll']); + Route::apiResource('domains', API\V4\DomainsController::class); Route::get('domains/{id}/confirm', [API\V4\DomainsController::class, 'confirm']); Route::get('domains/{id}/skus', [API\V4\SkusController::class, 'domainSkus']); Route::get('domains/{id}/status', [API\V4\DomainsController::class, 'status']); Route::post('domains/{id}/config', [API\V4\DomainsController::class, 'setConfig']); Route::apiResource('groups', API\V4\GroupsController::class); Route::get('groups/{id}/status', [API\V4\GroupsController::class, 'status']); Route::post('groups/{id}/config', [API\V4\GroupsController::class, 'setConfig']); Route::apiResource('packages', API\V4\PackagesController::class); Route::apiResource('resources', API\V4\ResourcesController::class); Route::get('resources/{id}/status', [API\V4\ResourcesController::class, 'status']); Route::post('resources/{id}/config', [API\V4\ResourcesController::class, 'setConfig']); Route::apiResource('shared-folders', API\V4\SharedFoldersController::class); Route::get('shared-folders/{id}/status', [API\V4\SharedFoldersController::class, 'status']); Route::post('shared-folders/{id}/config', [API\V4\SharedFoldersController::class, 'setConfig']); Route::apiResource('skus', API\V4\SkusController::class); Route::apiResource('users', API\V4\UsersController::class); Route::post('users/{id}/config', [API\V4\UsersController::class, 'setConfig']); Route::get('users/{id}/skus', [API\V4\SkusController::class, 'userSkus']); Route::get('users/{id}/status', [API\V4\UsersController::class, 'status']); Route::apiResource('wallets', API\V4\WalletsController::class); Route::get('wallets/{id}/transactions', [API\V4\WalletsController::class, 'transactions']); Route::get('wallets/{id}/receipts', [API\V4\WalletsController::class, 'receipts']); Route::get('wallets/{id}/receipts/{receipt}', [API\V4\WalletsController::class, 'receiptDownload']); Route::get('password-policy', [API\PasswordPolicyController::class, 'index']); Route::post('password-reset/code', [API\PasswordResetController::class, 'codeCreate']); Route::delete('password-reset/code/{id}', [API\PasswordResetController::class, 'codeDelete']); Route::post('payments', [API\V4\PaymentsController::class, 'store']); //Route::delete('payments', [API\V4\PaymentsController::class, 'cancel']); Route::get('payments/mandate', [API\V4\PaymentsController::class, 'mandate']); Route::post('payments/mandate', [API\V4\PaymentsController::class, 'mandateCreate']); Route::put('payments/mandate', [API\V4\PaymentsController::class, 'mandateUpdate']); Route::delete('payments/mandate', [API\V4\PaymentsController::class, 'mandateDelete']); Route::get('payments/methods', [API\V4\PaymentsController::class, 'paymentMethods']); Route::get('payments/pending', [API\V4\PaymentsController::class, 'payments']); Route::get('payments/has-pending', [API\V4\PaymentsController::class, 'hasPayments']); Route::get('openvidu/rooms', [API\V4\OpenViduController::class, 'index']); Route::post('openvidu/rooms/{id}/close', [API\V4\OpenViduController::class, 'closeRoom']); Route::post('openvidu/rooms/{id}/config', [API\V4\OpenViduController::class, 'setRoomConfig']); Route::post('openvidu/rooms/{id}', [API\V4\OpenViduController::class, 'joinRoom']) ->withoutMiddleware(['auth:api']); Route::post('openvidu/rooms/{id}/connections', [API\V4\OpenViduController::class, 'createConnection']) ->withoutMiddleware(['auth:api']); // 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::class, 'dismissConnection']) ->withoutMiddleware(['auth:api']); Route::put('openvidu/rooms/{id}/connections/{conn}', [API\V4\OpenViduController::class, 'updateConnection']) ->withoutMiddleware(['auth:api']); Route::post('openvidu/rooms/{id}/request/{reqid}/accept', [API\V4\OpenViduController::class, 'acceptJoinRequest']) ->withoutMiddleware(['auth:api']); Route::post('openvidu/rooms/{id}/request/{reqid}/deny', [API\V4\OpenViduController::class, 'denyJoinRequest']) ->withoutMiddleware(['auth:api']); Route::post('support/request', [API\V4\SupportController::class, 'request']) ->withoutMiddleware(['auth:api']) ->middleware(['api']); } ); Route::group( [ 'domain' => \config('app.website_domain'), 'prefix' => 'webhooks' ], function () { Route::post('payment/{provider}', [API\V4\PaymentsController::class, 'webhook']); Route::post('meet/openvidu', [API\V4\OpenViduController::class, 'webhook']); } ); if (\config('app.with_services')) { Route::group( [ 'domain' => 'services.' . \config('app.website_domain'), 'prefix' => 'webhooks' ], function () { Route::get('nginx', [API\V4\NGINXController::class, 'authenticate']); Route::get('nginx-httpauth', [API\V4\NGINXController::class, 'httpauth']); Route::post('policy/greylist', [API\V4\PolicyController::class, 'greylist']); Route::post('policy/ratelimit', [API\V4\PolicyController::class, 'ratelimit']); Route::post('policy/spf', [API\V4\PolicyController::class, 'senderPolicyFramework']); } ); } if (\config('app.with_admin')) { Route::group( [ 'domain' => 'admin.' . \config('app.website_domain'), 'middleware' => ['auth:api', 'admin'], 'prefix' => 'v4', ], function () { Route::apiResource('domains', API\V4\Admin\DomainsController::class); Route::get('domains/{id}/skus', [API\V4\Admin\SkusController::class, 'domainSkus']); Route::post('domains/{id}/suspend', [API\V4\Admin\DomainsController::class, 'suspend']); Route::post('domains/{id}/unsuspend', [API\V4\Admin\DomainsController::class, 'unsuspend']); Route::apiResource('groups', API\V4\Admin\GroupsController::class); Route::post('groups/{id}/suspend', [API\V4\Admin\GroupsController::class, 'suspend']); Route::post('groups/{id}/unsuspend', [API\V4\Admin\GroupsController::class, 'unsuspend']); Route::apiResource('resources', API\V4\Admin\ResourcesController::class); Route::apiResource('shared-folders', API\V4\Admin\SharedFoldersController::class); 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::class, 'userDiscounts']); Route::post('users/{id}/reset2FA', [API\V4\Admin\UsersController::class, 'reset2FA']); Route::get('users/{id}/skus', [API\V4\Admin\SkusController::class, 'userSkus']); Route::post('users/{id}/skus/{sku}', [API\V4\Admin\UsersController::class, 'setSku']); Route::post('users/{id}/suspend', [API\V4\Admin\UsersController::class, 'suspend']); Route::post('users/{id}/unsuspend', [API\V4\Admin\UsersController::class, 'unsuspend']); Route::apiResource('wallets', API\V4\Admin\WalletsController::class); Route::post('wallets/{id}/one-off', [API\V4\Admin\WalletsController::class, 'oneOff']); Route::get('wallets/{id}/transactions', [API\V4\Admin\WalletsController::class, 'transactions']); Route::get('stats/chart/{chart}', [API\V4\Admin\StatsController::class, 'chart']); } ); } if (\config('app.with_reseller')) { Route::group( [ 'domain' => 'reseller.' . \config('app.website_domain'), 'middleware' => ['auth:api', 'reseller'], 'prefix' => 'v4', ], function () { Route::apiResource('domains', API\V4\Reseller\DomainsController::class); Route::get('domains/{id}/skus', [API\V4\Reseller\SkusController::class, 'domainSkus']); Route::post('domains/{id}/suspend', [API\V4\Reseller\DomainsController::class, 'suspend']); Route::post('domains/{id}/unsuspend', [API\V4\Reseller\DomainsController::class, 'unsuspend']); Route::apiResource('groups', API\V4\Reseller\GroupsController::class); Route::post('groups/{id}/suspend', [API\V4\Reseller\GroupsController::class, 'suspend']); Route::post('groups/{id}/unsuspend', [API\V4\Reseller\GroupsController::class, 'unsuspend']); Route::apiResource('invitations', API\V4\Reseller\InvitationsController::class); Route::post('invitations/{id}/resend', [API\V4\Reseller\InvitationsController::class, 'resend']); Route::post('payments', [API\V4\Reseller\PaymentsController::class, 'store']); Route::get('payments/mandate', [API\V4\Reseller\PaymentsController::class, 'mandate']); Route::post('payments/mandate', [API\V4\Reseller\PaymentsController::class, 'mandateCreate']); Route::put('payments/mandate', [API\V4\Reseller\PaymentsController::class, 'mandateUpdate']); Route::delete('payments/mandate', [API\V4\Reseller\PaymentsController::class, 'mandateDelete']); Route::get('payments/methods', [API\V4\Reseller\PaymentsController::class, 'paymentMethods']); Route::get('payments/pending', [API\V4\Reseller\PaymentsController::class, 'payments']); Route::get('payments/has-pending', [API\V4\Reseller\PaymentsController::class, 'hasPayments']); Route::apiResource('resources', API\V4\Reseller\ResourcesController::class); Route::apiResource('shared-folders', API\V4\Reseller\SharedFoldersController::class); 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::class, 'userDiscounts']); Route::post('users/{id}/reset2FA', [API\V4\Reseller\UsersController::class, 'reset2FA']); Route::get('users/{id}/skus', [API\V4\Reseller\SkusController::class, 'userSkus']); Route::post('users/{id}/skus/{sku}', [API\V4\Admin\UsersController::class, 'setSku']); Route::post('users/{id}/suspend', [API\V4\Reseller\UsersController::class, 'suspend']); Route::post('users/{id}/unsuspend', [API\V4\Reseller\UsersController::class, 'unsuspend']); Route::apiResource('wallets', API\V4\Reseller\WalletsController::class); Route::post('wallets/{id}/one-off', [API\V4\Reseller\WalletsController::class, 'oneOff']); Route::get('wallets/{id}/receipts', [API\V4\Reseller\WalletsController::class, 'receipts']); Route::get('wallets/{id}/receipts/{receipt}', [API\V4\Reseller\WalletsController::class, 'receiptDownload']); Route::get('wallets/{id}/transactions', [API\V4\Reseller\WalletsController::class, 'transactions']); Route::get('stats/chart/{chart}', [API\V4\Reseller\StatsController::class, 'chart']); } ); } diff --git a/src/tests/Feature/Controller/CompanionAppsTest.php b/src/tests/Feature/Controller/CompanionAppsTest.php index 08eb45be..84a75c3c 100644 --- a/src/tests/Feature/Controller/CompanionAppsTest.php +++ b/src/tests/Feature/Controller/CompanionAppsTest.php @@ -1,82 +1,200 @@ deleteTestUser('UsersControllerTest1@userscontroller.com'); - $this->deleteTestDomain('userscontroller.com'); + $this->deleteTestUser('CompanionAppsTest1@userscontroller.com'); + $this->deleteTestUser('CompanionAppsTest2@userscontroller.com'); + $this->deleteTestCompanionApp('testdevice'); } /** * {@inheritDoc} */ public function tearDown(): void { - $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); - $this->deleteTestDomain('userscontroller.com'); + $this->deleteTestUser('CompanionAppsTest1@userscontroller.com'); + $this->deleteTestUser('CompanionAppsTest2@userscontroller.com'); + $this->deleteTestCompanionApp('testdevice'); parent::tearDown(); } /** * Test registering the app */ public function testRegister(): void { $user = $this->getTestUser('CompanionAppsTest1@userscontroller.com'); $notificationToken = "notificationToken"; $deviceId = "deviceId"; + $name = "testname"; $response = $this->actingAs($user)->post( "api/v4/companion/register", - ['notificationToken' => $notificationToken, 'deviceId' => $deviceId] + ['notificationToken' => $notificationToken, 'deviceId' => $deviceId, 'name' => $name] ); $response->assertStatus(200); $companionApp = \App\CompanionApp::where('device_id', $deviceId)->first(); $this->assertTrue($companionApp != null); $this->assertEquals($deviceId, $companionApp->device_id); + $this->assertEquals($name, $companionApp->name); $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] + ['notificationToken' => $notificationToken, 'deviceId' => $deviceId, 'name' => $name] ); $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] + ['notificationToken' => $notificationToken, 'deviceId' => $deviceId, 'name' => $name] ); $response->assertStatus(403); } + + public function testIndex(): void + { + $response = $this->get("api/v4/companion"); + $response->assertStatus(401); + + $user = $this->getTestUser('CompanionAppsTest1@userscontroller.com'); + + $companionApp = $this->getTestCompanionApp( + 'testdevice', + $user, + [ + 'notification_token' => 'notificationtoken', + 'mfa_enabled' => 1, + 'name' => 'testname', + ] + ); + + $response = $this->actingAs($user)->get("api/v4/companion"); + $response->assertStatus(200); + + $json = $response->json(); + $this->assertSame(1, $json['count']); + $this->assertCount(1, $json['list']); + $this->assertSame($user->id, $json['list'][0]['user_id']); + $this->assertSame($companionApp['device_id'], $json['list'][0]['device_id']); + $this->assertSame($companionApp['name'], $json['list'][0]['name']); + $this->assertSame($companionApp['notification_token'], $json['list'][0]['notification_token']); + $this->assertSame($companionApp['mfa_enabled'], $json['list'][0]['mfa_enabled']); + + $user2 = $this->getTestUser('CompanionAppsTest2@userscontroller.com'); + $response = $this->actingAs($user2)->get( + "api/v4/companion" + ); + $response->assertStatus(200); + + $json = $response->json(); + $this->assertSame(0, $json['count']); + $this->assertCount(0, $json['list']); + } + + public function testShow(): void + { + $user = $this->getTestUser('CompanionAppsTest1@userscontroller.com'); + $companionApp = $this->getTestCompanionApp('testdevice', $user); + + $response = $this->get("api/v4/companion/{$companionApp->id}"); + $response->assertStatus(401); + + $response = $this->actingAs($user)->get("api/v4/companion/aaa"); + $response->assertStatus(404); + + $response = $this->actingAs($user)->get("api/v4/companion/{$companionApp->id}"); + $response->assertStatus(200); + + $json = $response->json(); + $this->assertSame($companionApp->id, $json['id']); + + $user2 = $this->getTestUser('CompanionAppsTest2@userscontroller.com'); + $response = $this->actingAs($user2)->get("api/v4/companion/{$companionApp->id}"); + $response->assertStatus(403); + } + + public function testPairing(): void + { + $response = $this->get("api/v4/companion/pairing"); + $response->assertStatus(401); + + $user = $this->getTestUser('CompanionAppsTest1@userscontroller.com'); + $response = $this->actingAs($user)->get("api/v4/companion/pairing"); + $response->assertStatus(200); + + $json = $response->json(); + $this->assertArrayHasKey('qrcode', $json); + $this->assertSame('data:image/svg+xml;base64,', substr($json['qrcode'], 0, 26)); + } + + public function testRevoke(): void + { + $user = $this->getTestUser('CompanionAppsTest1@userscontroller.com'); + $companionApp = $this->getTestCompanionApp('testdevice', $user); + $clientIdentifier = \App\Tenant::getConfig($user->tenant_id, 'auth.companion_app.client_id'); + + $tokenRepository = app(TokenRepository::class); + $tokenRepository->create([ + 'id' => 'testtoken', + 'revoked' => false, + 'user_id' => $user->id, + 'client_id' => $clientIdentifier + ]); + + //Make sure we have a token to revoke + $tokenCount = Token::where('user_id', $user->id)->where('client_id', $clientIdentifier)->count(); + $this->assertTrue($tokenCount > 0); + + $response = $this->post("api/v4/companion/revoke"); + $response->assertStatus(401); + + $response = $this->actingAs($user)->post("api/v4/companion/revoke"); + $response->assertStatus(200); + $json = $response->json(); + $this->assertSame('success', $json['status']); + $this->assertArrayHasKey('message', $json); + + $companionApp = \App\CompanionApp::where('device_id', 'testdevice')->first(); + $this->assertTrue($companionApp == null); + + $tokenCount = Token::where('user_id', $user->id) + ->where('client_id', $clientIdentifier) + ->where('revoked', false)->count(); + $this->assertSame(0, $tokenCount); + } } diff --git a/src/tests/Feature/Controller/NGINXTest.php b/src/tests/Feature/Controller/NGINXTest.php index 1dd6773a..946aa140 100644 --- a/src/tests/Feature/Controller/NGINXTest.php +++ b/src/tests/Feature/Controller/NGINXTest.php @@ -1,246 +1,263 @@ 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(); } 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->get("api/webhooks/nginx"); $response->assertStatus(200); $response->assertHeader('auth-status', 'authentication failure'); $pass = \App\Utils::generatePassphrase(); $headers = [ 'Auth-Login-Attempt' => '1', 'Auth-Method' => 'plain', '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->withHeaders($headers)->get("api/webhooks/nginx"); $response->assertStatus(200); $response->assertHeader('auth-status', 'OK'); $response->assertHeader('auth-port', '12143'); // Invalid Password $modifiedHeaders = $headers; $modifiedHeaders['Auth-Pass'] = "Invalid"; $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', '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->withHeaders($headers)->get("api/webhooks/nginx"); $response->assertStatus(200); $response->assertHeader('auth-status', 'OK'); $response->assertHeader('auth-server', '127.0.0.1'); $response->assertHeader('auth-port', '9143'); - // 2-FA without device - $john->setSettings( + + $companionApp = $this->getTestCompanionApp( + 'testdevice', + $john, [ - '2fa_enabled' => true, + 'notification_token' => 'notificationtoken', + 'mfa_enabled' => 1, + 'name' => 'testname', ] ); - \App\CompanionApp::where('user_id', $john->id)->delete(); - - $response = $this->withHeaders($headers)->get("api/webhooks/nginx"); - $response->assertStatus(200); - $response->assertHeader('auth-status', 'authentication failure'); // 2-FA with accepted auth attempt $authAttempt = \App\AuthAttempt::recordAuthAttempt($john, "127.0.0.1"); $authAttempt->accept(); $response = $this->withHeaders($headers)->get("api/webhooks/nginx"); $response->assertStatus(200); $response->assertHeader('auth-status', 'OK'); + + // Deny + $authAttempt->deny(); + $response = $this->withHeaders($headers)->get("api/webhooks/nginx"); + $response->assertStatus(200); + $response->assertHeader('auth-status', 'authentication failure'); + + // 2-FA without device + $companionApp->delete(); + $response = $this->withHeaders($headers)->get("api/webhooks/nginx"); + $response->assertStatus(200); + $response->assertHeader('auth-status', 'OK'); } /** * Test the httpauth webhook */ public function testNGINXHttpAuthHook(): void { $john = $this->getTestUser('john@kolab.org'); $response = $this->get("api/webhooks/nginx-httpauth"); $response->assertStatus(401); $pass = \App\Utils::generatePassphrase(); $headers = [ 'Php-Auth-Pw' => $pass, 'Php-Auth-User' => 'john@kolab.org', 'X-Forwarded-For' => '127.0.0.1', 'X-Forwarded-Proto' => 'https', 'X-Original-Uri' => '/iRony/', 'X-Real-Ip' => '127.0.0.1', ]; // Pass $response = $this->withHeaders($headers)->get("api/webhooks/nginx-httpauth"); $response->assertStatus(200); // domain.tld\username $modifiedHeaders = $headers; $modifiedHeaders['Php-Auth-User'] = "kolab.org\\john"; $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx-httpauth"); $response->assertStatus(200); // Invalid Password $modifiedHeaders = $headers; $modifiedHeaders['Php-Auth-Pw'] = "Invalid"; $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx-httpauth"); $response->assertStatus(403); // Empty Password $modifiedHeaders = $headers; $modifiedHeaders['Php-Auth-Pw'] = ""; $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx-httpauth"); $response->assertStatus(401); // Empty User $modifiedHeaders = $headers; $modifiedHeaders['Php-Auth-User'] = ""; $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx-httpauth"); $response->assertStatus(403); // Invalid User $modifiedHeaders = $headers; $modifiedHeaders['Php-Auth-User'] = "foo@kolab.org"; $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx-httpauth"); $response->assertStatus(403); // Empty Ip $modifiedHeaders = $headers; $modifiedHeaders['X-Real-Ip'] = ""; $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx-httpauth"); $response->assertStatus(403); - - // 2-FA without device - $john->setSettings( + $companionApp = $this->getTestCompanionApp( + 'testdevice', + $john, [ - '2fa_enabled' => true, + 'notification_token' => 'notificationtoken', + 'mfa_enabled' => 1, + 'name' => 'testname', ] ); - \App\CompanionApp::where('user_id', $john->id)->delete(); - - $response = $this->withHeaders($headers)->get("api/webhooks/nginx-httpauth"); - $response->assertStatus(403); // 2-FA with accepted auth attempt $authAttempt = \App\AuthAttempt::recordAuthAttempt($john, "127.0.0.1"); $authAttempt->accept(); $response = $this->withHeaders($headers)->get("api/webhooks/nginx-httpauth"); $response->assertStatus(200); + + // Deny + $authAttempt->deny(); + $response = $this->withHeaders($headers)->get("api/webhooks/nginx-httpauth"); + $response->assertStatus(403); + + // 2-FA without device + $companionApp->delete(); + $response = $this->withHeaders($headers)->get("api/webhooks/nginx-httpauth"); + $response->assertStatus(200); } } diff --git a/src/tests/TestCaseTrait.php b/src/tests/TestCaseTrait.php index 71fbb2d2..0afd21fa 100644 --- a/src/tests/TestCaseTrait.php +++ b/src/tests/TestCaseTrait.php @@ -1,629 +1,670 @@ 'John', 'last_name' => 'Doe', 'organization' => 'Test Domain Owner', ]; /** * Some users for the hosted domain, ultimately including the owner. * * @var \App\User[] */ protected $domainUsers = []; /** * A specific user that is a regular user in the hosted domain. * * @var ?\App\User */ protected $jack; /** * A specific user that is a controller on the wallet to which the hosted domain is charged. * * @var ?\App\User */ protected $jane; /** * A specific user that has a second factor configured. * * @var ?\App\User */ protected $joe; /** * One of the domains that is available for public registration. * * @var ?\App\Domain */ protected $publicDomain; /** * A newly generated user in a public domain. * * @var ?\App\User */ protected $publicDomainUser; /** * A placeholder for a password that can be generated. * * Should be generated with `\App\Utils::generatePassphrase()`. * * @var ?string */ protected $userPassword; /** * Register the beta entitlement for a user */ protected function addBetaEntitlement($user, $titles = []): void { // Add beta + $title entitlements $beta_sku = Sku::withEnvTenantContext()->where('title', 'beta')->first(); $user->assignSku($beta_sku); if (!empty($titles)) { Sku::withEnvTenantContext()->whereIn('title', (array) $titles)->get() ->each(function ($sku) use ($user) { $user->assignSku($sku); }); } } /** * Assert that the entitlements for the user match the expected list of entitlements. * * @param \App\User|\App\Domain $object The object for which the entitlements need to be pulled. * @param array $expected An array of expected \App\Sku titles. */ protected function assertEntitlements($object, $expected) { // Assert the user entitlements $skus = $object->entitlements()->get() ->map(function ($ent) { return $ent->sku->title; }) ->toArray(); sort($skus); Assert::assertSame($expected, $skus); } protected function backdateEntitlements($entitlements, $targetDate, $targetCreatedDate = null) { $wallets = []; $ids = []; foreach ($entitlements as $entitlement) { $ids[] = $entitlement->id; $wallets[] = $entitlement->wallet_id; } \App\Entitlement::whereIn('id', $ids)->update([ 'created_at' => $targetCreatedDate ?: $targetDate, 'updated_at' => $targetDate, ]); if (!empty($wallets)) { $wallets = array_unique($wallets); $owners = \App\Wallet::whereIn('id', $wallets)->pluck('user_id')->all(); \App\User::whereIn('id', $owners)->update([ 'created_at' => $targetCreatedDate ?: $targetDate ]); } } /** * Removes all beta entitlements from the database */ protected function clearBetaEntitlements(): void { $beta_handlers = [ 'App\Handlers\Beta', 'App\Handlers\Beta\Distlists', 'App\Handlers\Beta\Resources', 'App\Handlers\Beta\SharedFolders', ]; $betas = Sku::whereIn('handler_class', $beta_handlers)->pluck('id')->all(); \App\Entitlement::whereIn('sku_id', $betas)->delete(); } /** * Creates the application. * * @return \Illuminate\Foundation\Application */ public function createApplication() { $app = require __DIR__ . '/../bootstrap/app.php'; $app->make(Kernel::class)->bootstrap(); return $app; } /** * Create a set of transaction log entries for a wallet */ protected function createTestTransactions($wallet) { $result = []; $date = Carbon::now(); $debit = 0; $entitlementTransactions = []; foreach ($wallet->entitlements as $entitlement) { if ($entitlement->cost) { $debit += $entitlement->cost; $entitlementTransactions[] = $entitlement->createTransaction( Transaction::ENTITLEMENT_BILLED, $entitlement->cost ); } } $transaction = Transaction::create( [ 'user_email' => 'jeroen@jeroen.jeroen', 'object_id' => $wallet->id, 'object_type' => \App\Wallet::class, 'type' => Transaction::WALLET_DEBIT, 'amount' => $debit * -1, 'description' => 'Payment', ] ); $result[] = $transaction; Transaction::whereIn('id', $entitlementTransactions)->update(['transaction_id' => $transaction->id]); $transaction = Transaction::create( [ 'user_email' => null, 'object_id' => $wallet->id, 'object_type' => \App\Wallet::class, 'type' => Transaction::WALLET_CREDIT, 'amount' => 2000, 'description' => 'Payment', ] ); $transaction->created_at = $date->next(Carbon::MONDAY); $transaction->save(); $result[] = $transaction; $types = [ Transaction::WALLET_AWARD, Transaction::WALLET_PENALTY, ]; // The page size is 10, so we generate so many to have at least two pages $loops = 10; while ($loops-- > 0) { $type = $types[count($result) % count($types)]; $transaction = Transaction::create([ 'user_email' => 'jeroen.@jeroen.jeroen', 'object_id' => $wallet->id, 'object_type' => \App\Wallet::class, 'type' => $type, 'amount' => 11 * (count($result) + 1) * ($type == Transaction::WALLET_PENALTY ? -1 : 1), 'description' => 'TRANS' . $loops, ]); $transaction->created_at = $date->next(Carbon::MONDAY); $transaction->save(); $result[] = $transaction; } return $result; } /** * Delete a test domain whatever it takes. * * @coversNothing */ protected function deleteTestDomain($name) { Queue::fake(); $domain = Domain::withTrashed()->where('namespace', $name)->first(); if (!$domain) { return; } $job = new \App\Jobs\Domain\DeleteJob($domain->id); $job->handle(); $domain->forceDelete(); } /** * Delete a test group whatever it takes. * * @coversNothing */ protected function deleteTestGroup($email) { Queue::fake(); $group = Group::withTrashed()->where('email', $email)->first(); if (!$group) { return; } LDAP::deleteGroup($group); $group->forceDelete(); } /** * Delete a test resource whatever it takes. * * @coversNothing */ protected function deleteTestResource($email) { Queue::fake(); $resource = Resource::withTrashed()->where('email', $email)->first(); if (!$resource) { return; } LDAP::deleteResource($resource); $resource->forceDelete(); } /** * Delete a test shared folder whatever it takes. * * @coversNothing */ protected function deleteTestSharedFolder($email) { Queue::fake(); $folder = SharedFolder::withTrashed()->where('email', $email)->first(); if (!$folder) { return; } LDAP::deleteSharedFolder($folder); $folder->forceDelete(); } /** * Delete a test user whatever it takes. * * @coversNothing */ protected function deleteTestUser($email) { Queue::fake(); $user = User::withTrashed()->where('email', $email)->first(); if (!$user) { return; } LDAP::deleteUser($user); $user->forceDelete(); } + /** + * Delete a test companion app whatever it takes. + * + * @coversNothing + */ + protected function deleteTestCompanionApp($deviceId) + { + Queue::fake(); + + $companionApp = CompanionApp::where('device_id', $deviceId)->first(); + + if (!$companionApp) { + return; + } + + $companionApp->forceDelete(); + } + /** * Helper to access protected property of an object */ protected static function getObjectProperty($object, $property_name) { $reflection = new \ReflectionClass($object); $property = $reflection->getProperty($property_name); $property->setAccessible(true); return $property->getValue($object); } /** * Get Domain object by namespace, create it if needed. * Skip LDAP jobs. * * @coversNothing */ protected function getTestDomain($name, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); return Domain::firstOrCreate(['namespace' => $name], $attrib); } /** * Get Group object by email, create it if needed. * Skip LDAP jobs. */ protected function getTestGroup($email, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); return Group::firstOrCreate(['email' => $email], $attrib); } /** * Get Resource object by email, create it if needed. * Skip LDAP jobs. */ protected function getTestResource($email, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); $resource = Resource::where('email', $email)->first(); if (!$resource) { list($local, $domain) = explode('@', $email, 2); $resource = new Resource(); $resource->email = $email; $resource->domainName = $domain; if (!isset($attrib['name'])) { $resource->name = $local; } } foreach ($attrib as $key => $val) { $resource->{$key} = $val; } $resource->save(); return $resource; } /** * Get SharedFolder object by email, create it if needed. * Skip LDAP jobs. */ protected function getTestSharedFolder($email, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); $folder = SharedFolder::where('email', $email)->first(); if (!$folder) { list($local, $domain) = explode('@', $email, 2); $folder = new SharedFolder(); $folder->email = $email; $folder->domainName = $domain; if (!isset($attrib['name'])) { $folder->name = $local; } } foreach ($attrib as $key => $val) { $folder->{$key} = $val; } $folder->save(); return $folder; } /** * Get User object by email, create it if needed. * Skip LDAP jobs. * * @coversNothing */ protected function getTestUser($email, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); $user = User::firstOrCreate(['email' => $email], $attrib); if ($user->trashed()) { // Note: we do not want to use user restore here User::where('id', $user->id)->forceDelete(); $user = User::create(['email' => $email] + $attrib); } return $user; } + /** + * Get CompanionApp object by deviceId, create it if needed. + * Skip LDAP jobs. + * + * @coversNothing + */ + protected function getTestCompanionApp($deviceId, $user, $attrib = []) + { + // Disable jobs (i.e. skip LDAP oprations) + Queue::fake(); + $companionApp = CompanionApp::firstOrCreate( + [ + 'device_id' => $deviceId, + 'user_id' => $user->id, + 'notification_token' => '', + 'mfa_enabled' => 1 + ], + $attrib + ); + return $companionApp; + } + /** * Call protected/private method of a class. * * @param object $object Instantiated object that we will run method on. * @param string $methodName Method name to call * @param array $parameters Array of parameters to pass into method. * * @return mixed Method return. */ protected function invokeMethod($object, $methodName, array $parameters = []) { $reflection = new \ReflectionClass(get_class($object)); $method = $reflection->getMethod($methodName); $method->setAccessible(true); return $method->invokeArgs($object, $parameters); } /** * Extract content of an email message. * * @param \Illuminate\Mail\Mailable $mail Mailable object * * @return array Parsed message data: * - 'plain': Plain text body * - 'html: HTML body * - 'subject': Mail subject */ protected function renderMail(\Illuminate\Mail\Mailable $mail): array { $mail->build(); // @phpstan-ignore-line $result = $this->invokeMethod($mail, 'renderForAssertions'); return [ 'plain' => $result[1], 'html' => $result[0], 'subject' => $mail->subject, ]; } protected function setUpTest() { $this->userPassword = \App\Utils::generatePassphrase(); $this->domainHosted = $this->getTestDomain( 'test.domain', [ 'type' => \App\Domain::TYPE_EXTERNAL, 'status' => \App\Domain::STATUS_ACTIVE | \App\Domain::STATUS_CONFIRMED | \App\Domain::STATUS_VERIFIED ] ); $this->getTestDomain( 'test2.domain2', [ 'type' => \App\Domain::TYPE_EXTERNAL, 'status' => \App\Domain::STATUS_ACTIVE | \App\Domain::STATUS_CONFIRMED | \App\Domain::STATUS_VERIFIED ] ); $packageKolab = \App\Package::where('title', 'kolab')->first(); $this->domainOwner = $this->getTestUser('john@test.domain', ['password' => $this->userPassword]); $this->domainOwner->assignPackage($packageKolab); $this->domainOwner->setSettings($this->domainOwnerSettings); $this->domainOwner->setAliases(['alias1@test2.domain2']); // separate for regular user $this->jack = $this->getTestUser('jack@test.domain', ['password' => $this->userPassword]); // separate for wallet controller $this->jane = $this->getTestUser('jane@test.domain', ['password' => $this->userPassword]); $this->joe = $this->getTestUser('joe@test.domain', ['password' => $this->userPassword]); $this->domainUsers[] = $this->jack; $this->domainUsers[] = $this->jane; $this->domainUsers[] = $this->joe; $this->domainUsers[] = $this->getTestUser('jill@test.domain', ['password' => $this->userPassword]); foreach ($this->domainUsers as $user) { $this->domainOwner->assignPackage($packageKolab, $user); } $this->domainUsers[] = $this->domainOwner; // assign second factor to joe $this->joe->assignSku(Sku::where('title', '2fa')->first()); \App\Auth\SecondFactor::seed($this->joe->email); usort( $this->domainUsers, function ($a, $b) { return $a->email > $b->email; } ); $this->domainHosted->assignPackage( \App\Package::where('title', 'domain-hosting')->first(), $this->domainOwner ); $wallet = $this->domainOwner->wallets()->first(); $wallet->addController($this->jane); $this->publicDomain = \App\Domain::where('type', \App\Domain::TYPE_PUBLIC)->first(); $this->publicDomainUser = $this->getTestUser( 'john@' . $this->publicDomain->namespace, ['password' => $this->userPassword] ); $this->publicDomainUser->assignPackage($packageKolab); } public function tearDown(): void { foreach ($this->domainUsers as $user) { if ($user == $this->domainOwner) { continue; } $this->deleteTestUser($user->email); } if ($this->domainOwner) { $this->deleteTestUser($this->domainOwner->email); } if ($this->domainHosted) { $this->deleteTestDomain($this->domainHosted->namespace); } if ($this->publicDomainUser) { $this->deleteTestUser($this->publicDomainUser->email); } parent::tearDown(); } }