added "members" for an event that help the admins to moderate. members must be invited via email.

This commit is contained in:
Codex Agent
2025-11-09 22:24:40 +01:00
parent 082b78cd43
commit 7ec3db9c59
23 changed files with 836 additions and 101 deletions

View 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',
];
}
}

View File

@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\Tenant;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\TenantAdminTokenRequest; use App\Http\Requests\Auth\TenantAdminTokenRequest;
use App\Models\EventMember;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -33,11 +34,7 @@ class TenantAdminTokenController extends Controller
]); ]);
} }
if (! in_array($user->role, ['tenant_admin', 'super_admin'], true)) { $this->ensureUserCanAccessPanel($user);
throw ValidationException::withMessages([
'login' => [trans('auth.not_authorized')],
]);
}
if ($user->email_verified_at === null) { if ($user->email_verified_at === null) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
@@ -162,12 +159,7 @@ class TenantAdminTokenController extends Controller
return response()->noContent(); return response()->noContent();
} }
if (! in_array($user->role, ['tenant_admin', 'super_admin'], true)) { $this->ensureUserCanAccessPanel($user);
return response()->json([
'error' => 'forbidden',
'message' => trans('auth.not_authorized'),
], 403);
}
if ($user->email_verified_at === null) { if ($user->email_verified_at === null) {
return response()->json([ return response()->json([
@@ -197,12 +189,16 @@ class TenantAdminTokenController extends Controller
*/ */
private function resolveTokenAbilities(User $user): array private function resolveTokenAbilities(User $user): array
{ {
$abilities = ['tenant-admin']; $abilities = ['tenant-member'];
if ($user->tenant_id) { if ($user->tenant_id) {
$abilities[] = 'tenant:'.$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') { if ($user->role === 'super_admin') {
$abilities[] = 'super-admin'; $abilities[] = 'super-admin';
} }
@@ -222,4 +218,35 @@ class TenantAdminTokenController extends Controller
return [$token->plainTextToken, $abilities]; 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();
}
} }

View File

@@ -3,6 +3,7 @@
namespace App\Http\Middleware; namespace App\Http\Middleware;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User;
use App\Support\ApiError; use App\Support\ApiError;
use Closure; use Closure;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -30,12 +31,12 @@ class EnsureTenantAdminToken
return $this->unauthorizedResponse('Missing personal access token context.'); return $this->unauthorizedResponse('Missing personal access token context.');
} }
if (! in_array($user->role, ['tenant_admin', 'super_admin'], true)) { if (! in_array($user->role, $this->allowedRoles(), true)) {
return $this->forbiddenResponse('Only tenant administrators may access this resource.'); return $this->forbiddenResponse($this->forbiddenRoleMessage());
} }
if (! $accessToken->can('tenant-admin')) { if (! $this->hasRequiredAbilities($accessToken, $user)) {
return $this->forbiddenResponse('Access token does not include the tenant-admin ability.'); return $this->forbiddenResponse($this->abilityErrorMessage());
} }
/** @var Tenant|null $tenant */ /** @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 private function resolveRequestedTenantId(Request $request): ?int
{ {
$routeTenant = $request->route('tenant'); $routeTenant = $request->route('tenant');

View 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');
}
}

View 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'],
];
}
}

View 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,
];
}
}

View File

@@ -107,6 +107,11 @@ class Event extends Model
return $this->hasMany(EventJoinToken::class); return $this->hasMany(EventJoinToken::class);
} }
public function members(): HasMany
{
return $this->hasMany(EventMember::class);
}
public function hasActivePackage(): bool public function hasActivePackage(): bool
{ {
return $this->eventPackage && $this->eventPackage->isActive(); return $this->eventPackage && $this->eventPackage->isActive();

View 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');
}
}

View File

@@ -49,6 +49,11 @@ class Tenant extends Model
); );
} }
public function members(): HasMany
{
return $this->hasMany(EventMember::class);
}
public function purchases(): HasMany public function purchases(): HasMany
{ {
return $this->hasMany(PackagePurchase::class); return $this->hasMany(PackagePurchase::class);

View File

@@ -3,21 +3,22 @@
namespace App\Models; namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail; // 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\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; 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\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; 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 HasFactory<\Database\Factories\UserFactory> */
use HasApiTokens, HasFactory, Notifiable; use HasApiTokens, HasFactory, Notifiable;
@@ -38,6 +39,7 @@ class User extends Authenticatable implements MustVerifyEmail, HasName, Filament
'address', 'address',
'phone', 'phone',
'role', 'role',
'tenant_id',
'pending_purchase', 'pending_purchase',
]; ];
@@ -77,7 +79,7 @@ class User extends Authenticatable implements MustVerifyEmail, HasName, Filament
if (isset($credentials['login'])) { if (isset($credentials['login'])) {
$login = $credentials['login']; $login = $credentials['login'];
$query->where('email', $login) $query->where('email', $login)
->orWhere('username', $login); ->orWhere('username', $login);
} else { } else {
foreach ($this->getAuthIdentifiers() as $key => $value) { foreach ($this->getAuthIdentifiers() as $key => $value) {
$query->where($key, $value); $query->where($key, $value);
@@ -93,15 +95,16 @@ class User extends Authenticatable implements MustVerifyEmail, HasName, Filament
protected function fullName(): Attribute protected function fullName(): Attribute
{ {
return Attribute::make( 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 public function getFilamentName(): string
{ {
if ($this->first_name && $this->last_name) { 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'; 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(); 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') { if ($this->role === 'super_admin') {
return Tenant::query()->orderBy('name')->get(); return Tenant::query()->orderBy('name')->get();
@@ -148,4 +151,9 @@ class User extends Authenticatable implements MustVerifyEmail, HasName, Filament
return $tenant ? collect([$tenant]) : collect(); return $tenant ? collect([$tenant]) : collect();
} }
public function eventMemberships(): HasMany
{
return $this->hasMany(EventMember::class);
}
} }

View File

@@ -24,7 +24,7 @@ class TenantAuth
} }
$user = $request->user(); $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) { if ($user->role !== 'super_admin' || (int) $user->tenant_id === (int) $tenantId) {
return $user; return $user;
} }
@@ -32,7 +32,7 @@ class TenantAuth
$user = User::query() $user = User::query()
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->whereIn('role', ['tenant_admin', 'admin']) ->whereIn('role', ['tenant_admin', 'admin', 'member'])
->orderByDesc('email_verified_at') ->orderByDesc('email_verified_at')
->orderBy('id') ->orderBy('id')
->first(); ->first();

View File

@@ -2,6 +2,7 @@
use App\Http\Middleware\CreditCheckMiddleware; use App\Http\Middleware\CreditCheckMiddleware;
use App\Http\Middleware\EnsureTenantAdminToken; use App\Http\Middleware\EnsureTenantAdminToken;
use App\Http\Middleware\EnsureTenantCollaboratorToken;
use App\Http\Middleware\HandleAppearance; use App\Http\Middleware\HandleAppearance;
use App\Http\Middleware\HandleInertiaRequests; use App\Http\Middleware\HandleInertiaRequests;
use App\Http\Middleware\SetLocaleFromUser; use App\Http\Middleware\SetLocaleFromUser;
@@ -33,6 +34,7 @@ return Application::configure(basePath: dirname(__DIR__))
'superadmin.auth' => \App\Http\Middleware\SuperAdminAuth::class, 'superadmin.auth' => \App\Http\Middleware\SuperAdminAuth::class,
'credit.check' => CreditCheckMiddleware::class, 'credit.check' => CreditCheckMiddleware::class,
'tenant.admin' => EnsureTenantAdminToken::class, 'tenant.admin' => EnsureTenantAdminToken::class,
'tenant.collaborator' => EnsureTenantCollaboratorToken::class,
]); ]);
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']); $middleware->encryptCookies(except: ['appearance', 'sidebar_state']);

View 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();
}
});
}
}

