Merge branch 'main' of github.com:solidtime-io/solidtime

This commit is contained in:
Gregor Vostrak
2024-04-22 13:38:30 +02:00
21 changed files with 218 additions and 45 deletions

View File

@@ -12,7 +12,9 @@ use App\Models\Organization;
use App\Models\OrganizationInvitation;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Mail;
use Laravel\Jetstream\Contracts\InvitesTeamMembers;
use Laravel\Jetstream\Mail\TeamInvitation;
class InvitationController extends Controller
{
@@ -64,6 +66,22 @@ class InvitationController extends Controller
return response()->json(null, 204);
}
/**
* Resend email for a pending invitation
*
* @throws AuthorizationException
*
* @operationId resendInvitationEmail
*/
public function resend(Organization $organization, OrganizationInvitation $invitation): JsonResponse
{
$this->checkPermission($organization, 'invitations:resend', $invitation);
Mail::to($invitation->email)->send(new TeamInvitation($invitation));
return response()->json(null, 204);
}
/**
* Remove a pending invitation
*

View File

@@ -27,8 +27,9 @@ use Laravel\Jetstream\Team as JetstreamTeam;
* @property User $owner
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property Collection<User> $users
* @property Collection<string, User> $realUsers
* @property Collection<int, User> $users
* @property Collection<int, User> $realUsers
* @property-read Collection<int, OrganizationInvitation> $teamInvitations
* @property Membership $membership
*
* @method HasMany<OrganizationInvitation> teamInvitations()
@@ -83,7 +84,7 @@ class Organization extends JetstreamTeam
/**
* Get all the non-placeholder users of the organization including its owner.
*
* @return Collection<string, User>
* @return Collection<int, User>
*/
public function allRealUsers(): Collection
{

View File

@@ -22,7 +22,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
* @property int|null $billable_rate
* @property-read Organization $organization
* @property-read Client|null $client
* @property-read Collection<Task> $tasks
* @property-read Collection<int, Task> $tasks
*
* @method Builder<Project> visibleByUser(User $user)
* @method static ProjectFactory factory()

View File

@@ -22,7 +22,7 @@ use Illuminate\Support\Carbon;
* @property Carbon|null $updated_at
* @property-read Project $project
* @property-read Organization $organization
* @property-read Collection<TimeEntry> $timeEntries
* @property-read Collection<int, TimeEntry> $timeEntries
*
* @method static TaskFactory factory()
*/

View File

@@ -21,14 +21,14 @@ use Illuminate\Session\Middleware\AuthenticateSession;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\Support\Facades\App;
use Illuminate\View\Middleware\ShareErrorsFromSession;
use Nwidart\Modules\Facades\Module;
use pxlrbt\FilamentEnvironmentIndicator\EnvironmentIndicatorPlugin;
class AdminPanelProvider extends PanelProvider
{
public function panel(Panel $panel): Panel
{
return $panel
->default()
$panel->default()
->id('admin')
->path('admin')
->colors([
@@ -72,5 +72,26 @@ class AdminPanelProvider extends PanelProvider
->authMiddleware([
Authenticate::class,
]);
$modules = Module::allEnabled();
foreach ($modules as $module) {
$panel->discoverResources(
in: module_path($module->getName(), 'app/Filament/Resources'),
for: 'Extensions\\'.$module->getName().'\\App\\Filament\\Resources'
);
$panel->discoverPages(
in: module_path($module->getName(), 'app/Filament/Pages'),
for: 'Extensions\\'.$module->getName().'\\App\\Filament\\Pages'
);
$panel->discoverWidgets(
in: module_path($module->getName(), 'app/Filament/Widgets'),
for: 'Extensions\\'.$module->getName().'\\App\\Filament\\Widgets'
);
}
return $panel;
}
}

View File

@@ -8,8 +8,10 @@ use App\Actions\Fortify\CreateNewUser;
use App\Actions\Fortify\ResetUserPassword;
use App\Actions\Fortify\UpdateUserPassword;
use App\Actions\Fortify\UpdateUserProfileInformation;
use App\Models\User;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
@@ -35,6 +37,20 @@ class FortifyServiceProvider extends ServiceProvider
Fortify::updateUserPasswordsUsing(UpdateUserPassword::class);
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
Fortify::authenticateUsing(function (Request $request): ?User {
/** @var User|null $user */
$user = User::query()
->where('email', $request->email)
->where('is_placeholder', '=', false)
->first();
if ($user !== null && Hash::check($request->password, $user->password)) {
return $user;
}
return null;
});
RateLimiter::for('login', function (Request $request) {
$throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip());

View File

@@ -16,6 +16,7 @@ use App\Enums\Role;
use App\Enums\Weekday;
use App\Models\Organization;
use App\Models\OrganizationInvitation;
use App\Models\User;
use App\Service\TimezoneService;
use Brick\Money\Currency;
use Brick\Money\ISOCurrencyProvider;
@@ -71,6 +72,7 @@ class JetstreamServiceProvider extends ServiceProvider
'project-members:update',
'project-members:delete',
'tasks:view',
'tasks:view:all',
'tasks:create',
'tasks:update',
'tasks:delete',
@@ -95,13 +97,14 @@ class JetstreamServiceProvider extends ServiceProvider
'import',
'invitations:view',
'invitations:create',
'invitations:resend',
'invitations:remove',
'members:view',
'members:invite-placeholder',
'members:change-role',
'members:update',
'members:delete',
])->description('Owner users can perform any action.');
])->description('Owner users can perform any action. There is only one owner per organization.');
Jetstream::role(Role::Admin->value, 'Administrator', [
'projects:view',
@@ -114,6 +117,7 @@ class JetstreamServiceProvider extends ServiceProvider
'project-members:update',
'project-members:delete',
'tasks:view',
'tasks:view:all',
'tasks:create',
'tasks:update',
'tasks:delete',
@@ -136,9 +140,13 @@ class JetstreamServiceProvider extends ServiceProvider
'organizations:view',
'organizations:update',
'import',
'invitations:view',
'invitations:create',
'invitations:resend',
'invitations:remove',
'members:view',
'members:invite-placeholder',
])->description('Administrator users can perform any action.');
])->description('Administrator users can perform any action, except accessing the billing dashboard.');
Jetstream::role(Role::Manager->value, 'Manager', [
'projects:view',
@@ -151,6 +159,7 @@ class JetstreamServiceProvider extends ServiceProvider
'project-members:update',
'project-members:delete',
'tasks:view',
'tasks:view:all',
'tasks:create',
'tasks:update',
'tasks:delete',
@@ -171,8 +180,9 @@ class JetstreamServiceProvider extends ServiceProvider
'clients:update',
'clients:delete',
'organizations:view',
'invitations:view',
'members:view',
])->description('Managers have the ability to read, create, and update their own time entries as well as those of their team.');
])->description('Managers have full access to all projects, time entries, ect. but cannot manage the organization (add/remove member, edit the organization, ect.).');
Jetstream::role(Role::Employee->value, 'Employee', [
'projects:view',
@@ -183,7 +193,7 @@ class JetstreamServiceProvider extends ServiceProvider
'time-entries:update:own',
'time-entries:delete:own',
'organizations:view',
])->description('Employees have the ability to read, create, and update their own time entries.');
])->description('Employees have the ability to read, create, and update their own time entries and they can see the projects that they are members of.');
Jetstream::role(Role::Placeholder->value, 'Placeholder', [
])->description('Placeholders are used for importing data. They cannot log in and have no permissions.');
@@ -201,7 +211,41 @@ class JetstreamServiceProvider extends ServiceProvider
->whenRendering(
'Teams/Show',
function (Request $request, array $data): array {
/** @var Organization $teamModel */
$teamModel = $data['team'];
$owner = $teamModel->owner;
return array_merge($data, [
'team' => [
'id' => $teamModel->getKey(),
'name' => $teamModel->name,
'currency' => $teamModel->currency,
'owner' => [
'id' => $owner->getKey(),
'name' => $owner->name,
'email' => $owner->email,
'profile_photo_url' => $owner->profile_photo_url,
],
'users' => $teamModel->users->map(function (User $user): array {
return [
'id' => $user->getKey(),
'name' => $user->name,
'email' => $user->email,
'profile_photo_url' => $user->profile_photo_url,
'membership' => [
'id' => $user->membership->id,
'role' => $user->membership->role,
],
];
}),
'team_invitations' => $teamModel->teamInvitations->map(function (OrganizationInvitation $invitation): array {
return [
'id' => $invitation->getKey(),
'email' => $invitation->email,
'role' => $invitation->role,
];
}),
],
'currencies' => array_map(function (Currency $currency): string {
return $currency->getName();
}, ISOCurrencyProvider::getInstance()->getAvailableCurrencies()),

View File

@@ -21,7 +21,7 @@ class TelescopeServiceProvider extends TelescopeApplicationServiceProvider
$this->hideSensitiveRequestDetails();
Telescope::filter(function (IncomingEntry $entry) {
Telescope::filter(function (IncomingEntry $entry): bool {
if ($this->app->environment('local')) {
return true;
}

View File

@@ -162,12 +162,12 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
#[\Override]
public function getName(): string
{
return __('importer.toggl_data_importer.name');
return __('importer.clockify_time_entries.name');
}
#[\Override]
public function getDescription(): string
{
return __('importer.toggl_data_importer.description');
return __('importer.clockify_time_entries.description');
}
}

View File

@@ -67,6 +67,11 @@ return [
'replace_placeholders' => true,
],
'stack_production' => [
'driver' => 'stack',
'channels' => ['single', 'sentry'],
],
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
@@ -84,6 +89,12 @@ return [
'replace_placeholders' => true,
],
'sentry' => [
'driver' => 'sentry',
'level' => env('LOG_LEVEL', 'error'),
'bubble' => true,
],
'papertrail' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),

View File

@@ -3,7 +3,7 @@
<msapplication>
<tile>
<square150x150logo src="/favicons/mstile-150x150.png"/>
<TileColor>#da532c</TileColor>
<TileColor>#000000</TileColor>
</tile>
</msapplication>
</browserconfig>

View File

@@ -13,7 +13,7 @@
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"theme_color": "#000000",
"background_color": "#000000",
"display": "standalone"
}

View File

@@ -90,16 +90,14 @@ const switchToTeam = (team: Organization) => {
<DropdownLink
v-if="isBillingActivated()"
href="
/billing
">
href="/billing">
Billing
</DropdownLink>
<DropdownLink
v-if="page.props.jetstream.canCreateTeams"
:href="route('teams.create')">
Create New Team
Create new organization
</DropdownLink>
<!-- Organization Switcher -->
@@ -107,7 +105,7 @@ const switchToTeam = (team: Organization) => {
<div class="border-t border-card-background-separator" />
<div class="block px-4 py-2 text-xs text-muted">
Switch Teams
Switch Organizations
</div>
<template

View File

@@ -29,7 +29,7 @@ const page = usePage<{
<template #title> Organization Details</template>
<template #description>
Create a new team to collaborate with others on projects.
Create a new organization to collaborate with others on projects.
</template>
<template #form>

View File

@@ -26,21 +26,21 @@ const deleteTeam = () => {
<template>
<ActionSection>
<template #title> Delete Team </template>
<template #title> Delete Organization </template>
<template #description> Permanently delete this team. </template>
<template #description> Permanently delete this organization. </template>
<template #content>
<div class="max-w-xl text-sm text-muted">
Once a team is deleted, all of its resources and data will be
permanently deleted. Before deleting this team, please download
any data or information regarding this team that you wish to
Once a organization is deleted, all of its resources and data will be
permanently deleted. Before deleting this organization, please download
any data or information regarding this organization that you wish to
retain.
</div>
<div class="mt-5">
<DangerButton @click="confirmTeamDeletion">
Delete Team
Delete Organization
</DangerButton>
</div>
@@ -48,7 +48,7 @@ const deleteTeam = () => {
<ConfirmationModal
:show="confirmingTeamDeletion"
@close="confirmingTeamDeletion = false">
<template #title> Delete Team </template>
<template #title> Delete Organization </template>
<template #content>
Are you sure you want to delete this team? Once a team is
@@ -66,7 +66,7 @@ const deleteTeam = () => {
:class="{ 'opacity-25': form.processing }"
:disabled="form.processing"
@click="deleteTeam">
Delete Team
Delete Organization
</DangerButton>
</template>
</ConfirmationModal>

View File

@@ -130,10 +130,10 @@ const displayableRole = (role: string) => {
<!-- Add Organization Member -->
<FormSection @submitted="addTeamMember">
<template #title> Add Team Member</template>
<template #title> Add Organization Member</template>
<template #description>
Add a new team member to your team, allowing them to
Add a new member to your organization, allowing them to
collaborate with you.
</template>
@@ -141,7 +141,7 @@ const displayableRole = (role: string) => {
<div class="col-span-6">
<div class="max-w-xl text-sm text-muted">
Please provide the email address of the person you
would like to add to this team.
would like to add to this organization.
</div>
</div>
@@ -257,8 +257,8 @@ const displayableRole = (role: string) => {
<template #title> Pending Team Invitations</template>
<template #description>
These people have been invited to your team and have been
sent an invitation email. They may join the team by
These people have been invited to your organization and have been
sent an invitation email. They may join the organization by
accepting the email invitation.
</template>
@@ -293,10 +293,10 @@ const displayableRole = (role: string) => {
<!-- Manage Organization Members -->
<ActionSection class="mt-10 sm:mt-0">
<template #title> Team Members</template>
<template #title> Organization Members</template>
<template #description>
All of the people that are part of this team.
All of the people that are part of this organization.
</template>
<!-- Organization Member List -->
@@ -443,10 +443,10 @@ const displayableRole = (role: string) => {
<ConfirmationModal
:show="confirmingLeavingTeam"
@close="confirmingLeavingTeam = false">
<template #title> Leave Team</template>
<template #title> Leave Organization</template>
<template #content>
Are you sure you would like to leave this team?
Are you sure you would like to leave this organization?
</template>
<template #footer>
@@ -471,7 +471,7 @@ const displayableRole = (role: string) => {
<template #title> Remove Team Member</template>
<template #content>
Are you sure you would like to remove this person from the team?
Are you sure you would like to remove this person from the organization?
</template>
<template #footer>

View File

@@ -34,14 +34,14 @@ const updateTeamName = () => {
<template #title> Organization Name</template>
<template #description>
The team's name and owner information.
The organization's name and owner information.
</template>
<template #form>
<!-- Organization Owner Information -->
<div class="col-span-6 flex items-center justify-between">
<div class="">
<InputLabel value="Team Owner" />
<InputLabel value="Organization Owner" />
<div class="flex items-center mt-2">
<img

View File

@@ -17,9 +17,9 @@
<link rel="manifest" href="/favicons/site.webmanifest">
<link rel="mask-icon" href="/favicons/safari-pinned-tab.svg" color="#000000">
<link rel="shortcut icon" href="/favicons/favicon.ico">
<meta name="msapplication-TileColor" content="#da532c">
<meta name="msapplication-TileColor" content="#000000">
<meta name="msapplication-config" content="/favicons/browserconfig.xml">
<meta name="theme-color" content="#ffffff">
<meta name="theme-color" content="#000000">
<!-- Scripts -->
@routes

View File

@@ -49,6 +49,7 @@ Route::middleware([
Route::name('invitations.')->group(static function () {
Route::get('/organizations/{organization}/invitations', [InvitationController::class, 'index'])->name('index');
Route::post('/organizations/{organization}/invitations', [InvitationController::class, 'store'])->name('store');
Route::post('/organizations/{organization}/invitations/{invitation}/resend', [InvitationController::class, 'resend'])->name('resend');
Route::delete('/organizations/{organization}/invitations/{invitation}', [InvitationController::class, 'destroy'])->name('destroy');
});

View File

@@ -6,6 +6,7 @@ namespace Tests;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Illuminate\Support\Facades\Mail;
use TiMacDonald\Log\LogFake;
abstract class TestCase extends BaseTestCase
@@ -15,6 +16,7 @@ abstract class TestCase extends BaseTestCase
protected function setUp(): void
{
parent::setUp();
Mail::fake();
LogFake::bind();
}

View File

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace Tests\Unit\Endpoint\Api\V1;
use App\Models\OrganizationInvitation;
use Illuminate\Support\Facades\Mail;
use Laravel\Jetstream\Mail\TeamInvitation;
use Laravel\Passport\Passport;
class InvitationEndpointTest extends ApiEndpointTestAbstract
@@ -77,6 +79,65 @@ class InvitationEndpointTest extends ApiEndpointTestAbstract
$this->assertEquals('employee', $invitation->role);
}
public function test_resend_fails_if_user_has_no_permission_to_resend_the_invitation(): void
{
// Arrange
$data = $this->createUserWithPermission([
]);
Passport::actingAs($data->user);
$invitation = OrganizationInvitation::factory()->forOrganization($data->organization)->create();
// Act
$response = $this->postJson(route('api.v1.invitations.resend', [
$data->organization->getKey(),
$invitation->getKey(),
]));
// Assert
Mail::assertNothingSent();
Mail::assertNothingQueued();
$response->assertStatus(403);
}
public function test_resend_fails_if_invitation_belongs_to_different_organization(): void
{
// Arrange
$data = $this->createUserWithPermission([
'invitations:resend',
]);
Passport::actingAs($data->user);
$invitation = OrganizationInvitation::factory()->create();
// Act
$response = $this->postJson(route('api.v1.invitations.resend', [$data->organization->getKey(), $invitation->getKey()]));
// Assert
Mail::assertNothingSent();
Mail::assertNothingQueued();
$response->assertStatus(403);
}
public function test_resend_resends_invitation_email(): void
{
// Arrange
$data = $this->createUserWithPermission([
'invitations:resend',
]);
Passport::actingAs($data->user);
$invitation = OrganizationInvitation::factory()->forOrganization($data->organization)->create();
// Act
$response = $this->postJson(route('api.v1.invitations.resend', [
$data->organization->getKey(),
$invitation->getKey(),
]));
// Assert
Mail::assertSent(fn (TeamInvitation $mail): bool => $mail->invitation->is($invitation));
Mail::assertNothingQueued();
$response->assertStatus(204);
}
public function test_delete_fails_if_user_has_no_permission_to_remove_invitations(): void
{
// Arrange