mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-06-15 13:32:43 +01:00
Compare commits
7 Commits
v0.11.0
...
feature/ap
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8dba13eba | ||
|
|
91d6ff7392 | ||
|
|
427c904747 | ||
|
|
861b6c2642 | ||
|
|
51f7ba0509 | ||
|
|
e0506fa3e3 | ||
|
|
a9d9c13846 |
41
.env.ci
41
.env.ci
@@ -1,3 +1,4 @@
|
||||
# Application
|
||||
APP_NAME=solidtime
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
@@ -19,35 +20,39 @@ DB_TEST_DATABASE=laravel
|
||||
DB_TEST_USERNAME=root
|
||||
DB_TEST_PASSWORD=root
|
||||
|
||||
BROADCAST_DRIVER=log
|
||||
# Broadcasting
|
||||
BROADCAST_DRIVER=null
|
||||
|
||||
# Cache
|
||||
CACHE_DRIVER=file
|
||||
|
||||
# Queue
|
||||
QUEUE_CONNECTION=sync
|
||||
|
||||
# Session
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
|
||||
# Mail
|
||||
MAIL_MAILER=log
|
||||
MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
MAIL_FROM_ADDRESS="no-reply@solidtime.test"
|
||||
MAIL_FROM_NAME="solidtime"
|
||||
MAIL_REPLY_TO_ADDRESS="hello@solidtime.test"
|
||||
MAIL_REPLY_TO_NAME="solidtime"
|
||||
|
||||
# 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}"
|
||||
|
||||
22
.env.example
22
.env.example
@@ -4,7 +4,7 @@ APP_ENV=local
|
||||
APP_KEY=base64:UNQNf1SXeASNkWux01Rj8EnHYx8FO0kAxWNDwktclkk=
|
||||
APP_DEBUG=true
|
||||
APP_URL=https://solidtime.test
|
||||
AUDITING_ENABLED=true
|
||||
APP_FORCE_HTTPS=false
|
||||
APP_ENABLE_REGISTRATION=true
|
||||
SUPER_ADMINS=admin@example.com
|
||||
PAGINATION_PER_PAGE_DEFAULT=500
|
||||
@@ -49,7 +49,9 @@ MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_ENCRYPTION=null
|
||||
MAIL_FROM_ADDRESS="no-reply@solidtime.test"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
MAIL_FROM_NAME="solidtime"
|
||||
MAIL_REPLY_TO_ADDRESS="hello@solidtime.test"
|
||||
MAIL_REPLY_TO_NAME="solidtime"
|
||||
|
||||
# Filesystems
|
||||
FILESYSTEM_DISK=s3
|
||||
@@ -62,14 +64,24 @@ 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
|
||||
|
||||
3
.github/workflows/npm-publish-api.yml
vendored
3
.github/workflows/npm-publish-api.yml
vendored
@@ -8,7 +8,8 @@ jobs:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@v4
|
||||
# Setup .npmrc file to publish to npm
|
||||
- name: Install root project dependencies
|
||||
run: npm ci
|
||||
|
||||
3
.github/workflows/npm-publish-ui.yml
vendored
3
.github/workflows/npm-publish-ui.yml
vendored
@@ -8,7 +8,8 @@ jobs:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@v4
|
||||
# Setup .npmrc file to publish to npm
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
|
||||
24
.github/workflows/playwright.yml
vendored
24
.github/workflows/playwright.yml
vendored
@@ -27,45 +27,47 @@ jobs:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- name: "Setup node"
|
||||
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 migrate --seed
|
||||
php artisan passport:keys
|
||||
php artisan migrate --seed
|
||||
|
||||
- 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'
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- name: "Upload test results"
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: test-results
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Api;
|
||||
|
||||
class PersonalAccessClientIsNotConfiguredException extends ApiException
|
||||
{
|
||||
public const string KEY = 'personal_access_client_is_not_configured';
|
||||
}
|
||||
148
app/Filament/Resources/TokenResource.php
Normal file
148
app/Filament/Resources/TokenResource.php
Normal file
@@ -0,0 +1,148 @@
|
||||
<?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}'),
|
||||
];
|
||||
}
|
||||
}
|
||||
19
app/Filament/Resources/TokenResource/Pages/ListTokens.php
Normal file
19
app/Filament/Resources/TokenResource/Pages/ListTokens.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?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 [
|
||||
];
|
||||
}
|
||||
}
|
||||
19
app/Filament/Resources/TokenResource/Pages/ViewToken.php
Normal file
19
app/Filament/Resources/TokenResource/Pages/ViewToken.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?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 [
|
||||
];
|
||||
}
|
||||
}
|
||||
114
app/Http/Controllers/Api/V1/ApiTokenController.php
Normal file
114
app/Http/Controllers/Api/V1/ApiTokenController.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
32
app/Http/Requests/V1/ApiToken/ApiTokenStoreRequest.php
Normal file
32
app/Http/Requests/V1/ApiToken/ApiTokenStoreRequest.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?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');
|
||||
}
|
||||
}
|
||||
17
app/Http/Resources/V1/ApiToken/ApiTokenCollection.php
Normal file
17
app/Http/Resources/V1/ApiToken/ApiTokenCollection.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?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;
|
||||
}
|
||||
38
app/Http/Resources/V1/ApiToken/ApiTokenResource.php
Normal file
38
app/Http/Resources/V1/ApiToken/ApiTokenResource.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?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,
|
||||
];
|
||||
}
|
||||
}
|
||||
9
app/Models/Passport/AuthCode.php
Normal file
9
app/Models/Passport/AuthCode.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models\Passport;
|
||||
|
||||
use Laravel\Passport\AuthCode as PassportAuthCode;
|
||||
|
||||
class AuthCode extends PassportAuthCode {}
|
||||
26
app/Models/Passport/Client.php
Normal file
26
app/Models/Passport/Client.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?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;
|
||||
}
|
||||
9
app/Models/Passport/PersonalAccessClient.php
Normal file
9
app/Models/Passport/PersonalAccessClient.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models\Passport;
|
||||
|
||||
use Laravel\Passport\PersonalAccessClient as PassportPersonalAccessClient;
|
||||
|
||||
class PersonalAccessClient extends PassportPersonalAccessClient {}
|
||||
9
app/Models/Passport/RefreshToken.php
Normal file
9
app/Models/Passport/RefreshToken.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models\Passport;
|
||||
|
||||
use Laravel\Passport\RefreshToken as PassportRefreshToken;
|
||||
|
||||
class RefreshToken extends PassportRefreshToken {}
|
||||
38
app/Models/Passport/Token.php
Normal file
38
app/Models/Passport/Token.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?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');
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ 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;
|
||||
@@ -27,7 +28,6 @@ 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;
|
||||
|
||||
/**
|
||||
@@ -44,6 +44,7 @@ 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
|
||||
@@ -196,6 +197,17 @@ 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
|
||||
*/
|
||||
|
||||
@@ -9,6 +9,7 @@ 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;
|
||||
@@ -100,5 +101,6 @@ class AppServiceProvider extends ServiceProvider
|
||||
// Routing
|
||||
Route::model('member', Member::class);
|
||||
Route::model('invitation', OrganizationInvitation::class);
|
||||
Route::model('apiToken', Token::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,11 @@ 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;
|
||||
@@ -42,6 +47,16 @@ 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']);
|
||||
|
||||
|
||||
@@ -69,6 +69,9 @@ class AdminPanelProvider extends PanelProvider
|
||||
NavigationGroup::make()
|
||||
->label('System')
|
||||
->collapsed(),
|
||||
NavigationGroup::make()
|
||||
->label('Auth')
|
||||
->collapsed(),
|
||||
])
|
||||
->middleware([
|
||||
EncryptCookies::class,
|
||||
|
||||
@@ -15,7 +15,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'guard' => 'web',
|
||||
'guard' => 'api',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
55
database/factories/Passport/ClientFactory.php
Normal file
55
database/factories/Passport/ClientFactory.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?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(),
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
54
database/factories/Passport/TokenFactory.php
Normal file
54
database/factories/Passport/TokenFactory.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?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(),
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,29 @@ 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',
|
||||
@@ -55,6 +78,8 @@ 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',
|
||||
@@ -159,15 +184,6 @@ 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
|
||||
|
||||
@@ -1,25 +1,69 @@
|
||||
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').fill('NEW NAME');
|
||||
await page.getByLabel('Name', {exact: true} ).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')).toHaveValue('NEW NAME');
|
||||
await expect(page.getByLabel('Name', {exact: true})).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');
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ 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;
|
||||
@@ -37,6 +38,7 @@ 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.',
|
||||
];
|
||||
|
||||
@@ -41,5 +41,7 @@
|
||||
<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>
|
||||
|
||||
@@ -39,7 +39,7 @@ async function resendInvitation() {
|
||||
await handleApiRequestNotifications(
|
||||
() =>
|
||||
api.resendInvitationEmail(
|
||||
{},
|
||||
undefined,
|
||||
{
|
||||
params: {
|
||||
invitation: props.invitation.id,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useMembersStore } from '@/utils/useMembers';
|
||||
import { UserIcon, ChevronDownIcon } from '@heroicons/vue/24/solid';
|
||||
@@ -46,22 +46,6 @@ 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;
|
||||
|
||||
@@ -32,7 +32,7 @@ async function invitePlaceholder(id: string) {
|
||||
await handleApiRequestNotifications(
|
||||
() =>
|
||||
api.invitePlaceholder(
|
||||
{},
|
||||
undefined,
|
||||
{
|
||||
params: {
|
||||
organization: organizationId,
|
||||
|
||||
332
resources/js/Pages/Profile/Partials/ApiTokensForm.vue
Normal file
332
resources/js/Pages/Profile/Partials/ApiTokensForm.vue
Normal file
@@ -0,0 +1,332 @@
|
||||
<script setup lang="ts">
|
||||
import FormSection from '@/Components/FormSection.vue';
|
||||
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
||||
import {computed, ref} from 'vue';
|
||||
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
|
||||
import {
|
||||
api,
|
||||
type ApiToken,
|
||||
type CreateApiTokenBody
|
||||
} from '@/packages/api/src';
|
||||
import SectionBorder from "@/Components/SectionBorder.vue";
|
||||
import DangerButton from "@/packages/ui/src/Buttons/DangerButton.vue";
|
||||
import TextInput from "../../../packages/ui/src/Input/TextInput.vue";
|
||||
import SecondaryButton from "../../../packages/ui/src/Buttons/SecondaryButton.vue";
|
||||
import DialogModal from "@/packages/ui/src/DialogModal.vue";
|
||||
import InputError from "@/packages/ui/src/Input/InputError.vue";
|
||||
import ActionMessage from "@/Components/ActionMessage.vue";
|
||||
import ConfirmationModal from "@/Components/ConfirmationModal.vue";
|
||||
import ActionSection from "@/Components/ActionSection.vue";
|
||||
import {useForm} from "@inertiajs/vue3";
|
||||
import {useMutation, useQuery, useQueryClient} from "@tanstack/vue-query";
|
||||
import {useNotificationsStore} from "@/utils/notification";
|
||||
import {useClipboard} from "@vueuse/core";
|
||||
import { formatDateTimeLocalized} from "../../../packages/ui/src/utils/time";
|
||||
import {ClockIcon} from "@heroicons/vue/20/solid";
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const apiTokenBeingDeleted = ref<ApiToken | null>(null);
|
||||
const apiTokenBeingRevoked = ref<ApiToken | null>(null);
|
||||
|
||||
const { handleApiRequestNotifications } = useNotificationsStore();
|
||||
const newToken = ref('');
|
||||
|
||||
const { copy, copied, isSupported } = useClipboard();
|
||||
|
||||
async function createApiToken(){
|
||||
await handleApiRequestNotifications(
|
||||
() =>
|
||||
createApiTokenMutation.mutateAsync({
|
||||
name: createApiTokenForm.name,
|
||||
}),
|
||||
'API Token successfully created',
|
||||
'There was an error while creating the API Token',
|
||||
(response) => {
|
||||
createApiTokenForm.name = '';
|
||||
displayingToken.value = true;
|
||||
// @ts-expect-error temporary fix until openapi docs type is fixed
|
||||
newToken.value = response.data.access_token;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const createApiTokenForm = useForm({
|
||||
name: '',
|
||||
});
|
||||
|
||||
function confirmApiTokenDeletion (token: ApiToken) {
|
||||
apiTokenBeingDeleted.value = token;
|
||||
}
|
||||
|
||||
function confirmApiTokenRevocation(token: ApiToken){
|
||||
apiTokenBeingRevoked.value = token;
|
||||
}
|
||||
|
||||
const displayingToken = ref(false);
|
||||
|
||||
async function deleteApiToken () {
|
||||
if(apiTokenBeingDeleted.value){
|
||||
await handleApiRequestNotifications(
|
||||
() =>
|
||||
deleteApiTokenMutation.mutateAsync(apiTokenBeingDeleted.value!.id),
|
||||
'API Token successfully deleted',
|
||||
'There was an error while deleting the API Token',
|
||||
() => {
|
||||
apiTokenBeingDeleted.value = null;
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
async function revokeApiToken () {
|
||||
if(apiTokenBeingRevoked.value){
|
||||
await handleApiRequestNotifications(
|
||||
() =>
|
||||
revokeApiTokenMutation.mutateAsync(apiTokenBeingRevoked.value!.id),
|
||||
'API Token successfully revoked',
|
||||
'There was an error while revoking the API Token',
|
||||
() => {
|
||||
apiTokenBeingRevoked.value = null;
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const { data: sharedReportResponseData } = useQuery({
|
||||
queryKey: ['api-tokens'],
|
||||
queryFn: () =>
|
||||
api.getApiTokens(),
|
||||
});
|
||||
|
||||
const tokens = computed(() => {
|
||||
return sharedReportResponseData.value?.data ?? [];
|
||||
})
|
||||
|
||||
const createApiTokenMutation = useMutation({
|
||||
mutationFn: async (apiToken: CreateApiTokenBody) => {
|
||||
return await api.createApiToken(apiToken);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['api-tokens'] })
|
||||
},
|
||||
});
|
||||
|
||||
const deleteApiTokenMutation = useMutation({
|
||||
mutationFn: async (apiTokenId: string) => {
|
||||
return await api.deleteApiToken(undefined, {
|
||||
params: {
|
||||
apiTokenId: apiTokenId,
|
||||
},
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['api-tokens'] })
|
||||
},
|
||||
});
|
||||
|
||||
const revokeApiTokenMutation = useMutation({
|
||||
mutationFn: async (apiTokenId: string) => {
|
||||
return await api.revokeApiToken(undefined, {
|
||||
params: {
|
||||
apiTokenId: apiTokenId,
|
||||
},
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['api-tokens'] })
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- Generate API Token -->
|
||||
<FormSection @submitted="createApiToken">
|
||||
<template #title> Create API Token </template>
|
||||
|
||||
<template #description>
|
||||
API tokens allow third-party services to authenticate with our
|
||||
application on your behalf.
|
||||
</template>
|
||||
|
||||
<template #form>
|
||||
<!-- Token Name -->
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="api_key_name" value="API Key Name" />
|
||||
<TextInput
|
||||
id="api_key_name"
|
||||
v-model="createApiTokenForm.name"
|
||||
type="text"
|
||||
class="mt-1 block w-full" />
|
||||
<InputError
|
||||
:message="createApiTokenForm.errors.name"
|
||||
class="mt-2" />
|
||||
<div class="text-text-tertiary text-sm pt-3 flex space-x-1.5 font-medium items-center">
|
||||
<ClockIcon class="w-4"></ClockIcon>
|
||||
<span>
|
||||
API Tokens are valid for 1 year
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<ActionMessage
|
||||
:on="createApiTokenForm.recentlySuccessful"
|
||||
class="me-3">
|
||||
Created.
|
||||
</ActionMessage>
|
||||
|
||||
<PrimaryButton
|
||||
:class="{ 'opacity-25': createApiTokenForm.processing }"
|
||||
:disabled="createApiTokenForm.processing">
|
||||
Create API Key
|
||||
</PrimaryButton>
|
||||
</template>
|
||||
</FormSection>
|
||||
|
||||
<div v-if="tokens.length > 0">
|
||||
<SectionBorder />
|
||||
|
||||
<!-- Manage API Tokens -->
|
||||
<div class="mt-10 sm:mt-0">
|
||||
<ActionSection>
|
||||
<template #title> Manage API Tokens </template>
|
||||
|
||||
<template #description>
|
||||
You may delete or revoke any of your existing tokens if they are
|
||||
no longer needed.
|
||||
</template>
|
||||
|
||||
<!-- API Token List -->
|
||||
<template #content>
|
||||
<div class="divide-border-secondary divide-y">
|
||||
<div
|
||||
v-for="token in tokens"
|
||||
:key="token.id"
|
||||
class="flex items-center py-2.5 justify-between">
|
||||
<div class="break-all text-white">
|
||||
<div>{{ token.name }}</div>
|
||||
<div class="text-sm text-text-tertiary space-x-3">
|
||||
<span v-if="token.created_at">
|
||||
Created at {{ formatDateTimeLocalized(token.created_at) }}
|
||||
</span>
|
||||
<span v-if="token.expires_at">
|
||||
Expires at {{ formatDateTimeLocalized(token.expires_at) }}
|
||||
</span>
|
||||
<span v-if="token.revoked">
|
||||
Revoked
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center ms-2">
|
||||
<div
|
||||
v-if="token.last_used_ago"
|
||||
class="text-sm text-gray-400">
|
||||
Last used {{ token.last_used_ago }}
|
||||
</div>
|
||||
<button
|
||||
v-if="!token.revoked"
|
||||
class="cursor-pointer ms-6 text-sm text-text-secondary"
|
||||
:aria-label="'Revoke API Token ' + token.name"
|
||||
@click="confirmApiTokenRevocation(token)">
|
||||
Revoke
|
||||
</button>
|
||||
<button
|
||||
class="cursor-pointer ms-6 text-sm text-red-500"
|
||||
:aria-label="'Delete API Token ' + token.name"
|
||||
@click="confirmApiTokenDeletion(token)">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ActionSection>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Token Value Modal -->
|
||||
<DialogModal :show="displayingToken" @close="displayingToken = false">
|
||||
<template #title> API Token created successfully </template>
|
||||
|
||||
<template #content>
|
||||
<div>
|
||||
Please copy your new API token. For your security, it won't
|
||||
be shown again.
|
||||
<strong>This token is valid for one year</strong> unless you revoke it.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 pt-6 w-full">
|
||||
<TextInput v-if="newToken" disabled :model-value="newToken" class="flex-1 text-gray-500"></TextInput>
|
||||
<PrimaryButton v-if="isSupported" @click="copy(newToken)">{{ copied ? 'Copied!' : 'Copy Token' }}</PrimaryButton>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<SecondaryButton @click="displayingToken = false">
|
||||
Close
|
||||
</SecondaryButton>
|
||||
</template>
|
||||
</DialogModal>
|
||||
|
||||
<!-- Delete Token Confirmation Modal -->
|
||||
<ConfirmationModal
|
||||
:show="apiTokenBeingDeleted != null"
|
||||
@close="apiTokenBeingDeleted = null">
|
||||
<template #title> Delete API Token </template>
|
||||
|
||||
<template #content>
|
||||
Are you sure you would like to delete this API token?
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<SecondaryButton @click="apiTokenBeingDeleted = null">
|
||||
Cancel
|
||||
</SecondaryButton>
|
||||
|
||||
<DangerButton
|
||||
class="ms-3"
|
||||
:class="{ 'opacity-25': createApiTokenMutation.isPending.value }"
|
||||
:disabled="createApiTokenMutation.isPending.value"
|
||||
@click="deleteApiToken">
|
||||
Delete
|
||||
</DangerButton>
|
||||
</template>
|
||||
</ConfirmationModal>
|
||||
|
||||
<ConfirmationModal
|
||||
:show="apiTokenBeingRevoked != null"
|
||||
@close="apiTokenBeingRevoked = null">
|
||||
<template #title> Revoke API Token </template>
|
||||
|
||||
<template #content>
|
||||
Are you sure you would like to revoke this API token?
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<SecondaryButton @click="apiTokenBeingRevoked = null">
|
||||
Cancel
|
||||
</SecondaryButton>
|
||||
|
||||
<DangerButton
|
||||
class="ms-3"
|
||||
:class="{ 'opacity-25': revokeApiTokenMutation.isPending.value }"
|
||||
:disabled="revokeApiTokenMutation.isPending.value"
|
||||
@click="revokeApiToken">
|
||||
Revoke
|
||||
</DangerButton>
|
||||
</template>
|
||||
</ConfirmationModal>
|
||||
</div>
|
||||
</template>
|
||||
@@ -9,6 +9,7 @@ import UpdateProfileInformationForm from '@/Pages/Profile/Partials/UpdateProfile
|
||||
import { usePage } from '@inertiajs/vue3';
|
||||
import type { User } from '@/types/models';
|
||||
import type { Session } from '@/types/jetstream';
|
||||
import ApiTokensForm from "@/Pages/Profile/Partials/ApiTokensForm.vue";
|
||||
|
||||
defineProps<{
|
||||
confirmsTwoFactorAuthentication: boolean;
|
||||
@@ -65,6 +66,9 @@ const page = usePage<{
|
||||
<LogoutOtherBrowserSessionsForm
|
||||
:sessions="sessions"
|
||||
class="mt-10 sm:mt-0" />
|
||||
<SectionBorder />
|
||||
|
||||
<ApiTokensForm></ApiTokensForm>
|
||||
|
||||
<template
|
||||
v-if="page.props.jetstream.hasAccountDeletionFeatures">
|
||||
|
||||
@@ -29,7 +29,7 @@ async function exportData() {
|
||||
const response = await handleApiRequestNotifications(
|
||||
() =>
|
||||
api.exportOrganization(
|
||||
{},
|
||||
undefined,
|
||||
{
|
||||
params: {
|
||||
organization: organizationId,
|
||||
|
||||
@@ -162,6 +162,15 @@ export type UpdateReportBody = ZodiosBodyByAlias<SolidTimeApi, 'updateReport'>;
|
||||
export type CreateReportBodyProperties = CreateReportBody['properties'];
|
||||
export type Report = ReportIndexResponse['data'][0];
|
||||
|
||||
export type ApiTokenIndexResponse = ZodiosResponseByAlias<
|
||||
SolidTimeApi,
|
||||
'getApiTokens'
|
||||
>;
|
||||
|
||||
export type CreateApiTokenBody = ZodiosBodyByAlias<SolidTimeApi, 'createApiToken'>;
|
||||
export type ApiToken = ApiTokenIndexResponse['data'][0];
|
||||
|
||||
|
||||
const api = createApiClient('/api', { validate: 'none' });
|
||||
|
||||
export { createApiClient, api };
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
import { makeApi, Zodios, type ZodiosOptions } from '@zodios/core';
|
||||
import { z } from 'zod';
|
||||
|
||||
const ApiTokenResource = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
revoked: z.string(),
|
||||
scopes: z.string(),
|
||||
created_at: z.union([z.string(), z.null()]),
|
||||
expires_at: z.union([z.string(), z.null()]),
|
||||
})
|
||||
.passthrough();
|
||||
const ApiTokenCollection = z.array(ApiTokenResource);
|
||||
const ApiTokenStoreRequest = z
|
||||
.object({ name: z.string().min(1).max(255) })
|
||||
.passthrough();
|
||||
const ApiTokenWithAccessTokenResource = z.string();
|
||||
const ClientResource = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
@@ -26,9 +41,11 @@ const ImportRequest = z
|
||||
const InvitationResource = z
|
||||
.object({ id: z.string(), email: z.string(), role: z.string() })
|
||||
.passthrough();
|
||||
const Role = z.enum(['owner', 'admin', 'manager', 'employee', 'placeholder']);
|
||||
const InvitationStoreRequest = z
|
||||
.object({ email: z.string().email(), role: Role })
|
||||
.object({
|
||||
email: z.string().email(),
|
||||
role: z.enum(['admin', 'manager', 'employee']),
|
||||
})
|
||||
.passthrough();
|
||||
const MemberResource = z
|
||||
.object({
|
||||
@@ -41,6 +58,7 @@ const MemberResource = z
|
||||
billable_rate: z.union([z.number(), z.null()]),
|
||||
})
|
||||
.passthrough();
|
||||
const Role = z.enum(['owner', 'admin', 'manager', 'employee', 'placeholder']);
|
||||
const MemberUpdateRequest = z
|
||||
.object({ role: Role, billable_rate: z.union([z.number(), z.null()]) })
|
||||
.partial()
|
||||
@@ -190,13 +208,6 @@ const ReportStoreRequest = z
|
||||
timezone: z.union([z.string(), z.null()]).optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
'properties.member_ids': z.string().optional(),
|
||||
'properties.client_ids': z.string().optional(),
|
||||
'properties.project_ids': z.string().optional(),
|
||||
'properties.tag_ids': z.string().optional(),
|
||||
'properties.task_ids': z.string().optional(),
|
||||
'properties.week_start': z.string().optional(),
|
||||
'properties.timezone': z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
const DetailedReportResource = z
|
||||
@@ -459,18 +470,21 @@ const PersonalMembershipResource = z
|
||||
role: z.string(),
|
||||
})
|
||||
.passthrough();
|
||||
const PersonalMembershipCollection = z.array(PersonalMembershipResource);
|
||||
|
||||
export const schemas = {
|
||||
ApiTokenResource,
|
||||
ApiTokenCollection,
|
||||
ApiTokenStoreRequest,
|
||||
ApiTokenWithAccessTokenResource,
|
||||
ClientResource,
|
||||
ClientCollection,
|
||||
ClientStoreRequest,
|
||||
ClientUpdateRequest,
|
||||
ImportRequest,
|
||||
InvitationResource,
|
||||
Role,
|
||||
InvitationStoreRequest,
|
||||
MemberResource,
|
||||
Role,
|
||||
MemberUpdateRequest,
|
||||
OrganizationResource,
|
||||
OrganizationUpdateRequest,
|
||||
@@ -502,7 +516,6 @@ export const schemas = {
|
||||
TimeEntryUpdateRequest,
|
||||
UserResource,
|
||||
PersonalMembershipResource,
|
||||
PersonalMembershipCollection,
|
||||
};
|
||||
|
||||
const endpoints = makeApi([
|
||||
@@ -786,11 +799,6 @@ const endpoints = makeApi([
|
||||
alias: 'exportOrganization',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'body',
|
||||
type: 'Body',
|
||||
schema: z.object({}).partial().passthrough(),
|
||||
},
|
||||
{
|
||||
name: 'organization',
|
||||
type: 'Path',
|
||||
@@ -1122,11 +1130,6 @@ const endpoints = makeApi([
|
||||
alias: 'resendInvitationEmail',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'body',
|
||||
type: 'Body',
|
||||
schema: z.object({}).partial().passthrough(),
|
||||
},
|
||||
{
|
||||
name: 'organization',
|
||||
type: 'Path',
|
||||
@@ -1345,11 +1348,6 @@ const endpoints = makeApi([
|
||||
alias: 'invitePlaceholder',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'body',
|
||||
type: 'Body',
|
||||
schema: z.object({}).partial().passthrough(),
|
||||
},
|
||||
{
|
||||
name: 'organization',
|
||||
type: 'Path',
|
||||
@@ -1397,11 +1395,6 @@ const endpoints = makeApi([
|
||||
alias: 'v1.members.make-placeholder',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'body',
|
||||
type: 'Body',
|
||||
schema: z.object({}).partial().passthrough(),
|
||||
},
|
||||
{
|
||||
name: 'organization',
|
||||
type: 'Path',
|
||||
@@ -2614,6 +2607,11 @@ Users with the permission `time-entries:view:own` can only use this en
|
||||
type: 'Query',
|
||||
schema: z.enum(['true', 'false']).optional(),
|
||||
},
|
||||
{
|
||||
name: 'user_id',
|
||||
type: 'Query',
|
||||
schema: z.string().optional(),
|
||||
},
|
||||
{
|
||||
name: 'member_ids',
|
||||
type: 'Query',
|
||||
@@ -2639,11 +2637,6 @@ Users with the permission `time-entries:view:own` can only use this en
|
||||
type: 'Query',
|
||||
schema: z.array(z.string()).min(1).optional(),
|
||||
},
|
||||
{
|
||||
name: 'user_id',
|
||||
type: 'Query',
|
||||
schema: z.string().optional(),
|
||||
},
|
||||
],
|
||||
response: z
|
||||
.object({
|
||||
@@ -3438,6 +3431,120 @@ The report is considered public if the `is_public` field is set to &#x
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'get',
|
||||
path: '/v1/users/me/api-tokens',
|
||||
alias: 'getApiTokens',
|
||||
description: `This endpoint is independent of organization.`,
|
||||
requestFormat: 'json',
|
||||
response: z.object({ data: ApiTokenCollection }).passthrough(),
|
||||
errors: [
|
||||
{
|
||||
status: 401,
|
||||
description: `Unauthenticated`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
description: `Authorization error`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'post',
|
||||
path: '/v1/users/me/api-tokens',
|
||||
alias: 'createApiToken',
|
||||
description: `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.`,
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'body',
|
||||
type: 'Body',
|
||||
schema: z
|
||||
.object({ name: z.string().min(1).max(255) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
response: z
|
||||
.object({ data: ApiTokenWithAccessTokenResource })
|
||||
.passthrough(),
|
||||
errors: [
|
||||
{
|
||||
status: 401,
|
||||
description: `Unauthenticated`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
description: `Authorization error`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'delete',
|
||||
path: '/v1/users/me/api-tokens/:apiTokenId',
|
||||
alias: 'deleteApiToken',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'apiTokenId',
|
||||
type: 'Path',
|
||||
schema: z.string(),
|
||||
},
|
||||
],
|
||||
response: z.null(),
|
||||
errors: [
|
||||
{
|
||||
status: 401,
|
||||
description: `Unauthenticated`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
description: `Authorization error`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'post',
|
||||
path: '/v1/users/me/api-tokens/:apiTokenId/revoke',
|
||||
alias: 'revokeApiToken',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'apiTokenId',
|
||||
type: 'Path',
|
||||
schema: z.string(),
|
||||
},
|
||||
],
|
||||
response: z.null(),
|
||||
errors: [
|
||||
{
|
||||
status: 401,
|
||||
description: `Unauthenticated`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
description: `Authorization error`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'get',
|
||||
path: '/v1/users/me/memberships',
|
||||
@@ -3445,7 +3552,7 @@ The report is considered public if the `is_public` field is set to &#x
|
||||
description: `This endpoint is independent of organization.`,
|
||||
requestFormat: 'json',
|
||||
response: z
|
||||
.object({ data: PersonalMembershipCollection })
|
||||
.object({ data: z.array(PersonalMembershipResource) })
|
||||
.passthrough(),
|
||||
errors: [
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts" generic="T">
|
||||
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import {computed, nextTick, onMounted, ref, watch} from 'vue';
|
||||
import SelectDropdownItem from '@/packages/ui/src/Input/SelectDropdownItem.vue';
|
||||
import { onKeyStroke } from '@vueuse/core';
|
||||
import { type Placement } from '@floating-ui/vue';
|
||||
@@ -43,10 +43,22 @@ const filteredItems = computed<T[]>(() => {
|
||||
const highlightedItemId = ref<string | null>(model.value);
|
||||
|
||||
watch(model, () => {
|
||||
highlightedItemId.value = model.value;
|
||||
if(model.value){
|
||||
highlightedItemId.value = model.value;
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (!highlightedItemId.value) {
|
||||
resetHightlightedItem();
|
||||
}
|
||||
});
|
||||
|
||||
watch(filteredItems, () => {
|
||||
resetHightlightedItem();
|
||||
});
|
||||
|
||||
function resetHightlightedItem(){
|
||||
if (
|
||||
filteredItems.value.length > 0 &&
|
||||
filteredItems.value.find(
|
||||
@@ -55,7 +67,7 @@ watch(filteredItems, () => {
|
||||
) {
|
||||
highlightedItemId.value = props.getKeyFromItem(filteredItems.value[0]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
watch(highlightedItemId, () => {
|
||||
if (highlightedItemId.value) {
|
||||
|
||||
@@ -175,7 +175,6 @@ type BillableOption = {
|
||||
size="xlarge">
|
||||
<TagIcon
|
||||
v-if="timeEntry.tags.length === 0"
|
||||
tag="button"
|
||||
class="w-4"></TagIcon>
|
||||
<div
|
||||
v-else
|
||||
|
||||
@@ -99,6 +99,10 @@ export function formatDateLocalized(date: string): string {
|
||||
return getLocalizedDayJs(date).format('DD.MM.YYYY');
|
||||
}
|
||||
|
||||
export function formatDateTimeLocalized(date: string): string {
|
||||
return getLocalizedDayJs(date).format('DD.MM.YYYY HH:mm');
|
||||
}
|
||||
|
||||
export function formatWeek(date: string | null): string {
|
||||
return 'Week ' + getDayJsInstance()(date).week();
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Http\Controllers\Api\V1\ApiTokenController;
|
||||
use App\Http\Controllers\Api\V1\ClientController;
|
||||
use App\Http\Controllers\Api\V1\ExportController;
|
||||
use App\Http\Controllers\Api\V1\ImportController;
|
||||
@@ -57,6 +58,14 @@ Route::prefix('v1')->name('v1.')->group(static function (): void {
|
||||
Route::get('/users/me', [UserController::class, 'me'])->name('me');
|
||||
});
|
||||
|
||||
// Api token routes
|
||||
Route::name('api-tokens.')->group(static function (): void {
|
||||
Route::get('/users/me/api-tokens', [ApiTokenController::class, 'index'])->name('index');
|
||||
Route::post('/users/me/api-tokens', [ApiTokenController::class, 'store'])->name('store');
|
||||
Route::post('/users/me/api-tokens/{apiToken}/revoke', [ApiTokenController::class, 'revoke'])->name('revoke');
|
||||
Route::delete('/users/me/api-tokens/{apiToken}', [ApiTokenController::class, 'destroy'])->name('destroy');
|
||||
});
|
||||
|
||||
// User Member routes
|
||||
Route::name('users.memberships.')->group(static function (): void {
|
||||
Route::get('/users/me/memberships', [UserMembershipController::class, 'myMemberships'])->name('my-memberships');
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace Tests\Unit\Database;
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SeederTest extends TestCase
|
||||
@@ -13,6 +14,7 @@ class SeederTest extends TestCase
|
||||
|
||||
public function test_running_the_seeder_multiple_times_runs_successfully(): void
|
||||
{
|
||||
$this->setupForSeeder();
|
||||
$this->artisan('db:seed')
|
||||
->assertSuccessful();
|
||||
$this->artisan('db:seed')
|
||||
@@ -21,9 +23,16 @@ class SeederTest extends TestCase
|
||||
|
||||
public function test_fresh_migration_with_seeder_and_rollback_runs_successfully(): void
|
||||
{
|
||||
$this->setupForSeeder();
|
||||
$this->artisan('db:seed')
|
||||
->assertSuccessful();
|
||||
$this->artisan('migrate:rollback')
|
||||
->assertSuccessful();
|
||||
}
|
||||
|
||||
private function setupForSeeder(): void
|
||||
{
|
||||
Config::set('passport.personal_access_client.id', '9e27f54d-5dfb-4dde-99d7-834518236c92');
|
||||
Config::set('passport.personal_access_client.secret', 'EL5mXp3aF8ITjcwoOXRpbSK7zGrWhW4zTDpQXTkf');
|
||||
}
|
||||
}
|
||||
|
||||
312
tests/Unit/Endpoint/Api/V1/ApiTokenEndpointTest.php
Normal file
312
tests/Unit/Endpoint/Api/V1/ApiTokenEndpointTest.php
Normal file
@@ -0,0 +1,312 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Endpoint\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Api\V1\ApiTokenController;
|
||||
use App\Models\Passport\Client;
|
||||
use App\Models\Passport\Token;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Laravel\Passport\ClientRepository;
|
||||
use Laravel\Passport\Passport;
|
||||
use PHPUnit\Framework\Attributes\UsesClass;
|
||||
|
||||
#[UsesClass(ApiTokenController::class)]
|
||||
class ApiTokenEndpointTest extends ApiEndpointTestAbstract
|
||||
{
|
||||
public function test_index_endpoint_returns_list_api_tokens(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([]);
|
||||
$personalAccessClient = $this->createPersonalAccessClient();
|
||||
Config::set('passport.personal_access_client.id', $personalAccessClient->id);
|
||||
Config::set('passport.personal_access_client.secret', $personalAccessClient->secret);
|
||||
$client = $this->createClient();
|
||||
$token = Token::factory()->forUser($data->user)->forClient($personalAccessClient)->create();
|
||||
$otherTokenType = Token::factory()->forUser($data->user)->forClient($client)->create();
|
||||
$otherData = $this->createUserWithPermission([]);
|
||||
$otherToken = Token::factory()->forUser($otherData->user)->forClient($personalAccessClient)->create();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->getJson(route('api.v1.api-tokens.index'));
|
||||
|
||||
// Assert
|
||||
$this->assertResponseCode($response, 200);
|
||||
$response->assertExactJson([
|
||||
'data' => [
|
||||
[
|
||||
'id' => $token->id,
|
||||
'name' => $token->name,
|
||||
'scopes' => $token->scopes,
|
||||
'revoked' => $token->revoked,
|
||||
'created_at' => $token->created_at->toIso8601ZuluString(),
|
||||
'expires_at' => $token->expires_at->toIso8601ZuluString(),
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_store_endpoint_creates_new_api_token(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([]);
|
||||
$personalAccessClient = $this->createPersonalAccessClient();
|
||||
Config::set('passport.personal_access_client.id', $personalAccessClient->id);
|
||||
Config::set('passport.personal_access_client.secret', $personalAccessClient->secret);
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.api-tokens.store'), [
|
||||
'name' => 'Test Token',
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$this->assertResponseCode($response, 200);
|
||||
$response->assertJsonStructure([
|
||||
'data' => [
|
||||
'id',
|
||||
'name',
|
||||
'scopes',
|
||||
'revoked',
|
||||
'created_at',
|
||||
'expires_at',
|
||||
'access_token',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_store_fails_if_personal_access_client_is_not_configured(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([]);
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.api-tokens.store'), [
|
||||
'name' => 'Test Token',
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$this->assertResponseCode($response, 400);
|
||||
$response->assertExactJson([
|
||||
'error' => true,
|
||||
'key' => 'personal_access_client_is_not_configured',
|
||||
'message' => 'Personal access client is not configured',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_revoke_endpoint_revokes_api_token(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([]);
|
||||
$client = $this->createPersonalAccessClient();
|
||||
Config::set('passport.personal_access_client.id', $client->id);
|
||||
Config::set('passport.personal_access_client.secret', $client->secret);
|
||||
$token = Token::factory()->forUser($data->user)->forClient($client)->create();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.api-tokens.revoke', $token->id));
|
||||
|
||||
// Assert
|
||||
$this->assertResponseCode($response, 204);
|
||||
$this->assertDatabaseHas(Token::class, [
|
||||
'id' => $token->id,
|
||||
'revoked' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_revoke_fails_if_token_is_not_personal_access_token(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([]);
|
||||
$personalAccessClient = $this->createPersonalAccessClient();
|
||||
Config::set('passport.personal_access_client.id', $personalAccessClient->id);
|
||||
Config::set('passport.personal_access_client.secret', $personalAccessClient->secret);
|
||||
$client = $this->createClient();
|
||||
$token = Token::factory()->forUser($data->user)->forClient($client)->create();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.api-tokens.revoke', $token->id));
|
||||
|
||||
// Assert
|
||||
$this->assertResponseCode($response, 403);
|
||||
$this->assertDatabaseHas(Token::class, [
|
||||
'id' => $token->id,
|
||||
'revoked' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_revoke_fails_if_token_with_id_does_not_exist(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([]);
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.api-tokens.revoke', 'not-valid'));
|
||||
|
||||
// Assert
|
||||
$this->assertResponseCode($response, 404);
|
||||
}
|
||||
|
||||
public function test_revoke_fails_if_personal_access_client_is_not_configured(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([]);
|
||||
$client = $this->createPersonalAccessClient();
|
||||
$token = Token::factory()->forUser($data->user)->forClient($client)->create();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.api-tokens.revoke', $token->id));
|
||||
|
||||
// Assert
|
||||
$this->assertResponseCode($response, 400);
|
||||
$response->assertExactJson([
|
||||
'error' => true,
|
||||
'key' => 'personal_access_client_is_not_configured',
|
||||
'message' => 'Personal access client is not configured',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_revoke_fails_if_the_token_does_not_belong_to_the_user(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([]);
|
||||
$otherData = $this->createUserWithPermission([]);
|
||||
$client = $this->createPersonalAccessClient();
|
||||
Config::set('passport.personal_access_client.id', $client->id);
|
||||
Config::set('passport.personal_access_client.secret', $client->secret);
|
||||
$token = Token::factory()->forUser($otherData->user)->forClient($client)->create();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.api-tokens.revoke', $token->id));
|
||||
|
||||
// Assert
|
||||
$this->assertResponseCode($response, 403);
|
||||
$this->assertDatabaseHas(Token::class, [
|
||||
'id' => $token->id,
|
||||
'revoked' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_destroy_endpoint_deletes_api_token(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([]);
|
||||
$client = $this->createPersonalAccessClient();
|
||||
Config::set('passport.personal_access_client.id', $client->id);
|
||||
Config::set('passport.personal_access_client.secret', $client->secret);
|
||||
$token = Token::factory()->forUser($data->user)->forClient($client)->create();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->deleteJson(route('api.v1.api-tokens.destroy', $token->id));
|
||||
|
||||
// Assert
|
||||
$this->assertResponseCode($response, 204);
|
||||
$this->assertDatabaseMissing(Token::class, ['id' => $token->id]);
|
||||
}
|
||||
|
||||
public function test_destroy_fails_if_personal_access_client_is_not_configured(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([]);
|
||||
$client = $this->createPersonalAccessClient();
|
||||
$token = Token::factory()->forUser($data->user)->forClient($client)->create();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->deleteJson(route('api.v1.api-tokens.destroy', $token->id));
|
||||
|
||||
// Assert
|
||||
$this->assertResponseCode($response, 400);
|
||||
$response->assertExactJson([
|
||||
'error' => true,
|
||||
'key' => 'personal_access_client_is_not_configured',
|
||||
'message' => 'Personal access client is not configured',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_destroy_fails_if_token_is_not_personal_access_token(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([]);
|
||||
$personalAccessClient = $this->createPersonalAccessClient();
|
||||
Config::set('passport.personal_access_client.id', $personalAccessClient->id);
|
||||
Config::set('passport.personal_access_client.secret', $personalAccessClient->secret);
|
||||
$client = $this->createClient();
|
||||
$token = Token::factory()->forUser($data->user)->forClient($client)->create();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->deleteJson(route('api.v1.api-tokens.destroy', $token->id));
|
||||
|
||||
// Assert
|
||||
$this->assertResponseCode($response, 403);
|
||||
$this->assertDatabaseHas(Token::class, [
|
||||
'id' => $token->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_destroy_fails_if_token_with_id_does_not_exist(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([]);
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->deleteJson(route('api.v1.api-tokens.destroy', 'not-valid'));
|
||||
|
||||
// Assert
|
||||
$this->assertResponseCode($response, 404);
|
||||
}
|
||||
|
||||
public function test_destroy_fails_if_the_token_does_not_belong_to_the_user(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([]);
|
||||
$otherData = $this->createUserWithPermission([]);
|
||||
$client = $this->createPersonalAccessClient();
|
||||
Config::set('passport.personal_access_client.id', $client->id);
|
||||
Config::set('passport.personal_access_client.secret', $client->secret);
|
||||
$token = Token::factory()->forUser($otherData->user)->forClient($client)->create();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->deleteJson(route('api.v1.api-tokens.destroy', $token->id));
|
||||
|
||||
// Assert
|
||||
$this->assertResponseCode($response, 403);
|
||||
$this->assertDatabaseHas(Token::class, [
|
||||
'id' => $token->id,
|
||||
]);
|
||||
}
|
||||
|
||||
private function createPersonalAccessClient(): Client
|
||||
{
|
||||
$clientRepository = new ClientRepository;
|
||||
/** @var Client $client */
|
||||
$client = $clientRepository->createPersonalAccessClient(
|
||||
null, 'Test Personal Access Client', 'http://localhost'
|
||||
);
|
||||
|
||||
return $client;
|
||||
}
|
||||
|
||||
private function createClient(): Client
|
||||
{
|
||||
$clientRepository = new ClientRepository;
|
||||
/** @var Client $client */
|
||||
$client = $clientRepository->create(
|
||||
null, 'Desktop App', 'http://localhost', null
|
||||
);
|
||||
|
||||
return $client;
|
||||
}
|
||||
}
|
||||
94
tests/Unit/Filament/Resources/TokenResourceTest.php
Normal file
94
tests/Unit/Filament/Resources/TokenResourceTest.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\TokenResource;
|
||||
use App\Models\Passport\Client;
|
||||
use App\Models\Passport\Token;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Livewire\Livewire;
|
||||
use PHPUnit\Framework\Attributes\UsesClass;
|
||||
use Tests\Unit\Filament\FilamentTestCase;
|
||||
|
||||
#[UsesClass(TokenResource::class)]
|
||||
class TokenResourceTest extends FilamentTestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Config::set('auth.super_admins', ['admin@example.com']);
|
||||
$user = User::factory()->withPersonalOrganization()->create([
|
||||
'email' => 'admin@example.com',
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
}
|
||||
|
||||
public function test_can_list_tokens(): void
|
||||
{
|
||||
// Arrange
|
||||
$client = Client::factory()->create();
|
||||
$tokens = Token::factory()->forClient($client)->createMany(5);
|
||||
|
||||
// Act
|
||||
$response = Livewire::test(TokenResource\Pages\ListTokens::class);
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
$response->assertCanSeeTableRecords($tokens);
|
||||
}
|
||||
|
||||
public function test_list_tokens_with_filter_is_personal_access_client_true(): void
|
||||
{
|
||||
// Arrange
|
||||
$client = Client::factory()->create();
|
||||
$personalAccessClient = Client::factory()->personalAccessClient()->create();
|
||||
$tokens = Token::factory()->forClient($client)->createMany(5);
|
||||
$personalAccessTokens = Token::factory()->forClient($personalAccessClient)->createMany(5);
|
||||
|
||||
// Act
|
||||
$response = Livewire::test(TokenResource\Pages\ListTokens::class)
|
||||
->filterTable('is_personal_access_client', true);
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
$response->assertCountTableRecords(5);
|
||||
$response->assertCanSeeTableRecords($personalAccessTokens);
|
||||
$response->assertCanNotSeeTableRecords($tokens);
|
||||
}
|
||||
|
||||
public function test_list_tokens_with_filter_is_personal_access_client_false(): void
|
||||
{
|
||||
// Arrange
|
||||
$client = Client::factory()->create();
|
||||
$personalAccessClient = Client::factory()->personalAccessClient()->create();
|
||||
$tokens = Token::factory()->forClient($client)->createMany(5);
|
||||
$personalAccessTokens = Token::factory()->forClient($personalAccessClient)->createMany(5);
|
||||
|
||||
// Act
|
||||
$response = Livewire::test(TokenResource\Pages\ListTokens::class)
|
||||
->filterTable('is_personal_access_client', false);
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
$response->assertCountTableRecords(5);
|
||||
$response->assertCanSeeTableRecords($tokens);
|
||||
$response->assertCanNotSeeTableRecords($personalAccessTokens);
|
||||
}
|
||||
|
||||
public function test_can_see_view_page_of_token(): void
|
||||
{
|
||||
// Arrange
|
||||
$client = Client::factory()->create();
|
||||
$token = Token::factory()->forClient($client)->create();
|
||||
|
||||
// Act
|
||||
$response = Livewire::test(TokenResource\Pages\ViewToken::class, ['record' => $token->getKey()]);
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user