View File

@@ -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');
}
};

View File

@@ -331,10 +331,12 @@ export type EventMember = {
id: number; id: number;
name: string; name: string;
email: string | null; email: string | null;
role: 'tenant_admin' | 'member' | 'guest' | string; role: 'tenant_admin' | 'member';
status?: 'pending' | 'active' | 'invited' | string; status?: 'pending' | 'active' | 'invited' | string;
joined_at?: string | null; joined_at?: string | null;
avatar_url?: string | null; avatar_url?: string | null;
permissions?: string[] | null;
user_id?: number | null;
}; };
type EventListResponse = { data?: JsonValue[] }; type EventListResponse = { data?: JsonValue[] };
@@ -815,6 +817,12 @@ function normalizeMember(member: JsonValue): EventMember {
status: member.status ?? 'active', status: member.status ?? 'active',
joined_at: member.joined_at ?? member.created_at ?? null, joined_at: member.joined_at ?? member.created_at ?? null,
avatar_url: member.avatar_url ?? member.avatar ?? 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( const response = await authorizedFetch(
`/api/v1/tenant/events/${encodeURIComponent(String(eventIdentifier))}/members`, `/api/v1/tenant/events/${encodeURIComponent(String(eventIdentifier))}/members`,
{ {

View File

@@ -15,16 +15,20 @@ export type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated';
export interface TenantProfile { export interface TenantProfile {
id: number; id: number;
tenant_id: number; tenant_id: number;
role?: string | null;
name?: string; name?: string;
slug?: string; slug?: string;
email?: string | null; email?: string | null;
event_credits_balance?: number | null; event_credits_balance?: number | null;
features?: Record<string, unknown>;
[key: string]: unknown; [key: string]: unknown;
} }
interface AuthContextValue { interface AuthContextValue {
status: AuthStatus; status: AuthStatus;
user: TenantProfile | null; user: TenantProfile | null;
abilities: string[];
hasAbility: (ability: string) => boolean;
refreshProfile: () => Promise<void>; refreshProfile: () => Promise<void>;
logout: (options?: { redirect?: string }) => Promise<void>; logout: (options?: { redirect?: string }) => Promise<void>;
applyToken: (token: string, abilities: 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 }) => { export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [status, setStatus] = React.useState<AuthStatus>('loading'); const [status, setStatus] = React.useState<AuthStatus>('loading');
const [user, setUser] = React.useState<TenantProfile | null>(null); const [user, setUser] = React.useState<TenantProfile | null>(null);
const [abilities, setAbilities] = React.useState<string[]>([]);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const profileQueryKey = React.useMemo(() => ['tenantProfile'], []); const profileQueryKey = React.useMemo(() => ['tenantProfile'], []);
@@ -88,6 +93,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
invalidateTenantApiCache(); invalidateTenantApiCache();
queryClient.removeQueries({ queryKey: profileQueryKey }); queryClient.removeQueries({ queryKey: profileQueryKey });
setUser(null); setUser(null);
setAbilities([]);
setStatus('unauthenticated'); setStatus('unauthenticated');
}, [profileQueryKey, queryClient]); }, [profileQueryKey, queryClient]);
@@ -127,6 +133,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
: data.user; : data.user;
setUser(composed ?? null); setUser(composed ?? null);
setAbilities(Array.isArray(data?.abilities) ? data.abilities : []);
setStatus('authenticated'); setStatus('authenticated');
} catch (error) { } catch (error) {
console.error('[Auth] Failed to refresh profile', 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]); }, [handleAuthFailure, profileQueryKey, queryClient]);
const applyToken = React.useCallback(async (token: string, abilities: string[]) => { const applyToken = React.useCallback(async (token: string, tokenAbilities: string[]) => {
storePersonalAccessToken(token, abilities); storePersonalAccessToken(token, tokenAbilities);
setAbilities(tokenAbilities);
await refreshProfile(); await refreshProfile();
}, [refreshProfile]); }, [refreshProfile]);
@@ -221,9 +229,11 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
} }
}, [handleAuthFailure]); }, [handleAuthFailure]);
const hasAbility = React.useCallback((ability: string) => abilities.includes(ability), [abilities]);
const value = React.useMemo<AuthContextValue>( const value = React.useMemo<AuthContextValue>(
() => ({ status, user, refreshProfile, logout, applyToken }), () => ({ status, user, abilities, hasAbility, refreshProfile, logout, applyToken }),
[status, user, refreshProfile, logout, applyToken] [status, user, abilities, hasAbility, refreshProfile, logout, applyToken]
); );
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>; return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;

