Compare commits

..

9 Commits

Author SHA1 Message Date
Constantin Graf
0a28be83a1 Add more tests; Add filter in filament resource; Added options for user create command 2025-02-06 12:15:49 -05:00
Constantin Graf
4eb716d2cc Fixed bugs causing incorrect computed attributes in imported data 2025-02-04 19:51:54 -05:00
Constantin Graf
55323fa4b4 Add missing serve option to local filesystem disk 2025-02-04 19:51:23 -05:00
Constantin Graf
6df20ed1e5 Updated composer dependencies 2025-02-04 19:50:34 -05:00
Constantin Graf
bc7c564eb2 Added estimated time to clockify project import 2025-02-04 13:45:10 -05:00
Constantin Graf
5423b03201 Fixed timezones in unit tests 2024-12-20 19:47:12 -05:00
Constantin Graf
0e910ba565 Updated composer dependencies 2024-12-20 19:28:18 -05:00
Constantin Graf
bad1cd1343 Fixed reports in deletion service 2024-12-20 19:28:10 -05:00
Constantin Graf
dd312b396b Deactivated registration 2024-12-20 19:05:21 -05:00
199 changed files with 3524 additions and 5577 deletions

42
.env.ci
View File

@@ -1,4 +1,3 @@
# Application
APP_NAME=solidtime
APP_ENV=local
APP_KEY=
@@ -6,6 +5,7 @@ APP_DEBUG=true
APP_URL=http://localhost
APP_FORCE_HTTPS=false
APP_ENABLE_REGISTRATION=true
SESSION_SECURE_COOKIE=false
# Logging
LOG_CHANNEL=stack
@@ -20,39 +20,35 @@ DB_TEST_DATABASE=laravel
DB_TEST_USERNAME=root
DB_TEST_PASSWORD=root
# Broadcasting
BROADCAST_DRIVER=null
# Cache
BROADCAST_DRIVER=log
CACHE_DRIVER=file
# Queue
QUEUE_CONNECTION=sync
# Session
SESSION_DRIVER=database
SESSION_LIFETIME=120
# Mail
MAIL_MAILER=log
MAIL_FROM_ADDRESS="no-reply@solidtime.test"
MAIL_FROM_NAME="solidtime"
MAIL_REPLY_TO_ADDRESS="hello@solidtime.test"
MAIL_REPLY_TO_NAME="solidtime"
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
# Filesystems
FILESYSTEM_DISK=local
PUBLIC_FILESYSTEM_DISK=public
# Passport
PASSPORT_PERSONAL_ACCESS_CLIENT_ID="9e27f54d-5dfb-4dde-99d7-834518236c92"
PASSPORT_PERSONAL_ACCESS_CLIENT_SECRET="EL5mXp3aF8ITjcwoOXRpbSK7zGrWhW4zTDpQXTkf"
# Auditing
AUDITING_ENABLED=true
# Telescope
TELESCOPE_ENABLED=false
# Services
GOTENBERG_URL=http://0.0.0.0:3000
PUSHER_APP_ID=
PUSHER_APP_KEY=
PUSHER_APP_SECRET=
PUSHER_HOST=
PUSHER_PORT=443
PUSHER_SCHEME=https
PUSHER_APP_CLUSTER=mt1
VITE_APP_NAME="${APP_NAME}"
VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
VITE_PUSHER_HOST="${PUSHER_HOST}"
VITE_PUSHER_PORT="${PUSHER_PORT}"
VITE_PUSHER_SCHEME="${PUSHER_SCHEME}"
VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"

View File

@@ -4,7 +4,7 @@ APP_ENV=local
APP_KEY=base64:UNQNf1SXeASNkWux01Rj8EnHYx8FO0kAxWNDwktclkk=
APP_DEBUG=true
APP_URL=https://solidtime.test
APP_FORCE_HTTPS=false
AUDITING_ENABLED=true
APP_ENABLE_REGISTRATION=true
SUPER_ADMINS=admin@example.com
PAGINATION_PER_PAGE_DEFAULT=500
@@ -49,9 +49,7 @@ MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS="no-reply@solidtime.test"
MAIL_FROM_NAME="solidtime"
MAIL_REPLY_TO_ADDRESS="hello@solidtime.test"
MAIL_REPLY_TO_NAME="solidtime"
MAIL_FROM_NAME="${APP_NAME}"
# Filesystems
FILESYSTEM_DISK=s3
@@ -64,24 +62,14 @@ S3_URL=http://storage.solidtime.test/local
S3_ENDPOINT=http://storage.solidtime.test
S3_USE_PATH_STYLE_ENDPOINT=true
# Passport
PASSPORT_PERSONAL_ACCESS_CLIENT_ID="9e27f54d-5dfb-4dde-99d7-834518236c92"
PASSPORT_PERSONAL_ACCESS_CLIENT_SECRET="EL5mXp3aF8ITjcwoOXRpbSK7zGrWhW4zTDpQXTkf"
# Auditing
AUDITING_ENABLED=true
# Telescope
TELESCOPE_ENABLED=false
# Services
GOTENBERG_URL=http://gotenberg:3000
VITE_HOST_NAME=vite.solidtime.test
VITE_APP_NAME="${APP_NAME}"
# Local setup
NGINX_HOST_NAME=solidtime.test
NETWORK_NAME=reverse-proxy-docker-traefik_routing
FORWARD_DB_PORT=5432
FORWARD_WEB_PORT=8083
VITE_HOST_NAME=vite.solidtime.test
VITE_APP_NAME="${APP_NAME}"
#SAIL_XDEBUG_MODE=develop,debug,coverage

View File

@@ -5,6 +5,7 @@ VITE_APP_NAME=solidtime
APP_ENV=production
APP_DEBUG=false
APP_FORCE_HTTPS=true
SESSION_SECURE_COOKIE=true
OCTANE_SERVER=frankenphp
PAGINATION_PER_PAGE_DEFAULT=500

13
.eslintrc.cjs Normal file
View File

@@ -0,0 +1,13 @@
/* eslint-env node */
require("@rushstack/eslint-patch/modern-module-resolution")
module.exports = {
extends: ['plugin:vue/vue3-essential', '@vue/eslint-config-typescript/recommended', '@vue/eslint-config-prettier'],
rules: {
'vue/multi-word-component-names': 'off',
"@typescript-eslint/no-unused-vars": "off",
"unused-imports/no-unused-imports": "error",
"unused-imports/no-unused-vars": "error",
},
plugins: ['unused-imports'],
}

View File

@@ -14,7 +14,7 @@ on:
name: Build - Public
jobs:
build:
runs-on: ubuntu-22.04
runs-on: ubuntu-latest
permissions:
packages: write
contents: read

View File

@@ -8,8 +8,7 @@ jobs:
contents: read
id-token: write
steps:
- name: "Checkout code"
uses: actions/checkout@v4
- uses: actions/checkout@v4
# Setup .npmrc file to publish to npm
- name: Install root project dependencies
run: npm ci

View File

@@ -8,8 +8,7 @@ jobs:
contents: read
id-token: write
steps:
- name: "Checkout code"
uses: actions/checkout@v4
- uses: actions/checkout@v4
# Setup .npmrc file to publish to npm
- uses: actions/setup-node@v4
with:

View File

@@ -63,7 +63,7 @@ jobs:
run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml
- name: "Upload coverage reports to Codecov"
uses: codecov/codecov-action@v5.3.1
uses: codecov/codecov-action@v4.5.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: solidtime-io/solidtime

View File

@@ -10,6 +10,6 @@ jobs:
uses: actions/checkout@v4
- name: "Check code style"
uses: aglipanci/laravel-pint-action@2.5
uses: aglipanci/laravel-pint-action@2.4
with:
configPath: "pint.json"

View File

@@ -27,47 +27,45 @@ jobs:
- name: "Checkout code"
uses: actions/checkout@v4
- name: "Setup node"
uses: actions/setup-node@v4
- uses: actions/setup-node@v4
with:
node-version: '20.x'
- name: "Setup PHP"
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv
coverage: none
- name: "Run composer install"
- name: Run composer install
run: composer install -n --prefer-dist
- name: "Prepare Laravel Application"
- name: Prepare Laravel Application
run: |
cp .env.ci .env
php artisan key:generate
php artisan passport:keys
php artisan migrate --seed
php artisan passport:keys
- name: "Install dependencies"
- name: Install dependencies
run: npm ci
- name: "Build Frontend"
- name: Build Frontend
run: npm run build
- name: "Run Laravel Server"
- name: Run Laravel Server
run: php artisan serve > /dev/null 2>&1 &
- name: "Install Playwright Browsers"
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: "Run Playwright tests"
- name: Run Playwright tests
run: npx playwright test
env:
PLAYWRIGHT_BASE_URL: 'http://127.0.0.1:8000'
- name: "Upload test results"
uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v4
if: always()
with:
name: test-results

View File

