Compare commits

...

14 Commits

Author SHA1 Message Date
Gregor Vostrak
d9ffa9899f add focus trap to tag dropdown to fix focus management 2025-03-05 11:57:26 +01:00
Gregor Vostrak
aa588196ee fix datepicker dropdown and taborder in create time entry 2025-03-05 11:13:07 +01:00
Constantin Graf
b6bbcd7097 Fixed bug in toggl data importer if import contains invalid timezone 2025-03-04 17:08:28 -05:00
Constantin Graf
0d4ffa1061 Fixed GitHub issue templates 2025-02-18 12:21:53 -05:00
Constantin Graf
b7abe3738e Added GitHub issue templates 2025-02-18 11:55:48 -05:00
Constantin Graf
128a21ba63 Fix docker for ARM 2025-02-18 11:55:48 -05:00
Constantin Graf
e25461a439 Fix desktop auth 2025-02-14 10:55:20 -05:00
Gregor Vostrak
ba8751c7c4 add api key e2e tests and improve labels 2025-02-13 17:04:18 -05:00
Gregor Vostrak
21b33a0028 add api token expiry information notices 2025-02-13 17:04:18 -05:00
Gregor Vostrak
97585b5771 fix inconsistencies in dropdown highlighted item, indirectly fix flaky project member test 2025-02-13 17:04:18 -05:00
Constantin Graf
ae76135373 Add filament resource for tokens; Ignore non-personal tokens in API token endpoints 2025-02-13 17:04:18 -05:00
Constantin Graf
69a8c8bb2b Fixed api token endpoint documentation 2025-02-13 17:04:18 -05:00
Gregor Vostrak
4ea55e5867 add frontend support for api token create, delete and revoke 2025-02-13 17:04:18 -05:00
Constantin Graf
bbed618fdc Added API endpoints for user API tokens 2025-02-13 17:04:18 -05:00
63 changed files with 2204 additions and 185 deletions

41
.env.ci
View File

@@ -1,3 +1,4 @@
# Application
APP_NAME=solidtime APP_NAME=solidtime
APP_ENV=local APP_ENV=local
APP_KEY= APP_KEY=
@@ -19,35 +20,39 @@ DB_TEST_DATABASE=laravel
DB_TEST_USERNAME=root DB_TEST_USERNAME=root
DB_TEST_PASSWORD=root DB_TEST_PASSWORD=root
BROADCAST_DRIVER=log # Broadcasting
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="hello@example.com" MAIL_FROM_ADDRESS="no-reply@solidtime.test"
MAIL_FROM_NAME="${APP_NAME}" MAIL_FROM_NAME="solidtime"
MAIL_REPLY_TO_ADDRESS="hello@solidtime.test"
MAIL_REPLY_TO_NAME="solidtime"
# Filesystems # 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}"

View File

@@ -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
AUDITING_ENABLED=true APP_FORCE_HTTPS=false
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,7 +49,9 @@ 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="${APP_NAME}" MAIL_FROM_NAME="solidtime"
MAIL_REPLY_TO_ADDRESS="hello@solidtime.test"
MAIL_REPLY_TO_NAME="solidtime"
# Filesystems # Filesystems
FILESYSTEM_DISK=s3 FILESYSTEM_DISK=s3
@@ -62,14 +64,24 @@ 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

47
.github/ISSUE_TEMPLATE/1_bug_report.yml vendored Normal file
View File

@@ -0,0 +1,47 @@
name: Bug Report
description: "Report a bug"
body:
- type: markdown
attributes:
value: |
Before creating a new bug report, please check that there isn't already a similar issue.
- type: textarea
attributes:
label: Description
description: A clear and concise description of what the bug is.
validations:
required: true
- type: textarea
attributes:
label: "Steps To Reproduce"
description: How do you trigger this bug? Please walk us through it step by step.
value: |
1.
2.
3.
...
validations:
required: false
- type: dropdown
attributes:
label: "Self-hosted or Cloud?"
options:
- Self-Hosted
- solidtime Cloud
- Both
- type: input
attributes:
label: "Version of solidtime: (for self-hosted)"
validations:
required: false
- type: input
attributes:
label: "solidtime self-hosting guide: (for self-hosted)"
description: "Did you use the official guide to self-host solidtime? If yes, which one?"
validations:
required: false

8
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: 🚀 Feature Request
url: https://github.com/solidtime-io/solidtime/discussions/new?category=feature-requests
about: Share ideas for new features
- name: ❓ Ask a Question
url: https://github.com/solidtime-io/solidtime/discussions/new?category=general
about: Ask the community for help

8
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

