Team functionality (#459)
* add api enpoints for adding, removing, updating user to workspace and leaving workspace * feat: updates client site workspace settings * refactor and add domain setting ui in modal * move workspace user functionality to its own component * adds tests * fix linting * updates select input to FlatSelectInput * moves workspace user role edit to seperated component * move user adding to its own component * adds check to usure users exist before checking is admin * fix loading users * feat: invite user to team functionality * fix token coulmn * fix self host mode changes * tests for user invite * Refactor back-end * Rename variables * Improve some styling elements + refactor workspace settings * More styling * More UI polishing * More UI fixes * PHP linting * Implemented most of the logic for team-functionnality * Fix user avatar URL * WIP remove users on cancellation * Finished pricing for team functionality * Fix tests * Fix linting * Added pricing_enabled helper * Fix pricing_enabled shortcut * Debug CI * Disable pricing when testing --------- Co-authored-by: LL-Etiane <lukongleinyuyetiane@gmail.com> Co-authored-by: Lukong Etiane <83535251+LL-Etiane@users.noreply.github.com> Co-authored-by: Julien Nahum <julien@nahum.net>
This commit is contained in:
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Auth;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\UserResource;
|
||||
use App\Models\User;
|
||||
use App\Models\UserInvite;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Foundation\Auth\RegistersUsers;
|
||||
@@ -62,6 +63,7 @@ class RegisterController extends Controller
|
||||
'hear_about_us' => 'required|string',
|
||||
'agree_terms' => ['required', Rule::in([true])],
|
||||
'appsumo_license' => ['nullable'],
|
||||
'invite_token' => ['nullable', 'string'],
|
||||
], [
|
||||
'agree_terms' => 'Please agree with the terms and conditions.',
|
||||
]);
|
||||
@@ -69,15 +71,11 @@ class RegisterController extends Controller
|
||||
|
||||
/**
|
||||
* Create a new user instance after a valid registration.
|
||||
*
|
||||
* @return \App\User
|
||||
*/
|
||||
protected function create(array $data)
|
||||
{
|
||||
$workspace = Workspace::create([
|
||||
'name' => 'My Workspace',
|
||||
'icon' => '🧪',
|
||||
]);
|
||||
$this->checkRegistrationAllowed($data);
|
||||
[$workspace, $role] = $this->getWorkspaceAndRole($data);
|
||||
|
||||
$user = User::create([
|
||||
'name' => $data['name'],
|
||||
@@ -89,7 +87,7 @@ class RegisterController extends Controller
|
||||
// Add relation with user
|
||||
$user->workspaces()->sync([
|
||||
$workspace->id => [
|
||||
'role' => 'admin',
|
||||
'role' => $role,
|
||||
],
|
||||
], false);
|
||||
|
||||
@@ -97,4 +95,45 @@ class RegisterController extends Controller
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function checkRegistrationAllowed(array $data)
|
||||
{
|
||||
if (config('app.self_hosted') && !array_key_exists('invite_token', $data)) {
|
||||
response()->json(['message' => 'Registration is not allowed in self host mode'], 400)->throwResponse();
|
||||
}
|
||||
}
|
||||
|
||||
private function getWorkspaceAndRole(array $data)
|
||||
{
|
||||
if (!array_key_exists('invite_token', $data)) {
|
||||
return [
|
||||
Workspace::create([
|
||||
'name' => 'My Workspace',
|
||||
'icon' => '🧪',
|
||||
]),
|
||||
User::ROLE_ADMIN
|
||||
];
|
||||
}
|
||||
|
||||
$userInvite = UserInvite::where('email', $data['email'])
|
||||
->where('token', $data['invite_token'])
|
||||
->first();
|
||||
|
||||
if (!$userInvite) {
|
||||
response()->json(['message' => 'Invite token is invalid.'], 400)->throwResponse();
|
||||
}
|
||||
if ($userInvite->hasExpired()) {
|
||||
response()->json(['message' => 'Invite token has expired.'], 400)->throwResponse();
|
||||
}
|
||||
|
||||
if ($userInvite->status == UserInvite::ACCEPTED_STATUS) {
|
||||
response()->json(['message' => 'Invite is already accepted.'], 400)->throwResponse();
|
||||
}
|
||||
|
||||
$userInvite->markAsAccepted();
|
||||
return [
|
||||
$userInvite->workspace,
|
||||
$userInvite->role,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\Subscriptions\UpdateStripeDetailsRequest;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use App\Service\BillingHelper;
|
||||
use App\Service\UserHelper;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Laravel\Cashier\Subscription;
|
||||
|
||||
@@ -36,7 +37,7 @@ class SubscriptionController extends Controller
|
||||
}
|
||||
|
||||
$checkoutBuilder = $user
|
||||
->newSubscription($pricing, $this->getPricing($pricing)[$plan])
|
||||
->newSubscription($pricing, BillingHelper::getPricing($pricing)[$plan])
|
||||
->allowPromotionCodes();
|
||||
|
||||
if ($trial != null) {
|
||||
@@ -60,10 +61,18 @@ class SubscriptionController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function getUsersCount()
|
||||
{
|
||||
$this->middleware('auth');
|
||||
return [
|
||||
'count' => (new UserHelper(Auth::user()))->getActiveMembersCount() - 1,
|
||||
];
|
||||
}
|
||||
|
||||
public function updateStripeDetails(UpdateStripeDetailsRequest $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (! $user->hasStripeId()) {
|
||||
if (!$user->hasStripeId()) {
|
||||
$user->createAsStripeCustomer();
|
||||
}
|
||||
$user->updateStripeCustomer([
|
||||
@@ -79,7 +88,7 @@ class SubscriptionController extends Controller
|
||||
public function billingPortal()
|
||||
{
|
||||
$this->middleware('auth');
|
||||
if (! Auth::user()->has_customer_id) {
|
||||
if (!Auth::user()->has_customer_id) {
|
||||
return $this->error([
|
||||
'message' => 'Please subscribe before accessing your billing portal.',
|
||||
]);
|
||||
@@ -89,9 +98,4 @@ class SubscriptionController extends Controller
|
||||
'portal_url' => Auth::user()->billingPortalUrl(front_url('/home')),
|
||||
]);
|
||||
}
|
||||
|
||||
private function getPricing($product = 'default')
|
||||
{
|
||||
return App::environment() == 'production' ? config('pricing.production.'.$product.'.pricing') : config('pricing.test.'.$product.'.pricing');
|
||||
}
|
||||
}
|
||||
|
||||
60
app/Http/Controllers/UserInviteController.php
Normal file
60
app/Http/Controllers/UserInviteController.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\UserInvite;
|
||||
use App\Models\Workspace;
|
||||
use App\Service\WorkspaceHelper;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class UserInviteController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('auth');
|
||||
}
|
||||
|
||||
public function listInvites(Request $request, $workspaceId)
|
||||
{
|
||||
$workspace = Workspace::findOrFail($workspaceId);
|
||||
$this->authorize('view', $workspace);
|
||||
|
||||
return (new WorkspaceHelper($workspace))->getAllInvites();
|
||||
}
|
||||
|
||||
public function resendInvite($workspaceId, $inviteId)
|
||||
{
|
||||
$workspace = Workspace::findOrFail($workspaceId);
|
||||
$this->authorize('adminAction', $workspace);
|
||||
$userInvite = $workspace->invites()->find($inviteId);
|
||||
if (!$userInvite) {
|
||||
return $this->error(['success' => false, 'message' => 'Invite not found for this workspace.']);
|
||||
}
|
||||
|
||||
if($userInvite->status == UserInvite::ACCEPTED_STATUS) {
|
||||
return $this->error(['success' => false, 'message' => 'Invite already accepted.']);
|
||||
}
|
||||
|
||||
$userInvite->sendEmail();
|
||||
|
||||
return $this->success(['message' => 'Invite email resent successfully.']);
|
||||
}
|
||||
|
||||
public function cancelInvite($workspaceId, $inviteId)
|
||||
{
|
||||
$workspace = Workspace::findOrFail($workspaceId);
|
||||
$this->authorize('adminAction', $workspace);
|
||||
$userInvite = $workspace->invites()->find($inviteId);
|
||||
if (!$userInvite) {
|
||||
return $this->error(['success' => false, 'message' => 'Invite not found for this workspace.']);
|
||||
}
|
||||
|
||||
if($userInvite->status == UserInvite::ACCEPTED_STATUS) {
|
||||
return $this->error(['success' => false, 'message' => 'Invite already accepted.']);
|
||||
}
|
||||
|
||||
$userInvite->delete();
|
||||
|
||||
return $this->success(['message' => 'Invite deleted successfully.']);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers\Webhook;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\Billing\RemoveWorkspaceGuests;
|
||||
use App\Models\License;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -56,7 +57,8 @@ class AppSumoController extends Controller
|
||||
|
||||
private function handleDeactivateEvent($request)
|
||||
{
|
||||
$this->deactivateLicense($request->license_key);
|
||||
$license = $this->deactivateLicense($request->license_key);
|
||||
RemoveWorkspaceGuests::dispatch($license->user);
|
||||
}
|
||||
|
||||
private function createLicense(array $licenseData): License
|
||||
|
||||
@@ -41,17 +41,17 @@ class StripeController extends WebhookController
|
||||
|
||||
$subscription->type = $subscription->type ?? $data['metadata']['name'] ?? $this->newSubscriptionName($payload);
|
||||
|
||||
$firstItem = $data['items']['data'][0];
|
||||
$mainItem = $this->getMainSubscriptionLineItem($data['items']['data']);
|
||||
$isSinglePrice = count($data['items']['data']) === 1;
|
||||
|
||||
// Price...
|
||||
$subscription->stripe_price = $isSinglePrice ? $firstItem['price']['id'] : null;
|
||||
$subscription->stripe_price = $isSinglePrice ? $mainItem['price']['id'] : null;
|
||||
|
||||
// Type - previously (Name)
|
||||
$subscription->type = $this->getSubscriptionName($data['plan']['product']);
|
||||
$subscription->type = $this->getSubscriptionName($mainItem['price']['product']);
|
||||
|
||||
// Quantity...
|
||||
$subscription->quantity = $isSinglePrice && isset($firstItem['quantity']) ? $firstItem['quantity'] : null;
|
||||
$subscription->quantity = $isSinglePrice && isset($mainItem['quantity']) ? $mainItem['quantity'] : null;
|
||||
|
||||
// Trial ending date...
|
||||
if (isset($data['trial_end'])) {
|
||||
@@ -115,6 +115,13 @@ class StripeController extends WebhookController
|
||||
return $this->successMethod();
|
||||
}
|
||||
|
||||
private function getMainSubscriptionLineItem(array $items)
|
||||
{
|
||||
return collect($items)->first(function ($item) {
|
||||
return in_array($this->getSubscriptionName($item['price']['product']), ['default']);
|
||||
});
|
||||
}
|
||||
|
||||
private function getSubscriptionName(string $stripeProductId)
|
||||
{
|
||||
$config = App::environment() == 'production' ? config('pricing.production') : config('pricing.test');
|
||||
|
||||
@@ -5,7 +5,6 @@ namespace App\Http\Controllers;
|
||||
use App\Http\Requests\Workspace\CustomDomainRequest;
|
||||
use App\Http\Resources\WorkspaceResource;
|
||||
use App\Models\Workspace;
|
||||
use App\Service\WorkspaceHelper;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
@@ -23,14 +22,6 @@ class WorkspaceController extends Controller
|
||||
return WorkspaceResource::collection(Auth::user()->workspaces);
|
||||
}
|
||||
|
||||
public function listUsers(Request $request, $workspaceId)
|
||||
{
|
||||
$workspace = Workspace::findOrFail($workspaceId);
|
||||
$this->authorize('view', $workspace);
|
||||
|
||||
return (new WorkspaceHelper($workspace))->getAllUsers();
|
||||
}
|
||||
|
||||
public function saveCustomDomain(CustomDomainRequest $request)
|
||||
{
|
||||
$request->workspace->custom_domains = $request->customDomains;
|
||||
|
||||
127
app/Http/Controllers/WorkspaceUserController.php
Normal file
127
app/Http/Controllers/WorkspaceUserController.php
Normal file
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Jobs\Billing\WorkspaceUsersUpdated;
|
||||
use App\Models\UserInvite;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\User;
|
||||
use App\Service\WorkspaceHelper;
|
||||
|
||||
class WorkspaceUserController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('auth');
|
||||
}
|
||||
|
||||
public function listUsers(Request $request, $workspaceId)
|
||||
{
|
||||
$workspace = Workspace::findOrFail($workspaceId);
|
||||
$this->authorize('view', $workspace);
|
||||
|
||||
return (new WorkspaceHelper($workspace))->getAllUsers();
|
||||
}
|
||||
|
||||
public function addUser(Request $request, $workspaceId)
|
||||
{
|
||||
$workspace = Workspace::findOrFail($workspaceId);
|
||||
$this->authorize('inviteUser', $workspace);
|
||||
|
||||
$this->validate($request, [
|
||||
'email' => 'required|email',
|
||||
'role' => 'required|in:admin,user',
|
||||
]);
|
||||
|
||||
$user = User::where('email', $request->email)->first();
|
||||
if (!$user) {
|
||||
return $this->inviteUser($workspace, $request->email, $request->role);
|
||||
}
|
||||
|
||||
if ($workspace->users->contains($user->id)) {
|
||||
return $this->success([
|
||||
'message' => 'User is already in workspace.'
|
||||
]);
|
||||
}
|
||||
|
||||
// User found - add user to workspace
|
||||
$workspace->users()->sync([
|
||||
$user->id => [
|
||||
'role' => $request->role,
|
||||
],
|
||||
], false);
|
||||
WorkspaceUsersUpdated::dispatch($workspace);
|
||||
|
||||
return $this->success([
|
||||
'message' => 'User has been successfully added to workspace.'
|
||||
]);
|
||||
}
|
||||
|
||||
private function inviteUser(Workspace $workspace, string $email, string $role)
|
||||
{
|
||||
if (
|
||||
UserInvite::where('email', $email)
|
||||
->where('workspace_id', $workspace->id)
|
||||
->notExpired()
|
||||
->pending()
|
||||
->exists()) {
|
||||
return $this->success([
|
||||
'message' => 'User has already been invited.'
|
||||
]);
|
||||
}
|
||||
|
||||
// Send new invite
|
||||
UserInvite::inviteUser($email, $role, $workspace, now()->addDays(7));
|
||||
|
||||
return $this->success([
|
||||
'message' => 'Registration invitation email sent to user.'
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateUserRole(Request $request, $workspaceId, $userId)
|
||||
{
|
||||
$workspace = Workspace::findOrFail($workspaceId);
|
||||
$user = User::findOrFail($userId);
|
||||
$this->authorize('adminAction', $workspace);
|
||||
|
||||
$this->validate($request, [
|
||||
'role' => 'required|in:admin,user',
|
||||
]);
|
||||
|
||||
$workspace->users()->sync([
|
||||
$user->id => [
|
||||
'role' => $request->role,
|
||||
],
|
||||
], false);
|
||||
|
||||
return $this->success([
|
||||
'message' => 'User role changed successfully.'
|
||||
]);
|
||||
}
|
||||
|
||||
public function removeUser(Request $request, $workspaceId, $userId)
|
||||
{
|
||||
$workspace = Workspace::findOrFail($workspaceId);
|
||||
$this->authorize('adminAction', $workspace);
|
||||
|
||||
$workspace->users()->detach($userId);
|
||||
WorkspaceUsersUpdated::dispatch($workspace);
|
||||
|
||||
return $this->success([
|
||||
'message' => 'User removed from workspace successfully.'
|
||||
]);
|
||||
}
|
||||
|
||||
public function leaveWorkspace(Request $request, $workspaceId)
|
||||
{
|
||||
$workspace = Workspace::findOrFail($workspaceId);
|
||||
$this->authorize('view', $workspace);
|
||||
|
||||
$workspace->users()->detach($request->user()->id);
|
||||
|
||||
return $this->success([
|
||||
'message' => 'You have left the workspace successfully.'
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user