@@ -57,7 +57,7 @@ class UserCreateCommand extends Command
}
$user = null;
DB::transaction(function () use (&$user, $name, $email, $password, $verifyEmail): void {
DB::transaction(function () use (&$user, $name, $email, $password): void {
$user = app(UserService::class)->createUser(
$name,
$email,
@@ -65,7 +65,6 @@ class UserCreateCommand extends Command
'UTC',
Weekday::Monday,
'EUR',
$verifyEmail
);
});
/** @var Organization|null $organization */
@@ -74,6 +73,10 @@ class UserCreateCommand extends Command
throw new LogicException('User does not have an organization');
}
if ($verifyEmail) {
$user->markEmailAsVerified();
}
$this->info('Created user "'.$name.'" ("'.$email.'")');
$this->line('ID: '.$user->getKey());
$this->line('Name: '.$name);

View File

@@ -1,10 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Api;
class PersonalAccessClientIsNotConfiguredException extends ApiException
{
public const string KEY = 'personal_access_client_is_not_configured';
}

View File

@@ -1,148 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\TokenResource\Pages;
use App\Models\Passport\Client;
use App\Models\Passport\Token;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class TokenResource extends Resource
{
protected static ?string $model = Token::class;
protected static ?string $navigationIcon = 'heroicon-o-key';
protected static ?string $navigationGroup = 'Auth';
protected static ?int $navigationSort = 6;
public static function form(Form $form): Form
{
return $form
->columns(1)
->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\Select::make('user_id')
->label('User')
->relationship(name: 'user', titleAttribute: 'name')
->searchable(['name'])
->disabled()
->required(),
Forms\Components\Select::make('client_id')
->label('Client')
->relationship(name: 'client', titleAttribute: 'name')
->searchable(['name'])
->required(),
Forms\Components\Toggle::make('revoked')
->label('Revoked')
->required(),
Forms\Components\DateTimePicker::make('expires_at')
->label('Expires At')
->disabled(),
Forms\Components\DateTimePicker::make('created_at')
->label('Created At')
->disabled(),
Forms\Components\DateTimePicker::make('updated_at')
->label('Updated At')
->disabled(),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('user.name')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('client.name')
->searchable()
->sortable(),
Tables\Columns\IconColumn::make('client.personal_access_client')
->boolean()
->label('API token?')
->sortable(),
Tables\Columns\IconColumn::make('revoked')
->boolean()
->label('Revoked?')
->sortable(),
Tables\Columns\TextColumn::make('expires_at')
->dateTime()
->sortable(),
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->sortable(),
Tables\Columns\TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->defaultSort('created_at', 'desc')
->filters([
TernaryFilter::make('is_personal_access_client')
->queries(
true: function (Builder $query) {
/** @var Builder<Token> $query */
return $query->whereHas('client', function (Builder $query) {
/** @var Builder<Client> $query */
return $query->where('personal_access_client', true);
});
},
false: function (Builder $query) {
/** @var Builder<Token> $query */
return $query->whereHas('client', function (Builder $query) {
/** @var Builder<Client> $query */
return $query->where('personal_access_client', false);
});
},
blank: function (Builder $query) {
/** @var Builder<Token> $query */
return $query;
},
)
->label('API token?'),
TernaryFilter::make('revoked')
->label('Revoked?'),
])
->actions([
Tables\Actions\ViewAction::make(),
])
->bulkActions([
]);
}
public static function getRelations(): array
{
return [
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListTokens::route('/'),
'view' => Pages\ViewToken::route('/{record}'),
];
}
}

View File

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

View File

@@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\TokenResource\Pages;
use App\Filament\Resources\TokenResource;
use Filament\Resources\Pages\ViewRecord;
class ViewToken extends ViewRecord
{
protected static string $resource = TokenResource::class;
protected function getHeaderActions(): array
{
return [
];
}
}

View File

@@ -25,7 +25,6 @@ use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
use STS\FilamentImpersonate\Tables\Actions\Impersonate;
class UserResource extends Resource
@@ -40,9 +39,6 @@ class UserResource extends Resource
public static function form(Form $form): Form
{
/** @var User|null $record */
$record = $form->getRecord();
return $form
->columns(1)
->schema([
@@ -59,13 +55,6 @@ class UserResource extends Resource
Forms\Components\TextInput::make('email')
->label('Email')
->required()
->rules($record?->is_placeholder ? [] : [
UniqueEloquent::make(User::class, 'email')
->ignore($record?->getKey()),
])
->rule([
'email',
])
->maxLength(255),
Forms\Components\Toggle::make('is_placeholder')
->label('Is Placeholder?')
@@ -73,11 +62,7 @@ class UserResource extends Resource
->disabledOn(['edit']),
Forms\Components\DateTimePicker::make('email_verified_at')
->label('Email Verified At')
->hiddenOn(['create'])
->nullable(),
Forms\Components\Toggle::make('is_email_verified')
->label('Email Verified?')
->visibleOn(['create']),
Forms\Components\Select::make('timezone')
->label('Timezone')
->options(fn (): array => app(TimezoneService::class)->getSelectOptions())
@@ -89,16 +74,8 @@ class UserResource extends Resource
->required(),
TextInput::make('password')
->password()
->label('Password')
->dehydrateStateUsing(fn ($state) => Hash::make($state))
->dehydrated(fn ($state) => filled($state))
->hiddenOn(['create'])
->required(fn (string $context): bool => $context === 'create')
->maxLength(255),
TextInput::make('password_create')
->password()
->label('Password')
->visibleOn(['create'])
->required(fn (string $context): bool => $context === 'create')
->maxLength(255),
Forms\Components\Select::make('currency')

View File

@@ -20,11 +20,10 @@ class CreateUser extends CreateRecord
$user = $userService->createUser(
$data['name'],
$data['email'],
$data['password_create'],
$data['password'],
$data['timezone'],
Weekday::from($data['week_start']),
$data['currency'],
(bool) $data['is_email_verified']
);
return $user;

View File

@@ -1,114 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Exceptions\Api\PersonalAccessClientIsNotConfiguredException;
use App\Http\Requests\V1\ApiToken\ApiTokenStoreRequest;
use App\Http\Resources\V1\ApiToken\ApiTokenCollection;
use App\Http\Resources\V1\ApiToken\ApiTokenWithAccessTokenResource;
use App\Models\Passport\Token;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
class ApiTokenController extends Controller
{
/**
* List all api token of the currently authenticated user
*
* This endpoint is independent of organization.
*
* @operationId getApiTokens
*
* @throws AuthorizationException
*/
public function index(): ApiTokenCollection
{
$user = $this->user();
$tokens = $user->tokens()
->where('client_id', '=', config('passport.personal_access_client.id'))
->get();
return new ApiTokenCollection($tokens);
}
/**
* Create a new api token for the currently authenticated user
*
* The response will contain the access token that can be used to send authenticated API requests.
* Please note that the access token is only shown in this response and cannot be retrieved later.
*
* @operationId createApiToken
*
* @throws AuthorizationException|PersonalAccessClientIsNotConfiguredException
*/
public function store(ApiTokenStoreRequest $request): ApiTokenWithAccessTokenResource
{
$user = $this->user();
if (config('passport.personal_access_client.id') === null || config('passport.personal_access_client.secret') === null) {
throw new PersonalAccessClientIsNotConfiguredException;
}
$token = $user->createToken($request->getName(), ['*']);
/** @var Token $tokenModel */
$tokenModel = $token->token;
return new ApiTokenWithAccessTokenResource($tokenModel, $token->accessToken);
}
/**
* Revoke an api token
*
* @operationId revokeApiToken
*
* @throws AuthorizationException
* @throws PersonalAccessClientIsNotConfiguredException
*/
public function revoke(Token $apiToken): JsonResponse
{
$user = $this->user();
if (config('passport.personal_access_client.id') === null || config('passport.personal_access_client.secret') === null) {
throw new PersonalAccessClientIsNotConfiguredException;
}
if ($apiToken->user_id !== $user->getKey()) {
throw new AuthorizationException('API token does not belong to user');
}
if ($apiToken->client_id !== config('passport.personal_access_client.id')) {
throw new AuthorizationException('API token is not a personal access token');
}
$apiToken->revoke();
return response()->json(null, 204);
}
/**
* Delete an api token
*
* @operationId deleteApiToken
*
* @throws AuthorizationException|PersonalAccessClientIsNotConfiguredException
*/
public function destroy(Token $apiToken): JsonResponse
{
$user = $this->user();
if (config('passport.personal_access_client.id') === null || config('passport.personal_access_client.secret') === null) {
throw new PersonalAccessClientIsNotConfiguredException;
}
if ($apiToken->user_id !== $user->getKey()) {
throw new AuthorizationException('API token does not belong to user');
}
if ($apiToken->client_id !== config('passport.personal_access_client.id')) {
throw new AuthorizationException('API token is not a personal access token');
}
$apiToken->delete();
return response()->json(null, 204);
}
}

View File

@@ -64,7 +64,6 @@ class HealthCheckController extends Controller
$response['app_env'] = app()->environment();
$response['app_timezone'] = config('app.timezone');
$response['app_force_https'] = config('app.force_https');
$response['session_secure'] = config('session.secure');
$response['trusted_proxies'] = config('trustedproxy.proxies');
$headers = $request->headers->all();
if (isset($headers['cookie'])) {

View File

@@ -18,7 +18,7 @@ class Kernel extends HttpKernel
* @var array<int, class-string|string>
*/
protected $middleware = [
\App\Http\Middleware\ForceHttps::class,
// \App\Http\Middleware\TrustHosts::class,
\App\Http\Middleware\TrustProxies::class,
\Illuminate\Http\Middleware\HandleCors::class,
\App\Http\Middleware\PreventRequestsDuringMaintenance::class,

View File

@@ -1,29 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\URL;
use Symfony\Component\HttpFoundation\Response;
class ForceHttps
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next, string ...$guards): Response
{
if (config('app.force_https', false)) {
URL::forceScheme('https');
$request->server->set('HTTPS', 'on');
$request->headers->set('X-Forwarded-Proto', 'https');
}
return $next($request);
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Illuminate\Http\Middleware\TrustHosts as Middleware;
class TrustHosts extends Middleware
{
/**
* Get the host patterns that should be trusted.
*
* @return array<int, string|null>
*/
public function hosts(): array
{
return [
$this->allSubdomainsOfApplicationUrl(),
];
}
}

View File

@@ -1,32 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\ApiToken;
use Illuminate\Foundation\Http\FormRequest;
class ApiTokenStoreRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string>>
*/
public function rules(): array
{
return [
'name' => [
'required',
'string',
'min:1',
'max:255',
],
];
}
public function getName(): string
{
return $this->input('name');
}
}

View File

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

View File

@@ -1,38 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\V1\ApiToken;
use App\Http\Resources\V1\BaseResource;
use App\Models\Passport\Token;
use Illuminate\Http\Request;
/**
* @property-read Token $resource
*/
class ApiTokenResource extends BaseResource
{
/**
* Transform the resource into an array.
*
* @return array<string, string|bool|int|null|array<string>>
*/
public function toArray(Request $request): array
{
return [
/** @var string $id ID of the API token, this ID is NOT a UUID */
'id' => $this->resource->id,
/** @var string $name Name of the API token */
'name' => $this->resource->name,
/** @var bool $revoked Whether the API token is revoked */
'revoked' => $this->resource->revoked,
/** @var array<string> $scopes List of scopes that the API token has */
'scopes' => $this->resource->scopes,
/** @var string $created_at When the API token was created (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */
'created_at' => $this->formatDateTime($this->resource->created_at),
/** @var string|null $expires_at At what time the API token expires (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */
'expires_at' => $this->formatDateTime($this->resource->expires_at),
];
}
}

View File

@@ -1,49 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\V1\ApiToken;
use App\Http\Resources\V1\BaseResource;
use App\Models\Passport\Token;
use Illuminate\Http\Request;
/**
* @property-read Token $resource
*/
class ApiTokenWithAccessTokenResource extends BaseResource
{
private string $accessToken;
public function __construct(Token $resource, string $accessToken)
{
$this->accessToken = $accessToken;
parent::__construct($resource);
}
/**
* Transform the resource into an array.
*
* @return array<string, string|bool|int|null|array<string>>
*/
public function toArray(Request $request): array
{
return [
/** @var string $id ID of the API token, this ID is NOT a UUID */
'id' => $this->resource->id,
/** @var string $name Name of the API token */
'name' => $this->resource->name,
/** @var bool $revoked Whether the API token is revoked */
'revoked' => $this->resource->revoked,
/** @var array<string> $scopes List of scopes that the API token has */
'scopes' => $this->resource->scopes,
/** @var string $created_at When the API token was created (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */
'created_at' => $this->formatDateTime($this->resource->created_at),
/** @var string|null $expires_at At what time the API token expires (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */
'expires_at' => $this->formatDateTime($this->resource->expires_at),
// Additional fields
/** @var string $access_token Access token that can be used to authenticate requests */
'access_token' => $this->accessToken,
];
}
}

View File

@@ -1,9 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models\Passport;
use Laravel\Passport\AuthCode as PassportAuthCode;
class AuthCode extends PassportAuthCode {}

View File

@@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models\Passport;
use Database\Factories\Passport\ClientFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Laravel\Passport\Client as PassportClient;
/**
* @property string $id
* @property string|null $user_id
* @property string $name
* @property string|null $secret
* @property string|null $provider
* @property string $redirect
* @property bool $personal_access_client
* @property bool $password_client
* @property bool $revoked
*/
class Client extends PassportClient
{
/** @use HasFactory<ClientFactory> */
use HasFactory;
}

View File

@@ -1,9 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models\Passport;
use Laravel\Passport\PersonalAccessClient as PassportPersonalAccessClient;
class PersonalAccessClient extends PassportPersonalAccessClient {}

View File

@@ -1,9 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models\Passport;
use Laravel\Passport\RefreshToken as PassportRefreshToken;
class RefreshToken extends PassportRefreshToken {}

View File

@@ -1,38 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models\Passport;
use Database\Factories\Passport\TokenFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
use Laravel\Passport\Token as PassportToken;
/**
* @property string $id
* @property null|string $user_id
* @property string $client_id
* @property null|string $name
* @property array<string> $scopes
* @property bool $revoked
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property Carbon|null $expires_at
*/
class Token extends PassportToken
{
/** @use HasFactory<TokenFactory> */
use HasFactory;
/**
* Get the client that the token belongs to.
*
* @return BelongsTo<Client, Token>
*/
public function client(): BelongsTo
{
return $this->belongsTo(Client::class, 'client_id', 'id');
}
}

View File

@@ -7,7 +7,6 @@ namespace App\Models;
use App\Enums\Weekday;
use App\Models\Concerns\CustomAuditable;
use App\Models\Concerns\HasUuids;
use App\Models\Passport\Token;
use Database\Factories\UserFactory;
use Filament\Models\Contracts\FilamentUser;
use Filament\Panel;
@@ -28,13 +27,14 @@ use Laravel\Jetstream\HasProfilePhoto;
use Laravel\Jetstream\HasTeams;
use Laravel\Passport\AuthCode;
use Laravel\Passport\HasApiTokens;
use Laravel\Passport\Token;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
/**
* @property string $id
* @property string $name
* @property string $email
* @property Carbon|null $email_verified_at
* @property string|null $email_verified_at
* @property string|null $password
* @property string|null $two_factor_secret
* @property string $timezone
@@ -44,7 +44,6 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property-read Organization|null $currentOrganization
* @property-read Organization|null $currentTeam
* @property-read string $profile_photo_url
* @property-read Collection<int, Token> $tokens
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property string|null $current_team_id
@@ -197,17 +196,6 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
return $this->hasMany(AuthCode::class);
}
/**
* Get the access tokens for the user.
*
* @return HasMany<Token>
*/
public function tokens(): HasMany
{
return $this->hasMany(Token::class, 'user_id')
->orderBy('created_at', 'desc');
}
/**
* @param Builder<User> $builder
*/

View File

@@ -9,7 +9,6 @@ use App\Models\FailedJob;
use App\Models\Member;
use App\Models\Organization;
use App\Models\OrganizationInvitation;
use App\Models\Passport\Token;
use App\Models\Project;
use App\Models\ProjectMember;
use App\Models\Tag;
@@ -30,6 +29,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
@@ -90,6 +90,12 @@ class AppServiceProvider extends ServiceProvider
);
});
if (config('app.force_https', false)) {
URL::forceScheme('https');
request()->server->set('HTTPS', 'on');
request()->headers->set('X-Forwarded-Proto', 'https');
}
$this->app->scoped(PermissionStore::class, function (Application $app): PermissionStore {
return new PermissionStore;
});
@@ -101,6 +107,5 @@ class AppServiceProvider extends ServiceProvider
// Routing
Route::model('member', Member::class);
Route::model('invitation', OrganizationInvitation::class);
Route::model('apiToken', Token::class);
}
}