@@ -0,0 +1,8 @@
<!--
This project is early stage. The structure and APIs are still subject to change and not stable.
Therefore, we do not currently accept any contributions, unless you are a member of the team.
As soon as we feel comfortable enough that the application structure is stable enough, we will open up the project for contributions.
We do accept contributions in the [documentation repository](https://github.com/solidtime-io/docs) f.e. to add new self-hosting guides.
-->

View File

@@ -11,10 +11,21 @@ on:
- 'docker/prod/**' - 'docker/prod/**'
workflow_dispatch: workflow_dispatch:
env:
DOCKERHUB_REPO: solidtime/solidtime
GHCR_REPO: ghcr.io/solidtime-io/solidtime
name: Build - Public name: Build - Public
jobs: jobs:
build: build:
runs-on: ubuntu-22.04 strategy:
matrix:
include:
- runs-on: "ubuntu-24.04-arm"
platform: "linux/arm64"
- runs-on: "ubuntu-24.04"
platform: "linux/amd64"
runs-on: ${{ matrix.runs-on }}
permissions: permissions:
packages: write packages: write
contents: read contents: read
@@ -29,7 +40,7 @@ jobs:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
- name: "Get build" - name: "Get build"
id: build id: release-build
run: echo "build=$(git rev-parse --short=8 HEAD)" >> "$GITHUB_OUTPUT" run: echo "build=$(git rev-parse --short=8 HEAD)" >> "$GITHUB_OUTPUT"
- name: "Get Previous tag (normal push)" - name: "Get Previous tag (normal push)"
@@ -40,7 +51,7 @@ jobs:
prefix: "v" prefix: "v"
- name: "Get version" - name: "Get version"
id: version id: release-version
run: | run: |
if ${{ !startsWith(github.ref, 'refs/tags/v') }}; then if ${{ !startsWith(github.ref, 'refs/tags/v') }}; then
if ${{ startsWith(steps.previoustag.outputs.tag, 'v') }}; then if ${{ startsWith(steps.previoustag.outputs.tag, 'v') }}; then
@@ -61,21 +72,23 @@ jobs:
rm .env.production .env.ci .env.example rm .env.production .env.ci .env.example
- name: "Add version to .env" - name: "Add version to .env"
run: sed -i 's/APP_VERSION=0.0.0/APP_VERSION=${{ steps.version.outputs.app_version }}/g' .env run: sed -i 's/APP_VERSION=0.0.0/APP_VERSION=${{ steps.release-version.outputs.app_version }}/g' .env
- name: "Add build to .env" - name: "Add build to .env"
run: sed -i 's/APP_BUILD=0/APP_BUILD=${{ steps.build.outputs.build }}/g' .env run: sed -i 's/APP_BUILD=0/APP_BUILD=${{ steps.release-build.outputs.build }}/g' .env
- name: "Output .env" - name: "Output .env"
run: cat .env run: cat .env
- name: "Install dependencies" - name: "Setup PHP with PECL extension"
uses: php-actions/composer@v6 uses: shivammathur/setup-php@v2
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
with: with:
command: install php-version: '8.3'
only_args: --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative extensions: mbstring, dom, fileinfo, pgsql
php_version: 8.3
- name: "Install dependencies"
run: composer install --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
- name: "Use Node.js" - name: "Use Node.js"
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -88,29 +101,31 @@ jobs:
- name: "Build" - name: "Build"
run: npm run build run: npm run build
- name: "Login to GitHub Container Registry" - name: "Prepare"
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: "Docker meta"
id: "meta"
uses: docker/metadata-action@v5
with:
images: |
${{ env.DOCKERHUB_REPO }}
${{ env.GHCR_REPO }}
- name: "Login to Docker Hub Container Registry"
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: "Login to GitHub Container Registry" - name: "Login to GitHub Container Registry"
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: "Docker meta"
id: "meta"
uses: docker/metadata-action@v5
with:
images: |
solidtime/solidtime
ghcr.io/${{ github.repository }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: "Set up QEMU" - name: "Set up QEMU"
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
@@ -118,16 +133,90 @@ jobs:
- name: "Set up Docker Buildx" - name: "Set up Docker Buildx"
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: "Build and push" - name: "Build and push by digest"
id: build
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: . context: .
file: docker/prod/Dockerfile file: docker/prod/Dockerfile
build-args: | build-args: |
DOCKER_FILES_BASE_PATH=docker/prod/ DOCKER_FILES_BASE_PATH=docker/prod/
platforms: linux/amd64,linux/arm64 platforms: ${{ matrix.platform }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,"name=${{ env.DOCKERHUB_REPO }},${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
- name: "Export digest"
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: "Upload digest"
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
attestations: write
id-token: write
timeout-minutes: 90
needs:
- build
steps:
- name: "Download digests"
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: "Login to Docker Hub"
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: "Login to GHCR"
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: "Set up Docker Buildx"
uses: docker/setup-buildx-action@v3
- name: "Docker meta"
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.DOCKERHUB_REPO }}
${{ env.GHCR_REPO }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: "Create manifest list and push"
working-directory: ${{ runner.temp }}/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.DOCKERHUB_REPO }}@sha256:%s ' *)
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.GHCR_REPO }}@sha256:%s ' *)
- name: "Inspect image"
run: |
docker buildx imagetools inspect ${{ env.DOCKERHUB_REPO }}:${{ steps.meta.outputs.version }}
docker buildx imagetools inspect ${{ env.GHCR_REPO }}:${{ steps.meta.outputs.version }}

View File

@@ -8,7 +8,8 @@ jobs:
contents: read contents: read
id-token: write id-token: write
steps: steps:
- uses: actions/checkout@v4 - name: "Checkout code"
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

View File

@@ -8,7 +8,8 @@ jobs:
contents: read contents: read
id-token: write id-token: write
steps: steps:
- uses: actions/checkout@v4 - name: "Checkout code"
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:

View File

@@ -27,45 +27,47 @@ jobs:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v4 uses: actions/checkout@v4
- uses: actions/setup-node@v4 - name: "Setup node"
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 migrate --seed
php artisan passport:keys php artisan passport:keys
php artisan migrate --seed
- 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'
- uses: actions/upload-artifact@v4 - name: "Upload test results"
uses: actions/upload-artifact@v4
if: always() if: always()
with: with:
name: test-results name: test-results

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ 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;
@@ -27,7 +28,6 @@ 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,6 +44,7 @@ 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
@@ -196,6 +197,17 @@ 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
*/ */

View File

@@ -9,6 +9,7 @@ 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;
@@ -100,5 +101,6 @@ 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);
} }
} }

View File

@@ -5,6 +5,11 @@ 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;
@@ -42,6 +47,16 @@ 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']);

View File

@@ -69,6 +69,9 @@ 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,

View File

@@ -5,8 +5,11 @@ declare(strict_types=1);
namespace App\Service\Import\Importers; namespace App\Service\Import\Importers;
use App\Enums\Role; use App\Enums\Role;
use App\Service\TimezoneService;
use Exception; use Exception;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Override; use Override;
use Spatie\TemporaryDirectory\TemporaryDirectory; use Spatie\TemporaryDirectory\TemporaryDirectory;
use ValueError; use ValueError;
@@ -93,11 +96,22 @@ class TogglDataImporter extends DefaultImporter
} }
foreach ($workspaceUsers as $workspaceUser) { foreach ($workspaceUsers as $workspaceUser) {
$timezone = Str::trim($workspaceUser->timezone);
if ($timezone === '') {
$timezone = 'UTC';
}
if (! app(TimezoneService::class)->isValid($timezone)) {
Log::warning('TogglDateImporter: Invalid timezone', [
'timezone' => $timezone,
]);
$timezone = 'UTC';
}
$userId = $this->userImportHelper->getKey([ $userId = $this->userImportHelper->getKey([
'email' => $workspaceUser->email, 'email' => $workspaceUser->email,
], [ ], [
'name' => $workspaceUser->name, 'name' => $workspaceUser->name,
'timezone' => $workspaceUser->timezone ?? 'UTC', 'timezone' => $timezone,
'is_placeholder' => true, 'is_placeholder' => true,
], (string) $workspaceUser->uid); ], (string) $workspaceUser->uid);
$this->memberImportHelper->getKey([ $this->memberImportHelper->getKey([

View File

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

View File

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

View File

@@ -34,6 +34,29 @@ 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',
@@ -55,6 +78,8 @@ 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',
@@ -159,15 +184,6 @@ 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

View File

@@ -182,7 +182,7 @@ COPY --chown=${USER}:${USER} ${DOCKER_FILES_BASE_PATH}deployment/php.ini /lib/ph
# && composer clear-cache # && composer clear-cache
RUN cat .env RUN cat .env
RUN php artisan env #RUN php artisan env
RUN php artisan storage:link RUN php artisan storage:link
RUN chmod +x /usr/local/bin/start-container RUN chmod +x /usr/local/bin/start-container

View File

@@ -1,25 +1,69 @@
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').fill('NEW NAME'); await page.getByLabel('Name', {exact: true} ).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')).toHaveValue('NEW NAME'); await expect(page.getByLabel('Name', {exact: true})).toHaveValue('NEW NAME');
}); });
test.skip('test that user email can be updated', async ({ page }) => { test.skip('test that user email can be updated', async ({page}) => {
// this does not work because of email verification currently // 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');
});

View File

@@ -12,6 +12,7 @@ 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;
@@ -37,6 +38,7 @@ 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.',
]; ];