View File

@@ -18,6 +18,7 @@ import { NotificationCenter } from './NotificationCenter';
import { UserMenu } from './UserMenu'; import { UserMenu } from './UserMenu';
import { useEventContext } from '../context/EventContext'; import { useEventContext } from '../context/EventContext';
import { EventSwitcher, EventMenuBar } from './EventNav'; import { EventSwitcher, EventMenuBar } from './EventNav';
import { useAuth } from '../auth/context';
type NavItem = { type NavItem = {
key: string; key: string;
@@ -50,7 +51,7 @@ export function AdminLayout({ title, subtitle, actions, children }: AdminLayoutP
const photosLabel = t('navigation.photos', { defaultValue: 'Fotos' }); const photosLabel = t('navigation.photos', { defaultValue: 'Fotos' });
const settingsLabel = t('navigation.settings'); const settingsLabel = t('navigation.settings');
const navItems = React.useMemo<NavItem[]>(() => [ const baseNavItems = React.useMemo<NavItem[]>(() => [
{ {
key: 'dashboard', key: 'dashboard',
to: ADMIN_HOME_PATH, 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]); ], [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(() => ({ const prefetchers = React.useMemo(() => ({
[ADMIN_HOME_PATH]: () => [ADMIN_HOME_PATH]: () =>
Promise.all([ Promise.all([

View File

@@ -31,6 +31,8 @@ export function UserMenu() {
const [pendingLocale, setPendingLocale] = React.useState<SupportedLocale | null>(null); const [pendingLocale, setPendingLocale] = React.useState<SupportedLocale | null>(null);
const currentLocale = getCurrentLocale(); const currentLocale = getCurrentLocale();
const isMember = user?.role === 'member';
const initials = React.useMemo(() => { const initials = React.useMemo(() => {
if (user?.name) { if (user?.name) {
return user.name return user.name
@@ -94,14 +96,18 @@ export function UserMenu() {
<User className="h-4 w-4" /> <User className="h-4 w-4" />
{t('navigation.profile', { defaultValue: 'Profil' })} {t('navigation.profile', { defaultValue: 'Profil' })}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onSelect={() => goTo(ADMIN_SETTINGS_PATH)}> {!isMember && (
<Settings className="h-4 w-4" /> <>
{t('navigation.settings', { defaultValue: 'Einstellungen' })} <DropdownMenuItem onSelect={() => goTo(ADMIN_SETTINGS_PATH)}>
</DropdownMenuItem> <Settings className="h-4 w-4" />
<DropdownMenuItem onSelect={() => goTo(ADMIN_BILLING_PATH)}> {t('navigation.settings', { defaultValue: 'Einstellungen' })}
<CreditCard className="h-4 w-4" /> </DropdownMenuItem>
{t('navigation.billing', { defaultValue: 'Billing' })} <DropdownMenuItem onSelect={() => goTo(ADMIN_BILLING_PATH)}>
</DropdownMenuItem> <CreditCard className="h-4 w-4" />
{t('navigation.billing', { defaultValue: 'Billing' })}
</DropdownMenuItem>
</>
)}
</DropdownMenuGroup> </DropdownMenuGroup>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuSub> <DropdownMenuSub>

View File

@@ -26,7 +26,7 @@ import { ADMIN_EVENTS_PATH } from '../constants';
type InviteForm = { type InviteForm = {
email: string; email: string;
name: string; name: string;
role: string; role: EventMember['role'];
}; };
const emptyInvite: InviteForm = { const emptyInvite: InviteForm = {
@@ -68,7 +68,6 @@ export default function EventMembersPage() {
() => ({ () => ({
tenant_admin: t('management.members.roles.tenantAdmin', 'Tenant-Admin'), tenant_admin: t('management.members.roles.tenantAdmin', 'Tenant-Admin'),
member: t('management.members.roles.member', 'Mitglied'), member: t('management.members.roles.member', 'Mitglied'),
guest: t('management.members.roles.guest', 'Gast'),
}), }),
[t] [t]
); );
@@ -275,6 +274,12 @@ export default function EventMembersPage() {
<Users className="h-4 w-4 text-emerald-500" /> <Users className="h-4 w-4 text-emerald-500" />
{t('management.members.sections.invite.title', 'Neues Mitglied einladen')} {t('management.members.sections.invite.title', 'Neues Mitglied einladen')}
</h3> </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}> <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"> <div className="space-y-2">
<Label htmlFor="invite-email">{t('management.members.form.emailLabel', 'E-Mail')}</Label> <Label htmlFor="invite-email">{t('management.members.form.emailLabel', 'E-Mail')}</Label>
@@ -308,7 +313,6 @@ export default function EventMembersPage() {
<SelectContent> <SelectContent>
<SelectItem value="tenant_admin">{roleLabels.tenant_admin}</SelectItem> <SelectItem value="tenant_admin">{roleLabels.tenant_admin}</SelectItem>
<SelectItem value="member">{roleLabels.member}</SelectItem> <SelectItem value="member">{roleLabels.member}</SelectItem>
<SelectItem value="guest">{roleLabels.guest}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>

View File

@@ -10,7 +10,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { useAuth } from '../auth/context'; 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 { encodeReturnTo, resolveReturnTarget } from '../lib/returnTo';
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
@@ -48,7 +48,7 @@ type LoginResponse = {
} }
export default function LoginPage(): JSX.Element { export default function LoginPage(): JSX.Element {
const { status, applyToken } = useAuth(); const { status, applyToken, abilities } = useAuth();
const { t } = useTranslation('auth'); const { t } = useTranslation('auth');
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -56,7 +56,15 @@ export default function LoginPage(): JSX.Element {
const searchParams = React.useMemo(() => new URLSearchParams(location.search), [location.search]); const searchParams = React.useMemo(() => new URLSearchParams(location.search), [location.search]);
const rawReturnTo = searchParams.get('return_to'); 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( const { finalTarget } = React.useMemo(
() => resolveReturnTarget(rawReturnTo, fallbackTarget), () => resolveReturnTarget(rawReturnTo, fallbackTarget),
[rawReturnTo, fallbackTarget] [rawReturnTo, fallbackTarget]
@@ -82,7 +90,9 @@ export default function LoginPage(): JSX.Element {
onSuccess: async (data) => { onSuccess: async (data) => {
setError(null); setError(null);
await applyToken(data.token, data.abilities ?? []); 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 });
}, },
}); });

View File

@@ -4,6 +4,7 @@ import { useAuth } from './auth/context';
import { import {
ADMIN_BASE_PATH, ADMIN_BASE_PATH,
ADMIN_DEFAULT_AFTER_LOGIN_PATH, ADMIN_DEFAULT_AFTER_LOGIN_PATH,
ADMIN_EVENTS_PATH,
ADMIN_HOME_PATH, ADMIN_HOME_PATH,
ADMIN_LOGIN_PATH, ADMIN_LOGIN_PATH,
ADMIN_LOGIN_START_PATH, ADMIN_LOGIN_START_PATH,
@@ -56,7 +57,7 @@ function RequireAuth() {
} }
function LandingGate() { function LandingGate() {
const { status } = useAuth(); const { status, user } = useAuth();
if (status === 'loading') { if (status === 'loading') {
return ( return (
@@ -67,12 +68,23 @@ function LandingGate() {
} }
if (status === 'authenticated') { 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 />; 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([ export const router = createBrowserRouter([
{ {
path: ADMIN_BASE_PATH, path: ADMIN_BASE_PATH,
@@ -86,13 +98,13 @@ export const router = createBrowserRouter([
{ {
element: <RequireAuth />, element: <RequireAuth />,
children: [ children: [
{ path: 'dashboard', element: <DashboardPage /> }, { path: 'dashboard', element: <RequireAdminAccess><DashboardPage /></RequireAdminAccess> },
{ path: 'events', element: <EventsPage /> }, { path: 'events', element: <EventsPage /> },
{ path: 'events/new', element: <EventFormPage /> }, { path: 'events/new', element: <RequireAdminAccess><EventFormPage /></RequireAdminAccess> },
{ path: 'events/:slug', element: <EventDetailPage /> }, { 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/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/tasks', element: <EventTasksPage /> },
{ path: 'events/:slug/invites', element: <EventInvitesPage /> }, { path: 'events/:slug/invites', element: <EventInvitesPage /> },
{ path: 'events/:slug/toolkit', element: <EventToolkitPage /> }, { path: 'events/:slug/toolkit', element: <EventToolkitPage /> },
@@ -100,14 +112,14 @@ export const router = createBrowserRouter([
{ path: 'tasks', element: <TasksPage /> }, { path: 'tasks', element: <TasksPage /> },
{ path: 'task-collections', element: <TaskCollectionsPage /> }, { path: 'task-collections', element: <TaskCollectionsPage /> },
{ path: 'emotions', element: <EmotionsPage /> }, { path: 'emotions', element: <EmotionsPage /> },
{ path: 'billing', element: <BillingPage /> }, { path: 'billing', element: <RequireAdminAccess><BillingPage /></RequireAdminAccess> },
{ path: 'settings', element: <SettingsPage /> }, { path: 'settings', element: <RequireAdminAccess><SettingsPage /></RequireAdminAccess> },
{ path: 'faq', element: <FaqPage /> }, { path: 'faq', element: <FaqPage /> },
{ path: 'settings/profile', element: <ProfilePage /> }, { path: 'settings/profile', element: <RequireAdminAccess><ProfilePage /></RequireAdminAccess> },
{ path: 'welcome', element: <WelcomeLandingPage /> }, { path: 'welcome', element: <RequireAdminAccess><WelcomeLandingPage /></RequireAdminAccess> },
{ path: 'welcome/packages', element: <WelcomePackagesPage /> }, { path: 'welcome/packages', element: <RequireAdminAccess><WelcomePackagesPage /></RequireAdminAccess> },
{ path: 'welcome/summary', element: <WelcomeOrderSummaryPage /> }, { path: 'welcome/summary', element: <RequireAdminAccess><WelcomeOrderSummaryPage /></RequireAdminAccess> },
{ path: 'welcome/event', element: <WelcomeEventSetupPage /> }, { path: 'welcome/event', element: <RequireAdminAccess><WelcomeEventSetupPage /></RequireAdminAccess> },
], ],
}, },
], ],

View File

@@ -8,6 +8,7 @@ use App\Http\Controllers\Api\Tenant\EmotionController;
use App\Http\Controllers\Api\Tenant\EventController; use App\Http\Controllers\Api\Tenant\EventController;
use App\Http\Controllers\Api\Tenant\EventJoinTokenController; use App\Http\Controllers\Api\Tenant\EventJoinTokenController;
use App\Http\Controllers\Api\Tenant\EventJoinTokenLayoutController; 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\EventTypeController;
use App\Http\Controllers\Api\Tenant\NotificationLogController; use App\Http\Controllers\Api\Tenant\NotificationLogController;
use App\Http\Controllers\Api\Tenant\OnboardingController; use App\Http\Controllers\Api\Tenant\OnboardingController;
@@ -80,29 +81,46 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
->name('gallery.photos.asset'); ->name('gallery.photos.asset');
}); });
Route::middleware(['auth:sanctum', 'tenant.admin', 'tenant.isolation', 'throttle:tenant-api'])->prefix('tenant')->group(function () { Route::middleware(['auth:sanctum', 'tenant.collaborator', 'tenant.isolation', 'throttle:tenant-api'])->prefix('tenant')->group(function () {
Route::get('profile', [ProfileController::class, 'show'])->name('tenant.profile.show'); Route::get('profile', [ProfileController::class, 'show'])
Route::put('profile', [ProfileController::class, 'update'])->name('tenant.profile.update'); ->middleware('tenant.admin')
Route::get('onboarding', [OnboardingController::class, 'show'])->name('tenant.onboarding.show'); ->name('tenant.profile.show');
Route::post('onboarding', [OnboardingController::class, 'store'])->name('tenant.onboarding.store'); Route::put('profile', [ProfileController::class, 'update'])
Route::get('me', [TenantAdminTokenController::class, 'legacyTenantMe'])->name('tenant.me'); ->middleware('tenant.admin')
Route::get('dashboard', DashboardController::class)->name('tenant.dashboard'); ->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::get('event-types', EventTypeController::class)->name('tenant.event-types.index');
Route::apiResource('events', EventController::class) Route::get('events', [EventController::class, 'index'])
->only(['index', 'show', 'destroy']) ->name('tenant.events.index');
->parameters(['events' => 'event:slug']); 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::post('events', [EventController::class, 'store'])->name('tenant.events.store');
Route::match(['put', 'patch'], 'events/{event:slug}', [EventController::class, 'update'])->name('tenant.events.update'); Route::match(['put', 'patch'], 'events/{event:slug}', [EventController::class, 'update'])->name('tenant.events.update');
}); });
Route::prefix('events/{event:slug}')->scopeBindings()->group(function () { Route::prefix('events/{event:slug}')->scopeBindings()->group(function () {
Route::get('stats', [EventController::class, 'stats'])->name('tenant.events.stats'); Route::middleware('tenant.admin')->group(function () {
Route::post('toggle', [EventController::class, 'toggle'])->name('tenant.events.toggle'); Route::get('stats', [EventController::class, 'stats'])->name('tenant.events.stats');
Route::post('invites', [EventController::class, 'createInvite'])->name('tenant.events.invites'); Route::post('toggle', [EventController::class, 'toggle'])->name('tenant.events.toggle');
Route::get('toolkit', [EventController::class, 'toolkit'])->name('tenant.events.toolkit'); 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::prefix('join-tokens')->group(function () {
Route::get('/', [EventJoinTokenController::class, 'index'])->name('tenant.events.join-tokens.index'); 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'); ->name('tenant.events.join-tokens.destroy');
}); });
Route::get('photos', [PhotoController::class, 'index'])->name('tenant.events.photos.index'); Route::prefix('photos')->group(function () {
Route::post('photos', [PhotoController::class, 'store'])->name('tenant.events.photos.store'); Route::get('/', [PhotoController::class, 'index'])->name('tenant.events.photos.index');
Route::get('photos/{photo}', [PhotoController::class, 'show'])->name('tenant.events.photos.show'); Route::post('/', [PhotoController::class, 'store'])->name('tenant.events.photos.store');
Route::patch('photos/{photo}', [PhotoController::class, 'update'])->name('tenant.events.photos.update'); Route::get('{photo}', [PhotoController::class, 'show'])->name('tenant.events.photos.show');
Route::delete('photos/{photo}', [PhotoController::class, 'destroy'])->name('tenant.events.photos.destroy'); Route::patch('{photo}', [PhotoController::class, 'update'])->name('tenant.events.photos.update');
Route::post('photos/{photo}/feature', [PhotoController::class, 'feature'])->name('tenant.events.photos.feature'); Route::delete('{photo}', [PhotoController::class, 'destroy'])->name('tenant.events.photos.destroy');
Route::post('photos/{photo}/unfeature', [PhotoController::class, 'unfeature'])->name('tenant.events.photos.unfeature'); Route::post('{photo}/feature', [PhotoController::class, 'feature'])->name('tenant.events.photos.feature');
Route::post('photos/bulk-approve', [PhotoController::class, 'bulkApprove'])->name('tenant.events.photos.bulk-approve'); Route::post('{photo}/unfeature', [PhotoController::class, 'unfeature'])->name('tenant.events.photos.unfeature');
Route::post('photos/bulk-reject', [PhotoController::class, 'bulkReject'])->name('tenant.events.photos.bulk-reject'); Route::post('bulk-approve', [PhotoController::class, 'bulkApprove'])->name('tenant.events.photos.bulk-approve');
Route::get('photos/moderation', [PhotoController::class, 'forModeration'])->name('tenant.events.photos.for-moderation'); Route::post('bulk-reject', [PhotoController::class, 'bulkReject'])->name('tenant.events.photos.bulk-reject');
Route::get('photos/stats', [PhotoController::class, 'stats'])->name('tenant.events.photos.stats'); Route::get('moderation', [PhotoController::class, 'forModeration'])->name('tenant.events.photos.for-moderation');
Route::get('stats', [PhotoController::class, 'stats'])->name('tenant.events.photos.stats');
});
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'])->name('tenant.events.bulk-status'); Route::post('events/bulk-status', [EventController::class, 'bulkUpdateStatus'])
Route::get('events/search', [EventController::class, 'search'])->name('tenant.events.search'); ->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::apiResource('tasks', TaskController::class);
Route::post('tasks/{task}/assign-event/{event}', [TaskController::class, 'assignToEvent']) 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']) Route::post('task-collections/{collection}/activate', [TaskCollectionController::class, 'activate'])
->name('tenant.task-collections.activate'); ->name('tenant.task-collections.activate');
Route::get('emotions', [EmotionController::class, 'index'])->name('tenant.emotions.index'); Route::middleware('tenant.admin')->group(function () {
Route::post('emotions', [EmotionController::class, 'store'])->name('tenant.emotions.store'); Route::get('emotions', [EmotionController::class, 'index'])->name('tenant.emotions.index');
Route::patch('emotions/{emotion}', [EmotionController::class, 'update'])->name('tenant.emotions.update'); 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']) Route::get('/', [SettingsController::class, 'index'])
->name('tenant.settings.index'); ->name('tenant.settings.index');
Route::post('/', [SettingsController::class, 'update']) Route::post('/', [SettingsController::class, 'update'])
@@ -175,9 +211,10 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
}); });
Route::get('notifications/logs', [NotificationLogController::class, 'index']) Route::get('notifications/logs', [NotificationLogController::class, 'index'])
->middleware('tenant.admin')
->name('tenant.notifications.logs.index'); ->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('balance', [CreditController::class, 'balance'])->name('tenant.credits.balance');
Route::get('ledger', [CreditController::class, 'ledger'])->name('tenant.credits.ledger'); Route::get('ledger', [CreditController::class, 'ledger'])->name('tenant.credits.ledger');
Route::get('history', [CreditController::class, 'history'])->name('tenant.credits.history'); 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::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::get('/', [PackageController::class, 'index'])->name('packages.index');
Route::post('/purchase', [PackageController::class, 'purchase'])->name('packages.purchase'); Route::post('/purchase', [PackageController::class, 'purchase'])->name('packages.purchase');
Route::post('/payment-intent', [PackageController::class, 'createPaymentIntent'])->name('packages.payment-intent'); 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::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('/', [TenantPackageController::class, 'index'])->name('tenant.packages.index');
}); });
Route::get('tenant/billing/transactions', [TenantBillingController::class, 'transactions']) Route::get('tenant/billing/transactions', [TenantBillingController::class, 'transactions'])
->middleware('tenant.admin')
->name('tenant.billing.transactions'); ->name('tenant.billing.transactions');
Route::post('feedback', [TenantFeedbackController::class, 'store']) Route::post('feedback', [TenantFeedbackController::class, 'store'])

View 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']);
}
}