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:
Favour Olayinka
2024-07-04 16:21:36 +01:00
committed by GitHub
parent 383fff7b2c
commit 90ff91b1e9
64 changed files with 2503 additions and 596 deletions

View File

@@ -2,7 +2,8 @@
namespace App\Models\Billing;
use App\Events\SubscriptionCreated;
use App\Events\Billing\SubscriptionCreated;
use App\Events\Billing\SubscriptionUpdated;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Laravel\Cashier\Subscription as CashierSubscription;
@@ -12,6 +13,7 @@ class Subscription extends CashierSubscription
protected $dispatchesEvents = [
'created' => SubscriptionCreated::class,
'updated' => SubscriptionUpdated::class,
];
public static function booted(): void

View File

@@ -8,8 +8,8 @@ use Illuminate\Database\Eloquent\Model;
class License extends Model
{
use HasFactory;
public const STATUS_ACTIVE = 'active';
public const STATUS_ACTIVE = 'active';
public const STATUS_INACTIVE = 'inactive';
protected $fillable = [
@@ -32,6 +32,11 @@ class License extends Model
return $this->belongsTo(User::class);
}
public function isActive()
{
return $this->status === self::STATUS_ACTIVE;
}
public function scopeActive($query)
{
return $query->where('status', self::STATUS_ACTIVE);
@@ -55,6 +60,15 @@ class License extends Model
][$this->meta['tier']];
}
public function getMaxUsersLimitCountAttribute(): ?int
{
return [
1 => 1,
2 => 5,
3 => null,
][$this->meta['tier']];
}
public static function booted(): void
{
static::saved(function (License $license) {

View File

@@ -17,6 +17,9 @@ class User extends Authenticatable implements JWTSubject
use HasFactory;
use Notifiable;
public const ROLE_ADMIN = 'admin';
public const ROLE_USER = 'user';
/**
* The attributes that are mass assignable.
*
@@ -80,7 +83,7 @@ class User extends Authenticatable implements JWTSubject
{
return vsprintf('https://www.gravatar.com/avatar/%s.jpg?s=200&d=%s', [
md5(strtolower($this->email)),
$this->name ? urlencode("https://ui-avatars.com/api/$this->name") : 'mp',
$this->name ? urlencode("https://ui-avatars.com/api/$this->name.jpg") : 'mp',
]);
}
@@ -235,6 +238,8 @@ class User extends Authenticatable implements JWTSubject
foreach ($user->workspaces as $workspace) {
if ($workspace->users()->count() == 1) {
$workspace->delete();
} else {
$workspace->users()->detach($user->id);
}
}
});

87
app/Models/UserInvite.php Normal file
View File

@@ -0,0 +1,87 @@
<?php
namespace App\Models;
use App\Jobs\Billing\WorkspaceUsersUpdated;
use App\Mail\UserInvitationEmail;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
class UserInvite extends Model
{
use HasFactory;
public const PENDING_STATUS = 'pending';
public const ACCEPTED_STATUS = 'accepted';
protected $fillable = [
'email',
'role',
'workspace_id',
'valid_until',
'status',
'token',
];
public static function inviteUser(
string $email,
string $role,
Workspace $workspace,
Carbon $validUntil = null
): self {
// Generate a token
do {
$token = Str::random(100);
} while (UserInvite::where('token', $token)->exists());
$invite = self::create([
'email' => $email,
'role' => $role,
'workspace_id' => $workspace->id,
'valid_until' => $validUntil ?? now()->addDays(7),
'token' => $token,
]);
$invite->sendEmail();
return $invite;
}
public function getLink()
{
return front_url('/register?email=' . urlencode($this->email) . '&invite_token=' . urlencode($this->token));
}
public function hasExpired()
{
return Carbon::parse($this->valid_until)->isPast();
}
public function workspace()
{
return $this->belongsTo(Workspace::class);
}
public function markAsAccepted()
{
$this->update(['status' => self::ACCEPTED_STATUS]);
WorkspaceUsersUpdated::dispatch($this->workspace);
return $this;
}
public function sendEmail()
{
Mail::to($this->email)->send(new UserInvitationEmail($this));
}
public function scopeNotExpired($query)
{
return $query->where('valid_until', '>', now());
}
public function scopePending($query)
{
return $query->where('status', self::PENDING_STATUS);
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class UserWorkspace extends Model
{
use HasFactory;
protected $table = 'user_workspace';
protected $fillable = [
'user_id',
'workspace_id',
'role',
];
public function user()
{
return $this->belongsTo(User::class);
}
public function workspace()
{
return $this->belongsTo(Workspace::class);
}
}

View File

@@ -7,6 +7,7 @@ use App\Models\Traits\CachableAttributes;
use App\Models\Traits\CachesAttributes;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
class Workspace extends Model implements CachableAttributes
{
@@ -50,7 +51,7 @@ class Workspace extends Model implements CachableAttributes
public function getMaxFileSizeAttribute()
{
if (is_null(config('cashier.key'))) {
if (!pricing_enabled()) {
return self::MAX_FILE_SIZE_PRO;
}
@@ -73,7 +74,7 @@ class Workspace extends Model implements CachableAttributes
public function getCustomDomainCountLimitAttribute()
{
if (is_null(config('cashier.key'))) {
if (!pricing_enabled()) {
return null;
}
@@ -95,7 +96,7 @@ class Workspace extends Model implements CachableAttributes
public function getIsProAttribute()
{
if (is_null(config('cashier.key'))) {
if (!pricing_enabled()) {
return true; // If no paid plan so TRUE for ALL
}
@@ -113,7 +114,7 @@ class Workspace extends Model implements CachableAttributes
public function getIsTrialingAttribute()
{
if (is_null(config('cashier.key'))) {
if (!pricing_enabled()) {
return false; // If no paid plan so FALSE for ALL
}
@@ -131,7 +132,7 @@ class Workspace extends Model implements CachableAttributes
public function getIsEnterpriseAttribute()
{
if (is_null(config('cashier.key'))) {
if (!pricing_enabled()) {
return true; // If no paid plan so TRUE for ALL
}
@@ -181,11 +182,21 @@ class Workspace extends Model implements CachableAttributes
return $this->belongsToMany(User::class);
}
public function invites()
{
return $this->hasMany(UserInvite::class);
}
public function owners()
{
return $this->users()->wherePivot('role', 'admin');
}
public function billingOwners(): Collection
{
return $this->owners->filter(fn ($owner) => $owner->is_subscribed);
}
public function forms()
{
return $this->hasMany(Form::class);