added "members" for an event that help the admins to moderate. members must be invited via email.
This commit is contained in:
195
app/Http/Controllers/Api/Tenant/EventMemberController.php
Normal file
195
app/Http/Controllers/Api/Tenant/EventMemberController.php
Normal file
@@ -0,0 +1,195 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Tenant;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Tenant\EventMemberInviteRequest;
|
||||
use App\Http\Resources\Tenant\EventMemberResource;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventMember;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class EventMemberController extends Controller
|
||||
{
|
||||
public function index(Request $request, Event $event): JsonResponse
|
||||
{
|
||||
$this->assertEventTenant($request, $event);
|
||||
|
||||
/** @var LengthAwarePaginator $members */
|
||||
$members = $event->members()
|
||||
->orderByDesc('created_at')
|
||||
->paginate($request->integer('per_page', 25));
|
||||
|
||||
return EventMemberResource::collection($members)->response();
|
||||
}
|
||||
|
||||
public function store(EventMemberInviteRequest $request, Event $event): JsonResponse
|
||||
{
|
||||
$this->assertEventTenant($request, $event);
|
||||
|
||||
$data = $request->validated();
|
||||
$tenant = $this->resolveTenantFromRequest($request);
|
||||
|
||||
if (! $tenant) {
|
||||
throw ValidationException::withMessages([
|
||||
'tenant_id' => __('Tenant context missing for invitation.'),
|
||||
]);
|
||||
}
|
||||
|
||||
$email = Str::lower($data['email']);
|
||||
$role = $data['role'];
|
||||
$name = $data['name'] ?? null;
|
||||
|
||||
$user = $this->findOrCreateUser($tenant, $email, $name, $role);
|
||||
|
||||
if ((int) $user->tenant_id !== (int) $tenant->id) {
|
||||
throw ValidationException::withMessages([
|
||||
'email' => __('Dieser Benutzer gehört bereits zu einem anderen Mandanten.'),
|
||||
]);
|
||||
}
|
||||
|
||||
$existing = EventMember::query()
|
||||
->where('event_id', $event->id)
|
||||
->where('email', $email)
|
||||
->first();
|
||||
|
||||
$member = EventMember::updateOrCreate(
|
||||
[
|
||||
'event_id' => $event->id,
|
||||
'email' => $email,
|
||||
],
|
||||
[
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => $user->id,
|
||||
'invited_by' => $request->user()?->id,
|
||||
'name' => $name ?? $user->name ?? $email,
|
||||
'role' => $role,
|
||||
'status' => $user->email_verified_at ? 'active' : 'invited',
|
||||
'invite_token' => $this->ensureInviteToken($event, $email),
|
||||
'invited_at' => $existing?->invited_at ?? now(),
|
||||
'joined_at' => $user->email_verified_at ? ($existing?->joined_at ?? now()) : null,
|
||||
'permissions' => $this->defaultPermissionsForRole($role),
|
||||
]
|
||||
);
|
||||
|
||||
$member->refresh();
|
||||
|
||||
return (new EventMemberResource($member))->response()->setStatusCode(201);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, Event $event, EventMember $member): JsonResponse
|
||||
{
|
||||
$this->assertEventTenant($request, $event);
|
||||
|
||||
if ((int) $member->event_id !== (int) $event->id) {
|
||||
throw ValidationException::withMessages([
|
||||
'member_id' => __('Dieses Mitglied gehört nicht zu diesem Event.'),
|
||||
]);
|
||||
}
|
||||
|
||||
$member->delete();
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
private function assertEventTenant(Request $request, Event $event): void
|
||||
{
|
||||
$tenantId = $request->attributes->get('tenant_id');
|
||||
if ($tenantId === null || (int) $event->tenant_id !== (int) $tenantId) {
|
||||
abort(403, 'Event belongs to a different tenant.');
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveTenantFromRequest(Request $request): ?Tenant
|
||||
{
|
||||
$tenant = $request->attributes->get('tenant');
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
$tenantId = $request->attributes->get('tenant_id');
|
||||
|
||||
return $tenantId ? Tenant::query()->find($tenantId) : null;
|
||||
}
|
||||
|
||||
private function findOrCreateUser(Tenant $tenant, string $email, ?string $name, string $role): User
|
||||
{
|
||||
$user = User::query()->where('email', $email)->first();
|
||||
|
||||
if (! $user) {
|
||||
$user = new User;
|
||||
$user->email = $email;
|
||||
$user->password = Hash::make(Str::random(32));
|
||||
}
|
||||
|
||||
if ($user->tenant_id && (int) $user->tenant_id !== (int) $tenant->id && $user->role !== 'super_admin') {
|
||||
throw ValidationException::withMessages([
|
||||
'email' => __('Dieser Benutzer ist einem anderen Mandanten zugeordnet.'),
|
||||
]);
|
||||
}
|
||||
|
||||
$user->tenant_id = $tenant->id;
|
||||
|
||||
if ($role === 'tenant_admin' && $user->role !== 'super_admin') {
|
||||
$user->role = 'tenant_admin';
|
||||
} elseif (! in_array($user->role, ['tenant_admin', 'super_admin'], true)) {
|
||||
$user->role = 'member';
|
||||
}
|
||||
|
||||
if ($name) {
|
||||
$user->name = $name;
|
||||
[$first, $last] = $this->splitName($name);
|
||||
$user->first_name = $first;
|
||||
$user->last_name = $last;
|
||||
}
|
||||
|
||||
if (! $user->exists) {
|
||||
$user->email_verified_at = null;
|
||||
}
|
||||
|
||||
$user->save();
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function splitName(string $name): array
|
||||
{
|
||||
$parts = preg_split('/\s+/', trim($name));
|
||||
$first = Arr::first($parts) ?? $name;
|
||||
$last = count($parts) > 1 ? Arr::last($parts) : null;
|
||||
|
||||
return [$first, $last];
|
||||
}
|
||||
|
||||
private function ensureInviteToken(Event $event, string $email): string
|
||||
{
|
||||
$existing = EventMember::query()
|
||||
->where('event_id', $event->id)
|
||||
->where('email', $email)
|
||||
->value('invite_token');
|
||||
|
||||
return $existing ?? Str::random(40);
|
||||
}
|
||||
|
||||
private function defaultPermissionsForRole(string $role): array
|
||||
{
|
||||
if ($role === 'tenant_admin') {
|
||||
return ['*'];
|
||||
}
|
||||
|
||||
return [
|
||||
'photos:moderate',
|
||||
'tasks:manage',
|
||||
'join-tokens:manage',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\Tenant;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Auth\TenantAdminTokenRequest;
|
||||
use App\Models\EventMember;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -33,11 +34,7 @@ class TenantAdminTokenController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
if (! in_array($user->role, ['tenant_admin', 'super_admin'], true)) {
|
||||
throw ValidationException::withMessages([
|
||||
'login' => [trans('auth.not_authorized')],
|
||||
]);
|
||||
}
|
||||
$this->ensureUserCanAccessPanel($user);
|
||||
|
||||
if ($user->email_verified_at === null) {
|
||||
throw ValidationException::withMessages([
|
||||
@@ -162,12 +159,7 @@ class TenantAdminTokenController extends Controller
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
if (! in_array($user->role, ['tenant_admin', 'super_admin'], true)) {
|
||||
return response()->json([
|
||||
'error' => 'forbidden',
|
||||
'message' => trans('auth.not_authorized'),
|
||||
], 403);
|
||||
}
|
||||
$this->ensureUserCanAccessPanel($user);
|
||||
|
||||
if ($user->email_verified_at === null) {
|
||||
return response()->json([
|
||||
@@ -197,12 +189,16 @@ class TenantAdminTokenController extends Controller
|
||||
*/
|
||||
private function resolveTokenAbilities(User $user): array
|
||||
{
|
||||
$abilities = ['tenant-admin'];
|
||||
$abilities = ['tenant-member'];
|
||||
|
||||
if ($user->tenant_id) {
|
||||
$abilities[] = 'tenant:'.$user->tenant_id;
|
||||
}
|
||||
|
||||
if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin'], true)) {
|
||||
$abilities[] = 'tenant-admin';
|
||||
}
|
||||
|
||||
if ($user->role === 'super_admin') {
|
||||
$abilities[] = 'super-admin';
|
||||
}
|
||||
@@ -222,4 +218,35 @@ class TenantAdminTokenController extends Controller
|
||||
|
||||
return [$token->plainTextToken, $abilities];
|
||||
}
|
||||
|
||||
private function ensureUserCanAccessPanel(User $user): void
|
||||
{
|
||||
if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin'], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($user->role === 'member' && $this->userHasCollaboratorMembership($user)) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'login' => [trans('auth.not_authorized')],
|
||||
]);
|
||||
}
|
||||
|
||||
private function userHasCollaboratorMembership(User $user): bool
|
||||
{
|
||||
if (! $user->tenant_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return EventMember::query()
|
||||
->where('tenant_id', $user->tenant_id)
|
||||
->where(function ($query) use ($user) {
|
||||
$query->where('user_id', $user->id)
|
||||
->orWhere('email', $user->email);
|
||||
})
|
||||
->whereIn('status', ['active', 'invited'])
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Support\ApiError;
|
||||
use Closure;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -30,12 +31,12 @@ class EnsureTenantAdminToken
|
||||
return $this->unauthorizedResponse('Missing personal access token context.');
|
||||
}
|
||||
|
||||
if (! in_array($user->role, ['tenant_admin', 'super_admin'], true)) {
|
||||
return $this->forbiddenResponse('Only tenant administrators may access this resource.');
|
||||
if (! in_array($user->role, $this->allowedRoles(), true)) {
|
||||
return $this->forbiddenResponse($this->forbiddenRoleMessage());
|
||||
}
|
||||
|
||||
if (! $accessToken->can('tenant-admin')) {
|
||||
return $this->forbiddenResponse('Access token does not include the tenant-admin ability.');
|
||||
if (! $this->hasRequiredAbilities($accessToken, $user)) {
|
||||
return $this->forbiddenResponse($this->abilityErrorMessage());
|
||||
}
|
||||
|
||||
/** @var Tenant|null $tenant */
|
||||
@@ -90,6 +91,40 @@ class EnsureTenantAdminToken
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
protected function allowedRoles(): array
|
||||
{
|
||||
return ['tenant_admin', 'super_admin', 'admin'];
|
||||
}
|
||||
|
||||
protected function forbiddenRoleMessage(): string
|
||||
{
|
||||
return 'Only tenant administrators may access this resource.';
|
||||
}
|
||||
|
||||
protected function requiredAbilitiesForUser(User $user): array
|
||||
{
|
||||
return ['tenant-admin'];
|
||||
}
|
||||
|
||||
protected function abilityErrorMessage(): string
|
||||
{
|
||||
return 'Access token does not include the tenant-admin ability.';
|
||||
}
|
||||
|
||||
protected function hasRequiredAbilities(PersonalAccessToken $accessToken, User $user): bool
|
||||
{
|
||||
foreach ($this->requiredAbilitiesForUser($user) as $ability) {
|
||||
if (! $accessToken->can($ability)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function resolveRequestedTenantId(Request $request): ?int
|
||||
{
|
||||
$routeTenant = $request->route('tenant');
|
||||
|
||||
33
app/Http/Middleware/EnsureTenantCollaboratorToken.php
Normal file
33
app/Http/Middleware/EnsureTenantCollaboratorToken.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\User;
|
||||
use Laravel\Sanctum\PersonalAccessToken;
|
||||
|
||||
class EnsureTenantCollaboratorToken extends EnsureTenantAdminToken
|
||||
{
|
||||
protected function allowedRoles(): array
|
||||
{
|
||||
return ['tenant_admin', 'super_admin', 'admin', 'member'];
|
||||
}
|
||||
|
||||
protected function forbiddenRoleMessage(): string
|
||||
{
|
||||
return 'Only tenant collaborators may access this resource.';
|
||||
}
|
||||
|
||||
protected function abilityErrorMessage(): string
|
||||
{
|
||||
return 'Access token does not include the tenant-member ability.';
|
||||
}
|
||||
|
||||
protected function hasRequiredAbilities(PersonalAccessToken $accessToken, User $user): bool
|
||||
{
|
||||
if ($accessToken->can('tenant-admin')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $accessToken->can('tenant-member');
|
||||
}
|
||||
}
|
||||
30
app/Http/Requests/Tenant/EventMemberInviteRequest.php
Normal file
30
app/Http/Requests/Tenant/EventMemberInviteRequest.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Tenant;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class EventMemberInviteRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'email' => ['required', 'email:rfc', 'max:255'],
|
||||
'name' => ['nullable', 'string', 'max:255'],
|
||||
'role' => ['required', 'string', 'in:tenant_admin,member'],
|
||||
];
|
||||
}
|
||||
}
|
||||
32
app/Http/Resources/Tenant/EventMemberResource.php
Normal file
32
app/Http/Resources/Tenant/EventMemberResource.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources\Tenant;
|
||||
|
||||
use App\Models\EventMember;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/** @mixin EventMember */
|
||||
class EventMemberResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name ?? $this->user?->full_name ?? $this->email,
|
||||
'email' => $this->email,
|
||||
'role' => $this->role,
|
||||
'status' => $this->status,
|
||||
'joined_at' => $this->joined_at,
|
||||
'invited_at' => $this->invited_at,
|
||||
'avatar_url' => $this->user?->profile_photo_url ?? null,
|
||||
'permissions' => $this->permissions,
|
||||
'user_id' => $this->user_id,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -107,6 +107,11 @@ class Event extends Model
|
||||
return $this->hasMany(EventJoinToken::class);
|
||||
}
|
||||
|
||||
public function members(): HasMany
|
||||
{
|
||||
return $this->hasMany(EventMember::class);
|
||||
}
|
||||
|
||||
public function hasActivePackage(): bool
|
||||
{
|
||||
return $this->eventPackage && $this->eventPackage->isActive();
|
||||
|
||||
57
app/Models/EventMember.php
Normal file
57
app/Models/EventMember.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/** @property array<string,mixed>|null $permissions */
|
||||
class EventMember extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\EventMemberFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'event_id',
|
||||
'user_id',
|
||||
'invited_by',
|
||||
'name',
|
||||
'email',
|
||||
'role',
|
||||
'status',
|
||||
'invite_token',
|
||||
'invited_at',
|
||||
'joined_at',
|
||||
'last_activity_at',
|
||||
'permissions',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'invited_at' => 'datetime',
|
||||
'joined_at' => 'datetime',
|
||||
'last_activity_at' => 'datetime',
|
||||
'permissions' => 'array',
|
||||
];
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
public function event(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Event::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function invitedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'invited_by');
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,11 @@ class Tenant extends Model
|
||||
);
|
||||
}
|
||||
|
||||
public function members(): HasMany
|
||||
{
|
||||
return $this->hasMany(EventMember::class);
|
||||
}
|
||||
|
||||
public function purchases(): HasMany
|
||||
{
|
||||
return $this->hasMany(PackagePurchase::class);
|
||||
|
||||
@@ -3,21 +3,22 @@
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Filament\Models\Contracts\FilamentUser;
|
||||
use Filament\Models\Contracts\HasName;
|
||||
use Filament\Models\Contracts\HasTenants as FilamentHasTenants;
|
||||
use Filament\Panel;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
use Filament\Models\Contracts\FilamentUser;
|
||||
use Filament\Models\Contracts\HasTenants as FilamentHasTenants;
|
||||
use Filament\Panel;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Filament\Models\Contracts\HasName;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Collection;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
|
||||
class User extends Authenticatable implements MustVerifyEmail, HasName, FilamentUser, FilamentHasTenants
|
||||
class User extends Authenticatable implements FilamentHasTenants, FilamentUser, HasName, MustVerifyEmail
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||
use HasApiTokens, HasFactory, Notifiable;
|
||||
@@ -38,6 +39,7 @@ class User extends Authenticatable implements MustVerifyEmail, HasName, Filament
|
||||
'address',
|
||||
'phone',
|
||||
'role',
|
||||
'tenant_id',
|
||||
'pending_purchase',
|
||||
];
|
||||
|
||||
@@ -93,15 +95,16 @@ class User extends Authenticatable implements MustVerifyEmail, HasName, Filament
|
||||
protected function fullName(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => trim(($this->first_name ?? '') . ' ' . ($this->last_name ?? '')) ?: $this->name,
|
||||
get: fn () => trim(($this->first_name ?? '').' '.($this->last_name ?? '')) ?: $this->name,
|
||||
);
|
||||
}
|
||||
|
||||
public function getFilamentName(): string
|
||||
{
|
||||
if ($this->first_name && $this->last_name) {
|
||||
return trim($this->first_name . ' ' . $this->last_name);
|
||||
return trim($this->first_name.' '.$this->last_name);
|
||||
}
|
||||
|
||||
return $this->username ?? $this->email ?? 'Unnamed User';
|
||||
}
|
||||
|
||||
@@ -138,7 +141,7 @@ class User extends Authenticatable implements MustVerifyEmail, HasName, Filament
|
||||
return (int) $tenant->getKey() === (int) $ownedTenant->getKey();
|
||||
}
|
||||
|
||||
public function getTenants(Panel $panel): array | Collection
|
||||
public function getTenants(Panel $panel): array|Collection
|
||||
{
|
||||
if ($this->role === 'super_admin') {
|
||||
return Tenant::query()->orderBy('name')->get();
|
||||
@@ -148,4 +151,9 @@ class User extends Authenticatable implements MustVerifyEmail, HasName, Filament
|
||||
|
||||
return $tenant ? collect([$tenant]) : collect();
|
||||
}
|
||||
|
||||
public function eventMemberships(): HasMany
|
||||
{
|
||||
return $this->hasMany(EventMember::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ class TenantAuth
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
if ($user && in_array($user->role, ['tenant_admin', 'admin', 'super_admin'], true)) {
|
||||
if ($user && in_array($user->role, ['tenant_admin', 'admin', 'super_admin', 'member'], true)) {
|
||||
if ($user->role !== 'super_admin' || (int) $user->tenant_id === (int) $tenantId) {
|
||||
return $user;
|
||||
}
|
||||
@@ -32,7 +32,7 @@ class TenantAuth
|
||||
|
||||
$user = User::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('role', ['tenant_admin', 'admin'])
|
||||
->whereIn('role', ['tenant_admin', 'admin', 'member'])
|
||||
->orderByDesc('email_verified_at')
|
||||
->orderBy('id')
|
||||
->first();
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use App\Http\Middleware\CreditCheckMiddleware;
|
||||
use App\Http\Middleware\EnsureTenantAdminToken;
|
||||
use App\Http\Middleware\EnsureTenantCollaboratorToken;
|
||||
use App\Http\Middleware\HandleAppearance;
|
||||
use App\Http\Middleware\HandleInertiaRequests;
|
||||
use App\Http\Middleware\SetLocaleFromUser;
|
||||
@@ -33,6 +34,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
'superadmin.auth' => \App\Http\Middleware\SuperAdminAuth::class,
|
||||
'credit.check' => CreditCheckMiddleware::class,
|
||||
'tenant.admin' => EnsureTenantAdminToken::class,
|
||||
'tenant.collaborator' => EnsureTenantCollaboratorToken::class,
|
||||
]);
|
||||
|
||||
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']);
|
||||
|
||||
77
database/factories/EventMemberFactory.php
Normal file
77
database/factories/EventMemberFactory.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\EventMember;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\EventMember>
|
||||
*/
|
||||
class EventMemberFactory extends Factory
|
||||
{
|
||||
protected $model = EventMember::class;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'tenant_id' => Tenant::factory(),
|
||||
'event_id' => Event::factory(),
|
||||
'user_id' => null,
|
||||
'invited_by' => null,
|
||||
'name' => $this->faker->name(),
|
||||
'email' => $this->faker->unique()->safeEmail(),
|
||||
'role' => 'member',
|
||||
'status' => 'invited',
|
||||
'invite_token' => Str::random(32),
|
||||
'invited_at' => now(),
|
||||
'joined_at' => null,
|
||||
'last_activity_at' => null,
|
||||
'permissions' => null,
|
||||
];
|
||||
}
|
||||
|
||||
public function admin(): static
|
||||
{
|
||||
return $this->state(fn () => [
|
||||
'role' => 'tenant_admin',
|
||||
'status' => 'active',
|
||||
'joined_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function withUser(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'user_id' => User::factory()->state([
|
||||
'role' => $attributes['role'] ?? 'member',
|
||||
]),
|
||||
'email' => $attributes['email'] ?? $this->faker->unique()->safeEmail(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function configure(): static
|
||||
{
|
||||
return $this->afterMaking(function (EventMember $member): void {
|
||||
$event = $member->event ?? Event::find($member->event_id);
|
||||
if ($event && $member->tenant_id !== $event->tenant_id) {
|
||||
$member->tenant_id = $event->tenant_id;
|
||||
}
|
||||
})->afterCreating(function (EventMember $member): void {
|
||||
$event = $member->event()->withoutGlobalScopes()->first();
|
||||
if ($event && $member->tenant_id !== $event->tenant_id) {
|
||||
$member->tenant_id = $event->tenant_id;
|
||||
$member->save();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('event_members', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('event_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('user_id')->nullable()->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('invited_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->string('name')->nullable();
|
||||
$table->string('email')->nullable();
|
||||
$table->string('role')->default('member');
|
||||
$table->string('status')->default('invited');
|
||||
$table->string('invite_token', 64)->nullable()->unique();
|
||||
$table->timestamp('invited_at')->nullable();
|
||||
$table->timestamp('joined_at')->nullable();
|
||||
$table->timestamp('last_activity_at')->nullable();
|
||||
$table->json('permissions')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['event_id', 'email']);
|
||||
$table->index(['tenant_id', 'role']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('event_members');
|
||||
}
|
||||
};
|
||||
@@ -331,10 +331,12 @@ export type EventMember = {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string | null;
|
||||
role: 'tenant_admin' | 'member' | 'guest' | string;
|
||||
role: 'tenant_admin' | 'member';
|
||||
status?: 'pending' | 'active' | 'invited' | string;
|
||||
joined_at?: string | null;
|
||||
avatar_url?: string | null;
|
||||
permissions?: string[] | null;
|
||||
user_id?: number | null;
|
||||
};
|
||||
|
||||
type EventListResponse = { data?: JsonValue[] };
|
||||
@@ -815,6 +817,12 @@ function normalizeMember(member: JsonValue): EventMember {
|
||||
status: member.status ?? 'active',
|
||||
joined_at: member.joined_at ?? member.created_at ?? null,
|
||||
avatar_url: member.avatar_url ?? member.avatar ?? null,
|
||||
permissions: Array.isArray(member.permissions)
|
||||
? (member.permissions as string[])
|
||||
: member.permissions
|
||||
? String(member.permissions).split(',').map((entry) => entry.trim())
|
||||
: null,
|
||||
user_id: member.user_id ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1695,7 +1703,10 @@ export async function getEventMembers(eventIdentifier: number | string, page = 1
|
||||
};
|
||||
}
|
||||
|
||||
export async function inviteEventMember(eventIdentifier: number | string, payload: { email: string; role?: string; name?: string }): Promise<EventMember> {
|
||||
export async function inviteEventMember(
|
||||
eventIdentifier: number | string,
|
||||
payload: { email: string; role: EventMember['role']; name?: string }
|
||||
): Promise<EventMember> {
|
||||
const response = await authorizedFetch(
|
||||
`/api/v1/tenant/events/${encodeURIComponent(String(eventIdentifier))}/members`,
|
||||
{
|
||||
|
||||
@@ -15,16 +15,20 @@ export type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated';
|
||||
export interface TenantProfile {
|
||||
id: number;
|
||||
tenant_id: number;
|
||||
role?: string | null;
|
||||
name?: string;
|
||||
slug?: string;
|
||||
email?: string | null;
|
||||
event_credits_balance?: number | null;
|
||||
features?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface AuthContextValue {
|
||||
status: AuthStatus;
|
||||
user: TenantProfile | null;
|
||||
abilities: string[];
|
||||
hasAbility: (ability: string) => boolean;
|
||||
refreshProfile: () => Promise<void>;
|
||||
logout: (options?: { redirect?: string }) => Promise<void>;
|
||||
applyToken: (token: string, abilities: string[]) => Promise<void>;
|
||||
@@ -80,6 +84,7 @@ async function exchangeSessionForToken(): Promise<{ token: string; abilities: st
|
||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [status, setStatus] = React.useState<AuthStatus>('loading');
|
||||
const [user, setUser] = React.useState<TenantProfile | null>(null);
|
||||
const [abilities, setAbilities] = React.useState<string[]>([]);
|
||||
const queryClient = useQueryClient();
|
||||
const profileQueryKey = React.useMemo(() => ['tenantProfile'], []);
|
||||
|
||||
@@ -88,6 +93,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
invalidateTenantApiCache();
|
||||
queryClient.removeQueries({ queryKey: profileQueryKey });
|
||||
setUser(null);
|
||||
setAbilities([]);
|
||||
setStatus('unauthenticated');
|
||||
}, [profileQueryKey, queryClient]);
|
||||
|
||||
@@ -127,6 +133,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
: data.user;
|
||||
|
||||
setUser(composed ?? null);
|
||||
setAbilities(Array.isArray(data?.abilities) ? data.abilities : []);
|
||||
setStatus('authenticated');
|
||||
} catch (error) {
|
||||
console.error('[Auth] Failed to refresh profile', error);
|
||||
@@ -141,8 +148,9 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
}
|
||||
}, [handleAuthFailure, profileQueryKey, queryClient]);
|
||||
|
||||
const applyToken = React.useCallback(async (token: string, abilities: string[]) => {
|
||||
storePersonalAccessToken(token, abilities);
|
||||
const applyToken = React.useCallback(async (token: string, tokenAbilities: string[]) => {
|
||||
storePersonalAccessToken(token, tokenAbilities);
|
||||
setAbilities(tokenAbilities);
|
||||
await refreshProfile();
|
||||
}, [refreshProfile]);
|
||||
|
||||
@@ -221,9 +229,11 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
}
|
||||
}, [handleAuthFailure]);
|
||||
|
||||
const hasAbility = React.useCallback((ability: string) => abilities.includes(ability), [abilities]);
|
||||
|
||||
const value = React.useMemo<AuthContextValue>(
|
||||
() => ({ status, user, refreshProfile, logout, applyToken }),
|
||||
[status, user, refreshProfile, logout, applyToken]
|
||||
() => ({ status, user, abilities, hasAbility, refreshProfile, logout, applyToken }),
|
||||
[status, user, abilities, hasAbility, refreshProfile, logout, applyToken]
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
|
||||
@@ -18,6 +18,7 @@ import { NotificationCenter } from './NotificationCenter';
|
||||
import { UserMenu } from './UserMenu';
|
||||
import { useEventContext } from '../context/EventContext';
|
||||
import { EventSwitcher, EventMenuBar } from './EventNav';
|
||||
import { useAuth } from '../auth/context';
|
||||
|
||||
type NavItem = {
|
||||
key: string;
|
||||
@@ -50,7 +51,7 @@ export function AdminLayout({ title, subtitle, actions, children }: AdminLayoutP
|
||||
const photosLabel = t('navigation.photos', { defaultValue: 'Fotos' });
|
||||
const settingsLabel = t('navigation.settings');
|
||||
|
||||
const navItems = React.useMemo<NavItem[]>(() => [
|
||||
const baseNavItems = React.useMemo<NavItem[]>(() => [
|
||||
{
|
||||
key: 'dashboard',
|
||||
to: ADMIN_HOME_PATH,
|
||||
@@ -85,6 +86,19 @@ export function AdminLayout({ title, subtitle, actions, children }: AdminLayoutP
|
||||
},
|
||||
], [eventsLabel, eventsPath, photosPath, photosLabel, settingsLabel, singleEvent, events.length, t]);
|
||||
|
||||
const { user } = useAuth();
|
||||
const isMember = user?.role === 'member';
|
||||
|
||||
const navItems = React.useMemo(
|
||||
() => baseNavItems.filter((item) => {
|
||||
if (!isMember) {
|
||||
return true;
|
||||
}
|
||||
return !['dashboard', 'settings'].includes(item.key);
|
||||
}),
|
||||
[baseNavItems, isMember],
|
||||
);
|
||||
|
||||
const prefetchers = React.useMemo(() => ({
|
||||
[ADMIN_HOME_PATH]: () =>
|
||||
Promise.all([
|
||||
|
||||
@@ -31,6 +31,8 @@ export function UserMenu() {
|
||||
const [pendingLocale, setPendingLocale] = React.useState<SupportedLocale | null>(null);
|
||||
const currentLocale = getCurrentLocale();
|
||||
|
||||
const isMember = user?.role === 'member';
|
||||
|
||||
const initials = React.useMemo(() => {
|
||||
if (user?.name) {
|
||||
return user.name
|
||||
@@ -94,6 +96,8 @@ export function UserMenu() {
|
||||
<User className="h-4 w-4" />
|
||||
{t('navigation.profile', { defaultValue: 'Profil' })}
|
||||
</DropdownMenuItem>
|
||||
{!isMember && (
|
||||
<>
|
||||
<DropdownMenuItem onSelect={() => goTo(ADMIN_SETTINGS_PATH)}>
|
||||
<Settings className="h-4 w-4" />
|
||||
{t('navigation.settings', { defaultValue: 'Einstellungen' })}
|
||||
@@ -102,6 +106,8 @@ export function UserMenu() {
|
||||
<CreditCard className="h-4 w-4" />
|
||||
{t('navigation.billing', { defaultValue: 'Billing' })}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuSub>
|
||||
|
||||
@@ -26,7 +26,7 @@ import { ADMIN_EVENTS_PATH } from '../constants';
|
||||
type InviteForm = {
|
||||
email: string;
|
||||
name: string;
|
||||
role: string;
|
||||
role: EventMember['role'];
|
||||
};
|
||||
|
||||
const emptyInvite: InviteForm = {
|
||||
@@ -68,7 +68,6 @@ export default function EventMembersPage() {
|
||||
() => ({
|
||||
tenant_admin: t('management.members.roles.tenantAdmin', 'Tenant-Admin'),
|
||||
member: t('management.members.roles.member', 'Mitglied'),
|
||||
guest: t('management.members.roles.guest', 'Gast'),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
@@ -275,6 +274,12 @@ export default function EventMembersPage() {
|
||||
<Users className="h-4 w-4 text-emerald-500" />
|
||||
{t('management.members.sections.invite.title', 'Neues Mitglied einladen')}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500">
|
||||
{t(
|
||||
'management.members.sections.invite.helper',
|
||||
'Mitglieder erhalten Zugriff auf Fotomoderation, Aufgaben und QR-Einladungen. Admins steuern zusätzlich Pakete, Abrechnung und Events.'
|
||||
)}
|
||||
</p>
|
||||
<form className="space-y-3 rounded-2xl border border-emerald-100 bg-white/90 p-4 shadow-sm" onSubmit={handleInvite}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invite-email">{t('management.members.form.emailLabel', 'E-Mail')}</Label>
|
||||
@@ -308,7 +313,6 @@ export default function EventMembersPage() {
|
||||
<SelectContent>
|
||||
<SelectItem value="tenant_admin">{roleLabels.tenant_admin}</SelectItem>
|
||||
<SelectItem value="member">{roleLabels.member}</SelectItem>
|
||||
<SelectItem value="guest">{roleLabels.guest}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
import { useAuth } from '../auth/context';
|
||||
import { ADMIN_DEFAULT_AFTER_LOGIN_PATH } from '../constants';
|
||||
import { ADMIN_DEFAULT_AFTER_LOGIN_PATH, ADMIN_EVENTS_PATH } from '../constants';
|
||||
import { encodeReturnTo, resolveReturnTarget } from '../lib/returnTo';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
@@ -48,7 +48,7 @@ type LoginResponse = {
|
||||
}
|
||||
|
||||
export default function LoginPage(): JSX.Element {
|
||||
const { status, applyToken } = useAuth();
|
||||
const { status, applyToken, abilities } = useAuth();
|
||||
const { t } = useTranslation('auth');
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
@@ -56,7 +56,15 @@ export default function LoginPage(): JSX.Element {
|
||||
const searchParams = React.useMemo(() => new URLSearchParams(location.search), [location.search]);
|
||||
const rawReturnTo = searchParams.get('return_to');
|
||||
|
||||
const fallbackTarget = ADMIN_DEFAULT_AFTER_LOGIN_PATH;
|
||||
const computeDefaultAfterLogin = React.useCallback(
|
||||
(abilityList?: string[]) => {
|
||||
const source = abilityList ?? abilities;
|
||||
return source.includes('tenant-admin') ? ADMIN_DEFAULT_AFTER_LOGIN_PATH : ADMIN_EVENTS_PATH;
|
||||
},
|
||||
[abilities],
|
||||
);
|
||||
|
||||
const fallbackTarget = computeDefaultAfterLogin();
|
||||
const { finalTarget } = React.useMemo(
|
||||
() => resolveReturnTarget(rawReturnTo, fallbackTarget),
|
||||
[rawReturnTo, fallbackTarget]
|
||||
@@ -82,7 +90,9 @@ export default function LoginPage(): JSX.Element {
|
||||
onSuccess: async (data) => {
|
||||
setError(null);
|
||||
await applyToken(data.token, data.abilities ?? []);
|
||||
navigate(finalTarget, { replace: true });
|
||||
const postLoginFallback = computeDefaultAfterLogin(data.abilities ?? []);
|
||||
const { finalTarget: successTarget } = resolveReturnTarget(rawReturnTo, postLoginFallback);
|
||||
navigate(successTarget, { replace: true });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useAuth } from './auth/context';
|
||||
import {
|
||||
ADMIN_BASE_PATH,
|
||||
ADMIN_DEFAULT_AFTER_LOGIN_PATH,
|
||||
ADMIN_EVENTS_PATH,
|
||||
ADMIN_HOME_PATH,
|
||||
ADMIN_LOGIN_PATH,
|
||||
ADMIN_LOGIN_START_PATH,
|
||||
@@ -56,7 +57,7 @@ function RequireAuth() {
|
||||
}
|
||||
|
||||
function LandingGate() {
|
||||
const { status } = useAuth();
|
||||
const { status, user } = useAuth();
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
@@ -67,12 +68,23 @@ function LandingGate() {
|
||||
}
|
||||
|
||||
if (status === 'authenticated') {
|
||||
return <Navigate to={ADMIN_DEFAULT_AFTER_LOGIN_PATH} replace />;
|
||||
const target = user?.role === 'member' ? ADMIN_EVENTS_PATH : ADMIN_DEFAULT_AFTER_LOGIN_PATH;
|
||||
return <Navigate to={target} replace />;
|
||||
}
|
||||
|
||||
return <WelcomeTeaserPage />;
|
||||
}
|
||||
|
||||
function RequireAdminAccess({ children }: { children: React.ReactNode }) {
|
||||
const { user } = useAuth();
|
||||
|
||||
if (user?.role === 'member') {
|
||||
return <Navigate to={ADMIN_EVENTS_PATH} replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
path: ADMIN_BASE_PATH,
|
||||
@@ -86,13 +98,13 @@ export const router = createBrowserRouter([
|
||||
{
|
||||
element: <RequireAuth />,
|
||||
children: [
|
||||
{ path: 'dashboard', element: <DashboardPage /> },
|
||||
{ path: 'dashboard', element: <RequireAdminAccess><DashboardPage /></RequireAdminAccess> },
|
||||
{ path: 'events', element: <EventsPage /> },
|
||||
{ path: 'events/new', element: <EventFormPage /> },
|
||||
{ path: 'events/new', element: <RequireAdminAccess><EventFormPage /></RequireAdminAccess> },
|
||||
{ path: 'events/:slug', element: <EventDetailPage /> },
|
||||
{ path: 'events/:slug/edit', element: <EventFormPage /> },
|
||||
{ path: 'events/:slug/edit', element: <RequireAdminAccess><EventFormPage /></RequireAdminAccess> },
|
||||
{ path: 'events/:slug/photos', element: <EventPhotosPage /> },
|
||||
{ path: 'events/:slug/members', element: <EventMembersPage /> },
|
||||
{ path: 'events/:slug/members', element: <RequireAdminAccess><EventMembersPage /></RequireAdminAccess> },
|
||||
{ path: 'events/:slug/tasks', element: <EventTasksPage /> },
|
||||
{ path: 'events/:slug/invites', element: <EventInvitesPage /> },
|
||||
{ path: 'events/:slug/toolkit', element: <EventToolkitPage /> },
|
||||
@@ -100,14 +112,14 @@ export const router = createBrowserRouter([
|
||||
{ path: 'tasks', element: <TasksPage /> },
|
||||
{ path: 'task-collections', element: <TaskCollectionsPage /> },
|
||||
{ path: 'emotions', element: <EmotionsPage /> },
|
||||
{ path: 'billing', element: <BillingPage /> },
|
||||
{ path: 'settings', element: <SettingsPage /> },
|
||||
{ path: 'billing', element: <RequireAdminAccess><BillingPage /></RequireAdminAccess> },
|
||||
{ path: 'settings', element: <RequireAdminAccess><SettingsPage /></RequireAdminAccess> },
|
||||
{ path: 'faq', element: <FaqPage /> },
|
||||
{ path: 'settings/profile', element: <ProfilePage /> },
|
||||
{ path: 'welcome', element: <WelcomeLandingPage /> },
|
||||
{ path: 'welcome/packages', element: <WelcomePackagesPage /> },
|
||||
{ path: 'welcome/summary', element: <WelcomeOrderSummaryPage /> },
|
||||
{ path: 'welcome/event', element: <WelcomeEventSetupPage /> },
|
||||
{ path: 'settings/profile', element: <RequireAdminAccess><ProfilePage /></RequireAdminAccess> },
|
||||
{ path: 'welcome', element: <RequireAdminAccess><WelcomeLandingPage /></RequireAdminAccess> },
|
||||
{ path: 'welcome/packages', element: <RequireAdminAccess><WelcomePackagesPage /></RequireAdminAccess> },
|
||||
{ path: 'welcome/summary', element: <RequireAdminAccess><WelcomeOrderSummaryPage /></RequireAdminAccess> },
|
||||
{ path: 'welcome/event', element: <RequireAdminAccess><WelcomeEventSetupPage /></RequireAdminAccess> },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Http\Controllers\Api\Tenant\EmotionController;
|
||||
use App\Http\Controllers\Api\Tenant\EventController;
|
||||
use App\Http\Controllers\Api\Tenant\EventJoinTokenController;
|
||||
use App\Http\Controllers\Api\Tenant\EventJoinTokenLayoutController;
|
||||
use App\Http\Controllers\Api\Tenant\EventMemberController;
|
||||
use App\Http\Controllers\Api\Tenant\EventTypeController;
|
||||
use App\Http\Controllers\Api\Tenant\NotificationLogController;
|
||||
use App\Http\Controllers\Api\Tenant\OnboardingController;
|
||||
@@ -80,29 +81,46 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
||||
->name('gallery.photos.asset');
|
||||
});
|
||||
|
||||
Route::middleware(['auth:sanctum', 'tenant.admin', 'tenant.isolation', 'throttle:tenant-api'])->prefix('tenant')->group(function () {
|
||||
Route::get('profile', [ProfileController::class, 'show'])->name('tenant.profile.show');
|
||||
Route::put('profile', [ProfileController::class, 'update'])->name('tenant.profile.update');
|
||||
Route::get('onboarding', [OnboardingController::class, 'show'])->name('tenant.onboarding.show');
|
||||
Route::post('onboarding', [OnboardingController::class, 'store'])->name('tenant.onboarding.store');
|
||||
Route::get('me', [TenantAdminTokenController::class, 'legacyTenantMe'])->name('tenant.me');
|
||||
Route::get('dashboard', DashboardController::class)->name('tenant.dashboard');
|
||||
Route::middleware(['auth:sanctum', 'tenant.collaborator', 'tenant.isolation', 'throttle:tenant-api'])->prefix('tenant')->group(function () {
|
||||
Route::get('profile', [ProfileController::class, 'show'])
|
||||
->middleware('tenant.admin')
|
||||
->name('tenant.profile.show');
|
||||
Route::put('profile', [ProfileController::class, 'update'])
|
||||
->middleware('tenant.admin')
|
||||
->name('tenant.profile.update');
|
||||
Route::get('onboarding', [OnboardingController::class, 'show'])
|
||||
->middleware('tenant.admin')
|
||||
->name('tenant.onboarding.show');
|
||||
Route::post('onboarding', [OnboardingController::class, 'store'])
|
||||
->middleware('tenant.admin')
|
||||
->name('tenant.onboarding.store');
|
||||
Route::get('me', [TenantAdminTokenController::class, 'legacyTenantMe'])
|
||||
->name('tenant.me');
|
||||
Route::get('dashboard', DashboardController::class)
|
||||
->middleware('tenant.admin')
|
||||
->name('tenant.dashboard');
|
||||
Route::get('event-types', EventTypeController::class)->name('tenant.event-types.index');
|
||||
|
||||
Route::apiResource('events', EventController::class)
|
||||
->only(['index', 'show', 'destroy'])
|
||||
->parameters(['events' => 'event:slug']);
|
||||
Route::get('events', [EventController::class, 'index'])
|
||||
->name('tenant.events.index');
|
||||
Route::get('events/{event:slug}', [EventController::class, 'show'])
|
||||
->name('tenant.events.show');
|
||||
Route::delete('events/{event:slug}', [EventController::class, 'destroy'])
|
||||
->middleware('tenant.admin')
|
||||
->name('tenant.events.destroy');
|
||||
|
||||
Route::middleware(['package.check', 'credit.check'])->group(function () {
|
||||
Route::middleware(['package.check', 'credit.check', 'tenant.admin'])->group(function () {
|
||||
Route::post('events', [EventController::class, 'store'])->name('tenant.events.store');
|
||||
Route::match(['put', 'patch'], 'events/{event:slug}', [EventController::class, 'update'])->name('tenant.events.update');
|
||||
});
|
||||
|
||||
Route::prefix('events/{event:slug}')->scopeBindings()->group(function () {
|
||||
Route::middleware('tenant.admin')->group(function () {
|
||||
Route::get('stats', [EventController::class, 'stats'])->name('tenant.events.stats');
|
||||
Route::post('toggle', [EventController::class, 'toggle'])->name('tenant.events.toggle');
|
||||
Route::post('invites', [EventController::class, 'createInvite'])->name('tenant.events.invites');
|
||||
Route::get('toolkit', [EventController::class, 'toolkit'])->name('tenant.events.toolkit');
|
||||
});
|
||||
|
||||
Route::prefix('join-tokens')->group(function () {
|
||||
Route::get('/', [EventJoinTokenController::class, 'index'])->name('tenant.events.join-tokens.index');
|
||||
@@ -122,21 +140,37 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
||||
->name('tenant.events.join-tokens.destroy');
|
||||
});
|
||||
|
||||
Route::get('photos', [PhotoController::class, 'index'])->name('tenant.events.photos.index');
|
||||
Route::post('photos', [PhotoController::class, 'store'])->name('tenant.events.photos.store');
|
||||
Route::get('photos/{photo}', [PhotoController::class, 'show'])->name('tenant.events.photos.show');
|
||||
Route::patch('photos/{photo}', [PhotoController::class, 'update'])->name('tenant.events.photos.update');
|
||||
Route::delete('photos/{photo}', [PhotoController::class, 'destroy'])->name('tenant.events.photos.destroy');
|
||||
Route::post('photos/{photo}/feature', [PhotoController::class, 'feature'])->name('tenant.events.photos.feature');
|
||||
Route::post('photos/{photo}/unfeature', [PhotoController::class, 'unfeature'])->name('tenant.events.photos.unfeature');
|
||||
Route::post('photos/bulk-approve', [PhotoController::class, 'bulkApprove'])->name('tenant.events.photos.bulk-approve');
|
||||
Route::post('photos/bulk-reject', [PhotoController::class, 'bulkReject'])->name('tenant.events.photos.bulk-reject');
|
||||
Route::get('photos/moderation', [PhotoController::class, 'forModeration'])->name('tenant.events.photos.for-moderation');
|
||||
Route::get('photos/stats', [PhotoController::class, 'stats'])->name('tenant.events.photos.stats');
|
||||
Route::prefix('photos')->group(function () {
|
||||
Route::get('/', [PhotoController::class, 'index'])->name('tenant.events.photos.index');
|
||||
Route::post('/', [PhotoController::class, 'store'])->name('tenant.events.photos.store');
|
||||
Route::get('{photo}', [PhotoController::class, 'show'])->name('tenant.events.photos.show');
|
||||
Route::patch('{photo}', [PhotoController::class, 'update'])->name('tenant.events.photos.update');
|
||||
Route::delete('{photo}', [PhotoController::class, 'destroy'])->name('tenant.events.photos.destroy');
|
||||
Route::post('{photo}/feature', [PhotoController::class, 'feature'])->name('tenant.events.photos.feature');
|
||||
Route::post('{photo}/unfeature', [PhotoController::class, 'unfeature'])->name('tenant.events.photos.unfeature');
|
||||
Route::post('bulk-approve', [PhotoController::class, 'bulkApprove'])->name('tenant.events.photos.bulk-approve');
|
||||
Route::post('bulk-reject', [PhotoController::class, 'bulkReject'])->name('tenant.events.photos.bulk-reject');
|
||||
Route::get('moderation', [PhotoController::class, 'forModeration'])->name('tenant.events.photos.for-moderation');
|
||||
Route::get('stats', [PhotoController::class, 'stats'])->name('tenant.events.photos.stats');
|
||||
});
|
||||
|
||||
Route::post('events/bulk-status', [EventController::class, 'bulkUpdateStatus'])->name('tenant.events.bulk-status');
|
||||
Route::get('events/search', [EventController::class, 'search'])->name('tenant.events.search');
|
||||
Route::get('members', [EventMemberController::class, 'index'])
|
||||
->middleware('tenant.admin')
|
||||
->name('tenant.events.members.index');
|
||||
Route::post('members', [EventMemberController::class, 'store'])
|
||||
->middleware('tenant.admin')
|
||||
->name('tenant.events.members.store');
|
||||
Route::delete('members/{member}', [EventMemberController::class, 'destroy'])
|
||||
->middleware('tenant.admin')
|
||||
->whereNumber('member')
|
||||
->name('tenant.events.members.destroy');
|
||||
});
|
||||
|
||||
Route::post('events/bulk-status', [EventController::class, 'bulkUpdateStatus'])
|
||||
->middleware('tenant.admin')
|
||||
->name('tenant.events.bulk-status');
|
||||
Route::get('events/search', [EventController::class, 'search'])
|
||||
->name('tenant.events.search');
|
||||
|
||||
Route::apiResource('tasks', TaskController::class);
|
||||
Route::post('tasks/{task}/assign-event/{event}', [TaskController::class, 'assignToEvent'])
|
||||
@@ -155,11 +189,13 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
||||
Route::post('task-collections/{collection}/activate', [TaskCollectionController::class, 'activate'])
|
||||
->name('tenant.task-collections.activate');
|
||||
|
||||
Route::middleware('tenant.admin')->group(function () {
|
||||
Route::get('emotions', [EmotionController::class, 'index'])->name('tenant.emotions.index');
|
||||
Route::post('emotions', [EmotionController::class, 'store'])->name('tenant.emotions.store');
|
||||
Route::patch('emotions/{emotion}', [EmotionController::class, 'update'])->name('tenant.emotions.update');
|
||||
});
|
||||
|
||||
Route::prefix('settings')->group(function () {
|
||||
Route::prefix('settings')->middleware('tenant.admin')->group(function () {
|
||||
Route::get('/', [SettingsController::class, 'index'])
|
||||
->name('tenant.settings.index');
|
||||
Route::post('/', [SettingsController::class, 'update'])
|
||||
@@ -175,9 +211,10 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
||||
});
|
||||
|
||||
Route::get('notifications/logs', [NotificationLogController::class, 'index'])
|
||||
->middleware('tenant.admin')
|
||||
->name('tenant.notifications.logs.index');
|
||||
|
||||
Route::prefix('credits')->group(function () {
|
||||
Route::prefix('credits')->middleware('tenant.admin')->group(function () {
|
||||
Route::get('balance', [CreditController::class, 'balance'])->name('tenant.credits.balance');
|
||||
Route::get('ledger', [CreditController::class, 'ledger'])->name('tenant.credits.ledger');
|
||||
Route::get('history', [CreditController::class, 'history'])->name('tenant.credits.history');
|
||||
@@ -185,7 +222,7 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
||||
Route::post('sync', [CreditController::class, 'sync'])->name('tenant.credits.sync');
|
||||
});
|
||||
|
||||
Route::prefix('packages')->group(function () {
|
||||
Route::prefix('packages')->middleware('tenant.admin')->group(function () {
|
||||
Route::get('/', [PackageController::class, 'index'])->name('packages.index');
|
||||
Route::post('/purchase', [PackageController::class, 'purchase'])->name('packages.purchase');
|
||||
Route::post('/payment-intent', [PackageController::class, 'createPaymentIntent'])->name('packages.payment-intent');
|
||||
@@ -194,11 +231,12 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
||||
Route::post('/paddle-checkout', [PackageController::class, 'createPaddleCheckout'])->name('packages.paddle-checkout');
|
||||
});
|
||||
|
||||
Route::prefix('tenant/packages')->group(function () {
|
||||
Route::prefix('tenant/packages')->middleware('tenant.admin')->group(function () {
|
||||
Route::get('/', [TenantPackageController::class, 'index'])->name('tenant.packages.index');
|
||||
});
|
||||
|
||||
Route::get('tenant/billing/transactions', [TenantBillingController::class, 'transactions'])
|
||||
->middleware('tenant.admin')
|
||||
->name('tenant.billing.transactions');
|
||||
|
||||
Route::post('feedback', [TenantFeedbackController::class, 'store'])
|
||||
|
||||
81
tests/Feature/Tenant/EventMemberControllerTest.php
Normal file
81
tests/Feature/Tenant/EventMemberControllerTest.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Tenant;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\EventMember;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class EventMemberControllerTest extends TenantTestCase
|
||||
{
|
||||
public function test_admin_can_invite_member(): void
|
||||
{
|
||||
$event = Event::factory()->for($this->tenant)->create([
|
||||
'slug' => 'collab-event',
|
||||
]);
|
||||
|
||||
$payload = [
|
||||
'email' => 'new.member@example.com',
|
||||
'name' => 'New Member',
|
||||
'role' => 'member',
|
||||
];
|
||||
|
||||
$response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/members", $payload);
|
||||
|
||||
$response->assertCreated();
|
||||
$response->assertJsonPath('data.email', 'new.member@example.com');
|
||||
$response->assertJsonPath('data.role', 'member');
|
||||
|
||||
$this->assertDatabaseHas(EventMember::class, [
|
||||
'event_id' => $event->id,
|
||||
'email' => 'new.member@example.com',
|
||||
'role' => 'member',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas(User::class, [
|
||||
'email' => 'new.member@example.com',
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'role' => 'member',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_member_token_can_access_photo_moderation_routes(): void
|
||||
{
|
||||
$event = Event::factory()->for($this->tenant)->create([
|
||||
'slug' => 'moderate-event',
|
||||
]);
|
||||
|
||||
$memberUser = User::factory()->create([
|
||||
'email' => 'member@example.com',
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'role' => 'member',
|
||||
'password' => Hash::make('secret123'),
|
||||
]);
|
||||
|
||||
EventMember::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'event_id' => $event->id,
|
||||
'user_id' => $memberUser->id,
|
||||
'email' => $memberUser->email,
|
||||
'role' => 'member',
|
||||
'status' => 'active',
|
||||
'permissions' => ['photos:moderate'],
|
||||
]);
|
||||
|
||||
$login = $this->postJson('/api/v1/tenant-auth/login', [
|
||||
'login' => $memberUser->email,
|
||||
'password' => 'secret123',
|
||||
]);
|
||||
|
||||
$login->assertOk();
|
||||
$token = $login->json('token');
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
])->getJson("/api/v1/tenant/events/{$event->slug}/photos");
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonStructure(['data']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user