diff --git a/src/app/Http/Controllers/API/V4/DomainsController.php b/src/app/Http/Controllers/API/V4/DomainsController.php --- a/src/app/Http/Controllers/API/V4/DomainsController.php +++ b/src/app/Http/Controllers/API/V4/DomainsController.php @@ -4,6 +4,8 @@ use App\Domain; use App\Http\Controllers\Controller; +use App\Backends\LDAP; +use Carbon\Carbon; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -57,14 +59,16 @@ } if (!$domain->confirm()) { - // TODO: This should include an error message to display to the user - return response()->json(['status' => 'error']); + return response()->json([ + 'status' => 'error', + 'message' => \trans('app.domain-verify-error'), + ]); } return response()->json([ 'status' => 'success', 'statusInfo' => self::statusInfo($domain), - 'message' => __('app.domain-verify-success'), + 'message' => \trans('app.domain-verify-success'), ]); } @@ -140,6 +144,56 @@ } /** + * Fetch domain status (and reload setup process) + * + * @param int $id Domain identifier + * + * @return \Illuminate\Http\JsonResponse + */ + public function status($id) + { + $domain = Domain::find($id); + + // Only owner (or admin) has access to the domain + if (!Auth::guard()->user()->canRead($domain)) { + return $this->errorResponse(403); + } + + $response = self::statusInfo($domain); + + if (!empty(request()->input('refresh'))) { + $updated = false; + $last_step = 'none'; + + foreach ($response['process'] as $idx => $step) { + $last_step = $step['label']; + + if (!$step['state']) { + if (!$this->execProcessStep($domain, $step['label'])) { + break; + } + + $updated = true; + } + } + + if ($updated) { + $response = self::statusInfo($domain); + } + + $success = $response['isReady']; + $suffix = $success ? 'success' : 'error-' . $last_step; + + $response['status'] = $success ? 'success' : 'error'; + $response['message'] = \trans('app.process-' . $suffix); + } + + $response = array_merge($response, self::domainStatuses($domain)); + + return response()->json($response); + } + + /** * Update the specified resource in storage. * * @param \Illuminate\Http\Request $request @@ -272,9 +326,56 @@ } } + $state = $count === 0 ? 'done' : 'running'; + + // After 180 seconds assume the process is in failed state, + // this should unlock the Refresh button in the UI + if ($count > 0 && $domain->created_at->diffInSeconds(Carbon::now()) > 180) { + $state = 'failed'; + } + return [ 'process' => $process, + 'processState' => $state, 'isReady' => $count === 0, ]; } + + /** + * Execute (synchronously) specified step in a domain setup process. + * + * @param \App\Domain $domain Domain object + * @param string $step Step identifier (as in self::statusInfo()) + * + * @return bool True if the execution succeeded, False otherwise + */ + public static function execProcessStep(Domain $domain, string $step): bool + { + try { + switch ($step) { + case 'domain-ldap-ready': + // Domain not in LDAP, create it + if (!$domain->isLdapReady()) { + LDAP::createDomain($domain); + $domain->status |= Domain::STATUS_LDAP_READY; + $domain->save(); + } + return $domain->isLdapReady(); + + case 'domain-verified': + // Domain existence not verified + $domain->verify(); + return $domain->isVerified(); + + case 'domain-confirmed': + // Domain ownership confirmation + $domain->confirm(); + return $domain->isConfirmed(); + } + } catch (\Exception $e) { + \Log::error($e); + } + + return false; + } } diff --git a/src/app/Http/Controllers/API/V4/UsersController.php b/src/app/Http/Controllers/API/V4/UsersController.php --- a/src/app/Http/Controllers/API/V4/UsersController.php +++ b/src/app/Http/Controllers/API/V4/UsersController.php @@ -8,6 +8,7 @@ use App\Rules\UserEmailLocal; use App\Sku; use App\User; +use Carbon\Carbon; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; @@ -113,6 +114,59 @@ } /** + * Fetch user status (and reload setup process) + * + * @param int $id User identifier + * + * @return \Illuminate\Http\JsonResponse + */ + public function status($id) + { + $user = User::find($id); + + if (empty($user)) { + return $this->errorResponse(404); + } + + if (!$this->guard()->user()->canRead($user)) { + return $this->errorResponse(403); + } + + $response = self::statusInfo($user); + + if (!empty(request()->input('refresh'))) { + $updated = false; + $last_step = 'none'; + + foreach ($response['process'] as $idx => $step) { + $last_step = $step['label']; + + if (!$step['state']) { + if (!$this->execProcessStep($user, $step['label'])) { + break; + } + + $updated = true; + } + } + + if ($updated) { + $response = self::statusInfo($user); + } + + $success = $response['isReady']; + $suffix = $success ? 'success' : 'error-' . $last_step; + + $response['status'] = $success ? 'success' : 'error'; + $response['message'] = \trans('app.process-' . $suffix); + } + + $response = array_merge($response, self::userStatuses($user)); + + return response()->json($response); + } + + /** * User status (extended) information * * @param \App\User $user User object @@ -139,7 +193,6 @@ $process[] = $step; } - list ($local, $domain) = explode('@', $user->email); $domain = Domain::where('namespace', $domain)->first(); @@ -154,8 +207,17 @@ return $v['state']; })); + $state = $all === $checked ? 'done' : 'running'; + + // After 180 seconds assume the process is in failed state, + // this should unlock the Refresh button in the UI + if ($all !== $checked && $user->created_at->diffInSeconds(Carbon::now()) > 180) { + $state = 'failed'; + } + return [ 'process' => $process, + 'processState' => $state, 'isReady' => $all === $checked, ]; } @@ -494,4 +556,42 @@ $settings = $request->only(array_keys($rules)); unset($settings['password'], $settings['aliases'], $settings['email']); } + + /** + * 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 True if the execution succeeded, False otherwise + */ + 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\UserCreate($user); + $job->handle(); + return $user->isLdapReady(); + + case 'user-imap-ready': + // User not in IMAP? Verify again + $job = new \App\Jobs\UserVerify($user); + $job->handle(); + return $user->isImapReady(); + } + } catch (\Exception $e) { + \Log::error($e); + } + + return false; + } } diff --git a/src/resources/lang/en/app.php b/src/resources/lang/en/app.php --- a/src/resources/lang/en/app.php +++ b/src/resources/lang/en/app.php @@ -12,15 +12,22 @@ 'planbutton' => 'Choose :plan', - 'process-user-new' => 'User registered', - 'process-user-ldap-ready' => 'User created', - 'process-user-imap-ready' => 'User mailbox created', - 'process-domain-new' => 'Custom domain registered', - 'process-domain-ldap-ready' => 'Custom domain created', - 'process-domain-verified' => 'Custom domain verified', - 'process-domain-confirmed' => 'Custom domain ownership verified', + '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-user-ldap-ready' => 'Failed to create a user.', + 'process-error-user-imap-ready' => 'Failed to verify that a mailbox exists.', + '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.', 'domain-verify-success' => 'Domain verified successfully.', + 'domain-verify-error' => 'Domain ownership verification failed.', 'user-update-success' => 'User data updated successfully.', 'user-create-success' => 'User created successfully.', 'user-delete-success' => 'User deleted successfully.', diff --git a/src/resources/sass/app.scss b/src/resources/sass/app.scss --- a/src/resources/sass/app.scss +++ b/src/resources/sass/app.scss @@ -137,18 +137,28 @@ } } -ul.status-list { - list-style: none; - padding: 0; - margin: 0; +#status-box { + background-color: lighten($green, 35); - svg { - width: 1.25rem !important; - height: 1.25rem; + .progress { + background-color: #fff; + height: 10px; + } + + .progress-label { + font-size: 0.9em; } - span { - vertical-align: top; + .progress-bar { + background-color: $green; + } + + &.process-failed { + background-color: lighten($orange, 30); + + .progress-bar { + background-color: $red; + } } } @@ -156,15 +166,14 @@ display: flex; flex-wrap: wrap; justify-content: center; - margin-top: 1rem; & > a { padding: 1rem; text-align: center; white-space: nowrap; - margin: 0 0.5rem 0.5rem 0; + margin-top: 0.5rem; text-decoration: none; - min-width: 8rem; + width: 150px; &.disabled { pointer-events: none; @@ -176,11 +185,16 @@ top: 0.5rem; right: 0.5rem; } + + & + a { + margin-left: 0.5rem; + } } svg { width: 6rem; height: 6rem; + margin: auto; } } diff --git a/src/resources/vue/Dashboard.vue b/src/resources/vue/Dashboard.vue --- a/src/resources/vue/Dashboard.vue +++ b/src/resources/vue/Dashboard.vue @@ -1,23 +1,7 @@ diff --git a/src/resources/vue/User/Info.vue b/src/resources/vue/User/Info.vue --- a/src/resources/vue/User/Info.vue +++ b/src/resources/vue/User/Info.vue @@ -1,5 +1,7 @@