Added telescope, basic project endpoint, basic filament resources, GitHub actions

This commit is contained in:
Constantin Graf
2024-01-28 18:57:28 +01:00
parent e4f0eac834
commit e60e502612
80 changed files with 2563 additions and 135 deletions

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\ClientResource\Pages;
use App\Models\Client;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
class ClientResource extends Resource
{
protected static ?string $model = Client::class;
protected static ?string $navigationIcon = 'heroicon-o-briefcase';
protected static ?string $navigationGroup = 'Timetracking';
protected static ?int $navigationSort = 4;
public static function form(Form $form): Form
{
return $form
->schema([
//
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')
->label('Name')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('organization.name')
->sortable()
->label('Organization'),
Tables\Columns\TextColumn::make('created_at')
->label('Created at')
->sortable(),
Tables\Columns\TextColumn::make('updated_at')
->label('Updated at')
->sortable(),
])
->defaultSort('created_at', 'desc')
->filters([
SelectFilter::make('organization')
->relationship('organization', 'name')
->searchable(),
])
->actions([
Tables\Actions\EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListClients::route('/'),
'create' => Pages\CreateClient::route('/create'),
'edit' => Pages\EditClient::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\ClientResource\Pages;
use App\Filament\Resources\ClientResource;
use Filament\Resources\Pages\CreateRecord;
class CreateClient extends CreateRecord
{
protected static string $resource = ClientResource::class;
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\ClientResource\Pages;
use App\Filament\Resources\ClientResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditClient extends EditRecord
{
protected static string $resource = ClientResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\ClientResource\Pages;
use App\Filament\Resources\ClientResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListClients extends ListRecords
{
protected static string $resource = ClientResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\OrganizationResource\Pages;
use App\Models\Organization;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
class OrganizationResource extends Resource
{
protected static ?string $model = Organization::class;
protected static ?string $navigationIcon = 'heroicon-o-building-office-2';
protected static ?string $navigationGroup = 'Users';
protected static ?int $navigationSort = 7;
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('name')
->label('Name')
->required()
->maxLength(255),
Forms\Components\Toggle::make('Is personal?')
->label('Is personal?')
->required(),
Forms\Components\Select::make('owner_id')
->relationship(name: 'owner', titleAttribute: 'email')
->searchable(['name', 'email'])
->required(),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')
->searchable()
->sortable(),
Tables\Columns\ToggleColumn::make('is_personal')
->label('Is personal?')
->sortable(),
Tables\Columns\TextColumn::make('owner.email')
->sortable(),
])
->filters([
//
])
->actions([
Tables\Actions\EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListOrganizations::route('/'),
'create' => Pages\CreateOrganization::route('/create'),
'edit' => Pages\EditOrganization::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\OrganizationResource\Pages;
use App\Filament\Resources\OrganizationResource;
use Filament\Resources\Pages\CreateRecord;
class CreateOrganization extends CreateRecord
{
protected static string $resource = OrganizationResource::class;
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\OrganizationResource\Pages;
use App\Filament\Resources\OrganizationResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditOrganization extends EditRecord
{
protected static string $resource = OrganizationResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\OrganizationResource\Pages;
use App\Filament\Resources\OrganizationResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListOrganizations extends ListRecords
{
protected static string $resource = OrganizationResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\ProjectResource\Pages;
use App\Models\Project;
use Filament\Forms;
use Filament\Forms\Components\ColorPicker;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Columns\ColorColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
class ProjectResource extends Resource
{
protected static ?string $model = Project::class;
protected static ?string $navigationIcon = 'heroicon-o-folder';
protected static ?string $navigationGroup = 'Timetracking';
protected static ?int $navigationSort = 2;
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('name')
->label('Name')
->required()
->maxLength(255),
ColorPicker::make('color')
->label('Color')
->required(),
Forms\Components\Select::make('organization_id')
->relationship(name: 'organization', titleAttribute: 'name')
->searchable(['name'])
->required(),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
ColorColumn::make('color'),
TextColumn::make('name')
->searchable()
->sortable(),
TextColumn::make('organization.name')
->sortable(),
TextColumn::make('created_at')
->sortable(),
TextColumn::make('updated_at')
->sortable(),
])
->filters([
SelectFilter::make('organization')
->relationship('organization', 'name')
->searchable(),
])
->defaultSort('created_at', 'desc')
->actions([
Tables\Actions\EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListProjects::route('/'),
'create' => Pages\CreateProject::route('/create'),
'edit' => Pages\EditProject::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\ProjectResource\Pages;
use App\Filament\Resources\ProjectResource;
use Filament\Resources\Pages\CreateRecord;
class CreateProject extends CreateRecord
{
protected static string $resource = ProjectResource::class;
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\ProjectResource\Pages;
use App\Filament\Resources\ProjectResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditProject extends EditRecord
{
protected static string $resource = ProjectResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\ProjectResource\Pages;
use App\Filament\Resources\ProjectResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListProjects extends ListRecords
{
protected static string $resource = ProjectResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\TagResource\Pages;
use App\Models\Tag;
use Filament\Forms;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
class TagResource extends Resource
{
protected static ?string $model = Tag::class;
protected static ?string $navigationIcon = 'heroicon-o-tag';
protected static ?string $navigationGroup = 'Timetracking';
protected static ?int $navigationSort = 5;
public static function form(Form $form): Form
{
return $form
->schema([
TextInput::make('name')
->label('Name')
->required(),
Forms\Components\Select::make('organization_id')
->relationship(name: 'organization', titleAttribute: 'name')
->label('Organization')
->searchable(['name'])
->required(),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')
->label('Name')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('organization.name')
->sortable()
->label('Organization'),
Tables\Columns\TextColumn::make('created_at')
->label('Created at')
->sortable(),
Tables\Columns\TextColumn::make('updated_at')
->label('Updated at')
->sortable(),
])
->filters([
//
])
->actions([
Tables\Actions\EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListTags::route('/'),
'create' => Pages\CreateTag::route('/create'),
'edit' => Pages\EditTag::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\TagResource\Pages;
use App\Filament\Resources\TagResource;
use Filament\Resources\Pages\CreateRecord;
class CreateTag extends CreateRecord
{
protected static string $resource = TagResource::class;
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\TagResource\Pages;
use App\Filament\Resources\TagResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditTag extends EditRecord
{
protected static string $resource = TagResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\TagResource\Pages;
use App\Filament\Resources\TagResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListTags extends ListRecords
{
protected static string $resource = TagResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\TaskResource\Pages;
use App\Models\Task;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
class TaskResource extends Resource
{
protected static ?string $model = Task::class;
protected static ?string $navigationIcon = 'heroicon-o-list-bullet';
protected static ?string $navigationGroup = 'Timetracking';
protected static ?int $navigationSort = 3;
public static function form(Form $form): Form
{
return $form
->schema([
//
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('project.name')
->sortable(),
Tables\Columns\TextColumn::make('organization.name')
->sortable(),
Tables\Columns\TextColumn::make('created_at')
->sortable(),
Tables\Columns\TextColumn::make('updated_at')
->sortable(),
])
->filters([
//
])
->defaultSort('created_at', 'desc')
->actions([
Tables\Actions\EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListTasks::route('/'),
'create' => Pages\CreateTask::route('/create'),
'edit' => Pages\EditTask::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\TaskResource\Pages;
use App\Filament\Resources\TaskResource;
use Filament\Resources\Pages\CreateRecord;
class CreateTask extends CreateRecord
{
protected static string $resource = TaskResource::class;
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\TaskResource\Pages;
use App\Filament\Resources\TaskResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditTask extends EditRecord
{
protected static string $resource = TaskResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\TaskResource\Pages;
use App\Filament\Resources\TaskResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListTasks extends ListRecords
{
protected static string $resource = TaskResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\TimeEntryResource\Pages;
use App\Models\TimeEntry;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class TimeEntryResource extends Resource
{
protected static ?string $model = TimeEntry::class;
protected static ?string $navigationIcon = 'heroicon-o-clock';
protected static ?string $navigationGroup = 'Timetracking';
protected static ?int $navigationSort = 1;
public static function form(Form $form): Form
{
return $form
->schema([
TextInput::make('id')
->label('ID')
->readOnly()
->disabled(),
TextInput::make('description')
->label('Description')
->required()
->maxLength(255),
Toggle::make('billable')
->label('Is Billable?')
->required(),
DateTimePicker::make('start')
->label('Start')
->required(),
DateTimePicker::make('end')
->label('End')
->nullable()
->rules([
'after:start',
]),
Select::make('user_id')
->relationship(name: 'user', titleAttribute: 'email')
->searchable(['name', 'email'])
->required(),
Select::make('project_id')
->relationship(name: 'project', titleAttribute: 'name')
->searchable(['name'])
->nullable(),
// TODO
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('description')
->label('Description'),
TextColumn::make('user.email')
->label('User'),
TextColumn::make('project.name')
->label('Project'),
TextColumn::make('task.name')
->label('Task'),
TextColumn::make('time')
->getStateUsing(function (TimeEntry $record): string {
return ($record->getDuration()?->cascade()?->forHumans() ?? '-').' '.
' ('.$record->start->toDateTimeString('minute').' - '.
($record->end?->toDateTimeString('minute') ?? '...').')';
})
->label('Time'),
Tables\Columns\TextColumn::make('organization.name')
->sortable(),
Tables\Columns\TextColumn::make('created_at')
->sortable(),
Tables\Columns\TextColumn::make('updated_at')
->sortable(),
])
->filters([
//
])
->defaultSort('created_at', 'desc')
->actions([
Tables\Actions\EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListTimeEntries::route('/'),
'create' => Pages\CreateTimeEntry::route('/create'),
'edit' => Pages\EditTimeEntry::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\TimeEntryResource\Pages;
use App\Filament\Resources\TimeEntryResource;
use Filament\Resources\Pages\CreateRecord;
class CreateTimeEntry extends CreateRecord
{
protected static string $resource = TimeEntryResource::class;
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\TimeEntryResource\Pages;
use App\Filament\Resources\TimeEntryResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditTimeEntry extends EditRecord
{
protected static string $resource = TimeEntryResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\TimeEntryResource\Pages;
use App\Filament\Resources\TimeEntryResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListTimeEntries extends ListRecords
{
protected static string $resource = TimeEntryResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\UserResource\Pages;
use App\Models\User;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
class UserResource extends Resource
{
protected static ?string $model = User::class;
protected static ?string $navigationIcon = 'heroicon-o-user';
protected static ?string $navigationGroup = 'Users';
protected static ?int $navigationSort = 6;
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('id')
->label('ID')
->disabled()
->visibleOn(['update', 'show'])
->readOnly()
->maxLength(255),
Forms\Components\TextInput::make('name')
->label('Name')
->required()
->maxLength(255),
Forms\Components\TextInput::make('email')
->label('Email')
->required()
->maxLength(255),
Forms\Components\TextInput::make('password')
->label('Password')
->required()
->password()
->maxLength(255),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('email')
->icon('heroicon-m-envelope')
->searchable()
->sortable(),
])
->filters([
//
])
->actions([
Tables\Actions\EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListUsers::route('/'),
'create' => Pages\CreateUser::route('/create'),
'edit' => Pages\EditUser::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use Filament\Resources\Pages\CreateRecord;
class CreateUser extends CreateRecord
{
protected static string $resource = UserResource::class;
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditUser extends EditRecord
{
protected static string $resource = UserResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListUsers extends ListRecords
{
protected static string $resource = UserResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Models\Organization;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\Auth;
class Controller extends \App\Http\Controllers\Controller
{
/**
* @throws AuthorizationException
*/
protected function checkPermission(Organization $organization, string $permission): void
{
if (! Auth::user()->hasTeamPermission($organization, $permission)) {
throw new AuthorizationException();
}
}
}

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Requests\V1\Project\ProjectStoreRequest;
use App\Http\Requests\V1\Project\ProjectUpdateRequest;
use App\Http\Resources\V1\Project\ProjectCollection;
use App\Http\Resources\V1\Project\ProjectResource;
use App\Models\Organization;
use App\Models\Project;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\Resources\Json\JsonResource;
class ProjectController extends Controller
{
/**
* @throws AuthorizationException
*/
public function index(Organization $organization): JsonResource
{
$this->checkPermission($organization, 'projects:view');
$projects = Project::query()
->whereBelongsTo($organization, 'organization')
->get();
return new ProjectCollection($projects);
}
/**
* @throws AuthorizationException
*/
public function show(Organization $organization, Project $project): JsonResource
{
$this->checkPermission($organization, 'projects:view');
$project->load('organization');
return new ProjectResource($project);
}
/**
* @throws AuthorizationException
*/
public function store(Organization $organization, ProjectStoreRequest $request): JsonResource
{
$this->checkPermission($organization, 'projects:create');
$project = new Project();
$project->name = $request->input('name');
$project->color = $request->input('color');
$project->organization()->associate($organization);
$project->save();
return new ProjectResource($project);
}
/**
* @throws AuthorizationException
*/
public function update(Organization $organization, Project $project, ProjectUpdateRequest $request): JsonResource
{
$this->checkPermission($organization, 'projects:update');
$project->name = $request->input('name');
$project->color = $request->input('color');
$project->save();
return new ProjectResource($project);
}
/**
* @throws AuthorizationException
*/
public function destroy(Organization $organization, Project $project): JsonResource
{
$this->checkPermission($organization, 'projects:delete');
$project->delete();
return new ProjectResource($project);
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http;
use App\Http\Middleware\ForceJsonResponse;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
class Kernel extends HttpKernel
@@ -40,12 +41,13 @@ class Kernel extends HttpKernel
\Illuminate\Routing\Middleware\SubstituteBindings::class,
\App\Http\Middleware\HandleInertiaRequests::class,
\Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets::class,
\Laravel\Passport\Http\Middleware\CreateFreshApiToken::class,
],
'api' => [
// \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
\Illuminate\Routing\Middleware\ThrottleRequests::class.':api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
ForceJsonResponse::class,
],
];

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class ForceJsonResponse
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next, string ...$guards): Response
{
$request->headers->set('Accept', 'application/json');
return $next($request);
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\Project;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class ProjectStoreRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => [
'required',
'string',
'max:255',
],
'color' => [
'required',
'string',
'max:255',
],
];
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\Project;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class ProjectUpdateRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => [
'required',
'string',
'max:255',
],
'color' => [
'required',
'string',
'max:255',
],
];
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\V1\Project;
use Illuminate\Http\Resources\Json\ResourceCollection;
class ProjectCollection extends ResourceCollection
{
/**
* The resource that this resource collects.
*
* @var string
*/
public $collects = ProjectResource::class;
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\V1\Project;
use App\Models\Project;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @property Project $resource
*/
class ProjectResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, string|boolean|integer>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->resource->id,
'name' => $this->resource->name,
];
}
}

View File

@@ -15,6 +15,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* @property string $id
* @property string $name
* @property string $color
* @property string $organization_id
* @property string $client_id
* @property-read Organization $organization
@@ -35,6 +36,7 @@ class Project extends Model
*/
protected $casts = [
'name' => 'string',
'color' => 'string',
];
/**

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Models;
use Carbon\CarbonInterval;
use Database\Factories\TimeEntryFactory;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -43,6 +44,11 @@ class TimeEntry extends Model
'tags' => 'array',
];
public function getDuration(): ?CarbonInterval
{
return $this->end === null ? null : $this->start->diffAsCarbonInterval($this->end);
}
/**
* @return BelongsTo<User, TimeEntry>
*/

View File

@@ -20,6 +20,7 @@ use Laravel\Passport\HasApiTokens;
/**
* @property string $id
* @property string $name
* @property string $email
*
* @method HasMany<Organization> ownedTeams()
* @method static UserFactory factory()
@@ -64,6 +65,7 @@ class User extends Authenticatable
*/
protected $casts = [
'email_verified_at' => 'datetime',
'is_admin' => 'boolean',
];
/**
@@ -77,8 +79,7 @@ class User extends Authenticatable
public function canAccessPanel(Panel $panel): bool
{
// TODO: Implement canAccessPanel() method.
return false;
return in_array($this->email, config('auth.super_admins', []), true);
}
/**

View File

@@ -6,6 +6,7 @@ namespace App\Policies;
use App\Models\Organization;
use App\Models\User;
use Filament\Facades\Filament;
use Illuminate\Auth\Access\HandlesAuthorization;
class OrganizationPolicy
@@ -17,7 +18,11 @@ class OrganizationPolicy
*/
public function viewAny(User $user): bool
{
return true;
if (Filament::isServing()) {
return true;
}
return false;
}
/**
@@ -25,6 +30,10 @@ class OrganizationPolicy
*/
public function view(User $user, Organization $organization): bool
{
if (Filament::isServing()) {
return true;
}
return $user->belongsToTeam($organization);
}
@@ -33,6 +42,10 @@ class OrganizationPolicy
*/
public function create(User $user): bool
{
if (Filament::isServing()) {
return true;
}
return true;
}
@@ -41,6 +54,10 @@ class OrganizationPolicy
*/
public function update(User $user, Organization $organization): bool
{
if (Filament::isServing()) {
return true;
}
return $user->ownsTeam($organization);
}
@@ -49,6 +66,10 @@ class OrganizationPolicy
*/
public function addTeamMember(User $user, Organization $organization): bool
{
if (Filament::isServing()) {
return true;
}
return $user->ownsTeam($organization);
}
@@ -57,6 +78,10 @@ class OrganizationPolicy
*/
public function updateTeamMember(User $user, Organization $organization): bool
{
if (Filament::isServing()) {
return true;
}
return $user->ownsTeam($organization);
}
@@ -65,6 +90,10 @@ class OrganizationPolicy
*/
public function removeTeamMember(User $user, Organization $organization): bool
{
if (Filament::isServing()) {
return true;
}
return $user->ownsTeam($organization);
}
@@ -73,6 +102,10 @@ class OrganizationPolicy
*/
public function delete(User $user, Organization $organization): bool
{
if (Filament::isServing()) {
return true;
}
return $user->ownsTeam($organization);
}
}

View File

@@ -4,10 +4,16 @@ declare(strict_types=1);
namespace App\Providers;
use App\Models\Client;
use App\Models\Membership;
use App\Models\Organization;
use App\Models\OrganizationInvitation;
use App\Models\Project;
use App\Models\Tag;
use App\Models\Task;
use App\Models\TimeEntry;
use App\Models\User;
use Filament\Forms\Components\Section;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\ServiceProvider;
@@ -27,13 +33,27 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot(): void
{
if ($this->app->environment('local')) {
$this->app->register(\Laravel\Telescope\TelescopeServiceProvider::class);
$this->app->register(TelescopeServiceProvider::class);
}
Model::preventLazyLoading(! $this->app->isProduction());
Model::preventSilentlyDiscardingAttributes(! $this->app->isProduction());
Relation::enforceMorphMap([
'membership' => Membership::class,
'team' => Organization::class,
'team_invitation' => OrganizationInvitation::class,
'organization' => Organization::class,
'organization-invitation' => OrganizationInvitation::class,
'user' => User::class,
'time-entry' => TimeEntry::class,
'project' => Project::class,
'task' => Task::class,
'client' => Client::class,
'tag' => Tag::class,
]);
Model::unguard();
Section::configureUsing(function (Section $section): void {
$section->columns(1);
}, null, true);
}
}

View File

@@ -7,6 +7,7 @@ namespace App\Providers\Filament;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\DisableBladeIconComponents;
use Filament\Http\Middleware\DispatchServingFilamentEvent;
use Filament\Navigation\NavigationGroup;
use Filament\Pages;
use Filament\Panel;
use Filament\PanelProvider;
@@ -18,7 +19,9 @@ use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Session\Middleware\AuthenticateSession;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\Support\Facades\App;
use Illuminate\View\Middleware\ShareErrorsFromSession;
use pxlrbt\FilamentEnvironmentIndicator\EnvironmentIndicatorPlugin;
class AdminPanelProvider extends PanelProvider
{
@@ -40,6 +43,21 @@ class AdminPanelProvider extends PanelProvider
->widgets([
Widgets\AccountWidget::class,
])
->plugins([
EnvironmentIndicatorPlugin::make()
->color(fn () => match (App::environment()) {
'production' => null,
'staging' => Color::Orange,
default => Color::Blue,
}),
])
->navigationGroups([
NavigationGroup::make()
->label('Timetracking'),
NavigationGroup::make()
->label('Users')
->collapsed(),
])
->middleware([
EncryptCookies::class,
AddQueuedCookiesToResponse::class,

View File

@@ -49,19 +49,27 @@ class JetstreamServiceProvider extends ServiceProvider
*/
protected function configurePermissions(): void
{
Jetstream::defaultApiTokenPermissions(['read']);
Jetstream::defaultApiTokenPermissions([]);
Jetstream::role('admin', 'Administrator', [
'create',
'read',
'update',
'delete',
'projects:view',
'projects:create',
'projects:update',
'projects:delete',
])->description('Administrator users can perform any action.');
Jetstream::role('editor', 'Editor', [
'read',
'create',
'update',
Jetstream::role('manager', 'Manager', [
'projects:view',
'projects:create',
'projects:update',
'projects:delete',
])->description('Editor users have the ability to read, create, and update.');
Jetstream::role('employee', 'Employee', [
'projects:view',
'projects:create',
'projects:update',
'projects:delete',
])->description('Editor users have the ability to read, create, and update.');
}
}

View File

@@ -33,6 +33,7 @@ class RouteServiceProvider extends ServiceProvider
$this->routes(function () {
Route::middleware('api')
->prefix('api')
->name('api.')
->group(base_path('routes/api.php'));
Route::middleware('web')

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Providers;
use App\Models\User;
use Illuminate\Support\Facades\Gate;
use Laravel\Telescope\IncomingEntry;
use Laravel\Telescope\Telescope;
use Laravel\Telescope\TelescopeApplicationServiceProvider;
class TelescopeServiceProvider extends TelescopeApplicationServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
Telescope::night();
$this->hideSensitiveRequestDetails();
Telescope::filter(function (IncomingEntry $entry) {
if ($this->app->environment('local')) {
return true;
}
return $entry->isReportableException() ||
$entry->isFailedRequest() ||
$entry->isFailedJob() ||
$entry->isScheduledTask() ||
$entry->hasMonitoredTag();
});
}
/**
* Prevent sensitive request details from being logged by Telescope.
*/
protected function hideSensitiveRequestDetails(): void
{
if ($this->app->environment('local')) {
return;
}
Telescope::hideRequestParameters(['_token']);
Telescope::hideRequestHeaders([
'cookie',
'x-csrf-token',
'x-xsrf-token',
]);
}
/**
* Register the Telescope gate.
*
* This gate determines who can access Telescope in non-local environments.
*/
protected function gate(): void
{
Gate::define('viewTelescope', function (User $user): bool {
// Note: Telescope is only available in local environments, so this should not be relevant.
return false;
});
}
}