View File

@@ -5,11 +5,6 @@ declare(strict_types=1);
namespace App\Providers;
use App\Models\Organization;
use App\Models\Passport\AuthCode;
use App\Models\Passport\Client;
use App\Models\Passport\PersonalAccessClient;
use App\Models\Passport\RefreshToken;
use App\Models\Passport\Token;
use App\Policies\OrganizationPolicy;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Laravel\Jetstream\Jetstream;
@@ -47,16 +42,6 @@ class AuthServiceProvider extends ServiceProvider
// 'delete',
]);
Passport::useTokenModel(Token::class);
Passport::useRefreshTokenModel(RefreshToken::class);
Passport::useAuthCodeModel(AuthCode::class);
Passport::useClientModel(Client::class);
Passport::usePersonalAccessClientModel(PersonalAccessClient::class);
// Passport::tokensExpireIn(now()->addDays(15));
// Passport::refreshTokensExpireIn(now()->addDays(30));
Passport::personalAccessTokensExpireIn(now()->addMonths(12));
// same as passport default above
Jetstream::defaultApiTokenPermissions(['read']);

View File

@@ -69,9 +69,6 @@ class AdminPanelProvider extends PanelProvider
NavigationGroup::make()
->label('System')
->collapsed(),
NavigationGroup::make()
->label('Auth')
->collapsed(),
])
->middleware([
EncryptCookies::class,

View File

@@ -12,12 +12,11 @@ use App\Models\Organization;
use App\Models\ProjectMember;
use App\Models\TimeEntry;
use App\Models\User;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Hash;
class UserService
{
public function createUser(string $name, string $email, string $password, string $timezone, Weekday $weekStart, string $currency, bool $verifyEmail = false): User
public function createUser(string $name, string $email, string $password, string $timezone, Weekday $weekStart, string $currency): User
{
$user = new User;
$user->name = $name;
@@ -25,9 +24,6 @@ class UserService
$user->password = Hash::make($password);
$user->timezone = $timezone;
$user->week_start = $weekStart;
if ($verifyEmail) {
$user->email_verified_at = Carbon::now();
}
$user->save();
$organization = new Organization;

116
composer.lock generated
View File

@@ -128,16 +128,16 @@
},
{
"name": "aws/aws-sdk-php",
"version": "3.339.7",
"version": "3.339.6",
"source": {
"type": "git",
"url": "https://github.com/aws/aws-sdk-php.git",
"reference": "7b7e48ce7970c0416c5fda045df7b93948fbf643"
"reference": "cc0b21de3b1eaabb7d0a1ed4f3f067bad4be5f31"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/7b7e48ce7970c0416c5fda045df7b93948fbf643",
"reference": "7b7e48ce7970c0416c5fda045df7b93948fbf643",
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/cc0b21de3b1eaabb7d0a1ed4f3f067bad4be5f31",
"reference": "cc0b21de3b1eaabb7d0a1ed4f3f067bad4be5f31",
"shasum": ""
},
"require": {
@@ -220,9 +220,9 @@
"support": {
"forum": "https://github.com/aws/aws-sdk-php/discussions",
"issues": "https://github.com/aws/aws-sdk-php/issues",
"source": "https://github.com/aws/aws-sdk-php/tree/3.339.7"
"source": "https://github.com/aws/aws-sdk-php/tree/3.339.6"
},
"time": "2025-02-05T19:06:15+00:00"
"time": "2025-02-04T19:03:40+00:00"
},
{
"name": "bacon/bacon-qr-code",
@@ -692,16 +692,16 @@
},
{
"name": "composer/class-map-generator",
"version": "1.6.0",
"version": "1.5.0",
"source": {
"type": "git",
"url": "https://github.com/composer/class-map-generator.git",
"reference": "ffe442c5974c44a9343e37a0abcb1cc37319f5b9"
"reference": "4b0a223cf5be7c9ee7e0ef1bc7db42b4a97c9915"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/composer/class-map-generator/zipball/ffe442c5974c44a9343e37a0abcb1cc37319f5b9",
"reference": "ffe442c5974c44a9343e37a0abcb1cc37319f5b9",
"url": "https://api.github.com/repos/composer/class-map-generator/zipball/4b0a223cf5be7c9ee7e0ef1bc7db42b4a97c9915",
"reference": "4b0a223cf5be7c9ee7e0ef1bc7db42b4a97c9915",
"shasum": ""
},
"require": {
@@ -745,7 +745,7 @@
],
"support": {
"issues": "https://github.com/composer/class-map-generator/issues",
"source": "https://github.com/composer/class-map-generator/tree/1.6.0"
"source": "https://github.com/composer/class-map-generator/tree/1.5.0"
},
"funding": [
{
@@ -761,7 +761,7 @@
"type": "tidelift"
}
],
"time": "2025-02-05T10:05:34+00:00"
"time": "2024-11-25T16:11:06+00:00"
},
{
"name": "composer/composer",
@@ -1519,16 +1519,16 @@
},
{
"name": "dedoc/scramble",
"version": "v0.12.5",
"version": "v0.12.2",
"source": {
"type": "git",
"url": "https://github.com/dedoc/scramble.git",
"reference": "de055584b61338dd6de6cc1cc20d79da6a442230"
"reference": "9482d3c6285ebb16a067bd28b01e94899e1fddd8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dedoc/scramble/zipball/de055584b61338dd6de6cc1cc20d79da6a442230",
"reference": "de055584b61338dd6de6cc1cc20d79da6a442230",
"url": "https://api.github.com/repos/dedoc/scramble/zipball/9482d3c6285ebb16a067bd28b01e94899e1fddd8",
"reference": "9482d3c6285ebb16a067bd28b01e94899e1fddd8",
"shasum": ""
},
"require": {
@@ -1583,7 +1583,7 @@
],
"support": {
"issues": "https://github.com/dedoc/scramble/issues",
"source": "https://github.com/dedoc/scramble/tree/v0.12.5"
"source": "https://github.com/dedoc/scramble/tree/v0.12.2"
},
"funding": [
{
@@ -1591,7 +1591,7 @@
"type": "github"
}
],
"time": "2025-02-05T11:22:52+00:00"
"time": "2025-02-03T12:23:33+00:00"
},
{
"name": "defuse/php-encryption",
@@ -2249,16 +2249,16 @@
},
{
"name": "filament/actions",
"version": "v3.2.137",
"version": "v3.2.136",
"source": {
"type": "git",
"url": "https://github.com/filamentphp/actions.git",
"reference": "0eee8f8eeea4422c279c2622442b2a9a101d558c"
"reference": "cdefacc18993050cdd37e8e980ec66ca4109ae9a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/filamentphp/actions/zipball/0eee8f8eeea4422c279c2622442b2a9a101d558c",
"reference": "0eee8f8eeea4422c279c2622442b2a9a101d558c",
"url": "https://api.github.com/repos/filamentphp/actions/zipball/cdefacc18993050cdd37e8e980ec66ca4109ae9a",
"reference": "cdefacc18993050cdd37e8e980ec66ca4109ae9a",
"shasum": ""
},
"require": {
@@ -2298,20 +2298,20 @@
"issues": "https://github.com/filamentphp/filament/issues",
"source": "https://github.com/filamentphp/filament"
},
"time": "2025-02-06T11:47:20+00:00"
"time": "2025-01-31T11:08:24+00:00"
},
{
"name": "filament/filament",
"version": "v3.2.137",
"version": "v3.2.136",
"source": {
"type": "git",
"url": "https://github.com/filamentphp/panels.git",
"reference": "407b52ee57feed6f67706af2a18bc81d32aea3f1"
"reference": "c92daf4b6e4b478be5d32d5e1b404ba92bb45414"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/filamentphp/panels/zipball/407b52ee57feed6f67706af2a18bc81d32aea3f1",
"reference": "407b52ee57feed6f67706af2a18bc81d32aea3f1",
"url": "https://api.github.com/repos/filamentphp/panels/zipball/c92daf4b6e4b478be5d32d5e1b404ba92bb45414",
"reference": "c92daf4b6e4b478be5d32d5e1b404ba92bb45414",
"shasum": ""
},
"require": {
@@ -2363,20 +2363,20 @@
"issues": "https://github.com/filamentphp/filament/issues",
"source": "https://github.com/filamentphp/filament"
},
"time": "2025-02-06T11:47:25+00:00"
"time": "2025-01-31T11:08:29+00:00"
},
{
"name": "filament/forms",
"version": "v3.2.137",
"version": "v3.2.136",
"source": {
"type": "git",
"url": "https://github.com/filamentphp/forms.git",
"reference": "6ab0419fd3599aacdd7b2a8f69641cab6f158674"
"reference": "8856b3b3714a0efae65d9f817fcc1934b6f34fda"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/filamentphp/forms/zipball/6ab0419fd3599aacdd7b2a8f69641cab6f158674",
"reference": "6ab0419fd3599aacdd7b2a8f69641cab6f158674",
"url": "https://api.github.com/repos/filamentphp/forms/zipball/8856b3b3714a0efae65d9f817fcc1934b6f34fda",
"reference": "8856b3b3714a0efae65d9f817fcc1934b6f34fda",
"shasum": ""
},
"require": {
@@ -2419,11 +2419,11 @@
"issues": "https://github.com/filamentphp/filament/issues",
"source": "https://github.com/filamentphp/filament"
},
"time": "2025-02-06T11:47:21+00:00"
"time": "2025-01-31T11:08:23+00:00"
},
{
"name": "filament/infolists",
"version": "v3.2.137",
"version": "v3.2.136",
"source": {
"type": "git",
"url": "https://github.com/filamentphp/infolists.git",
@@ -2474,7 +2474,7 @@
},
{
"name": "filament/notifications",
"version": "v3.2.137",
"version": "v3.2.136",
"source": {
"type": "git",
"url": "https://github.com/filamentphp/notifications.git",
@@ -2526,7 +2526,7 @@
},
{
"name": "filament/support",
"version": "v3.2.137",
"version": "v3.2.136",
"source": {
"type": "git",
"url": "https://github.com/filamentphp/support.git",
@@ -2585,16 +2585,16 @@
},
{
"name": "filament/tables",
"version": "v3.2.137",
"version": "v3.2.136",
"source": {
"type": "git",
"url": "https://github.com/filamentphp/tables.git",
"reference": "d420b0b0cbe605c712ab054a43d51baf5095dc87"
"reference": "1b1d9c3b837c11408ad28240dc9e3e33340d870b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/filamentphp/tables/zipball/d420b0b0cbe605c712ab054a43d51baf5095dc87",
"reference": "d420b0b0cbe605c712ab054a43d51baf5095dc87",
"url": "https://api.github.com/repos/filamentphp/tables/zipball/1b1d9c3b837c11408ad28240dc9e3e33340d870b",
"reference": "1b1d9c3b837c11408ad28240dc9e3e33340d870b",
"shasum": ""
},
"require": {
@@ -2633,11 +2633,11 @@
"issues": "https://github.com/filamentphp/filament/issues",
"source": "https://github.com/filamentphp/filament"
},
"time": "2025-02-06T11:47:34+00:00"
"time": "2025-01-31T11:08:58+00:00"
},
{
"name": "filament/widgets",
"version": "v3.2.137",
"version": "v3.2.136",
"source": {
"type": "git",
"url": "https://github.com/filamentphp/widgets.git",
@@ -9112,27 +9112,27 @@
},
{
"name": "spatie/laravel-package-tools",
"version": "1.19.0",
"version": "1.18.3",
"source": {
"type": "git",
"url": "https://github.com/spatie/laravel-package-tools.git",
"reference": "1c9c30ac6a6576b8d15c6c37b6cf23d748df2faa"
"reference": "ba67eee37d86ed775dab7dad58a7cbaf9a6cfe78"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/1c9c30ac6a6576b8d15c6c37b6cf23d748df2faa",
"reference": "1c9c30ac6a6576b8d15c6c37b6cf23d748df2faa",
"url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/ba67eee37d86ed775dab7dad58a7cbaf9a6cfe78",
"reference": "ba67eee37d86ed775dab7dad58a7cbaf9a6cfe78",
"shasum": ""
},
"require": {
"illuminate/contracts": "^9.28|^10.0|^11.0|^12.0",
"illuminate/contracts": "^9.28|^10.0|^11.0",
"php": "^8.0"
},
"require-dev": {
"mockery/mockery": "^1.5",
"orchestra/testbench": "^7.7|^8.0|^9.0|^10.0",
"pestphp/pest": "^1.23|^2.1|^3.1",
"phpunit/phpunit": "^9.5.24|^10.5|^11.5",
"orchestra/testbench": "^7.7|^8.0|^9.0",
"pestphp/pest": "^1.22|^2",
"phpunit/phpunit": "^9.5.24|^10.5",
"spatie/pest-plugin-test-time": "^1.1|^2.2"
},
"type": "library",
@@ -9160,7 +9160,7 @@
],
"support": {
"issues": "https://github.com/spatie/laravel-package-tools/issues",
"source": "https://github.com/spatie/laravel-package-tools/tree/1.19.0"
"source": "https://github.com/spatie/laravel-package-tools/tree/1.18.3"
},
"funding": [
{
@@ -9168,7 +9168,7 @@
"type": "github"
}
],
"time": "2025-02-06T14:58:20+00:00"
"time": "2025-01-22T08:51:18+00:00"
},
{
"name": "spatie/regex",
@@ -14188,16 +14188,16 @@
},
{
"name": "phpunit/phpunit",
"version": "11.5.7",
"version": "11.5.6",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git",
"reference": "e1cb706f019e2547039ca2c839898cd5f557ee5d"
"reference": "3c3ae14c90f244cdda95028c3e469028e8d1c02c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/e1cb706f019e2547039ca2c839898cd5f557ee5d",
"reference": "e1cb706f019e2547039ca2c839898cd5f557ee5d",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/3c3ae14c90f244cdda95028c3e469028e8d1c02c",
"reference": "3c3ae14c90f244cdda95028c3e469028e8d1c02c",
"shasum": ""
},
"require": {
@@ -14269,7 +14269,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
"source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.7"
"source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.6"
},
"funding": [
{
@@ -14285,7 +14285,7 @@
"type": "tidelift"
}
],
"time": "2025-02-06T16:10:05+00:00"
"time": "2025-01-31T07:03:30+00:00"
},
{
"name": "sebastian/cli-parser",

View File

@@ -15,7 +15,7 @@ return [
|
*/
'guard' => 'api',
'guard' => 'web',
/*
|--------------------------------------------------------------------------

View File

@@ -168,7 +168,7 @@ return [
|
*/
'secure' => env('SESSION_SECURE_COOKIE', env('APP_FORCE_HTTPS')),
'secure' => env('SESSION_SECURE_COOKIE'),
/*
|--------------------------------------------------------------------------

View File

@@ -1,55 +0,0 @@
<?php
declare(strict_types=1);
namespace Database\Factories\Passport;
use App\Models\Passport\Client;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Client>
*/
class ClientFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'id' => $this->faker->uuid,
'user_id' => null,
'name' => $this->faker->company(),
'secret' => $this->faker->regexify('[A-Za-z]{40}'),
'provider' => 'users',
'redirect' => $this->faker->url(),
'personal_access_client' => false,
'password_client' => false,
'revoked' => false,
'created_at' => $this->faker->dateTime(),
'updated_at' => $this->faker->dateTime(),
];
}
public function personalAccessClient(): self
{
return $this->state(function (array $attributes) {
return [
'personal_access_client' => true,
];
});
}
public function forUser(User $user): self
{
return $this->state(function (array $attributes) use ($user): array {
return [
'user_id' => $user->getKey(),
];
});
}
}

View File

@@ -1,54 +0,0 @@
<?php
declare(strict_types=1);
namespace Database\Factories\Passport;
use App\Models\Passport\Client;
use App\Models\Passport\Token;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Token>
*/
class TokenFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'id' => $this->faker->uuid,
'user_id' => null,
'client_id' => $this->faker->uuid,
'name' => null,
'scopes' => [],
'revoked' => false,
'created_at' => $this->faker->dateTime,
'updated_at' => $this->faker->dateTime,
'expires_at' => $this->faker->dateTime,
];
}
public function forUser(User $user): self
{
return $this->state(function (array $attributes) use ($user): array {
return [
'user_id' => $user->getKey(),
];
});
}
public function forClient(Client $client): self
{
return $this->state(function (array $attributes) use ($client): array {
return [
'client_id' => $client->getKey(),
];
});
}
}

View File

@@ -34,29 +34,6 @@ class DatabaseSeeder extends Seeder
public function run(): void
{
$this->deleteAll();
app(ClientRepository::class)->create(
null,
'desktop',
'solidtime://oauth/callback',
null,
false,
false,
false
);
$personalAccessClient = new PassportClient;
$personalAccessClient->id = config('passport.personal_access_client.id');
$personalAccessClient->secret = config('passport.personal_access_client.secret');
$personalAccessClient->name = 'API';
$personalAccessClient->redirect = 'http://localhost';
$personalAccessClient->user_id = null;
$personalAccessClient->revoked = false;
$personalAccessClient->provider = null;
$personalAccessClient->personal_access_client = true;
$personalAccessClient->password_client = false;
$personalAccessClient->save();
$userWithMultipleOrganizations = User::factory()->withPersonalOrganization()->create([
'name' => 'Mister Overemployed',
'email' => 'overemployed@acme.test',
@@ -78,8 +55,6 @@ class DatabaseSeeder extends Seeder
'name' => 'Acme Manager',
'email' => 'test@example.com',
]);
$userAcmeManager->createToken('Testing Token 1')->accessToken;
$userAcmeManager->createToken('Testing Token 2')->accessToken;
$userAcmeAdmin = User::factory()->withPersonalOrganization()->create([
'name' => 'Acme Admin',
'email' => 'admin@acme.test',
@@ -184,6 +159,15 @@ class DatabaseSeeder extends Seeder
'email' => 'admin@example.com',
]);
app(ClientRepository::class)->create(
null,
'desktop',
'solidtime://oauth/callback',
null,
false,
false,
false
);
}
private function deleteAll(): void

View File

@@ -109,7 +109,7 @@ services:
- sail
- reverse-proxy
playwright:
image: mcr.microsoft.com/playwright:v1.50.0-jammy
image: mcr.microsoft.com/playwright:v1.46.1-jammy
command: ['npx', 'playwright', 'test', '--ui-port=8080', '--ui-host=0.0.0.0']
working_dir: /src
extra_hosts:

View File

@@ -1,69 +1,25 @@
import {test, expect} from '../playwright/fixtures';
import {PLAYWRIGHT_BASE_URL} from '../playwright/config';
import { test, expect } from '../playwright/fixtures';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
test('test that user name can be updated', async ({page}) => {
test('test that user name can be updated', async ({ page }) => {
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
await page.getByLabel('Name', {exact: true} ).fill('NEW NAME');
await page.getByLabel('Name').fill('NEW NAME');
await Promise.all([
page.getByRole('button', {name: 'Save'}).first().click(),
page.getByRole('button', { name: 'Save' }).first().click(),
page.waitForResponse('**/user/profile-information'),
]);
await page.reload();
await expect(page.getByLabel('Name', {exact: true})).toHaveValue('NEW NAME');
await expect(page.getByLabel('Name')).toHaveValue('NEW NAME');
});
test.skip('test that user email can be updated', async ({page}) => {
test.skip('test that user email can be updated', async ({ page }) => {
// this does not work because of email verification currently
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
const emailId = Math.round(Math.random() * 10000);
await page.getByLabel('Email').fill(`newemail+${emailId}@test.com`);
await page.getByRole('button', {name: 'Save'}).first().click();
await page.getByRole('button', { name: 'Save' }).first().click();
await page.reload();
await expect(page.getByLabel('Email')).toHaveValue(
`newemail+${emailId}@test.com`
);
});
async function createNewApiToken(page) {
await page.getByLabel('API Key Name').fill('NEW API KEY');
await Promise.all([
page.getByRole('button', {name: 'Create API Key'}).click(),
page.waitForResponse('**/users/me/api-tokens')
]);
await expect(page.locator('body')).toContainText('API Token created successfully');
await page.getByRole('dialog').getByText('Close').click();
await expect(page.locator('body')).toContainText('NEW API KEY');
}
test('test that user can create an API key', async ({page}) => {
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
await createNewApiToken(page);
});
test('test that user can delete an API key', async ({page}) => {
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
await createNewApiToken(page);
page.getByLabel('Delete API Token NEW API KEY').click();
await expect(page.getByRole('dialog')).toContainText('Are you sure you would like to delete this API token?');
await Promise.all([
page.getByRole('dialog').getByRole('button', {name: 'Delete'}).click(),
page.waitForResponse('**/users/me/api-tokens')
]);
await expect(page.locator('body')).not.toContainText('NEW API KEY');
});
test('test that user can revoke an API key', async ({page}) => {
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
await createNewApiToken(page);
page.getByLabel('Revoke API Token NEW API KEY').click();
await expect(page.getByRole('dialog')).toContainText('Are you sure you would like to revoke this API token?');
await Promise.all([
page.getByRole('dialog').getByRole('button', {name: 'Revoke'}).click(),
page.waitForResponse('**/users/me/api-tokens')
]);
await expect(page.getByRole('button', {name: 'Revoke'})).toBeHidden();
await expect(page.locator('body')).toContainText('NEW API KEY');
await expect(page.locator('body')).toContainText('Revoked');
});

View File

@@ -191,7 +191,7 @@ test('test that updating a the start of an existing time entry in the overview w
'time_entry_range_selector'
);
await timeEntryRangeElement.click();
await page.getByTestId('time_entry_range_start').first().fill('1');
await page.getByTestId('time_picker_input').first().fill('1');
await Promise.all([
page.waitForResponse(async (response) => {
return (
@@ -204,7 +204,10 @@ test('test that updating a the start of an existing time entry in the overview w
(await response.json()).data.end !== null
);
}),
page.getByTestId('time_entry_range_end').press('Enter'),
page
.getByTestId('time_entry_range_end')
.getByTestId('time_picker_input')
.press('Enter'),
]);
});

View File

@@ -152,7 +152,7 @@ test('test that starting and updating the time while running works', async ({
JSON.stringify([])
);
}),
page.getByTestId('time_entry_time').press('Enter'),
page.getByTestId('time_entry_time').press('Tab'),
]);
await expect(page.getByTestId('time_entry_time')).toHaveValue(/00:20/);

View File

@@ -1,36 +0,0 @@
import eslint from '@eslint/js';
import eslintConfigPrettier from 'eslint-config-prettier';
import eslintPluginVue from 'eslint-plugin-vue';
import globals from 'globals';
import typescriptEslint from 'typescript-eslint';
import unusedImports from "eslint-plugin-unused-imports";
export default typescriptEslint.config(
{ ignores: ['*.d.ts', '**/coverage', '**/dist'] },
{
extends: [
eslint.configs.recommended,
...typescriptEslint.configs.recommended,
...eslintPluginVue.configs['flat/recommended'],
],
files: ['**/*.{ts,vue,js}'],
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
globals: globals.browser,
parserOptions: {
parser: typescriptEslint.parser,
},
},
plugins: {
"unused-imports": unusedImports,
},
rules: {
"vue/multi-word-component-names": "off",
"@typescript-eslint/no-unused-vars": "off",
"unused-imports/no-unused-imports": "error",
"unused-imports/no-unused-vars": "error",
},
},
eslintConfigPrettier
);

View File

@@ -12,7 +12,6 @@ use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
use App\Exceptions\Api\OrganizationHasNoSubscriptionButMultipleMembersException;
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
use App\Exceptions\Api\PersonalAccessClientIsNotConfiguredException;
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
use App\Exceptions\Api\TimeEntryStillRunningApiException;
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
@@ -38,7 +37,6 @@ return [
OrganizationHasNoSubscriptionButMultipleMembersException::KEY => 'Organization has no subscription but multiple members',
PdfRendererIsNotConfiguredException::KEY => 'PDF renderer is not configured',
FeatureIsNotAvailableInFreePlanApiException::KEY => 'Feature is not available in free plan',
PersonalAccessClientIsNotConfiguredException::KEY => 'Personal access client is not configured',
],
'unknown_error_in_admin_panel' => 'An unknown error occurred. Please check the logs.',
];

View File

@@ -5,20 +5,18 @@ declare(strict_types=1);
return [
'clockify_time_entries' => [
'name' => 'Clockify Time Entries',
'description' => '1. First make sure that you set the Date format to "MM/DD/YYYY" and the Time format to "12-hour" in the user settings.<br>'.
'2. In the same preferences page change the language of Clockfiy to English.<br>'.
'3. Go to REPORTS -> TIME -> Detailed in the navigation on the left. <br>'.
'4. Now select the date range that you want to export in the right top. '.
'description' => '1. First make sure that you set the Date format to "MM/DD/YYYY" and the Time format to "12-hour" in the user settings.<br> '.
'2. Go to REPORTS -> TIME -> Detailed in the navigation on the left. <br>'.
'3. Now select the date range that you want to export in the right top. '.
'It is currently not possible to select more than one year. You can export each year separately and import them one after another .'.
'<br> 4. Now click Export -> Save as CSV. The Export dropdown is in the header of the export table left of the printer symbol. '.
'<br><br>Before you import make sure that the Timezone settings in Clockify are the same as in solidtime.',
],
'clockify_projects' => [
'name' => 'Clockify Projects',
'description' => '1. Make sure to set the language of Clockify to English in "Preferences -> General".<br>'.
'2. Go to PROJECTS in the navigation on the left.<br> '.
'3. Now click on the three dots on the right of the project that you want to export and select Export.<br> '.
'4. Now click Export -> Save as CSV. The Export dropdown is in the header of the export table in the top right corner.',
'description' => '1. Go to PROJECTS in the navigation on the left.<br> '.
'2. Now click on the three dots on the right of the project that you want to export and select Export.<br> '.
'3. Now click Export -> Save as CSV. The Export dropdown is in the header of the export table in the top right corner.',
],
'toggl_data_importer' => [
'name' => 'Toggl Data Importer',

3414
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,60 +4,53 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint resources/js",
"lint:fix": "eslint --fix resources/js",
"lint": "eslint --ext .js,.vue,.ts --ignore-path .gitignore resources/js",
"lint:fix": "eslint --fix --ext .js,.vue,.ts --ignore-path .gitignore resources/js",
"type-check": "vue-tsc --noEmit",
"test:e2e": "rm -rf test-results/.auth && npx playwright test",
"zod:generate": "npx openapi-zod-client http://localhost:80/docs/api.json --output resources/js/packages/api/src/openapi.json.client.ts --base-url /api"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.19.0",
"@inertiajs/vue3": "^1.0.0",
"@playwright/test": "^1.41.1",
"@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15",
"@types/node": "^22.10.10",
"@vitejs/plugin-vue": "^5.2.1",
"@types/node": "^20.11.5",
"@vitejs/plugin-vue": "^4.5.0",
"@vue/tsconfig": "^0.5.1",
"autoprefixer": "^10.4.20",
"axios": "^1.6.4",
"eslint-plugin-unused-imports": "^4.1.4",
"eslint-plugin-unused-imports": "^3.1.0",
"laravel-vite-plugin": "^1.0.0",
"openapi-zod-client": "^1.16.2",
"postcss": "^8.4.47",
"postcss-nesting": "^12.1.5",
"tailwindcss": "^3.4.13",
"typescript": "^5.7.3",
"vite": "^6.0.11",
"vite-plugin-checker": "^0.8.0",
"vue": "^3.5.0",
"vue-tsc": "^2.2.0"
"typescript": "^5.3.3",
"vite": "^5.0.0",
"vite-plugin-checker": "^0.7.2",
"vue": "^3.4.0",
"vue-tsc": "^2.0.28"
},
"dependencies": {
"@floating-ui/core": "^1.6.0",
"@floating-ui/vue": "^1.0.6",
"@heroicons/vue": "^2.1.1",
"@rushstack/eslint-patch": "^1.10.5",
"@rushstack/eslint-patch": "^1.7.0",
"@tailwindcss/container-queries": "^0.1.1",
"@tanstack/vue-query": "^5.56.2",
"@tanstack/vue-query-devtools": "^5.58.0",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.3.0",
"@vueuse/core": "^12.5.0",
"@vueuse/integrations": "^12.5.0",
"@vue/eslint-config-prettier": "^9.0.0",
"@vue/eslint-config-typescript": "^13.0.0",
"@vueuse/core": "^10.11.0",
"@vueuse/integrations": "^11.1.0",
"dayjs": "^1.11.11",
"echarts": "^5.5.0",
"focus-trap": "^7.6.0",
"parse-duration": "^2.0.1",
"parse-duration": "^1.1.0",
"pinia": "^2.1.7",
"radix-vue": "^1.9.6",
"tailwind-merge": "^2.2.1",
"vue-echarts": "^7.0.3"
},
"overrides": {
"vite-plugin-checker": {
"vue-tsc": "$vue-tsc"
}
"vue-echarts": "^6.7.2"
}
}

View File

@@ -29,8 +29,6 @@
</source>
<php>
<env name="APP_ENV" value="testing"/>
<env name="APP_FORCE_HTTPS" value="false"/>
<env name="TRUSTED_PROXIES" value="0.0.0.0/0,2000:0:0:0:0:0:0:0/3"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_DRIVER" value="array"/>
<env name="DB_CONNECTION" value="pgsql_test"/>
@@ -41,7 +39,5 @@
<env name="TELESCOPE_ENABLED" value="false"/>
<env name="AUDITING_ENABLED" value="true"/>
<env name="NEWSLETTER_URL" value="null"/>
<env name="PASSPORT_PERSONAL_ACCESS_CLIENT_ID" value="null"/>
<env name="PASSPORT_PERSONAL_ACCESS_CLIENT_SECRET" value="null"/>
</php>
</phpunit>

View File

@@ -112,7 +112,7 @@ const showBlackFridayBanner = computed(() => {
<span>Upgrade now</span>
</div>
</Link>
<button class="p-1" @click="hideBlackFridayBanner = true">
<button @click="hideBlackFridayBanner = true" class="p-1">
<XMarkIcon
class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
</button>
@@ -142,7 +142,7 @@ const showBlackFridayBanner = computed(() => {
<span>Upgrade now</span>
</div>
</Link>
<button class="p-1" @click="hideTrialBanner = true">
<button @click="hideTrialBanner = true" class="p-1">
<XMarkIcon
class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
</button>
@@ -174,7 +174,7 @@ const showBlackFridayBanner = computed(() => {
<span>Upgrade now</span>
</div>
</Link>
<button class="p-1" @click="hideBlockedBanner = true">
<button @click="hideBlockedBanner = true" class="p-1">
<XMarkIcon
class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
</button>
@@ -206,7 +206,7 @@ const showBlackFridayBanner = computed(() => {
<span>Upgrade now</span>
</div>
</Link>
<button class="p-1" @click="hideFreeUpgradeBanner = true">
<button @click="hideFreeUpgradeBanner = true" class="p-1">
<XMarkIcon
class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
</button>

View File

@@ -45,10 +45,10 @@ useFocus(clientNameInput, { initialValue: true });
v-model="client.name"
type="text"
placeholder="Client Name"
@keydown.enter="submit"
class="mt-1 block w-full"
required
autocomplete="clientName"
@keydown.enter="submit" />
autocomplete="clientName" />
</div>
</div>
</template>

View File

@@ -46,10 +46,10 @@ useFocus(clientNameInput, { initialValue: true });
v-model="clientBody.name"
type="text"
placeholder="Client Name"
@keydown.enter="submit"
class="mt-1 block w-full"
required
autocomplete="clientName"
@keydown.enter="submit" />
autocomplete="clientName" />
</div>
</div>
</template>

View File

@@ -23,28 +23,28 @@ const props = defineProps<{
<div class="min-w-[150px]">
<button
v-if="canUpdateClients()"
@click="emit('edit')"
:aria-label="'Edit Client ' + props.client.name"
data-testid="client_edit"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
@click="emit('edit')">
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<PencilSquareIcon
class="w-5 text-icon-active"></PencilSquareIcon>
<span>Edit</span>
</button>
<button
@click.prevent="emit('archive')"
v-if="canUpdateClients()"
:aria-label="'Archive Client ' + props.client.name"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
@click.prevent="emit('archive')">
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<ArchiveBoxIcon class="w-5 text-icon-active"></ArchiveBoxIcon>
<span>{{ client.is_archived ? 'Unarchive' : 'Archive' }}</span>
</button>
<button
v-if="canDeleteClients()"
@click="emit('delete')"
:aria-label="'Delete Client ' + props.client.name"
data-testid="client_delete"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
@click="emit('delete')">
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
<span>Delete</span>
</button>

View File

@@ -18,7 +18,7 @@ function getNameForItem(item: Client) {
<template>
<MultiselectDropdown
search-placeholder="Search for a Client..."
searchPlaceholder="Search for a Client..."
:items="clients"
:get-key-from-item="getKeyFromItem"
:get-name-for-item="getNameForItem">

View File

@@ -25,18 +25,18 @@ const createClient = ref(false);
style="grid-template-columns: 1fr 150px 200px 80px">
<ClientTableHeading></ClientTableHeading>
<div
v-if="clients.length === 0"
class="col-span-2 py-24 text-center">
class="col-span-2 py-24 text-center"
v-if="clients.length === 0">
<UserCircleIcon
class="w-8 text-icon-default inline pb-2"></UserCircleIcon>
<h3 class="text-white font-semibold">No clients found</h3>
<p v-if="canCreateClients()" class="pb-5">
<p class="pb-5" v-if="canCreateClients()">
Create your first client now!
</p>
<SecondaryButton
v-if="canCreateClients()"
:icon="PlusIcon as Component"
@click="createClient = true"
:icon="PlusIcon as Component"
>Create your First Client
</SecondaryButton>
</div>

View File

@@ -38,8 +38,8 @@ const showEditModal = ref(false);
<template>
<TableRow>
<ClientEditModal
v-model:show="showEditModal"
:client="client"></ClientEditModal>
:client="client"
v-model:show="showEditModal"></ClientEditModal>
<div
class="whitespace-nowrap flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-white pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
<span>

View File

@@ -10,16 +10,16 @@ const emit = defineEmits<{
<template>
<MoreOptionsDropdown label="Actions for the invitation">
<button
@click="emit('resend')"
data-testid="invitation_delete"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
@click="emit('resend')">
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<ArrowPathIcon class="w-5 text-icon-active"></ArrowPathIcon>
<span>Resend Invitation</span>
</button>
<button
@click="emit('delete')"
data-testid="invitation_delete"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
@click="emit('delete')">
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
<span>Delete</span>
</button>

View File

@@ -39,7 +39,7 @@ async function resendInvitation() {
await handleApiRequestNotifications(
() =>
api.resendInvitationEmail(
undefined,
{},
{
params: {
invitation: props.invitation.id,

View File

@@ -18,10 +18,10 @@ defineEmits<{
<template>
<BillableRateModal
@submit="$emit('submit')"
v-model:show="show"
v-model:saving="saving"
title="Update Member Billable Rate"
@submit="$emit('submit')">
title="Update Member Billable Rate">
<p class="py-1 text-center">
The billable rate of {{ memberName }} will be updated to
<strong>{{

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { computed, onMounted, ref, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { useMembersStore } from '@/utils/useMembers';
import { UserIcon, ChevronDownIcon } from '@heroicons/vue/24/solid';
@@ -17,8 +17,8 @@ const model = defineModel<string>({
const props = withDefaults(
defineProps<{
hiddenMembers?: ProjectMember[];
disabled?: boolean;
hiddenMembers: ProjectMember[];
disabled: boolean;
}>(),
{
hiddenMembers: () => [] as ProjectMember[],
@@ -46,6 +46,22 @@ const filteredMembers = computed<Member[]>(() => {
});
});
watch(filteredMembers, () => {
resetHighlightedItem();
});
onMounted(() => {
resetHighlightedItem();
});
function resetHighlightedItem() {
if (filteredMembers.value.length > 0) {
highlightedItemId.value = filteredMembers.value[0].id;
}
}
const highlightedItemId = ref<string | null>(null);
const currentValue = computed(() => {
if (model.value) {
return members.value.find((member) => member.id === model.value)?.name;
@@ -60,7 +76,7 @@ const currentValue = computed(() => {
:items="filteredMembers"
:get-key-from-item="(member) => member.id"
:get-name-for-item="(member) => member.name">
<template #trigger>
<template v-slot:trigger>
<Badge
tag="button"
class="flex w-full text-base text-left space-x-3 px-3 text-text-secondary font-normal cursor py-1.5">
@@ -68,7 +84,7 @@ const currentValue = computed(() => {
<div v-if="currentValue" class="flex-1 truncate">
{{ currentValue }}
</div>
<div v-else class="flex-1">Select a member...</div>
<div class="flex-1" v-else>Select a member...</div>
<ChevronDownIcon class="w-4 text-muted"></ChevronDownIcon>
</Badge>
</template>

View File

@@ -108,11 +108,11 @@ const roleDescription = computed(() => {
v-model:saving="saving"
v-model:show="showBillableRateModal"
:member-name="member.name"
:new-billable-rate="memberBody.billable_rate"
:newBillableRate="memberBody.billable_rate"
@submit="submitBillableRate"></MemberBillableRateModal>
<MemberOwnershipTransferConfirmModal
v-model:show="showOwnershipTransferConfirmModal"
:member-name="member.name"
v-model:show="showOwnershipTransferConfirmModal"
@submit="submit"></MemberOwnershipTransferConfirmModal>
<DialogModal closeable :show="show" @close="show = false">
<template #title>
@@ -127,9 +127,9 @@ const roleDescription = computed(() => {
<div>
<InputLabel for="role" value="Role" />
<MemberRoleSelect
v-model="memberBody.role"
class="mt-2"
name="role"></MemberRoleSelect>
name="role"
v-model="memberBody.role"></MemberRoleSelect>
</div>
<div class="flex-1 text-xs flex items-center pt-6">
<p>{{ roleDescription }}</p>
@@ -140,28 +140,28 @@ const roleDescription = computed(() => {
<div>
<InputLabel for="billableType" value="Billable" />
<MemberBillableSelect
class="mt-2"
name="billableType"
v-model="
billableRateSelect
"
class="mt-2"
name="billableType"></MemberBillableSelect>
"></MemberBillableSelect>
</div>
<div
v-if="billableRateSelect === 'custom-rate'"
class="flex-1">
class="flex-1"
v-if="billableRateSelect === 'custom-rate'">
<InputLabel
for="memberBillableRate"
class="mb-2"
value="Billable Rate" />
<BillableRateInput
v-model="
memberBody.billable_rate
"
focus
class="w-full"
:currency="getOrganizationCurrencyString()"
@keydown.enter="saveWithChecks()"
name="memberBillableRate"
@keydown.enter="saveWithChecks()"></BillableRateInput>
v-model="
memberBody.billable_rate
"></BillableRateInput>
</div>
</div>
</div>

View File

@@ -112,11 +112,11 @@ useFocus(clientNameInput, { initialValue: true });
v-if="isBillingActivated() && canManageBilling()"
href="/billing">
<PrimaryButton
type="button"
class="mt-6"
v-if="
isBillingActivated() && canUpdateOrganization()
"
type="button"
class="mt-6">
">
<CreditCardIcon class="w-5 h-5 me-2" />
Go to Billing
</PrimaryButton>
@@ -128,15 +128,15 @@ useFocus(clientNameInput, { initialValue: true });
<InputLabel for="email" value="Email" />
<TextInput
id="email"
name="email"
ref="memberEmailInput"
v-model="addTeamMemberForm.email"
name="email"
type="text"
placeholder="Member Email"
@keydown.enter="submit"
class="mt-1 block w-full"
required
autocomplete="memberName"
@keydown.enter="submit" />
autocomplete="memberName" />
<InputError :message="errors.email" class="mt-2" />
</div>

View File

@@ -20,19 +20,19 @@ const props = defineProps<{
<div class="min-w-[150px]">
<button
v-if="canUpdateMembers()"
@click="emit('edit')"
:aria-label="'Edit Member ' + props.member.name"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
@click="emit('edit')">
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<PencilSquareIcon
class="w-5 text-icon-active"></PencilSquareIcon>
<span>Edit</span>
</button>
<button
v-if="canDeleteMembers()"
@click="emit('delete')"
:aria-label="'Delete Member ' + props.member.name"
data-testid="member_delete"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
@click="emit('delete')">
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
<span>Delete</span>
</button>

View File

@@ -18,7 +18,7 @@ function getNameForItem(item: Member) {
<template>
<MultiselectDropdown
search-placeholder="Search for a Member..."
searchPlaceholder="Search for a Member..."
:items="members"
:get-key-from-item="getKeyFromItem"
:get-name-for-item="getNameForItem">

View File

@@ -36,9 +36,9 @@ const emit = defineEmits<{
<SecondaryButton @click="show = false"> Cancel</SecondaryButton>
<PrimaryButton
class="ms-3"
@click="emit('submit')"
:class="{ 'opacity-25': saving }"
:disabled="saving"
@click="emit('submit')">
:disabled="saving">
Confirm Transfer
</PrimaryButton>
</template>

View File

@@ -32,7 +32,7 @@ async function invitePlaceholder(id: string) {
await handleApiRequestNotifications(
() =>
api.invitePlaceholder(
undefined,
{},
{
params: {
organization: organizationId,
@@ -89,8 +89,8 @@ async function invitePlaceholder(id: string) {
member.is_placeholder === true &&
canInvitePlaceholderMembers()
"
size="small"
@click="invitePlaceholder(member.id)"
size="small"
>Invite</SecondaryButton
>
<MemberMoreOptionsDropdown
@@ -99,8 +99,8 @@ async function invitePlaceholder(id: string) {
@delete="removeMember"></MemberMoreOptionsDropdown>
</div>
<MemberEditModal
v-model:show="showEditMemberModal"
:member="member"></MemberEditModal>
:member="member"
v-model:show="showEditMemberModal"></MemberEditModal>
</TableRow>
</template>

View File

@@ -34,8 +34,8 @@
<div class="ml-4 flex flex-shrink-0">
<button
type="button"
class="inline-flex rounded-md bg-card-background text-muted hover:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
@click="show = false">
@click="show = false"
class="inline-flex rounded-md bg-card-background text-muted hover:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2">
<span class="sr-only">Close</span>
<XMarkIcon class="h-5 w-5" aria-hidden="true" />
</button>

View File

@@ -17,10 +17,10 @@ defineEmits<{
<template>
<BillableRateModal
@submit="$emit('submit')"
v-model:show="show"
v-model:saving="saving"
title="Update Organization Billable Rate"
@submit="$emit('submit')">
title="Update Organization Billable Rate">
<p class="py-0.5 text-center">
The organization billable rate will be updated to
<strong>{{

View File

@@ -40,7 +40,7 @@ const shownProjects = computed(() => {
withDefaults(
defineProps<{
border?: boolean;
border: boolean;
}>(),
{
border: true,
@@ -123,17 +123,17 @@ function updateValue(project: Project) {
<template #content>
<ComboboxRoot
:open="open"
:model-value="currentProject"
:search-term="searchValue"
class="relative"
@update:model-value="updateValue"
@update:search-term="(e) => console.log(e)">
:modelValue="currentProject"
@update:modelValue="updateValue"
@update:searchTerm="(e) => console.log(e)"
:searchTerm="searchValue"
class="relative">
<ComboboxAnchor>
<ComboboxInput
@keydown.enter="addProjectIfNoneExists"
ref="searchInput"
class="bg-card-background border-0 placeholder-muted text-sm text-white py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
placeholder="Search for a project..."
@keydown.enter="addProjectIfNoneExists" />
placeholder="Search for a project..." />
</ComboboxAnchor>
<ComboboxContent>
<ComboboxViewport

View File

@@ -90,8 +90,8 @@ async function submitBillableRate() {
<div class="text-center">
<InputLabel for="color" value="Color" />
<ProjectColorSelector
v-model="project.color"
class="mt-1"></ProjectColorSelector>
class="mt-1"
v-model="project.color"></ProjectColorSelector>
</div>
</div>
<div class="w-full">
@@ -102,18 +102,18 @@ async function submitBillableRate() {
v-model="project.name"
type="text"
placeholder="Project Name"
@keydown.enter="submit()"
class="mt-1 block w-full"
required
autocomplete="projectName"
@keydown.enter="submit()" />
autocomplete="projectName" />
</div>
<div class="">
<InputLabel for="client" value="Client" />
<ClientDropdown
v-model="project.client_id"
:create-client
:createClient
:clients="clients"
class="mt-1">
class="mt-1"
v-model="project.client_id">
<template #trigger>
<Badge
class="bg-input-background cursor-pointer hover:bg-tertiary"
@@ -133,18 +133,18 @@ async function submitBillableRate() {
<div class="lg:grid grid-cols-2 gap-12">
<div>
<ProjectEditBillableSection
v-model:is-billable="project.is_billable"
v-model:billable-rate="
project.billable_rate
"
@submit="submit"
:currency="getOrganizationCurrencyString()"
@submit="submit"></ProjectEditBillableSection>
v-model:isBillable="project.is_billable"
v-model:billableRate="
project.billable_rate
"></ProjectEditBillableSection>
</div>
<div>
<EstimatedTimeSection
v-if="isAllowedToPerformPremiumAction()"
v-model="project.estimated_time"
@submit="submit()"></EstimatedTimeSection>
@submit="submit()"
v-model="project.estimated_time"></EstimatedTimeSection>
</div>
</div>
</template>
@@ -163,9 +163,9 @@ async function submitBillableRate() {
<ProjectBillableRateModal
v-model:show="showBillableRateModal"
:currency="getOrganizationCurrencyString()"
@submit="submitBillableRate"
:new-billable-rate="project.billable_rate"
:project-name="project.name"
@submit="submitBillableRate"></ProjectBillableRateModal>
:project-name="project.name"></ProjectBillableRateModal>
</template>
<style scoped></style>

View File

@@ -21,29 +21,29 @@ const props = defineProps<{
<MoreOptionsDropdown :label="'Actions for Project ' + props.project.name">
<div class="min-w-[150px]">
<button
@click.prevent="emit('edit')"
v-if="canUpdateProjects()"
:aria-label="'Edit Project ' + props.project.name"
data-testid="project_edit"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
@click.prevent="emit('edit')">
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<PencilSquareIcon
class="w-5 text-icon-active"></PencilSquareIcon>
<span>Edit</span>
</button>
<button
@click.prevent="emit('archive')"
v-if="canUpdateProjects()"
:aria-label="'Archive Project ' + props.project.name"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
@click.prevent="emit('archive')">
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<ArchiveBoxIcon class="w-5 text-icon-active"></ArchiveBoxIcon>
<span>{{ project.is_archived ? 'Unarchive' : 'Archive' }}</span>
</button>
<button
v-if="canDeleteProjects()"
@click.prevent="emit('delete')"
:aria-label="'Delete Project ' + props.project.name"
data-testid="project_delete"
class="border-b border-card-background-separator flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
@click.prevent="emit('delete')">
v-if="canDeleteProjects()"
class="border-b border-card-background-separator flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
<span>Delete</span>
</button>

View File

@@ -18,7 +18,7 @@ function getNameForItem(item: Project) {
<template>
<MultiselectDropdown
search-placeholder="Search for a Project..."
searchPlaceholder="Search for a Project..."
:items="projects"
:get-key-from-item="getKeyFromItem"
:get-name-for-item="getNameForItem">

View File

@@ -44,12 +44,12 @@ import { isAllowedToPerformPremiumAction } from '@/utils/billing';
<template>
<ProjectCreateModal
v-model:show="showCreateProjectModal"
:create-project
:create-client
:createProject
:createClient
:currency="getOrganizationCurrencyString()"
:clients="clients"
:enable-estimated-time="isAllowedToPerformPremiumAction"></ProjectCreateModal>
:enableEstimatedTime="isAllowedToPerformPremiumAction"
v-model:show="showCreateProjectModal"></ProjectCreateModal>
<div class="flow-root max-w-[100vw] overflow-x-auto">
<div class="inline-block min-w-full align-middle">
<div
@@ -57,12 +57,12 @@ import { isAllowedToPerformPremiumAction } from '@/utils/billing';
class="grid min-w-full"
:style="gridTemplate">
<ProjectTableHeading
:show-billable-rate="
:showBillableRate="
props.showBillableRate
"></ProjectTableHeading>
<div
v-if="projects.length === 0"
class="col-span-5 py-24 text-center">
class="col-span-5 py-24 text-center"
v-if="projects.length === 0">
<FolderPlusIcon
class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
<h3 class="text-white font-semibold">
@@ -81,14 +81,14 @@ import { isAllowedToPerformPremiumAction } from '@/utils/billing';
</p>
<SecondaryButton
v-if="canCreateProjects()"
:icon="PlusIcon"
@click="showCreateProjectModal = true"
:icon="PlusIcon"
>Create your First Project
</SecondaryButton>
</div>
<template v-for="project in projects" :key="project.id">
<ProjectTableRow
:show-billable-rate="props.showBillableRate"
:showBillableRate="props.showBillableRate"
:project="project"></ProjectTableRow>
</template>
</div>

View File

@@ -19,8 +19,8 @@ defineProps<{
Progress
</div>
<div
v-if="showBillableRate"
class="px-3 py-1.5 text-left font-semibold text-white">
class="px-3 py-1.5 text-left font-semibold text-white"
v-if="showBillableRate">
Billable Rate
</div>
<div class="px-3 py-1.5 text-left font-semibold text-white">Status</div>

View File

@@ -83,8 +83,8 @@ const showEditProjectModal = ref(false);
</div>
<div class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-muted">
<div
v-if="project.client_id"
class="overflow-ellipsis overflow-hidden">
class="overflow-ellipsis overflow-hidden"
v-if="project.client_id">
{{ client?.name }}
</div>
<div v-else>No client</div>
@@ -106,8 +106,8 @@ const showEditProjectModal = ref(false);
<span v-else> -- </span>
</div>
<div
v-if="showBillableRate"
class="whitespace-nowrap px-3 py-4 text-sm text-muted">
class="whitespace-nowrap px-3 py-4 text-sm text-muted"
v-if="showBillableRate">
{{ billableRateInfo }}
</div>
<div

View File

@@ -18,10 +18,10 @@ defineEmits<{
<template>
<BillableRateModal
@submit="$emit('submit')"
v-model:show="show"
v-model:saving="saving"
title="Update Project Member Billable Rate"
@submit="$emit('submit')">
title="Update Project Member Billable Rate">
<p class="py-1 text-center">
The billable rate of {{ memberName }} will be updated to
<strong>{{

View File

@@ -52,16 +52,16 @@ useFocus(projectNameInput, { initialValue: true });
<div class="grid grid-cols-3 items-center space-x-4">
<div class="col-span-3 sm:col-span-2">
<MemberCombobox
v-model="projectMember.member_id"
:hidden-members="props.existingMembers"></MemberCombobox>
:hidden-members="props.existingMembers"
v-model="projectMember.member_id"></MemberCombobox>
</div>
<div class="col-span-3 sm:col-span-1 flex-1">
<BillableRateInput
name="billable_rate"
:currency="getOrganizationCurrencyString()"
v-model="
projectMember.billable_rate
"
name="billable_rate"
:currency="getOrganizationCurrencyString()"></BillableRateInput>
"></BillableRateInput>
</div>
</div>
</template>

View File

@@ -75,8 +75,8 @@ useFocus(projectNameInput, { initialValue: true });
<template #content>
<ProjectMemberBillableRateModal
v-model:show="showBillableRateModal"
:member-name="props.name"
v-model:show="showBillableRateModal"
:new-billable-rate="projectMemberBody.billable_rate"
@close="showBillableRateModal = false"
@submit="submitBillableRate"></ProjectMemberBillableRateModal>
@@ -92,12 +92,12 @@ useFocus(projectNameInput, { initialValue: true });
class="mb-2"
value="Billable Rate"></InputLabel>
<BillableRateInput
v-model="
projectMemberBody.billable_rate
"
@keydown.enter="submit"
:currency="getOrganizationCurrencyString()"
name="billable_rate"
@keydown.enter="submit"></BillableRateInput>
v-model="
projectMemberBody.billable_rate
"></BillableRateInput>
</div>
</div>
</template>

View File

@@ -27,17 +27,17 @@ const currentMember = computed(() => {
<MoreOptionsDropdown
:label="'Actions for Project Member ' + currentMember?.name">
<button
@click.prevent="emit('edit')"
:aria-label="'Edit Project Member ' + currentMember?.name"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
@click.prevent="emit('edit')">
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<PencilSquareIcon class="w-5 text-icon-active"></PencilSquareIcon>
<span>Edit</span>
</button>
<button
@click.prevent="emit('delete')"
:aria-label="'Delete Project Member ' + currentMember?.name"
data-testid="project_delete"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
@click.prevent="emit('delete')">
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
<span>Remove from Team</span>
</button>

View File

@@ -18,9 +18,9 @@ const createProjectMember = ref(false);
<template>
<ProjectMemberCreateModal
v-model:show="createProjectMember"
:existing-members="projectMembers"
:project-id="projectId"></ProjectMemberCreateModal>
:project-id="projectId"
v-model:show="createProjectMember"></ProjectMemberCreateModal>
<div class="flow-root">
<div class="inline-block min-w-full align-middle">
<div
@@ -29,15 +29,15 @@ const createProjectMember = ref(false);
style="grid-template-columns: 1fr 150px 150px 80px">
<ProjectMemberTableHeading></ProjectMemberTableHeading>
<div
v-if="projectMembers.length === 0"
class="col-span-5 py-24 text-center">
class="col-span-5 py-24 text-center"
v-if="projectMembers.length === 0">
<UserGroupIcon
class="w-8 text-icon-default inline pb-2"></UserGroupIcon>
<h3 class="text-white font-semibold">No project members</h3>
<p class="pb-5">Add the first project member!</p>
<SecondaryButton
:icon="PlusIcon"
@click="createProjectMember = true"
:icon="PlusIcon"
>Add a new Project Member
</SecondaryButton>
</div>

View File

@@ -37,8 +37,8 @@ const showEditModal = ref(false);
<template>
<TableRow>
<ProjectMemberEditModal
v-model:show="showEditModal"
:name="member?.name"
v-model:show="showEditModal"
:project-member="projectMember"></ProjectMemberEditModal>
<div
class="whitespace-nowrap flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-white pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">

View File

@@ -82,22 +82,22 @@ async function submit() {
<InputLabel for="name" value="Name" />
<TextInput
id="name"
v-model="report.name"
class="mt-1.5 w-full"></TextInput>
class="mt-1.5 w-full"
v-model="report.name"></TextInput>
</div>
<div>
<InputLabel for="description" value="Description" />
<TextInput
id="description"
v-model="report.description"
class="mt-1.5 w-full"></TextInput>
class="mt-1.5 w-full"
v-model="report.description"></TextInput>
</div>
<InputLabel value="Visibility" />
<div class="flex items-center space-x-12">
<div class="flex items-center space-x-3 px-2 py-3">
<Checkbox
id="is_public"
v-model:checked="report.is_public"></Checkbox>
v-model:checked="report.is_public"
id="is_public"></Checkbox>
<InputLabel for="is_public" value="Public" />
</div>
<div

View File

@@ -96,22 +96,22 @@ async function submit() {
<InputLabel for="name" value="Name" />
<TextInput
id="name"
v-model="report.name"
class="mt-1.5 w-full"></TextInput>
class="mt-1.5 w-full"
v-model="report.name"></TextInput>
</div>
<div>
<InputLabel for="description" value="Description" />
<TextInput
id="description"
v-model="report.description"
class="mt-1.5 w-full"></TextInput>
class="mt-1.5 w-full"
v-model="report.description"></TextInput>
</div>
<InputLabel value="Visibility" />
<div class="flex items-center space-x-12">
<div class="flex items-center space-x-2 px-2 py-3">
<Checkbox
id="is_public"
v-model:checked="report.is_public"></Checkbox>
v-model:checked="report.is_public"
id="is_public"></Checkbox>
<InputLabel for="is_public" value="Public" />
</div>
<div

View File

@@ -17,19 +17,19 @@ const props = defineProps<{
<MoreOptionsDropdown :label="'Actions for Project ' + props.report.name">
<div class="min-w-[150px]">
<button
@click.prevent="emit('edit')"
v-if="canUpdateReport()"
:aria-label="'Edit Report ' + props.report.name"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
@click.prevent="emit('edit')">
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<PencilSquareIcon
class="w-5 text-icon-active"></PencilSquareIcon>
<span>Edit</span>
</button>
<button
v-if="canDeleteReport()"
@click.prevent="emit('delete')"
:aria-label="'Delete Report ' + props.report.name"
class="border-b border-card-background-separator flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
@click.prevent="emit('delete')">
v-if="canDeleteReport()"
class="border-b border-card-background-separator flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
<span>Delete</span>
</button>

View File

@@ -27,8 +27,8 @@ function onSaveReportClick() {
<template>
<ReportCreateModal
v-model:show="showCreateReportModal"
:properties="reportProperties"></ReportCreateModal>
:properties="reportProperties"
v-model:show="showCreateReportModal"></ReportCreateModal>
<UpgradeModal v-model:show="showPremiumModal">
<strong>Sharable Reports</strong> is only available in solidtime
Professional.

View File

@@ -27,19 +27,19 @@ const gridTemplate = computed(() => {
:style="gridTemplate">
<ReportTableHeading></ReportTableHeading>
<div
v-if="reports.length === 0"
class="col-span-5 py-24 text-center">
class="col-span-5 py-24 text-center"
v-if="reports.length === 0">
<FolderPlusIcon
class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
<h3 class="text-white font-semibold">
No shared reports found
</h3>
<p v-if="canCreateProjects()" class="pb-5">
<p class="pb-5" v-if="canCreateProjects()">
Create your first project now!
</p>
<SecondaryButton
:icon="PlusIcon"
@click="router.visit(route('reporting'))"
:icon="PlusIcon"
>Go to the overview to create a report
</SecondaryButton>
</div>

View File

@@ -87,7 +87,7 @@ async function deleteReport() {
<span v-else>Copied!</span>
</SecondaryButton>
<button
class="outline-0 focus-visible:ring-2 w-6 h-6 flex items-center justify-center rounded focus-visible:ring-ring"
class="outline-0 focus-visible:ring-2 w-6 h-6 flex items-center justify-center rounded focus-visible:ring-white/80"
@click="openSharableLink">
<ArrowTopRightOnSquareIcon
class="w-4 text-text-tertiary hover:text-text-secondary transition"></ArrowTopRightOnSquareIcon>

View File

@@ -157,7 +157,7 @@ const option = ref({
:autoresize="true"
class="chart"
:option="option" />
<div v-else class="chart flex flex-col items-center justify-center">
<div class="chart flex flex-col items-center justify-center" v-else>
<p class="text-lg text-white font-semibold">
No time entries found
</p>

View File

@@ -22,10 +22,10 @@ function downloadCurrentExport() {
<Modal
closeable
max-width="lg"
:show="showExportModal"
@close="showExportModal = false">
@close="showExportModal = false"
:show="showExportModal">
<button
class="text-text-tertiary w-6 mx-auto absolute focus-visible:outline-none focus-visible:ring-2 rounded-full focus-visible:ring-ring transition focus-visible:text-text-primary hover:text-text-primary top-2 right-2">
class="text-text-tertiary w-6 mx-auto absolute focus-visible:outline-none focus-visible:ring-2 rounded-full focus-visible:ring-white/80 transition focus-visible:text-text-primary hover:text-text-primary top-2 right-2">
<XMarkIcon @click="showExportModal = false"></XMarkIcon>
</button>
<div class="text-center text-text-primary py-6">

View File

@@ -21,7 +21,6 @@ const activeClass = computed(() => {
<template>
<Badge
size="large"
tag="button"
:class="
twMerge(
'cursor-pointer hover:bg-card-background transition flex',

View File

@@ -23,7 +23,7 @@ const title = computed(() => {
:get-key-from-item="(item) => item.value"
:get-name-for-item="(item) => item.label"
:items="groupByOptions">
<template #trigger>
<template v-slot:trigger>
<Badge
size="large"
class="cursor-pointer hover:bg-card-background transition space-x-5 flex">

View File

@@ -35,9 +35,9 @@ const expanded = ref(false);
)
">
<GroupedItemsCountButton
v-if="entry.grouped_data && entry.grouped_data?.length > 0"
:expanded="expanded"
@click="expanded = !expanded">
@click="expanded = !expanded"
v-if="entry.grouped_data && entry.grouped_data?.length > 0">
{{ entry.grouped_data?.length }}
</GroupedItemsCountButton>
<span>
@@ -52,13 +52,13 @@ const expanded = ref(false);
</div>
</div>
<div
v-if="expanded && entry.grouped_data"
class="col-span-3 grid bg-quaternary"
style="grid-template-columns: 1fr 150px 150px">
style="grid-template-columns: 1fr 150px 150px"
v-if="expanded && entry.grouped_data">
<ReportingRow
indent
v-for="subEntry in entry.grouped_data"
:key="subEntry.description ?? 'none'"
indent
:entry="subEntry"></ReportingRow>
</div>
</template>

View File

@@ -10,18 +10,18 @@ defineProps<{
<template>
<TabBar>
<TabBarItem
:active="active === 'reporting'"
@click="router.visit(route('reporting'))"
:active="active === 'reporting'"
>Overview</TabBarItem
>
<TabBarItem
:active="active === 'detailed'"
@click="router.visit(route('reporting.detailed'))"
:active="active === 'detailed'"
>Detailed</TabBarItem
>
<TabBarItem
:active="active === 'shared'"
@click="router.visit(route('reporting.shared'))"
:active="active === 'shared'"
>Shared</TabBarItem
>
</TabBar>

View File

@@ -14,10 +14,10 @@ const props = defineProps<{
<template>
<MoreOptionsDropdown :label="'Actions for Tag ' + props.tag.name">
<button
@click="emit('delete')"
:aria-label="'Delete Tag ' + props.tag.name"
data-testid="tag_delete"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
@click="emit('delete')">
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
<span>Delete</span>
</button>

View File

@@ -19,8 +19,8 @@ const showCreateTagModal = ref(false);
<template>
<TagCreateModal
v-model:show="showCreateTagModal"
:create-tag></TagCreateModal>
:createTag
v-model:show="showCreateTagModal"></TagCreateModal>
<div class="flow-root">
<div class="inline-block min-w-full align-middle">
<div
@@ -29,18 +29,18 @@ const showCreateTagModal = ref(false);
style="grid-template-columns: 1fr 80px">
<TagTableHeading></TagTableHeading>
<div
v-if="tags.length === 0"
class="col-span-5 py-24 text-center">
class="col-span-5 py-24 text-center"
v-if="tags.length === 0">
<FolderPlusIcon
class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
<h3 class="text-white font-semibold">No tags found</h3>
<p v-if="canCreateTags()" class="pb-5">
<p class="pb-5" v-if="canCreateTags()">
Create your first tag now!
</p>
<SecondaryButton
v-if="canCreateTags()"
:icon="PlusIcon"
@click="showCreateTagModal = true"
:icon="PlusIcon"
>Create your First Tag</SecondaryButton
>
</div>

View File

@@ -53,19 +53,19 @@ useFocus(taskNameInput, { initialValue: true });
v-model="taskName"
type="text"
placeholder="Task Name"
@keydown.enter="submit()"
class="mt-1 block w-full"
required
autocomplete="taskName"
@keydown.enter="submit()" />
autocomplete="taskName" />
</div>
<div class="col-span-6 sm:col-span-4">
<ProjectDropdown :model-value="projectId"></ProjectDropdown>
<ProjectDropdown :modelValue="projectId"></ProjectDropdown>
</div>
</div>
<EstimatedTimeSection
v-if="isAllowedToPerformPremiumAction()"
v-model="estimatedTime"
@submit="submit()"></EstimatedTimeSection>
@submit="submit()"
v-model="estimatedTime"></EstimatedTimeSection>
</template>
<template #footer>
<SecondaryButton @click="show = false"> Cancel </SecondaryButton>

View File

@@ -50,16 +50,16 @@ useFocus(taskNameInput, { initialValue: true });
v-model="taskBody.name"
type="text"
placeholder="Task Name"
@keydown.enter="submit()"
class="mt-1 block w-full"
required
autocomplete="taskName"
@keydown.enter="submit()" />
autocomplete="taskName" />
</div>
</div>
<EstimatedTimeSection
v-if="isAllowedToPerformPremiumAction()"
v-model="taskBody.estimated_time"
@submit="submit()"></EstimatedTimeSection>
@submit="submit()"
v-model="taskBody.estimated_time"></EstimatedTimeSection>
</template>
<template #footer>
<SecondaryButton @click="show = false"> Cancel </SecondaryButton>

Some files were not shown because too many files have changed in this diff Show More