diff --git a/.editorconfig b/.editorconfig index 37cfad7..00bc7f2 100644 --- a/.editorconfig +++ b/.editorconfig @@ -3,10 +3,13 @@ root = true [*] charset = utf-8 end_of_line = lf -indent_size = 4 +indent_size = 2 indent_style = space insert_final_newline = true trim_trailing_whitespace = true +[*.php] +indent_size = 4 + [*.md] trim_trailing_whitespace = false diff --git a/app/Enums/TeamMemberRole.php b/app/Enums/TeamMemberRole.php new file mode 100644 index 0000000..905cda8 --- /dev/null +++ b/app/Enums/TeamMemberRole.php @@ -0,0 +1,10 @@ + Team::visibleTo($request->user()), + ]); + } + + public function create() + { + // + } + + /** + * Store a newly created resource in storage. + */ + public function store(StoreTeamRequest $request) + { + // + } + + + /** + * Show the form for editing the specified resource. + */ + public function edit(Team $team) + { + // + } + + /** + * Update the specified resource in storage. + */ + public function update(UpdateTeamRequest $request, Team $team) + { + // + } + + /** + * Remove the specified resource from storage. + */ + public function destroy(Team $team) + { + // + } +} diff --git a/app/Http/Requests/StoreTeamRequest.php b/app/Http/Requests/StoreTeamRequest.php new file mode 100644 index 0000000..a43e0e8 --- /dev/null +++ b/app/Http/Requests/StoreTeamRequest.php @@ -0,0 +1,31 @@ +|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'slug' => ['required', 'string', 'max:255', 'unique:teams,slug'], + 'require_2fa' => ['required', 'boolean'], + ]; + } + + protected function prepareForValidation(): void + { + $this->merge([ + 'slug' => Str::slug($this->name), + ]); + } +} diff --git a/app/Http/Requests/UpdateTeamRequest.php b/app/Http/Requests/UpdateTeamRequest.php new file mode 100644 index 0000000..ad7be4c --- /dev/null +++ b/app/Http/Requests/UpdateTeamRequest.php @@ -0,0 +1,31 @@ +|string> + */ + public function rules(): array + { + return [ + 'name' => ['nullable', 'string', 'max:255'], + 'slug' => ['nullable', 'required_if:name', 'string', 'max:255', 'unique:teams,slug,'.$this->route('team')->id], + 'require_2fa' => ['required', 'boolean'], + ]; + } + + protected function prepareForValidation(): void + { + $this->merge([ + 'slug' => Str::slug($this->name), + ]); + } +} diff --git a/app/Http/Resources/TeamMemberResource.php b/app/Http/Resources/TeamMemberResource.php new file mode 100644 index 0000000..2cae29d --- /dev/null +++ b/app/Http/Resources/TeamMemberResource.php @@ -0,0 +1,23 @@ + $this->name, + 'email' => $this->email, + 'has_multi_factor' => $this->hasVerifiedEmail() || $this->hasEnabledTwoFactorAuthentication(), + 'role' => $this->pivot->role, + ]; + } +} diff --git a/app/Http/Resources/TeamResource.php b/app/Http/Resources/TeamResource.php new file mode 100644 index 0000000..4462d2c --- /dev/null +++ b/app/Http/Resources/TeamResource.php @@ -0,0 +1,28 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'name' => $this->name, + 'slug' => $this->slug, + 'members' => TeamMemberResource::collection($this->members), + 'multi_factor_required' => $this->require_2fa, + ]; + } +} diff --git a/app/Models/Team.php b/app/Models/Team.php new file mode 100644 index 0000000..ce44553 --- /dev/null +++ b/app/Models/Team.php @@ -0,0 +1,58 @@ + $members + * @property-read int|null $members_count + * @method static Builder|Team newModelQuery() + * @method static Builder|Team newQuery() + * @method static Builder|Team query() + * @method static Builder|Team visibleTo(\App\Models\User $user) + * @method static Builder|Team whereCreatedAt($value) + * @method static Builder|Team whereCreatedBy($value) + * @method static Builder|Team whereId($value) + * @method static Builder|Team whereName($value) + * @method static Builder|Team whereRequire2fa($value) + * @method static Builder|Team whereSlug($value) + * @method static Builder|Team whereUpdatedAt($value) + * @mixin \Eloquent + */ +#[Fillable(['name', 'slug', 'require_2fa'])] +class Team extends Model +{ + protected function casts() + { + return [ + 'require_2fa' => 'boolean', + ]; + } + + public function members(): BelongsToMany + { + return $this->belongsToMany(User::class, 'team_members') + ->using(TeamMember::class) + ->withPivot('role') + ->withTimestamps(); + } + + public function scopeVisibleTo(Builder $query, User $user): Builder + { + return $query->whereHas('members', function (Builder $query) use ($user): void { + $query->whereKey($user->getKey()); + }); + } +} diff --git a/app/Models/TeamMember.php b/app/Models/TeamMember.php new file mode 100644 index 0000000..b5b496a --- /dev/null +++ b/app/Models/TeamMember.php @@ -0,0 +1,35 @@ +|TeamMember newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|TeamMember newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|TeamMember query() + * @method static \Illuminate\Database\Eloquent\Builder|TeamMember whereCreatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|TeamMember whereRole($value) + * @method static \Illuminate\Database\Eloquent\Builder|TeamMember whereTeamId($value) + * @method static \Illuminate\Database\Eloquent\Builder|TeamMember whereUpdatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|TeamMember whereUserId($value) + * @mixin \Eloquent + */ +class TeamMember extends Pivot +{ + protected $table = 'team_members'; + + protected function casts(): array + { + return [ + 'role' => TeamMemberRole::class, + ]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index e67ee72..3c199a9 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,14 +2,14 @@ namespace App\Models; -// use Illuminate\Contracts\Auth\MustVerifyEmail; +use Illuminate\Contracts\Auth\MustVerifyEmail; use Database\Factories\UserFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; -use Illuminate\Support\Carbon; use Laravel\Fortify\Contracts\PasskeyUser; use Laravel\Fortify\PasskeyAuthenticatable; use Laravel\Fortify\TwoFactorAuthenticatable; @@ -18,18 +18,41 @@ use Laravel\Fortify\TwoFactorAuthenticatable; * @property int $id * @property string $name * @property string $email - * @property Carbon|null $email_verified_at + * @property \Carbon\CarbonImmutable|null $email_verified_at * @property string $password + * @property string|null $remember_token + * @property \Carbon\CarbonImmutable|null $created_at + * @property \Carbon\CarbonImmutable|null $updated_at * @property string|null $two_factor_secret * @property string|null $two_factor_recovery_codes - * @property Carbon|null $two_factor_confirmed_at - * @property string|null $remember_token - * @property Carbon|null $created_at - * @property Carbon|null $updated_at + * @property \Carbon\CarbonImmutable|null $two_factor_confirmed_at + * @property-read \Illuminate\Notifications\DatabaseNotificationCollection $notifications + * @property-read int|null $notifications_count + * @property-read \Illuminate\Database\Eloquent\Collection $passkeys + * @property-read int|null $passkeys_count + * @property-read \App\Models\TeamMember|null $pivot + * @property-read \Illuminate\Database\Eloquent\Collection $teams + * @property-read int|null $teams_count + * @method static \Database\Factories\UserFactory factory($count = null, $state = []) + * @method static \Illuminate\Database\Eloquent\Builder|User newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|User newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|User query() + * @method static \Illuminate\Database\Eloquent\Builder|User whereCreatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|User whereEmail($value) + * @method static \Illuminate\Database\Eloquent\Builder|User whereEmailVerifiedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|User whereId($value) + * @method static \Illuminate\Database\Eloquent\Builder|User whereName($value) + * @method static \Illuminate\Database\Eloquent\Builder|User wherePassword($value) + * @method static \Illuminate\Database\Eloquent\Builder|User whereRememberToken($value) + * @method static \Illuminate\Database\Eloquent\Builder|User whereTwoFactorConfirmedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|User whereTwoFactorRecoveryCodes($value) + * @method static \Illuminate\Database\Eloquent\Builder|User whereTwoFactorSecret($value) + * @method static \Illuminate\Database\Eloquent\Builder|User whereUpdatedAt($value) + * @mixin \Eloquent */ #[Fillable(['name', 'email', 'password'])] #[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])] -class User extends Authenticatable implements PasskeyUser +class User extends Authenticatable implements PasskeyUser, MustVerifyEmail { /** @use HasFactory */ use HasFactory, Notifiable, PasskeyAuthenticatable, TwoFactorAuthenticatable; @@ -47,4 +70,12 @@ class User extends Authenticatable implements PasskeyUser 'two_factor_confirmed_at' => 'datetime', ]; } + + public function teams(): BelongsToMany + { + return $this->belongsToMany(Team::class, 'team_members') + ->using(TeamMember::class) + ->withPivot(['role']) + ->withTimestamps(); + } } diff --git a/app/Policies/TeamPolicy.php b/app/Policies/TeamPolicy.php new file mode 100644 index 0000000..c331fb5 --- /dev/null +++ b/app/Policies/TeamPolicy.php @@ -0,0 +1,33 @@ +visibleTo($user) + ->whereKey($team->getKey()) + ->exists(); + } + + public function create(User $user): bool + { + return $user->hasVerifiedEmail(); + } + + public function manage(User $user, Team $team): bool + { + $membership = $team->members()->whereKey($user->id)->first(); + if (! $membership) { + return false; + } + + return $membership->pivot->role === TeamMemberRole::Admin; + } +} diff --git a/composer.json b/composer.json index 7febde8..947a07c 100644 --- a/composer.json +++ b/composer.json @@ -18,6 +18,7 @@ "laravel/wayfinder": "^0.1.14" }, "require-dev": { + "barryvdh/laravel-ide-helper": "^3.7", "fakerphp/faker": "^1.24", "larastan/larastan": "^3.9", "laravel/boost": "^2.2", diff --git a/composer.lock b/composer.lock index 3e549a4..35ba4ff 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "859863f5ca81e4a2e3cf62f862513073", + "content-hash": "5dc37eb696378f9735e0d1d9e44899f6", "packages": [ { "name": "bacon/bacon-qr-code", @@ -7629,6 +7629,153 @@ } ], "packages-dev": [ + { + "name": "barryvdh/laravel-ide-helper", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/barryvdh/laravel-ide-helper.git", + "reference": "ad7e37676f1ff985d55ef1b6b96a0c0a40f2609a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/barryvdh/laravel-ide-helper/zipball/ad7e37676f1ff985d55ef1b6b96a0c0a40f2609a", + "reference": "ad7e37676f1ff985d55ef1b6b96a0c0a40f2609a", + "shasum": "" + }, + "require": { + "barryvdh/reflection-docblock": "^2.4", + "composer/class-map-generator": "^1.0", + "ext-json": "*", + "illuminate/console": "^11.15 || ^12 || ^13.0", + "illuminate/database": "^11.15 || ^12 || ^13.0", + "illuminate/filesystem": "^11.15 || ^12 || ^13.0", + "illuminate/support": "^11.15 || ^12 || ^13.0", + "php": "^8.2" + }, + "require-dev": { + "ext-pdo_sqlite": "*", + "friendsofphp/php-cs-fixer": "^3", + "illuminate/config": "^11.15 || ^12 || ^13.0", + "illuminate/view": "^11.15 || ^12 || ^13.0", + "larastan/larastan": "^3.1", + "mockery/mockery": "^1.4", + "orchestra/testbench": "^9.2 || ^10 || ^11.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5 || ^11.5.3 || ^12.5.12", + "spatie/phpunit-snapshot-assertions": "^4 || ^5", + "vlucas/phpdotenv": "^5" + }, + "suggest": { + "illuminate/events": "Required for automatic helper generation (^6|^7|^8|^9|^10|^11)." + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Barryvdh\\LaravelIdeHelper\\IdeHelperServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Barryvdh\\LaravelIdeHelper\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Barry vd. Heuvel", + "email": "barryvdh@gmail.com" + } + ], + "description": "Laravel IDE Helper, generates correct PHPDocs for all Facade classes, to improve auto-completion.", + "keywords": [ + "autocomplete", + "codeintel", + "dev", + "helper", + "ide", + "laravel", + "netbeans", + "phpdoc", + "phpstorm", + "sublime" + ], + "support": { + "issues": "https://github.com/barryvdh/laravel-ide-helper/issues", + "source": "https://github.com/barryvdh/laravel-ide-helper/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://fruitcake.nl", + "type": "custom" + }, + { + "url": "https://github.com/barryvdh", + "type": "github" + } + ], + "time": "2026-03-17T14:12:51+00:00" + }, + { + "name": "barryvdh/reflection-docblock", + "version": "v2.4.1", + "source": { + "type": "git", + "url": "https://github.com/barryvdh/ReflectionDocBlock.git", + "reference": "4f5ba70c30c81f2ce03a16a9965832cfcc31ed3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/barryvdh/ReflectionDocBlock/zipball/4f5ba70c30c81f2ce03a16a9965832cfcc31ed3b", + "reference": "4f5ba70c30c81f2ce03a16a9965832cfcc31ed3b", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.14|^9" + }, + "suggest": { + "dflydev/markdown": "~1.0", + "erusev/parsedown": "~1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.3.x-dev" + } + }, + "autoload": { + "psr-0": { + "Barryvdh": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "mike.vanriel@naenius.com" + } + ], + "support": { + "source": "https://github.com/barryvdh/ReflectionDocBlock/tree/v2.4.1" + }, + "time": "2026-03-05T20:09:01+00:00" + }, { "name": "brianium/paratest", "version": "v7.24.1", @@ -7722,6 +7869,151 @@ ], "time": "2026-08-17T06:31:26+00:00" }, + { + "name": "composer/class-map-generator", + "version": "1.7.3", + "source": { + "type": "git", + "url": "https://github.com/composer/class-map-generator.git", + "reference": "86d8208fc3c649a3a999daf1a63c25201be2990f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/class-map-generator/zipball/86d8208fc3c649a3a999daf1a63c25201be2990f", + "reference": "86d8208fc3c649a3a999daf1a63c25201be2990f", + "shasum": "" + }, + "require": { + "composer/pcre": "^2.1 || ^3.1", + "php": "^7.2 || ^8.0", + "symfony/finder": "^4.4 || ^5.3 || ^6 || ^7 || ^8" + }, + "require-dev": { + "phpstan/phpstan": "^1.12 || ^2", + "phpstan/phpstan-deprecation-rules": "^1 || ^2", + "phpstan/phpstan-phpunit": "^1 || ^2", + "phpstan/phpstan-strict-rules": "^1.1 || ^2", + "phpunit/phpunit": "^8", + "symfony/filesystem": "^5.4 || ^6 || ^7 || ^8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\ClassMapGenerator\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Utilities to scan PHP code and generate class maps.", + "keywords": [ + "classmap" + ], + "support": { + "issues": "https://github.com/composer/class-map-generator/issues", + "source": "https://github.com/composer/class-map-generator/tree/1.7.3" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2026-05-05T09:17:07+00:00" + }, + { + "name": "composer/pcre", + "version": "3.4.0", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<2.2.2" + }, + "require-dev": { + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^9" + }, + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.4.0" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2026-06-07T11:47:49+00:00" + }, { "name": "composer/semver", "version": "3.4.4", @@ -11528,5 +11820,5 @@ "php": "^8.3" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/database/migrations/2026_09_10_192513_create_teams_table.php b/database/migrations/2026_09_10_192513_create_teams_table.php new file mode 100644 index 0000000..1ad28ad --- /dev/null +++ b/database/migrations/2026_09_10_192513_create_teams_table.php @@ -0,0 +1,31 @@ +id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->boolean('require_2fa')->default(false); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('teams'); + } +}; diff --git a/database/migrations/2026_09_10_195126_create_team_members_table.php b/database/migrations/2026_09_10_195126_create_team_members_table.php new file mode 100644 index 0000000..5655b39 --- /dev/null +++ b/database/migrations/2026_09_10_195126_create_team_members_table.php @@ -0,0 +1,34 @@ +foreignIdFor(Team::class)->constrained()->cascadeOnDelete(); + $table->foreignIdFor(User::class)->constrained()->cascadeOnDelete(); + $table->enum('role', TeamMemberRole::cases())->default(TeamMemberRole::Member); + $table->timestamps(); + + $table->unique(['team_id', 'user_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('team_members'); + } +}; diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index a65f9d0..a5e29a8 100644 --- a/resources/js/components/AppSidebar.vue +++ b/resources/js/components/AppSidebar.vue @@ -1,6 +1,6 @@ diff --git a/resources/js/pages/team/Create.vue b/resources/js/pages/team/Create.vue new file mode 100644 index 0000000..85af715 --- /dev/null +++ b/resources/js/pages/team/Create.vue @@ -0,0 +1,11 @@ + + + + + diff --git a/resources/js/pages/team/Edit.vue b/resources/js/pages/team/Edit.vue new file mode 100644 index 0000000..85af715 --- /dev/null +++ b/resources/js/pages/team/Edit.vue @@ -0,0 +1,11 @@ + + + + + diff --git a/resources/js/pages/team/Index.vue b/resources/js/pages/team/Index.vue new file mode 100644 index 0000000..99f3903 --- /dev/null +++ b/resources/js/pages/team/Index.vue @@ -0,0 +1,55 @@ + + + diff --git a/resources/js/types/models.ts b/resources/js/types/models.ts new file mode 100644 index 0000000..14b0294 --- /dev/null +++ b/resources/js/types/models.ts @@ -0,0 +1,16 @@ +export interface Team { + name: string; + slug: string; + members: TeamMember[]; + multi_factor_required: boolean; +} + +export type TeamMemberRole = 'admin' | 'member' + +export interface TeamMember { + name: string; + email: string; + has_multi_factor: boolean; + role: TeamMemberRole; +} + diff --git a/routes/settings.php b/routes/settings.php index 2e0e1eb..7ddfa79 100644 --- a/routes/settings.php +++ b/routes/settings.php @@ -10,9 +10,7 @@ Route::middleware(['auth'])->group(function () { Route::get('settings/profile', [ProfileController::class, 'edit'])->name('profile.edit'); Route::patch('settings/profile', [ProfileController::class, 'update'])->name('profile.update'); -}); -Route::middleware(['auth', 'verified'])->group(function () { Route::delete('settings/profile', [ProfileController::class, 'destroy'])->name('profile.destroy'); Route::get('settings/security', [SecurityController::class, 'edit']) diff --git a/routes/web.php b/routes/web.php index 4f0de56..d92fe91 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,11 +1,18 @@ name('home'); -Route::middleware(['auth', 'verified'])->group(function () { +Route::middleware(['auth'])->group(function () { Route::inertia('dashboard', 'Dashboard')->name('dashboard'); + + Route::controller(TeamController::class)->prefix('teams')->name('teams.')->group(function () { + Route::get('/', 'index')->name('index'); + Route::get('/create', 'create')->name('create'); + }); + }); require __DIR__.'/settings.php';