mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-09 08:42:15 +01:00
Compare commits
1 Commits
feature/ap
...
feature/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45a60a926f |
42
.env.ci
42
.env.ci
@@ -1,4 +1,3 @@
|
|||||||
# Application
|
|
||||||
APP_NAME=solidtime
|
APP_NAME=solidtime
|
||||||
APP_ENV=local
|
APP_ENV=local
|
||||||
APP_KEY=
|
APP_KEY=
|
||||||
@@ -6,6 +5,7 @@ APP_DEBUG=true
|
|||||||
APP_URL=http://localhost
|
APP_URL=http://localhost
|
||||||
APP_FORCE_HTTPS=false
|
APP_FORCE_HTTPS=false
|
||||||
APP_ENABLE_REGISTRATION=true
|
APP_ENABLE_REGISTRATION=true
|
||||||
|
SESSION_SECURE_COOKIE=false
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
LOG_CHANNEL=stack
|
LOG_CHANNEL=stack
|
||||||
@@ -20,39 +20,35 @@ DB_TEST_DATABASE=laravel
|
|||||||
DB_TEST_USERNAME=root
|
DB_TEST_USERNAME=root
|
||||||
DB_TEST_PASSWORD=root
|
DB_TEST_PASSWORD=root
|
||||||
|
|
||||||
# Broadcasting
|
BROADCAST_DRIVER=log
|
||||||
BROADCAST_DRIVER=null
|
|
||||||
|
|
||||||
# Cache
|
|
||||||
CACHE_DRIVER=file
|
CACHE_DRIVER=file
|
||||||
|
|
||||||
# Queue
|
|
||||||
QUEUE_CONNECTION=sync
|
QUEUE_CONNECTION=sync
|
||||||
|
|
||||||
# Session
|
|
||||||
SESSION_DRIVER=database
|
SESSION_DRIVER=database
|
||||||
SESSION_LIFETIME=120
|
SESSION_LIFETIME=120
|
||||||
|
|
||||||
# Mail
|
# Mail
|
||||||
MAIL_MAILER=log
|
MAIL_MAILER=log
|
||||||
MAIL_FROM_ADDRESS="no-reply@solidtime.test"
|
MAIL_FROM_ADDRESS="hello@example.com"
|
||||||
MAIL_FROM_NAME="solidtime"
|
MAIL_FROM_NAME="${APP_NAME}"
|
||||||
MAIL_REPLY_TO_ADDRESS="hello@solidtime.test"
|
|
||||||
MAIL_REPLY_TO_NAME="solidtime"
|
|
||||||
|
|
||||||
# Filesystems
|
# Filesystems
|
||||||
FILESYSTEM_DISK=local
|
FILESYSTEM_DISK=local
|
||||||
PUBLIC_FILESYSTEM_DISK=public
|
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
|
# Services
|
||||||
GOTENBERG_URL=http://0.0.0.0:3000
|
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_KEY=base64:UNQNf1SXeASNkWux01Rj8EnHYx8FO0kAxWNDwktclkk=
|
||||||
APP_DEBUG=true
|
APP_DEBUG=true
|
||||||
APP_URL=https://solidtime.test
|
APP_URL=https://solidtime.test
|
||||||
APP_FORCE_HTTPS=false
|
AUDITING_ENABLED=true
|
||||||
APP_ENABLE_REGISTRATION=true
|
APP_ENABLE_REGISTRATION=true
|
||||||
SUPER_ADMINS=admin@example.com
|
SUPER_ADMINS=admin@example.com
|
||||||
PAGINATION_PER_PAGE_DEFAULT=500
|
PAGINATION_PER_PAGE_DEFAULT=500
|
||||||
@@ -49,9 +49,7 @@ MAIL_USERNAME=null
|
|||||||
MAIL_PASSWORD=null
|
MAIL_PASSWORD=null
|
||||||
MAIL_ENCRYPTION=null
|
MAIL_ENCRYPTION=null
|
||||||
MAIL_FROM_ADDRESS="no-reply@solidtime.test"
|
MAIL_FROM_ADDRESS="no-reply@solidtime.test"
|
||||||
MAIL_FROM_NAME="solidtime"
|
MAIL_FROM_NAME="${APP_NAME}"
|
||||||
MAIL_REPLY_TO_ADDRESS="hello@solidtime.test"
|
|
||||||
MAIL_REPLY_TO_NAME="solidtime"
|
|
||||||
|
|
||||||
# Filesystems
|
# Filesystems
|
||||||
FILESYSTEM_DISK=s3
|
FILESYSTEM_DISK=s3
|
||||||
@@ -64,24 +62,14 @@ S3_URL=http://storage.solidtime.test/local
|
|||||||
S3_ENDPOINT=http://storage.solidtime.test
|
S3_ENDPOINT=http://storage.solidtime.test
|
||||||
S3_USE_PATH_STYLE_ENDPOINT=true
|
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
|
# Services
|
||||||
GOTENBERG_URL=http://gotenberg:3000
|
GOTENBERG_URL=http://gotenberg:3000
|
||||||
|
|
||||||
|
VITE_HOST_NAME=vite.solidtime.test
|
||||||
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
|
|
||||||
# Local setup
|
# Local setup
|
||||||
NGINX_HOST_NAME=solidtime.test
|
NGINX_HOST_NAME=solidtime.test
|
||||||
NETWORK_NAME=reverse-proxy-docker-traefik_routing
|
NETWORK_NAME=reverse-proxy-docker-traefik_routing
|
||||||
FORWARD_DB_PORT=5432
|
FORWARD_DB_PORT=5432
|
||||||
FORWARD_WEB_PORT=8083
|
FORWARD_WEB_PORT=8083
|
||||||
VITE_HOST_NAME=vite.solidtime.test
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
|
||||||
#SAIL_XDEBUG_MODE=develop,debug,coverage
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ VITE_APP_NAME=solidtime
|
|||||||
APP_ENV=production
|
APP_ENV=production
|
||||||
APP_DEBUG=false
|
APP_DEBUG=false
|
||||||
APP_FORCE_HTTPS=true
|
APP_FORCE_HTTPS=true
|
||||||
|
SESSION_SECURE_COOKIE=true
|
||||||
OCTANE_SERVER=frankenphp
|
OCTANE_SERVER=frankenphp
|
||||||
PAGINATION_PER_PAGE_DEFAULT=500
|
PAGINATION_PER_PAGE_DEFAULT=500
|
||||||
|
|
||||||
|
|||||||
3
.github/workflows/npm-publish-api.yml
vendored
3
.github/workflows/npm-publish-api.yml
vendored
@@ -8,8 +8,7 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
id-token: write
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- uses: actions/checkout@v4
|
||||||
uses: actions/checkout@v4
|
|
||||||
# Setup .npmrc file to publish to npm
|
# Setup .npmrc file to publish to npm
|
||||||
- name: Install root project dependencies
|
- name: Install root project dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|||||||
3
.github/workflows/npm-publish-ui.yml
vendored
3
.github/workflows/npm-publish-ui.yml
vendored
@@ -8,8 +8,7 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
id-token: write
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- uses: actions/checkout@v4
|
||||||
uses: actions/checkout@v4
|
|
||||||
# Setup .npmrc file to publish to npm
|
# Setup .npmrc file to publish to npm
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
|
|||||||
24
.github/workflows/playwright.yml
vendored
24
.github/workflows/playwright.yml
vendored
@@ -27,47 +27,45 @@ jobs:
|
|||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: "Setup node"
|
- uses: actions/setup-node@v4
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
|
||||||
- name: "Setup PHP"
|
- name: Setup PHP
|
||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@v2
|
||||||
with:
|
with:
|
||||||
php-version: '8.3'
|
php-version: '8.3'
|
||||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv
|
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv
|
||||||
coverage: none
|
coverage: none
|
||||||
|
|
||||||
- name: "Run composer install"
|
- name: Run composer install
|
||||||
run: composer install -n --prefer-dist
|
run: composer install -n --prefer-dist
|
||||||
|
|
||||||
- name: "Prepare Laravel Application"
|
- name: Prepare Laravel Application
|
||||||
run: |
|
run: |
|
||||||
cp .env.ci .env
|
cp .env.ci .env
|
||||||
php artisan key:generate
|
php artisan key:generate
|
||||||
php artisan passport:keys
|
|
||||||
php artisan migrate --seed
|
php artisan migrate --seed
|
||||||
|
php artisan passport:keys
|
||||||
|
|
||||||
- name: "Install dependencies"
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: "Build Frontend"
|
- name: Build Frontend
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
- name: "Run Laravel Server"
|
- name: Run Laravel Server
|
||||||
run: php artisan serve > /dev/null 2>&1 &
|
run: php artisan serve > /dev/null 2>&1 &
|
||||||
|
|
||||||
- name: "Install Playwright Browsers"
|
- name: Install Playwright Browsers
|
||||||
run: npx playwright install --with-deps
|
run: npx playwright install --with-deps
|
||||||
|
|
||||||
- name: "Run Playwright tests"
|
- name: Run Playwright tests
|
||||||
run: npx playwright test
|
run: npx playwright test
|
||||||
env:
|
env:
|
||||||
PLAYWRIGHT_BASE_URL: 'http://127.0.0.1:8000'
|
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()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: test-results
|
name: test-results
|
||||||
|
|||||||
@@ -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';
|
|
||||||
}
|
|
||||||
@@ -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}'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 [
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 [
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -42,7 +42,6 @@ class UserResource extends Resource
|
|||||||
{
|
{
|
||||||
/** @var User|null $record */
|
/** @var User|null $record */
|
||||||
$record = $form->getRecord();
|
$record = $form->getRecord();
|
||||||
|
|
||||||
return $form
|
return $form
|
||||||
->columns(1)
|
->columns(1)
|
||||||
->schema([
|
->schema([
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -64,7 +64,6 @@ class HealthCheckController extends Controller
|
|||||||
$response['app_env'] = app()->environment();
|
$response['app_env'] = app()->environment();
|
||||||
$response['app_timezone'] = config('app.timezone');
|
$response['app_timezone'] = config('app.timezone');
|
||||||
$response['app_force_https'] = config('app.force_https');
|
$response['app_force_https'] = config('app.force_https');
|
||||||
$response['session_secure'] = config('session.secure');
|
|
||||||
$response['trusted_proxies'] = config('trustedproxy.proxies');
|
$response['trusted_proxies'] = config('trustedproxy.proxies');
|
||||||
$headers = $request->headers->all();
|
$headers = $request->headers->all();
|
||||||
if (isset($headers['cookie'])) {
|
if (isset($headers['cookie'])) {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class Kernel extends HttpKernel
|
|||||||
* @var array<int, class-string|string>
|
* @var array<int, class-string|string>
|
||||||
*/
|
*/
|
||||||
protected $middleware = [
|
protected $middleware = [
|
||||||
\App\Http\Middleware\ForceHttps::class,
|
// \App\Http\Middleware\TrustHosts::class,
|
||||||
\App\Http\Middleware\TrustProxies::class,
|
\App\Http\Middleware\TrustProxies::class,
|
||||||
\Illuminate\Http\Middleware\HandleCors::class,
|
\Illuminate\Http\Middleware\HandleCors::class,
|
||||||
\App\Http\Middleware\PreventRequestsDuringMaintenance::class,
|
\App\Http\Middleware\PreventRequestsDuringMaintenance::class,
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
22
app/Http/Middleware/TrustHosts.php
Normal file
22
app/Http/Middleware/TrustHosts.php
Normal 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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Models\Passport;
|
|
||||||
|
|
||||||
use Laravel\Passport\AuthCode as PassportAuthCode;
|
|
||||||
|
|
||||||
class AuthCode extends PassportAuthCode {}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Models\Passport;
|
|
||||||
|
|
||||||
use Laravel\Passport\PersonalAccessClient as PassportPersonalAccessClient;
|
|
||||||
|
|
||||||
class PersonalAccessClient extends PassportPersonalAccessClient {}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Models\Passport;
|
|
||||||
|
|
||||||
use Laravel\Passport\RefreshToken as PassportRefreshToken;
|
|
||||||
|
|
||||||
class RefreshToken extends PassportRefreshToken {}
|
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,6 @@ namespace App\Models;
|
|||||||
use App\Enums\Weekday;
|
use App\Enums\Weekday;
|
||||||
use App\Models\Concerns\CustomAuditable;
|
use App\Models\Concerns\CustomAuditable;
|
||||||
use App\Models\Concerns\HasUuids;
|
use App\Models\Concerns\HasUuids;
|
||||||
use App\Models\Passport\Token;
|
|
||||||
use Database\Factories\UserFactory;
|
use Database\Factories\UserFactory;
|
||||||
use Filament\Models\Contracts\FilamentUser;
|
use Filament\Models\Contracts\FilamentUser;
|
||||||
use Filament\Panel;
|
use Filament\Panel;
|
||||||
@@ -28,6 +27,7 @@ use Laravel\Jetstream\HasProfilePhoto;
|
|||||||
use Laravel\Jetstream\HasTeams;
|
use Laravel\Jetstream\HasTeams;
|
||||||
use Laravel\Passport\AuthCode;
|
use Laravel\Passport\AuthCode;
|
||||||
use Laravel\Passport\HasApiTokens;
|
use Laravel\Passport\HasApiTokens;
|
||||||
|
use Laravel\Passport\Token;
|
||||||
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,7 +44,6 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
|||||||
* @property-read Organization|null $currentOrganization
|
* @property-read Organization|null $currentOrganization
|
||||||
* @property-read Organization|null $currentTeam
|
* @property-read Organization|null $currentTeam
|
||||||
* @property-read string $profile_photo_url
|
* @property-read string $profile_photo_url
|
||||||
* @property-read Collection<int, Token> $tokens
|
|
||||||
* @property Carbon|null $created_at
|
* @property Carbon|null $created_at
|
||||||
* @property Carbon|null $updated_at
|
* @property Carbon|null $updated_at
|
||||||
* @property string|null $current_team_id
|
* @property string|null $current_team_id
|
||||||
@@ -197,17 +196,6 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
|
|||||||
return $this->hasMany(AuthCode::class);
|
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
|
* @param Builder<User> $builder
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ use App\Models\FailedJob;
|
|||||||
use App\Models\Member;
|
use App\Models\Member;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\OrganizationInvitation;
|
use App\Models\OrganizationInvitation;
|
||||||
use App\Models\Passport\Token;
|
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\ProjectMember;
|
use App\Models\ProjectMember;
|
||||||
use App\Models\Tag;
|
use App\Models\Tag;
|
||||||
@@ -30,6 +29,7 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use Illuminate\Support\Facades\URL;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
class AppServiceProvider extends 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 {
|
$this->app->scoped(PermissionStore::class, function (Application $app): PermissionStore {
|
||||||
return new PermissionStore;
|
return new PermissionStore;
|
||||||
});
|
});
|
||||||
@@ -101,6 +107,5 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
// Routing
|
// Routing
|
||||||
Route::model('member', Member::class);
|
Route::model('member', Member::class);
|
||||||
Route::model('invitation', OrganizationInvitation::class);
|
Route::model('invitation', OrganizationInvitation::class);
|
||||||
Route::model('apiToken', Token::class);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,6 @@ declare(strict_types=1);
|
|||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
use App\Models\Organization;
|
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 App\Policies\OrganizationPolicy;
|
||||||
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
||||||
use Laravel\Jetstream\Jetstream;
|
use Laravel\Jetstream\Jetstream;
|
||||||
@@ -47,16 +42,6 @@ class AuthServiceProvider extends ServiceProvider
|
|||||||
// 'delete',
|
// '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
|
// same as passport default above
|
||||||
Jetstream::defaultApiTokenPermissions(['read']);
|
Jetstream::defaultApiTokenPermissions(['read']);
|
||||||
|
|
||||||
|
|||||||
@@ -69,9 +69,6 @@ class AdminPanelProvider extends PanelProvider
|
|||||||
NavigationGroup::make()
|
NavigationGroup::make()
|
||||||
->label('System')
|
->label('System')
|
||||||
->collapsed(),
|
->collapsed(),
|
||||||
NavigationGroup::make()
|
|
||||||
->label('Auth')
|
|
||||||
->collapsed(),
|
|
||||||
])
|
])
|
||||||
->middleware([
|
->middleware([
|
||||||
EncryptCookies::class,
|
EncryptCookies::class,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ return [
|
|||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'guard' => 'api',
|
'guard' => 'web',
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ return [
|
|||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'secure' => env('SESSION_SECURE_COOKIE', env('APP_FORCE_HTTPS')),
|
'secure' => env('SESSION_SECURE_COOKIE'),
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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(),
|
|
||||||
];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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(),
|
|
||||||
];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -34,29 +34,6 @@ class DatabaseSeeder extends Seeder
|
|||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
$this->deleteAll();
|
$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([
|
$userWithMultipleOrganizations = User::factory()->withPersonalOrganization()->create([
|
||||||
'name' => 'Mister Overemployed',
|
'name' => 'Mister Overemployed',
|
||||||
'email' => 'overemployed@acme.test',
|
'email' => 'overemployed@acme.test',
|
||||||
@@ -78,8 +55,6 @@ class DatabaseSeeder extends Seeder
|
|||||||
'name' => 'Acme Manager',
|
'name' => 'Acme Manager',
|
||||||
'email' => 'test@example.com',
|
'email' => 'test@example.com',
|
||||||
]);
|
]);
|
||||||
$userAcmeManager->createToken('Testing Token 1')->accessToken;
|
|
||||||
$userAcmeManager->createToken('Testing Token 2')->accessToken;
|
|
||||||
$userAcmeAdmin = User::factory()->withPersonalOrganization()->create([
|
$userAcmeAdmin = User::factory()->withPersonalOrganization()->create([
|
||||||
'name' => 'Acme Admin',
|
'name' => 'Acme Admin',
|
||||||
'email' => 'admin@acme.test',
|
'email' => 'admin@acme.test',
|
||||||
@@ -184,6 +159,15 @@ class DatabaseSeeder extends Seeder
|
|||||||
'email' => 'admin@example.com',
|
'email' => 'admin@example.com',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
app(ClientRepository::class)->create(
|
||||||
|
null,
|
||||||
|
'desktop',
|
||||||
|
'solidtime://oauth/callback',
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function deleteAll(): void
|
private function deleteAll(): void
|
||||||
|
|||||||
@@ -1,69 +1,25 @@
|
|||||||
import {test, expect} from '../playwright/fixtures';
|
import { test, expect } from '../playwright/fixtures';
|
||||||
import {PLAYWRIGHT_BASE_URL} from '../playwright/config';
|
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.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([
|
await Promise.all([
|
||||||
page.getByRole('button', {name: 'Save'}).first().click(),
|
page.getByRole('button', { name: 'Save' }).first().click(),
|
||||||
page.waitForResponse('**/user/profile-information'),
|
page.waitForResponse('**/user/profile-information'),
|
||||||
]);
|
]);
|
||||||
await page.reload();
|
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
|
// this does not work because of email verification currently
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
|
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
|
||||||
const emailId = Math.round(Math.random() * 10000);
|
const emailId = Math.round(Math.random() * 10000);
|
||||||
await page.getByLabel('Email').fill(`newemail+${emailId}@test.com`);
|
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 page.reload();
|
||||||
await expect(page.getByLabel('Email')).toHaveValue(
|
await expect(page.getByLabel('Email')).toHaveValue(
|
||||||
`newemail+${emailId}@test.com`
|
`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,7 +12,6 @@ use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
|
|||||||
use App\Exceptions\Api\OrganizationHasNoSubscriptionButMultipleMembersException;
|
use App\Exceptions\Api\OrganizationHasNoSubscriptionButMultipleMembersException;
|
||||||
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
|
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
|
||||||
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
|
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
|
||||||
use App\Exceptions\Api\PersonalAccessClientIsNotConfiguredException;
|
|
||||||
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
|
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
|
||||||
use App\Exceptions\Api\TimeEntryStillRunningApiException;
|
use App\Exceptions\Api\TimeEntryStillRunningApiException;
|
||||||
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
|
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
|
||||||
@@ -38,7 +37,6 @@ return [
|
|||||||
OrganizationHasNoSubscriptionButMultipleMembersException::KEY => 'Organization has no subscription but multiple members',
|
OrganizationHasNoSubscriptionButMultipleMembersException::KEY => 'Organization has no subscription but multiple members',
|
||||||
PdfRendererIsNotConfiguredException::KEY => 'PDF renderer is not configured',
|
PdfRendererIsNotConfiguredException::KEY => 'PDF renderer is not configured',
|
||||||
FeatureIsNotAvailableInFreePlanApiException::KEY => 'Feature is not available in free plan',
|
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.',
|
'unknown_error_in_admin_panel' => 'An unknown error occurred. Please check the logs.',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -29,8 +29,6 @@
|
|||||||
</source>
|
</source>
|
||||||
<php>
|
<php>
|
||||||
<env name="APP_ENV" value="testing"/>
|
<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="BCRYPT_ROUNDS" value="4"/>
|
||||||
<env name="CACHE_DRIVER" value="array"/>
|
<env name="CACHE_DRIVER" value="array"/>
|
||||||
<env name="DB_CONNECTION" value="pgsql_test"/>
|
<env name="DB_CONNECTION" value="pgsql_test"/>
|
||||||
@@ -41,7 +39,5 @@
|
|||||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||||
<env name="AUDITING_ENABLED" value="true"/>
|
<env name="AUDITING_ENABLED" value="true"/>
|
||||||
<env name="NEWSLETTER_URL" value="null"/>
|
<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>
|
</php>
|
||||||
</phpunit>
|
</phpunit>
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ async function resendInvitation() {
|
|||||||
await handleApiRequestNotifications(
|
await handleApiRequestNotifications(
|
||||||
() =>
|
() =>
|
||||||
api.resendInvitationEmail(
|
api.resendInvitationEmail(
|
||||||
undefined,
|
{},
|
||||||
{
|
{
|
||||||
params: {
|
params: {
|
||||||
invitation: props.invitation.id,
|
invitation: props.invitation.id,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { useMembersStore } from '@/utils/useMembers';
|
import { useMembersStore } from '@/utils/useMembers';
|
||||||
import { UserIcon, ChevronDownIcon } from '@heroicons/vue/24/solid';
|
import { UserIcon, ChevronDownIcon } from '@heroicons/vue/24/solid';
|
||||||
@@ -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(() => {
|
const currentValue = computed(() => {
|
||||||
if (model.value) {
|
if (model.value) {
|
||||||
return members.value.find((member) => member.id === model.value)?.name;
|
return members.value.find((member) => member.id === model.value)?.name;
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ async function invitePlaceholder(id: string) {
|
|||||||
await handleApiRequestNotifications(
|
await handleApiRequestNotifications(
|
||||||
() =>
|
() =>
|
||||||
api.invitePlaceholder(
|
api.invitePlaceholder(
|
||||||
undefined,
|
{},
|
||||||
{
|
{
|
||||||
params: {
|
params: {
|
||||||
organization: organizationId,
|
organization: organizationId,
|
||||||
|
|||||||
@@ -1,332 +0,0 @@
|
|||||||
<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,7 +9,6 @@ import UpdateProfileInformationForm from '@/Pages/Profile/Partials/UpdateProfile
|
|||||||
import { usePage } from '@inertiajs/vue3';
|
import { usePage } from '@inertiajs/vue3';
|
||||||
import type { User } from '@/types/models';
|
import type { User } from '@/types/models';
|
||||||
import type { Session } from '@/types/jetstream';
|
import type { Session } from '@/types/jetstream';
|
||||||
import ApiTokensForm from "@/Pages/Profile/Partials/ApiTokensForm.vue";
|
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
confirmsTwoFactorAuthentication: boolean;
|
confirmsTwoFactorAuthentication: boolean;
|
||||||
@@ -66,9 +65,6 @@ const page = usePage<{
|
|||||||
<LogoutOtherBrowserSessionsForm
|
<LogoutOtherBrowserSessionsForm
|
||||||
:sessions="sessions"
|
:sessions="sessions"
|
||||||
class="mt-10 sm:mt-0" />
|
class="mt-10 sm:mt-0" />
|
||||||
<SectionBorder />
|
|
||||||
|
|
||||||
<ApiTokensForm></ApiTokensForm>
|
|
||||||
|
|
||||||
<template
|
<template
|
||||||
v-if="page.props.jetstream.hasAccountDeletionFeatures">
|
v-if="page.props.jetstream.hasAccountDeletionFeatures">
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ async function exportData() {
|
|||||||
const response = await handleApiRequestNotifications(
|
const response = await handleApiRequestNotifications(
|
||||||
() =>
|
() =>
|
||||||
api.exportOrganization(
|
api.exportOrganization(
|
||||||
undefined,
|
{},
|
||||||
{
|
{
|
||||||
params: {
|
params: {
|
||||||
organization: organizationId,
|
organization: organizationId,
|
||||||
|
|||||||
@@ -162,15 +162,6 @@ export type UpdateReportBody = ZodiosBodyByAlias<SolidTimeApi, 'updateReport'>;
|
|||||||
export type CreateReportBodyProperties = CreateReportBody['properties'];
|
export type CreateReportBodyProperties = CreateReportBody['properties'];
|
||||||
export type Report = ReportIndexResponse['data'][0];
|
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' });
|
const api = createApiClient('/api', { validate: 'none' });
|
||||||
|
|
||||||
export { createApiClient, api };
|
export { createApiClient, api };
|
||||||
|
|||||||
@@ -1,21 +1,6 @@
|
|||||||
import { makeApi, Zodios, type ZodiosOptions } from '@zodios/core';
|
import { makeApi, Zodios, type ZodiosOptions } from '@zodios/core';
|
||||||
import { z } from 'zod';
|
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
|
const ClientResource = z
|
||||||
.object({
|
.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
@@ -41,11 +26,9 @@ const ImportRequest = z
|
|||||||
const InvitationResource = z
|
const InvitationResource = z
|
||||||
.object({ id: z.string(), email: z.string(), role: z.string() })
|
.object({ id: z.string(), email: z.string(), role: z.string() })
|
||||||
.passthrough();
|
.passthrough();
|
||||||
|
const Role = z.enum(['owner', 'admin', 'manager', 'employee', 'placeholder']);
|
||||||
const InvitationStoreRequest = z
|
const InvitationStoreRequest = z
|
||||||
.object({
|
.object({ email: z.string().email(), role: Role })
|
||||||
email: z.string().email(),
|
|
||||||
role: z.enum(['admin', 'manager', 'employee']),
|
|
||||||
})
|
|
||||||
.passthrough();
|
.passthrough();
|
||||||
const MemberResource = z
|
const MemberResource = z
|
||||||
.object({
|
.object({
|
||||||
@@ -58,7 +41,6 @@ const MemberResource = z
|
|||||||
billable_rate: z.union([z.number(), z.null()]),
|
billable_rate: z.union([z.number(), z.null()]),
|
||||||
})
|
})
|
||||||
.passthrough();
|
.passthrough();
|
||||||
const Role = z.enum(['owner', 'admin', 'manager', 'employee', 'placeholder']);
|
|
||||||
const MemberUpdateRequest = z
|
const MemberUpdateRequest = z
|
||||||
.object({ role: Role, billable_rate: z.union([z.number(), z.null()]) })
|
.object({ role: Role, billable_rate: z.union([z.number(), z.null()]) })
|
||||||
.partial()
|
.partial()
|
||||||
@@ -208,6 +190,13 @@ const ReportStoreRequest = z
|
|||||||
timezone: z.union([z.string(), z.null()]).optional(),
|
timezone: z.union([z.string(), z.null()]).optional(),
|
||||||
})
|
})
|
||||||
.passthrough(),
|
.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();
|
.passthrough();
|
||||||
const DetailedReportResource = z
|
const DetailedReportResource = z
|
||||||
@@ -470,21 +459,18 @@ const PersonalMembershipResource = z
|
|||||||
role: z.string(),
|
role: z.string(),
|
||||||
})
|
})
|
||||||
.passthrough();
|
.passthrough();
|
||||||
|
const PersonalMembershipCollection = z.array(PersonalMembershipResource);
|
||||||
|
|
||||||
export const schemas = {
|
export const schemas = {
|
||||||
ApiTokenResource,
|
|
||||||
ApiTokenCollection,
|
|
||||||
ApiTokenStoreRequest,
|
|
||||||
ApiTokenWithAccessTokenResource,
|
|
||||||
ClientResource,
|
ClientResource,
|
||||||
ClientCollection,
|
ClientCollection,
|
||||||
ClientStoreRequest,
|
ClientStoreRequest,
|
||||||
ClientUpdateRequest,
|
ClientUpdateRequest,
|
||||||
ImportRequest,
|
ImportRequest,
|
||||||
InvitationResource,
|
InvitationResource,
|
||||||
|
Role,
|
||||||
InvitationStoreRequest,
|
InvitationStoreRequest,
|
||||||
MemberResource,
|
MemberResource,
|
||||||
Role,
|
|
||||||
MemberUpdateRequest,
|
MemberUpdateRequest,
|
||||||
OrganizationResource,
|
OrganizationResource,
|
||||||
OrganizationUpdateRequest,
|
OrganizationUpdateRequest,
|
||||||
@@ -516,6 +502,7 @@ export const schemas = {
|
|||||||
TimeEntryUpdateRequest,
|
TimeEntryUpdateRequest,
|
||||||
UserResource,
|
UserResource,
|
||||||
PersonalMembershipResource,
|
PersonalMembershipResource,
|
||||||
|
PersonalMembershipCollection,
|
||||||
};
|
};
|
||||||
|
|
||||||
const endpoints = makeApi([
|
const endpoints = makeApi([
|
||||||
@@ -799,6 +786,11 @@ const endpoints = makeApi([
|
|||||||
alias: 'exportOrganization',
|
alias: 'exportOrganization',
|
||||||
requestFormat: 'json',
|
requestFormat: 'json',
|
||||||
parameters: [
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'body',
|
||||||
|
type: 'Body',
|
||||||
|
schema: z.object({}).partial().passthrough(),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'organization',
|
name: 'organization',
|
||||||
type: 'Path',
|
type: 'Path',
|
||||||
@@ -1130,6 +1122,11 @@ const endpoints = makeApi([
|
|||||||
alias: 'resendInvitationEmail',
|
alias: 'resendInvitationEmail',
|
||||||
requestFormat: 'json',
|
requestFormat: 'json',
|
||||||
parameters: [
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'body',
|
||||||
|
type: 'Body',
|
||||||
|
schema: z.object({}).partial().passthrough(),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'organization',
|
name: 'organization',
|
||||||
type: 'Path',
|
type: 'Path',
|
||||||
@@ -1348,6 +1345,11 @@ const endpoints = makeApi([
|
|||||||
alias: 'invitePlaceholder',
|
alias: 'invitePlaceholder',
|
||||||
requestFormat: 'json',
|
requestFormat: 'json',
|
||||||
parameters: [
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'body',
|
||||||
|
type: 'Body',
|
||||||
|
schema: z.object({}).partial().passthrough(),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'organization',
|
name: 'organization',
|
||||||
type: 'Path',
|
type: 'Path',
|
||||||
@@ -1395,6 +1397,11 @@ const endpoints = makeApi([
|
|||||||
alias: 'v1.members.make-placeholder',
|
alias: 'v1.members.make-placeholder',
|
||||||
requestFormat: 'json',
|
requestFormat: 'json',
|
||||||
parameters: [
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'body',
|
||||||
|
type: 'Body',
|
||||||
|
schema: z.object({}).partial().passthrough(),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'organization',
|
name: 'organization',
|
||||||
type: 'Path',
|
type: 'Path',
|
||||||
@@ -2607,11 +2614,6 @@ Users with the permission `time-entries:view:own` can only use this en
|
|||||||
type: 'Query',
|
type: 'Query',
|
||||||
schema: z.enum(['true', 'false']).optional(),
|
schema: z.enum(['true', 'false']).optional(),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'user_id',
|
|
||||||
type: 'Query',
|
|
||||||
schema: z.string().optional(),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'member_ids',
|
name: 'member_ids',
|
||||||
type: 'Query',
|
type: 'Query',
|
||||||
@@ -2637,6 +2639,11 @@ Users with the permission `time-entries:view:own` can only use this en
|
|||||||
type: 'Query',
|
type: 'Query',
|
||||||
schema: z.array(z.string()).min(1).optional(),
|
schema: z.array(z.string()).min(1).optional(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'user_id',
|
||||||
|
type: 'Query',
|
||||||
|
schema: z.string().optional(),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
response: z
|
response: z
|
||||||
.object({
|
.object({
|
||||||
@@ -3431,120 +3438,6 @@ 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',
|
method: 'get',
|
||||||
path: '/v1/users/me/memberships',
|
path: '/v1/users/me/memberships',
|
||||||
@@ -3552,7 +3445,7 @@ Please note that the access token is only shown in this response and cannot be r
|
|||||||
description: `This endpoint is independent of organization.`,
|
description: `This endpoint is independent of organization.`,
|
||||||
requestFormat: 'json',
|
requestFormat: 'json',
|
||||||
response: z
|
response: z
|
||||||
.object({ data: z.array(PersonalMembershipResource) })
|
.object({ data: PersonalMembershipCollection })
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
errors: [
|
errors: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts" generic="T">
|
<script setup lang="ts" generic="T">
|
||||||
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
|
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
|
||||||
import {computed, nextTick, onMounted, ref, watch} from 'vue';
|
import { computed, nextTick, ref, watch } from 'vue';
|
||||||
import SelectDropdownItem from '@/packages/ui/src/Input/SelectDropdownItem.vue';
|
import SelectDropdownItem from '@/packages/ui/src/Input/SelectDropdownItem.vue';
|
||||||
import { onKeyStroke } from '@vueuse/core';
|
import { onKeyStroke } from '@vueuse/core';
|
||||||
import { type Placement } from '@floating-ui/vue';
|
import { type Placement } from '@floating-ui/vue';
|
||||||
@@ -43,22 +43,10 @@ const filteredItems = computed<T[]>(() => {
|
|||||||
const highlightedItemId = ref<string | null>(model.value);
|
const highlightedItemId = ref<string | null>(model.value);
|
||||||
|
|
||||||
watch(model, () => {
|
watch(model, () => {
|
||||||
if(model.value){
|
highlightedItemId.value = model.value;
|
||||||
highlightedItemId.value = model.value;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (!highlightedItemId.value) {
|
|
||||||
resetHightlightedItem();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(filteredItems, () => {
|
watch(filteredItems, () => {
|
||||||
resetHightlightedItem();
|
|
||||||
});
|
|
||||||
|
|
||||||
function resetHightlightedItem(){
|
|
||||||
if (
|
if (
|
||||||
filteredItems.value.length > 0 &&
|
filteredItems.value.length > 0 &&
|
||||||
filteredItems.value.find(
|
filteredItems.value.find(
|
||||||
@@ -67,7 +55,7 @@ function resetHightlightedItem(){
|
|||||||
) {
|
) {
|
||||||
highlightedItemId.value = props.getKeyFromItem(filteredItems.value[0]);
|
highlightedItemId.value = props.getKeyFromItem(filteredItems.value[0]);
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
watch(highlightedItemId, () => {
|
watch(highlightedItemId, () => {
|
||||||
if (highlightedItemId.value) {
|
if (highlightedItemId.value) {
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ function onSelectChange(event: Event) {
|
|||||||
:project="timeEntry.project_id"
|
:project="timeEntry.project_id"
|
||||||
:enable-estimated-time
|
:enable-estimated-time
|
||||||
:currency="currency"
|
:currency="currency"
|
||||||
|
class="border border-border-primary"
|
||||||
:task="
|
:task="
|
||||||
timeEntry.task_id
|
timeEntry.task_id
|
||||||
"
|
"
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ type BillableOption = {
|
|||||||
size="xlarge">
|
size="xlarge">
|
||||||
<TagIcon
|
<TagIcon
|
||||||
v-if="timeEntry.tags.length === 0"
|
v-if="timeEntry.tags.length === 0"
|
||||||
|
tag="button"
|
||||||
class="w-4"></TagIcon>
|
class="w-4"></TagIcon>
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ function onSelectChange(event: Event) {
|
|||||||
:show-badge-border="false"
|
:show-badge-border="false"
|
||||||
:project="timeEntry.project_id"
|
:project="timeEntry.project_id"
|
||||||
:currency="currency"
|
:currency="currency"
|
||||||
|
class="border border-border-primary"
|
||||||
:enable-estimated-time
|
:enable-estimated-time
|
||||||
:task="
|
:task="
|
||||||
timeEntry.task_id
|
timeEntry.task_id
|
||||||
|
|||||||
@@ -103,14 +103,10 @@ function setBillableDefaultForProject() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const blockRefocus = ref(false);
|
|
||||||
|
|
||||||
function onToggleButtonPress(newState: boolean) {
|
function onToggleButtonPress(newState: boolean) {
|
||||||
if (newState) {
|
if (newState) {
|
||||||
emit('startTimer');
|
emit('startTimer');
|
||||||
if (!blockRefocus.value){
|
currentTimeEntryDescriptionInput.value?.focus();
|
||||||
currentTimeEntryDescriptionInput.value?.focus();
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
emit('stopTimer');
|
emit('stopTimer');
|
||||||
}
|
}
|
||||||
@@ -133,27 +129,11 @@ function updateTimeEntryDescription() {
|
|||||||
|
|
||||||
const {timeEntries} = storeToRefs(useTimeEntriesStore());
|
const {timeEntries} = storeToRefs(useTimeEntriesStore());
|
||||||
const filteredRecentlyTrackedTimeEntries = computed(() => {
|
const filteredRecentlyTrackedTimeEntries = computed(() => {
|
||||||
// do not include running time entries
|
return timeEntries.value.filter((item) => {
|
||||||
const finishedTimeEntries = timeEntries.value.filter((item) => item.end !== null);
|
|
||||||
|
|
||||||
// filter out duplicates based on description, task, project, tags and billable
|
|
||||||
const nonDuplicateTimeEntries = finishedTimeEntries.filter((item, index, self) => {
|
|
||||||
return index === self.findIndex((t) => (
|
|
||||||
t.description === item.description &&
|
|
||||||
t.task_id === item.task_id &&
|
|
||||||
t.project_id === item.project_id &&
|
|
||||||
t.tags.length === item.tags.length &&
|
|
||||||
t.tags.every((tag) => item.tags.includes(tag)) &&
|
|
||||||
t.billable === item.billable
|
|
||||||
));
|
|
||||||
});
|
|
||||||
|
|
||||||
// filter time entries based on current description
|
|
||||||
return nonDuplicateTimeEntries.filter((item) => {
|
|
||||||
return item.description
|
return item.description
|
||||||
?.toLowerCase()
|
?.toLowerCase()
|
||||||
?.includes(tempDescription.value?.toLowerCase()?.trim() || '');
|
?.includes(tempDescription.value?.toLowerCase()?.trim() || '');
|
||||||
}).slice(0, 5);
|
}).slice(0, 5);;
|
||||||
});
|
});
|
||||||
|
|
||||||
const showDropdown = ref(false);
|
const showDropdown = ref(false);
|
||||||
@@ -163,14 +143,6 @@ watch(focused, (focused) => {
|
|||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
// make sure the click event on the dropdown does not get interrupted
|
// make sure the click event on the dropdown does not get interrupted
|
||||||
showDropdown.value = focused
|
showDropdown.value = focused
|
||||||
|
|
||||||
// make sure that the input does not get refocused after the dropdown is closed
|
|
||||||
if(!focused){
|
|
||||||
blockRefocus.value = true;
|
|
||||||
setTimeout(() => {
|
|
||||||
blockRefocus.value = false;
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -30,14 +30,13 @@ const task = computed(() => {
|
|||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
:data-select-id="timeEntry.id"
|
:data-select-id="timeEntry.id"
|
||||||
:class="twMerge('px-2 py-1.5 flex justify-between items-center space-x-2 w-full rounded', props.highlighted && 'bg-card-background-active')">
|
:class="twMerge('px-2 py-1.5 flex justify-between items-center space-x-2 w-full rounded', props.highlighted && 'bg-card-background-active')">
|
||||||
<span v-if="timeEntry.description !== ''" class="text-sm font-medium">
|
<span class="text-sm font-medium">
|
||||||
{{
|
{{
|
||||||
timeEntry.description
|
timeEntry.description !== ''
|
||||||
|
? timeEntry.description
|
||||||
|
: 'No Description'
|
||||||
}}
|
}}
|
||||||
</span>
|
</span>
|
||||||
<span v-else class="text-sm text-text-tertiary font-medium">
|
|
||||||
No Description
|
|
||||||
</span>
|
|
||||||
<ProjectBadge
|
<ProjectBadge
|
||||||
ref="projectDropdownTrigger"
|
ref="projectDropdownTrigger"
|
||||||
:color="project?.color"
|
:color="project?.color"
|
||||||
|
|||||||
@@ -99,10 +99,6 @@ export function formatDateLocalized(date: string): string {
|
|||||||
return getLocalizedDayJs(date).format('DD.MM.YYYY');
|
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 {
|
export function formatWeek(date: string | null): string {
|
||||||
return 'Week ' + getDayJsInstance()(date).week();
|
return 'Week ' + getDayJsInstance()(date).week();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use App\Http\Controllers\Api\V1\ApiTokenController;
|
|
||||||
use App\Http\Controllers\Api\V1\ClientController;
|
use App\Http\Controllers\Api\V1\ClientController;
|
||||||
use App\Http\Controllers\Api\V1\ExportController;
|
use App\Http\Controllers\Api\V1\ExportController;
|
||||||
use App\Http\Controllers\Api\V1\ImportController;
|
use App\Http\Controllers\Api\V1\ImportController;
|
||||||
@@ -58,14 +57,6 @@ Route::prefix('v1')->name('v1.')->group(static function (): void {
|
|||||||
Route::get('/users/me', [UserController::class, 'me'])->name('me');
|
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
|
// User Member routes
|
||||||
Route::name('users.memberships.')->group(static function (): void {
|
Route::name('users.memberships.')->group(static function (): void {
|
||||||
Route::get('/users/me/memberships', [UserMembershipController::class, 'myMemberships'])->name('my-memberships');
|
Route::get('/users/me/memberships', [UserMembershipController::class, 'myMemberships'])->name('my-memberships');
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
|||||||
namespace Tests\Unit\Database;
|
namespace Tests\Unit\Database;
|
||||||
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Config;
|
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
class SeederTest extends TestCase
|
class SeederTest extends TestCase
|
||||||
@@ -14,7 +13,6 @@ class SeederTest extends TestCase
|
|||||||
|
|
||||||
public function test_running_the_seeder_multiple_times_runs_successfully(): void
|
public function test_running_the_seeder_multiple_times_runs_successfully(): void
|
||||||
{
|
{
|
||||||
$this->setupForSeeder();
|
|
||||||
$this->artisan('db:seed')
|
$this->artisan('db:seed')
|
||||||
->assertSuccessful();
|
->assertSuccessful();
|
||||||
$this->artisan('db:seed')
|
$this->artisan('db:seed')
|
||||||
@@ -23,16 +21,9 @@ class SeederTest extends TestCase
|
|||||||
|
|
||||||
public function test_fresh_migration_with_seeder_and_rollback_runs_successfully(): void
|
public function test_fresh_migration_with_seeder_and_rollback_runs_successfully(): void
|
||||||
{
|
{
|
||||||
$this->setupForSeeder();
|
|
||||||
$this->artisan('db:seed')
|
$this->artisan('db:seed')
|
||||||
->assertSuccessful();
|
->assertSuccessful();
|
||||||
$this->artisan('migrate:rollback')
|
$this->artisan('migrate:rollback')
|
||||||
->assertSuccessful();
|
->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');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,312 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -82,7 +82,6 @@ class HealthCheckEndpointTest extends EndpointTestAbstract
|
|||||||
'secure',
|
'secure',
|
||||||
'timestamp',
|
'timestamp',
|
||||||
'timezone',
|
'timezone',
|
||||||
'session_secure',
|
|
||||||
'trusted_proxies',
|
'trusted_proxies',
|
||||||
'url',
|
'url',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -5,15 +5,11 @@ declare(strict_types=1);
|
|||||||
namespace Tests\Unit\Endpoint\Web;
|
namespace Tests\Unit\Endpoint\Web;
|
||||||
|
|
||||||
use App\Http\Controllers\Web\HomeController;
|
use App\Http\Controllers\Web\HomeController;
|
||||||
use App\Http\Middleware\Authenticate;
|
|
||||||
use App\Http\Middleware\RedirectIfAuthenticated;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use PHPUnit\Framework\Attributes\CoversClass;
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
use PHPUnit\Framework\Attributes\UsesClass;
|
use PHPUnit\Framework\Attributes\UsesClass;
|
||||||
|
|
||||||
#[CoversClass(HomeController::class)]
|
#[CoversClass(HomeController::class)]
|
||||||
#[CoversClass(Authenticate::class)]
|
|
||||||
#[CoversClass(RedirectIfAuthenticated::class)]
|
|
||||||
#[UsesClass(HomeController::class)]
|
#[UsesClass(HomeController::class)]
|
||||||
class HomeEndpointTest extends EndpointTestAbstract
|
class HomeEndpointTest extends EndpointTestAbstract
|
||||||
{
|
{
|
||||||
@@ -40,17 +36,4 @@ class HomeEndpointTest extends EndpointTestAbstract
|
|||||||
// Assert
|
// Assert
|
||||||
$response->assertRedirect('/login');
|
$response->assertRedirect('/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_login_redirects_to_dashboard_if_user_is_logged_in(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$user = User::factory()->withPersonalOrganization()->create();
|
|
||||||
$this->actingAs($user);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = $this->get('/login');
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$response->assertRedirect('/dashboard');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
<?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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -47,20 +47,6 @@ class EnsureEmailIsVerifiedMiddlewareTest extends MiddlewareTestAbstract
|
|||||||
$response->assertRedirect(route('verification.notice'));
|
$response->assertRedirect(route('verification.notice'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_users_with_unverified_email_get_error_if_the_request_is_json(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$user = User::factory()->unverified()->create();
|
|
||||||
$route = $this->createTestRoute();
|
|
||||||
$this->actingAs($user);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = $this->getJson($route);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$response->assertForbidden();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_users_with_verified_email_can_access_route(): void
|
public function test_users_with_verified_email_can_access_route(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Tests\Unit\Middleware;
|
|
||||||
|
|
||||||
use App\Http\Middleware\ForceHttps;
|
|
||||||
use Illuminate\Support\Facades\Config;
|
|
||||||
use Illuminate\Support\Facades\Route;
|
|
||||||
use PHPUnit\Framework\Attributes\CoversClass;
|
|
||||||
use PHPUnit\Framework\Attributes\UsesClass;
|
|
||||||
|
|
||||||
#[CoversClass(ForceHttps::class)]
|
|
||||||
#[UsesClass(ForceHttps::class)]
|
|
||||||
class ForceHttpsMiddlewareTest extends MiddlewareTestAbstract
|
|
||||||
{
|
|
||||||
private function createTestRoute(): string
|
|
||||||
{
|
|
||||||
return Route::get('/test-route', function () {
|
|
||||||
return [
|
|
||||||
'is_secure' => request()->secure(),
|
|
||||||
];
|
|
||||||
})->middleware(ForceHttps::class)->uri;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_if_config_app_force_https_is_true_then_the_request_will_be_modified_to_make_the_app_think_it_was_a_https_request(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
Config::set('app.force_https', true);
|
|
||||||
$route = $this->createTestRoute();
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = $this->get($route);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$response->assertSuccessful();
|
|
||||||
$response->assertJson(['is_secure' => true]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_if_config_app_force_https_is_true_then_the_request_will_be_modified_to_make_the_app_think_it_was_a_https_request_even_if_a_load_balancer_says_it_was_a_http_request(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
Config::set('app.force_https', true);
|
|
||||||
$route = $this->createTestRoute();
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = $this->get($route, ['X-Forwarded-Proto' => 'http']);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$response->assertSuccessful();
|
|
||||||
$response->assertJson(['is_secure' => true]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_if_config_app_force_https_is_false_then_the_request_will_not_be_modified_to_make_the_app_think_it_was_a_https_request(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
Config::set('app.force_https', false);
|
|
||||||
$route = $this->createTestRoute();
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = $this->get($route);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$response->assertSuccessful();
|
|
||||||
$response->assertJson(['is_secure' => false]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_if_config_app_force_https_is_false_then_the_request_will_not_be_modified_but_the_request_can_still_be_https(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
Config::set('app.force_https', false);
|
|
||||||
$route = $this->createTestRoute();
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = $this->get($route, ['X-Forwarded-Proto' => 'https']);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$response->assertSuccessful();
|
|
||||||
$response->assertJson(['is_secure' => true]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user