View File

@@ -41,5 +41,7 @@
<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>

View File

@@ -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,

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'; import { computed, ref } 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,22 +46,6 @@ const filteredMembers = computed<Member[]>(() => {
}); });
}); });
watch(filteredMembers, () => {
resetHighlightedItem();
});
onMounted(() => {
resetHighlightedItem();
});
function resetHighlightedItem() {
if (filteredMembers.value.length > 0) {
highlightedItemId.value = filteredMembers.value[0].id;
}
}
const highlightedItemId = ref<string | null>(null);
const currentValue = computed(() => { 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;

View File

@@ -32,7 +32,7 @@ async function invitePlaceholder(id: string) {
await handleApiRequestNotifications( await handleApiRequestNotifications(
() => () =>
api.invitePlaceholder( api.invitePlaceholder(
{}, undefined,
{ {
params: { params: {
organization: organizationId, organization: organizationId,

View File

@@ -0,0 +1,332 @@
<script setup lang="ts">
import FormSection from '@/Components/FormSection.vue';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import {computed, ref} from 'vue';
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
import {
api,
type ApiToken,
type CreateApiTokenBody
} from '@/packages/api/src';
import SectionBorder from "@/Components/SectionBorder.vue";
import DangerButton from "@/packages/ui/src/Buttons/DangerButton.vue";
import TextInput from "../../../packages/ui/src/Input/TextInput.vue";
import SecondaryButton from "../../../packages/ui/src/Buttons/SecondaryButton.vue";
import DialogModal from "@/packages/ui/src/DialogModal.vue";
import InputError from "@/packages/ui/src/Input/InputError.vue";
import ActionMessage from "@/Components/ActionMessage.vue";
import ConfirmationModal from "@/Components/ConfirmationModal.vue";
import ActionSection from "@/Components/ActionSection.vue";
import {useForm} from "@inertiajs/vue3";
import {useMutation, useQuery, useQueryClient} from "@tanstack/vue-query";
import {useNotificationsStore} from "@/utils/notification";
import {useClipboard} from "@vueuse/core";
import { formatDateTimeLocalized} from "../../../packages/ui/src/utils/time";
import {ClockIcon} from "@heroicons/vue/20/solid";
const queryClient = useQueryClient();
const apiTokenBeingDeleted = ref<ApiToken | null>(null);
const apiTokenBeingRevoked = ref<ApiToken | null>(null);
const { handleApiRequestNotifications } = useNotificationsStore();
const newToken = ref('');
const { copy, copied, isSupported } = useClipboard();
async function createApiToken(){
await handleApiRequestNotifications(
() =>
createApiTokenMutation.mutateAsync({
name: createApiTokenForm.name,
}),
'API Token successfully created',
'There was an error while creating the API Token',
(response) => {
createApiTokenForm.name = '';
displayingToken.value = true;
// @ts-expect-error temporary fix until openapi docs type is fixed
newToken.value = response.data.access_token;
}
);
}
const createApiTokenForm = useForm({
name: '',
});
function confirmApiTokenDeletion (token: ApiToken) {
apiTokenBeingDeleted.value = token;
}
function confirmApiTokenRevocation(token: ApiToken){
apiTokenBeingRevoked.value = token;
}
const displayingToken = ref(false);
async function deleteApiToken () {
if(apiTokenBeingDeleted.value){
await handleApiRequestNotifications(
() =>
deleteApiTokenMutation.mutateAsync(apiTokenBeingDeleted.value!.id),
'API Token successfully deleted',
'There was an error while deleting the API Token',
() => {
apiTokenBeingDeleted.value = null;
}
);
}
};
async function revokeApiToken () {
if(apiTokenBeingRevoked.value){
await handleApiRequestNotifications(
() =>
revokeApiTokenMutation.mutateAsync(apiTokenBeingRevoked.value!.id),
'API Token successfully revoked',
'There was an error while revoking the API Token',
() => {
apiTokenBeingRevoked.value = null;
}
);
}
};
const { data: sharedReportResponseData } = useQuery({
queryKey: ['api-tokens'],
queryFn: () =>
api.getApiTokens(),
});
const tokens = computed(() => {
return sharedReportResponseData.value?.data ?? [];
})
const createApiTokenMutation = useMutation({
mutationFn: async (apiToken: CreateApiTokenBody) => {
return await api.createApiToken(apiToken);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['api-tokens'] })
},
});
const deleteApiTokenMutation = useMutation({
mutationFn: async (apiTokenId: string) => {
return await api.deleteApiToken(undefined, {
params: {
apiTokenId: apiTokenId,
},
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['api-tokens'] })
},
});
const revokeApiTokenMutation = useMutation({
mutationFn: async (apiTokenId: string) => {
return await api.revokeApiToken(undefined, {
params: {
apiTokenId: apiTokenId,
},
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['api-tokens'] })
},
});
</script>
<template>
<div>
<!-- Generate API Token -->
<FormSection @submitted="createApiToken">
<template #title> Create API Token </template>
<template #description>
API tokens allow third-party services to authenticate with our
application on your behalf.
</template>
<template #form>
<!-- Token Name -->
<div class="col-span-6 sm:col-span-4">
<InputLabel for="api_key_name" value="API Key Name" />
<TextInput
id="api_key_name"
v-model="createApiTokenForm.name"
type="text"
class="mt-1 block w-full" />
<InputError
:message="createApiTokenForm.errors.name"
class="mt-2" />
<div class="text-text-tertiary text-sm pt-3 flex space-x-1.5 font-medium items-center">
<ClockIcon class="w-4"></ClockIcon>
<span>
API Tokens are valid for 1 year
</span>
</div>
</div>
</template>
<template #actions>
<ActionMessage
:on="createApiTokenForm.recentlySuccessful"
class="me-3">
Created.
</ActionMessage>
<PrimaryButton
:class="{ 'opacity-25': createApiTokenForm.processing }"
:disabled="createApiTokenForm.processing">
Create API Key
</PrimaryButton>
</template>
</FormSection>
<div v-if="tokens.length > 0">
<SectionBorder />
<!-- Manage API Tokens -->
<div class="mt-10 sm:mt-0">
<ActionSection>
<template #title> Manage API Tokens </template>
<template #description>
You may delete or revoke any of your existing tokens if they are
no longer needed.
</template>
<!-- API Token List -->
<template #content>
<div class="divide-border-secondary divide-y">
<div
v-for="token in tokens"
:key="token.id"
class="flex items-center py-2.5 justify-between">
<div class="break-all text-white">
<div>{{ token.name }}</div>
<div class="text-sm text-text-tertiary space-x-3">
<span v-if="token.created_at">
Created at {{ formatDateTimeLocalized(token.created_at) }}
</span>
<span v-if="token.expires_at">
Expires at {{ formatDateTimeLocalized(token.expires_at) }}
</span>
<span v-if="token.revoked">
Revoked
</span>
</div>
</div>
<div class="flex items-center ms-2">
<div
v-if="token.last_used_ago"
class="text-sm text-gray-400">
Last used {{ token.last_used_ago }}
</div>
<button
v-if="!token.revoked"
class="cursor-pointer ms-6 text-sm text-text-secondary"
:aria-label="'Revoke API Token ' + token.name"
@click="confirmApiTokenRevocation(token)">
Revoke
</button>
<button
class="cursor-pointer ms-6 text-sm text-red-500"
:aria-label="'Delete API Token ' + token.name"
@click="confirmApiTokenDeletion(token)">
Delete
</button>
</div>
</div>
</div>
</template>
</ActionSection>
</div>
</div>
<!-- Token Value Modal -->
<DialogModal :show="displayingToken" @close="displayingToken = false">
<template #title> API Token created successfully </template>
<template #content>
<div>
Please copy your new API token. For your security, it won't
be shown again.
<strong>This token is valid for one year</strong> unless you revoke it.
</div>
<div>
</div>
<div class="flex gap-2 pt-6 w-full">
<TextInput v-if="newToken" disabled :model-value="newToken" class="flex-1 text-gray-500"></TextInput>
<PrimaryButton v-if="isSupported" @click="copy(newToken)">{{ copied ? 'Copied!' : 'Copy Token' }}</PrimaryButton>
</div>
</template>
<template #footer>
<SecondaryButton @click="displayingToken = false">
Close
</SecondaryButton>
</template>
</DialogModal>
<!-- Delete Token Confirmation Modal -->
<ConfirmationModal
:show="apiTokenBeingDeleted != null"
@close="apiTokenBeingDeleted = null">
<template #title> Delete API Token </template>
<template #content>
Are you sure you would like to delete this API token?
</template>
<template #footer>
<SecondaryButton @click="apiTokenBeingDeleted = null">
Cancel
</SecondaryButton>
<DangerButton
class="ms-3"
:class="{ 'opacity-25': createApiTokenMutation.isPending.value }"
:disabled="createApiTokenMutation.isPending.value"
@click="deleteApiToken">
Delete
</DangerButton>
</template>
</ConfirmationModal>
<ConfirmationModal
:show="apiTokenBeingRevoked != null"
@close="apiTokenBeingRevoked = null">
<template #title> Revoke API Token </template>
<template #content>
Are you sure you would like to revoke this API token?
</template>
<template #footer>
<SecondaryButton @click="apiTokenBeingRevoked = null">
Cancel
</SecondaryButton>
<DangerButton
class="ms-3"
:class="{ 'opacity-25': revokeApiTokenMutation.isPending.value }"
:disabled="revokeApiTokenMutation.isPending.value"
@click="revokeApiToken">
Revoke
</DangerButton>
</template>
</ConfirmationModal>
</div>
</template>

View File

@@ -9,6 +9,7 @@ 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;
@@ -65,6 +66,9 @@ 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">

View File

@@ -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,

View File

@@ -162,6 +162,15 @@ 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 };

View File

@@ -1,6 +1,21 @@
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(),
@@ -26,9 +41,11 @@ 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({ email: z.string().email(), role: Role }) .object({
email: z.string().email(),
role: z.enum(['admin', 'manager', 'employee']),
})
.passthrough(); .passthrough();
const MemberResource = z const MemberResource = z
.object({ .object({
@@ -41,6 +58,7 @@ 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()
@@ -190,13 +208,6 @@ 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
@@ -459,18 +470,21 @@ 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,
@@ -502,7 +516,6 @@ export const schemas = {
TimeEntryUpdateRequest, TimeEntryUpdateRequest,
UserResource, UserResource,
PersonalMembershipResource, PersonalMembershipResource,
PersonalMembershipCollection,
}; };
const endpoints = makeApi([ const endpoints = makeApi([
@@ -786,11 +799,6 @@ 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',
@@ -1122,11 +1130,6 @@ 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',
@@ -1345,11 +1348,6 @@ 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',
@@ -1397,11 +1395,6 @@ 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',
@@ -2614,6 +2607,11 @@ Users with the permission &#x60;time-entries:view:own&#x60; 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',
@@ -2639,11 +2637,6 @@ Users with the permission &#x60;time-entries:view:own&#x60; 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({
@@ -3438,6 +3431,120 @@ The report is considered public if the &#x60;is_public&#x60; 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',
@@ -3445,7 +3552,7 @@ The report is considered public if the &#x60;is_public&#x60; field is set to &#x
description: `This endpoint is independent of organization.`, description: `This endpoint is independent of organization.`,
requestFormat: 'json', requestFormat: 'json',
response: z response: z
.object({ data: PersonalMembershipCollection }) .object({ data: z.array(PersonalMembershipResource) })
.passthrough(), .passthrough(),
errors: [ errors: [
{ {

View File

@@ -8,6 +8,7 @@ import { twMerge } from 'tailwind-merge';
const props = defineProps<{ const props = defineProps<{
class?: string; class?: string;
tabindex?: string;
}>(); }>();
// This has to be a localized timestamp, not UTC // This has to be a localized timestamp, not UTC
@@ -50,6 +51,7 @@ const emit = defineEmits(['changed']);
<input <input
id="start" id="start"
ref="datePicker" ref="datePicker"
:tabindex="tabindex"
:class=" :class="
twMerge( twMerge(
'bg-input-background border text-white border-input-border focus-visible:outline-0 focus-visible:border-input-border-active focus-visible:ring-0 rounded-md', 'bg-input-background border text-white border-input-border focus-visible:outline-0 focus-visible:border-input-border-active focus-visible:ring-0 rounded-md',

View File

@@ -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, ref, watch } from 'vue'; import {computed, nextTick, onMounted, 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,10 +43,22 @@ const filteredItems = computed<T[]>(() => {
const highlightedItemId = ref<string | null>(model.value); const highlightedItemId = ref<string | null>(model.value);
watch(model, () => { watch(model, () => {
highlightedItemId.value = model.value; if(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(
@@ -55,7 +67,7 @@ watch(filteredItems, () => {
) { ) {
highlightedItemId.value = props.getKeyFromItem(filteredItems.value[0]); highlightedItemId.value = props.getKeyFromItem(filteredItems.value[0]);
} }
}); }
watch(highlightedItemId, () => { watch(highlightedItemId, () => {
if (highlightedItemId.value) { if (highlightedItemId.value) {

View File

@@ -6,6 +6,7 @@ import TagCreateModal from '@/packages/ui/src/Tag/TagCreateModal.vue';
import MultiselectDropdownItem from '@/packages/ui/src/Input/MultiselectDropdownItem.vue'; import MultiselectDropdownItem from '@/packages/ui/src/Input/MultiselectDropdownItem.vue';
import type { Tag } from '@/packages/api/src'; import type { Tag } from '@/packages/api/src';
import type { Placement } from '@floating-ui/vue'; import type { Placement } from '@floating-ui/vue';
import {UseFocusTrap} from "@vueuse/integrations/useFocusTrap/component";
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@@ -177,46 +178,50 @@ const showCreateTagModal = ref(false);
<slot name="trigger"></slot> <slot name="trigger"></slot>
</template> </template>
<template #content> <template #content>
<input <UseFocusTrap
ref="searchInput" v-if="open"
:value="searchValue" :options="{ immediate: true, allowOutsideClick: true }">
data-testid="tag_dropdown_search" <input
class="bg-card-background border-0 placeholder-muted text-sm text-white py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full" ref="searchInput"
placeholder="Search for a Tag..." :value="searchValue"
@input="updateSearchValue" data-testid="tag_dropdown_search"
@keydown.enter="addTagIfNoneExists" class="bg-card-background border-0 placeholder-muted text-sm text-white py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
@keydown.up.prevent="moveHighlightUp" placeholder="Search for a Tag..."
@keydown.down.prevent="moveHighlightDown" /> @input="updateSearchValue"
<div ref="dropdownViewport" class="w-60 max-h-60 overflow-y-scroll"> @keydown.enter="addTagIfNoneExists"
<div @keydown.up.prevent="moveHighlightUp"
v-for="tag in filteredTags" @keydown.down.prevent="moveHighlightDown" />
:key="tag.id" <div ref="dropdownViewport" class="w-60 max-h-60 overflow-y-scroll">
role="option" <div
:value="tag.id" v-for="tag in filteredTags"
:class="{ :key="tag.id"
role="option"
:value="tag.id"
:class="{
'bg-card-background-active': 'bg-card-background-active':
tag.id === highlightedItemId, tag.id === highlightedItemId,
}" }"
data-testid="tag_dropdown_entries" data-testid="tag_dropdown_entries"
:data-tag-id="tag.id"> :data-tag-id="tag.id">
<MultiselectDropdownItem <MultiselectDropdownItem
:selected="isTagSelected(tag.id)" :selected="isTagSelected(tag.id)"
:name="tag.name" :name="tag.name"
@click="toggleTag(tag.id)"></MultiselectDropdownItem> @click="toggleTag(tag.id)"></MultiselectDropdownItem>
</div>
</div> </div>
</div> <div class="hover:bg-card-background-active rounded-b-lg">
<div class="hover:bg-card-background-active rounded-b-lg"> <button
<button class="text-white w-full flex space-x-3 items-center px-4 py-3 text-xs font-semibold border-t border-card-background-separator"
class="text-white w-full flex space-x-3 items-center px-4 py-3 text-xs font-semibold border-t border-card-background-separator" @click="
@click="
open = false; open = false;
showCreateTagModal = true; showCreateTagModal = true;
"> ">
<PlusCircleIcon <PlusCircleIcon
class="w-5 flex-shrink-0 text-icon-default"></PlusCircleIcon> class="w-5 flex-shrink-0 text-icon-default"></PlusCircleIcon>
<span>Create new Tag</span> <span>Create new Tag</span>
</button> </button>
</div> </div>
</UseFocusTrap>
</template> </template>
</Dropdown> </Dropdown>
</template> </template>

View File

@@ -18,7 +18,6 @@ import type {
Client, Client,
CreateTimeEntryBody, CreateTimeEntryBody,
} from '@/packages/api/src'; } from '@/packages/api/src';
import TimePicker from '@/packages/ui/src/Input/TimePicker.vue';
import { getOrganizationCurrencyString } from '@/utils/money'; import { getOrganizationCurrencyString } from '@/utils/money';
import { canCreateProjects } from '@/utils/permissions'; import { canCreateProjects } from '@/utils/permissions';
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue'; import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
@@ -30,6 +29,7 @@ import DurationHumanInput from '@/packages/ui/src/Input/DurationHumanInput.vue';
import { InformationCircleIcon } from '@heroicons/vue/20/solid'; import { InformationCircleIcon } from '@heroicons/vue/20/solid';
import type { Tag, Task } from '@/packages/api/src'; import type { Tag, Task } from '@/packages/api/src';
import TimePickerSimple from "@/packages/ui/src/Input/TimePickerSimple.vue";
const show = defineModel('show', { default: false }); const show = defineModel('show', { default: false });
const saving = ref(false); const saving = ref(false);
@@ -175,7 +175,6 @@ 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
@@ -245,30 +244,34 @@ type BillableOption = {
<div class=""> <div class="">
<InputLabel>Start</InputLabel> <InputLabel>Start</InputLabel>
<div class="flex flex-col items-center space-y-2 mt-1"> <div class="flex flex-col items-center space-y-2 mt-1">
<TimePicker <TimePickerSimple
v-model="localStart" v-model="localStart"
size="large"></TimePicker> size="large"></TimePickerSimple>
<DatePicker <DatePicker
v-model="localStart" v-model="localStart"
tabindex="1"
class="text-xs text-text-tertiary max-w-28 px-1.5 py-1.5"></DatePicker> class="text-xs text-text-tertiary max-w-28 px-1.5 py-1.5"></DatePicker>
</div> </div>
</div> </div>
<div class=""> <div class="">
<InputLabel>End</InputLabel> <InputLabel>End</InputLabel>
<div class="flex flex-col items-center space-y-2 mt-1"> <div class="flex flex-col items-center space-y-2 mt-1">
<TimePicker <TimePickerSimple
v-model="localEnd" v-model="localEnd"
size="large"></TimePicker> size="large"></TimePickerSimple>
<DatePicker <DatePicker
v-model="localEnd" v-model="localEnd"
tabindex="1"
class="text-xs text-text-tertiary max-w-28 px-1.5 py-1.5"></DatePicker> class="text-xs text-text-tertiary max-w-28 px-1.5 py-1.5"></DatePicker>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<template #footer> <template #footer>
<SecondaryButton @click="show = false"> Cancel</SecondaryButton> <SecondaryButton tabindex="2" @click="show = false"> Cancel</SecondaryButton>
<PrimaryButton <PrimaryButton
tabindex="2"
class="ms-3" class="ms-3"
:class="{ 'opacity-25': saving }" :class="{ 'opacity-25': saving }"
:disabled="saving" :disabled="saving"

View File

@@ -99,6 +99,10 @@ 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();
} }

View File

@@ -0,0 +1,16 @@
[
{
"archived": false,
"creator_id": 201,
"id": 301,
"name": "Big Company",
"wid": 0
},
{
"archived": true,
"creator_id": 201,
"id": 302,
"name": "Other Company (Archived)",
"wid": 0
}
]

View File

@@ -0,0 +1,86 @@
[
{
"active": true,
"actual_hours": null,
"actual_seconds": null,
"auto_estimates": false,
"billable": false,
"cid": null,
"client_id": null,
"color": "#ef5350",
"currency": "EUR",
"estimated_hours": null,
"estimated_seconds": null,
"fixed_fee": null,
"guid": "",
"id": 401,
"is_private": true,
"name": "Project without Client",
"rate": null,
"rate_last_updated": null,
"recurring": false,
"recurring_parameters": null,
"start_date": "2020-01-01",
"status": "active",
"template": false,
"template_id": null,
"wid": 0,
"workspace_id": 0
},
{
"active": true,
"actual_hours": null,
"actual_seconds": null,
"auto_estimates": false,
"billable": true,
"cid": 301,
"client_id": 301,
"color": "#ec407a",
"currency": null,
"estimated_hours": null,
"estimated_seconds": null,
"fixed_fee": null,
"guid": "",
"id": 402,
"is_private": true,
"name": "Project for Big Company",
"rate": 100.01,
"rate_last_updated": null,
"recurring": false,
"recurring_parameters": null,
"start_date": "2020-01-01",
"status": "active",
"template": false,
"template_id": null,
"wid": 0,
"workspace_id": 0
},
{
"active": false,
"actual_hours": null,
"actual_seconds": null,
"auto_estimates": false,
"billable": true,
"cid": 302,
"client_id": 302,
"color": "#6a407f",
"currency": null,
"estimated_hours": null,
"estimated_seconds": null,
"fixed_fee": null,
"guid": "",
"id": 403,
"is_private": false,
"name": "Project (Archived)",
"rate": null,
"rate_last_updated": null,
"recurring": false,
"recurring_parameters": null,
"start_date": "2020-01-01",
"status": "active",
"template": false,
"template_id": null,
"wid": 0,
"workspace_id": 0
}
]

View File

@@ -0,0 +1,14 @@
[
{
"gid": null,
"group_id": null,
"id": 801,
"labour_cost": null,
"manager": true,
"project_id": 402,
"rate": 100.02,
"rate_last_updated": null,
"user_id": 2001,
"workspace_id": 0
}
]

View File

@@ -0,0 +1,14 @@
[
{
"creator_id": 0,
"id": 501,
"name": "Development",
"workspace_id": 0
},
{
"creator_id": 0,
"id": 502,
"name": "Backend",
"workspace_id": 0
}
]

View File

@@ -0,0 +1 @@
[]

View File

@@ -0,0 +1,24 @@
[
{
"active": true,
"estimated_seconds": 0,
"id": 601,
"name": "Task 1",
"project_id": 402,
"recurring": false,
"tracked_seconds": 0,
"user_id": null,
"workspace_id": 0
},
{
"active": false,
"estimated_seconds": 0,
"id": 602,
"name": "Task 2",
"project_id": 403,
"recurring": false,
"tracked_seconds": 0,
"user_id": null,
"workspace_id": 0
}
]

View File

@@ -0,0 +1 @@
[]

View File

@@ -0,0 +1,19 @@
[
{
"active": true,
"admin": true,
"email": "peter.test@email.test",
"group_ids": [],
"id": 201,
"inactive": false,
"labour_cost": null,
"name": "Peter Tester",
"rate": null,
"rate_last_updated": null,
"role": "admin",
"timezone": "Etc/UTC",
"uid": 2001,
"wid": 0,
"working_hours_in_minutes": null
}
]

View File

@@ -2,6 +2,7 @@
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;
@@ -57,6 +58,14 @@ 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');

View File

@@ -5,6 +5,7 @@ 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
@@ -13,6 +14,7 @@ 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')
@@ -21,9 +23,16 @@ 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');
}
} }

View File

@@ -0,0 +1,312 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Endpoint\Api\V1;
use App\Http\Controllers\Api\V1\ApiTokenController;
use App\Models\Passport\Client;
use App\Models\Passport\Token;
use Illuminate\Support\Facades\Config;
use Laravel\Passport\ClientRepository;
use Laravel\Passport\Passport;
use PHPUnit\Framework\Attributes\UsesClass;
#[UsesClass(ApiTokenController::class)]
class ApiTokenEndpointTest extends ApiEndpointTestAbstract
{
public function test_index_endpoint_returns_list_api_tokens(): void
{
// Arrange
$data = $this->createUserWithPermission([]);
$personalAccessClient = $this->createPersonalAccessClient();
Config::set('passport.personal_access_client.id', $personalAccessClient->id);
Config::set('passport.personal_access_client.secret', $personalAccessClient->secret);
$client = $this->createClient();
$token = Token::factory()->forUser($data->user)->forClient($personalAccessClient)->create();
$otherTokenType = Token::factory()->forUser($data->user)->forClient($client)->create();
$otherData = $this->createUserWithPermission([]);
$otherToken = Token::factory()->forUser($otherData->user)->forClient($personalAccessClient)->create();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.api-tokens.index'));
// Assert
$this->assertResponseCode($response, 200);
$response->assertExactJson([
'data' => [
[
'id' => $token->id,
'name' => $token->name,
'scopes' => $token->scopes,
'revoked' => $token->revoked,
'created_at' => $token->created_at->toIso8601ZuluString(),
'expires_at' => $token->expires_at->toIso8601ZuluString(),
],
],
]);
}
public function test_store_endpoint_creates_new_api_token(): void
{
// Arrange
$data = $this->createUserWithPermission([]);
$personalAccessClient = $this->createPersonalAccessClient();
Config::set('passport.personal_access_client.id', $personalAccessClient->id);
Config::set('passport.personal_access_client.secret', $personalAccessClient->secret);
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.api-tokens.store'), [
'name' => 'Test Token',
]);
// Assert
$this->assertResponseCode($response, 200);
$response->assertJsonStructure([
'data' => [
'id',
'name',
'scopes',
'revoked',
'created_at',
'expires_at',
'access_token',
],
]);
}
public function test_store_fails_if_personal_access_client_is_not_configured(): void
{
// Arrange
$data = $this->createUserWithPermission([]);
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.api-tokens.store'), [
'name' => 'Test Token',
]);
// Assert
$this->assertResponseCode($response, 400);
$response->assertExactJson([
'error' => true,
'key' => 'personal_access_client_is_not_configured',
'message' => 'Personal access client is not configured',
]);
}
public function test_revoke_endpoint_revokes_api_token(): void
{
// Arrange
$data = $this->createUserWithPermission([]);
$client = $this->createPersonalAccessClient();
Config::set('passport.personal_access_client.id', $client->id);
Config::set('passport.personal_access_client.secret', $client->secret);
$token = Token::factory()->forUser($data->user)->forClient($client)->create();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.api-tokens.revoke', $token->id));
// Assert
$this->assertResponseCode($response, 204);
$this->assertDatabaseHas(Token::class, [
'id' => $token->id,
'revoked' => true,
]);
}
public function test_revoke_fails_if_token_is_not_personal_access_token(): void
{
// Arrange
$data = $this->createUserWithPermission([]);
$personalAccessClient = $this->createPersonalAccessClient();
Config::set('passport.personal_access_client.id', $personalAccessClient->id);
Config::set('passport.personal_access_client.secret', $personalAccessClient->secret);
$client = $this->createClient();
$token = Token::factory()->forUser($data->user)->forClient($client)->create();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.api-tokens.revoke', $token->id));
// Assert
$this->assertResponseCode($response, 403);
$this->assertDatabaseHas(Token::class, [
'id' => $token->id,
'revoked' => false,
]);
}
public function test_revoke_fails_if_token_with_id_does_not_exist(): void
{
// Arrange
$data = $this->createUserWithPermission([]);
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.api-tokens.revoke', 'not-valid'));
// Assert
$this->assertResponseCode($response, 404);
}
public function test_revoke_fails_if_personal_access_client_is_not_configured(): void
{
// Arrange
$data = $this->createUserWithPermission([]);
$client = $this->createPersonalAccessClient();
$token = Token::factory()->forUser($data->user)->forClient($client)->create();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.api-tokens.revoke', $token->id));
// Assert
$this->assertResponseCode($response, 400);
$response->assertExactJson([
'error' => true,
'key' => 'personal_access_client_is_not_configured',
'message' => 'Personal access client is not configured',
]);
}
public function test_revoke_fails_if_the_token_does_not_belong_to_the_user(): void
{
// Arrange
$data = $this->createUserWithPermission([]);
$otherData = $this->createUserWithPermission([]);
$client = $this->createPersonalAccessClient();
Config::set('passport.personal_access_client.id', $client->id);
Config::set('passport.personal_access_client.secret', $client->secret);
$token = Token::factory()->forUser($otherData->user)->forClient($client)->create();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.api-tokens.revoke', $token->id));
// Assert
$this->assertResponseCode($response, 403);
$this->assertDatabaseHas(Token::class, [
'id' => $token->id,
'revoked' => false,
]);
}
public function test_destroy_endpoint_deletes_api_token(): void
{
// Arrange
$data = $this->createUserWithPermission([]);
$client = $this->createPersonalAccessClient();
Config::set('passport.personal_access_client.id', $client->id);
Config::set('passport.personal_access_client.secret', $client->secret);
$token = Token::factory()->forUser($data->user)->forClient($client)->create();
Passport::actingAs($data->user);
// Act
$response = $this->deleteJson(route('api.v1.api-tokens.destroy', $token->id));
// Assert
$this->assertResponseCode($response, 204);
$this->assertDatabaseMissing(Token::class, ['id' => $token->id]);
}
public function test_destroy_fails_if_personal_access_client_is_not_configured(): void
{
// Arrange
$data = $this->createUserWithPermission([]);
$client = $this->createPersonalAccessClient();
$token = Token::factory()->forUser($data->user)->forClient($client)->create();
Passport::actingAs($data->user);
// Act
$response = $this->deleteJson(route('api.v1.api-tokens.destroy', $token->id));
// Assert
$this->assertResponseCode($response, 400);
$response->assertExactJson([
'error' => true,
'key' => 'personal_access_client_is_not_configured',
'message' => 'Personal access client is not configured',
]);
}
public function test_destroy_fails_if_token_is_not_personal_access_token(): void
{
// Arrange
$data = $this->createUserWithPermission([]);
$personalAccessClient = $this->createPersonalAccessClient();
Config::set('passport.personal_access_client.id', $personalAccessClient->id);
Config::set('passport.personal_access_client.secret', $personalAccessClient->secret);
$client = $this->createClient();
$token = Token::factory()->forUser($data->user)->forClient($client)->create();
Passport::actingAs($data->user);
// Act
$response = $this->deleteJson(route('api.v1.api-tokens.destroy', $token->id));
// Assert
$this->assertResponseCode($response, 403);
$this->assertDatabaseHas(Token::class, [
'id' => $token->id,
]);
}
public function test_destroy_fails_if_token_with_id_does_not_exist(): void
{
// Arrange
$data = $this->createUserWithPermission([]);
Passport::actingAs($data->user);
// Act
$response = $this->deleteJson(route('api.v1.api-tokens.destroy', 'not-valid'));
// Assert
$this->assertResponseCode($response, 404);
}
public function test_destroy_fails_if_the_token_does_not_belong_to_the_user(): void
{
// Arrange
$data = $this->createUserWithPermission([]);
$otherData = $this->createUserWithPermission([]);
$client = $this->createPersonalAccessClient();
Config::set('passport.personal_access_client.id', $client->id);
Config::set('passport.personal_access_client.secret', $client->secret);
$token = Token::factory()->forUser($otherData->user)->forClient($client)->create();
Passport::actingAs($data->user);
// Act
$response = $this->deleteJson(route('api.v1.api-tokens.destroy', $token->id));
// Assert
$this->assertResponseCode($response, 403);
$this->assertDatabaseHas(Token::class, [
'id' => $token->id,
]);
}
private function createPersonalAccessClient(): Client
{
$clientRepository = new ClientRepository;
/** @var Client $client */
$client = $clientRepository->createPersonalAccessClient(
null, 'Test Personal Access Client', 'http://localhost'
);
return $client;
}
private function createClient(): Client
{
$clientRepository = new ClientRepository;
/** @var Client $client */
$client = $clientRepository->create(
null, 'Desktop App', 'http://localhost', null
);
return $client;
}
}

View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Filament\Resources;
use App\Filament\Resources\TokenResource;
use App\Models\Passport\Client;
use App\Models\Passport\Token;
use App\Models\User;
use Illuminate\Support\Facades\Config;
use Livewire\Livewire;
use PHPUnit\Framework\Attributes\UsesClass;
use Tests\Unit\Filament\FilamentTestCase;
#[UsesClass(TokenResource::class)]
class TokenResourceTest extends FilamentTestCase
{
protected function setUp(): void
{
parent::setUp();
Config::set('auth.super_admins', ['admin@example.com']);
$user = User::factory()->withPersonalOrganization()->create([
'email' => 'admin@example.com',
]);
$this->actingAs($user);
}
public function test_can_list_tokens(): void
{
// Arrange
$client = Client::factory()->create();
$tokens = Token::factory()->forClient($client)->createMany(5);
// Act
$response = Livewire::test(TokenResource\Pages\ListTokens::class);
// Assert
$response->assertSuccessful();
$response->assertCanSeeTableRecords($tokens);
}
public function test_list_tokens_with_filter_is_personal_access_client_true(): void
{
// Arrange
$client = Client::factory()->create();
$personalAccessClient = Client::factory()->personalAccessClient()->create();
$tokens = Token::factory()->forClient($client)->createMany(5);
$personalAccessTokens = Token::factory()->forClient($personalAccessClient)->createMany(5);
// Act
$response = Livewire::test(TokenResource\Pages\ListTokens::class)
->filterTable('is_personal_access_client', true);
// Assert
$response->assertSuccessful();
$response->assertCountTableRecords(5);
$response->assertCanSeeTableRecords($personalAccessTokens);
$response->assertCanNotSeeTableRecords($tokens);
}
public function test_list_tokens_with_filter_is_personal_access_client_false(): void
{
// Arrange
$client = Client::factory()->create();
$personalAccessClient = Client::factory()->personalAccessClient()->create();
$tokens = Token::factory()->forClient($client)->createMany(5);
$personalAccessTokens = Token::factory()->forClient($personalAccessClient)->createMany(5);
// Act
$response = Livewire::test(TokenResource\Pages\ListTokens::class)
->filterTable('is_personal_access_client', false);
// Assert
$response->assertSuccessful();
$response->assertCountTableRecords(5);
$response->assertCanSeeTableRecords($tokens);
$response->assertCanNotSeeTableRecords($personalAccessTokens);
}
public function test_can_see_view_page_of_token(): void
{
// Arrange
$client = Client::factory()->create();
$token = Token::factory()->forClient($client)->create();
// Act
$response = Livewire::test(TokenResource\Pages\ViewToken::class, ['record' => $token->getKey()]);
// Assert
$response->assertSuccessful();
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Tests\Unit\Service\Import\Importers; namespace Tests\Unit\Service\Import\Importers;
use App\Models\Organization; use App\Models\Organization;
use App\Models\User;
use App\Service\Import\Importers\DefaultImporter; use App\Service\Import\Importers\DefaultImporter;
use App\Service\Import\Importers\ImportException; use App\Service\Import\Importers\ImportException;
use App\Service\Import\Importers\TogglDataImporter; use App\Service\Import\Importers\TogglDataImporter;
@@ -88,4 +89,30 @@ class TogglDataImporterTest extends ImporterTestAbstract
$this->assertSame(0, $report->projectsCreated); $this->assertSame(0, $report->projectsCreated);
$this->assertSame(0, $report->clientsCreated); $this->assertSame(0, $report->clientsCreated);
} }
public function test_import_of_user_with_unknown_timezone_will_be_mapped_to_utc(): void
{
// Arrange
$zipPath = $this->createTestZip('toggl_data_import_test_2');
$timezone = 'Europe/Vienna';
$organization = Organization::factory()->create();
$importer = new TogglDataImporter;
$importer->init($organization);
$data = file_get_contents($zipPath);
// Act
$importer->importData($data, $timezone);
$report = $importer->getReport();
// Assert
$this->assertSame(0, $report->timeEntriesCreated);
$this->assertSame(2, $report->tagsCreated);
$this->assertSame(2, $report->tasksCreated);
$this->assertSame(1, $report->usersCreated);
$this->assertSame(3, $report->projectsCreated);
$this->assertSame(2, $report->clientsCreated);
$user = User::query()->where('email', '=', 'peter.test@email.test')->first();
$this->assertSame('UTC', $user->timezone);
$this->assertTrue($user->is_placeholder);
}
} }