mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-06-15 13:32:43 +01:00
Compare commits
17 Commits
feature/fi
...
feature/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9ffa9899f | ||
|
|
aa588196ee | ||
|
|
b6bbcd7097 | ||
|
|
0d4ffa1061 | ||
|
|
b7abe3738e | ||
|
|
128a21ba63 | ||
|
|
e25461a439 | ||
|
|
ba8751c7c4 | ||
|
|
21b33a0028 | ||
|
|
97585b5771 | ||
|
|
ae76135373 | ||
|
|
69a8c8bb2b | ||
|
|
4ea55e5867 | ||
|
|
bbed618fdc | ||
|
|
d924fa74ec | ||
|
|
adf0d35c11 | ||
|
|
4ed8f16ae3 |
42
.env.ci
42
.env.ci
@@ -1,3 +1,4 @@
|
||||
# Application
|
||||
APP_NAME=solidtime
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
@@ -5,7 +6,6 @@ APP_DEBUG=true
|
||||
APP_URL=http://localhost
|
||||
APP_FORCE_HTTPS=false
|
||||
APP_ENABLE_REGISTRATION=true
|
||||
SESSION_SECURE_COOKIE=false
|
||||
|
||||
# Logging
|
||||
LOG_CHANNEL=stack
|
||||
@@ -20,35 +20,39 @@ DB_TEST_DATABASE=laravel
|
||||
DB_TEST_USERNAME=root
|
||||
DB_TEST_PASSWORD=root
|
||||
|
||||
BROADCAST_DRIVER=log
|
||||
# Broadcasting
|
||||
BROADCAST_DRIVER=null
|
||||
|
||||
# Cache
|
||||
CACHE_DRIVER=file
|
||||
|
||||
# Queue
|
||||
QUEUE_CONNECTION=sync
|
||||
|
||||
# Session
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
|
||||
# Mail
|
||||
MAIL_MAILER=log
|
||||
MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
MAIL_FROM_ADDRESS="no-reply@solidtime.test"
|
||||
MAIL_FROM_NAME="solidtime"
|
||||
MAIL_REPLY_TO_ADDRESS="hello@solidtime.test"
|
||||
MAIL_REPLY_TO_NAME="solidtime"
|
||||
|
||||
# Filesystems
|
||||
FILESYSTEM_DISK=local
|
||||
PUBLIC_FILESYSTEM_DISK=public
|
||||
|
||||
# Passport
|
||||
PASSPORT_PERSONAL_ACCESS_CLIENT_ID="9e27f54d-5dfb-4dde-99d7-834518236c92"
|
||||
PASSPORT_PERSONAL_ACCESS_CLIENT_SECRET="EL5mXp3aF8ITjcwoOXRpbSK7zGrWhW4zTDpQXTkf"
|
||||
|
||||
# Auditing
|
||||
AUDITING_ENABLED=true
|
||||
|
||||
# Telescope
|
||||
TELESCOPE_ENABLED=false
|
||||
|
||||
# Services
|
||||
GOTENBERG_URL=http://0.0.0.0:3000
|
||||
|
||||
PUSHER_APP_ID=
|
||||
PUSHER_APP_KEY=
|
||||
PUSHER_APP_SECRET=
|
||||
PUSHER_HOST=
|
||||
PUSHER_PORT=443
|
||||
PUSHER_SCHEME=https
|
||||
PUSHER_APP_CLUSTER=mt1
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
|
||||
VITE_PUSHER_HOST="${PUSHER_HOST}"
|
||||
VITE_PUSHER_PORT="${PUSHER_PORT}"
|
||||
VITE_PUSHER_SCHEME="${PUSHER_SCHEME}"
|
||||
VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
|
||||
|
||||
22
.env.example
22
.env.example
@@ -4,7 +4,7 @@ APP_ENV=local
|
||||
APP_KEY=base64:UNQNf1SXeASNkWux01Rj8EnHYx8FO0kAxWNDwktclkk=
|
||||
APP_DEBUG=true
|
||||
APP_URL=https://solidtime.test
|
||||
AUDITING_ENABLED=true
|
||||
APP_FORCE_HTTPS=false
|
||||
APP_ENABLE_REGISTRATION=true
|
||||
SUPER_ADMINS=admin@example.com
|
||||
PAGINATION_PER_PAGE_DEFAULT=500
|
||||
@@ -49,7 +49,9 @@ MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_ENCRYPTION=null
|
||||
MAIL_FROM_ADDRESS="no-reply@solidtime.test"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
MAIL_FROM_NAME="solidtime"
|
||||
MAIL_REPLY_TO_ADDRESS="hello@solidtime.test"
|
||||
MAIL_REPLY_TO_NAME="solidtime"
|
||||
|
||||
# Filesystems
|
||||
FILESYSTEM_DISK=s3
|
||||
@@ -62,14 +64,24 @@ S3_URL=http://storage.solidtime.test/local
|
||||
S3_ENDPOINT=http://storage.solidtime.test
|
||||
S3_USE_PATH_STYLE_ENDPOINT=true
|
||||
|
||||
# Passport
|
||||
PASSPORT_PERSONAL_ACCESS_CLIENT_ID="9e27f54d-5dfb-4dde-99d7-834518236c92"
|
||||
PASSPORT_PERSONAL_ACCESS_CLIENT_SECRET="EL5mXp3aF8ITjcwoOXRpbSK7zGrWhW4zTDpQXTkf"
|
||||
|
||||
# Auditing
|
||||
AUDITING_ENABLED=true
|
||||
|
||||
# Telescope
|
||||
TELESCOPE_ENABLED=false
|
||||
|
||||
# Services
|
||||
GOTENBERG_URL=http://gotenberg:3000
|
||||
|
||||
VITE_HOST_NAME=vite.solidtime.test
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
# Local setup
|
||||
NGINX_HOST_NAME=solidtime.test
|
||||
NETWORK_NAME=reverse-proxy-docker-traefik_routing
|
||||
FORWARD_DB_PORT=5432
|
||||
FORWARD_WEB_PORT=8083
|
||||
VITE_HOST_NAME=vite.solidtime.test
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
#SAIL_XDEBUG_MODE=develop,debug,coverage
|
||||
|
||||
@@ -5,7 +5,6 @@ VITE_APP_NAME=solidtime
|
||||
APP_ENV=production
|
||||
APP_DEBUG=false
|
||||
APP_FORCE_HTTPS=true
|
||||
SESSION_SECURE_COOKIE=true
|
||||
OCTANE_SERVER=frankenphp
|
||||
PAGINATION_PER_PAGE_DEFAULT=500
|
||||
|
||||
|
||||
47
.github/ISSUE_TEMPLATE/1_bug_report.yml
vendored
Normal file
47
.github/ISSUE_TEMPLATE/1_bug_report.yml
vendored
Normal 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
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal 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
8
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal 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.
|
||||
-->
|
||||
145
.github/workflows/build-public.yml
vendored
145
.github/workflows/build-public.yml
vendored
@@ -11,10 +11,21 @@ on:
|
||||
- 'docker/prod/**'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
DOCKERHUB_REPO: solidtime/solidtime
|
||||
GHCR_REPO: ghcr.io/solidtime-io/solidtime
|
||||
|
||||
name: Build - Public
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
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:
|
||||
packages: write
|
||||
contents: read
|
||||
@@ -29,7 +40,7 @@ jobs:
|
||||
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
|
||||
|
||||
- name: "Get build"
|
||||
id: build
|
||||
id: release-build
|
||||
run: echo "build=$(git rev-parse --short=8 HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: "Get Previous tag (normal push)"
|
||||
@@ -40,7 +51,7 @@ jobs:
|
||||
prefix: "v"
|
||||
|
||||
- name: "Get version"
|
||||
id: version
|
||||
id: release-version
|
||||
run: |
|
||||
if ${{ !startsWith(github.ref, 'refs/tags/v') }}; then
|
||||
if ${{ startsWith(steps.previoustag.outputs.tag, 'v') }}; then
|
||||
@@ -61,21 +72,23 @@ jobs:
|
||||
rm .env.production .env.ci .env.example
|
||||
|
||||
- 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"
|
||||
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"
|
||||
run: cat .env
|
||||
|
||||
- name: "Install dependencies"
|
||||
uses: php-actions/composer@v6
|
||||
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
|
||||
- name: "Setup PHP with PECL extension"
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
command: install
|
||||
only_args: --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
|
||||
php_version: 8.3
|
||||
php-version: '8.3'
|
||||
extensions: mbstring, dom, fileinfo, pgsql
|
||||
|
||||
- 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"
|
||||
uses: actions/setup-node@v4
|
||||
@@ -88,29 +101,31 @@ jobs:
|
||||
- name: "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
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: "Login to GitHub Container Registry"
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
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"
|
||||
uses: docker/setup-qemu-action@v3
|
||||
@@ -118,16 +133,90 @@ jobs:
|
||||
- name: "Set up Docker Buildx"
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: "Build and push"
|
||||
- name: "Build and push by digest"
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/prod/Dockerfile
|
||||
build-args: |
|
||||
DOCKER_FILES_BASE_PATH=docker/prod/
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
platforms: ${{ matrix.platform }}
|
||||
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-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 }}
|
||||
|
||||
3
.github/workflows/npm-publish-api.yml
vendored
3
.github/workflows/npm-publish-api.yml
vendored
@@ -8,7 +8,8 @@ jobs:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@v4
|
||||
# Setup .npmrc file to publish to npm
|
||||
- name: Install root project dependencies
|
||||
run: npm ci
|
||||
|
||||
3
.github/workflows/npm-publish-ui.yml
vendored
3
.github/workflows/npm-publish-ui.yml
vendored
@@ -8,7 +8,8 @@ jobs:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@v4
|
||||
# Setup .npmrc file to publish to npm
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
|
||||
24
.github/workflows/playwright.yml
vendored
24
.github/workflows/playwright.yml
vendored
@@ -27,45 +27,47 @@ jobs:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- name: "Setup node"
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
|
||||
- name: Setup PHP
|
||||
- name: "Setup PHP"
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.3'
|
||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv
|
||||
coverage: none
|
||||
|
||||
- name: Run composer install
|
||||
- name: "Run composer install"
|
||||
run: composer install -n --prefer-dist
|
||||
|
||||
- name: Prepare Laravel Application
|
||||
- name: "Prepare Laravel Application"
|
||||
run: |
|
||||
cp .env.ci .env
|
||||
php artisan key:generate
|
||||
php artisan migrate --seed
|
||||
php artisan passport:keys
|
||||
php artisan migrate --seed
|
||||
|
||||
- name: Install dependencies
|
||||
- name: "Install dependencies"
|
||||
run: npm ci
|
||||
|
||||
- name: Build Frontend
|
||||
- name: "Build Frontend"
|
||||
run: npm run build
|
||||
|
||||
- name: Run Laravel Server
|
||||
- name: "Run Laravel Server"
|
||||
run: php artisan serve > /dev/null 2>&1 &
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
- name: "Install Playwright Browsers"
|
||||
run: npx playwright install --with-deps
|
||||
|
||||
- name: Run Playwright tests
|
||||
- name: "Run Playwright tests"
|
||||
run: npx playwright test
|
||||
env:
|
||||
PLAYWRIGHT_BASE_URL: 'http://127.0.0.1:8000'
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- name: "Upload test results"
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: test-results
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Api;
|
||||
|
||||
class PersonalAccessClientIsNotConfiguredException extends ApiException
|
||||
{
|
||||
public const string KEY = 'personal_access_client_is_not_configured';
|
||||
}
|
||||
148
app/Filament/Resources/TokenResource.php
Normal file
148
app/Filament/Resources/TokenResource.php
Normal file
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\TokenResource\Pages;
|
||||
use App\Models\Passport\Client;
|
||||
use App\Models\Passport\Token;
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Filters\TernaryFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class TokenResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Token::class;
|
||||
|
||||
protected static ?string $navigationIcon = 'heroicon-o-key';
|
||||
|
||||
protected static ?string $navigationGroup = 'Auth';
|
||||
|
||||
protected static ?int $navigationSort = 6;
|
||||
|
||||
public static function form(Form $form): Form
|
||||
{
|
||||
return $form
|
||||
->columns(1)
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('id')
|
||||
->label('ID')
|
||||
->disabled()
|
||||
->visibleOn(['update', 'show'])
|
||||
->readOnly()
|
||||
->maxLength(255),
|
||||
Forms\Components\TextInput::make('name')
|
||||
->label('Name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
Forms\Components\Select::make('user_id')
|
||||
->label('User')
|
||||
->relationship(name: 'user', titleAttribute: 'name')
|
||||
->searchable(['name'])
|
||||
->disabled()
|
||||
->required(),
|
||||
Forms\Components\Select::make('client_id')
|
||||
->label('Client')
|
||||
->relationship(name: 'client', titleAttribute: 'name')
|
||||
->searchable(['name'])
|
||||
->required(),
|
||||
Forms\Components\Toggle::make('revoked')
|
||||
->label('Revoked')
|
||||
->required(),
|
||||
Forms\Components\DateTimePicker::make('expires_at')
|
||||
->label('Expires At')
|
||||
->disabled(),
|
||||
Forms\Components\DateTimePicker::make('created_at')
|
||||
->label('Created At')
|
||||
->disabled(),
|
||||
Forms\Components\DateTimePicker::make('updated_at')
|
||||
->label('Updated At')
|
||||
->disabled(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('name')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('user.name')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('client.name')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
Tables\Columns\IconColumn::make('client.personal_access_client')
|
||||
->boolean()
|
||||
->label('API token?')
|
||||
->sortable(),
|
||||
Tables\Columns\IconColumn::make('revoked')
|
||||
->boolean()
|
||||
->label('Revoked?')
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('expires_at')
|
||||
->dateTime()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('updated_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->defaultSort('created_at', 'desc')
|
||||
->filters([
|
||||
TernaryFilter::make('is_personal_access_client')
|
||||
->queries(
|
||||
true: function (Builder $query) {
|
||||
/** @var Builder<Token> $query */
|
||||
return $query->whereHas('client', function (Builder $query) {
|
||||
/** @var Builder<Client> $query */
|
||||
return $query->where('personal_access_client', true);
|
||||
});
|
||||
},
|
||||
false: function (Builder $query) {
|
||||
/** @var Builder<Token> $query */
|
||||
return $query->whereHas('client', function (Builder $query) {
|
||||
/** @var Builder<Client> $query */
|
||||
return $query->where('personal_access_client', false);
|
||||
});
|
||||
},
|
||||
blank: function (Builder $query) {
|
||||
/** @var Builder<Token> $query */
|
||||
return $query;
|
||||
},
|
||||
)
|
||||
->label('API token?'),
|
||||
TernaryFilter::make('revoked')
|
||||
->label('Revoked?'),
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\ViewAction::make(),
|
||||
])
|
||||
->bulkActions([
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListTokens::route('/'),
|
||||
'view' => Pages\ViewToken::route('/{record}'),
|
||||
];
|
||||
}
|
||||
}
|
||||
19
app/Filament/Resources/TokenResource/Pages/ListTokens.php
Normal file
19
app/Filament/Resources/TokenResource/Pages/ListTokens.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\TokenResource\Pages;
|
||||
|
||||
use App\Filament\Resources\TokenResource;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListTokens extends ListRecords
|
||||
{
|
||||
protected static string $resource = TokenResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
];
|
||||
}
|
||||
}
|
||||
19
app/Filament/Resources/TokenResource/Pages/ViewToken.php
Normal file
19
app/Filament/Resources/TokenResource/Pages/ViewToken.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\TokenResource\Pages;
|
||||
|
||||
use App\Filament\Resources\TokenResource;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewToken extends ViewRecord
|
||||
{
|
||||
protected static string $resource = TokenResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ class UserResource extends Resource
|
||||
{
|
||||
/** @var User|null $record */
|
||||
$record = $form->getRecord();
|
||||
|
||||
return $form
|
||||
->columns(1)
|
||||
->schema([
|
||||
|
||||
114
app/Http/Controllers/Api/V1/ApiTokenController.php
Normal file
114
app/Http/Controllers/Api/V1/ApiTokenController.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Exceptions\Api\PersonalAccessClientIsNotConfiguredException;
|
||||
use App\Http\Requests\V1\ApiToken\ApiTokenStoreRequest;
|
||||
use App\Http\Resources\V1\ApiToken\ApiTokenCollection;
|
||||
use App\Http\Resources\V1\ApiToken\ApiTokenWithAccessTokenResource;
|
||||
use App\Models\Passport\Token;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class ApiTokenController extends Controller
|
||||
{
|
||||
/**
|
||||
* List all api token of the currently authenticated user
|
||||
*
|
||||
* This endpoint is independent of organization.
|
||||
*
|
||||
* @operationId getApiTokens
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function index(): ApiTokenCollection
|
||||
{
|
||||
$user = $this->user();
|
||||
|
||||
$tokens = $user->tokens()
|
||||
->where('client_id', '=', config('passport.personal_access_client.id'))
|
||||
->get();
|
||||
|
||||
return new ApiTokenCollection($tokens);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new api token for the currently authenticated user
|
||||
*
|
||||
* The response will contain the access token that can be used to send authenticated API requests.
|
||||
* Please note that the access token is only shown in this response and cannot be retrieved later.
|
||||
*
|
||||
* @operationId createApiToken
|
||||
*
|
||||
* @throws AuthorizationException|PersonalAccessClientIsNotConfiguredException
|
||||
*/
|
||||
public function store(ApiTokenStoreRequest $request): ApiTokenWithAccessTokenResource
|
||||
{
|
||||
$user = $this->user();
|
||||
|
||||
if (config('passport.personal_access_client.id') === null || config('passport.personal_access_client.secret') === null) {
|
||||
throw new PersonalAccessClientIsNotConfiguredException;
|
||||
}
|
||||
|
||||
$token = $user->createToken($request->getName(), ['*']);
|
||||
/** @var Token $tokenModel */
|
||||
$tokenModel = $token->token;
|
||||
|
||||
return new ApiTokenWithAccessTokenResource($tokenModel, $token->accessToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke an api token
|
||||
*
|
||||
* @operationId revokeApiToken
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
* @throws PersonalAccessClientIsNotConfiguredException
|
||||
*/
|
||||
public function revoke(Token $apiToken): JsonResponse
|
||||
{
|
||||
$user = $this->user();
|
||||
|
||||
if (config('passport.personal_access_client.id') === null || config('passport.personal_access_client.secret') === null) {
|
||||
throw new PersonalAccessClientIsNotConfiguredException;
|
||||
}
|
||||
if ($apiToken->user_id !== $user->getKey()) {
|
||||
throw new AuthorizationException('API token does not belong to user');
|
||||
}
|
||||
if ($apiToken->client_id !== config('passport.personal_access_client.id')) {
|
||||
throw new AuthorizationException('API token is not a personal access token');
|
||||
}
|
||||
|
||||
$apiToken->revoke();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an api token
|
||||
*
|
||||
* @operationId deleteApiToken
|
||||
*
|
||||
* @throws AuthorizationException|PersonalAccessClientIsNotConfiguredException
|
||||
*/
|
||||
public function destroy(Token $apiToken): JsonResponse
|
||||
{
|
||||
$user = $this->user();
|
||||
|
||||
if (config('passport.personal_access_client.id') === null || config('passport.personal_access_client.secret') === null) {
|
||||
throw new PersonalAccessClientIsNotConfiguredException;
|
||||
}
|
||||
if ($apiToken->user_id !== $user->getKey()) {
|
||||
throw new AuthorizationException('API token does not belong to user');
|
||||
}
|
||||
if ($apiToken->client_id !== config('passport.personal_access_client.id')) {
|
||||
throw new AuthorizationException('API token is not a personal access token');
|
||||
}
|
||||
|
||||
$apiToken->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,7 @@ class HealthCheckController extends Controller
|
||||
$response['app_env'] = app()->environment();
|
||||
$response['app_timezone'] = config('app.timezone');
|
||||
$response['app_force_https'] = config('app.force_https');
|
||||
$response['session_secure'] = config('session.secure');
|
||||
$response['trusted_proxies'] = config('trustedproxy.proxies');
|
||||
$headers = $request->headers->all();
|
||||
if (isset($headers['cookie'])) {
|
||||
|
||||
@@ -18,7 +18,7 @@ class Kernel extends HttpKernel
|
||||
* @var array<int, class-string|string>
|
||||
*/
|
||||
protected $middleware = [
|
||||
// \App\Http\Middleware\TrustHosts::class,
|
||||
\App\Http\Middleware\ForceHttps::class,
|
||||
\App\Http\Middleware\TrustProxies::class,
|
||||
\Illuminate\Http\Middleware\HandleCors::class,
|
||||
\App\Http\Middleware\PreventRequestsDuringMaintenance::class,
|
||||
|
||||
29
app/Http/Middleware/ForceHttps.php
Normal file
29
app/Http/Middleware/ForceHttps.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ForceHttps
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next, string ...$guards): Response
|
||||
{
|
||||
if (config('app.force_https', false)) {
|
||||
URL::forceScheme('https');
|
||||
$request->server->set('HTTPS', 'on');
|
||||
$request->headers->set('X-Forwarded-Proto', 'https');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Illuminate\Http\Middleware\TrustHosts as Middleware;
|
||||
|
||||
class TrustHosts extends Middleware
|
||||
{
|
||||
/**
|
||||
* Get the host patterns that should be trusted.
|
||||
*
|
||||
* @return array<int, string|null>
|
||||
*/
|
||||
public function hosts(): array
|
||||
{
|
||||
return [
|
||||
$this->allSubdomainsOfApplicationUrl(),
|
||||
];
|
||||
}
|
||||
}
|
||||
32
app/Http/Requests/V1/ApiToken/ApiTokenStoreRequest.php
Normal file
32
app/Http/Requests/V1/ApiToken/ApiTokenStoreRequest.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\V1\ApiToken;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ApiTokenStoreRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, array<string>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => [
|
||||
'required',
|
||||
'string',
|
||||
'min:1',
|
||||
'max:255',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->input('name');
|
||||
}
|
||||
}
|
||||
17
app/Http/Resources/V1/ApiToken/ApiTokenCollection.php
Normal file
17
app/Http/Resources/V1/ApiToken/ApiTokenCollection.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\V1\ApiToken;
|
||||
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
class ApiTokenCollection extends ResourceCollection
|
||||
{
|
||||
/**
|
||||
* The resource that this resource collects.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public $collects = ApiTokenResource::class;
|
||||
}
|
||||
38
app/Http/Resources/V1/ApiToken/ApiTokenResource.php
Normal file
38
app/Http/Resources/V1/ApiToken/ApiTokenResource.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\V1\ApiToken;
|
||||
|
||||
use App\Http\Resources\V1\BaseResource;
|
||||
use App\Models\Passport\Token;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* @property-read Token $resource
|
||||
*/
|
||||
class ApiTokenResource extends BaseResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, string|bool|int|null|array<string>>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
/** @var string $id ID of the API token, this ID is NOT a UUID */
|
||||
'id' => $this->resource->id,
|
||||
/** @var string $name Name of the API token */
|
||||
'name' => $this->resource->name,
|
||||
/** @var bool $revoked Whether the API token is revoked */
|
||||
'revoked' => $this->resource->revoked,
|
||||
/** @var array<string> $scopes List of scopes that the API token has */
|
||||
'scopes' => $this->resource->scopes,
|
||||
/** @var string $created_at When the API token was created (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */
|
||||
'created_at' => $this->formatDateTime($this->resource->created_at),
|
||||
/** @var string|null $expires_at At what time the API token expires (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */
|
||||
'expires_at' => $this->formatDateTime($this->resource->expires_at),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\V1\ApiToken;
|
||||
|
||||
use App\Http\Resources\V1\BaseResource;
|
||||
use App\Models\Passport\Token;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* @property-read Token $resource
|
||||
*/
|
||||
class ApiTokenWithAccessTokenResource extends BaseResource
|
||||
{
|
||||
private string $accessToken;
|
||||
|
||||
public function __construct(Token $resource, string $accessToken)
|
||||
{
|
||||
$this->accessToken = $accessToken;
|
||||
parent::__construct($resource);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, string|bool|int|null|array<string>>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
/** @var string $id ID of the API token, this ID is NOT a UUID */
|
||||
'id' => $this->resource->id,
|
||||
/** @var string $name Name of the API token */
|
||||
'name' => $this->resource->name,
|
||||
/** @var bool $revoked Whether the API token is revoked */
|
||||
'revoked' => $this->resource->revoked,
|
||||
/** @var array<string> $scopes List of scopes that the API token has */
|
||||
'scopes' => $this->resource->scopes,
|
||||
/** @var string $created_at When the API token was created (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */
|
||||
'created_at' => $this->formatDateTime($this->resource->created_at),
|
||||
/** @var string|null $expires_at At what time the API token expires (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */
|
||||
'expires_at' => $this->formatDateTime($this->resource->expires_at),
|
||||
// Additional fields
|
||||
/** @var string $access_token Access token that can be used to authenticate requests */
|
||||
'access_token' => $this->accessToken,
|
||||
];
|
||||
}
|
||||
}
|
||||
9
app/Models/Passport/AuthCode.php
Normal file
9
app/Models/Passport/AuthCode.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models\Passport;
|
||||
|
||||
use Laravel\Passport\AuthCode as PassportAuthCode;
|
||||
|
||||
class AuthCode extends PassportAuthCode {}
|
||||
26
app/Models/Passport/Client.php
Normal file
26
app/Models/Passport/Client.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models\Passport;
|
||||
|
||||
use Database\Factories\Passport\ClientFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Laravel\Passport\Client as PassportClient;
|
||||
|
||||
/**
|
||||
* @property string $id
|
||||
* @property string|null $user_id
|
||||
* @property string $name
|
||||
* @property string|null $secret
|
||||
* @property string|null $provider
|
||||
* @property string $redirect
|
||||
* @property bool $personal_access_client
|
||||
* @property bool $password_client
|
||||
* @property bool $revoked
|
||||
*/
|
||||
class Client extends PassportClient
|
||||
{
|
||||
/** @use HasFactory<ClientFactory> */
|
||||
use HasFactory;
|
||||
}
|
||||
9
app/Models/Passport/PersonalAccessClient.php
Normal file
9
app/Models/Passport/PersonalAccessClient.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models\Passport;
|
||||
|
||||
use Laravel\Passport\PersonalAccessClient as PassportPersonalAccessClient;
|
||||
|
||||
class PersonalAccessClient extends PassportPersonalAccessClient {}
|
||||
9
app/Models/Passport/RefreshToken.php
Normal file
9
app/Models/Passport/RefreshToken.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models\Passport;
|
||||
|
||||
use Laravel\Passport\RefreshToken as PassportRefreshToken;
|
||||
|
||||
class RefreshToken extends PassportRefreshToken {}
|
||||
38
app/Models/Passport/Token.php
Normal file
38
app/Models/Passport/Token.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models\Passport;
|
||||
|
||||
use Database\Factories\Passport\TokenFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Laravel\Passport\Token as PassportToken;
|
||||
|
||||
/**
|
||||
* @property string $id
|
||||
* @property null|string $user_id
|
||||
* @property string $client_id
|
||||
* @property null|string $name
|
||||
* @property array<string> $scopes
|
||||
* @property bool $revoked
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
* @property Carbon|null $expires_at
|
||||
*/
|
||||
class Token extends PassportToken
|
||||
{
|
||||
/** @use HasFactory<TokenFactory> */
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* Get the client that the token belongs to.
|
||||
*
|
||||
* @return BelongsTo<Client, Token>
|
||||
*/
|
||||
public function client(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Client::class, 'client_id', 'id');
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ namespace App\Models;
|
||||
use App\Enums\Weekday;
|
||||
use App\Models\Concerns\CustomAuditable;
|
||||
use App\Models\Concerns\HasUuids;
|
||||
use App\Models\Passport\Token;
|
||||
use Database\Factories\UserFactory;
|
||||
use Filament\Models\Contracts\FilamentUser;
|
||||
use Filament\Panel;
|
||||
@@ -27,7 +28,6 @@ use Laravel\Jetstream\HasProfilePhoto;
|
||||
use Laravel\Jetstream\HasTeams;
|
||||
use Laravel\Passport\AuthCode;
|
||||
use Laravel\Passport\HasApiTokens;
|
||||
use Laravel\Passport\Token;
|
||||
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
|
||||
/**
|
||||
@@ -44,6 +44,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
* @property-read Organization|null $currentOrganization
|
||||
* @property-read Organization|null $currentTeam
|
||||
* @property-read string $profile_photo_url
|
||||
* @property-read Collection<int, Token> $tokens
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
* @property string|null $current_team_id
|
||||
@@ -196,6 +197,17 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
|
||||
return $this->hasMany(AuthCode::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the access tokens for the user.
|
||||
*
|
||||
* @return HasMany<Token>
|
||||
*/
|
||||
public function tokens(): HasMany
|
||||
{
|
||||
return $this->hasMany(Token::class, 'user_id')
|
||||
->orderBy('created_at', 'desc');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<User> $builder
|
||||
*/
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Models\FailedJob;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\OrganizationInvitation;
|
||||
use App\Models\Passport\Token;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectMember;
|
||||
use App\Models\Tag;
|
||||
@@ -29,7 +30,6 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
@@ -90,12 +90,6 @@ class AppServiceProvider extends ServiceProvider
|
||||
);
|
||||
});
|
||||
|
||||
if (config('app.force_https', false)) {
|
||||
URL::forceScheme('https');
|
||||
request()->server->set('HTTPS', 'on');
|
||||
request()->headers->set('X-Forwarded-Proto', 'https');
|
||||
}
|
||||
|
||||
$this->app->scoped(PermissionStore::class, function (Application $app): PermissionStore {
|
||||
return new PermissionStore;
|
||||
});
|
||||
@@ -107,5 +101,6 @@ class AppServiceProvider extends ServiceProvider
|
||||
// Routing
|
||||
Route::model('member', Member::class);
|
||||
Route::model('invitation', OrganizationInvitation::class);
|
||||
Route::model('apiToken', Token::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,11 @@ declare(strict_types=1);
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Models\Passport\AuthCode;
|
||||
use App\Models\Passport\Client;
|
||||
use App\Models\Passport\PersonalAccessClient;
|
||||
use App\Models\Passport\RefreshToken;
|
||||
use App\Models\Passport\Token;
|
||||
use App\Policies\OrganizationPolicy;
|
||||
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
||||
use Laravel\Jetstream\Jetstream;
|
||||
@@ -42,6 +47,16 @@ class AuthServiceProvider extends ServiceProvider
|
||||
// 'delete',
|
||||
]);
|
||||
|
||||
Passport::useTokenModel(Token::class);
|
||||
Passport::useRefreshTokenModel(RefreshToken::class);
|
||||
Passport::useAuthCodeModel(AuthCode::class);
|
||||
Passport::useClientModel(Client::class);
|
||||
Passport::usePersonalAccessClientModel(PersonalAccessClient::class);
|
||||
|
||||
// Passport::tokensExpireIn(now()->addDays(15));
|
||||
// Passport::refreshTokensExpireIn(now()->addDays(30));
|
||||
Passport::personalAccessTokensExpireIn(now()->addMonths(12));
|
||||
|
||||
// same as passport default above
|
||||
Jetstream::defaultApiTokenPermissions(['read']);
|
||||
|
||||
|
||||
@@ -69,6 +69,9 @@ class AdminPanelProvider extends PanelProvider
|
||||
NavigationGroup::make()
|
||||
->label('System')
|
||||
->collapsed(),
|
||||
NavigationGroup::make()
|
||||
->label('Auth')
|
||||
->collapsed(),
|
||||
])
|
||||
->middleware([
|
||||
EncryptCookies::class,
|
||||
|
||||
@@ -5,8 +5,11 @@ declare(strict_types=1);
|
||||
namespace App\Service\Import\Importers;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Service\TimezoneService;
|
||||
use Exception;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Override;
|
||||
use Spatie\TemporaryDirectory\TemporaryDirectory;
|
||||
use ValueError;
|
||||
@@ -93,11 +96,22 @@ class TogglDataImporter extends DefaultImporter
|
||||
}
|
||||
|
||||
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([
|
||||
'email' => $workspaceUser->email,
|
||||
], [
|
||||
'name' => $workspaceUser->name,
|
||||
'timezone' => $workspaceUser->timezone ?? 'UTC',
|
||||
'timezone' => $timezone,
|
||||
'is_placeholder' => true,
|
||||
], (string) $workspaceUser->uid);
|
||||
$this->memberImportHelper->getKey([
|
||||
|
||||
@@ -168,7 +168,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'secure' => env('SESSION_SECURE_COOKIE'),
|
||||
'secure' => env('SESSION_SECURE_COOKIE', env('APP_FORCE_HTTPS')),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
55
database/factories/Passport/ClientFactory.php
Normal file
55
database/factories/Passport/ClientFactory.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories\Passport;
|
||||
|
||||
use App\Models\Passport\Client;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<Client>
|
||||
*/
|
||||
class ClientFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->faker->uuid,
|
||||
'user_id' => null,
|
||||
'name' => $this->faker->company(),
|
||||
'secret' => $this->faker->regexify('[A-Za-z]{40}'),
|
||||
'provider' => 'users',
|
||||
'redirect' => $this->faker->url(),
|
||||
'personal_access_client' => false,
|
||||
'password_client' => false,
|
||||
'revoked' => false,
|
||||
'created_at' => $this->faker->dateTime(),
|
||||
'updated_at' => $this->faker->dateTime(),
|
||||
];
|
||||
}
|
||||
|
||||
public function personalAccessClient(): self
|
||||
{
|
||||
return $this->state(function (array $attributes) {
|
||||
return [
|
||||
'personal_access_client' => true,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
public function forUser(User $user): self
|
||||
{
|
||||
return $this->state(function (array $attributes) use ($user): array {
|
||||
return [
|
||||
'user_id' => $user->getKey(),
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
54
database/factories/Passport/TokenFactory.php
Normal file
54
database/factories/Passport/TokenFactory.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories\Passport;
|
||||
|
||||
use App\Models\Passport\Client;
|
||||
use App\Models\Passport\Token;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<Token>
|
||||
*/
|
||||
class TokenFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->faker->uuid,
|
||||
'user_id' => null,
|
||||
'client_id' => $this->faker->uuid,
|
||||
'name' => null,
|
||||
'scopes' => [],
|
||||
'revoked' => false,
|
||||
'created_at' => $this->faker->dateTime,
|
||||
'updated_at' => $this->faker->dateTime,
|
||||
'expires_at' => $this->faker->dateTime,
|
||||
];
|
||||
}
|
||||
|
||||
public function forUser(User $user): self
|
||||
{
|
||||
return $this->state(function (array $attributes) use ($user): array {
|
||||
return [
|
||||
'user_id' => $user->getKey(),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
public function forClient(Client $client): self
|
||||
{
|
||||
return $this->state(function (array $attributes) use ($client): array {
|
||||
return [
|
||||
'client_id' => $client->getKey(),
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,29 @@ class DatabaseSeeder extends Seeder
|
||||
public function run(): void
|
||||
{
|
||||
$this->deleteAll();
|
||||
|
||||
app(ClientRepository::class)->create(
|
||||
null,
|
||||
'desktop',
|
||||
'solidtime://oauth/callback',
|
||||
null,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
);
|
||||
|
||||
$personalAccessClient = new PassportClient;
|
||||
$personalAccessClient->id = config('passport.personal_access_client.id');
|
||||
$personalAccessClient->secret = config('passport.personal_access_client.secret');
|
||||
$personalAccessClient->name = 'API';
|
||||
$personalAccessClient->redirect = 'http://localhost';
|
||||
$personalAccessClient->user_id = null;
|
||||
$personalAccessClient->revoked = false;
|
||||
$personalAccessClient->provider = null;
|
||||
$personalAccessClient->personal_access_client = true;
|
||||
$personalAccessClient->password_client = false;
|
||||
$personalAccessClient->save();
|
||||
|
||||
$userWithMultipleOrganizations = User::factory()->withPersonalOrganization()->create([
|
||||
'name' => 'Mister Overemployed',
|
||||
'email' => 'overemployed@acme.test',
|
||||
@@ -55,6 +78,8 @@ class DatabaseSeeder extends Seeder
|
||||
'name' => 'Acme Manager',
|
||||
'email' => 'test@example.com',
|
||||
]);
|
||||
$userAcmeManager->createToken('Testing Token 1')->accessToken;
|
||||
$userAcmeManager->createToken('Testing Token 2')->accessToken;
|
||||
$userAcmeAdmin = User::factory()->withPersonalOrganization()->create([
|
||||
'name' => 'Acme Admin',
|
||||
'email' => 'admin@acme.test',
|
||||
@@ -159,15 +184,6 @@ class DatabaseSeeder extends Seeder
|
||||
'email' => 'admin@example.com',
|
||||
]);
|
||||
|
||||
app(ClientRepository::class)->create(
|
||||
null,
|
||||
'desktop',
|
||||
'solidtime://oauth/callback',
|
||||
null,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
private function deleteAll(): void
|
||||
|
||||
@@ -182,7 +182,7 @@ COPY --chown=${USER}:${USER} ${DOCKER_FILES_BASE_PATH}deployment/php.ini /lib/ph
|
||||
# && composer clear-cache
|
||||
|
||||
RUN cat .env
|
||||
RUN php artisan env
|
||||
#RUN php artisan env
|
||||
RUN php artisan storage:link
|
||||
|
||||
RUN chmod +x /usr/local/bin/start-container
|
||||
|
||||
@@ -1,25 +1,69 @@
|
||||
import { test, expect } from '../playwright/fixtures';
|
||||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||
import {test, expect} from '../playwright/fixtures';
|
||||
import {PLAYWRIGHT_BASE_URL} from '../playwright/config';
|
||||
|
||||
test('test that user name can be updated', async ({ page }) => {
|
||||
test('test that user name can be updated', async ({page}) => {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
|
||||
await page.getByLabel('Name').fill('NEW NAME');
|
||||
await page.getByLabel('Name', {exact: true} ).fill('NEW NAME');
|
||||
await Promise.all([
|
||||
page.getByRole('button', { name: 'Save' }).first().click(),
|
||||
page.getByRole('button', {name: 'Save'}).first().click(),
|
||||
page.waitForResponse('**/user/profile-information'),
|
||||
]);
|
||||
await page.reload();
|
||||
await expect(page.getByLabel('Name')).toHaveValue('NEW NAME');
|
||||
await expect(page.getByLabel('Name', {exact: true})).toHaveValue('NEW NAME');
|
||||
});
|
||||
|
||||
test.skip('test that user email can be updated', async ({ page }) => {
|
||||
test.skip('test that user email can be updated', async ({page}) => {
|
||||
// this does not work because of email verification currently
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
|
||||
const emailId = Math.round(Math.random() * 10000);
|
||||
await page.getByLabel('Email').fill(`newemail+${emailId}@test.com`);
|
||||
await page.getByRole('button', { name: 'Save' }).first().click();
|
||||
await page.getByRole('button', {name: 'Save'}).first().click();
|
||||
await page.reload();
|
||||
await expect(page.getByLabel('Email')).toHaveValue(
|
||||
`newemail+${emailId}@test.com`
|
||||
);
|
||||
});
|
||||
|
||||
async function createNewApiToken(page) {
|
||||
await page.getByLabel('API Key Name').fill('NEW API KEY');
|
||||
await Promise.all([
|
||||
page.getByRole('button', {name: 'Create API Key'}).click(),
|
||||
page.waitForResponse('**/users/me/api-tokens')
|
||||
]);
|
||||
|
||||
await expect(page.locator('body')).toContainText('API Token created successfully');
|
||||
await page.getByRole('dialog').getByText('Close').click();
|
||||
await expect(page.locator('body')).toContainText('NEW API KEY');
|
||||
}
|
||||
|
||||
test('test that user can create an API key', async ({page}) => {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
|
||||
await createNewApiToken(page);
|
||||
});
|
||||
|
||||
test('test that user can delete an API key', async ({page}) => {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
|
||||
await createNewApiToken(page);
|
||||
page.getByLabel('Delete API Token NEW API KEY').click();
|
||||
await expect(page.getByRole('dialog')).toContainText('Are you sure you would like to delete this API token?');
|
||||
await Promise.all([
|
||||
page.getByRole('dialog').getByRole('button', {name: 'Delete'}).click(),
|
||||
page.waitForResponse('**/users/me/api-tokens')
|
||||
]);
|
||||
await expect(page.locator('body')).not.toContainText('NEW API KEY');
|
||||
});
|
||||
|
||||
|
||||
test('test that user can revoke an API key', async ({page}) => {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
|
||||
await createNewApiToken(page);
|
||||
page.getByLabel('Revoke API Token NEW API KEY').click();
|
||||
await expect(page.getByRole('dialog')).toContainText('Are you sure you would like to revoke this API token?');
|
||||
await Promise.all([
|
||||
page.getByRole('dialog').getByRole('button', {name: 'Revoke'}).click(),
|
||||
page.waitForResponse('**/users/me/api-tokens')
|
||||
]);
|
||||
await expect(page.getByRole('button', {name: 'Revoke'})).toBeHidden();
|
||||
await expect(page.locator('body')).toContainText('NEW API KEY');
|
||||
await expect(page.locator('body')).toContainText('Revoked');
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
|
||||
use App\Exceptions\Api\OrganizationHasNoSubscriptionButMultipleMembersException;
|
||||
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
|
||||
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
|
||||
use App\Exceptions\Api\PersonalAccessClientIsNotConfiguredException;
|
||||
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
|
||||
use App\Exceptions\Api\TimeEntryStillRunningApiException;
|
||||
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
|
||||
@@ -37,6 +38,7 @@ return [
|
||||
OrganizationHasNoSubscriptionButMultipleMembersException::KEY => 'Organization has no subscription but multiple members',
|
||||
PdfRendererIsNotConfiguredException::KEY => 'PDF renderer is not configured',
|
||||
FeatureIsNotAvailableInFreePlanApiException::KEY => 'Feature is not available in free plan',
|
||||
PersonalAccessClientIsNotConfiguredException::KEY => 'Personal access client is not configured',
|
||||
],
|
||||
'unknown_error_in_admin_panel' => 'An unknown error occurred. Please check the logs.',
|
||||
];
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
</source>
|
||||
<php>
|
||||
<env name="APP_ENV" value="testing"/>
|
||||
<env name="APP_FORCE_HTTPS" value="false"/>
|
||||
<env name="TRUSTED_PROXIES" value="0.0.0.0/0,2000:0:0:0:0:0:0:0/3"/>
|
||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||
<env name="CACHE_DRIVER" value="array"/>
|
||||
<env name="DB_CONNECTION" value="pgsql_test"/>
|
||||
@@ -39,5 +41,7 @@
|
||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||
<env name="AUDITING_ENABLED" value="true"/>
|
||||
<env name="NEWSLETTER_URL" value="null"/>
|
||||
<env name="PASSPORT_PERSONAL_ACCESS_CLIENT_ID" value="null"/>
|
||||
<env name="PASSPORT_PERSONAL_ACCESS_CLIENT_SECRET" value="null"/>
|
||||
</php>
|
||||
</phpunit>
|
||||
|
||||
@@ -39,7 +39,7 @@ async function resendInvitation() {
|
||||
await handleApiRequestNotifications(
|
||||
() =>
|
||||
api.resendInvitationEmail(
|
||||
{},
|
||||
undefined,
|
||||
{
|
||||
params: {
|
||||
invitation: props.invitation.id,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useMembersStore } from '@/utils/useMembers';
|
||||
import { UserIcon, ChevronDownIcon } from '@heroicons/vue/24/solid';
|
||||
@@ -46,22 +46,6 @@ const filteredMembers = computed<Member[]>(() => {
|
||||
});
|
||||
});
|
||||
|
||||
watch(filteredMembers, () => {
|
||||
resetHighlightedItem();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
resetHighlightedItem();
|
||||
});
|
||||
|
||||
function resetHighlightedItem() {
|
||||
if (filteredMembers.value.length > 0) {
|
||||
highlightedItemId.value = filteredMembers.value[0].id;
|
||||
}
|
||||
}
|
||||
|
||||
const highlightedItemId = ref<string | null>(null);
|
||||
|
||||
const currentValue = computed(() => {
|
||||
if (model.value) {
|
||||
return members.value.find((member) => member.id === model.value)?.name;
|
||||
|
||||
@@ -32,7 +32,7 @@ async function invitePlaceholder(id: string) {
|
||||
await handleApiRequestNotifications(
|
||||
() =>
|
||||
api.invitePlaceholder(
|
||||
{},
|
||||
undefined,
|
||||
{
|
||||
params: {
|
||||
organization: organizationId,
|
||||
|
||||
332
resources/js/Pages/Profile/Partials/ApiTokensForm.vue
Normal file
332
resources/js/Pages/Profile/Partials/ApiTokensForm.vue
Normal file
@@ -0,0 +1,332 @@
|
||||
<script setup lang="ts">
|
||||
import FormSection from '@/Components/FormSection.vue';
|
||||
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
||||
import {computed, ref} from 'vue';
|
||||
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
|
||||
import {
|
||||
api,
|
||||
type ApiToken,
|
||||
type CreateApiTokenBody
|
||||
} from '@/packages/api/src';
|
||||
import SectionBorder from "@/Components/SectionBorder.vue";
|
||||
import DangerButton from "@/packages/ui/src/Buttons/DangerButton.vue";
|
||||
import TextInput from "../../../packages/ui/src/Input/TextInput.vue";
|
||||
import SecondaryButton from "../../../packages/ui/src/Buttons/SecondaryButton.vue";
|
||||
import DialogModal from "@/packages/ui/src/DialogModal.vue";
|
||||
import InputError from "@/packages/ui/src/Input/InputError.vue";
|
||||
import ActionMessage from "@/Components/ActionMessage.vue";
|
||||
import ConfirmationModal from "@/Components/ConfirmationModal.vue";
|
||||
import ActionSection from "@/Components/ActionSection.vue";
|
||||
import {useForm} from "@inertiajs/vue3";
|
||||
import {useMutation, useQuery, useQueryClient} from "@tanstack/vue-query";
|
||||
import {useNotificationsStore} from "@/utils/notification";
|
||||
import {useClipboard} from "@vueuse/core";
|
||||
import { formatDateTimeLocalized} from "../../../packages/ui/src/utils/time";
|
||||
import {ClockIcon} from "@heroicons/vue/20/solid";
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const apiTokenBeingDeleted = ref<ApiToken | null>(null);
|
||||
const apiTokenBeingRevoked = ref<ApiToken | null>(null);
|
||||
|
||||
const { handleApiRequestNotifications } = useNotificationsStore();
|
||||
const newToken = ref('');
|
||||
|
||||
const { copy, copied, isSupported } = useClipboard();
|
||||
|
||||
async function createApiToken(){
|
||||
await handleApiRequestNotifications(
|
||||
() =>
|
||||
createApiTokenMutation.mutateAsync({
|
||||
name: createApiTokenForm.name,
|
||||
}),
|
||||
'API Token successfully created',
|
||||
'There was an error while creating the API Token',
|
||||
(response) => {
|
||||
createApiTokenForm.name = '';
|
||||
displayingToken.value = true;
|
||||
// @ts-expect-error temporary fix until openapi docs type is fixed
|
||||
newToken.value = response.data.access_token;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const createApiTokenForm = useForm({
|
||||
name: '',
|
||||
});
|
||||
|
||||
function confirmApiTokenDeletion (token: ApiToken) {
|
||||
apiTokenBeingDeleted.value = token;
|
||||
}
|
||||
|
||||
function confirmApiTokenRevocation(token: ApiToken){
|
||||
apiTokenBeingRevoked.value = token;
|
||||
}
|
||||
|
||||
const displayingToken = ref(false);
|
||||
|
||||
async function deleteApiToken () {
|
||||
if(apiTokenBeingDeleted.value){
|
||||
await handleApiRequestNotifications(
|
||||
() =>
|
||||
deleteApiTokenMutation.mutateAsync(apiTokenBeingDeleted.value!.id),
|
||||
'API Token successfully deleted',
|
||||
'There was an error while deleting the API Token',
|
||||
() => {
|
||||
apiTokenBeingDeleted.value = null;
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
async function revokeApiToken () {
|
||||
if(apiTokenBeingRevoked.value){
|
||||
await handleApiRequestNotifications(
|
||||
() =>
|
||||
revokeApiTokenMutation.mutateAsync(apiTokenBeingRevoked.value!.id),
|
||||
'API Token successfully revoked',
|
||||
'There was an error while revoking the API Token',
|
||||
() => {
|
||||
apiTokenBeingRevoked.value = null;
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const { data: sharedReportResponseData } = useQuery({
|
||||
queryKey: ['api-tokens'],
|
||||
queryFn: () =>
|
||||
api.getApiTokens(),
|
||||
});
|
||||
|
||||
const tokens = computed(() => {
|
||||
return sharedReportResponseData.value?.data ?? [];
|
||||
})
|
||||
|
||||
const createApiTokenMutation = useMutation({
|
||||
mutationFn: async (apiToken: CreateApiTokenBody) => {
|
||||
return await api.createApiToken(apiToken);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['api-tokens'] })
|
||||
},
|
||||
});
|
||||
|
||||
const deleteApiTokenMutation = useMutation({
|
||||
mutationFn: async (apiTokenId: string) => {
|
||||
return await api.deleteApiToken(undefined, {
|
||||
params: {
|
||||
apiTokenId: apiTokenId,
|
||||
},
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['api-tokens'] })
|
||||
},
|
||||
});
|
||||
|
||||
const revokeApiTokenMutation = useMutation({
|
||||
mutationFn: async (apiTokenId: string) => {
|
||||
return await api.revokeApiToken(undefined, {
|
||||
params: {
|
||||
apiTokenId: apiTokenId,
|
||||
},
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['api-tokens'] })
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- Generate API Token -->
|
||||
<FormSection @submitted="createApiToken">
|
||||
<template #title> Create API Token </template>
|
||||
|
||||
<template #description>
|
||||
API tokens allow third-party services to authenticate with our
|
||||
application on your behalf.
|
||||
</template>
|
||||
|
||||
<template #form>
|
||||
<!-- Token Name -->
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="api_key_name" value="API Key Name" />
|
||||
<TextInput
|
||||
id="api_key_name"
|
||||
v-model="createApiTokenForm.name"
|
||||
type="text"
|
||||
class="mt-1 block w-full" />
|
||||
<InputError
|
||||
:message="createApiTokenForm.errors.name"
|
||||
class="mt-2" />
|
||||
<div class="text-text-tertiary text-sm pt-3 flex space-x-1.5 font-medium items-center">
|
||||
<ClockIcon class="w-4"></ClockIcon>
|
||||
<span>
|
||||
API Tokens are valid for 1 year
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<ActionMessage
|
||||
:on="createApiTokenForm.recentlySuccessful"
|
||||
class="me-3">
|
||||
Created.
|
||||
</ActionMessage>
|
||||
|
||||
<PrimaryButton
|
||||
:class="{ 'opacity-25': createApiTokenForm.processing }"
|
||||
:disabled="createApiTokenForm.processing">
|
||||
Create API Key
|
||||
</PrimaryButton>
|
||||
</template>
|
||||
</FormSection>
|
||||
|
||||
<div v-if="tokens.length > 0">
|
||||
<SectionBorder />
|
||||
|
||||
<!-- Manage API Tokens -->
|
||||
<div class="mt-10 sm:mt-0">
|
||||
<ActionSection>
|
||||
<template #title> Manage API Tokens </template>
|
||||
|
||||
<template #description>
|
||||
You may delete or revoke any of your existing tokens if they are
|
||||
no longer needed.
|
||||
</template>
|
||||
|
||||
<!-- API Token List -->
|
||||
<template #content>
|
||||
<div class="divide-border-secondary divide-y">
|
||||
<div
|
||||
v-for="token in tokens"
|
||||
:key="token.id"
|
||||
class="flex items-center py-2.5 justify-between">
|
||||
<div class="break-all text-white">
|
||||
<div>{{ token.name }}</div>
|
||||
<div class="text-sm text-text-tertiary space-x-3">
|
||||
<span v-if="token.created_at">
|
||||
Created at {{ formatDateTimeLocalized(token.created_at) }}
|
||||
</span>
|
||||
<span v-if="token.expires_at">
|
||||
Expires at {{ formatDateTimeLocalized(token.expires_at) }}
|
||||
</span>
|
||||
<span v-if="token.revoked">
|
||||
Revoked
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center ms-2">
|
||||
<div
|
||||
v-if="token.last_used_ago"
|
||||
class="text-sm text-gray-400">
|
||||
Last used {{ token.last_used_ago }}
|
||||
</div>
|
||||
<button
|
||||
v-if="!token.revoked"
|
||||
class="cursor-pointer ms-6 text-sm text-text-secondary"
|
||||
:aria-label="'Revoke API Token ' + token.name"
|
||||
@click="confirmApiTokenRevocation(token)">
|
||||
Revoke
|
||||
</button>
|
||||
<button
|
||||
class="cursor-pointer ms-6 text-sm text-red-500"
|
||||
:aria-label="'Delete API Token ' + token.name"
|
||||
@click="confirmApiTokenDeletion(token)">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ActionSection>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Token Value Modal -->
|
||||
<DialogModal :show="displayingToken" @close="displayingToken = false">
|
||||
<template #title> API Token created successfully </template>
|
||||
|
||||
<template #content>
|
||||
<div>
|
||||
Please copy your new API token. For your security, it won't
|
||||
be shown again.
|
||||
<strong>This token is valid for one year</strong> unless you revoke it.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 pt-6 w-full">
|
||||
<TextInput v-if="newToken" disabled :model-value="newToken" class="flex-1 text-gray-500"></TextInput>
|
||||
<PrimaryButton v-if="isSupported" @click="copy(newToken)">{{ copied ? 'Copied!' : 'Copy Token' }}</PrimaryButton>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<SecondaryButton @click="displayingToken = false">
|
||||
Close
|
||||
</SecondaryButton>
|
||||
</template>
|
||||
</DialogModal>
|
||||
|
||||
<!-- Delete Token Confirmation Modal -->
|
||||
<ConfirmationModal
|
||||
:show="apiTokenBeingDeleted != null"
|
||||
@close="apiTokenBeingDeleted = null">
|
||||
<template #title> Delete API Token </template>
|
||||
|
||||
<template #content>
|
||||
Are you sure you would like to delete this API token?
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<SecondaryButton @click="apiTokenBeingDeleted = null">
|
||||
Cancel
|
||||
</SecondaryButton>
|
||||
|
||||
<DangerButton
|
||||
class="ms-3"
|
||||
:class="{ 'opacity-25': createApiTokenMutation.isPending.value }"
|
||||
:disabled="createApiTokenMutation.isPending.value"
|
||||
@click="deleteApiToken">
|
||||
Delete
|
||||
</DangerButton>
|
||||
</template>
|
||||
</ConfirmationModal>
|
||||
|
||||
<ConfirmationModal
|
||||
:show="apiTokenBeingRevoked != null"
|
||||
@close="apiTokenBeingRevoked = null">
|
||||
<template #title> Revoke API Token </template>
|
||||
|
||||
<template #content>
|
||||
Are you sure you would like to revoke this API token?
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<SecondaryButton @click="apiTokenBeingRevoked = null">
|
||||
Cancel
|
||||
</SecondaryButton>
|
||||
|
||||
<DangerButton
|
||||
class="ms-3"
|
||||
:class="{ 'opacity-25': revokeApiTokenMutation.isPending.value }"
|
||||
:disabled="revokeApiTokenMutation.isPending.value"
|
||||
@click="revokeApiToken">
|
||||
Revoke
|
||||
</DangerButton>
|
||||
</template>
|
||||
</ConfirmationModal>
|
||||
</div>
|
||||
</template>
|
||||
@@ -9,6 +9,7 @@ import UpdateProfileInformationForm from '@/Pages/Profile/Partials/UpdateProfile
|
||||
import { usePage } from '@inertiajs/vue3';
|
||||
import type { User } from '@/types/models';
|
||||
import type { Session } from '@/types/jetstream';
|
||||
import ApiTokensForm from "@/Pages/Profile/Partials/ApiTokensForm.vue";
|
||||
|
||||
defineProps<{
|
||||
confirmsTwoFactorAuthentication: boolean;
|
||||
@@ -65,6 +66,9 @@ const page = usePage<{
|
||||
<LogoutOtherBrowserSessionsForm
|
||||
:sessions="sessions"
|
||||
class="mt-10 sm:mt-0" />
|
||||
<SectionBorder />
|
||||
|
||||
<ApiTokensForm></ApiTokensForm>
|
||||
|
||||
<template
|
||||
v-if="page.props.jetstream.hasAccountDeletionFeatures">
|
||||
|
||||
@@ -29,7 +29,7 @@ async function exportData() {
|
||||
const response = await handleApiRequestNotifications(
|
||||
() =>
|
||||
api.exportOrganization(
|
||||
{},
|
||||
undefined,
|
||||
{
|
||||
params: {
|
||||
organization: organizationId,
|
||||
|
||||
@@ -162,6 +162,15 @@ export type UpdateReportBody = ZodiosBodyByAlias<SolidTimeApi, 'updateReport'>;
|
||||
export type CreateReportBodyProperties = CreateReportBody['properties'];
|
||||
export type Report = ReportIndexResponse['data'][0];
|
||||
|
||||
export type ApiTokenIndexResponse = ZodiosResponseByAlias<
|
||||
SolidTimeApi,
|
||||
'getApiTokens'
|
||||
>;
|
||||
|
||||
export type CreateApiTokenBody = ZodiosBodyByAlias<SolidTimeApi, 'createApiToken'>;
|
||||
export type ApiToken = ApiTokenIndexResponse['data'][0];
|
||||
|
||||
|
||||
const api = createApiClient('/api', { validate: 'none' });
|
||||
|
||||
export { createApiClient, api };
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
import { makeApi, Zodios, type ZodiosOptions } from '@zodios/core';
|
||||
import { z } from 'zod';
|
||||
|
||||
const ApiTokenResource = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
revoked: z.string(),
|
||||
scopes: z.string(),
|
||||
created_at: z.union([z.string(), z.null()]),
|
||||
expires_at: z.union([z.string(), z.null()]),
|
||||
})
|
||||
.passthrough();
|
||||
const ApiTokenCollection = z.array(ApiTokenResource);
|
||||
const ApiTokenStoreRequest = z
|
||||
.object({ name: z.string().min(1).max(255) })
|
||||
.passthrough();
|
||||
const ApiTokenWithAccessTokenResource = z.string();
|
||||
const ClientResource = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
@@ -26,9 +41,11 @@ const ImportRequest = z
|
||||
const InvitationResource = z
|
||||
.object({ id: z.string(), email: z.string(), role: z.string() })
|
||||
.passthrough();
|
||||
const Role = z.enum(['owner', 'admin', 'manager', 'employee', 'placeholder']);
|
||||
const InvitationStoreRequest = z
|
||||
.object({ email: z.string().email(), role: Role })
|
||||
.object({
|
||||
email: z.string().email(),
|
||||
role: z.enum(['admin', 'manager', 'employee']),
|
||||
})
|
||||
.passthrough();
|
||||
const MemberResource = z
|
||||
.object({
|
||||
@@ -41,6 +58,7 @@ const MemberResource = z
|
||||
billable_rate: z.union([z.number(), z.null()]),
|
||||
})
|
||||
.passthrough();
|
||||
const Role = z.enum(['owner', 'admin', 'manager', 'employee', 'placeholder']);
|
||||
const MemberUpdateRequest = z
|
||||
.object({ role: Role, billable_rate: z.union([z.number(), z.null()]) })
|
||||
.partial()
|
||||
@@ -190,13 +208,6 @@ const ReportStoreRequest = z
|
||||
timezone: z.union([z.string(), z.null()]).optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
'properties.member_ids': z.string().optional(),
|
||||
'properties.client_ids': z.string().optional(),
|
||||
'properties.project_ids': z.string().optional(),
|
||||
'properties.tag_ids': z.string().optional(),
|
||||
'properties.task_ids': z.string().optional(),
|
||||
'properties.week_start': z.string().optional(),
|
||||
'properties.timezone': z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
const DetailedReportResource = z
|
||||
@@ -459,18 +470,21 @@ const PersonalMembershipResource = z
|
||||
role: z.string(),
|
||||
})
|
||||
.passthrough();
|
||||
const PersonalMembershipCollection = z.array(PersonalMembershipResource);
|
||||
|
||||
export const schemas = {
|
||||
ApiTokenResource,
|
||||
ApiTokenCollection,
|
||||
ApiTokenStoreRequest,
|
||||
ApiTokenWithAccessTokenResource,
|
||||
ClientResource,
|
||||
ClientCollection,
|
||||
ClientStoreRequest,
|
||||
ClientUpdateRequest,
|
||||
ImportRequest,
|
||||
InvitationResource,
|
||||
Role,
|
||||
InvitationStoreRequest,
|
||||
MemberResource,
|
||||
Role,
|
||||
MemberUpdateRequest,
|
||||
OrganizationResource,
|
||||
OrganizationUpdateRequest,
|
||||
@@ -502,7 +516,6 @@ export const schemas = {
|
||||
TimeEntryUpdateRequest,
|
||||
UserResource,
|
||||
PersonalMembershipResource,
|
||||
PersonalMembershipCollection,
|
||||
};
|
||||
|
||||
const endpoints = makeApi([
|
||||
@@ -786,11 +799,6 @@ const endpoints = makeApi([
|
||||
alias: 'exportOrganization',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'body',
|
||||
type: 'Body',
|
||||
schema: z.object({}).partial().passthrough(),
|
||||
},
|
||||
{
|
||||
name: 'organization',
|
||||
type: 'Path',
|
||||
@@ -1122,11 +1130,6 @@ const endpoints = makeApi([
|
||||
alias: 'resendInvitationEmail',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'body',
|
||||
type: 'Body',
|
||||
schema: z.object({}).partial().passthrough(),
|
||||
},
|
||||
{
|
||||
name: 'organization',
|
||||
type: 'Path',
|
||||
@@ -1345,11 +1348,6 @@ const endpoints = makeApi([
|
||||
alias: 'invitePlaceholder',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'body',
|
||||
type: 'Body',
|
||||
schema: z.object({}).partial().passthrough(),
|
||||
},
|
||||
{
|
||||
name: 'organization',
|
||||
type: 'Path',
|
||||
@@ -1397,11 +1395,6 @@ const endpoints = makeApi([
|
||||
alias: 'v1.members.make-placeholder',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'body',
|
||||
type: 'Body',
|
||||
schema: z.object({}).partial().passthrough(),
|
||||
},
|
||||
{
|
||||
name: 'organization',
|
||||
type: 'Path',
|
||||
@@ -2614,6 +2607,11 @@ Users with the permission `time-entries:view:own` can only use this en
|
||||
type: 'Query',
|
||||
schema: z.enum(['true', 'false']).optional(),
|
||||
},
|
||||
{
|
||||
name: 'user_id',
|
||||
type: 'Query',
|
||||
schema: z.string().optional(),
|
||||
},
|
||||
{
|
||||
name: 'member_ids',
|
||||
type: 'Query',
|
||||
@@ -2639,11 +2637,6 @@ Users with the permission `time-entries:view:own` can only use this en
|
||||
type: 'Query',
|
||||
schema: z.array(z.string()).min(1).optional(),
|
||||
},
|
||||
{
|
||||
name: 'user_id',
|
||||
type: 'Query',
|
||||
schema: z.string().optional(),
|
||||
},
|
||||
],
|
||||
response: z
|
||||
.object({
|
||||
@@ -3438,6 +3431,120 @@ The report is considered public if the `is_public` field is set to &#x
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'get',
|
||||
path: '/v1/users/me/api-tokens',
|
||||
alias: 'getApiTokens',
|
||||
description: `This endpoint is independent of organization.`,
|
||||
requestFormat: 'json',
|
||||
response: z.object({ data: ApiTokenCollection }).passthrough(),
|
||||
errors: [
|
||||
{
|
||||
status: 401,
|
||||
description: `Unauthenticated`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
description: `Authorization error`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'post',
|
||||
path: '/v1/users/me/api-tokens',
|
||||
alias: 'createApiToken',
|
||||
description: `The response will contain the access token that can be used to send authenticated API requests.
|
||||
Please note that the access token is only shown in this response and cannot be retrieved later.`,
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'body',
|
||||
type: 'Body',
|
||||
schema: z
|
||||
.object({ name: z.string().min(1).max(255) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
response: z
|
||||
.object({ data: ApiTokenWithAccessTokenResource })
|
||||
.passthrough(),
|
||||
errors: [
|
||||
{
|
||||
status: 401,
|
||||
description: `Unauthenticated`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
description: `Authorization error`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'delete',
|
||||
path: '/v1/users/me/api-tokens/:apiTokenId',
|
||||
alias: 'deleteApiToken',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'apiTokenId',
|
||||
type: 'Path',
|
||||
schema: z.string(),
|
||||
},
|
||||
],
|
||||
response: z.null(),
|
||||
errors: [
|
||||
{
|
||||
status: 401,
|
||||
description: `Unauthenticated`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
description: `Authorization error`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'post',
|
||||
path: '/v1/users/me/api-tokens/:apiTokenId/revoke',
|
||||
alias: 'revokeApiToken',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'apiTokenId',
|
||||
type: 'Path',
|
||||
schema: z.string(),
|
||||
},
|
||||
],
|
||||
response: z.null(),
|
||||
errors: [
|
||||
{
|
||||
status: 401,
|
||||
description: `Unauthenticated`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
description: `Authorization error`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'get',
|
||||
path: '/v1/users/me/memberships',
|
||||
@@ -3445,7 +3552,7 @@ The report is considered public if the `is_public` field is set to &#x
|
||||
description: `This endpoint is independent of organization.`,
|
||||
requestFormat: 'json',
|
||||
response: z
|
||||
.object({ data: PersonalMembershipCollection })
|
||||
.object({ data: z.array(PersonalMembershipResource) })
|
||||
.passthrough(),
|
||||
errors: [
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@ import { twMerge } from 'tailwind-merge';
|
||||
|
||||
const props = defineProps<{
|
||||
class?: string;
|
||||
tabindex?: string;
|
||||
}>();
|
||||
|
||||
// This has to be a localized timestamp, not UTC
|
||||
@@ -50,6 +51,7 @@ const emit = defineEmits(['changed']);
|
||||
<input
|
||||
id="start"
|
||||
ref="datePicker"
|
||||
:tabindex="tabindex"
|
||||
:class="
|
||||
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',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts" generic="T">
|
||||
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import {computed, nextTick, onMounted, ref, watch} from 'vue';
|
||||
import SelectDropdownItem from '@/packages/ui/src/Input/SelectDropdownItem.vue';
|
||||
import { onKeyStroke } from '@vueuse/core';
|
||||
import { type Placement } from '@floating-ui/vue';
|
||||
@@ -43,10 +43,22 @@ const filteredItems = computed<T[]>(() => {
|
||||
const highlightedItemId = ref<string | null>(model.value);
|
||||
|
||||
watch(model, () => {
|
||||
highlightedItemId.value = model.value;
|
||||
if(model.value){
|
||||
highlightedItemId.value = model.value;
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (!highlightedItemId.value) {
|
||||
resetHightlightedItem();
|
||||
}
|
||||
});
|
||||
|
||||
watch(filteredItems, () => {
|
||||
resetHightlightedItem();
|
||||
});
|
||||
|
||||
function resetHightlightedItem(){
|
||||
if (
|
||||
filteredItems.value.length > 0 &&
|
||||
filteredItems.value.find(
|
||||
@@ -55,7 +67,7 @@ watch(filteredItems, () => {
|
||||
) {
|
||||
highlightedItemId.value = props.getKeyFromItem(filteredItems.value[0]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
watch(highlightedItemId, () => {
|
||||
if (highlightedItemId.value) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import TagCreateModal from '@/packages/ui/src/Tag/TagCreateModal.vue';
|
||||
import MultiselectDropdownItem from '@/packages/ui/src/Input/MultiselectDropdownItem.vue';
|
||||
import type { Tag } from '@/packages/api/src';
|
||||
import type { Placement } from '@floating-ui/vue';
|
||||
import {UseFocusTrap} from "@vueuse/integrations/useFocusTrap/component";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -177,46 +178,50 @@ const showCreateTagModal = ref(false);
|
||||
<slot name="trigger"></slot>
|
||||
</template>
|
||||
<template #content>
|
||||
<input
|
||||
ref="searchInput"
|
||||
:value="searchValue"
|
||||
data-testid="tag_dropdown_search"
|
||||
class="bg-card-background border-0 placeholder-muted text-sm text-white py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
|
||||
placeholder="Search for a Tag..."
|
||||
@input="updateSearchValue"
|
||||
@keydown.enter="addTagIfNoneExists"
|
||||
@keydown.up.prevent="moveHighlightUp"
|
||||
@keydown.down.prevent="moveHighlightDown" />
|
||||
<div ref="dropdownViewport" class="w-60 max-h-60 overflow-y-scroll">
|
||||
<div
|
||||
v-for="tag in filteredTags"
|
||||
:key="tag.id"
|
||||
role="option"
|
||||
:value="tag.id"
|
||||
:class="{
|
||||
<UseFocusTrap
|
||||
v-if="open"
|
||||
:options="{ immediate: true, allowOutsideClick: true }">
|
||||
<input
|
||||
ref="searchInput"
|
||||
:value="searchValue"
|
||||
data-testid="tag_dropdown_search"
|
||||
class="bg-card-background border-0 placeholder-muted text-sm text-white py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
|
||||
placeholder="Search for a Tag..."
|
||||
@input="updateSearchValue"
|
||||
@keydown.enter="addTagIfNoneExists"
|
||||
@keydown.up.prevent="moveHighlightUp"
|
||||
@keydown.down.prevent="moveHighlightDown" />
|
||||
<div ref="dropdownViewport" class="w-60 max-h-60 overflow-y-scroll">
|
||||
<div
|
||||
v-for="tag in filteredTags"
|
||||
:key="tag.id"
|
||||
role="option"
|
||||
:value="tag.id"
|
||||
:class="{
|
||||
'bg-card-background-active':
|
||||
tag.id === highlightedItemId,
|
||||
}"
|
||||
data-testid="tag_dropdown_entries"
|
||||
:data-tag-id="tag.id">
|
||||
<MultiselectDropdownItem
|
||||
:selected="isTagSelected(tag.id)"
|
||||
:name="tag.name"
|
||||
@click="toggleTag(tag.id)"></MultiselectDropdownItem>
|
||||
data-testid="tag_dropdown_entries"
|
||||
:data-tag-id="tag.id">
|
||||
<MultiselectDropdownItem
|
||||
:selected="isTagSelected(tag.id)"
|
||||
:name="tag.name"
|
||||
@click="toggleTag(tag.id)"></MultiselectDropdownItem>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hover:bg-card-background-active rounded-b-lg">
|
||||
<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"
|
||||
@click="
|
||||
<div class="hover:bg-card-background-active rounded-b-lg">
|
||||
<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"
|
||||
@click="
|
||||
open = false;
|
||||
showCreateTagModal = true;
|
||||
">
|
||||
<PlusCircleIcon
|
||||
class="w-5 flex-shrink-0 text-icon-default"></PlusCircleIcon>
|
||||
<span>Create new Tag</span>
|
||||
</button>
|
||||
</div>
|
||||
<PlusCircleIcon
|
||||
class="w-5 flex-shrink-0 text-icon-default"></PlusCircleIcon>
|
||||
<span>Create new Tag</span>
|
||||
</button>
|
||||
</div>
|
||||
</UseFocusTrap>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</template>
|
||||
|
||||
@@ -129,7 +129,6 @@ function onSelectChange(event: Event) {
|
||||
:project="timeEntry.project_id"
|
||||
:enable-estimated-time
|
||||
:currency="currency"
|
||||
class="border border-border-primary"
|
||||
:task="
|
||||
timeEntry.task_id
|
||||
"
|
||||
|
||||
@@ -18,7 +18,6 @@ import type {
|
||||
Client,
|
||||
CreateTimeEntryBody,
|
||||
} from '@/packages/api/src';
|
||||
import TimePicker from '@/packages/ui/src/Input/TimePicker.vue';
|
||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||
import { canCreateProjects } from '@/utils/permissions';
|
||||
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 type { Tag, Task } from '@/packages/api/src';
|
||||
import TimePickerSimple from "@/packages/ui/src/Input/TimePickerSimple.vue";
|
||||
|
||||
const show = defineModel('show', { default: false });
|
||||
const saving = ref(false);
|
||||
@@ -175,7 +175,6 @@ type BillableOption = {
|
||||
size="xlarge">
|
||||
<TagIcon
|
||||
v-if="timeEntry.tags.length === 0"
|
||||
tag="button"
|
||||
class="w-4"></TagIcon>
|
||||
<div
|
||||
v-else
|
||||
@@ -245,30 +244,34 @@ type BillableOption = {
|
||||
<div class="">
|
||||
<InputLabel>Start</InputLabel>
|
||||
<div class="flex flex-col items-center space-y-2 mt-1">
|
||||
<TimePicker
|
||||
<TimePickerSimple
|
||||
|
||||
v-model="localStart"
|
||||
size="large"></TimePicker>
|
||||
size="large"></TimePickerSimple>
|
||||
<DatePicker
|
||||
v-model="localStart"
|
||||
tabindex="1"
|
||||
class="text-xs text-text-tertiary max-w-28 px-1.5 py-1.5"></DatePicker>
|
||||
</div>
|
||||
</div>
|
||||
<div class="">
|
||||
<InputLabel>End</InputLabel>
|
||||
<div class="flex flex-col items-center space-y-2 mt-1">
|
||||
<TimePicker
|
||||
<TimePickerSimple
|
||||
v-model="localEnd"
|
||||
size="large"></TimePicker>
|
||||
size="large"></TimePickerSimple>
|
||||
<DatePicker
|
||||
v-model="localEnd"
|
||||
tabindex="1"
|
||||
class="text-xs text-text-tertiary max-w-28 px-1.5 py-1.5"></DatePicker>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<SecondaryButton @click="show = false"> Cancel</SecondaryButton>
|
||||
<SecondaryButton tabindex="2" @click="show = false"> Cancel</SecondaryButton>
|
||||
<PrimaryButton
|
||||
tabindex="2"
|
||||
class="ms-3"
|
||||
:class="{ 'opacity-25': saving }"
|
||||
:disabled="saving"
|
||||
|
||||
@@ -119,7 +119,6 @@ function onSelectChange(event: Event) {
|
||||
:show-badge-border="false"
|
||||
:project="timeEntry.project_id"
|
||||
:currency="currency"
|
||||
class="border border-border-primary"
|
||||
:enable-estimated-time
|
||||
:task="
|
||||
timeEntry.task_id
|
||||
|
||||
@@ -103,10 +103,14 @@ function setBillableDefaultForProject() {
|
||||
}
|
||||
}
|
||||
|
||||
const blockRefocus = ref(false);
|
||||
|
||||
function onToggleButtonPress(newState: boolean) {
|
||||
if (newState) {
|
||||
emit('startTimer');
|
||||
currentTimeEntryDescriptionInput.value?.focus();
|
||||
if (!blockRefocus.value){
|
||||
currentTimeEntryDescriptionInput.value?.focus();
|
||||
}
|
||||
} else {
|
||||
emit('stopTimer');
|
||||
}
|
||||
@@ -129,11 +133,27 @@ function updateTimeEntryDescription() {
|
||||
|
||||
const {timeEntries} = storeToRefs(useTimeEntriesStore());
|
||||
const filteredRecentlyTrackedTimeEntries = computed(() => {
|
||||
return timeEntries.value.filter((item) => {
|
||||
// do not include running time entries
|
||||
const finishedTimeEntries = timeEntries.value.filter((item) => item.end !== null);
|
||||
|
||||
// filter out duplicates based on description, task, project, tags and billable
|
||||
const nonDuplicateTimeEntries = finishedTimeEntries.filter((item, index, self) => {
|
||||
return index === self.findIndex((t) => (
|
||||
t.description === item.description &&
|
||||
t.task_id === item.task_id &&
|
||||
t.project_id === item.project_id &&
|
||||
t.tags.length === item.tags.length &&
|
||||
t.tags.every((tag) => item.tags.includes(tag)) &&
|
||||
t.billable === item.billable
|
||||
));
|
||||
});
|
||||
|
||||
// filter time entries based on current description
|
||||
return nonDuplicateTimeEntries.filter((item) => {
|
||||
return item.description
|
||||
?.toLowerCase()
|
||||
?.includes(tempDescription.value?.toLowerCase()?.trim() || '');
|
||||
}).slice(0, 5);;
|
||||
}).slice(0, 5);
|
||||
});
|
||||
|
||||
const showDropdown = ref(false);
|
||||
@@ -143,6 +163,14 @@ watch(focused, (focused) => {
|
||||
nextTick(() => {
|
||||
// make sure the click event on the dropdown does not get interrupted
|
||||
showDropdown.value = focused
|
||||
|
||||
// make sure that the input does not get refocused after the dropdown is closed
|
||||
if(!focused){
|
||||
blockRefocus.value = true;
|
||||
setTimeout(() => {
|
||||
blockRefocus.value = false;
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -30,13 +30,14 @@ const task = computed(() => {
|
||||
tabindex="-1"
|
||||
:data-select-id="timeEntry.id"
|
||||
:class="twMerge('px-2 py-1.5 flex justify-between items-center space-x-2 w-full rounded', props.highlighted && 'bg-card-background-active')">
|
||||
<span class="text-sm font-medium">
|
||||
<span v-if="timeEntry.description !== ''" class="text-sm font-medium">
|
||||
{{
|
||||
timeEntry.description !== ''
|
||||
? timeEntry.description
|
||||
: 'No Description'
|
||||
timeEntry.description
|
||||
}}
|
||||
</span>
|
||||
<span v-else class="text-sm text-text-tertiary font-medium">
|
||||
No Description
|
||||
</span>
|
||||
<ProjectBadge
|
||||
ref="projectDropdownTrigger"
|
||||
:color="project?.color"
|
||||
|
||||
@@ -99,6 +99,10 @@ export function formatDateLocalized(date: string): string {
|
||||
return getLocalizedDayJs(date).format('DD.MM.YYYY');
|
||||
}
|
||||
|
||||
export function formatDateTimeLocalized(date: string): string {
|
||||
return getLocalizedDayJs(date).format('DD.MM.YYYY HH:mm');
|
||||
}
|
||||
|
||||
export function formatWeek(date: string | null): string {
|
||||
return 'Week ' + getDayJsInstance()(date).week();
|
||||
}
|
||||
|
||||
16
resources/testfiles/toggl_data_import_test_2/clients.json
Normal file
16
resources/testfiles/toggl_data_import_test_2/clients.json
Normal 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
|
||||
}
|
||||
]
|
||||
86
resources/testfiles/toggl_data_import_test_2/projects.json
Normal file
86
resources/testfiles/toggl_data_import_test_2/projects.json
Normal 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
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
14
resources/testfiles/toggl_data_import_test_2/tags.json
Normal file
14
resources/testfiles/toggl_data_import_test_2/tags.json
Normal 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
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
24
resources/testfiles/toggl_data_import_test_2/tasks/402.json
Normal file
24
resources/testfiles/toggl_data_import_test_2/tasks/402.json
Normal 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
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Http\Controllers\Api\V1\ApiTokenController;
|
||||
use App\Http\Controllers\Api\V1\ClientController;
|
||||
use App\Http\Controllers\Api\V1\ExportController;
|
||||
use App\Http\Controllers\Api\V1\ImportController;
|
||||
@@ -57,6 +58,14 @@ Route::prefix('v1')->name('v1.')->group(static function (): void {
|
||||
Route::get('/users/me', [UserController::class, 'me'])->name('me');
|
||||
});
|
||||
|
||||
// Api token routes
|
||||
Route::name('api-tokens.')->group(static function (): void {
|
||||
Route::get('/users/me/api-tokens', [ApiTokenController::class, 'index'])->name('index');
|
||||
Route::post('/users/me/api-tokens', [ApiTokenController::class, 'store'])->name('store');
|
||||
Route::post('/users/me/api-tokens/{apiToken}/revoke', [ApiTokenController::class, 'revoke'])->name('revoke');
|
||||
Route::delete('/users/me/api-tokens/{apiToken}', [ApiTokenController::class, 'destroy'])->name('destroy');
|
||||
});
|
||||
|
||||
// User Member routes
|
||||
Route::name('users.memberships.')->group(static function (): void {
|
||||
Route::get('/users/me/memberships', [UserMembershipController::class, 'myMemberships'])->name('my-memberships');
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace Tests\Unit\Database;
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SeederTest extends TestCase
|
||||
@@ -13,6 +14,7 @@ class SeederTest extends TestCase
|
||||
|
||||
public function test_running_the_seeder_multiple_times_runs_successfully(): void
|
||||
{
|
||||
$this->setupForSeeder();
|
||||
$this->artisan('db:seed')
|
||||
->assertSuccessful();
|
||||
$this->artisan('db:seed')
|
||||
@@ -21,9 +23,16 @@ class SeederTest extends TestCase
|
||||
|
||||
public function test_fresh_migration_with_seeder_and_rollback_runs_successfully(): void
|
||||
{
|
||||
$this->setupForSeeder();
|
||||
$this->artisan('db:seed')
|
||||
->assertSuccessful();
|
||||
$this->artisan('migrate:rollback')
|
||||
->assertSuccessful();
|
||||
}
|
||||
|
||||
private function setupForSeeder(): void
|
||||
{
|
||||
Config::set('passport.personal_access_client.id', '9e27f54d-5dfb-4dde-99d7-834518236c92');
|
||||
Config::set('passport.personal_access_client.secret', 'EL5mXp3aF8ITjcwoOXRpbSK7zGrWhW4zTDpQXTkf');
|
||||
}
|
||||
}
|
||||
|
||||
312
tests/Unit/Endpoint/Api/V1/ApiTokenEndpointTest.php
Normal file
312
tests/Unit/Endpoint/Api/V1/ApiTokenEndpointTest.php
Normal file
@@ -0,0 +1,312 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Endpoint\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Api\V1\ApiTokenController;
|
||||
use App\Models\Passport\Client;
|
||||
use App\Models\Passport\Token;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Laravel\Passport\ClientRepository;
|
||||
use Laravel\Passport\Passport;
|
||||
use PHPUnit\Framework\Attributes\UsesClass;
|
||||
|
||||
#[UsesClass(ApiTokenController::class)]
|
||||
class ApiTokenEndpointTest extends ApiEndpointTestAbstract
|
||||
{
|
||||
public function test_index_endpoint_returns_list_api_tokens(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([]);
|
||||
$personalAccessClient = $this->createPersonalAccessClient();
|
||||
Config::set('passport.personal_access_client.id', $personalAccessClient->id);
|
||||
Config::set('passport.personal_access_client.secret', $personalAccessClient->secret);
|
||||
$client = $this->createClient();
|
||||
$token = Token::factory()->forUser($data->user)->forClient($personalAccessClient)->create();
|
||||
$otherTokenType = Token::factory()->forUser($data->user)->forClient($client)->create();
|
||||
$otherData = $this->createUserWithPermission([]);
|
||||
$otherToken = Token::factory()->forUser($otherData->user)->forClient($personalAccessClient)->create();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->getJson(route('api.v1.api-tokens.index'));
|
||||
|
||||
// Assert
|
||||
$this->assertResponseCode($response, 200);
|
||||
$response->assertExactJson([
|
||||
'data' => [
|
||||
[
|
||||
'id' => $token->id,
|
||||
'name' => $token->name,
|
||||
'scopes' => $token->scopes,
|
||||
'revoked' => $token->revoked,
|
||||
'created_at' => $token->created_at->toIso8601ZuluString(),
|
||||
'expires_at' => $token->expires_at->toIso8601ZuluString(),
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_store_endpoint_creates_new_api_token(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([]);
|
||||
$personalAccessClient = $this->createPersonalAccessClient();
|
||||
Config::set('passport.personal_access_client.id', $personalAccessClient->id);
|
||||
Config::set('passport.personal_access_client.secret', $personalAccessClient->secret);
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.api-tokens.store'), [
|
||||
'name' => 'Test Token',
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$this->assertResponseCode($response, 200);
|
||||
$response->assertJsonStructure([
|
||||
'data' => [
|
||||
'id',
|
||||
'name',
|
||||
'scopes',
|
||||
'revoked',
|
||||
'created_at',
|
||||
'expires_at',
|
||||
'access_token',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_store_fails_if_personal_access_client_is_not_configured(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([]);
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.api-tokens.store'), [
|
||||
'name' => 'Test Token',
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$this->assertResponseCode($response, 400);
|
||||
$response->assertExactJson([
|
||||
'error' => true,
|
||||
'key' => 'personal_access_client_is_not_configured',
|
||||
'message' => 'Personal access client is not configured',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_revoke_endpoint_revokes_api_token(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([]);
|
||||
$client = $this->createPersonalAccessClient();
|
||||
Config::set('passport.personal_access_client.id', $client->id);
|
||||
Config::set('passport.personal_access_client.secret', $client->secret);
|
||||
$token = Token::factory()->forUser($data->user)->forClient($client)->create();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.api-tokens.revoke', $token->id));
|
||||
|
||||
// Assert
|
||||
$this->assertResponseCode($response, 204);
|
||||
$this->assertDatabaseHas(Token::class, [
|
||||
'id' => $token->id,
|
||||
'revoked' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_revoke_fails_if_token_is_not_personal_access_token(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([]);
|
||||
$personalAccessClient = $this->createPersonalAccessClient();
|
||||
Config::set('passport.personal_access_client.id', $personalAccessClient->id);
|
||||
Config::set('passport.personal_access_client.secret', $personalAccessClient->secret);
|
||||
$client = $this->createClient();
|
||||
$token = Token::factory()->forUser($data->user)->forClient($client)->create();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.api-tokens.revoke', $token->id));
|
||||
|
||||
// Assert
|
||||
$this->assertResponseCode($response, 403);
|
||||
$this->assertDatabaseHas(Token::class, [
|
||||
'id' => $token->id,
|
||||
'revoked' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_revoke_fails_if_token_with_id_does_not_exist(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([]);
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.api-tokens.revoke', 'not-valid'));
|
||||
|
||||
// Assert
|
||||
$this->assertResponseCode($response, 404);
|
||||
}
|
||||
|
||||
public function test_revoke_fails_if_personal_access_client_is_not_configured(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([]);
|
||||
$client = $this->createPersonalAccessClient();
|
||||
$token = Token::factory()->forUser($data->user)->forClient($client)->create();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.api-tokens.revoke', $token->id));
|
||||
|
||||
// Assert
|
||||
$this->assertResponseCode($response, 400);
|
||||
$response->assertExactJson([
|
||||
'error' => true,
|
||||
'key' => 'personal_access_client_is_not_configured',
|
||||
'message' => 'Personal access client is not configured',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_revoke_fails_if_the_token_does_not_belong_to_the_user(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([]);
|
||||
$otherData = $this->createUserWithPermission([]);
|
||||
$client = $this->createPersonalAccessClient();
|
||||
Config::set('passport.personal_access_client.id', $client->id);
|
||||
Config::set('passport.personal_access_client.secret', $client->secret);
|
||||
$token = Token::factory()->forUser($otherData->user)->forClient($client)->create();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.api-tokens.revoke', $token->id));
|
||||
|
||||
// Assert
|
||||
$this->assertResponseCode($response, 403);
|
||||
$this->assertDatabaseHas(Token::class, [
|
||||
'id' => $token->id,
|
||||
'revoked' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_destroy_endpoint_deletes_api_token(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([]);
|
||||
$client = $this->createPersonalAccessClient();
|
||||
Config::set('passport.personal_access_client.id', $client->id);
|
||||
Config::set('passport.personal_access_client.secret', $client->secret);
|
||||
$token = Token::factory()->forUser($data->user)->forClient($client)->create();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->deleteJson(route('api.v1.api-tokens.destroy', $token->id));
|
||||
|
||||
// Assert
|
||||
$this->assertResponseCode($response, 204);
|
||||
$this->assertDatabaseMissing(Token::class, ['id' => $token->id]);
|
||||
}
|
||||
|
||||
public function test_destroy_fails_if_personal_access_client_is_not_configured(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([]);
|
||||
$client = $this->createPersonalAccessClient();
|
||||
$token = Token::factory()->forUser($data->user)->forClient($client)->create();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->deleteJson(route('api.v1.api-tokens.destroy', $token->id));
|
||||
|
||||
// Assert
|
||||
$this->assertResponseCode($response, 400);
|
||||
$response->assertExactJson([
|
||||
'error' => true,
|
||||
'key' => 'personal_access_client_is_not_configured',
|
||||
'message' => 'Personal access client is not configured',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_destroy_fails_if_token_is_not_personal_access_token(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([]);
|
||||
$personalAccessClient = $this->createPersonalAccessClient();
|
||||
Config::set('passport.personal_access_client.id', $personalAccessClient->id);
|
||||
Config::set('passport.personal_access_client.secret', $personalAccessClient->secret);
|
||||
$client = $this->createClient();
|
||||
$token = Token::factory()->forUser($data->user)->forClient($client)->create();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->deleteJson(route('api.v1.api-tokens.destroy', $token->id));
|
||||
|
||||
// Assert
|
||||
$this->assertResponseCode($response, 403);
|
||||
$this->assertDatabaseHas(Token::class, [
|
||||
'id' => $token->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_destroy_fails_if_token_with_id_does_not_exist(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([]);
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->deleteJson(route('api.v1.api-tokens.destroy', 'not-valid'));
|
||||
|
||||
// Assert
|
||||
$this->assertResponseCode($response, 404);
|
||||
}
|
||||
|
||||
public function test_destroy_fails_if_the_token_does_not_belong_to_the_user(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([]);
|
||||
$otherData = $this->createUserWithPermission([]);
|
||||
$client = $this->createPersonalAccessClient();
|
||||
Config::set('passport.personal_access_client.id', $client->id);
|
||||
Config::set('passport.personal_access_client.secret', $client->secret);
|
||||
$token = Token::factory()->forUser($otherData->user)->forClient($client)->create();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->deleteJson(route('api.v1.api-tokens.destroy', $token->id));
|
||||
|
||||
// Assert
|
||||
$this->assertResponseCode($response, 403);
|
||||
$this->assertDatabaseHas(Token::class, [
|
||||
'id' => $token->id,
|
||||
]);
|
||||
}
|
||||
|
||||
private function createPersonalAccessClient(): Client
|
||||
{
|
||||
$clientRepository = new ClientRepository;
|
||||
/** @var Client $client */
|
||||
$client = $clientRepository->createPersonalAccessClient(
|
||||
null, 'Test Personal Access Client', 'http://localhost'
|
||||
);
|
||||
|
||||
return $client;
|
||||
}
|
||||
|
||||
private function createClient(): Client
|
||||
{
|
||||
$clientRepository = new ClientRepository;
|
||||
/** @var Client $client */
|
||||
$client = $clientRepository->create(
|
||||
null, 'Desktop App', 'http://localhost', null
|
||||
);
|
||||
|
||||
return $client;
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,7 @@ class HealthCheckEndpointTest extends EndpointTestAbstract
|
||||
'secure',
|
||||
'timestamp',
|
||||
'timezone',
|
||||
'session_secure',
|
||||
'trusted_proxies',
|
||||
'url',
|
||||
]);
|
||||
|
||||
@@ -5,11 +5,15 @@ declare(strict_types=1);
|
||||
namespace Tests\Unit\Endpoint\Web;
|
||||
|
||||
use App\Http\Controllers\Web\HomeController;
|
||||
use App\Http\Middleware\Authenticate;
|
||||
use App\Http\Middleware\RedirectIfAuthenticated;
|
||||
use App\Models\User;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\UsesClass;
|
||||
|
||||
#[CoversClass(HomeController::class)]
|
||||
#[CoversClass(Authenticate::class)]
|
||||
#[CoversClass(RedirectIfAuthenticated::class)]
|
||||
#[UsesClass(HomeController::class)]
|
||||
class HomeEndpointTest extends EndpointTestAbstract
|
||||
{
|
||||
@@ -36,4 +40,17 @@ class HomeEndpointTest extends EndpointTestAbstract
|
||||
// Assert
|
||||
$response->assertRedirect('/login');
|
||||
}
|
||||
|
||||
public function test_login_redirects_to_dashboard_if_user_is_logged_in(): void
|
||||
{
|
||||
// Arrange
|
||||
$user = User::factory()->withPersonalOrganization()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
// Act
|
||||
$response = $this->get('/login');
|
||||
|
||||
// Assert
|
||||
$response->assertRedirect('/dashboard');
|
||||
}
|
||||
}
|
||||
|
||||
94
tests/Unit/Filament/Resources/TokenResourceTest.php
Normal file
94
tests/Unit/Filament/Resources/TokenResourceTest.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\TokenResource;
|
||||
use App\Models\Passport\Client;
|
||||
use App\Models\Passport\Token;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Livewire\Livewire;
|
||||
use PHPUnit\Framework\Attributes\UsesClass;
|
||||
use Tests\Unit\Filament\FilamentTestCase;
|
||||
|
||||
#[UsesClass(TokenResource::class)]
|
||||
class TokenResourceTest extends FilamentTestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Config::set('auth.super_admins', ['admin@example.com']);
|
||||
$user = User::factory()->withPersonalOrganization()->create([
|
||||
'email' => 'admin@example.com',
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
}
|
||||
|
||||
public function test_can_list_tokens(): void
|
||||
{
|
||||
// Arrange
|
||||
$client = Client::factory()->create();
|
||||
$tokens = Token::factory()->forClient($client)->createMany(5);
|
||||
|
||||
// Act
|
||||
$response = Livewire::test(TokenResource\Pages\ListTokens::class);
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
$response->assertCanSeeTableRecords($tokens);
|
||||
}
|
||||
|
||||
public function test_list_tokens_with_filter_is_personal_access_client_true(): void
|
||||
{
|
||||
// Arrange
|
||||
$client = Client::factory()->create();
|
||||
$personalAccessClient = Client::factory()->personalAccessClient()->create();
|
||||
$tokens = Token::factory()->forClient($client)->createMany(5);
|
||||
$personalAccessTokens = Token::factory()->forClient($personalAccessClient)->createMany(5);
|
||||
|
||||
// Act
|
||||
$response = Livewire::test(TokenResource\Pages\ListTokens::class)
|
||||
->filterTable('is_personal_access_client', true);
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
$response->assertCountTableRecords(5);
|
||||
$response->assertCanSeeTableRecords($personalAccessTokens);
|
||||
$response->assertCanNotSeeTableRecords($tokens);
|
||||
}
|
||||
|
||||
public function test_list_tokens_with_filter_is_personal_access_client_false(): void
|
||||
{
|
||||
// Arrange
|
||||
$client = Client::factory()->create();
|
||||
$personalAccessClient = Client::factory()->personalAccessClient()->create();
|
||||
$tokens = Token::factory()->forClient($client)->createMany(5);
|
||||
$personalAccessTokens = Token::factory()->forClient($personalAccessClient)->createMany(5);
|
||||
|
||||
// Act
|
||||
$response = Livewire::test(TokenResource\Pages\ListTokens::class)
|
||||
->filterTable('is_personal_access_client', false);
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
$response->assertCountTableRecords(5);
|
||||
$response->assertCanSeeTableRecords($tokens);
|
||||
$response->assertCanNotSeeTableRecords($personalAccessTokens);
|
||||
}
|
||||
|
||||
public function test_can_see_view_page_of_token(): void
|
||||
{
|
||||
// Arrange
|
||||
$client = Client::factory()->create();
|
||||
$token = Token::factory()->forClient($client)->create();
|
||||
|
||||
// Act
|
||||
$response = Livewire::test(TokenResource\Pages\ViewToken::class, ['record' => $token->getKey()]);
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,20 @@ class EnsureEmailIsVerifiedMiddlewareTest extends MiddlewareTestAbstract
|
||||
$response->assertRedirect(route('verification.notice'));
|
||||
}
|
||||
|
||||
public function test_users_with_unverified_email_get_error_if_the_request_is_json(): void
|
||||
{
|
||||
// Arrange
|
||||
$user = User::factory()->unverified()->create();
|
||||
$route = $this->createTestRoute();
|
||||
$this->actingAs($user);
|
||||
|
||||
// Act
|
||||
$response = $this->getJson($route);
|
||||
|
||||
// Assert
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_users_with_verified_email_can_access_route(): void
|
||||
{
|
||||
// Arrange
|
||||
|
||||
81
tests/Unit/Middleware/ForceHttpsMiddlewareTest.php
Normal file
81
tests/Unit/Middleware/ForceHttpsMiddlewareTest.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Middleware;
|
||||
|
||||
use App\Http\Middleware\ForceHttps;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\UsesClass;
|
||||
|
||||
#[CoversClass(ForceHttps::class)]
|
||||
#[UsesClass(ForceHttps::class)]
|
||||
class ForceHttpsMiddlewareTest extends MiddlewareTestAbstract
|
||||
{
|
||||
private function createTestRoute(): string
|
||||
{
|
||||
return Route::get('/test-route', function () {
|
||||
return [
|
||||
'is_secure' => request()->secure(),
|
||||
];
|
||||
})->middleware(ForceHttps::class)->uri;
|
||||
}
|
||||
|
||||
public function test_if_config_app_force_https_is_true_then_the_request_will_be_modified_to_make_the_app_think_it_was_a_https_request(): void
|
||||
{
|
||||
// Arrange
|
||||
Config::set('app.force_https', true);
|
||||
$route = $this->createTestRoute();
|
||||
|
||||
// Act
|
||||
$response = $this->get($route);
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
$response->assertJson(['is_secure' => true]);
|
||||
}
|
||||
|
||||
public function test_if_config_app_force_https_is_true_then_the_request_will_be_modified_to_make_the_app_think_it_was_a_https_request_even_if_a_load_balancer_says_it_was_a_http_request(): void
|
||||
{
|
||||
// Arrange
|
||||
Config::set('app.force_https', true);
|
||||
$route = $this->createTestRoute();
|
||||
|
||||
// Act
|
||||
$response = $this->get($route, ['X-Forwarded-Proto' => 'http']);
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
$response->assertJson(['is_secure' => true]);
|
||||
}
|
||||
|
||||
public function test_if_config_app_force_https_is_false_then_the_request_will_not_be_modified_to_make_the_app_think_it_was_a_https_request(): void
|
||||
{
|
||||
// Arrange
|
||||
Config::set('app.force_https', false);
|
||||
$route = $this->createTestRoute();
|
||||
|
||||
// Act
|
||||
$response = $this->get($route);
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
$response->assertJson(['is_secure' => false]);
|
||||
}
|
||||
|
||||
public function test_if_config_app_force_https_is_false_then_the_request_will_not_be_modified_but_the_request_can_still_be_https(): void
|
||||
{
|
||||
// Arrange
|
||||
Config::set('app.force_https', false);
|
||||
$route = $this->createTestRoute();
|
||||
|
||||
// Act
|
||||
$response = $this->get($route, ['X-Forwarded-Proto' => 'https']);
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
$response->assertJson(['is_secure' => true]);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace Tests\Unit\Service\Import\Importers;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use App\Service\Import\Importers\DefaultImporter;
|
||||
use App\Service\Import\Importers\ImportException;
|
||||
use App\Service\Import\Importers\TogglDataImporter;
|
||||
@@ -88,4 +89,30 @@ class TogglDataImporterTest extends ImporterTestAbstract
|
||||
$this->assertSame(0, $report->projectsCreated);
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user