Compare commits

..
42 Commits
Author SHA1 Message Date
Twyco 508c39dd2d feat(KAN-53): add Android app and platform integration 2026-09-17 14:39:32 +02:00
Twyco 1be2d06ff4 feat(KAN-52): add server selection and isolated native sessions 2026-09-16 18:58:49 +02:00
Twyco 0f62445c3e KAN-51: add isolated Tauri macOS app scaffold
CI/CD / preconditions (pull_request) Successful in 2s
CI/CD / test_and_check_app (pull_request) Successful in 5m49s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-16 13:13:53 +02:00
Twyco 0a2807a28c Merge pull request 'KAN-50: UI Rework 35: Öffentliche Einstiegsseite' (#144) from feature/KAN-50-public-entry-page into main
CI/CD / test_and_check_app (push) Successful in 5m45s
CI/CD / preconditions (push) Successful in 2s
CI/CD / Publish immutable image (push) Successful in 33s
Reviewed-on: #144
2026-09-16 07:46:21 +00:00
Eric 8a9113a7af feat: add public entry page for KAN-50 2026-09-16 07:46:21 +00:00
Twyco 0a974cfd49 Merge pull request 'KAN-45: Rebuild project notification settings' (#140) from feature/KAN-45-project-notification-settings into main
CI/CD / preconditions (push) Successful in 1s
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 1m34s
Reviewed-on: #140
2026-09-16 07:32:21 +00:00
Twyco 538b17b9b8 feat(KAN-45): rebuild project notification settings
CI/CD / preconditions (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
2026-09-16 09:32:05 +02:00
Twyco b2789b4670 Merge pull request 'KAN-44: UI Rework 29: Repository-Einstellungen des Projekts' (#143) from feature/KAN-44-project-repository-settings into main
CI/CD / preconditions (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #143
2026-09-16 07:26:40 +00:00
Eric 7c6981dc0a feat(KAN-44): rebuild project repository settings 2026-09-16 07:26:40 +00:00
Twyco b8409a702c Merge pull request 'KAN-49: Rebuild user administration' (#142) from feature/KAN-49-user-administration into main
CI/CD / preconditions (push) Successful in 2s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #142
2026-09-16 07:01:11 +00:00
Twyco 2acbdb4760 feat(KAN-49): rebuild user administration 2026-09-16 07:01:11 +00:00
Twyco 9faf02e2c4 Merge pull request 'KAN-46: Rebuild reusable project template management' (#139) from feature/KAN-46-reusable-project-templates into main
CI/CD / preconditions (push) Successful in 2s
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 5m40s
Reviewed-on: #139
2026-09-16 07:01:03 +00:00
Twyco 0941745883 feat(KAN-46): rebuild reusable project template management 2026-09-16 07:01:03 +00:00
Twyco 5a10c85e61 Merge pull request 'KAN-43: UI Rework 28: Benutzerdefinierte Felder und Linktypen' (#138) from feature/KAN-43-custom-fields-link-types into main
CI/CD / preconditions (push) Successful in 2s
CI/CD / test_and_check_app (push) Successful in 5m49s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #138
2026-09-16 06:54:15 +00:00
Eric fb4faa6070 feat(KAN-43): rebuild custom fields and link types
CI/CD / preconditions (pull_request) Successful in 1m0s
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
2026-09-15 17:50:10 +02:00
Twyco b226ee8d48 Merge pull request 'KAN-42: Rebuild ticket types, priorities and display settings' (#137) from feature/KAN-42-ticket-types-priorities-display into main
CI/CD / preconditions (push) Successful in 2s
CI/CD / test_and_check_app (push) Successful in 22m13s
CI/CD / Publish immutable image (push) Successful in 43s
Reviewed-on: #137
2026-09-15 14:05:51 +00:00
Twyco ac553f0087 feat(KAN-42): rebuild ticket types priorities and display settings
CI/CD / preconditions (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
2026-09-15 16:05:30 +02:00
Twyco 28d20882ac Merge pull request 'KAN-41: Erlaubte Statusübergänge bearbeiten' (#136) from feature/KAN-41-allowed-status-transitions into main
CI/CD / preconditions (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #136
2026-09-15 14:05:14 +00:00
Twyco 208989d5fc feat(KAN-41): edit allowed status transitions
CI/CD / preconditions (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
2026-09-15 16:05:06 +02:00
Twyco d2f659de8f Merge pull request 'KAN-40: Workflow-Einstellungen für Spalten und Status' (#135) from feature/KAN-40-workflow-columns-and-statuses into main
CI/CD / preconditions (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #135
2026-09-15 14:04:52 +00:00
Twyco f461439ce6 feat(KAN-40): rebuild workflow column and status settings
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / preconditions (pull_request) Canceled after 36s
2026-09-15 16:04:05 +02:00
Twyco 3eea473336 Merge pull request 'KAN-39: Allgemeine Projekteinstellungen und Vererbung' (#134) from feature/KAN-39-project-settings-inheritance into main
CI/CD / preconditions (push) Successful in 1m4s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #134
2026-09-15 14:03:25 +00:00
Twyco bfec4f464e feat(KAN-39): rebuild general project settings and inheritance
CI/CD / preconditions (pull_request) Successful in 1m0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 6s
2026-09-15 16:02:41 +02:00
Twyco 2edee702d4 Merge pull request 'KAN-38: Persönliche Git-Verbindungen' (#133) from feature/KAN-38-personal-git-connections into main
CI/CD / preconditions (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #133
2026-09-15 14:01:31 +00:00
Twyco 9b90c24224 feat(KAN-38): rebuild personal Git connection settings 2026-09-15 14:01:31 +00:00
Twyco 3869e5b8a6 Merge pull request 'KAN-37: Sicherheit und Anwendungsschlüssel' (#132) from feature/KAN-37-security-application-keys into main
CI/CD / preconditions (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #132
2026-09-15 14:01:23 +00:00
Twyco 196623bb67 feat(KAN-37): rebuild security and application key settings 2026-09-15 14:01:23 +00:00
Twyco bc6a113acf Merge pull request 'KAN-36: Profil und Erscheinungsbild' (#131) from feature/KAN-36-profile-and-appearance into main
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / preconditions (push) Canceled after 38s
Reviewed-on: #131
2026-09-15 14:01:17 +00:00
Twyco 1377ee8510 feat(KAN-36): rebuild profile and appearance settings
CI/CD / preconditions (pull_request) Successful in 1m0s
CI/CD / test_and_check_app (pull_request) Failing after 3m17s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-14 17:36:41 +02:00
Twyco 2409c3b18f Merge pull request 'KAN-35 ticket changes' (#129) from feature/KAN-35-ticket-git-area-and-branch-creation into main
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / preconditions (push) Successful in 1m1s
Reviewed-on: #129
2026-09-14 14:52:29 +00:00
Twyco f61298fda4 test(KAN-35): isolate branch error browser scenario
CI/CD / preconditions (pull_request) Successful in 1m1s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 5s
2026-09-14 14:52:19 +00:00
Eric 7a4c6f3865 KAN-35 ticket changes 2026-09-14 14:52:19 +00:00
Twyco 79c78b3434 Merge pull request 'Feature/kan 34 blocker ticket history' (#130) from feature/KAN-34-blocker-ticket-history into main
CI/CD / preconditions (push) Successful in 1m0s
CI/CD / test_and_check_app (push) Canceled after 5s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #130
2026-09-14 14:52:12 +00:00
Eric 38438b4700 feat(KAN-34): rework blocker and ticket history
CI/CD / preconditions (pull_request) Successful in 1m1s
CI/CD / test_and_check_app (pull_request) Failing after 3m49s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-14 16:20:09 +02:00
Twyco fed0b657c0 Merge pull request 'Feature/kan 33 ticketbeziehungen und hierarchie' (#128) from feature/KAN-33-ticketbeziehungen-und-hierarchie into main
CI/CD / preconditions (push) Successful in 1m0s
CI/CD / test_and_check_app (push) Successful in 20m52s
CI/CD / Publish immutable image (push) Successful in 3m39s
Reviewed-on: #128
2026-09-14 09:45:54 +00:00
Twyco 1fe0c0b2d9 feat(KAN-33): redesign ticket relationships and hierarchy
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / preconditions (pull_request) Canceled after 5s
2026-09-14 09:45:45 +00:00
Twyco 463cb7fe36 Merge pull request 'feat(KAN-32): add hierarchical acceptance criteria checklist' (#127) from feature/KAN-32-akzeptanzkriterien-checkliste into main
CI/CD / preconditions (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #127
2026-09-14 09:45:39 +00:00
Twyco 3bf5a0c508 feat(KAN-32): add hierarchical acceptance criteria checklist
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / preconditions (pull_request) Canceled after 31s
2026-09-14 09:45:28 +00:00
Twyco 32de1f425a docker test env fix
CI/CD / preconditions (push) Successful in 1m2s
CI/CD / test_and_check_app (push) Successful in 11m39s
CI/CD / Publish immutable image (push) Successful in 3m47s
2026-09-14 10:52:07 +02:00
Twyco 1af98cd56e docker env example fix
CI/CD / preconditions (push) Successful in 1m0s
CI/CD / test_and_check_app (push) Failing after 1m11s
CI/CD / Publish immutable image (push) Skipped
2026-09-14 10:48:29 +02:00
Twyco aadf159ac5 docker test fix
CI/CD / preconditions (push) Successful in 1m1s
CI/CD / test_and_check_app (push) Failing after 3m11s
CI/CD / Publish immutable image (push) Skipped
2026-09-14 10:36:56 +02:00
Twyco 5c47453d4d rollback test app action
CI/CD / preconditions (push) Successful in 59s
CI/CD / test_and_check_app (push) Failing after 8m23s
CI/CD / Publish immutable image (push) Skipped
2026-09-12 14:23:18 +02:00
184 changed files with 25939 additions and 7757 deletions
+4 -3
View File
@@ -33,9 +33,6 @@
vendor
**/.DS_Store
.editorconfig
.env.example
.env.mariadb
.env.mariadb.example
.env.production
LICENSE
README.md
@@ -46,3 +43,7 @@ storage/framework/sessions/*
storage/framework/testing/*
storage/framework/views/*
storage/logs/*
# Native build artifacts are independent of the Laravel image.
native/src-tauri/target
native/src-tauri/gen
-4
View File
@@ -1,4 +0,0 @@
MARIADB_DATABASE=kanbangit
MARIADB_USER=kanbangit
MARIADB_PASSWORD=
MARIADB_ROOT_PASSWORD=
+12 -66
View File
@@ -1,72 +1,18 @@
name: "Lint and Test API"
description: "Run Pint and the Pest test suite for the Laravel API"
name: test_and_check_app
description: Build the test image and run all application checks
runs:
using: composite
steps:
- name: Setup PHP
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 #v2.37.2
with:
php-version: "8.5"
extensions: mbstring, pdo_sqlite
coverage: none
tools: composer:v2
- name: Verify Composer lockfile
- name: Test and check app
shell: bash
run: |
if [ ! -f composer.lock ]; then
echo "::error::api/composer.lock not found"
exit 1
fi
shell: bash
set -Eeuo pipefail
- name: Get Composer cache directory
id: composer_cache
run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"
shell: bash
- name: Cache Composer packages
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ${{ steps.composer_cache.outputs.dir }}
key: ${{ runner.os }}-php-8.5-${{ hashFiles('api/composer.lock') }}
- name: Setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v11.7.0
with:
package-json-file: package.json
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: "pnpm"
cache-dependency-path: "pnpm-lock.yaml"
- name: Install dependencies
run: pnpm install --frozen-lockfile
shell: bash
- name: Prepare environment file
run: cp .env.example .env
shell: bash
- name: Install dependencies
run: composer install --no-interaction --prefer-dist --no-progress
shell: bash
- name: Generate application key
run: php artisan key:generate
shell: bash
- name: Generate Wayfinder routes and actions
run: php artisan wayfinder:generate --with-form
shell: bash
- name: Build Vite app
run: pnpm run build
shell: bash
- name: Run ci check
run: composer run ci:check
shell: bash
docker buildx build \
--file Dockerfile \
--target test \
--tag "local/kanbangit-test:${GITHUB_SHA}" \
--progress plain \
--load \
.
+5
View File
@@ -28,3 +28,8 @@ yarn-error.log
/.vscode
/.zed
.codex/config.toml
/native/node_modules
/native/src-tauri/target
/native/src-tauri/gen/*
!/native/src-tauri/gen/android/
+6
View File
@@ -1,3 +1,9 @@
# Working Agreements
## Missing programs and SDKs
If implementation or verification requires a missing program, SDK, runtime, toolchain, or platform component, do not download or install a temporary copy in `/tmp` or another temporary directory. Do not use an ad hoc toolchain as a workaround. Tell the user which prerequisites are missing, ask them to install them permanently and confirm when installation is complete, and wait for that confirmation before continuing dependent work. This rule applies to every implementation task in this project.
<laravel-boost-guidelines>
=== foundation rules ===
+2 -1
View File
@@ -61,7 +61,8 @@ RUN composer install --prefer-dist --no-interaction --no-progress \
FROM build AS test
RUN touch .env \
RUN cp .env.example .env \
&& php artisan key:generate\
&& composer audit --locked --no-interaction \
&& COMPOSER_ALLOW_SUPERUSER=1 composer ci:check
@@ -34,8 +34,8 @@ class BuildProjectTicketMetadataSettingsPayload
DuplicateProjectConfiguration::COPY_TICKET_FIELDS,
DuplicateProjectConfiguration::COPY_TICKET_LINK_TYPES,
]),
'ticketFieldDefinitions' => TicketFieldDefinitionResource::collection($project->ticketFieldDefinitions()->get())->resolve(),
'ticketLinkTypes' => TicketLinkTypeResource::collection($project->ticketLinkTypes()->get())->resolve(),
'ticketFieldDefinitions' => TicketFieldDefinitionResource::collection($project->ticketFieldDefinitions()->withCount('ticketFieldValues')->get())->resolve(),
'ticketLinkTypes' => TicketLinkTypeResource::collection($project->ticketLinkTypes()->withCount('ticketLinks')->get())->resolve(),
'ticketPriorities' => TicketPriorityResource::collection($project->ticketPriorities()->get())->resolve(),
'ticketTypes' => TicketTypeResource::collection($project->ticketTypes()->withCount('tickets')->get())->resolve(),
];
@@ -29,6 +29,7 @@ class UpdateProjectTemplateRequest extends FormRequest
'copy_areas' => ['required', 'array', 'min:1'],
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())],
'confirm_linked_projects_update' => [
Rule::excludeIf(! $requiresImpactConfirmation),
Rule::requiredIf($requiresImpactConfirmation),
'accepted',
],
@@ -20,6 +20,7 @@ class TicketFieldDefinitionResource extends JsonResource
'label' => $this->label,
'position' => $this->position,
'project_id' => $this->project_id,
'ticket_field_values_count' => $this->when(isset($this->ticket_field_values_count), (int) $this->ticket_field_values_count),
'created_at' => $this->created_at?->toISOString(),
'updated_at' => $this->updated_at?->toISOString(),
];
@@ -20,6 +20,7 @@ class TicketLinkTypeResource extends JsonResource
'counterpart_label' => $this->counterpart_label ?: $this->label,
'position' => $this->position,
'project_id' => $this->project_id,
'ticket_links_count' => $this->when(isset($this->ticket_links_count), (int) $this->ticket_links_count),
'created_at' => $this->created_at?->toISOString(),
'updated_at' => $this->updated_at?->toISOString(),
];
+1 -1
View File
@@ -33,5 +33,5 @@ return Application::configure(basePath: dirname(__DIR__))
]);
})
->withExceptions(function (Exceptions $exceptions): void {
//
$exceptions->dontFlash(['api_token']);
})->create();
+3 -1
View File
@@ -27,7 +27,9 @@ const eslintConfig = [
ignores: [
'bootstrap/ssr/**',
'**/dist/**',
'node_modules/**',
'**/node_modules/**',
'native/src-tauri/target/**',
'native/src-tauri/gen/**',
'.github/**',
'**/*.yml',
'**/*.yaml',
+19
View File
@@ -0,0 +1,19 @@
{
"name": "kanbangit-native",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tauri dev",
"build": "tauri build",
"build:macos": "tauri build --debug --bundles app",
"test": "node --test tests/*.test.js && cargo test --manifest-path src-tauri/Cargo.toml --locked",
"check": "cargo fmt --manifest-path src-tauri/Cargo.toml --check && cargo clippy --manifest-path src-tauri/Cargo.toml --locked --all-targets --all-features -- -D warnings",
"smoke:macos": "cargo test --manifest-path src-tauri/Cargo.toml --locked --features smoke-test,custom-protocol --test macos_smoke",
"dev:android": "tauri android dev",
"build:android": "tauri android build --debug --apk --target aarch64"
},
"devDependencies": {
"@tauri-apps/cli": "2.11.4"
}
}
+140
View File
@@ -0,0 +1,140 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
devDependencies:
'@tauri-apps/cli':
specifier: 2.11.4
version: 2.11.4
packages:
'@tauri-apps/cli-darwin-arm64@2.11.4':
resolution: {integrity: sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@tauri-apps/cli-darwin-x64@2.11.4':
resolution: {integrity: sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@tauri-apps/cli-linux-arm-gnueabihf@2.11.4':
resolution: {integrity: sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@tauri-apps/cli-linux-arm64-gnu@2.11.4':
resolution: {integrity: sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-arm64-musl@2.11.4':
resolution: {integrity: sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tauri-apps/cli-linux-riscv64-gnu@2.11.4':
resolution: {integrity: sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-x64-gnu@2.11.4':
resolution: {integrity: sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-x64-musl@2.11.4':
resolution: {integrity: sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tauri-apps/cli-win32-arm64-msvc@2.11.4':
resolution: {integrity: sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@tauri-apps/cli-win32-ia32-msvc@2.11.4':
resolution: {integrity: sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==}
engines: {node: '>= 10'}
cpu: [ia32]
os: [win32]
'@tauri-apps/cli-win32-x64-msvc@2.11.4':
resolution: {integrity: sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@tauri-apps/cli@2.11.4':
resolution: {integrity: sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==}
engines: {node: '>= 10'}
hasBin: true
snapshots:
'@tauri-apps/cli-darwin-arm64@2.11.4':
optional: true
'@tauri-apps/cli-darwin-x64@2.11.4':
optional: true
'@tauri-apps/cli-linux-arm-gnueabihf@2.11.4':
optional: true
'@tauri-apps/cli-linux-arm64-gnu@2.11.4':
optional: true
'@tauri-apps/cli-linux-arm64-musl@2.11.4':
optional: true
'@tauri-apps/cli-linux-riscv64-gnu@2.11.4':
optional: true
'@tauri-apps/cli-linux-x64-gnu@2.11.4':
optional: true
'@tauri-apps/cli-linux-x64-musl@2.11.4':
optional: true
'@tauri-apps/cli-win32-arm64-msvc@2.11.4':
optional: true
'@tauri-apps/cli-win32-ia32-msvc@2.11.4':
optional: true
'@tauri-apps/cli-win32-x64-msvc@2.11.4':
optional: true
'@tauri-apps/cli@2.11.4':
optionalDependencies:
'@tauri-apps/cli-darwin-arm64': 2.11.4
'@tauri-apps/cli-darwin-x64': 2.11.4
'@tauri-apps/cli-linux-arm-gnueabihf': 2.11.4
'@tauri-apps/cli-linux-arm64-gnu': 2.11.4
'@tauri-apps/cli-linux-arm64-musl': 2.11.4
'@tauri-apps/cli-linux-riscv64-gnu': 2.11.4
'@tauri-apps/cli-linux-x64-gnu': 2.11.4
'@tauri-apps/cli-linux-x64-musl': 2.11.4
'@tauri-apps/cli-win32-arm64-msvc': 2.11.4
'@tauri-apps/cli-win32-ia32-msvc': 2.11.4
'@tauri-apps/cli-win32-x64-msvc': 2.11.4
+1
View File
@@ -0,0 +1 @@
minimumReleaseAge: 10080
+4509
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
[package]
name = "kanbangit-native"
version = "0.1.0"
description = "KanbanGit native application"
edition = "2021"
publish = false
[lib]
name = "kanbangit_native"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
[dev-dependencies]
tauri = { version = "2", features = ["test"] }
serde_json = "1"
[features]
smoke-test = []
custom-protocol = ["tauri/custom-protocol"]
[[test]]
name = "macos_smoke"
harness = false
required-features = ["smoke-test"]
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build();
}
@@ -0,0 +1,12 @@
# EditorConfig is awesome: https://EditorConfig.org
# top-most EditorConfig file
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = false
insert_final_newline = false
+26
View File
@@ -0,0 +1,26 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
build
/captures
.externalNativeBuild
.cxx
local.properties
key.properties
keystore.properties
/.tauri
/tauri.settings.gradle
/app/tauri.build.gradle.kts
/app/src/main/jniLibs
/app/src/main/java/de/kanbangit/desktop/generated
.kotlin
/app/tauri.properties
@@ -0,0 +1,6 @@
/src/main/**/generated
/src/main/jniLibs/**/*.so
/src/main/assets/tauri.conf.json
/tauri.build.gradle.kts
/proguard-tauri.pro
/tauri.properties
@@ -0,0 +1,76 @@
import java.util.Properties
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("rust")
}
val tauriProperties = Properties().apply {
val propFile = file("tauri.properties")
if (propFile.exists()) {
propFile.inputStream().use { load(it) }
}
}
android {
compileSdk = 36
buildToolsVersion = "36.0.0"
namespace = "de.kanbangit.desktop"
defaultConfig {
manifestPlaceholders["usesCleartextTraffic"] = "false"
applicationId = "de.kanbangit.desktop"
minSdk = 24
targetSdk = 36
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// Tauri exits the process when its last activity is destroyed; report results first.
testInstrumentationRunnerArguments["waitForActivitiesToComplete"] = "false"
versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt()
versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0")
}
buildTypes {
getByName("debug") {
manifestPlaceholders["usesCleartextTraffic"] = "false"
isDebuggable = true
isJniDebuggable = true
isMinifyEnabled = false
packaging {
jniLibs.keepDebugSymbols.add("*/arm64-v8a/*.so")
jniLibs.keepDebugSymbols.add("*/armeabi-v7a/*.so")
jniLibs.keepDebugSymbols.add("*/x86/*.so")
jniLibs.keepDebugSymbols.add("*/x86_64/*.so")
}
}
getByName("release") {
isMinifyEnabled = true
proguardFiles(
*fileTree(".") { include("**/*.pro") }
.plus(getDefaultProguardFile("proguard-android-optimize.txt"))
.toList().toTypedArray()
)
}
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
buildConfig = true
}
}
rust {
rootDirRel = "../../../"
}
dependencies {
implementation("androidx.webkit:webkit:1.14.0")
implementation("androidx.appcompat:appcompat:1.7.1")
implementation("androidx.activity:activity-ktx:1.10.1")
implementation("com.google.android.material:material:1.12.0")
implementation("androidx.lifecycle:lifecycle-process:2.10.0")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.4")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")
}
apply(from = "tauri.build.gradle.kts")
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,116 @@
package de.kanbangit.desktop
import android.content.Context
import android.os.SystemClock
import android.view.InputDevice
import android.view.MotionEvent
import android.view.View
import android.webkit.CookieManager
import android.webkit.WebView
import androidx.lifecycle.Lifecycle
import androidx.test.core.app.ActivityScenario
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
@RunWith(AndroidJUnit4::class)
class PlatformTest {
private val instrumentation = InstrumentationRegistry.getInstrumentation()
private fun evaluate(view: WebView, script: String): String {
val result = AtomicReference<String>()
val latch = CountDownLatch(1)
instrumentation.runOnMainSync { view.evaluateJavascript(script) { result.set(it); latch.countDown() } }
assertTrue("JavaScript callback timed out", latch.await(10, TimeUnit.SECONDS))
return result.get()
}
private fun waitFor(condition: () -> Boolean) {
val end = System.currentTimeMillis() + 25000
while (System.currentTimeMillis() < end) {
if (condition()) return
Thread.sleep(100)
}
fail("Platform condition timed out")
}
private fun tapCenter(view: View) {
val position = IntArray(2)
instrumentation.runOnMainSync {
view.getLocationOnScreen(position)
position[0] += view.width / 2
position[1] += view.height / 2
}
val downTime = SystemClock.uptimeMillis()
for (action in listOf(MotionEvent.ACTION_DOWN, MotionEvent.ACTION_UP)) {
val event = MotionEvent.obtain(downTime, SystemClock.uptimeMillis(), action, position[0].toFloat(), position[1].toFloat(), 0)
event.source = InputDevice.SOURCE_TOUCHSCREEN
try {
instrumentation.sendPointerSync(event)
} finally {
event.recycle()
}
}
instrumentation.waitForIdleSync()
}
@Test fun workspacePreservesFormsAndIsolatesServers() {
instrumentation.targetContext.getSharedPreferences("workspace", Context.MODE_PRIVATE).edit().clear().commit()
val scenario = ActivityScenario.launch(MainActivity::class.java)
Thread.sleep(1500)
scenario.onActivity { it.openWorkspace("https://kanbangit.de/") }
val remote = AtomicReference<WebView>()
waitFor {
var visible = false
scenario.onActivity { remote.set(it.workspace); visible = it.workspace?.visibility == View.VISIBLE }
visible && remote.get() != null && evaluate(remote.get(), "document.readyState === 'complete' && document.querySelector('#app')?.innerText.trim().length > 100") == "true"
}
val view = remote.get()
assertEquals("true", evaluate(view, "typeof window.nativeLauncher === 'undefined' && typeof window.__TAURI_INTERNALS__ === 'undefined'"))
assertEquals("true", evaluate(view, """
(() => {
const input = document.createElement('input');
input.id = 'kan53-input';
input.value = 'retained form';
document.body.append(input);
const button = document.createElement('button');
button.style.cssText = 'position:fixed;inset:0;width:100%;height:100%;z-index:2147483647';
button.textContent = 'Navigate';
button.onclick = () => {
history.pushState({}, '', '/kan53-history');
button.remove();
};
document.body.append(button);
return true;
})()
""".trimIndent()))
tapCenter(view)
waitFor { evaluate(view, "location.pathname === '/kan53-history'") == "true" }
waitFor {
var canGoBack = false
scenario.onActivity { canGoBack = view.canGoBack() }
canGoBack
}
scenario.moveToState(Lifecycle.State.CREATED)
scenario.moveToState(Lifecycle.State.RESUMED)
assertEquals("\"retained form\"", evaluate(view, "document.querySelector('#kan53-input').value"))
scenario.onActivity { it.onBackPressedDispatcher.onBackPressed() }
waitFor { evaluate(view, "location.pathname !== '/kan53-history'") == "true" }
assertEquals("\"retained form\"", evaluate(view, "document.querySelector('#kan53-input').value"))
evaluate(view, "document.cookie = 'kan53_fixture=secret; Path=/; Secure; SameSite=Lax'; localStorage.setItem('kan53_fixture', 'secret')")
scenario.onActivity { it.openWorkspace("https://127.0.0.1:9/") }
waitFor {
val cookie = AtomicReference<String>()
instrumentation.runOnMainSync { cookie.set(CookieManager.getInstance().getCookie("https://kanbangit.de/")) }
cookie.get()?.contains("kan53_fixture") != true
}
scenario.onActivity { assertNotSame(view, it.workspace) }
// Let instrumentation finish the process after reporting, rather than exiting via Tauri.
scenario.moveToState(Lifecycle.State.CREATED)
}
}
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:icon="@drawable/kanbangit"
android:label="@string/app_name"
android:theme="@style/Theme.kanbangit_native"
android:usesCleartextTraffic="${usesCleartextTraffic}">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
android:launchMode="singleTask"
android:label="@string/main_activity_title"
android:name=".MainActivity"
android:windowSoftInputMode="adjustResize"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -0,0 +1,306 @@
package de.kanbangit.desktop
import android.annotation.SuppressLint
import android.content.res.Configuration
import android.graphics.Bitmap
import android.net.Uri
import android.net.http.SslError
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.View
import android.view.ViewGroup
import android.webkit.CookieManager
import android.webkit.SslErrorHandler
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebSettings
import android.webkit.WebStorage
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Button
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.TextView
import androidx.activity.OnBackPressedCallback
import androidx.activity.enableEdgeToEdge
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import org.json.JSONObject
class MainActivity : TauriActivity() {
private val handler = Handler(Looper.getMainLooper())
private val preferences by lazy { getSharedPreferences("workspace", MODE_PRIVATE) }
private lateinit var launcher: WebView
private lateinit var root: LinearLayout
private lateinit var content: FrameLayout
private lateinit var settings: Button
internal var workspace: WebView? = null
private set
private var server = NavigationPolicy.DEFAULT_SERVER
private var phase = "idle"
private var ready = false
private var revision = 0
private var launcherVisible = true
private var keyboardVisible = false
private var initialized = false
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
when (NavigationPolicy.backAction(keyboardVisible, launcherVisible, ready, workspace?.canGoBack() == true)) {
"keyboard" -> WindowCompat.getInsetsController(window, window.decorView).hide(WindowInsetsCompat.Type.ime())
"resume" -> showWorkspace()
"history" -> workspace?.goBack()
"settings" -> showLauncher()
else -> moveTaskToBack(true)
}
}
})
}
override fun onWebViewCreate(webView: WebView) {
launcher = webView
if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
WebViewCompat.addWebMessageListener(launcher, "nativeLauncher", setOf(NavigationPolicy.LAUNCHER_ORIGIN)) {
_, message, origin, mainFrame, _ ->
if (mainFrame && origin.toString().trimEnd('/') == NavigationPolicy.LAUNCHER_ORIGIN) {
launcher.post { handleAction(message.data.orEmpty()) }
}
}
}
launcher.post { attachShell() }
}
private fun attachShell() {
if (::root.isInitialized) return
(launcher.parent as? ViewGroup)?.removeView(launcher)
root = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL }
settings = Button(this).apply {
text = getString(R.string.server_settings)
isAllCaps = false
visibility = View.GONE
setOnClickListener { showLauncher() }
}
root.addView(settings, LinearLayout.LayoutParams(-1, -2))
content = FrameLayout(this)
content.addView(launcher, FrameLayout.LayoutParams(-1, -1))
root.addView(content, LinearLayout.LayoutParams(-1, 0, 1f))
setContentView(root)
ViewCompat.setOnApplyWindowInsetsListener(root) { view, insets ->
val safe = insets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout())
val keyboard = insets.getInsets(WindowInsetsCompat.Type.ime())
keyboardVisible = insets.isVisible(WindowInsetsCompat.Type.ime())
view.setPadding(safe.left, safe.top, safe.right, maxOf(safe.bottom, keyboard.bottom))
WindowInsetsCompat.CONSUMED
}
ViewCompat.requestApplyInsets(root)
if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
content.removeAllViews()
content.addView(TextView(this).apply { text = getString(R.string.update_webview) })
}
}
private fun handleAction(value: String) {
attachShell()
val url = Uri.parse(value)
if (url.scheme != "https" || url.host != "tauri.localhost" || url.port != -1 || url.userInfo != null) return
when (url.path) {
"/action/ready" -> {
if (initialized) return
initialized = true
val saved = preferences.getString("server", null)
server = saved?.let(NavigationPolicy::installation) ?: NavigationPolicy.DEFAULT_SERVER
updateLauncher()
if (saved != null) openWorkspace(server)
}
"/action/open" -> openWorkspace(url.getQueryParameter("server").orEmpty())
"/action/resume" -> if (ready) showWorkspace()
}
}
internal fun openWorkspace(input: String) {
val address = NavigationPolicy.installation(input)
if (address == null) {
phase = "invalidServer"
showLauncher()
return
}
if (phase == "connecting") return
if (address == server && ready) {
if (phase == "connectionError") {
phase = "connecting"
workspace?.let { startTimeout(it); it.reload() }
updateLauncher()
return
}
showWorkspace()
return
}
val changed = address != preferences.getString("server", null)
val clearSession = changed || preferences.getBoolean("clearing", false)
if (!preferences.edit().putString("server", address).putBoolean("clearing", clearSession).commit()) {
phase = "storageError"
showLauncher()
return
}
server = address
ready = false
phase = "connecting"
revision++
workspace?.let { old ->
content.removeView(old)
old.stopLoading()
if (clearSession) old.clearCache(true)
old.destroy()
}
workspace = null
updateLauncher()
if (clearSession) {
WebStorage.getInstance().deleteAllData()
CookieManager.getInstance().removeAllCookies {
CookieManager.getInstance().flush()
if (!preferences.edit().putBoolean("clearing", false).commit()) {
phase = "storageError"
showLauncher()
} else if (!isDestroyed) {
createWorkspace()
}
}
} else {
createWorkspace()
}
}
@SuppressLint("SetJavaScriptEnabled")
private fun createWorkspace() {
val remote = WebView(this)
workspace = remote
remote.settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
allowFileAccess = false
allowContentAccess = false
mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
javaScriptCanOpenWindowsAutomatically = false
setSupportMultipleWindows(true)
}
CookieManager.getInstance().setAcceptThirdPartyCookies(remote, false)
remote.webChromeClient = WebChromeClient()
remote.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean =
!NavigationPolicy.allows(server, request.url.toString())
override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) {
if (!NavigationPolicy.allows(server, url)) {
view.stopLoading()
fail(view)
return
}
phase = "connecting"
startTimeout(view)
updateLauncher()
}
override fun onPageFinished(view: WebView, url: String) {
if (workspace !== view || phase != "connecting" || !NavigationPolicy.allows(server, url)) return
revision++
phase = "connected"
val firstLoad = !ready
ready = true
CookieManager.getInstance().flush()
updateLauncher()
if (firstLoad) showWorkspace()
}
override fun onReceivedError(view: WebView, request: WebResourceRequest, error: WebResourceError) {
if (request.isForMainFrame) fail(view)
}
override fun onReceivedHttpError(view: WebView, request: WebResourceRequest, response: WebResourceResponse) {
if (request.isForMainFrame && response.statusCode >= 500) fail(view)
}
override fun onReceivedSslError(view: WebView, sslHandler: SslErrorHandler, error: SslError) {
sslHandler.cancel()
fail(view)
}
}
remote.visibility = View.GONE
content.addView(remote, 0, FrameLayout.LayoutParams(-1, -1))
startTimeout(remote)
remote.loadUrl(server)
}
private fun startTimeout(view: WebView) {
val current = ++revision
handler.postDelayed({
if (current == revision && phase == "connecting" && workspace === view) {
view.stopLoading()
fail(view)
}
}, 15000)
}
private fun fail(view: WebView) {
if (workspace !== view) return
revision++
phase = "connectionError"
showLauncher()
}
private fun updateLauncher() {
val query = Uri.Builder().appendQueryParameter("server", server)
.appendQueryParameter("phase", phase).appendQueryParameter("ready", ready.toString())
.appendQueryParameter("platform", "android").build().encodedQuery.orEmpty()
launcher.evaluateJavascript("window.updateLauncher?.(${JSONObject.quote(query)})", null)
}
private fun showLauncher() {
updateLauncher()
launcherVisible = true
launcher.visibility = View.VISIBLE
workspace?.visibility = View.GONE
settings.visibility = View.GONE
launcher.requestFocus()
}
private fun showWorkspace() {
launcherVisible = false
launcher.visibility = View.GONE
workspace?.visibility = View.VISIBLE
settings.visibility = View.VISIBLE
workspace?.requestFocus()
}
override fun onPause() {
workspace?.onPause()
CookieManager.getInstance().flush()
super.onPause()
}
override fun onResume() {
super.onResume()
workspace?.onResume()
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
enableEdgeToEdge()
if (::root.isInitialized) ViewCompat.requestApplyInsets(root)
}
override fun onDestroy() {
handler.removeCallbacksAndMessages(null)
workspace?.destroy()
workspace = null
super.onDestroy()
}
}
@@ -0,0 +1,41 @@
package de.kanbangit.desktop
import java.net.URI
import java.util.Locale
object NavigationPolicy {
const val DEFAULT_SERVER = "https://kanbangit.de/"
const val LAUNCHER_ORIGIN = "https://tauri.localhost"
fun installation(input: String): String? = runCatching {
val trimmed = input.trim()
require(trimmed.none { it.isISOControl() || it == '\\' })
val uri = URI(trimmed)
require(uri.scheme.equals("https", ignoreCase = true))
require(!uri.host.isNullOrBlank() && uri.rawUserInfo == null)
require(uri.rawQuery == null && uri.rawFragment == null)
require(uri.port == -1 || uri.port in 1..65535)
require(!uri.host.equals("tauri.localhost", ignoreCase = true))
val port = if (uri.port == -1 || uri.port == 443) "" else ":${uri.port}"
val path = uri.rawPath.orEmpty().let { if (it.endsWith('/')) it else "$it/" }
"https://${uri.host.lowercase(Locale.ROOT)}$port$path"
}.getOrNull()
fun allows(server: String, destination: String): Boolean = runCatching {
val base = URI(server)
val target = URI(destination)
target.scheme.equals("https", ignoreCase = true) &&
target.host.equals(base.host, ignoreCase = true) &&
(if (target.port == -1) 443 else target.port) ==
(if (base.port == -1) 443 else base.port) &&
target.rawUserInfo == null && !destination.contains('\\')
}.getOrDefault(false)
fun backAction(keyboard: Boolean, launcher: Boolean, ready: Boolean, history: Boolean): String = when {
keyboard -> "keyboard"
launcher && ready -> "resume"
!launcher && history -> "history"
!launcher -> "settings"
else -> "background"
}
}
@@ -0,0 +1,8 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp" android:height="48dp"
android:viewportWidth="64" android:viewportHeight="64">
<path android:fillColor="#22c55e" android:pathData="M12,0 H52 Q64,0 64,12 V52 Q64,64 52,64 H12 Q0,64 0,52 V12 Q0,0 12,0 Z" />
<path android:fillColor="@android:color/transparent" android:strokeColor="#004b1e"
android:strokeWidth="3" android:strokeLineCap="round" android:strokeLineJoin="round"
android:pathData="M19,16 H45 Q48,16 48,19 V45 Q48,48 45,48 H19 Q16,48 16,45 V19 Q16,16 19,16 Z M27,16 V48 M37,16 V48" />
</vector>
@@ -0,0 +1,6 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.kanbangit_native" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Customize your theme here. -->
</style>
</resources>
@@ -0,0 +1,6 @@
<resources>
<string name="app_name">"KanbanGit"</string>
<string name="main_activity_title">"KanbanGit"</string>
<string name="server_settings">Server settings</string>
<string name="update_webview">Update Android System WebView to connect to your workspace.</string>
</resources>
@@ -0,0 +1,6 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.kanbangit_native" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Customize your theme here. -->
</style>
</resources>
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<exclude domain="root" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="database" path="." />
<exclude domain="file" path="." />
<exclude domain="external" path="." />
</cloud-backup>
<device-transfer>
<exclude domain="root" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="database" path="." />
<exclude domain="file" path="." />
<exclude domain="external" path="." />
</device-transfer>
</data-extraction-rules>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." />
</paths>
@@ -0,0 +1,33 @@
package de.kanbangit.desktop
import org.junit.Assert.*
import org.junit.Test
class NavigationPolicyTest {
@Test fun normalizesAddressesAndRejectsCredentialsAndUnsafeSchemes() {
assertEquals("https://example.com/kanban/", NavigationPolicy.installation(" HTTPS://Example.com:443/kanban "))
assertEquals("https://[::1]:8443/", NavigationPolicy.installation("https://[::1]:8443"))
for (value in listOf("http://example.com", "https:example.com", "https://user:pass@example.com", "https://example.com/?token=x", "https://example.com/#x", "https://exa\nmple.com", "https://example.com\\other", "https://example.com:99999", "https://tauri.localhost/", "file:///etc/passwd", "")) {
assertNull(value, NavigationPolicy.installation(value))
}
}
@Test fun allowsLoginTwoFactorAndHistoryOnlyOnTheSelectedOrigin() {
val server = "https://example.com:8443/app/"
for (path in listOf("/login", "/two-factor-challenge", "/projects/1?ticket=53#details")) {
assertTrue(NavigationPolicy.allows(server, "https://example.com:8443$path"))
}
for (value in listOf("https://example.com/", "http://example.com:8443/", "https://other.example.com:8443/", "https://user@example.com:8443/", "https://tauri.localhost/action/open", "javascript:alert(1)", "intent://example.com", "file:///etc/passwd")) {
assertFalse(value, NavigationPolicy.allows(server, value))
}
assertTrue(NavigationPolicy.allows("https://example.com/", "https://example.com:443/login"))
}
@Test fun backClosesKeyboardBeforeNavigatingAndPreservesTheWorkspace() {
assertEquals("keyboard", NavigationPolicy.backAction(true, false, true, true))
assertEquals("resume", NavigationPolicy.backAction(false, true, true, true))
assertEquals("history", NavigationPolicy.backAction(false, false, true, true))
assertEquals("settings", NavigationPolicy.backAction(false, false, true, false))
assertEquals("background", NavigationPolicy.backAction(false, true, false, false))
}
}
@@ -0,0 +1,21 @@
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath("com.android.tools.build:gradle:8.11.0")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.25")
}
}
allprojects {
repositories {
google()
mavenCentral()
}
}
tasks.register("clean").configure {
delete("build")
}
@@ -0,0 +1,22 @@
plugins {
`kotlin-dsl`
}
gradlePlugin {
plugins {
create("pluginsForCoolKids") {
id = "rust"
implementationClass = "RustPlugin"
}
}
}
repositories {
google()
mavenCentral()
}
dependencies {
compileOnly(gradleApi())
implementation("com.android.tools.build:gradle:8.11.0")
}
@@ -0,0 +1,68 @@
import java.io.File
import org.apache.tools.ant.taskdefs.condition.Os
import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.logging.LogLevel
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.TaskAction
open class BuildTask : DefaultTask() {
@Input
var rootDirRel: String? = null
@Input
var target: String? = null
@Input
var release: Boolean? = null
@TaskAction
fun assemble() {
val executable = """pnpm""";
try {
runTauriCli(executable)
} catch (e: Exception) {
if (Os.isFamily(Os.FAMILY_WINDOWS)) {
// Try different Windows-specific extensions
val fallbacks = listOf(
"$executable.exe",
"$executable.cmd",
"$executable.bat",
)
var lastException: Exception = e
for (fallback in fallbacks) {
try {
runTauriCli(fallback)
return
} catch (fallbackException: Exception) {
lastException = fallbackException
}
}
throw lastException
} else {
throw e;
}
}
}
fun runTauriCli(executable: String) {
val rootDirRel = rootDirRel ?: throw GradleException("rootDirRel cannot be null")
val target = target ?: throw GradleException("target cannot be null")
val release = release ?: throw GradleException("release cannot be null")
val args = listOf("tauri", "android", "android-studio-script");
project.exec {
workingDir(File(project.projectDir, rootDirRel))
executable(executable)
args(args)
if (project.logger.isEnabled(LogLevel.DEBUG)) {
args("-vv")
} else if (project.logger.isEnabled(LogLevel.INFO)) {
args("-v")
}
if (release) {
args("--release")
}
args(listOf("--target", target))
}.assertNormalExitValue()
}
}
@@ -0,0 +1,85 @@
import com.android.build.api.dsl.ApplicationExtension
import org.gradle.api.DefaultTask
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.get
const val TASK_GROUP = "rust"
open class Config {
lateinit var rootDirRel: String
}
open class RustPlugin : Plugin<Project> {
private lateinit var config: Config
override fun apply(project: Project) = with(project) {
config = extensions.create("rust", Config::class.java)
val defaultAbiList = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64");
val abiList = (findProperty("abiList") as? String)?.split(',') ?: defaultAbiList
val defaultArchList = listOf("arm64", "arm", "x86", "x86_64");
val archList = (findProperty("archList") as? String)?.split(',') ?: defaultArchList
val targetsList = (findProperty("targetList") as? String)?.split(',') ?: listOf("aarch64", "armv7", "i686", "x86_64")
extensions.configure<ApplicationExtension> {
@Suppress("UnstableApiUsage")
flavorDimensions.add("abi")
productFlavors {
create("universal") {
dimension = "abi"
ndk {
abiFilters += abiList
}
}
defaultArchList.forEachIndexed { index, arch ->
create(arch) {
dimension = "abi"
ndk {
abiFilters.add(defaultAbiList[index])
}
}
}
}
}
afterEvaluate {
for (profile in listOf("debug", "release")) {
val profileCapitalized = profile.replaceFirstChar { it.uppercase() }
val buildTask = tasks.maybeCreate(
"rustBuildUniversal$profileCapitalized",
DefaultTask::class.java
).apply {
group = TASK_GROUP
description = "Build dynamic library in $profile mode for all targets"
}
tasks["mergeUniversal${profileCapitalized}JniLibFolders"].dependsOn(buildTask)
for (targetPair in targetsList.withIndex()) {
val targetName = targetPair.value
val targetArch = archList[targetPair.index]
val targetArchCapitalized = targetArch.replaceFirstChar { it.uppercase() }
val targetBuildTask = project.tasks.maybeCreate(
"rustBuild$targetArchCapitalized$profileCapitalized",
BuildTask::class.java
).apply {
group = TASK_GROUP
description = "Build dynamic library in $profile mode for $targetArch"
rootDirRel = config.rootDirRel
target = targetName
release = profile == "release"
}
buildTask.dependsOn(targetBuildTask)
tasks["merge$targetArchCapitalized${profileCapitalized}JniLibFolders"].dependsOn(
targetBuildTask
)
}
}
}
}
}
@@ -0,0 +1,25 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app"s APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
android.nonFinalResIds=false
android.builder.sdkDownload=false
Binary file not shown.
@@ -0,0 +1,6 @@
#Tue May 10 19:22:52 CST 2022
distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env sh
#
# Copyright 2015 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
exec "$JAVACMD" "$@"
+89
View File
@@ -0,0 +1,89 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
@@ -0,0 +1,3 @@
include ':app'
apply from: 'tauri.settings.gradle'
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

+22
View File
@@ -0,0 +1,22 @@
use tauri::{WebviewUrl, WebviewWindowBuilder};
pub fn context<R: tauri::Runtime>() -> tauri::Context<R> {
tauri::generate_context!()
}
pub fn builder() -> tauri::Builder<tauri::Wry> {
tauri::Builder::default().setup(|app| {
WebviewWindowBuilder::new(app, "launcher", WebviewUrl::App("index.html".into()))
.use_https_scheme(true)
.on_navigation(|url| {
url.scheme() == "https"
&& url.host_str() == Some("tauri.localhost")
&& url.port().is_none()
&& url.username().is_empty()
&& url.password().is_none()
&& matches!(url.path(), "/" | "/index.html")
})
.build()?;
Ok(())
})
}
+456
View File
@@ -0,0 +1,456 @@
use crate::locale::en;
use crate::server::{validate_url, Server, DEFAULT_SERVER};
use std::{path::PathBuf, sync::Mutex, time::Duration};
use tauri::{
menu::{MenuBuilder, SubmenuBuilder},
webview::{NewWindowResponse, PageLoadEvent},
AppHandle, Manager, Url, WebviewUrl, WebviewWindowBuilder, WindowEvent,
};
#[derive(Default)]
struct Connection {
server: Option<Server>,
label: Option<String>,
revision: u64,
ready: bool,
phase: &'static str,
}
impl Connection {
fn begin_navigation(&mut self) -> u64 {
self.revision += 1;
self.phase = "connecting";
self.revision
}
fn timeout(&mut self, revision: u64) -> bool {
if self.revision != revision || self.phase != "connecting" {
return false;
}
self.phase = "connectionError";
true
}
}
fn allows_server_navigation(server: &Url, destination: &Url) -> bool {
destination.scheme() == "https"
&& destination.origin() == server.origin()
&& destination.username().is_empty()
&& destination.password().is_none()
}
fn allows_launcher_navigation(destination: &Url) -> bool {
destination.scheme() == "tauri"
&& destination.host_str() == Some("localhost")
&& destination.port().is_none()
&& destination.username().is_empty()
&& destination.password().is_none()
}
fn configuration_path(app: &AppHandle) -> tauri::Result<PathBuf> {
Ok(app.path().app_config_dir()?.join("active-server"))
}
fn update_launcher(app: &AppHandle) {
let state = app.state::<Mutex<Connection>>();
let state = state.lock().unwrap();
let mut message = Url::parse("tauri://localhost/").unwrap();
message
.query_pairs_mut()
.append_pair(
"server",
state
.server
.as_ref()
.map_or(DEFAULT_SERVER, |server| server.url.as_str()),
)
.append_pair("phase", state.phase)
.append_pair("ready", if state.ready { "true" } else { "false" });
if let Some(window) = app.get_webview_window("launcher") {
let _ = window.eval(format!(
"window.updateLauncher?.('{}')",
message.query().unwrap()
));
}
}
fn show_launcher(app: &AppHandle) {
update_launcher(app);
if let Some(window) = app.get_webview_window("launcher") {
let _ = window.show();
let _ = window.set_focus();
}
}
fn begin_navigation(app: &AppHandle, label: &str) {
let state = app.state::<Mutex<Connection>>();
let revision = {
let mut state = state.lock().unwrap();
if state.label.as_deref() != Some(label) {
return;
}
state.begin_navigation()
};
update_launcher(app);
let handle = app.clone();
std::thread::spawn(move || {
std::thread::sleep(Duration::from_secs(15));
let state = handle.state::<Mutex<Connection>>();
let timed_out = state.lock().unwrap().timeout(revision);
if timed_out {
show_launcher(&handle);
}
});
}
fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> {
let url = validate_url(input)?;
let state = app.state::<Mutex<Connection>>();
let (server, old_label, label) = {
let mut state = state.lock().unwrap();
let same_server = state
.server
.as_ref()
.is_some_and(|server| server.url == url);
if same_server && state.ready {
if let Some(window) = state
.label
.as_ref()
.and_then(|label| app.get_webview_window(label))
{
window.show().map_err(|_| "connectionError")?;
window.set_focus().map_err(|_| "connectionError")?;
return Ok(());
}
}
let server = if same_server {
state.server.clone().unwrap()
} else {
Server::new(url).map_err(|_| "storageError")?
};
server
.save(&configuration_path(app).map_err(|_| "storageError")?)
.map_err(|_| "storageError")?;
let old_label = state.label.take();
state.server = Some(server.clone());
state.ready = false;
state.revision += 1;
let label = format!("workspace-{}", state.revision);
state.label = Some(label.clone());
(server, old_label, label)
};
if let Some(window) = old_label.and_then(|label| app.get_webview_window(&label)) {
window.close().map_err(|_| "connectionError")?;
}
begin_navigation(app, &label);
let handle = app.clone();
let navigation_label = label.clone();
let server_url = server.url.clone();
let window = WebviewWindowBuilder::new(app, &label, WebviewUrl::External(server.url))
.title(en::APP_NAME)
.inner_size(1280.0, 850.0)
.min_inner_size(375.0, 600.0)
.center()
.visible(false)
.data_store_identifier(server.profile)
.on_navigation(move |destination| {
if !allows_server_navigation(&server_url, destination) {
return false;
}
begin_navigation(&handle, &navigation_label);
true
})
.on_new_window(|_, _| NewWindowResponse::Deny)
.on_page_load(|window, payload| {
if payload.event() != PageLoadEvent::Finished {
return;
}
let app = window.app_handle();
let state = app.state::<Mutex<Connection>>();
let should_show = {
let mut state = state.lock().unwrap();
if state.label.as_deref() != Some(window.label()) {
return;
}
let first_load = !state.ready;
state.ready = true;
state.phase = "connected";
first_load
};
update_launcher(app);
if should_show {
let _ = window.show();
let _ = window.set_focus();
if let Some(launcher) = app.get_webview_window("launcher") {
let _ = launcher.hide();
}
}
})
.build()
.map_err(|_| "connectionError")?;
let handle = app.clone();
window.on_window_event(move |event| {
if matches!(event, WindowEvent::Destroyed) {
let state = handle.state::<Mutex<Connection>>();
let current = {
let mut state = state.lock().unwrap();
if state.label.as_deref() == Some(&label) {
state.label = None;
state.ready = false;
state.revision += 1;
state.phase = "idle";
true
} else {
false
}
};
if current {
show_launcher(&handle);
}
}
});
Ok(())
}
fn handle_launcher_action(app: &AppHandle, destination: &Url) {
let result = match destination.path() {
"/action/open" => {
let input = destination
.query_pairs()
.find(|(key, _)| key == "server")
.map(|(_, value)| value.into_owned())
.unwrap_or_default();
open_workspace(app, &input)
}
"/action/resume" => {
let label = app
.state::<Mutex<Connection>>()
.lock()
.unwrap()
.label
.clone();
if let Some(window) = label.and_then(|label| app.get_webview_window(&label)) {
let _ = window.show();
let _ = window.set_focus();
}
Ok(())
}
_ => Ok(()),
};
if let Err(error) = result {
app.state::<Mutex<Connection>>().lock().unwrap().phase = error;
show_launcher(app);
}
}
pub fn context<R: tauri::Runtime>() -> tauri::Context<R> {
tauri::generate_context!()
}
pub fn builder() -> tauri::Builder<tauri::Wry> {
tauri::Builder::default().setup(|app| {
let (server, phase) = match Server::load(&configuration_path(app.handle())?) {
Ok(server) => (server, "idle"),
Err(_) => (None, "storageError"),
};
app.manage(Mutex::new(Connection {
server,
phase,
..Default::default()
}));
let menu = MenuBuilder::new(app)
.item(
&SubmenuBuilder::new(app, en::APP_NAME)
.text("server-settings", en::SERVER_SETTINGS)
.separator()
.quit()
.build()?,
)
.item(
&SubmenuBuilder::new(app, en::EDIT)
.undo()
.redo()
.separator()
.cut()
.copy()
.paste()
.select_all()
.build()?,
)
.build()?;
app.set_menu(menu)?;
app.on_menu_event(|app, event| {
if event.id().as_ref() == "server-settings" {
show_launcher(app);
}
});
let handle = app.handle().clone();
let launcher =
WebviewWindowBuilder::new(app, "launcher", WebviewUrl::App("index.html".into()))
.title(en::APP_NAME)
.inner_size(560.0, 720.0)
.min_inner_size(375.0, 640.0)
.center()
.on_navigation(move |destination| {
if !allows_launcher_navigation(destination) {
return false;
}
if destination.path().starts_with("/action/") {
let handle = handle.clone();
let destination = destination.clone();
let _ = handle.clone().run_on_main_thread(move || {
handle_launcher_action(&handle, &destination)
});
return false;
}
matches!(destination.path(), "" | "/" | "/index.html")
})
.on_new_window(|_, _| NewWindowResponse::Deny)
.on_page_load(|window, payload| {
if payload.event() == PageLoadEvent::Finished {
let app = window.app_handle().clone();
update_launcher(&app);
std::thread::spawn(move || {
let handle = app.clone();
let _ = app.run_on_main_thread(move || {
let server = handle
.state::<Mutex<Connection>>()
.lock()
.unwrap()
.server
.clone();
if let Some(server) = server {
if let Err(error) = open_workspace(&handle, server.url.as_str())
{
handle.state::<Mutex<Connection>>().lock().unwrap().phase =
error;
show_launcher(&handle);
}
}
});
});
}
})
.build()?;
let window = launcher.clone();
launcher.on_window_event(move |event| {
if let WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
let _ = window.hide();
}
});
Ok(())
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn navigation_is_limited_to_the_selected_https_origin() {
let server = validate_url("https://example.com:8443/app/").unwrap();
for path in [
"/login",
"/two-factor-challenge",
"/projects/1?ticket=52#details",
] {
assert!(allows_server_navigation(
&server,
&server.join(path).unwrap()
));
}
for destination in [
"http://example.com:8443/",
"https://example.com/",
"https://other.example.com:8443/",
"https://user:secret@example.com:8443/",
"tauri://localhost/action/open?server=https://evil.test",
"file:///etc/passwd",
"data:text/html,hello",
"javascript:alert(1)",
] {
assert!(!allows_server_navigation(
&server,
&Url::parse(destination).unwrap()
));
}
}
#[test]
fn launcher_actions_are_restricted_to_the_bundled_origin() {
assert!(allows_launcher_navigation(
&Url::parse("tauri://localhost/action/open").unwrap()
));
for input in [
DEFAULT_SERVER,
"https://localhost/",
"tauri://other/index.html",
"tauri://user@localhost/index.html",
] {
assert!(!allows_launcher_navigation(&Url::parse(input).unwrap()));
}
}
#[test]
fn stale_timeouts_do_not_replace_newer_connections_or_loaded_forms() {
let mut state = Connection::default();
let first = state.begin_navigation();
let second = state.begin_navigation();
assert!(!state.timeout(first));
state.ready = true;
assert!(state.timeout(second));
assert!(state.ready);
assert!(!state.timeout(second));
let third = state.begin_navigation();
state.phase = "connected";
assert!(!state.timeout(third));
}
#[test]
fn remote_pages_cannot_invoke_native_commands() {
let app = tauri::test::mock_builder().build(context()).unwrap();
let window = WebviewWindowBuilder::new(
&app,
"workspace-1",
WebviewUrl::External(Url::parse(DEFAULT_SERVER).unwrap()),
)
.build()
.unwrap();
for command in [
"plugin:window|close",
"plugin:webview|create_webview_window",
"plugin:path|resolve_directory",
] {
let response = tauri::test::get_ipc_response(
&window,
tauri::webview::InvokeRequest {
cmd: command.into(),
callback: tauri::ipc::CallbackFn(0),
error: tauri::ipc::CallbackFn(1),
url: Url::parse(DEFAULT_SERVER).unwrap(),
body: tauri::ipc::InvokeBody::default(),
headers: Default::default(),
invoke_key: tauri::test::INVOKE_KEY.into(),
},
);
assert!(response
.unwrap_err()
.as_str()
.unwrap()
.contains("not allowed"));
}
}
#[test]
fn packaged_app_exposes_no_native_capabilities_or_global_api() {
let config: serde_json::Value =
serde_json::from_str(include_str!("../tauri.conf.json")).unwrap();
assert_eq!(config["bundle"]["macOS"]["minimumSystemVersion"], "14.0");
assert_eq!(config["app"]["withGlobalTauri"], false);
assert_eq!(
config["app"]["security"]["capabilities"],
serde_json::json!([])
);
}
}
+18
View File
@@ -0,0 +1,18 @@
#[cfg(target_os = "android")]
mod android;
#[cfg(not(target_os = "android"))]
mod desktop;
#[cfg(not(target_os = "android"))]
mod locale;
#[cfg(not(target_os = "android"))]
mod server;
#[cfg(target_os = "android")]
pub use android::{builder, context};
#[cfg(not(target_os = "android"))]
pub use desktop::{builder, context};
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
builder().run(context()).expect("Could not start KanbanGit");
}
+5
View File
@@ -0,0 +1,5 @@
pub mod en {
pub const APP_NAME: &str = "KanbanGit";
pub const SERVER_SETTINGS: &str = "Server settings…";
pub const EDIT: &str = "Edit";
}
+5
View File
@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
kanbangit_native::run();
}
+137
View File
@@ -0,0 +1,137 @@
use std::{fs, io, io::Read, path::Path};
use tauri::Url;
pub const DEFAULT_SERVER: &str = "https://kanbangit.de/";
#[derive(Clone, Debug, PartialEq)]
pub struct Server {
pub url: Url,
pub profile: [u8; 16],
}
pub fn validate_url(input: &str) -> Result<Url, &'static str> {
let input = input.trim();
if !input
.get(..8)
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("https://"))
|| input
.chars()
.any(|character| character.is_control() || character == '\\')
{
return Err("invalidServer");
}
let mut url = Url::parse(input).map_err(|_| "invalidServer")?;
if url.scheme() != "https"
|| url.host_str().is_none()
|| !url.username().is_empty()
|| url.password().is_some()
|| url.query().is_some()
|| url.fragment().is_some()
{
return Err("invalidServer");
}
if !url.path().ends_with('/') {
url.set_path(&format!("{}/", url.path()));
}
Ok(url)
}
impl Server {
pub fn new(url: Url) -> io::Result<Self> {
let mut profile = [0; 16];
fs::File::open("/dev/urandom")?.read_exact(&mut profile)?;
Ok(Self { url, profile })
}
pub fn load(path: &Path) -> io::Result<Option<Self>> {
let contents = match fs::read_to_string(path) {
Ok(contents) => contents,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error),
};
let invalid = || io::Error::new(io::ErrorKind::InvalidData, "Invalid server configuration");
let (url, profile) = contents.split_once('\n').ok_or_else(invalid)?;
let url = validate_url(url).map_err(|_| invalid())?;
if profile.len() != 32 || !profile.is_ascii() {
return Err(invalid());
}
let mut bytes = [0; 16];
for (index, byte) in bytes.iter_mut().enumerate() {
*byte = u8::from_str_radix(&profile[index * 2..index * 2 + 2], 16)
.map_err(|_| invalid())?;
}
Ok(Some(Self {
url,
profile: bytes,
}))
}
pub fn save(&self, path: &Path) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let profile: String = self
.profile
.iter()
.map(|byte| format!("{byte:02x}"))
.collect();
let temporary = path.with_extension("tmp");
fs::write(&temporary, format!("{}\n{profile}", self.url))?;
fs::rename(temporary, path)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validates_and_normalizes_installation_addresses() {
assert_eq!(
validate_url(" HTTPS://Example.com:443/kanban ")
.unwrap()
.as_str(),
"https://example.com/kanban/"
);
assert!(validate_url("https://[::1]:8443/").is_ok());
for input in [
"http://example.com",
"https:example.com",
"https://user:secret@example.com",
"https://user@example.com",
"https://example.com/?token=secret",
"https://example.com/#secret",
"https://exa\nmple.com",
"https://example.com\\other",
"file:///tmp/test",
"javascript:alert(1)",
"",
] {
assert!(validate_url(input).is_err(), "{input}");
}
}
#[test]
fn configuration_round_trip_preserves_only_the_address_and_profile() {
let server = Server::new(validate_url(DEFAULT_SERVER).unwrap()).unwrap();
let path =
std::env::temp_dir().join(format!("kanbangit-server-{:x?}/active", server.profile));
assert_eq!(Server::load(&path).unwrap(), None);
server.save(&path).unwrap();
assert_eq!(Server::load(&path).unwrap(), Some(server.clone()));
let other = Server::new(validate_url("https://other.example.com:444/").unwrap()).unwrap();
assert_ne!(server.profile, other.profile);
other.save(&path).unwrap();
assert_eq!(Server::load(&path).unwrap(), Some(other));
for contents in [
"http://example.com/\n00000000000000000000000000000000",
"invalid",
"https://example.com/\nshort",
"https://example.com/\nzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
] {
fs::write(&path, contents).unwrap();
assert!(Server::load(&path).is_err());
}
fs::remove_dir_all(path.parent().unwrap()).unwrap();
}
}
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "KanbanGit",
"version": "0.1.0",
"identifier": "de.kanbangit.desktop",
"build": {
"frontendDist": "../ui"
},
"app": {
"windows": [],
"withGlobalTauri": false,
"security": {
"csp": "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; connect-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-src 'none'",
"capabilities": []
}
},
"bundle": {
"active": true,
"targets": ["app"],
"icon": ["icons/icon.png", "icons/icon.icns"],
"macOS": {
"minimumSystemVersion": "14.0"
}
}
}
+151
View File
@@ -0,0 +1,151 @@
#[cfg(target_os = "macos")]
fn main() {
use std::sync::{
atomic::{AtomicBool, AtomicUsize, Ordering},
Arc,
};
use tauri::{webview::PageLoadEvent, Manager};
let passed = Arc::new(AtomicBool::new(false));
let stage = Arc::new(AtomicUsize::new(0));
let page_stage = stage.clone();
let result_passed = passed.clone();
let plugin = tauri::plugin::Builder::<tauri::Wry>::new("macos-smoke")
.js_init_script("window.addEventListener('error', event => { location.href = 'tauri://localhost/__native_smoke_result?passed=false&error=' + encodeURIComponent(event.message || event.target.src); }, true);")
.on_page_load(move |webview, payload| {
if payload.event() != PageLoadEvent::Finished {
return;
}
if webview.label() == "launcher" {
webview.eval(r#"
(async () => {
const wait = async predicate => {
for (let attempt = 0; attempt < 220; attempt++) {
if (predicate()) return;
await new Promise(resolve => setTimeout(resolve, 100));
}
throw new Error('Timed out waiting for local state');
};
try {
await wait(() => document.querySelector('.open-workspace')?.textContent === 'Open workspace');
const input = document.querySelector('#server-url');
const form = document.querySelector('#server-form');
const status = document.querySelector('#connection-status');
input.value = 'http://example.com';
form.requestSubmit();
if (input.getAttribute('aria-invalid') !== 'true' || !status.textContent.includes('HTTPS')) throw new Error('Invalid address accepted');
for (const [theme, expected] of [['dark', 'rgb(15, 23, 42)'], ['light', 'rgb(247, 249, 251)']]) {
const appearance = document.querySelector('#appearance');
appearance.value = theme;
appearance.dispatchEvent(new Event('change'));
if (getComputedStyle(document.documentElement).backgroundColor !== expected) throw new Error('Theme failed');
}
input.value = 'https://127.0.0.1:9/';
form.requestSubmit();
await wait(() => status.dataset.state === 'connectionError');
if (input.value !== 'https://127.0.0.1:9/' || document.querySelector('.open-workspace').disabled) throw new Error('Offline recovery failed');
input.value = 'https://kanbangit.de/';
form.requestSubmit();
if (document.querySelector('#switch-confirmation').hidden) throw new Error('Missing switch confirmation');
document.querySelector('#cancel-switch').click();
if (!document.querySelector('#switch-confirmation').hidden) throw new Error('Cancel failed');
form.requestSubmit();
document.querySelector('#confirm-switch').click();
} catch (error) {
location.href = 'tauri://localhost/__native_smoke_result?passed=false&reason=' + encodeURIComponent(error.message);
}
})();
"#).unwrap();
} else if webview.label().starts_with("workspace-") && payload.url().host_str() == Some("kanbangit.de") {
let current_stage = page_stage.fetch_add(1, Ordering::SeqCst);
if current_stage == 0 {
webview.eval(r#"
(async () => {
for (let attempt = 0; attempt < 100; attempt++) {
if (document.querySelector('#app')?.innerText.trim().length > 100) break;
await new Promise(resolve => setTimeout(resolve, 100));
}
const rendered = document.querySelector('#app')?.innerText.trim().length > 100;
let denied = true;
for (const cmd of ['plugin:path|resolve_directory', 'plugin:window|get_all_windows']) {
try { await window.__TAURI_INTERNALS__.invoke(cmd); denied = false; }
catch (error) { denied = denied && String(error).includes('not allowed'); }
}
const input = document.createElement('input');
input.id = 'kan52-unsaved'; input.value = 'unsaved input'; document.body.append(input);
document.cookie = 'kan52_fixture=isolated; Path=/; Secure; SameSite=Lax';
localStorage.setItem('kan52_fixture', 'isolated');
const ok = rendered && denied && typeof window.__TAURI__ === 'undefined';
location.href = '/__native_smoke_result?stage=loaded&passed=' + ok;
})();
"#).unwrap();
} else {
webview.eval(r#"
const isolated = !document.cookie.includes('kan52_fixture') && localStorage.getItem('kan52_fixture') === null;
location.href = '/__native_smoke_result?stage=isolated&passed=' + isolated;
"#).unwrap();
}
}
})
.on_navigation(move |webview, url| {
if url.path() != "/__native_smoke_result" {
return true;
}
let success = url.query_pairs().any(|(key, value)| key == "passed" && value == "true");
let loaded = url.query_pairs().any(|(key, value)| key == "stage" && value == "loaded");
println!("macOS WebView smoke test: {}", url.query().unwrap_or_default());
if success && loaded {
let app = webview.app_handle().clone();
let label = webview.label().to_string();
std::thread::spawn(move || {
let window = app.get_webview_window(&label).unwrap();
// A blocked cross-origin navigation must leave the existing input intact.
window.navigate(tauri::Url::parse("https://kanbangit.de:9/unreachable").unwrap()).unwrap();
std::thread::sleep(std::time::Duration::from_secs(1));
window.eval(r#"
if (document.querySelector('#kan52-unsaved')?.value !== 'unsaved input') {
location.href = '/__native_smoke_result?passed=false&stage=preservation';
}
"#).unwrap();
let launcher = app.get_webview_window("launcher").unwrap();
launcher.show().unwrap();
launcher.eval(r#"
document.querySelector('#server-url').value = 'https://kanbangit.de/login/';
document.querySelector('#server-form').requestSubmit();
document.querySelector('#confirm-switch').click();
"#).unwrap();
});
} else {
result_passed.store(success, Ordering::SeqCst);
webview.app_handle().exit(if success { 0 } else { 1 });
}
false
})
.build();
let mut context = kanbangit_native::context();
context.config_mut().identifier = format!("de.kanbangit.smoke.kan52.{}", std::process::id());
let app = kanbangit_native::builder()
.plugin(plugin)
.build(context)
.expect("The macOS application must start");
let config_dir = app.path().app_config_dir().unwrap();
let handle = app.handle().clone();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_secs(60));
eprintln!("macOS smoke test timed out");
handle.exit(1);
});
app.run_return(|_, _| {});
let _ = std::fs::remove_dir_all(config_dir);
assert!(
passed.load(Ordering::SeqCst),
"The native smoke test must complete successfully"
);
}
#[cfg(not(target_os = "macos"))]
fn main() {
panic!("Run this smoke test on macOS with an active graphical session");
}
+148
View File
@@ -0,0 +1,148 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { createServer } from 'node:http';
import process from 'node:process';
const { chromium } = await import(
process.env.PLAYWRIGHT_MODULE ?? 'playwright'
);
const actions = [];
const server = createServer(async (request, response) => {
const url = new URL(request.url, 'http://localhost');
if (url.pathname.startsWith('/action/')) {
actions.push(url);
response.writeHead(204).end();
return;
}
const file = url.pathname === '/' ? 'index.html' : url.pathname.slice(1);
if (
![
'index.html',
'main.js',
'server.js',
'locales/en.js',
'styles.css',
'mark.svg',
].includes(file)
) {
response.writeHead(404).end();
return;
}
response.setHeader(
'Content-Type',
file.endsWith('.js')
? 'text/javascript'
: file.endsWith('.css')
? 'text/css'
: file.endsWith('.svg')
? 'image/svg+xml'
: 'text/html',
);
response.end(await readFile(new URL(`../ui/${file}`, import.meta.url)));
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const browser = await chromium.launch({ channel: 'chrome', headless: true });
const page = await browser.newPage();
const state = (phase, ready = false, server = 'https://kanbangit.de/') =>
page.evaluate(
({ phase, ready, server }) => {
window.updateLauncher(
new URLSearchParams({
phase,
ready: String(ready),
server,
}).toString(),
);
},
{ phase, ready, server },
);
const latestAction = async (name) => {
await page.waitForTimeout(100);
assert.equal(actions.at(-1)?.pathname, `/action/${name}`);
};
try {
await page.goto(`http://127.0.0.1:${server.address().port}/`);
await state('idle');
for (const width of [320, 390, 1440]) {
await page.setViewportSize({ width, height: 900 });
for (const theme of ['light', 'dark', 'system']) {
await page.getByLabel('Appearance').selectOption(theme);
assert.equal(
await page.evaluate(
() => document.documentElement.scrollWidth <= innerWidth,
),
true,
);
assert.equal(
await page
.getByRole('button', {
name: 'Open workspace',
exact: true,
})
.isVisible(),
true,
);
}
}
await page.getByLabel('Server address').fill('http://example.com/');
await page.getByLabel('Server address').press('Enter');
assert.equal(
await page.getByLabel('Server address').getAttribute('aria-invalid'),
'true',
);
await page.getByLabel('Server address').fill('https://first.example.com/');
await page.getByLabel('Server address').press('Enter');
await latestAction('open');
assert.equal(
actions.at(-1).searchParams.get('server'),
'https://first.example.com/',
);
await state('connecting', false, 'https://first.example.com/');
assert.equal(
await page
.getByRole('button', { name: 'Open workspace', exact: true })
.isDisabled(),
true,
);
await state('connectionError', false, 'https://first.example.com/');
assert.equal(
await page.getByLabel('Server address').inputValue(),
'https://first.example.com/',
);
await page.getByRole('button', { name: 'Try again', exact: true }).click();
await latestAction('open');
await state('connected', true, 'https://first.example.com/');
await page
.getByRole('button', { name: 'Return to workspace', exact: true })
.click();
await latestAction('resume');
await page
.getByLabel('Server address')
.fill('https://second.example.com/app');
await page
.getByRole('button', { name: 'Open workspace', exact: true })
.click();
assert.equal(
await page
.getByRole('button', { name: 'Switch server', exact: true })
.isVisible(),
true,
);
await page.getByRole('button', { name: 'Cancel', exact: true }).click();
await latestAction('resume');
await page.getByLabel('Server address').press('Enter');
await page
.getByRole('button', { name: 'Switch server', exact: true })
.click();
await latestAction('open');
assert.equal(
actions.at(-1).searchParams.get('server'),
'https://second.example.com/app/',
);
console.log(
'Launcher browser tests passed: responsive themes, keyboard, validation, offline retry, resume and confirmed switching.',
);
} finally {
await browser.close();
server.close();
}
+53
View File
@@ -0,0 +1,53 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import { en } from '../ui/locales/en.js';
import { normalizeServer, needsConfirmation } from '../ui/server.js';
const html = readFileSync(new URL('../ui/index.html', import.meta.url), 'utf8');
test('every launcher text resolves to a nonempty English translation', () => {
for (const [, key] of html.matchAll(/data-i18n="([^"]+)"/g)) {
assert.equal(typeof en[key], 'string', key);
assert.ok(en[key].trim(), key);
}
});
test('installation addresses support custom domains, ports and base paths', () => {
assert.equal(
normalizeServer(' HTTPS://Example.com:443/kanban '),
'https://example.com/kanban/',
);
assert.equal(normalizeServer('https://[::1]:8443/'), 'https://[::1]:8443/');
for (const input of [
'http://example.com',
'https:example.com',
'https://user:secret@example.com',
'https://user@example.com',
'https://example.com/?token=secret',
'https://example.com/#secret',
'https://exa\nmple.com',
'https://example.com\\other',
'file:///tmp/test',
'javascript:alert(1)',
'',
]) {
assert.throws(() => normalizeServer(input), undefined, input);
}
});
test('changing the installation requires confirmation, retrying does not', () => {
assert.equal(needsConfirmation(null, 'https://example.com/'), false);
assert.equal(
needsConfirmation('https://example.com/', 'https://example.com/'),
false,
);
assert.equal(
needsConfirmation('https://example.com/', 'https://example.com:8443/'),
true,
);
assert.equal(
needsConfirmation('https://example.com/a/', 'https://example.com/b/'),
true,
);
});
+89
View File
@@ -0,0 +1,89 @@
import assert from 'node:assert/strict';
import test from 'node:test';
class Element {
constructor() {
this.events = {};
this.attributes = {};
this.dataset = {};
this.hidden = true;
this.value = '';
}
addEventListener(name, callback) {
this.events[name] = callback;
}
setAttribute(name, value) {
this.attributes[name] = value;
}
removeAttribute(name) {
delete this.attributes[name];
}
focus() {}
fire(name) {
this.events[name]({ preventDefault() {}, target: this });
}
}
test('Android launcher sends only explicit local actions and preserves switch confirmation', async () => {
const elements = new Map();
const element = (selector) => {
if (!elements.has(selector)) {
elements.set(selector, new Element());
}
return elements.get(selector);
};
const messages = [];
globalThis.document = {
querySelectorAll: () => [],
querySelector: element,
documentElement: new Element(),
};
globalThis.window = {
nativeLauncher: {
postMessage: (value) => messages.push(new URL(value)),
},
};
globalThis.location = { href: 'https://tauri.localhost/index.html' };
try {
await import('../ui/main.js');
assert.equal(messages.shift().pathname, '/action/ready');
window.updateLauncher(
'server=https%3A%2F%2Fexample.com%2F&phase=idle&ready=false&platform=android',
);
assert.equal(element('#server-url').value, 'https://example.com/');
element('#server-url').value = 'http://example.com';
element('#server-form').fire('submit');
assert.equal(messages.length, 0);
assert.equal(element('#server-url').attributes['aria-invalid'], 'true');
element('#server-url').value = 'https://example.com/';
element('#server-form').fire('submit');
assert.equal(messages[0].pathname, '/action/open');
assert.equal(
messages.shift().searchParams.get('server'),
'https://example.com/',
);
window.updateLauncher(
'server=https%3A%2F%2Fexample.com%2F&phase=connected&ready=true&platform=android',
);
assert.match(
element('#connection-status').textContent,
/Use Server settings/,
);
element('#resume').fire('click');
assert.equal(messages.shift().pathname, '/action/resume');
element('#server-url').value = 'https://other.example.com/';
element('#server-form').fire('submit');
assert.equal(messages.length, 0);
assert.equal(element('#switch-confirmation').hidden, false);
element('#confirm-switch').fire('click');
assert.equal(
messages.shift().searchParams.get('server'),
'https://other.example.com/',
);
assert.equal(location.href, 'https://tauri.localhost/index.html');
} finally {
delete globalThis.window;
delete globalThis.document;
delete globalThis.location;
}
});
+40
View File
@@ -0,0 +1,40 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<title>KanbanGit</title>
<link rel="stylesheet" href="./styles.css" />
<script type="module" src="./main.js"></script>
</head>
<body>
<main>
<img class="brand" src="./mark.svg" alt="" width="48" height="48" />
<p class="eyebrow" data-i18n="appName"></p>
<h1 data-i18n="title"></h1>
<p class="description" data-i18n="description"></p>
<form id="server-form" novalidate>
<label for="server-url" data-i18n="serverLabel"></label>
<input id="server-url" type="url" inputmode="url" autocomplete="url" spellcheck="false" aria-describedby="server-help connection-status" value="https://kanbangit.de/" required />
<p id="server-help" class="description" data-i18n="serverHelp"></p>
<button class="open-workspace" type="submit" data-i18n="openWorkspace"></button>
</form>
<p id="connection-status" class="description" role="status" aria-live="polite"></p>
<div id="switch-confirmation" hidden>
<p class="description" data-i18n="switchWarning"></p>
<button id="confirm-switch" type="button" data-i18n="confirmSwitch"></button>
<button id="cancel-switch" type="button" data-i18n="cancel"></button>
</div>
<button id="resume" type="button" data-i18n="resume" hidden></button>
<label class="appearance">
<span data-i18n="appearance"></span>
<select id="appearance">
<option value="system" data-i18n="system"></option>
<option value="light" data-i18n="light"></option>
<option value="dark" data-i18n="dark"></option>
</select>
</label>
</main>
</body>
</html>
+31
View File
@@ -0,0 +1,31 @@
export const en = {
appName: 'KanbanGit',
title: 'Choose your workspace.',
description: 'Connect to your KanbanGit installation over HTTPS.',
serverLabel: 'Server address',
serverHelp:
'Use the installation address without login details, a query or a fragment.',
openWorkspace: 'Open workspace',
retry: 'Try again',
resume: 'Return to workspace',
switchWarning:
'Switching servers closes the current workspace and discards unsaved inputs. You will sign in separately on the new server.',
confirmSwitch: 'Switch server',
cancel: 'Cancel',
idle: '',
connecting: 'Connecting to your server…',
connected:
'Your workspace is open. Use KanbanGit → Server settings to change servers.',
connectedMobile:
'Your workspace is open. Use Server settings to change servers.',
connectionError:
'The server did not finish loading. Check your connection, server address and HTTPS certificate, then try again. An already open workspace and its inputs are kept.',
invalidServer:
'Enter a valid HTTPS installation address without login details, a query or a fragment.',
storageError:
'The server setting could not be read or saved. Check your local storage permissions and try again.',
appearance: 'Appearance',
system: 'System',
light: 'Light',
dark: 'Dark',
};
+100
View File
@@ -0,0 +1,100 @@
import { en } from './locales/en.js';
import { normalizeServer, needsConfirmation } from './server.js';
for (const element of document.querySelectorAll('[data-i18n]')) {
element.textContent = en[element.dataset.i18n];
}
const form = document.querySelector('#server-form');
const input = document.querySelector('#server-url');
const status = document.querySelector('#connection-status');
const open = document.querySelector('.open-workspace');
const resume = document.querySelector('#resume');
const confirmation = document.querySelector('#switch-confirmation');
let activeServer = null;
let pendingServer = null;
let initialized = false;
function action(name, server) {
const url = new URL(`/action/${name}`, location.href);
if (server) {
url.searchParams.set('server', server);
}
if (window.nativeLauncher) {
window.nativeLauncher.postMessage(url.href);
} else {
location.href = url.href;
}
}
window.updateLauncher = (query) => {
const state = new URLSearchParams(query);
const phase = state.get('phase');
const server = state.get('server');
if (!initialized) {
input.value = server;
initialized = true;
}
if (phase === 'connecting' || phase === 'connected') {
activeServer = server;
}
status.textContent =
en[
phase === 'connected' && state.get('platform') === 'android'
? 'connectedMobile'
: phase
] ?? '';
status.dataset.state = phase;
open.disabled = phase === 'connecting';
open.textContent =
en[phase === 'connectionError' ? 'retry' : 'openWorkspace'];
resume.hidden = state.get('ready') !== 'true';
form.setAttribute('aria-busy', String(phase === 'connecting'));
};
function connect(server) {
confirmation.hidden = true;
pendingServer = null;
action('open', server);
}
form.addEventListener('submit', (event) => {
event.preventDefault();
let server;
try {
server = normalizeServer(input.value);
} catch {
status.textContent = en.invalidServer;
input.setAttribute('aria-invalid', 'true');
input.focus();
return;
}
input.removeAttribute('aria-invalid');
if (needsConfirmation(activeServer, server)) {
pendingServer = server;
confirmation.hidden = false;
document.querySelector('#confirm-switch').focus();
return;
}
connect(server);
});
document.querySelector('#confirm-switch').addEventListener('click', () => {
if (pendingServer) {
connect(pendingServer);
}
});
document.querySelector('#cancel-switch').addEventListener('click', () => {
confirmation.hidden = true;
pendingServer = null;
input.focus();
});
resume.addEventListener('click', () => action('resume'));
document.querySelector('#appearance').addEventListener('change', (event) => {
document.documentElement.dataset.appearance = event.target.value;
});
if (window.nativeLauncher) {
action('ready');
}
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 64 64">
<rect width="64" height="64" rx="12" fill="#22c55e" />
<g fill="none" stroke="#004b1e" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<rect x="16" y="16" width="32" height="32" rx="3" />
<path d="M27 16v32M37 16v32" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 363 B

+33
View File
@@ -0,0 +1,33 @@
export function normalizeServer(input) {
const value = input.trim();
if (
!/^https:\/\//i.test(value) ||
[...value].some(
(character) =>
character.charCodeAt(0) < 32 ||
character.charCodeAt(0) === 127 ||
character === '\\',
)
) {
throw new Error('invalidServer');
}
const url = new URL(value);
if (
url.protocol !== 'https:' ||
!url.hostname ||
url.username ||
url.password ||
value.includes('?') ||
value.includes('#')
) {
throw new Error('invalidServer');
}
if (!url.pathname.endsWith('/')) {
url.pathname += '/';
}
return url.href;
}
export function needsConfirmation(activeServer, nextServer) {
return activeServer !== null && activeServer !== nextServer;
}
+180
View File
@@ -0,0 +1,180 @@
:root {
--surface: #f7f9fb;
--foreground: #191c1e;
--muted: #505f76;
--primary: #006e2f;
--border: #bccbb9;
--control: #ffffff;
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
color-scheme: light dark;
color: var(--foreground);
background: var(--surface);
font-synthesis: none;
}
:root[data-appearance='light'] {
color-scheme: light;
}
:root[data-appearance='dark'] {
color-scheme: dark;
--surface: #0f172a;
--foreground: #f8fafc;
--muted: #b7c8e1;
--primary: #4ae176;
--border: #334155;
--control: #1e293b;
}
@media (prefers-color-scheme: dark) {
:root:not([data-appearance='light']) {
--surface: #0f172a;
--foreground: #f8fafc;
--muted: #b7c8e1;
--primary: #4ae176;
--border: #334155;
--control: #1e293b;
}
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
padding: 32px;
}
main {
width: min(100%, 380px);
}
.brand {
display: block;
margin-bottom: 24px;
}
.eyebrow {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--primary);
}
h1 {
margin: 12px 0;
font-size: 28px;
font-weight: 600;
line-height: 1.2;
letter-spacing: -0.02em;
}
.description,
.server,
.appearance {
color: var(--muted);
font-size: 14px;
line-height: 1.5;
}
.open-workspace {
display: block;
margin-top: 28px;
padding: 12px 20px;
border-radius: 4px;
color: #004b1e;
background: #22c55e;
text-align: center;
text-decoration: none;
font-size: 14px;
font-weight: 600;
}
.open-workspace:hover {
background: #4ae176;
}
:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 4px;
}
.server {
text-align: center;
font-size: 12px;
}
.appearance {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 32px;
}
select {
min-height: 36px;
padding: 4px 10px;
font: inherit;
border: 1px solid var(--border);
border-radius: 4px;
color: inherit;
background: var(--control);
}
[hidden] {
display: none !important;
}
form {
margin-top: 24px;
}
label {
font-size: 14px;
font-weight: 600;
}
input,
button {
min-height: 44px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 4px;
font: inherit;
color: var(--foreground);
background: var(--control);
}
input {
display: block;
width: 100%;
margin-top: 8px;
font-size: 16px;
}
button {
cursor: pointer;
}
button:disabled {
cursor: wait;
opacity: 0.65;
}
.open-workspace {
width: 100%;
border: 0;
margin-top: 16px;
}
#switch-confirmation {
border: 1px solid var(--border);
border-radius: 4px;
padding: 12px;
margin-bottom: 16px;
}
+69 -28
View File
@@ -1,39 +1,80 @@
<script setup lang="ts">
import { Monitor, Moon, Sun } from 'lucide-vue-next';
import { Check, Monitor, Moon, Sun } from 'lucide-vue-next';
import { computed } from 'vue';
import { useAppearance } from '@/composables/useAppearance';
import { useLocalization } from '@/composables/useLocalization';
const { appearance, updateAppearance } = useAppearance();
const { t } = useLocalization();
const tabs = [
{ value: 'light', Icon: Sun, label: 'appearance.light' },
{ value: 'dark', Icon: Moon, label: 'appearance.dark' },
{ value: 'system', Icon: Monitor, label: 'appearance.system' },
] as const;
const tabs = computed(() => [
{
value: 'light' as const,
Icon: Sun,
label: t('appearance.light'),
description: t('appearance.lightDescription'),
},
{
value: 'dark' as const,
Icon: Moon,
label: t('appearance.dark'),
description: t('appearance.darkDescription'),
},
{
value: 'system' as const,
Icon: Monitor,
label: t('appearance.system'),
description: t('appearance.systemDescription'),
},
]);
</script>
<template>
<div
role="group"
:aria-label="t('appearance.label')"
class="border-border bg-muted inline-flex max-w-full gap-1 rounded-lg border p-1"
>
<button
v-for="{ value, Icon, label } in tabs"
:key="value"
type="button"
:aria-pressed="appearance === value"
@click="updateAppearance(value)"
:class="[
'min-h-control text-body focus-visible:outline-ring flex min-w-0 items-center justify-center gap-1.5 rounded-md px-3 font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2',
appearance === value
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
]"
>
<component :is="Icon" class="size-4 shrink-0" aria-hidden="true" />
<span>{{ t(label) }}</span>
</button>
</div>
<fieldset class="min-w-0">
<legend class="sr-only">{{ t('appearance.label') }}</legend>
<div class="grid min-w-0 gap-3 sm:grid-cols-3">
<label
v-for="{ value, Icon, label, description } in tabs"
:key="value"
class="relative min-w-0"
>
<input
type="radio"
name="appearance"
:value="value"
:checked="appearance === value"
:aria-describedby="`appearance-${value}-description`"
class="peer sr-only"
@change="updateAppearance(value)"
/>
<span
class="peer-checked:border-primary peer-checked:bg-accent/50 peer-focus-visible:ring-ring/40 gap-stack bg-background hover:border-primary/50 flex h-full min-h-32 cursor-pointer flex-col rounded-lg border p-4 transition-colors peer-focus-visible:ring-2"
>
<span class="flex items-center justify-between gap-3">
<span
class="bg-accent text-primary flex size-10 items-center justify-center rounded-lg"
>
<component
:is="Icon"
class="size-5"
aria-hidden="true"
/>
</span>
<Check
v-if="appearance === value"
class="text-primary size-5"
aria-hidden="true"
/>
</span>
<span class="font-semibold">{{ label }}</span>
<span
:id="`appearance-${value}-description`"
class="text-caption text-muted-foreground"
>
{{ description }}
</span>
</span>
</label>
</div>
</fieldset>
</template>
+91 -84
View File
@@ -2,10 +2,10 @@
import { Form } from '@inertiajs/vue3';
import { useTemplateRef } from 'vue';
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue';
import PasswordInput from '@/components/PasswordInput.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import {
Dialog,
DialogClose,
@@ -17,97 +17,104 @@ import {
DialogTrigger,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { useLocalization } from '@/composables/useLocalization';
const passwordInput = useTemplateRef('passwordInput');
const { t } = useLocalization();
</script>
<template>
<div class="space-y-6">
<Heading
variant="small"
title="Delete account"
description="Delete your account and all of its resources"
/>
<div
class="space-y-4 rounded-lg border border-red-100 bg-red-50 p-4 dark:border-red-200/10 dark:bg-red-700/10"
>
<div class="relative space-y-0.5 text-red-600 dark:text-red-100">
<p class="font-medium">Warning</p>
<p class="text-sm">
Please proceed with caution, this cannot be undone.
</p>
</div>
<Dialog>
<DialogTrigger as-child>
<Button variant="destructive" data-test="delete-user-button"
>Delete account</Button
>
</DialogTrigger>
<DialogContent>
<Form
v-bind="ProfileController.destroy.form()"
reset-on-success
@error="() => passwordInput?.focus()"
:options="{
preserveScroll: true,
}"
class="space-y-6"
v-slot="{ errors, processing, reset, clearErrors }"
>
<DialogHeader class="space-y-3">
<DialogTitle
>Are you sure you want to delete your
account?</DialogTitle
>
<DialogDescription>
Once your account is deleted, all of its
resources and data will also be permanently
deleted. Please enter your password to confirm
you would like to permanently delete your
account.
</DialogDescription>
</DialogHeader>
<Card
class="border-destructive/35 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
>
<CardHeader class="gap-stack border-b p-5 sm:p-6">
<h2 class="text-title">{{ t('profile.delete.title') }}</h2>
<p class="text-muted-foreground">
{{ t('profile.delete.description') }}
</p>
</CardHeader>
<CardContent class="p-5 sm:p-6">
<div
class="bg-destructive/8 border-destructive/25 gap-column grid rounded-lg border p-4"
>
<div class="gap-stack grid">
<p class="text-destructive font-semibold">
{{ t('profile.delete.warning') }}
</p>
<p class="text-body text-muted-foreground">
{{ t('profile.delete.warningDescription') }}
</p>
</div>
<Dialog>
<DialogTrigger as-child>
<Button
variant="destructive"
class="justify-self-start"
data-test="delete-user-button"
>{{ t('profile.delete.action') }}</Button
>
</DialogTrigger>
<DialogContent>
<Form
v-bind="ProfileController.destroy.form()"
reset-on-success
@error="() => passwordInput?.focus()"
:options="{
preserveScroll: true,
}"
class="space-y-6"
v-slot="{ errors, processing, reset, clearErrors }"
>
<DialogHeader class="space-y-3">
<DialogTitle>{{
t('profile.delete.confirmTitle')
}}</DialogTitle>
<DialogDescription>
{{ t('profile.delete.confirmDescription') }}
</DialogDescription>
</DialogHeader>
<div class="grid gap-2">
<Label for="password" class="sr-only"
>Password</Label
>
<PasswordInput
id="password"
name="password"
ref="passwordInput"
placeholder="Password"
/>
<InputError :message="errors.password" />
</div>
<div class="grid gap-2">
<Label for="password" class="sr-only">{{
t('profile.delete.password')
}}</Label>
<PasswordInput
id="password"
name="password"
ref="passwordInput"
:placeholder="t('profile.delete.password')"
/>
<InputError :message="errors.password" />
</div>
<DialogFooter class="gap-2">
<DialogClose as-child>
<Button
variant="secondary"
@click="
() => {
clearErrors();
reset();
}
"
>
{{ t('common.cancel') }}
</Button>
</DialogClose>
<DialogFooter class="gap-2">
<DialogClose as-child>
<Button
variant="secondary"
@click="
() => {
clearErrors();
reset();
}
"
type="submit"
variant="destructive"
:disabled="processing"
data-test="confirm-delete-user-button"
>
Cancel
{{ t('profile.delete.confirmAction') }}
</Button>
</DialogClose>
<Button
type="submit"
variant="destructive"
:disabled="processing"
data-test="confirm-delete-user-button"
>
Delete account
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
</div>
</div>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
</div>
</CardContent>
</Card>
</template>
+369
View File
@@ -0,0 +1,369 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { KeyRound, UserRound } from 'lucide-vue-next';
import { computed, ref } from 'vue';
import {
save,
store,
} from '@/actions/App/Http/Controllers/Admin/UserManagementController';
import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.vue';
import PasswordInput from '@/components/PasswordInput.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import type { UserModel } from '@/types/models/user-model';
const props = withDefaults(
defineProps<{
mode: 'create' | 'edit';
user?: UserModel | null;
}>(),
{ user: null },
);
const emit = defineEmits<{ cancel: [] }>();
const { t } = useLocalization();
const isEdit = computed(() => props.mode === 'edit');
const confirmationOpen = ref(false);
const form = useForm(
'ManagedUser:' + props.mode + ':' + (props.user?.id ?? 'new'),
{
name: props.user?.name ?? '',
email: props.user?.email ?? '',
password: '',
password_confirmation: '',
must_change_password_on_next_login:
props.user?.must_change_password_on_next_login ?? true,
},
);
const protection = useFormProtection(form);
function performSubmit(): void {
confirmationOpen.value = false;
protection.submit(
isEdit.value && props.user ? save(props.user.id) : store(),
{ preserveScroll: true },
);
}
function requestSubmit(): void {
if (isEdit.value) {
confirmationOpen.value = true;
return;
}
performSubmit();
}
function updateConfirmation(open: boolean): void {
if (!form.processing) {
confirmationOpen.value = open;
}
}
</script>
<template>
<Card class="gap-0 overflow-hidden rounded-xl py-0 shadow-none">
<CardHeader class="gap-stack border-b p-5 sm:p-6">
<div class="flex items-start gap-3">
<span
class="bg-accent text-primary flex size-10 shrink-0 items-center justify-center rounded-md"
>
<UserRound class="size-5" aria-hidden="true" />
</span>
<div class="gap-stack grid min-w-0">
<h2 class="text-title">
{{ t('adminUsers.form.detailsTitle') }}
</h2>
<p class="text-muted-foreground">
{{
t(
isEdit
? 'adminUsers.form.editDescription'
: 'adminUsers.form.createDescription',
)
}}
</p>
</div>
</div>
</CardHeader>
<CardContent class="p-5 sm:p-6">
<form
class="gap-page grid min-w-0"
:aria-busy="form.processing"
@submit.prevent="requestSubmit"
>
<FormFeedback
:state="protection.state.value"
@retry="performSubmit"
/>
<fieldset
class="gap-column grid min-w-0"
:disabled="form.processing"
>
<legend class="sr-only">
{{ t('adminUsers.form.detailsTitle') }}
</legend>
<div class="grid gap-5 sm:grid-cols-2">
<div class="grid min-w-0 gap-2">
<Label for="managed-user-name">{{
t('adminUsers.form.name')
}}</Label>
<Input
id="managed-user-name"
v-model="form.name"
name="name"
required
autofocus
autocomplete="name"
:placeholder="
t('adminUsers.form.namePlaceholder')
"
:aria-invalid="!!form.errors.name"
aria-describedby="managed-user-name-error"
/>
<InputError
id="managed-user-name-error"
:message="form.errors.name"
/>
</div>
<div class="grid min-w-0 gap-2">
<Label for="managed-user-email">{{
t('adminUsers.form.email')
}}</Label>
<Input
id="managed-user-email"
v-model="form.email"
name="email"
type="email"
required
autocomplete="email"
:placeholder="
t('adminUsers.form.emailPlaceholder')
"
:aria-invalid="!!form.errors.email"
aria-describedby="managed-user-email-error"
/>
<InputError
id="managed-user-email-error"
:message="form.errors.email"
/>
</div>
</div>
<section
aria-labelledby="managed-user-password-title"
class="gap-column grid min-w-0 border-t pt-6"
>
<div class="flex items-start gap-3">
<KeyRound
class="text-muted-foreground mt-0.5 size-5 shrink-0"
aria-hidden="true"
/>
<div class="gap-stack grid min-w-0">
<h3
id="managed-user-password-title"
class="font-semibold"
>
{{ t('adminUsers.form.passwordTitle') }}
</h3>
<p class="text-caption text-muted-foreground">
{{
t(
isEdit
? 'adminUsers.form.passwordEditHelp'
: 'adminUsers.form.passwordCreateHelp',
)
}}
</p>
</div>
</div>
<div class="grid gap-5 sm:grid-cols-2">
<div class="grid min-w-0 gap-2">
<Label for="managed-user-password">{{
t(
isEdit
? 'adminUsers.form.newPassword'
: 'adminUsers.form.password',
)
}}</Label>
<PasswordInput
id="managed-user-password"
v-model="form.password"
name="password"
:required="!isEdit"
autocomplete="new-password"
:placeholder="
t(
isEdit
? 'adminUsers.form.passwordEditPlaceholder'
: 'adminUsers.form.passwordPlaceholder',
)
"
:aria-invalid="!!form.errors.password"
aria-describedby="managed-user-password-error"
/>
<InputError
id="managed-user-password-error"
:message="form.errors.password"
/>
</div>
<div class="grid min-w-0 gap-2">
<Label
for="managed-user-password-confirmation"
>{{
t('adminUsers.form.confirmPassword')
}}</Label
>
<PasswordInput
id="managed-user-password-confirmation"
v-model="form.password_confirmation"
name="password_confirmation"
:required="!isEdit"
autocomplete="new-password"
:placeholder="
t(
'adminUsers.form.confirmPasswordPlaceholder',
)
"
:aria-invalid="
!!form.errors.password_confirmation
"
aria-describedby="managed-user-password-confirmation-error"
/>
<InputError
id="managed-user-password-confirmation-error"
:message="form.errors.password_confirmation"
/>
</div>
</div>
</section>
<div class="grid gap-2 border-t pt-6">
<Label
for="managed-user-force-password-change"
class="bg-muted/35 flex cursor-pointer items-start gap-3 rounded-lg border p-4"
>
<Checkbox
id="managed-user-force-password-change"
v-model="
form.must_change_password_on_next_login
"
name="must_change_password_on_next_login"
:invalid="
!!form.errors
.must_change_password_on_next_login
"
aria-describedby="managed-user-force-password-change-help managed-user-force-password-change-error"
/>
<span class="grid gap-1">
<span>{{
t('adminUsers.form.forcePasswordChange')
}}</span>
<span
id="managed-user-force-password-change-help"
class="text-caption text-muted-foreground font-normal"
>
{{
t(
'adminUsers.form.forcePasswordChangeHelp',
)
}}
</span>
</span>
</Label>
<InputError
id="managed-user-force-password-change-error"
:message="
form.errors.must_change_password_on_next_login
"
/>
</div>
</fieldset>
<div
class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end"
>
<Button
type="button"
variant="ghost"
@click="emit('cancel')"
>
{{ t('common.cancel') }}
</Button>
<Button
type="submit"
:loading="form.processing"
class="sm:min-w-36"
data-test="managed-user-submit"
>
{{
t(
isEdit
? 'adminUsers.form.reviewChanges'
: 'adminUsers.form.createAction',
)
}}
</Button>
</div>
</form>
</CardContent>
</Card>
<Dialog :open="confirmationOpen" @update:open="updateConfirmation">
<DialogContent>
<DialogHeader>
<DialogTitle>{{
t('adminUsers.form.confirmTitle', {
name: form.name || props.user?.name || '',
})
}}</DialogTitle>
<DialogDescription>
{{ t('adminUsers.form.confirmDescription') }}
</DialogDescription>
</DialogHeader>
<div
class="border-warning/35 bg-warning/8 text-body rounded-md border p-4"
>
{{ t('adminUsers.form.confirmWarning') }}
</div>
<DialogFooter>
<Button
type="button"
variant="secondary"
:disabled="form.processing"
@click="confirmationOpen = false"
>
{{ t('common.cancel') }}
</Button>
<Button
type="button"
:loading="form.processing"
data-test="confirm-managed-user-update"
@click="performSubmit"
>
{{ t('adminUsers.form.confirmAction') }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
+150 -104
View File
@@ -1,123 +1,169 @@
<script setup lang="ts">
import { Form } from '@inertiajs/vue3';
import { useForm } from '@inertiajs/vue3';
import { Eye, EyeOff, LockKeyhole, RefreshCw } from 'lucide-vue-next';
import { nextTick, onMounted, ref, useTemplateRef } from 'vue';
import { ref } from 'vue';
import AlertError from '@/components/AlertError.vue';
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
import { regenerateRecoveryCodes } from '@/routes/two-factor';
const { recoveryCodesList, fetchRecoveryCodes, errors } = useTwoFactorAuth();
const isRecoveryCodesVisible = ref<boolean>(false);
const recoveryCodeSectionRef = useTemplateRef('recoveryCodeSectionRef');
const { t } = useLocalization();
const {
recoveryCodesList,
fetchRecoveryCodes,
clearRecoveryCodes,
errors,
loading,
} = useTwoFactorAuth();
const visible = ref(false);
const showRegenerate = ref(false);
const form = useForm({});
const protection = useFormProtection(form);
const toggleRecoveryCodesVisibility = async () => {
if (!isRecoveryCodesVisible.value && !recoveryCodesList.value.length) {
await fetchRecoveryCodes();
function hide(): void {
visible.value = false;
clearRecoveryCodes();
}
function toggle(): void {
if (visible.value) {
hide();
} else {
visible.value = true;
void fetchRecoveryCodes();
}
isRecoveryCodesVisible.value = !isRecoveryCodesVisible.value;
if (isRecoveryCodesVisible.value) {
await nextTick();
recoveryCodeSectionRef.value?.scrollIntoView({ behavior: 'smooth' });
}
function closeRegenerate(open: boolean): void {
if (!form.processing) {
showRegenerate.value = open;
protection.clearFeedback();
}
};
onMounted(async () => {
if (!recoveryCodesList.value.length) {
await fetchRecoveryCodes();
}
});
}
function regenerate(): void {
protection.submit(regenerateRecoveryCodes(), {
preserveScroll: true,
onBefore: hide,
onSuccess: () => {
showRegenerate.value = false;
visible.value = true;
return fetchRecoveryCodes();
},
});
}
</script>
<template>
<Card class="w-full">
<CardHeader>
<CardTitle class="flex gap-3">
<LockKeyhole class="size-4" />2FA recovery codes
</CardTitle>
<CardDescription>
Recovery codes let you regain access if you lose your 2FA
device. Store them in a secure password manager.
</CardDescription>
</CardHeader>
<CardContent>
<div
class="flex select-none flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
<section
class="gap-column grid min-w-0 rounded-lg border p-4"
aria-labelledby="recovery-title"
>
<div class="gap-stack grid">
<h3
id="recovery-title"
class="flex items-center gap-2 font-semibold"
>
<Button @click="toggleRecoveryCodesVisibility" class="w-fit">
<component
:is="isRecoveryCodesVisible ? EyeOff : Eye"
class="size-4"
/>
{{ isRecoveryCodesVisible ? 'Hide' : 'View' }} recovery
codes
</Button>
<Form
v-if="isRecoveryCodesVisible && recoveryCodesList.length"
v-bind="regenerateRecoveryCodes.form()"
method="post"
:options="{ preserveScroll: true }"
@success="fetchRecoveryCodes"
#default="{ processing }"
<LockKeyhole class="text-primary size-4" />{{
t('security.recoveryTitle')
}}
</h3>
<p class="text-body text-muted-foreground">
{{ t('security.recoveryDescription') }}
</p>
</div>
<div class="flex flex-wrap gap-3">
<Button
variant="outline"
:aria-expanded="visible"
aria-controls="recovery-codes"
:disabled="form.processing"
@click="toggle"
><component :is="visible ? EyeOff : Eye" />{{
t(
visible
? 'security.hideRecovery'
: 'security.viewRecovery',
)
}}</Button
>
<Button
v-if="visible && recoveryCodesList.length"
variant="secondary"
@click="showRegenerate = true"
><RefreshCw />{{ t('security.regenerate') }}</Button
>
</div>
<div v-if="visible" id="recovery-codes" class="gap-stack grid">
<p
v-if="loading"
role="status"
class="bg-muted animate-pulse rounded-lg p-4"
>
{{ t('security.loadingRecovery') }}
</p>
<template v-else-if="errors.length">
<AlertError :errors="errors" />
<Button
class="justify-self-start"
variant="outline"
@click="fetchRecoveryCodes"
>{{ t('common.retry') }}</Button
>
<Button
variant="secondary"
type="submit"
:disabled="processing"
>
<RefreshCw /> Regenerate codes
</Button>
</Form>
</div>
<div
:class="[
'relative overflow-hidden transition-all duration-300',
isRecoveryCodesVisible
? 'h-auto opacity-100'
: 'h-0 opacity-0',
]"
</template>
<template v-else>
<ul
class="bg-muted grid gap-2 rounded-lg p-4 font-mono text-sm sm:grid-cols-2"
:aria-label="t('security.recoveryTitle')"
>
<li v-for="code in recoveryCodesList" :key="code">
{{ code }}
</li>
</ul>
<p class="text-caption text-muted-foreground">
{{ t('security.recoveryOnce') }}
</p>
</template>
</div>
<Dialog :open="showRegenerate" @update:open="closeRegenerate">
<DialogContent
:show-close-button="!form.processing"
@escape-key-down="form.processing && $event.preventDefault()"
@interact-outside="form.processing && $event.preventDefault()"
>
<div v-if="errors?.length" class="mt-6">
<AlertError :errors="errors" />
</div>
<div v-else class="mt-3 space-y-3">
<div
ref="recoveryCodeSectionRef"
class="bg-muted grid gap-1 rounded-lg p-4 font-mono text-sm"
<DialogHeader>
<DialogTitle>{{
t('security.regenerateTitle')
}}</DialogTitle>
<DialogDescription>{{
t('security.regenerateDescription')
}}</DialogDescription>
</DialogHeader>
<AuthFormFeedback
:state="protection.state.value"
:response-status="protection.responseStatus.value"
@retry="regenerate"
/>
<DialogFooter>
<Button
variant="outline"
:disabled="form.processing"
@click="closeRegenerate(false)"
>{{ t('common.cancel') }}</Button
>
<div v-if="!recoveryCodesList.length" class="space-y-2">
<div
v-for="n in 8"
:key="n"
class="bg-muted-foreground/20 h-4 animate-pulse rounded"
></div>
</div>
<div
v-else
v-for="(code, index) in recoveryCodesList"
:key="index"
>
{{ code }}
</div>
</div>
<p class="text-muted-foreground select-none text-xs">
Each recovery code can be used once to access your
account and will be removed after use. If you need more,
click
<span class="font-bold">Regenerate codes</span> above.
</p>
</div>
</div>
</CardContent>
</Card>
<Button :loading="form.processing" @click="regenerate">{{
t('security.regenerate')
}}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</section>
</template>
+220 -256
View File
@@ -1,297 +1,261 @@
<script setup lang="ts">
import { Form } from '@inertiajs/vue3';
import { useClipboard } from '@vueuse/core';
import { useForm } from '@inertiajs/vue3';
import { Check, Copy, ScanLine } from 'lucide-vue-next';
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue';
import AlertError from '@/components/AlertError.vue';
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from '@/components/ui/input-otp';
import { Spinner } from '@/components/ui/spinner';
import { useAppearance } from '@/composables/useAppearance';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
import { confirm } from '@/routes/two-factor';
import type { TwoFactorConfigContent } from '@/types';
type Props = {
const props = defineProps<{
requiresConfirmation: boolean;
twoFactorEnabled: boolean;
};
const { resolvedAppearance } = useAppearance();
const props = defineProps<Props>();
}>();
const isOpen = defineModel<boolean>('isOpen');
const { t } = useLocalization();
const {
qrCodeSvg,
manualSetupKey,
hasSetupData,
clearSetupData,
fetchSetupData,
errors,
loading,
} = useTwoFactorAuth();
const form = useForm({ code: '' }).dontRemember('code');
const protection = useFormProtection(form, { isDirty: () => false });
const verification = ref(false);
const copied = ref(false);
const copyError = ref(false);
const codeInput = useTemplateRef('codeInput');
const title = computed(() =>
t(
props.twoFactorEnabled
? 'security.setupEnabled'
: verification.value
? 'security.verifyTitle'
: 'security.setupTitle',
),
);
const description = computed(() =>
t(
props.twoFactorEnabled
? 'security.setupEnabledDescription'
: verification.value
? 'security.verifyDescription'
: 'security.setupDescription',
),
);
const { copy, copied } = useClipboard();
const { qrCodeSvg, manualSetupKey, clearSetupData, fetchSetupData, errors } =
useTwoFactorAuth();
const showVerificationStep = ref(false);
const code = ref<string>('');
const pinInputContainerRef = useTemplateRef('pinInputContainerRef');
const modalConfig = computed<TwoFactorConfigContent>(() => {
if (props.twoFactorEnabled) {
return {
title: 'Two-factor authentication enabled',
description:
'Two-factor authentication is now enabled. Scan the QR code or enter the setup key in your authenticator app.',
buttonText: 'Close',
};
function close(open: boolean): void {
if (!form.processing) {
isOpen.value = open;
}
if (showVerificationStep.value) {
return {
title: 'Verify authentication code',
description: 'Enter the 6-digit code from your authenticator app',
buttonText: 'Continue',
};
}
return {
title: 'Enable two-factor authentication',
description:
'To finish enabling two-factor authentication, scan the QR code or enter the setup key in your authenticator app',
buttonText: 'Continue',
};
});
const handleModalNextStep = () => {
if (props.requiresConfirmation) {
showVerificationStep.value = true;
nextTick(() => {
pinInputContainerRef.value?.querySelector('input')?.focus();
});
}
async function next(): Promise<void> {
if (props.twoFactorEnabled || !props.requiresConfirmation) {
close(false);
return;
}
clearSetupData();
isOpen.value = false;
};
const resetModalState = () => {
if (props.twoFactorEnabled) {
clearSetupData();
verification.value = true;
await nextTick();
codeInput.value?.$el?.focus();
}
function submit(): void {
protection.submit(confirm(), {
preserveScroll: true,
errorBag: 'confirmTwoFactorAuthentication',
onSuccess: () => {
isOpen.value = false;
},
onFinish: () => {
form.reset();
},
});
}
async function copyKey(): Promise<void> {
const key = manualSetupKey.value;
if (!key) {
return;
}
showVerificationStep.value = false;
code.value = '';
};
watch(
() => isOpen.value,
async (isOpen) => {
if (!isOpen) {
resetModalState();
return;
copyError.value = false;
try {
await navigator.clipboard.writeText(key);
if (isOpen.value && manualSetupKey.value === key) {
copied.value = true;
}
if (!qrCodeSvg.value) {
await fetchSetupData();
} catch {
if (isOpen.value) {
copyError.value = true;
}
},
);
}
}
watch(isOpen, (open) => {
verification.value = false;
copied.value = false;
copyError.value = false;
form.resetAndClearErrors();
protection.clearFeedback();
clearSetupData();
if (open) {
void fetchSetupData();
}
});
</script>
<template>
<Dialog :open="isOpen" @update:open="isOpen = $event">
<DialogContent class="sm:max-w-md">
<DialogHeader class="flex items-center justify-center">
<div
class="border-border bg-card mb-3 w-auto rounded-full border p-0.5 shadow-sm"
<Dialog :open="isOpen" @update:open="close">
<DialogContent
class="sm:max-w-md"
:show-close-button="!form.processing"
@escape-key-down="form.processing && $event.preventDefault()"
@interact-outside="form.processing && $event.preventDefault()"
>
<DialogHeader>
<ScanLine class="text-primary size-8" />
<DialogTitle>{{ title }}</DialogTitle>
<DialogDescription>{{ description }}</DialogDescription>
</DialogHeader>
<template v-if="!verification">
<p
v-if="loading"
role="status"
class="bg-muted animate-pulse rounded-lg p-6 text-center"
>
{{ t('security.loadingSetup') }}
</p>
<div v-else-if="errors.length" class="gap-stack grid">
<AlertError :errors="errors" />
<Button variant="outline" @click="fetchSetupData">{{
t('common.retry')
}}</Button>
</div>
<div v-else-if="hasSetupData" class="gap-column grid min-w-0">
<div
class="border-border bg-muted relative overflow-hidden rounded-full border p-2.5"
>
<div
class="absolute inset-0 grid grid-cols-5 opacity-50"
>
<div
v-for="i in 5"
:key="`col-${i}`"
class="border-border border-r last:border-r-0"
class="mx-auto aspect-square w-full max-w-56 rounded-lg border bg-white p-4 text-black"
v-html="qrCodeSvg"
/>
<div class="grid min-w-0 gap-2">
<Label for="setup-key">{{
t('security.manualKey')
}}</Label>
<div class="flex min-w-0 gap-2">
<Input
id="setup-key"
:model-value="manualSetupKey"
readonly
class="min-w-0 font-mono text-xs"
/>
<Button
variant="outline"
:aria-label="
t(
copied
? 'security.copied'
: 'security.copyKey',
)
"
@click="copyKey"
><Check v-if="copied" /><Copy v-else
/></Button>
</div>
<div
class="absolute inset-0 grid grid-rows-5 opacity-50"
<p
v-if="copied"
role="status"
class="text-caption text-success-foreground"
>
<div
v-for="i in 5"
:key="`row-${i}`"
class="border-border border-b last:border-b-0"
/>
</div>
<ScanLine
class="text-foreground relative z-20 size-6"
{{ t('security.copied') }}
</p>
<InputError
v-if="copyError"
:message="t('security.copyError')"
/>
</div>
</div>
<DialogTitle>{{ modalConfig.title }}</DialogTitle>
<DialogDescription class="text-center">
{{ modalConfig.description }}
</DialogDescription>
</DialogHeader>
<div
class="relative flex w-auto flex-col items-center justify-center space-y-5"
>
<template v-if="!showVerificationStep">
<AlertError v-if="errors?.length" :errors="errors" />
<template v-else>
<div
class="relative mx-auto flex max-w-md items-center overflow-hidden"
>
<div
class="border-border relative mx-auto aspect-square w-64 overflow-hidden rounded-lg border"
>
<div
v-if="!qrCodeSvg"
class="bg-background absolute inset-0 z-10 flex aspect-square h-auto w-full animate-pulse items-center justify-center"
>
<Spinner class="size-6" />
</div>
<div
v-else
class="relative z-10 overflow-hidden border p-5"
>
<div
v-html="qrCodeSvg"
class="flex aspect-square size-full items-center justify-center"
:style="{
filter:
resolvedAppearance === 'dark'
? 'invert(1) brightness(1.5)'
: undefined,
}"
/>
</div>
</div>
</div>
<div class="flex w-full items-center space-x-5">
<Button class="w-full" @click="handleModalNextStep">
{{ modalConfig.buttonText }}
</Button>
</div>
<div
class="relative flex w-full items-center justify-center"
>
<div
class="bg-border absolute inset-0 top-1/2 h-px w-full"
/>
<span class="bg-card relative px-2 py-1"
>or, enter the code manually</span
>
</div>
<div
class="flex w-full items-center justify-center space-x-2"
>
<div
class="border-border flex w-full items-stretch overflow-hidden rounded-xl border"
>
<div
v-if="!manualSetupKey"
class="bg-muted flex h-full w-full items-center justify-center p-3"
>
<Spinner />
</div>
<template v-else>
<input
type="text"
readonly
:value="manualSetupKey"
class="bg-background text-foreground h-full w-full p-3"
/>
<button
@click="copy(manualSetupKey || '')"
class="border-border hover:bg-muted relative block h-auto border-l px-3"
>
<Check
v-if="copied"
class="w-4 text-green-500"
/>
<Copy v-else class="w-4" />
</button>
</template>
</div>
</div>
</template>
</template>
<template v-else>
<Form
v-bind="confirm.form()"
error-bag="confirmTwoFactorAuthentication"
reset-on-error
@finish="code = ''"
@success="isOpen = false"
v-slot="{ errors, processing }"
<p
v-if="requiresConfirmation && !twoFactorEnabled"
class="text-caption text-muted-foreground"
>
{{ t('security.setupPendingDescription') }}
</p>
<DialogFooter>
<Button variant="outline" @click="close(false)">{{
t('common.close')
}}</Button>
<Button v-if="hasSetupData" @click="next">{{
t(
twoFactorEnabled || !requiresConfirmation
? 'common.done'
: 'security.continue',
)
}}</Button>
</DialogFooter>
</template>
<form v-else class="gap-column grid" @submit.prevent="submit">
<AuthFormFeedback
:state="protection.state.value"
:response-status="protection.responseStatus.value"
@retry="submit"
/>
<div class="grid gap-2">
<Label for="authentication-code">{{
t('security.authenticationCode')
}}</Label>
<Input
id="authentication-code"
ref="codeInput"
v-model="form.code"
name="code"
type="text"
inputmode="numeric"
autocomplete="one-time-code"
pattern="[0-9]{6}"
maxlength="6"
required
:disabled="form.processing"
:aria-invalid="!!form.errors.code"
aria-describedby="authentication-code-error"
class="font-mono text-lg tracking-widest"
/>
<InputError
id="authentication-code-error"
:message="form.errors.code"
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
:disabled="form.processing"
@click="
verification = false;
form.resetAndClearErrors();
protection.clearFeedback();
"
>{{ t('common.back') }}</Button
>
<input type="hidden" name="code" :value="code" />
<div
ref="pinInputContainerRef"
class="relative w-full space-y-3"
>
<div
class="flex w-full flex-col items-center justify-center space-y-3 py-2"
>
<InputOTP
id="otp"
v-model="code"
:maxlength="6"
:disabled="processing"
>
<InputOTPGroup>
<InputOTPSlot
v-for="index in 6"
:key="index"
:index="index - 1"
/>
</InputOTPGroup>
</InputOTP>
<InputError :message="errors?.code" />
</div>
<div class="flex w-full items-center space-x-5">
<Button
type="button"
variant="outline"
class="w-auto flex-1"
@click="showVerificationStep = false"
:disabled="processing"
>
Back
</Button>
<Button
type="submit"
class="w-auto flex-1"
:disabled="processing || code.length < 6"
>
Confirm
</Button>
</div>
</div>
</Form>
</template>
</div>
<Button
type="submit"
:disabled="form.code.length !== 6"
:loading="form.processing"
>{{ t('security.confirm') }}</Button
>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>
@@ -0,0 +1,363 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { computed, ref, watch } from 'vue';
import {
destroyPriority,
destroyType,
storePriority,
storeType,
updatePriority,
updateType,
} from '@/actions/App/Http/Controllers/ProjectTicketMetadataSettingsController';
import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import type { TicketPriorityModel } from '@/types/models/ticket-priority-model';
import type { TicketTypeModel } from '@/types/models/ticket-type-model';
import type { MetadataEditorTarget } from '@/types/project-metadata';
const props = defineProps<{
projectId: number;
target: MetadataEditorTarget | null;
types: TicketTypeModel[];
priorities: TicketPriorityModel[];
}>();
const emit = defineEmits<{ close: [] }>();
const { t } = useLocalization();
const form = useForm({
name: '',
position: 1,
is_parent: false,
replacement_ticket_type_id: '',
});
const protection = useFormProtection(form, {
isDirty: () => props.target !== null && form.isDirty,
});
const deletionError = ref<string | null>(null);
const deleting = computed(() => props.target?.action === 'delete');
const entries = computed(() =>
props.target?.kind === 'type' ? props.types : props.priorities,
);
const replacements = computed(() =>
props.types.filter((type) => type.id !== props.target?.entry?.id),
);
const needsReplacement = computed(
() =>
deleting.value &&
props.target?.kind === 'type' &&
(props.target.entry?.tickets_count ?? 0) > 0,
);
const title = computed(() => {
const target = props.target;
if (!target) {
return '';
}
if (target.action === 'create') {
return t(
target.kind === 'type'
? 'metadata.addType'
: 'metadata.addPriority',
);
}
return t(
target.action === 'edit'
? 'metadata.editEntry'
: 'metadata.deleteEntry',
{ name: target.entry.name },
);
});
watch(
() => props.target,
(target) => {
if (!target) {
return;
}
form.defaults({
name: target.entry?.name ?? '',
position: target.entry?.position ?? 1,
is_parent: target.entry?.is_parent ?? false,
replacement_ticket_type_id: '',
});
form.resetAndClearErrors();
protection.clearFeedback();
deletionError.value = null;
},
{ immediate: true },
);
function close(open: boolean): void {
if (open || form.processing || !protection.confirmLeave()) {
return;
}
form.resetAndClearErrors();
protection.clearFeedback();
emit('close');
}
function submit(): void {
const target = props.target;
if (
!target ||
form.processing ||
(deleting.value && entries.value.length <= 1)
) {
return;
}
const route =
target.kind === 'type'
? target.action === 'create'
? storeType(props.projectId)
: target.action === 'edit'
? updateType([props.projectId, target.entry.id])
: destroyType([props.projectId, target.entry.id])
: target.action === 'create'
? storePriority(props.projectId)
: target.action === 'edit'
? updatePriority([props.projectId, target.entry.id])
: destroyPriority([props.projectId, target.entry.id]);
form.transform((data) =>
target.action === 'delete'
? target.kind === 'type'
? {
replacement_ticket_type_id:
data.replacement_ticket_type_id || null,
}
: {}
: target.action === 'edit'
? { name: data.name, position: data.position }
: target.kind === 'type'
? { name: data.name, is_parent: data.is_parent }
: { name: data.name },
);
deletionError.value = null;
protection.submit(route, {
preserveScroll: true,
onSuccess: (page) => {
if (page.props.error) {
deletionError.value =
typeof page.props.status === 'string'
? page.props.status
: t('common.error');
return;
}
form.resetAndClearErrors();
emit('close');
},
});
}
</script>
<template>
<Dialog :open="target !== null" @update:open="close">
<DialogContent
:show-close-button="!form.processing"
@escape-key-down="form.processing && $event.preventDefault()"
@interact-outside="form.processing && $event.preventDefault()"
>
<DialogHeader>
<DialogTitle class="break-words">{{ title }}</DialogTitle>
<DialogDescription>{{
t(
deleting
? target?.kind === 'type'
? 'metadata.deleteTypeHelp'
: 'metadata.deletePriorityHelp'
: 'metadata.editHelp',
)
}}</DialogDescription>
</DialogHeader>
<form
class="grid min-w-0 gap-4"
:aria-busy="form.processing"
@submit.prevent="submit"
>
<FormFeedback
:state="deletionError ? 'idle' : protection.state.value"
@retry="submit"
/>
<p
v-if="deletionError"
role="alert"
class="text-destructive text-sm"
>
{{ deletionError }}
</p>
<fieldset
:disabled="form.processing"
class="grid min-w-0 gap-4"
>
<template v-if="!deleting">
<div class="grid gap-2">
<Label for="metadata-name">{{
t('metadata.name')
}}</Label>
<Input
id="metadata-name"
v-model="form.name"
name="name"
required
maxlength="255"
:aria-invalid="!!form.errors.name"
aria-describedby="metadata-name-error"
/>
<InputError
id="metadata-name-error"
:message="form.errors.name"
/>
</div>
<div
v-if="target?.action === 'edit'"
class="grid gap-2"
>
<Label for="metadata-position">{{
t('workflow.position')
}}</Label>
<Input
id="metadata-position"
v-model="form.position"
name="position"
type="number"
min="1"
:max="entries.length"
step="1"
required
:aria-invalid="!!form.errors.position"
aria-describedby="metadata-position-help metadata-position-error"
/>
<p
id="metadata-position-help"
class="text-caption text-muted-foreground"
>
{{
t('metadata.positionHelp', {
count: entries.length,
})
}}
</p>
<InputError
id="metadata-position-error"
:message="form.errors.position"
/>
</div>
<div v-if="target?.kind === 'type'" class="grid gap-2">
<Label
v-if="target.action === 'create'"
for="metadata-parent"
class="flex items-center gap-3"
>
<Checkbox
id="metadata-parent"
v-model="form.is_parent"
name="is_parent"
:invalid="!!form.errors.is_parent"
aria-describedby="metadata-parent-help metadata-parent-error"
/>{{ t('metadata.allowChildren') }}
</Label>
<p v-else class="text-sm font-medium">
{{
t(
form.is_parent
? 'metadata.parentType'
: 'metadata.standardType',
)
}}
</p>
<p
id="metadata-parent-help"
class="text-caption text-muted-foreground"
>
{{ t('metadata.parentFixed') }}
</p>
<InputError
id="metadata-parent-error"
:message="form.errors.is_parent"
/>
</div>
</template>
<div v-if="needsReplacement" class="grid gap-2">
<p class="text-sm">
{{
t('metadata.replacementHelp', {
count: target?.entry?.tickets_count ?? 0,
})
}}
</p>
<Label for="metadata-replacement">{{
t('metadata.replacement')
}}</Label>
<select
id="metadata-replacement"
v-model="form.replacement_ticket_type_id"
name="replacement_ticket_type_id"
required
class="border-input bg-background focus-visible:ring-ring min-h-11 w-full min-w-0 rounded-md border px-3 text-sm focus-visible:ring-2"
:aria-invalid="
!!form.errors.replacement_ticket_type_id
"
aria-describedby="metadata-replacement-help metadata-replacement-error"
>
<option value="">
{{ t('metadata.chooseType') }}
</option>
<option
v-for="entry in replacements"
:key="entry.id"
:value="entry.id"
>
{{ entry.name
}}{{
entry.is_parent
? ` (${t('metadata.parentType')})`
: ''
}}
</option>
</select>
<p
id="metadata-replacement-help"
class="text-caption text-muted-foreground"
>
{{ t('metadata.parentReplacement') }}
</p>
</div>
<InputError
id="metadata-replacement-error"
:message="form.errors.replacement_ticket_type_id"
/>
</fieldset>
<DialogFooter>
<Button
type="button"
variant="outline"
:disabled="form.processing"
@click="close(false)"
>{{ t('common.cancel') }}</Button
>
<Button
type="submit"
:variant="deleting ? 'destructive' : 'default'"
:loading="form.processing"
:disabled="deleting && entries.length <= 1"
>{{
deleting
? t('workflow.confirmDelete')
: target?.action === 'create'
? title
: t('common.save')
}}</Button
>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>
@@ -0,0 +1,147 @@
<script setup lang="ts">
import { LockKeyhole, Pencil, Plus, Trash2 } from 'lucide-vue-next';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { useLocalization } from '@/composables/useLocalization';
import type {
MetadataEditorTarget,
MetadataEntry,
MetadataKind,
} from '@/types/project-metadata';
defineProps<{ kind: MetadataKind; entries: MetadataEntry[] }>();
defineEmits<{ edit: [target: MetadataEditorTarget] }>();
const { t } = useLocalization();
</script>
<template>
<section
class="bg-card grid min-w-0 gap-4 overflow-hidden rounded-xl border p-4 sm:p-6"
:aria-labelledby="`metadata-${kind}`"
>
<header class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-2">
<h3 :id="`metadata-${kind}`" class="font-semibold">
{{
t(
kind === 'type'
? 'metadata.types'
: 'metadata.priorities',
)
}}
</h3>
<Badge variant="secondary">{{ entries.length }}</Badge>
</div>
<Button
type="button"
variant="outline"
class="w-full sm:w-auto"
@click="$emit('edit', { kind, action: 'create' })"
>
<Plus class="size-4" aria-hidden="true" />{{
t(
kind === 'type'
? 'metadata.addType'
: 'metadata.addPriority',
)
}}
</Button>
</header>
<p class="text-muted-foreground text-sm">
{{
t(
kind === 'type'
? 'metadata.typesDescription'
: 'metadata.prioritiesDescription',
)
}}
</p>
<ol
class="divide-y"
:aria-label="
t(kind === 'type' ? 'metadata.types' : 'metadata.priorities')
"
>
<li
v-for="entry in entries"
:key="entry.id"
class="flex min-w-0 flex-wrap items-center gap-3 py-4"
:data-metadata-entry="`${kind}-${entry.id}`"
>
<span
class="bg-accent text-accent-foreground flex size-9 shrink-0 items-center justify-center rounded-lg text-sm font-semibold"
:aria-label="
t('workflow.positionValue', {
position: entry.position,
})
"
>{{ entry.position }}</span
>
<div class="grid min-w-0 flex-1 basis-28 gap-1">
<p class="break-words font-medium">{{ entry.name }}</p>
<div
v-if="kind === 'type'"
class="text-caption text-muted-foreground flex flex-wrap items-center gap-2"
>
<Badge variant="outline">{{
t(
entry.is_parent
? 'metadata.parentType'
: 'metadata.standardType',
)
}}</Badge>
<span>{{
t('metadata.assignedTickets', {
count: entry.tickets_count ?? 0,
})
}}</span>
</div>
</div>
<div class="flex shrink-0 gap-1">
<Button
type="button"
variant="ghost"
size="icon"
:aria-label="
t('metadata.editEntry', { name: entry.name })
"
@click="$emit('edit', { kind, action: 'edit', entry })"
><Pencil class="size-4" aria-hidden="true"
/></Button>
<Button
type="button"
variant="ghost"
size="icon"
class="text-destructive"
:disabled="entries.length <= 1"
:aria-label="
t('metadata.deleteEntry', { name: entry.name })
"
:aria-describedby="
entries.length <= 1
? `metadata-${kind}-protection`
: undefined
"
@click="
$emit('edit', { kind, action: 'delete', entry })
"
><Trash2 class="size-4" aria-hidden="true"
/></Button>
</div>
</li>
</ol>
<p
v-if="entries.length <= 1"
:id="`metadata-${kind}-protection`"
class="text-caption text-muted-foreground flex items-start gap-2"
>
<LockKeyhole class="size-4 shrink-0" aria-hidden="true" />{{
t(
kind === 'type'
? 'metadata.lastType'
: 'metadata.lastPriority',
)
}}
</p>
</section>
</template>
@@ -0,0 +1,632 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { computed, watch } from 'vue';
import {
destroy,
store,
update,
} from '@/actions/App/Http/Controllers/ProjectNotificationSettingsController';
import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { notificationLabel } from '@/lib/notification-labels';
import { useLocalization } from '@/composables/useLocalization';
import {
serializeNotificationFilters,
supportsFilterValue,
} from '@/lib/notification-filters';
import type { ProjectNotificationSettingsPageProps } from '@/types';
import type {
NotificationEditorTarget,
NotificationFilterFormState,
} from '@/types/project-notifications';
const props = defineProps<{
target: NotificationEditorTarget | null;
settings: ProjectNotificationSettingsPageProps;
}>();
const emit = defineEmits<{ close: [] }>();
const { t } = useLocalization();
const form = useForm({
name: '',
event_type: '',
channel: '',
enabled: true,
delivery_url: '',
message_template: '',
allowed_mentions: [] as string[],
filters: [] as NotificationFilterFormState[],
});
const protection = useFormProtection(form, {
isDirty: () => props.target !== null && form.isDirty,
});
const deleting = computed(() => props.target?.action === 'delete');
const title = computed(() =>
t(
deleting.value
? 'notifications.delete'
: props.target?.action === 'edit'
? 'notifications.edit'
: 'notifications.create',
),
);
const inputClass =
'border-input bg-background focus-visible:ring-ring min-h-11 w-full min-w-0 rounded-md border px-3 py-2 text-sm focus-visible:ring-2';
let nextFilterId = 0;
watch(
() => props.target,
(target) => {
if (!target) {
return;
}
const entry = target.entry;
const mentions = entry?.channel_configuration.allowed_mentions;
form.defaults({
name: entry?.name ?? '',
event_type:
entry?.event_type ??
props.settings.eventTypeOptions[0]?.value ??
'',
channel:
entry?.channel ??
props.settings.channelOptions.find(
(option) => option.value === 'webhook',
)?.value ??
props.settings.channelOptions[0]?.value ??
'',
enabled: entry?.enabled ?? true,
delivery_url: '',
message_template: entry?.message_template ?? '',
allowed_mentions: Array.isArray(mentions)
? mentions.filter(
(value): value is string => typeof value === 'string',
)
: [],
filters: (entry?.filters ?? []).map((filter) => ({
id: nextFilterId++,
attribute: filter.attribute,
operator: filter.operator,
value: Array.isArray(filter.value)
? filter.value.join(', ')
: (filter.value ?? ''),
})),
});
form.resetAndClearErrors();
protection.clearFeedback();
},
{ immediate: true },
);
function close(open: boolean): void {
if (open || form.processing || !protection.confirmLeave()) {
return;
}
form.resetAndClearErrors();
protection.clearFeedback();
emit('close');
}
function addFilter(): void {
form.filters.push({
id: nextFilterId++,
attribute: props.settings.filterAttributeOptions[0]?.value ?? '',
operator: 'equals',
value: '',
});
}
function removeFilter(index: number): void {
form.filters.splice(index, 1);
form.clearErrors(
...(Object.keys(form.errors).filter((key) =>
key.startsWith('filters'),
) as (keyof typeof form.errors)[]),
);
}
function errorFor(prefix: string): string | undefined {
return Object.entries(form.errors).find(
([key]) => key === prefix || key.startsWith(`${prefix}.`),
)?.[1];
}
function submit(): void {
const target = props.target;
if (!target || form.processing) {
return;
}
const project = props.settings.project.id;
const route =
target.action === 'create'
? store(project)
: target.action === 'edit'
? update([project, target.entry.id])
: destroy([project, target.entry.id]);
form.transform((data) =>
deleting.value
? {}
: {
name: data.name,
event_type: data.event_type,
channel: data.channel,
enabled: data.enabled,
delivery_url: data.delivery_url || null,
channel_configuration:
data.channel === 'discord'
? { allowed_mentions: data.allowed_mentions }
: {},
message_template:
data.channel === 'discord' ? data.message_template : null,
filters: serializeNotificationFilters(data.filters),
},
);
protection.submit(route, {
preserveScroll: true,
onSuccess: () => {
form.resetAndClearErrors();
emit('close');
},
});
}
</script>
<template>
<Dialog :open="target !== null" @update:open="close">
<DialogContent
class="sm:max-w-2xl"
:show-close-button="!form.processing"
@escape-key-down="form.processing && $event.preventDefault()"
@interact-outside="form.processing && $event.preventDefault()"
>
<DialogHeader>
<DialogTitle>{{ title }}</DialogTitle>
<DialogDescription>{{
deleting
? t('notifications.deleteHelp', {
name: target?.entry?.name ?? '',
})
: t('notifications.editorHelp')
}}</DialogDescription>
</DialogHeader>
<form
class="grid min-w-0 gap-5"
:aria-busy="form.processing"
@submit.prevent="submit"
>
<FormFeedback :state="protection.state.value" @retry="submit" />
<fieldset
v-if="!deleting"
:disabled="form.processing"
class="grid min-w-0 gap-5"
>
<div class="grid gap-2">
<Label for="notification-name">{{
t('notifications.name')
}}</Label>
<Input
id="notification-name"
v-model="form.name"
name="name"
required
maxlength="255"
:aria-invalid="!!form.errors.name"
aria-describedby="notification-name-error"
/>
<InputError
id="notification-name-error"
:message="form.errors.name"
/>
</div>
<div class="grid min-w-0 gap-4 sm:grid-cols-2">
<div class="grid min-w-0 gap-2">
<Label for="notification-event">{{
t('notifications.event')
}}</Label>
<select
id="notification-event"
v-model="form.event_type"
name="event_type"
:class="inputClass"
:aria-invalid="!!form.errors.event_type"
aria-describedby="notification-event-error"
>
<option
v-for="option in settings.eventTypeOptions"
:key="option.value"
:value="option.value"
>
{{
notificationLabel(
t,
'event',
option.value,
option.label,
)
}}
</option>
</select>
<InputError
id="notification-event-error"
:message="form.errors.event_type"
/>
</div>
<div class="grid min-w-0 gap-2">
<Label for="notification-channel">{{
t('notifications.channel')
}}</Label>
<select
id="notification-channel"
v-model="form.channel"
name="channel"
:class="inputClass"
:aria-invalid="!!form.errors.channel"
aria-describedby="notification-channel-error"
>
<option
v-for="option in settings.channelOptions"
:key="option.value"
:value="option.value"
>
{{
notificationLabel(
t,
'channel',
option.value,
option.label,
)
}}
</option>
</select>
<InputError
id="notification-channel-error"
:message="form.errors.channel"
/>
</div>
</div>
<section
class="bg-muted/25 grid min-w-0 gap-3 rounded-lg border p-4"
aria-labelledby="notification-delivery-title"
>
<h3
id="notification-delivery-title"
class="font-semibold"
>
{{ t('notifications.delivery') }}
</h3>
<p v-if="target?.action === 'edit'" class="text-sm">
{{
t(
target.entry.has_delivery_url
? 'notifications.keepUrl'
: 'notifications.urlMissing',
)
}}
</p>
<Label for="notification-url">{{
t(
target?.action === 'edit'
? 'notifications.replaceUrl'
: 'notifications.url',
)
}}</Label>
<Input
id="notification-url"
v-model="form.delivery_url"
name="delivery_url"
type="password"
autocomplete="new-password"
:spellcheck="false"
:required="target?.action === 'create'"
maxlength="2048"
:aria-invalid="!!form.errors.delivery_url"
aria-describedby="notification-url-help notification-url-error"
/>
<p
id="notification-url-help"
class="text-caption text-muted-foreground"
>
{{ t('notifications.secretHelp') }}
</p>
<InputError
id="notification-url-error"
:message="form.errors.delivery_url"
/>
</section>
<section
v-if="form.channel === 'discord'"
class="grid min-w-0 gap-4"
aria-labelledby="notification-message-title"
>
<div class="grid gap-2">
<Label
id="notification-message-title"
for="notification-message"
>{{ t('notifications.message') }}</Label
>
<textarea
id="notification-message"
v-model="form.message_template"
name="message_template"
rows="4"
required
maxlength="2000"
:class="inputClass"
:aria-invalid="!!form.errors.message_template"
aria-describedby="notification-message-help notification-message-error"
/>
<p
id="notification-message-help"
class="text-caption text-muted-foreground"
>
{{ t('notifications.messageHelp') }}
</p>
<InputError
id="notification-message-error"
:message="form.errors.message_template"
/>
</div>
<details class="rounded-lg border p-3">
<summary
class="focus-visible:ring-ring cursor-pointer rounded font-medium focus-visible:ring-2"
>
{{ t('notifications.variables') }}
</summary>
<dl class="mt-3 grid min-w-0 gap-3 text-sm">
<div
v-for="variable in settings.templateVariables"
:key="variable.value"
class="grid min-w-0 gap-1"
>
<dt class="break-all font-mono">
{{ variable.value }}
</dt>
<dd class="text-muted-foreground">
{{
notificationLabel(
t,
'variable',
variable.value.slice(1),
variable.description,
)
}}
</dd>
</div>
</dl>
</details>
<fieldset
class="grid gap-2"
aria-describedby="notification-mentions-help notification-mentions-error"
>
<legend class="mb-2 text-sm font-medium">
{{ t('notifications.mentions') }}
</legend>
<label
v-for="option in settings.discordAllowedMentionOptions"
:key="option.value"
class="flex min-h-11 items-center gap-3 rounded-lg border px-3 py-2 text-sm"
>
<input
v-model="form.allowed_mentions"
type="checkbox"
:value="option.value"
:aria-invalid="
!!errorFor('channel_configuration')
"
class="accent-primary size-4"
/>{{
notificationLabel(
t,
'mention',
option.value,
option.label,
)
}}
</label>
<p
id="notification-mentions-help"
class="text-caption text-muted-foreground"
>
{{ t('notifications.mentionsHelp') }}
</p>
<InputError
id="notification-mentions-error"
:message="errorFor('channel_configuration')"
/>
</fieldset>
</section>
<section
class="grid min-w-0 gap-3 border-t pt-4"
aria-labelledby="notification-filters-title"
>
<h3
id="notification-filters-title"
class="font-semibold"
>
{{ t('notifications.filters') }}
</h3>
<p class="text-caption text-muted-foreground">
{{ t('notifications.filtersHelp') }}
</p>
<p v-if="!form.filters.length" class="text-sm">
{{ t('notifications.noFilters') }}
</p>
<fieldset
v-for="(filter, index) in form.filters"
:key="filter.id"
class="bg-muted/25 grid min-w-0 gap-3 rounded-lg border p-3"
>
<legend class="px-1 text-sm font-medium">
{{
t('notifications.filterNumber', {
number: index + 1,
})
}}
</legend>
<div class="grid min-w-0 gap-3 sm:grid-cols-2">
<div class="grid min-w-0 gap-2">
<Label
:for="`filter-attribute-${filter.id}`"
>{{
t('notifications.attribute')
}}</Label
>
<select
:id="`filter-attribute-${filter.id}`"
v-model="filter.attribute"
:class="inputClass"
:aria-invalid="
!!errorFor(`filters.${index}`)
"
:aria-describedby="`filter-error-${filter.id}`"
>
<option
v-for="option in settings.filterAttributeOptions"
:key="option.value"
:value="option.value"
>
{{
notificationLabel(
t,
'attribute',
option.value,
option.label,
)
}}
</option>
</select>
</div>
<div class="grid min-w-0 gap-2">
<Label
:for="`filter-operator-${filter.id}`"
>{{
t('notifications.operator')
}}</Label
>
<select
:id="`filter-operator-${filter.id}`"
v-model="filter.operator"
:class="inputClass"
:aria-invalid="
!!errorFor(`filters.${index}`)
"
:aria-describedby="`filter-error-${filter.id}`"
>
<option
v-for="option in settings.filterOperatorOptions"
:key="option.value"
:value="option.value"
>
{{
notificationLabel(
t,
'operator',
option.value,
option.label,
)
}}
</option>
</select>
</div>
</div>
<div
v-if="supportsFilterValue(filter.operator)"
class="grid gap-2"
>
<Label :for="`filter-value-${filter.id}`">{{
t(
filter.operator === 'in'
? 'notifications.values'
: 'notifications.value',
)
}}</Label>
<textarea
:id="`filter-value-${filter.id}`"
v-model="filter.value"
rows="2"
:class="inputClass"
:aria-invalid="
!!errorFor(`filters.${index}`)
"
:aria-describedby="`filter-error-${filter.id}`"
/>
</div>
<InputError
:id="`filter-error-${filter.id}`"
:message="errorFor(`filters.${index}`)"
/>
<Button
type="button"
variant="outline"
class="justify-self-end"
:aria-label="
t('notifications.removeFilterNumber', {
number: index + 1,
})
"
@click="removeFilter(index)"
>{{ t('notifications.removeFilter') }}</Button
>
</fieldset>
<InputError :message="form.errors.filters" />
<Button
type="button"
variant="outline"
class="justify-self-start"
@click="addFilter"
>{{ t('notifications.addFilter') }}</Button
>
</section>
<div class="grid gap-2 border-t pt-4">
<Label
for="notification-enabled"
class="flex min-h-11 items-center gap-3"
><Checkbox
id="notification-enabled"
v-model="form.enabled"
:invalid="!!form.errors.enabled"
aria-describedby="notification-enabled-help notification-enabled-error"
/>{{ t('notifications.enabled') }}</Label
>
<p
id="notification-enabled-help"
class="text-caption text-muted-foreground"
>
{{ t('notifications.enabledHelp') }}
</p>
<InputError
id="notification-enabled-error"
:message="form.errors.enabled"
/>
</div>
</fieldset>
<InputError v-else :message="errorFor('notificationAction')" />
<DialogFooter>
<Button
type="button"
variant="outline"
:disabled="form.processing"
@click="close(false)"
>{{ t('common.cancel') }}</Button
>
<Button
type="submit"
:variant="deleting ? 'destructive' : 'default'"
:loading="form.processing"
>{{
deleting
? t('notifications.confirmDelete')
: t('common.save')
}}</Button
>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>
@@ -0,0 +1,347 @@
<script setup lang="ts">
import { Link, useForm } from '@inertiajs/vue3';
import { Layers3, LockKeyhole } from 'lucide-vue-next';
import { computed } from 'vue';
import FormFeedback from '@/components/FormFeedback.vue';
import FeedbackState from '@/components/FeedbackState.vue';
import InputError from '@/components/InputError.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import { useProjectTemplateAreas } from '@/composables/useProjectTemplateAreas';
import { index, update } from '@/routes/project-templates';
import { show } from '@/routes/projects';
import { store } from '@/routes/projects/templates';
import type { ProjectDuplicateCopyAreaOption } from '@/types';
import type { ProjectModel } from '@/types/models/project-model';
import type { ProjectTemplateModel } from '@/types/models/project-template-model';
const props = defineProps<{
copyAreaOptions: ProjectDuplicateCopyAreaOption[];
project?: ProjectModel;
template?: ProjectTemplateModel;
}>();
const { t } = useLocalization();
const { areaText } = useProjectTemplateAreas();
const source = computed(() => props.template?.source_project ?? props.project);
const linkedCount = computed(() => props.template?.linked_project_count ?? 0);
const form = useForm(
`ProjectTemplate:${props.template ? `edit:${props.template.id}` : `create:${props.project?.id}`}`,
{
name:
props.template?.name ??
t('templates.defaultName', { project: props.project?.name ?? '' }),
slug:
props.template?.slug ??
`${props.project?.key.toLowerCase()}-template`,
copy_areas: props.template
? [...props.template.copy_areas]
: props.copyAreaOptions.map((area) => area.value),
confirm_linked_projects_update: false,
},
);
const protection = useFormProtection(form);
const copyAreasError = computed(
() =>
form.errors.copy_areas ??
Object.entries(form.errors).find(([key]) =>
key.startsWith('copy_areas.'),
)?.[1],
);
const cancelTarget = computed(() =>
props.template ? index() : show(props.project!.id),
);
function submit(): void {
if (
!source.value ||
(linkedCount.value > 0 && !form.confirm_linked_projects_update)
) {
return;
}
form.transform((data) =>
props.template
? {
name: data.name,
copy_areas: data.copy_areas,
...(linkedCount.value > 0
? {
confirm_linked_projects_update:
data.confirm_linked_projects_update,
}
: {}),
}
: { name: data.name, slug: data.slug, copy_areas: data.copy_areas },
);
protection.submit(
props.template ? update(props.template.id) : store(props.project!.id),
{ preserveScroll: true },
);
}
</script>
<template>
<form
class="gap-page grid min-w-0"
:aria-busy="form.processing"
@submit.prevent="submit"
>
<FormFeedback :state="protection.state.value" @retry="submit" />
<FeedbackState
v-if="!source"
state="error"
:title="t('templates.missingSource')"
:description="t('templates.missingSourceHelp')"
/>
<div
class="gap-page grid min-w-0 items-start lg:grid-cols-[minmax(0,1fr)_18rem]"
>
<div class="gap-page grid min-w-0">
<section
class="bg-card gap-column grid min-w-0 rounded-md border p-5"
aria-labelledby="template-details-title"
>
<h2 id="template-details-title" class="text-title">
{{ t('templates.details') }}
</h2>
<div class="gap-stack grid">
<Label for="name">{{ t('templates.name') }}</Label>
<Input
id="name"
v-model="form.name"
name="name"
required
maxlength="255"
:disabled="form.processing || !source"
:placeholder="t('templates.namePlaceholder')"
:invalid="!!form.errors.name"
aria-describedby="name-error"
/>
<InputError
id="name-error"
:message="form.errors.name"
/>
</div>
<div class="gap-stack grid">
<Label for="slug"
><LockKeyhole
v-if="template"
class="size-4"
aria-hidden="true"
/>{{ t('templates.slug') }}</Label
>
<Input
id="slug"
v-model="form.slug"
name="slug"
required
maxlength="255"
:disabled="!!template || form.processing || !source"
:placeholder="t('templates.slugPlaceholder')"
:invalid="!!form.errors.slug"
aria-describedby="slug-help slug-error"
:spellcheck="false"
autocapitalize="none"
/>
<p
id="slug-help"
class="text-caption text-muted-foreground"
>
{{
t(
template
? 'templates.slugLocked'
: 'templates.slugHelp',
)
}}
</p>
<InputError
id="slug-error"
:message="form.errors.slug"
/>
</div>
</section>
<fieldset
class="bg-card min-w-0 rounded-md border p-5"
:disabled="form.processing || !source"
aria-describedby="areas-help copy-areas-error"
>
<legend class="text-title px-1">
{{ t('templates.groups') }}
</legend>
<p
id="areas-help"
class="text-caption text-muted-foreground mb-4"
>
{{
t(
template
? 'templates.editGroupsHelp'
: 'templates.createGroupsHelp',
)
}}
</p>
<div class="gap-stack grid sm:grid-cols-2">
<label
v-for="area in copyAreaOptions"
:key="area.value"
class="hover:border-primary/45 has-checked:border-primary has-checked:bg-accent/50 flex min-h-11 min-w-0 cursor-pointer items-start gap-3 rounded-md border p-3 transition-colors"
>
<input
v-model="form.copy_areas"
type="checkbox"
:value="area.value"
:aria-labelledby="`area-${area.value}`"
:aria-describedby="`area-${area.value}-help copy-areas-error`"
:aria-invalid="!!copyAreasError"
class="accent-primary mt-0.5 size-5 shrink-0"
/>
<span class="gap-stack grid min-w-0">
<span
:id="`area-${area.value}`"
class="text-body font-semibold"
>{{
areaText(
area.value,
'label',
area.label,
)
}}</span
>
<span
:id="`area-${area.value}-help`"
class="text-caption text-muted-foreground"
>{{
areaText(
area.value,
'description',
area.description,
)
}}</span
>
</span>
</label>
</div>
<InputError
id="copy-areas-error"
class="mt-3"
:message="copyAreasError"
/>
</fieldset>
</div>
<aside
class="bg-card gap-column grid min-w-0 rounded-md border p-5"
aria-labelledby="template-impact-title"
>
<h2
id="template-impact-title"
class="text-title flex items-center gap-2"
>
<Layers3 class="text-primary size-5" aria-hidden="true" />{{
t('templates.impact')
}}
</h2>
<dl class="gap-column grid">
<div class="gap-stack grid">
<dt class="text-caption text-muted-foreground">
{{ t('projectCreation.sourceProject') }}
</dt>
<dd class="break-words font-semibold">
{{
source?.name ?? t('templates.sourceUnavailable')
}}
</dd>
<dd v-if="source">
<Badge variant="outline">{{ source.key }}</Badge>
</dd>
</div>
<div class="gap-stack grid" aria-live="polite">
<dt class="text-caption text-muted-foreground">
{{ t('templates.groups') }}
</dt>
<dd>
{{
t('projectCreation.selectedCount', {
count: form.copy_areas.length,
})
}}
</dd>
</div>
<div v-if="template" class="gap-stack grid">
<dt class="text-caption text-muted-foreground">
{{ t('templates.inheritance') }}
</dt>
<dd>
{{
t('templates.linkedCount', {
count: linkedCount,
})
}}
</dd>
</div>
</dl>
<p class="text-caption text-muted-foreground border-t pt-4">
{{
t(
template
? 'templates.refreshHelp'
: 'templates.cleanStart',
)
}}
</p>
</aside>
</div>
<section
v-if="linkedCount > 0"
class="border-warning/50 bg-warning/10 gap-column grid rounded-md border p-5"
aria-labelledby="linked-title"
>
<h2 id="linked-title" class="text-title">
{{ t('templates.linkedWarning') }}
</h2>
<p id="linked-help" class="text-body">
{{ t('templates.linkedImpact', { count: linkedCount }) }}
</p>
<label class="flex min-h-11 cursor-pointer items-start gap-3">
<input
id="confirm-linked"
v-model="form.confirm_linked_projects_update"
type="checkbox"
:disabled="form.processing || !source"
:aria-invalid="!!form.errors.confirm_linked_projects_update"
aria-describedby="linked-help linked-error"
class="accent-primary mt-0.5 size-5 shrink-0"
/>
<span>{{ t('templates.acknowledge') }}</span>
</label>
<InputError
id="linked-error"
:message="form.errors.confirm_linked_projects_update"
/>
</section>
<footer
class="flex flex-col-reverse gap-3 border-t pt-5 sm:flex-row sm:justify-end"
>
<Button as-child variant="outline" :disabled="form.processing"
><Link :href="cancelTarget">{{
t('common.cancel')
}}</Link></Button
>
<Button
type="submit"
:loading="form.processing"
:loading-label="t('forms.saving')"
:disabled="
!source ||
(linkedCount > 0 && !form.confirm_linked_projects_update)
"
>{{
t(template ? 'templates.saveChanges' : 'templates.saveNew')
}}</Button
>
</footer>
</form>
</template>
@@ -1,26 +1,32 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import ProjectController from '@/actions/App/Http/Controllers/ProjectController';
import { Layers3, RotateCcw } from 'lucide-vue-next';
import { computed, ref } from 'vue';
import { resetTemplate } from '@/actions/App/Http/Controllers/ProjectController';
import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import type { ProjectTemplateState, ProjectTemplateStateArea } from '@/types';
import { computed } from 'vue';
const props = defineProps<{
areas: string[];
projectId: number;
templateState?: ProjectTemplateState | null;
}>();
const { t } = useLocalization();
const form = useForm<{
areas: string[];
status_replacements: Record<string, number | undefined>;
@@ -28,51 +34,82 @@ const form = useForm<{
areas: [],
status_replacements: {},
});
const activeArea = computed(() => form.areas[0]);
const protection = useFormProtection(form);
const pendingArea = ref<ProjectTemplateStateArea | null>(null);
const inheritedAreas = computed(
() =>
props.templateState?.inheritedAreas.filter((area) =>
props.areas.includes(area.value),
) ?? [],
);
const overriddenAreas = computed(
() =>
props.templateState?.overriddenAreas.filter((area) =>
props.areas.includes(area.value),
) ?? [],
);
const statusRequirement = computed(
() => props.templateState?.resetRequirements?.ticket_statuses,
const statusRequirement = computed(() =>
pendingArea.value?.value === 'ticket_statuses'
? props.templateState?.resetRequirements?.ticket_statuses
: undefined,
);
const canReset = (area: ProjectTemplateStateArea): boolean => {
if (area.value !== 'ticket_statuses') {
return true;
}
return (
statusRequirement.value?.removedStatuses.every(
(status) =>
form.status_replacements[String(status.id)] !== undefined,
) ?? true
);
};
const resetArea = (area: ProjectTemplateStateArea): void => {
if (!canReset(area)) {
const canReset = computed(
() =>
statusRequirement.value?.removedStatuses.every((status) =>
statusRequirement.value?.replacementOptions.some(
(option) =>
option.id === form.status_replacements[String(status.id)],
),
) ?? true,
);
const areaKeys = {
project_settings: 'inheritance.area.projectSettings',
board_columns: 'inheritance.area.boardColumns',
ticket_statuses: 'inheritance.area.ticketStatuses',
status_transitions: 'inheritance.area.statusTransitions',
ticket_types: 'inheritance.area.ticketTypes',
ticket_priorities: 'inheritance.area.ticketPriorities',
ticket_fields: 'inheritance.area.ticketFields',
ticket_link_types: 'inheritance.area.ticketLinkTypes',
repository_connections: 'inheritance.area.repositoryConnections',
notification_actions: 'inheritance.area.notificationActions',
} as const;
function areaLabel(area: ProjectTemplateStateArea): string {
const key = areaKeys[area.value as keyof typeof areaKeys];
return key ? t(key) : area.label;
}
function openReset(area: ProjectTemplateStateArea): void {
form.resetAndClearErrors();
protection.clearFeedback();
pendingArea.value = area;
}
function closeReset(open: boolean): void {
if (open || form.processing || !protection.confirmLeave()) {
return;
}
form.areas = [area.value];
form.post(ProjectController.resetTemplate.url(props.projectId), {
pendingArea.value = null;
form.resetAndClearErrors();
protection.clearFeedback();
}
function resetArea(): void {
if (
!pendingArea.value ||
!canReset.value ||
!overriddenAreas.value.some(
(area) => area.value === pendingArea.value?.value,
)
) {
return;
}
form.areas = [pendingArea.value.value];
protection.submit(resetTemplate(props.projectId), {
preserveScroll: true,
onSuccess: () => form.reset(),
onSuccess: () => {
form.resetAndClearErrors();
pendingArea.value = null;
},
});
};
}
</script>
<template>
@@ -81,97 +118,208 @@ const resetArea = (area: ProjectTemplateStateArea): void => {
templateState?.inheritsFromTemplate &&
(inheritedAreas.length > 0 || overriddenAreas.length > 0)
"
class="min-w-0 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
>
<CardHeader>
<CardTitle>
Template inheritance: {{ templateState.templateName }}
</CardTitle>
<CardDescription>
Every setting area inherits independently. Local changes stay
unchanged when the template is updated until that area is reset.
</CardDescription>
<CardHeader class="gap-stack grid-cols-1 border-b p-5 sm:p-6">
<div class="flex items-center gap-2">
<Layers3
class="text-primary size-5 shrink-0"
aria-hidden="true"
/>
<h2 class="text-title">{{ t('inheritance.title') }}</h2>
</div>
<p class="text-muted-foreground break-words">
{{
t('inheritance.description', {
template: templateState.templateName,
})
}}
</p>
</CardHeader>
<CardContent class="space-y-4">
<div class="flex flex-wrap gap-2">
<Badge
v-for="area in inheritedAreas"
:key="`inherited-${area.value}`"
variant="outline"
>
{{ area.label }} · inherited
</Badge>
</div>
<div
v-for="area in overriddenAreas"
:key="`overridden-${area.value}`"
class="space-y-3 rounded-lg border p-4"
<CardContent class="gap-column grid min-w-0 p-5 sm:p-6">
<p class="text-caption text-muted-foreground">
{{ t('inheritance.help') }}
</p>
<ul
class="grid min-w-0 divide-y"
:aria-label="t('inheritance.areas')"
>
<div class="flex flex-wrap items-center justify-between gap-3">
<Badge>{{ area.label }} · template overridden</Badge>
<Button
type="button"
size="sm"
variant="outline"
:disabled="form.processing || !canReset(area)"
@click="resetArea(area)"
>
{{
activeArea === area.value && form.processing
? 'Resetting…'
: 'Reset to template'
}}
</Button>
</div>
<div
v-if="area.value === 'ticket_statuses' && statusRequirement"
class="space-y-3"
<li
v-for="area in inheritedAreas"
:key="area.value"
class="flex min-w-0 flex-wrap items-center justify-between gap-3 py-3"
>
<p class="text-muted-foreground text-sm">
Choose where tickets in local statuses should move
before those statuses are removed.
</p>
<div
v-for="status in statusRequirement.removedStatuses"
:key="status.id"
class="grid gap-2 sm:grid-cols-2 sm:items-center"
<span class="min-w-0 break-words font-medium">{{
areaLabel(area)
}}</span>
<Badge
variant="outline"
class="border-primary/25 text-primary"
>{{ t('projectSettings.inherited') }}</Badge
>
<Label :for="`replacement-${status.id}`">
{{ status.name }} ({{ status.ticketCount }} tickets)
</Label>
<div class="grid gap-1">
<select
:id="`replacement-${status.id}`"
v-model="
form.status_replacements[String(status.id)]
"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none focus-visible:ring-[3px]"
>
<option :value="undefined" disabled>
Select replacement status
</option>
<option
v-for="option in statusRequirement.replacementOptions"
:key="option.id"
:value="option.id"
>
{{ option.name }}
</option>
</select>
<InputError
:message="
form.errors[
`status_replacements.${status.id}`
]
"
/>
</div>
</li>
<li
v-for="area in overriddenAreas"
:key="area.value"
class="gap-stack grid min-w-0 py-3"
>
<div
class="flex min-w-0 flex-wrap items-center justify-between gap-3"
>
<span class="min-w-0 break-words font-medium">{{
areaLabel(area)
}}</span>
<Badge variant="secondary">{{
t('projectSettings.override')
}}</Badge>
</div>
</div>
<InputError :message="form.errors.areas" />
</div>
<div
class="flex flex-col items-start justify-between gap-3 sm:flex-row sm:items-center"
>
<p class="text-caption text-muted-foreground">
{{ t('inheritance.overrideHelp') }}
</p>
<Button
type="button"
size="sm"
variant="outline"
class="shrink-0"
:disabled="form.processing"
:aria-label="
t('inheritance.resetArea', {
area: areaLabel(area),
})
"
@click="openReset(area)"
>
<RotateCcw aria-hidden="true" />{{
t('inheritance.reset')
}}
</Button>
</div>
</li>
</ul>
</CardContent>
<Dialog :open="pendingArea !== null" @update:open="closeReset">
<DialogContent
:show-close-button="!form.processing"
@escape-key-down="form.processing && $event.preventDefault()"
@interact-outside="form.processing && $event.preventDefault()"
>
<DialogHeader>
<DialogTitle>{{
t('inheritance.confirmTitle')
}}</DialogTitle>
<DialogDescription>{{
t('inheritance.confirmDescription', {
area: pendingArea ? areaLabel(pendingArea) : '',
template: templateState.templateName,
})
}}</DialogDescription>
</DialogHeader>
<form
class="gap-column grid min-w-0"
:aria-busy="form.processing"
@submit.prevent="resetArea"
>
<FormFeedback
:state="protection.state.value"
:title="
form.processing
? t('inheritance.resetting')
: undefined
"
@retry="resetArea"
/>
<InputError
:message="form.errors.areas ?? form.errors['areas.0']"
/>
<fieldset
class="gap-column grid min-w-0"
:disabled="form.processing"
>
<legend class="sr-only">
{{ t('inheritance.confirmTitle') }}
</legend>
<div
v-if="statusRequirement"
class="gap-column grid min-w-0"
>
<p class="text-caption text-muted-foreground">
{{ t('inheritance.statusHelp') }}
</p>
<div
v-for="status in statusRequirement.removedStatuses"
:key="status.id"
class="grid gap-2"
>
<Label :for="'replacement-' + status.id">{{
t('inheritance.statusLabel', {
status: status.name,
count: status.ticketCount,
})
}}</Label>
<select
:id="'replacement-' + status.id"
v-model="
form.status_replacements[
String(status.id)
]
"
class="border-input bg-background focus-visible:border-ring focus-visible:ring-ring/50 min-h-11 w-full min-w-0 rounded-md border px-3 py-2 text-sm outline-none focus-visible:ring-2"
:aria-invalid="
!!form.errors[
`status_replacements.${status.id}`
]
"
:aria-describedby="
'replacement-error-' + status.id
"
>
<option :value="undefined" disabled>
{{ t('inheritance.selectStatus') }}
</option>
<option
v-for="option in statusRequirement.replacementOptions"
:key="option.id"
:value="option.id"
>
{{ option.name }}
</option>
</select>
<InputError
:id="'replacement-error-' + status.id"
:message="
form.errors[
`status_replacements.${status.id}`
]
"
/>
</div>
</div>
<p
class="border-destructive/25 bg-destructive/8 rounded-lg border p-3 text-sm"
>
{{ t('inheritance.resetWarning') }}
</p>
<DialogFooter>
<Button
type="button"
variant="secondary"
@click="closeReset(false)"
>{{ t('common.cancel') }}</Button
>
<Button type="submit" :disabled="!canReset">{{
t(
form.processing
? 'inheritance.resetting'
: 'inheritance.reset',
)
}}</Button>
</DialogFooter>
</fieldset>
</form>
</DialogContent>
</Dialog>
</Card>
</template>
@@ -0,0 +1,101 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { watch } from 'vue';
import { update } from '@/actions/App/Http/Controllers/ProjectTicketMetadataSettingsController';
import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import type { ProjectModel } from '@/types/models/project-model';
const props = defineProps<{ project: ProjectModel }>();
const { t } = useLocalization();
const form = useForm({
show_author: props.project.show_author,
show_story_points: props.project.show_story_points,
});
const protection = useFormProtection(form);
watch(
() => [props.project.show_author, props.project.show_story_points],
() => {
form.defaults({
show_author: props.project.show_author,
show_story_points: props.project.show_story_points,
});
form.resetAndClearErrors();
},
);
function submit(): void {
protection.submit(update(props.project.id), {
preserveScroll: true,
onSuccess: () => form.defaults(),
});
}
</script>
<template>
<form
class="bg-card grid min-w-0 gap-4 rounded-xl border p-4 sm:p-6"
:aria-busy="form.processing"
@submit.prevent="submit"
>
<h3 class="font-semibold">{{ t('metadata.display') }}</h3>
<p class="text-muted-foreground text-sm">
{{ t('metadata.displayDescription') }}
</p>
<FormFeedback :state="protection.state.value" @retry="submit" />
<fieldset :disabled="form.processing" class="grid gap-4">
<div
v-for="option in ['show_author', 'show_story_points'] as const"
:key="option"
class="grid gap-2"
>
<Label
:for="option"
class="bg-muted/35 flex min-h-14 cursor-pointer items-start gap-3 rounded-lg border p-4"
>
<Checkbox
:id="option"
v-model="form[option]"
:name="option"
:invalid="!!form.errors[option]"
:aria-describedby="`${option}-help ${option}-error`"
/>
<span class="grid gap-1">
<span>{{
t(
option === 'show_author'
? 'metadata.author'
: 'metadata.storyPoints',
)
}}</span>
<span
:id="`${option}-help`"
class="text-muted-foreground text-sm font-normal"
>{{
t(
option === 'show_author'
? 'metadata.authorHelp'
: 'metadata.storyPointsHelp',
)
}}</span
>
</span>
</Label>
<InputError
:id="`${option}-error`"
:message="form.errors[option]"
/>
</div>
</fieldset>
<Button
type="submit"
class="w-full sm:w-fit"
:loading="form.processing"
>{{ t('common.save') }}</Button
>
</form>
</template>
@@ -0,0 +1,267 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { CircleAlert } from 'lucide-vue-next';
import { computed, watch } from 'vue';
import {
destroyFieldDefinition,
storeFieldDefinition,
updateFieldDefinition,
} from '@/actions/App/Http/Controllers/ProjectTicketMetadataSettingsController';
import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import type { TicketFieldDefinitionModel } from '@/types/models/ticket-field-definition-model';
import type { TicketFieldDefinitionEditorTarget } from '@/types/project-metadata';
import type { TicketFieldTypeOption } from '@/types';
const props = defineProps<{
projectId: number;
target: TicketFieldDefinitionEditorTarget | null;
definitions: TicketFieldDefinitionModel[];
fieldTypeOptions: TicketFieldTypeOption[];
}>();
const emit = defineEmits<{ close: [] }>();
const { t } = useLocalization();
const form = useForm({
label: '',
field_type: '',
position: 1,
});
const protection = useFormProtection(form, {
isDirty: () => props.target !== null && form.isDirty,
});
const deleting = computed(() => props.target?.action === 'delete');
const title = computed(() => {
if (props.target?.action === 'create') {
return t('metadata.addField');
}
if (props.target?.action === 'edit') {
return t('metadata.editField', { name: props.target.entry.label });
}
return props.target
? t('metadata.deleteField', { name: props.target.entry.label })
: '';
});
watch(
() => props.target,
(target) => {
const values =
target?.action === 'edit'
? {
label: target.entry.label,
field_type: target.entry.field_type,
position: target.entry.position,
}
: {
label: '',
field_type: props.fieldTypeOptions[0]?.value ?? '',
position: props.definitions.length + 1,
};
form.defaults(values);
form.resetAndClearErrors();
protection.clearFeedback();
},
{ immediate: true },
);
function close(open: boolean): void {
if (open || form.processing || !protection.confirmLeave()) {
return;
}
form.resetAndClearErrors();
protection.clearFeedback();
emit('close');
}
function submit(): void {
const target = props.target;
if (!target || form.processing) {
return;
}
const route =
target.action === 'create'
? storeFieldDefinition(props.projectId)
: target.action === 'edit'
? updateFieldDefinition([props.projectId, target.entry.id])
: destroyFieldDefinition([props.projectId, target.entry.id]);
form.transform((data) =>
target.action === 'delete'
? {}
: target.action === 'create'
? { label: data.label, field_type: data.field_type }
: data,
);
protection.submit(route, {
preserveScroll: true,
onSuccess: () => {
form.resetAndClearErrors();
emit('close');
},
});
}
</script>
<template>
<Dialog :open="target !== null" @update:open="close">
<DialogContent
:show-close-button="!form.processing"
@escape-key-down="form.processing && $event.preventDefault()"
@interact-outside="form.processing && $event.preventDefault()"
>
<DialogHeader>
<DialogTitle class="break-words">{{ title }}</DialogTitle>
<DialogDescription>
{{
t(
deleting
? 'metadata.deleteFieldHelp'
: 'metadata.fieldEditorHelp',
)
}}
</DialogDescription>
</DialogHeader>
<form
class="grid min-w-0 gap-4"
:aria-busy="form.processing"
@submit.prevent="submit"
>
<FormFeedback :state="protection.state.value" @retry="submit" />
<div
v-if="deleting && target?.entry"
class="border-destructive/40 bg-destructive/5 text-destructive flex items-start gap-3 rounded-lg border p-4 text-sm"
>
<CircleAlert
class="mt-0.5 size-4 shrink-0"
aria-hidden="true"
/>
<p>
{{
t('metadata.fieldDeletionImpact', {
count:
target.entry.ticket_field_values_count ?? 0,
})
}}
</p>
</div>
<fieldset
v-if="!deleting"
:disabled="form.processing"
class="grid min-w-0 gap-4"
>
<div class="grid gap-2">
<Label for="field-definition-label">{{
t('metadata.fieldLabel')
}}</Label>
<Input
id="field-definition-label"
v-model="form.label"
name="label"
required
maxlength="255"
:aria-invalid="!!form.errors.label"
aria-describedby="field-definition-label-error"
/>
<InputError
id="field-definition-label-error"
:message="form.errors.label"
/>
</div>
<div class="grid gap-2">
<Label for="field-definition-type">{{
t('metadata.fieldType')
}}</Label>
<select
id="field-definition-type"
v-model="form.field_type"
name="field_type"
required
class="border-input bg-background focus-visible:ring-ring min-h-11 w-full min-w-0 rounded-md border px-3 text-sm focus-visible:ring-2"
:aria-invalid="!!form.errors.field_type"
aria-describedby="field-definition-type-help field-definition-type-error"
>
<option
v-for="option in fieldTypeOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
<p
id="field-definition-type-help"
class="text-caption text-muted-foreground"
>
{{ t('metadata.fieldTypeHelp') }}
</p>
<InputError
id="field-definition-type-error"
:message="form.errors.field_type"
/>
</div>
<div v-if="target?.action === 'edit'" class="grid gap-2">
<Label for="field-definition-position">{{
t('workflow.position')
}}</Label>
<Input
id="field-definition-position"
v-model="form.position"
name="position"
type="number"
min="1"
:max="definitions.length"
step="1"
required
:aria-invalid="!!form.errors.position"
aria-describedby="field-definition-position-help field-definition-position-error"
/>
<p
id="field-definition-position-help"
class="text-caption text-muted-foreground"
>
{{
t('metadata.positionHelp', {
count: definitions.length,
})
}}
</p>
<InputError
id="field-definition-position-error"
:message="form.errors.position"
/>
</div>
</fieldset>
<DialogFooter>
<Button
type="button"
variant="outline"
:disabled="form.processing"
@click="close(false)"
>{{ t('common.cancel') }}</Button
>
<Button
type="submit"
:variant="deleting ? 'destructive' : 'default'"
:loading="form.processing"
>{{
deleting
? t('workflow.confirmDelete')
: target?.action === 'create'
? title
: t('common.save')
}}</Button
>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>
@@ -0,0 +1,142 @@
<script setup lang="ts">
import { Braces, Pencil, Plus, Trash2 } from 'lucide-vue-next';
import { computed, ref } from 'vue';
import FeedbackState from '@/components/FeedbackState.vue';
import TicketFieldDefinitionEditorDialog from '@/components/projects/TicketFieldDefinitionEditorDialog.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { useLocalization } from '@/composables/useLocalization';
import type { TicketFieldDefinitionModel } from '@/types/models/ticket-field-definition-model';
import type { TicketFieldDefinitionEditorTarget } from '@/types/project-metadata';
import type { TicketFieldTypeOption } from '@/types';
const props = defineProps<{
projectId: number;
definitions: TicketFieldDefinitionModel[];
fieldTypeOptions: TicketFieldTypeOption[];
}>();
const { t } = useLocalization();
const target = ref<TicketFieldDefinitionEditorTarget | null>(null);
const fieldTypeLabels = computed(
() =>
new Map(
props.fieldTypeOptions.map((option) => [
option.value,
option.label,
]),
),
);
function fieldTypeLabel(fieldType: string): string {
return fieldTypeLabels.value.get(fieldType) ?? fieldType;
}
</script>
<template>
<section
class="bg-card grid min-w-0 gap-4 overflow-hidden rounded-xl border p-4 sm:p-6"
aria-labelledby="metadata-custom-fields"
>
<header class="flex flex-wrap items-center justify-between gap-3">
<div class="flex min-w-0 items-center gap-2">
<Braces
class="text-primary size-5 shrink-0"
aria-hidden="true"
/>
<h3 id="metadata-custom-fields" class="font-semibold">
{{ t('metadata.customFields') }}
</h3>
<Badge variant="secondary">{{ definitions.length }}</Badge>
</div>
<Button
type="button"
class="w-full sm:w-auto"
@click="target = { action: 'create' }"
>
<Plus class="size-4" aria-hidden="true" />{{
t('metadata.addField')
}}
</Button>
</header>
<p class="text-muted-foreground text-sm">
{{ t('metadata.customFieldsDescription') }}
</p>
<FeedbackState
v-if="definitions.length === 0"
state="empty"
:title="t('metadata.noCustomFields')"
:description="t('metadata.noCustomFieldsDescription')"
/>
<ol v-else class="divide-y" :aria-label="t('metadata.customFields')">
<li
v-for="definition in definitions"
:key="definition.id"
class="grid min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 py-4"
:data-ticket-field-definition="definition.id"
>
<span
class="bg-accent text-accent-foreground flex size-9 shrink-0 items-center justify-center rounded-lg text-sm font-semibold"
:aria-label="
t('workflow.positionValue', {
position: definition.position,
})
"
>{{ definition.position }}</span
>
<div class="grid min-w-0 gap-1">
<p class="break-words font-medium">
{{ definition.label }}
</p>
<div
class="text-caption text-muted-foreground flex flex-wrap items-center gap-2"
>
<Badge variant="outline">{{
fieldTypeLabel(definition.field_type)
}}</Badge>
<span>{{
t('metadata.storedValues', {
count:
definition.ticket_field_values_count ?? 0,
})
}}</span>
</div>
</div>
<div class="flex shrink-0 gap-1">
<Button
type="button"
variant="ghost"
size="icon"
:aria-label="
t('metadata.editField', { name: definition.label })
"
@click="target = { action: 'edit', entry: definition }"
>
<Pencil class="size-4" aria-hidden="true" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
class="text-destructive"
:aria-label="
t('metadata.deleteField', {
name: definition.label,
})
"
@click="
target = { action: 'delete', entry: definition }
"
>
<Trash2 class="size-4" aria-hidden="true" />
</Button>
</div>
</li>
</ol>
<TicketFieldDefinitionEditorDialog
:definitions="definitions"
:field-type-options="fieldTypeOptions"
:project-id="projectId"
:target="target"
@close="target = null"
/>
</section>
</template>
@@ -0,0 +1,269 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { ArrowRight, CircleAlert } from 'lucide-vue-next';
import { computed, watch } from 'vue';
import {
destroyLinkType,
storeLinkType,
updateLinkType,
} from '@/actions/App/Http/Controllers/ProjectTicketMetadataSettingsController';
import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import type { TicketLinkTypeModel } from '@/types/models/ticket-link-type-model';
import type { TicketLinkTypeEditorTarget } from '@/types/project-metadata';
const props = defineProps<{
projectId: number;
target: TicketLinkTypeEditorTarget | null;
linkTypes: TicketLinkTypeModel[];
}>();
const emit = defineEmits<{ close: [] }>();
const { t } = useLocalization();
const form = useForm({
name: '',
counterpart_name: '',
position: 1,
});
const protection = useFormProtection(form, {
isDirty: () => props.target !== null && form.isDirty,
});
const deleting = computed(() => props.target?.action === 'delete');
const title = computed(() => {
if (props.target?.action === 'create') {
return t('metadata.addLinkType');
}
if (props.target?.action === 'edit') {
return t('metadata.editLinkType', { name: props.target.entry.label });
}
return props.target
? t('metadata.deleteLinkType', { name: props.target.entry.label })
: '';
});
watch(
() => props.target,
(target) => {
const values =
target?.action === 'edit'
? {
name: target.entry.label,
counterpart_name: target.entry.counterpart_label,
position: target.entry.position,
}
: {
name: '',
counterpart_name: '',
position: props.linkTypes.length + 1,
};
form.defaults(values);
form.resetAndClearErrors();
protection.clearFeedback();
},
{ immediate: true },
);
function close(open: boolean): void {
if (open || form.processing || !protection.confirmLeave()) {
return;
}
form.resetAndClearErrors();
protection.clearFeedback();
emit('close');
}
function submit(): void {
const target = props.target;
if (!target || form.processing) {
return;
}
const route =
target.action === 'create'
? storeLinkType(props.projectId)
: target.action === 'edit'
? updateLinkType([props.projectId, target.entry.id])
: destroyLinkType([props.projectId, target.entry.id]);
form.transform((data) =>
target.action === 'delete'
? {}
: target.action === 'create'
? {
name: data.name,
counterpart_name: data.counterpart_name,
}
: data,
);
protection.submit(route, {
preserveScroll: true,
onSuccess: () => {
form.resetAndClearErrors();
emit('close');
},
});
}
</script>
<template>
<Dialog :open="target !== null" @update:open="close">
<DialogContent
:show-close-button="!form.processing"
@escape-key-down="form.processing && $event.preventDefault()"
@interact-outside="form.processing && $event.preventDefault()"
>
<DialogHeader>
<DialogTitle class="break-words">{{ title }}</DialogTitle>
<DialogDescription>
{{
t(
deleting
? 'metadata.deleteLinkTypeHelp'
: 'metadata.linkTypeEditorHelp',
)
}}
</DialogDescription>
</DialogHeader>
<form
class="grid min-w-0 gap-4"
:aria-busy="form.processing"
@submit.prevent="submit"
>
<FormFeedback :state="protection.state.value" @retry="submit" />
<div
v-if="deleting && target?.entry"
class="border-destructive/40 bg-destructive/5 text-destructive flex items-start gap-3 rounded-lg border p-4 text-sm"
>
<CircleAlert
class="mt-0.5 size-4 shrink-0"
aria-hidden="true"
/>
<p>
{{
t('metadata.linkTypeDeletionImpact', {
count: target.entry.ticket_links_count ?? 0,
})
}}
</p>
</div>
<fieldset
v-if="!deleting"
:disabled="form.processing"
class="grid min-w-0 gap-4"
>
<div class="grid gap-2">
<Label for="link-type-source-label">{{
t('metadata.sourceLabel')
}}</Label>
<Input
id="link-type-source-label"
v-model="form.name"
name="name"
required
maxlength="255"
:aria-invalid="!!form.errors.name"
aria-describedby="link-type-source-label-help link-type-source-label-error"
/>
<p
id="link-type-source-label-help"
class="text-caption text-muted-foreground"
>
{{ t('metadata.sourceLabelHelp') }}
</p>
<InputError
id="link-type-source-label-error"
:message="form.errors.name"
/>
</div>
<ArrowRight
class="text-primary mx-auto size-5 rotate-90 sm:rotate-0"
aria-hidden="true"
/>
<div class="grid gap-2">
<Label for="link-type-counterpart-label">{{
t('metadata.counterpartLabel')
}}</Label>
<Input
id="link-type-counterpart-label"
v-model="form.counterpart_name"
name="counterpart_name"
required
maxlength="255"
:aria-invalid="!!form.errors.counterpart_name"
aria-describedby="link-type-counterpart-label-help link-type-counterpart-label-error"
/>
<p
id="link-type-counterpart-label-help"
class="text-caption text-muted-foreground"
>
{{ t('metadata.counterpartLabelHelp') }}
</p>
<InputError
id="link-type-counterpart-label-error"
:message="form.errors.counterpart_name"
/>
</div>
<div v-if="target?.action === 'edit'" class="grid gap-2">
<Label for="link-type-position">{{
t('workflow.position')
}}</Label>
<Input
id="link-type-position"
v-model="form.position"
name="position"
type="number"
min="1"
:max="linkTypes.length"
step="1"
required
:aria-invalid="!!form.errors.position"
aria-describedby="link-type-position-help link-type-position-error"
/>
<p
id="link-type-position-help"
class="text-caption text-muted-foreground"
>
{{
t('metadata.positionHelp', {
count: linkTypes.length,
})
}}
</p>
<InputError
id="link-type-position-error"
:message="form.errors.position"
/>
</div>
</fieldset>
<DialogFooter>
<Button
type="button"
variant="outline"
:disabled="form.processing"
@click="close(false)"
>{{ t('common.cancel') }}</Button
>
<Button
type="submit"
:variant="deleting ? 'destructive' : 'default'"
:loading="form.processing"
>{{
deleting
? t('workflow.confirmDelete')
: target?.action === 'create'
? title
: t('common.save')
}}</Button
>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>
@@ -0,0 +1,135 @@
<script setup lang="ts">
import { ArrowRight, Link2, Pencil, Plus, Trash2 } from 'lucide-vue-next';
import { ref } from 'vue';
import FeedbackState from '@/components/FeedbackState.vue';
import TicketLinkTypeEditorDialog from '@/components/projects/TicketLinkTypeEditorDialog.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { useLocalization } from '@/composables/useLocalization';
import type { TicketLinkTypeModel } from '@/types/models/ticket-link-type-model';
import type { TicketLinkTypeEditorTarget } from '@/types/project-metadata';
defineProps<{ projectId: number; linkTypes: TicketLinkTypeModel[] }>();
const { t } = useLocalization();
const target = ref<TicketLinkTypeEditorTarget | null>(null);
</script>
<template>
<section
class="bg-card grid min-w-0 gap-4 overflow-hidden rounded-xl border p-4 sm:p-6"
aria-labelledby="metadata-link-types"
>
<header class="flex flex-wrap items-center justify-between gap-3">
<div class="flex min-w-0 items-center gap-2">
<Link2
class="text-primary size-5 shrink-0"
aria-hidden="true"
/>
<h3 id="metadata-link-types" class="font-semibold">
{{ t('metadata.linkTypes') }}
</h3>
<Badge variant="secondary">{{ linkTypes.length }}</Badge>
</div>
<Button
type="button"
class="w-full sm:w-auto"
@click="target = { action: 'create' }"
>
<Plus class="size-4" aria-hidden="true" />{{
t('metadata.addLinkType')
}}
</Button>
</header>
<p class="text-muted-foreground text-sm">
{{ t('metadata.linkTypesDescription') }}
</p>
<FeedbackState
v-if="linkTypes.length === 0"
state="empty"
:title="t('metadata.noLinkTypes')"
:description="t('metadata.noLinkTypesDescription')"
/>
<ol v-else class="divide-y" :aria-label="t('metadata.linkTypes')">
<li
v-for="linkType in linkTypes"
:key="linkType.id"
class="grid min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 py-4"
:data-ticket-link-type="linkType.id"
>
<span
class="bg-accent text-accent-foreground flex size-9 shrink-0 items-center justify-center rounded-lg text-sm font-semibold"
:aria-label="
t('workflow.positionValue', {
position: linkType.position,
})
"
>{{ linkType.position }}</span
>
<div class="grid min-w-0 gap-2">
<div
class="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1"
:aria-label="
t('metadata.linkDirection', {
source: linkType.label,
counterpart: linkType.counterpart_label,
})
"
>
<span class="break-words font-medium">{{
linkType.label
}}</span>
<ArrowRight
class="text-primary size-4 shrink-0"
aria-hidden="true"
/>
<span class="break-words font-medium">{{
linkType.counterpart_label
}}</span>
</div>
<p class="text-caption text-muted-foreground">
{{
t('metadata.ticketRelationships', {
count: linkType.ticket_links_count ?? 0,
})
}}
</p>
</div>
<div class="flex shrink-0 gap-1">
<Button
type="button"
variant="ghost"
size="icon"
:aria-label="
t('metadata.editLinkType', {
name: linkType.label,
})
"
@click="target = { action: 'edit', entry: linkType }"
>
<Pencil class="size-4" aria-hidden="true" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
class="text-destructive"
:aria-label="
t('metadata.deleteLinkType', {
name: linkType.label,
})
"
@click="target = { action: 'delete', entry: linkType }"
>
<Trash2 class="size-4" aria-hidden="true" />
</Button>
</div>
</li>
</ol>
<TicketLinkTypeEditorDialog
:link-types="linkTypes"
:project-id="projectId"
:target="target"
@close="target = null"
/>
</section>
</template>
@@ -0,0 +1,277 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { computed, watch } from 'vue';
import {
destroy,
destroyStatus,
store,
storeStatus,
update,
updateStatus,
} from '@/actions/App/Http/Controllers/ProjectBoardSettingsController';
import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import type { WorkflowEditorTarget } from '@/types/project-workflow';
const props = defineProps<{
projectId: number;
columnCount: number;
target: WorkflowEditorTarget | null;
}>();
const emit = defineEmits<{ close: [] }>();
const { t } = useLocalization();
const form = useForm({ name: '', position: 1 });
const protection = useFormProtection(form, {
isDirty: () => props.target !== null && form.isDirty,
});
const deleting = computed(() => props.target?.action === 'delete');
const title = computed(() => {
if (props.target?.kind === 'column') {
return t(
props.target.action === 'create'
? 'workflow.addColumn'
: props.target.action === 'edit'
? 'workflow.editColumn'
: 'workflow.deleteColumn',
);
}
return t(
props.target?.action === 'create'
? 'workflow.addStatus'
: props.target?.action === 'edit'
? 'workflow.editStatus'
: 'workflow.deleteStatus',
);
});
const description = computed(() => {
const target = props.target;
if (!target) {
return '';
}
if (target.kind === 'column') {
return t(
target.action === 'delete'
? 'workflow.deleteColumnDescription'
: target.action === 'create'
? 'workflow.addColumnDescription'
: 'workflow.editColumnDescription',
{ name: target.column?.name ?? '' },
);
}
return t(
target.action === 'delete'
? 'workflow.deleteStatusDescription'
: 'workflow.statusContext',
{
name: target.status?.name ?? '',
context: target.column?.name ?? t('workflow.backlog'),
replacement: target.replacement?.name ?? '',
},
);
});
const otherErrors = computed(() =>
Object.entries(form.errors)
.filter(([key]) => !['name', 'position'].includes(key))
.map(([, message]) => message),
);
watch(
() => props.target,
(target) => {
if (!target) {
return;
}
form.defaults({
name:
target.kind === 'column'
? (target.column?.name ?? '')
: (target.status?.name ?? ''),
position: target.column?.position ?? 1,
});
form.resetAndClearErrors();
protection.clearFeedback();
},
);
function close(open: boolean): void {
if (open || form.processing || !protection.confirmLeave()) {
return;
}
form.resetAndClearErrors();
protection.clearFeedback();
emit('close');
}
function submit(): void {
const target = props.target;
if (
!target ||
form.processing ||
(target.kind === 'status' &&
target.action === 'delete' &&
!target.replacement)
) {
return;
}
const route =
target.kind === 'column'
? target.action === 'create'
? store(props.projectId)
: target.action === 'edit'
? update([props.projectId, target.column.id])
: destroy([props.projectId, target.column.id])
: target.action === 'create'
? storeStatus(props.projectId)
: target.action === 'edit'
? updateStatus([props.projectId, target.status.id])
: destroyStatus([props.projectId, target.status.id]);
form.transform((data) =>
target.action === 'delete'
? {}
: target.kind === 'column' && target.action === 'edit'
? data
: target.kind === 'status' && target.action === 'create'
? {
name: data.name,
board_column_id: target.column?.id ?? null,
}
: { name: data.name },
);
protection.submit(route, {
preserveScroll: true,
onSuccess: () => {
form.resetAndClearErrors();
emit('close');
},
});
}
</script>
<template>
<Dialog :open="target !== null" @update:open="close">
<DialogContent
:show-close-button="!form.processing"
@escape-key-down="form.processing && $event.preventDefault()"
@interact-outside="form.processing && $event.preventDefault()"
>
<DialogHeader>
<DialogTitle>{{ title }}</DialogTitle>
<DialogDescription class="break-words">{{
description
}}</DialogDescription>
</DialogHeader>
<form
class="gap-column grid min-w-0"
:aria-busy="form.processing"
@submit.prevent="submit"
>
<FormFeedback :state="protection.state.value" @retry="submit" />
<InputError
v-for="message in otherErrors"
:key="message"
:message="message"
/>
<fieldset
v-if="!deleting"
:disabled="form.processing"
class="grid min-w-0 gap-4"
>
<div class="grid gap-2">
<Label for="workflow-name">{{
t(
target?.kind === 'column'
? 'workflow.columnName'
: 'workflow.statusName',
)
}}</Label>
<Input
id="workflow-name"
v-model="form.name"
name="name"
required
maxlength="255"
:aria-invalid="!!form.errors.name"
:aria-describedby="
form.errors.name
? 'workflow-name-error'
: undefined
"
/>
<InputError
id="workflow-name-error"
:message="form.errors.name"
/>
</div>
<div
v-if="
target?.kind === 'column' &&
target.action === 'edit'
"
class="grid gap-2"
>
<Label for="workflow-position">{{
t('workflow.position')
}}</Label>
<Input
id="workflow-position"
v-model="form.position"
name="position"
type="number"
min="1"
:max="columnCount"
step="1"
required
:aria-invalid="!!form.errors.position"
aria-describedby="workflow-position-help workflow-position-error"
/>
<p
id="workflow-position-help"
class="text-caption text-muted-foreground"
>
{{
t('workflow.positionHelp', {
count: columnCount,
})
}}
</p>
<InputError
id="workflow-position-error"
:message="form.errors.position"
/>
</div>
</fieldset>
<DialogFooter>
<Button
type="button"
variant="outline"
:disabled="form.processing"
@click="close(false)"
>{{ t('common.cancel') }}</Button
>
<Button
type="submit"
:variant="deleting ? 'destructive' : 'default'"
:loading="form.processing"
>{{
deleting
? t('workflow.confirmDelete')
: target?.action === 'create'
? title
: t('common.save')
}}</Button
>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>
@@ -0,0 +1,106 @@
<script setup lang="ts">
import { Pencil, Plus, Trash2 } from 'lucide-vue-next';
import { Button } from '@/components/ui/button';
import { useLocalization } from '@/composables/useLocalization';
import type { BoardColumnModel } from '@/types/models/board-column-model';
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
import type { WorkflowEditorTarget } from '@/types/project-workflow';
defineProps<{
column: BoardColumnModel | null;
statuses: TicketStatusModel[];
}>();
defineEmits<{ edit: [target: WorkflowEditorTarget] }>();
const { t } = useLocalization();
</script>
<template>
<div class="grid min-w-0 gap-3">
<ul
class="divide-y"
:aria-label="
t('workflow.statusesIn', {
context: column?.name ?? t('workflow.backlog'),
})
"
>
<li
v-for="status in statuses"
:key="status.id"
class="flex min-w-0 flex-wrap items-center justify-between gap-2 py-3 first:pt-0"
>
<span
class="min-w-0 flex-1 basis-28 break-words text-sm font-medium"
>{{ status.name }}</span
>
<div class="flex shrink-0 gap-1">
<Button
type="button"
variant="ghost"
size="icon"
:aria-label="
t('workflow.renameStatus', { name: status.name })
"
@click="
$emit('edit', {
kind: 'status',
action: 'edit',
column,
status,
})
"
><Pencil class="size-4" aria-hidden="true"
/></Button>
<Button
type="button"
variant="ghost"
size="icon"
class="text-destructive"
:disabled="statuses.length <= 1"
:aria-label="
t('workflow.removeStatus', { name: status.name })
"
:aria-describedby="
statuses.length <= 1
? `status-protection-${column?.id ?? 'backlog'}`
: undefined
"
@click="
$emit('edit', {
kind: 'status',
action: 'delete',
column,
status,
replacement: statuses.find(
(item) => item.id !== status.id,
),
})
"
><Trash2 class="size-4" aria-hidden="true"
/></Button>
</div>
</li>
</ul>
<p
v-if="statuses.length <= 1"
:id="`status-protection-${column?.id ?? 'backlog'}`"
class="text-caption text-muted-foreground"
>
{{ t('workflow.lastStatus') }}
</p>
<Button
type="button"
variant="outline"
class="w-full sm:w-fit"
:aria-label="
t('workflow.addStatusTo', {
context: column?.name ?? t('workflow.backlog'),
})
"
@click="$emit('edit', { kind: 'status', action: 'create', column })"
><Plus class="size-4" aria-hidden="true" />{{
t('workflow.addStatus')
}}</Button
>
</div>
</template>
@@ -0,0 +1,282 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { ArrowRightLeft } from 'lucide-vue-next';
import { computed, ref, watch } from 'vue';
import { updateStatusTransitions } from '@/actions/App/Http/Controllers/ProjectBoardSettingsController';
import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import { ticketStatusLabel } from '@/lib/tickets';
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
const props = defineProps<{
projectId: number;
statuses: TicketStatusModel[];
}>();
const { t } = useLocalization();
const sourceId = ref(props.statuses[0]?.id ?? null);
const source = computed(() =>
props.statuses.find((status) => status.id === sourceId.value),
);
const targets = computed(() =>
props.statuses.filter((status) => status.id !== sourceId.value),
);
const savedTargets = computed(
() =>
source.value?.allowed_target_statuses
?.map((status) => status.id)
.sort((a, b) => a - b) ?? [],
);
const form = useForm({ allowed_ticket_status_ids: [...savedTargets.value] });
const protection = useFormProtection(form);
const saved = ref(false);
const hasErrors = computed(() => Object.keys(form.errors).length > 0);
function reset(): void {
saved.value = false;
form.defaults({ allowed_ticket_status_ids: [...savedTargets.value] });
form.resetAndClearErrors();
protection.clearFeedback();
}
watch(
() => props.statuses,
() => {
if (!source.value) {
sourceId.value = props.statuses[0]?.id ?? null;
reset();
} else if (!form.isDirty && !form.processing) {
reset();
}
},
);
function selectSource(event: Event): void {
const select = event.target as HTMLSelectElement;
if (form.processing || !protection.confirmLeave()) {
select.value = String(sourceId.value);
return;
}
sourceId.value = Number(select.value);
reset();
}
function toggleTarget(id: number, event: Event): void {
saved.value = false;
const checked = (event.target as HTMLInputElement).checked;
form.allowed_ticket_status_ids = checked
? [...form.allowed_ticket_status_ids, id].sort((a, b) => a - b)
: form.allowed_ticket_status_ids.filter((target) => target !== id);
form.clearErrors();
protection.clearFeedback();
}
function submit(): void {
if (!source.value || !form.isDirty || form.processing) {
return;
}
protection.submit(
updateStatusTransitions([props.projectId, source.value.id]),
{
preserveScroll: true,
onSuccess: () => {
saved.value = true;
form.defaults({
allowed_ticket_status_ids: [...savedTargets.value],
});
form.resetAndClearErrors();
},
},
);
}
</script>
<template>
<section
class="bg-card min-w-0 overflow-hidden rounded-xl border"
aria-labelledby="workflow-transitions"
>
<header class="grid gap-3 border-b p-5 sm:p-6">
<div class="flex items-center gap-2">
<ArrowRightLeft
class="text-primary size-5 shrink-0"
aria-hidden="true"
/>
<h3 id="workflow-transitions" class="font-semibold">
{{ t('workflow.transitions') }}
</h3>
</div>
<p class="text-muted-foreground text-sm">
{{ t('workflow.transitionsDescription') }}
</p>
</header>
<div class="grid min-w-0 gap-5 p-5 sm:p-6">
<p v-if="!source" class="text-muted-foreground text-sm">
{{ t('workflow.noStatuses') }}
</p>
<template v-else>
<div class="grid min-w-0 gap-2">
<Label for="workflow-source">{{
t('workflow.sourceStatus')
}}</Label>
<select
id="workflow-source"
:value="sourceId"
:disabled="form.processing"
class="border-input bg-background focus-visible:ring-ring/40 min-h-11 w-full min-w-0 rounded-lg border px-3 py-2 text-sm outline-none focus-visible:ring-2 disabled:opacity-50"
@change="selectSource"
>
<option
v-for="status in statuses"
:key="status.id"
:value="status.id"
>
{{ ticketStatusLabel(status, '') }}
</option>
</select>
<p class="text-muted-foreground text-sm">
{{ t('workflow.directionHelp') }}
</p>
</div>
<form
class="grid min-w-0 gap-4"
:aria-busy="form.processing"
@submit.prevent="submit"
>
<fieldset
:disabled="form.processing"
class="grid min-w-0 gap-3"
aria-describedby="workflow-target-help workflow-transition-errors"
:aria-invalid="hasErrors"
>
<legend class="mb-3 break-words font-medium">
{{
t('workflow.targetsFrom', { name: source.name })
}}
</legend>
<p
id="workflow-target-help"
class="text-muted-foreground text-sm"
>
{{ t('workflow.targetHelp') }}
</p>
<p
v-if="targets.length === 0"
class="text-muted-foreground text-sm"
>
{{ t('workflow.noTargets') }}
</p>
<div v-else class="grid min-w-0 gap-2 sm:grid-cols-2">
<label
v-for="target in targets"
:key="target.id"
class="has-[:focus-visible]:ring-ring/40 flex min-h-14 min-w-0 cursor-pointer items-center gap-3 rounded-lg border p-3 has-[:focus-visible]:ring-2"
:class="
form.allowed_ticket_status_ids.includes(
target.id,
)
? 'border-primary/40 bg-accent/40'
: 'border-input'
"
>
<input
type="checkbox"
name="allowed_ticket_status_ids[]"
:value="target.id"
:checked="
form.allowed_ticket_status_ids.includes(
target.id,
)
"
class="accent-primary size-4 shrink-0"
@change="toggleTarget(target.id, $event)"
/>
<span class="grid min-w-0 gap-1">
<span
class="break-words text-sm font-medium"
>{{
ticketStatusLabel(target, '')
}}</span
>
<span
class="text-muted-foreground text-xs"
>{{
t(
form.allowed_ticket_status_ids.includes(
target.id,
)
? 'workflow.allowed'
: 'workflow.notAllowed',
)
}}</span
>
</span>
</label>
</div>
</fieldset>
<p
v-if="
targets.length > 0 &&
form.allowed_ticket_status_ids.length === 0
"
class="text-muted-foreground text-sm"
>
{{ t('workflow.noMovesHelp') }}
</p>
<div id="workflow-transition-errors" role="alert">
<InputError
v-for="(message, key) in form.errors"
:key="key"
:message="message"
/>
</div>
<FormFeedback
:state="
!saved && protection.state.value === 'success'
? 'idle'
: protection.state.value
"
@retry="submit"
/>
<div
class="flex flex-wrap items-center gap-3 border-t pt-4"
>
<Badge
:variant="form.isDirty ? 'warning' : 'secondary'"
role="status"
>{{
t(
form.isDirty
? 'workflow.unsavedMoves'
: 'workflow.savedMoves',
{ count: savedTargets.length },
)
}}</Badge
>
<div
class="flex w-full flex-wrap gap-2 sm:ml-auto sm:w-auto"
>
<Button
type="button"
variant="outline"
:disabled="!form.isDirty || form.processing"
@click="reset"
>{{ t('workflow.resetMoves') }}</Button
>
<Button
type="submit"
:disabled="!form.isDirty"
:loading="form.processing"
>{{ t('workflow.saveMoves') }}</Button
>
</div>
</div>
</form>
</template>
</div>
</section>
</template>
@@ -0,0 +1,320 @@
<script setup lang="ts">
import {
ClipboardCheck,
IndentDecrease,
IndentIncrease,
Plus,
Trash2,
} from 'lucide-vue-next';
import { computed, nextTick, ref, useId, watch } from 'vue';
import { Button } from '@/components/ui/button';
import InputError from '@/components/InputError.vue';
import { useLocalization } from '@/composables/useLocalization';
import {
canIndentAcceptanceCriterion,
indentAcceptanceCriterion,
removeAcceptanceCriterion,
syncAcceptanceCriteriaDescendantCheckedState,
} from '@/lib/acceptance-criteria';
import type { AcceptanceCriteriaModel } from '@/types/models/acceptance-criteria-model';
const props = withDefaults(
defineProps<{
modelValue: AcceptanceCriteriaModel;
readonly?: boolean;
disabled?: boolean;
persistToggles?: boolean;
allowEditing?: boolean;
saving?: boolean;
dirty?: boolean;
error?: string;
}>(),
{
readonly: false,
disabled: false,
persistToggles: false,
allowEditing: true,
saving: undefined,
dirty: false,
error: '',
},
);
const emit = defineEmits<{
'update:modelValue': [items: AcceptanceCriteriaModel];
toggle: [index: number, checked: boolean];
}>();
const { t } = useLocalization();
const id = useId();
const root = ref<HTMLElement>();
const editing = ref(!props.persistToggles);
const completed = computed(
() => props.modelValue.filter((item) => item.checked).length,
);
const locked = computed(() => props.readonly || props.disabled);
const update = (items: AcceptanceCriteriaModel): void => {
if (!locked.value) {
emit('update:modelValue', items);
}
};
const focusRow = async (index: number): Promise<void> => {
await nextTick();
const inputs =
root.value?.querySelectorAll<HTMLTextAreaElement>('textarea');
if (inputs?.[index]) {
inputs[index].focus();
} else {
root.value
?.querySelector<HTMLButtonElement>('[data-add-criterion]')
?.focus();
}
};
const add = (): void => {
update([...props.modelValue, { text: '', checked: false, indent: 0 }]);
void focusRow(props.modelValue.length);
};
const remove = (index: number): void => {
update(removeAcceptanceCriterion(props.modelValue, index));
void focusRow(Math.max(0, index - 1));
};
watch(
() => props.modelValue,
async () => {
await nextTick();
root.value
?.querySelectorAll<HTMLTextAreaElement>('textarea')
.forEach((input) => {
input.setCustomValidity(
input.value.trim() === '' ? t('criteria.required') : '',
);
});
},
);
const changeText = (index: number, event: Event): void => {
const text = (event.target as HTMLTextAreaElement).value;
update(
props.modelValue.map((item, position) =>
position === index ? { ...item, text } : item,
),
);
};
const toggle = (index: number, event: Event): void => {
const target = event.target as HTMLInputElement;
const checked = target.checked;
target.checked = props.modelValue[index].checked;
if (locked.value) {
return;
}
if (props.persistToggles && !editing.value && !props.dirty) {
emit('toggle', index, checked);
} else {
update(
syncAcceptanceCriteriaDescendantCheckedState(
props.modelValue,
index,
checked,
),
);
}
};
const onKeydown = (index: number, event: KeyboardEvent): void => {
if (!event.altKey || !['ArrowLeft', 'ArrowRight'].includes(event.key)) {
return;
}
event.preventDefault();
update(
indentAcceptanceCriterion(
props.modelValue,
index,
event.key === 'ArrowRight' ? 1 : -1,
),
);
};
</script>
<template>
<section
ref="root"
class="border-border bg-card min-w-0 space-y-4 rounded-xl border p-4 sm:p-5"
:aria-labelledby="`${id}-title`"
:aria-busy="disabled"
>
<div class="flex flex-wrap items-center justify-between gap-3">
<h3
:id="`${id}-title`"
class="flex items-center gap-2 font-semibold"
>
<ClipboardCheck
class="text-primary size-5"
aria-hidden="true"
/>
{{ t('criteria.title') }}
</h3>
<span class="text-muted-foreground text-sm" aria-live="polite">{{
t('criteria.progress', { completed, total: modelValue.length })
}}</span>
<Button
v-if="persistToggles && allowEditing && !readonly"
type="button"
variant="outline"
:disabled="
disabled ||
(editing &&
modelValue.some((item) => item.text.trim() === ''))
"
:aria-pressed="editing"
@click="editing = !editing"
>
{{ t(editing ? 'criteria.preview' : 'criteria.edit') }}
</Button>
</div>
<p v-if="!modelValue.length" class="text-muted-foreground text-sm">
{{ t('criteria.empty') }}
</p>
<ol v-else class="space-y-3">
<li
v-for="(item, index) in modelValue"
:key="item.id ?? `new-${index}`"
:data-indent="item.indent"
class="border-border min-w-0"
:class="{ 'border-s-2 ps-2': item.indent > 0 }"
:style="{ marginInlineStart: `${item.indent * 0.5}rem` }"
>
<div class="flex min-w-0 items-start gap-2">
<label
class="focus-within:ring-ring flex min-h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-md focus-within:ring-2"
:class="{ 'cursor-not-allowed opacity-60': locked }"
>
<input
:checked="item.checked"
type="checkbox"
:disabled="locked"
:aria-label="
t('criteria.complete', {
number: index + 1,
text: item.text,
})
"
class="border-input accent-primary size-5 rounded"
@change="toggle(index, $event)"
/>
</label>
<div class="min-w-0 flex-1">
<textarea
v-if="editing && !readonly"
:value="item.text"
:aria-label="
t('criteria.item', { number: index + 1 })
"
:aria-describedby="`${id}-hint ${id}-error`"
:disabled="disabled"
:aria-invalid="Boolean(error)"
required
rows="2"
class="border-input bg-background focus-visible:ring-ring min-h-11 w-full rounded-md border px-3 py-2 text-sm outline-none focus-visible:ring-2 disabled:opacity-60"
@input="changeText(index, $event)"
@keydown="onKeydown(index, $event)"
/>
<p
v-else
class="wrap-anywhere min-h-11 whitespace-pre-wrap py-2.5 text-sm"
:class="{
'text-muted-foreground line-through':
item.checked,
}"
>
{{ item.text }}
</p>
<div
v-if="editing && !readonly"
class="flex flex-wrap gap-1"
>
<Button
type="button"
variant="ghost"
size="icon"
:disabled="disabled || item.indent === 0"
:aria-label="
t('criteria.outdent', { number: index + 1 })
"
@click="
update(
indentAcceptanceCriterion(
modelValue,
index,
-1,
),
)
"
><IndentDecrease aria-hidden="true"
/></Button>
<Button
type="button"
variant="ghost"
size="icon"
:disabled="
disabled ||
!canIndentAcceptanceCriterion(
modelValue,
index,
)
"
:aria-label="
t('criteria.indent', { number: index + 1 })
"
@click="
update(
indentAcceptanceCriterion(
modelValue,
index,
1,
),
)
"
><IndentIncrease aria-hidden="true"
/></Button>
<Button
type="button"
variant="ghost"
size="icon"
:disabled="disabled"
:aria-label="
t('criteria.remove', { number: index + 1 })
"
@click="remove(index)"
><Trash2 aria-hidden="true"
/></Button>
</div>
</div>
</div>
</li>
</ol>
<template v-if="editing && !readonly">
<Button
data-add-criterion
type="button"
variant="outline"
:disabled="disabled"
@click="add"
><Plus aria-hidden="true" />{{ t('criteria.add') }}</Button
>
<p :id="`${id}-hint`" class="text-muted-foreground text-xs">
{{ t('criteria.hint') }}
</p>
</template>
<p
v-if="persistToggles && dirty"
class="text-muted-foreground text-sm"
role="status"
>
{{ t('criteria.unsaved') }}
</p>
<p
v-if="saving ?? disabled"
class="text-muted-foreground text-sm"
role="status"
>
{{ t('criteria.saving') }}
</p>
<InputError :id="`${id}-error`" :message="error" role="alert" />
</section>
</template>
@@ -0,0 +1,416 @@
<script setup lang="ts">
import { Link, useForm, usePage } from '@inertiajs/vue3';
import {
CheckCircle2,
CircleAlert,
GitBranchPlus,
LoaderCircle,
} from 'lucide-vue-next';
import { computed, ref, watch } from 'vue';
import {
store,
validate as validateBranch,
} from '@/actions/App/Http/Controllers/ProjectTicketBranchController';
import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import { edit as editGitConnections } from '@/routes/git-connections';
import type { BranchNameValidationModel } from '@/types/models/branch-name-validation-model';
import type {
TicketBranchCreateDialogProps,
TicketBranchFormData,
} from '@/types/ticket-detail';
const props = defineProps<TicketBranchCreateDialogProps>();
const { t } = useLocalization();
const appName = String(usePage().props.name);
const draftKey = `projects:${props.project.id}:tickets:${props.ticket.id}:branch`;
const isOpen = ref(false);
const isCheckingBranchName = ref(false);
const branchNameValidation = ref<BranchNameValidationModel | null>(null);
const defaultRepositoryConnectionId = computed(() =>
String(
props.repositoryConnections.find(
(repositoryConnection) => repositoryConnection.has_personal_access,
)?.id ??
props.repositoryConnections[0]?.id ??
'',
),
);
const form = useForm<TicketBranchFormData>(draftKey, {
branch_name: props.suggestedBranchName,
repository_connection_id: defaultRepositoryConnectionId.value,
});
const protection = useFormProtection(form, {
isDirty: () => isOpen.value && form.isDirty,
});
const selectedRepositoryConnection = computed(
() =>
props.repositoryConnections.find(
(repositoryConnection) =>
String(repositoryConnection.id) ===
form.repository_connection_id,
) ?? null,
);
const selectedRepositoryHasPersonalAccess = computed(
() => selectedRepositoryConnection.value?.has_personal_access ?? false,
);
const validationStateClasses = computed(() => {
if (isCheckingBranchName.value) {
return 'border-primary/30 bg-primary/5 text-foreground';
}
if (branchNameValidation.value?.is_valid) {
return 'border-success/40 bg-success/10 text-success-foreground';
}
if (branchNameValidation.value !== null) {
return 'border-warning/40 bg-warning/10 text-warning-foreground';
}
return 'border-border bg-muted/30 text-muted-foreground';
});
function updateOpen(open: boolean): void {
if (!open && !protection.confirmLeave()) {
return;
}
isOpen.value = open;
}
function initializeForm(): void {
if (form.isDirty) {
return;
}
form.clearErrors();
protection.clearFeedback();
form.branch_name = props.suggestedBranchName;
form.repository_connection_id = defaultRepositoryConnectionId.value;
branchNameValidation.value = null;
isCheckingBranchName.value = false;
}
watch(isOpen, (open) => {
if (!open) {
form.clearErrors();
protection.clearFeedback();
branchNameValidation.value = null;
isCheckingBranchName.value = false;
return;
}
initializeForm();
});
watch(
[isOpen, () => form.repository_connection_id, () => form.branch_name],
(
[open, repositoryConnectionId, branchName],
_previousValues,
onCleanup,
) => {
if (!open) {
return;
}
const repositoryConnection = selectedRepositoryConnection.value;
if (repositoryConnection === null || repositoryConnectionId === '') {
branchNameValidation.value = null;
isCheckingBranchName.value = false;
return;
}
if (!repositoryConnection.has_personal_access) {
branchNameValidation.value = {
is_valid: false,
message: t('ticketDetail.connectGitFirst'),
};
isCheckingBranchName.value = false;
return;
}
if (branchName.trim() === '') {
branchNameValidation.value = {
is_valid: false,
message: t('ticketDetail.branchRequired'),
};
isCheckingBranchName.value = false;
return;
}
branchNameValidation.value = null;
isCheckingBranchName.value = true;
const abortController = new AbortController();
const timeoutId = window.setTimeout(async () => {
try {
const response = await fetch(
validateBranch.url(
{
project: props.project.id,
ticket: props.ticket.id,
},
{
query: {
branch_name: branchName,
repository_connection_id:
repositoryConnectionId,
},
},
),
{
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
method: 'GET',
signal: abortController.signal,
},
);
if (!response.ok) {
throw new Error('Branch validation failed.');
}
branchNameValidation.value =
(await response.json()) as BranchNameValidationModel;
} catch (error) {
if (
error instanceof DOMException &&
error.name === 'AbortError'
) {
return;
}
branchNameValidation.value = {
is_valid: false,
message: t('ticketDetail.branchValidationFailed', {
app: appName,
}),
};
} finally {
if (!abortController.signal.aborted) {
isCheckingBranchName.value = false;
}
}
}, 300);
onCleanup(() => {
abortController.abort();
window.clearTimeout(timeoutId);
isCheckingBranchName.value = false;
});
},
{ immediate: true },
);
function submit(): void {
if (
!selectedRepositoryHasPersonalAccess.value ||
branchNameValidation.value?.is_valid !== true
) {
return;
}
protection.submit(
store({
project: props.project.id,
ticket: props.ticket.id,
}),
{
onSuccess: () => {
isOpen.value = false;
},
preserveScroll: true,
},
);
}
</script>
<template>
<Dialog :open="isOpen" @update:open="updateOpen">
<Button
type="button"
variant="outline"
class="w-full gap-2 sm:w-auto"
@click="isOpen = true"
>
<GitBranchPlus class="size-4" aria-hidden="true" />
{{ t('ticketDetail.createBranch') }}
</Button>
<DialogContent class="sm:max-w-lg">
<DialogHeader class="space-y-3">
<DialogTitle>{{
t('ticketDetail.createBranchTitle')
}}</DialogTitle>
<DialogDescription>{{
t('ticketDetail.createBranchDescription')
}}</DialogDescription>
</DialogHeader>
<form class="grid gap-4" @submit.prevent="submit">
<FormFeedback
:state="protection.state.value"
:title="
protection.state.value === 'loading'
? t('ticketDetail.creatingBranch')
: undefined
"
@retry="submit"
/>
<fieldset
:disabled="form.processing"
class="grid min-w-0 gap-4"
>
<div class="grid min-w-0 gap-2">
<Label for="repository_connection_id">{{
t('ticketDetail.repository')
}}</Label>
<select
id="repository_connection_id"
v-model="form.repository_connection_id"
class="min-h-control border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
>
<option
v-for="repositoryConnection in repositoryConnections"
:key="repositoryConnection.id"
:value="String(repositoryConnection.id)"
>
{{ repositoryConnection.repository_full_name
}}{{
repositoryConnection.has_personal_access
? ''
: ` — ${t('ticketDetail.personalAccessRequired')}`
}}
</option>
</select>
<InputError
:message="form.errors.repository_connection_id"
/>
</div>
<div class="grid min-w-0 gap-2">
<Label for="branch_name">{{
t('ticketDetail.branchName')
}}</Label>
<Input
id="branch_name"
v-model="form.branch_name"
:disabled="!selectedRepositoryHasPersonalAccess"
autocomplete="off"
/>
<InputError :message="form.errors.branch_name" />
</div>
</fieldset>
<div
role="status"
aria-live="polite"
:aria-busy="isCheckingBranchName || undefined"
class="rounded-lg border px-4 py-3 transition-colors"
:class="validationStateClasses"
>
<div class="flex items-start gap-3">
<LoaderCircle
v-if="isCheckingBranchName"
class="mt-0.5 size-4 shrink-0 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
<CheckCircle2
v-else-if="branchNameValidation?.is_valid"
class="mt-0.5 size-4 shrink-0"
aria-hidden="true"
/>
<CircleAlert
v-else-if="branchNameValidation !== null"
class="mt-0.5 size-4 shrink-0"
aria-hidden="true"
/>
<p class="text-sm">
{{
isCheckingBranchName
? t('ticketDetail.checkingBranch')
: (branchNameValidation?.message ??
t('ticketDetail.selectRepository'))
}}
</p>
</div>
</div>
<div
v-if="
!selectedRepositoryHasPersonalAccess &&
selectedRepositoryConnection !== null
"
class="border-border bg-muted/20 grid gap-3 rounded-lg border border-dashed px-4 py-4"
>
<div class="grid gap-1">
<p class="font-semibold">
{{ t('ticketDetail.gitAccessRequired') }}
</p>
<p class="text-muted-foreground text-sm">
{{ t('ticketDetail.branchAccess') }}
</p>
</div>
<Button
as-child
variant="outline"
size="sm"
class="justify-self-start"
>
<Link :href="editGitConnections()">{{
t('ticketDetail.openGitConnections')
}}</Link>
</Button>
</div>
<DialogFooter class="gap-2">
<Button
type="button"
variant="secondary"
:disabled="form.processing"
@click="updateOpen(false)"
>
{{ t('common.cancel') }}
</Button>
<Button
type="submit"
:disabled="
isCheckingBranchName ||
branchNameValidation?.is_valid !== true
"
:loading="form.processing"
:loading-label="t('ticketDetail.creatingBranch')"
>
{{ t('ticketDetail.createNow') }}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>
@@ -0,0 +1,204 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { computed } from 'vue';
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.vue';
import TicketFormField from '@/components/tickets/TicketFormField.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
} from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import type { TicketDetailBlockerProps } from '@/types/ticket-detail';
const props = defineProps<TicketDetailBlockerProps>();
const { t } = useLocalization();
const savedFormData = () => ({
acceptance_criteria: props.ticket.acceptance_criteria,
assignee_user_id: props.ticket.assignee_user_id,
blocked_comment: props.ticket.blocked_comment ?? '',
description: props.ticket.description,
field_values: (props.ticket.field_values ?? []).map((fieldValue) => ({
ticket_field_definition_id: fieldValue.ticket_field_definition_id,
value_number: fieldValue.value_number,
value_user_id: fieldValue.value_user_id,
})),
is_blocked: props.ticket.is_blocked,
parent_ticket_id: props.ticket.parent_ticket_id,
story_points: props.ticket.story_points,
ticket_priority_id: props.ticket.ticket_priority_id,
ticket_type_id: props.ticket.ticket_type_id,
title: props.ticket.title,
});
const form = useForm(
`projects:${props.project.id}:tickets:${props.ticket.id}:blocker`,
savedFormData(),
);
const protection = useFormProtection(form);
const isBlocked = computed(() => Boolean(form.is_blocked));
const reset = (): void => {
if (form.processing || !protection.confirmLeave()) {
return;
}
form.reset();
form.clearErrors();
protection.clearFeedback();
};
const submit = (): void => {
if (!props.canEditTicket || form.processing) {
return;
}
protection.submit(
ProjectTicketController.update({
project: props.project.id,
ticket: props.ticket.id,
}),
{
preserveScroll: true,
onSuccess: () => {
Object.assign(form, savedFormData());
form.defaults();
},
},
);
};
</script>
<template>
<section
id="ticket-blocker"
aria-labelledby="ticket-blocker-title"
class="min-w-0 scroll-mt-6"
>
<Card :class="{ 'border-warning/40': isBlocked }">
<CardHeader>
<div class="flex flex-wrap items-center justify-between gap-3">
<h2 id="ticket-blocker-title" class="text-lg font-semibold">
{{ t('ticketDetail.blocker') }}
</h2>
<Badge :variant="isBlocked ? 'warning' : 'outline'">
{{
isBlocked
? t('ticketDetail.blockerActive')
: t('ticketDetail.blockerInactive')
}}
</Badge>
</div>
<CardDescription>
{{ t('ticketDetail.blockerDescription') }}
</CardDescription>
</CardHeader>
<CardContent v-if="canEditTicket">
<form class="grid min-w-0 gap-5" @submit.prevent="submit">
<FormFeedback
:state="protection.state.value"
@retry="submit"
/>
<div
class="bg-muted/30 flex min-w-0 items-start gap-3 rounded-xl border p-4"
>
<input
id="is_blocked"
v-model="form.is_blocked"
type="checkbox"
:disabled="form.processing"
:aria-invalid="Boolean(form.errors.is_blocked)"
aria-describedby="ticket-blocker-state-description is_blocked-error"
class="border-input text-primary focus-visible:ring-ring mt-0.5 size-4 shrink-0 rounded focus-visible:ring-2"
/>
<div class="grid min-w-0 gap-1">
<Label for="is_blocked">
{{ t('ticketDetail.blocked') }}
</Label>
<p
id="ticket-blocker-state-description"
class="text-muted-foreground text-sm"
>
{{
isBlocked
? t(
'ticketDetail.blockerActiveDescription',
)
: t(
'ticketDetail.blockerInactiveDescription',
)
}}
</p>
</div>
</div>
<InputError
id="is_blocked-error"
:message="form.errors.is_blocked"
/>
<TicketFormField
v-if="isBlocked"
id="blocked_comment"
v-model="form.blocked_comment"
kind="textarea"
:rows="3"
:label="t('ticketDetail.blockerComment')"
:hint="t('ticketDetail.blockerCommentHelp')"
:placeholder="t('ticketDetail.blockerPlaceholder')"
:error="form.errors.blocked_comment"
:disabled="form.processing"
required
/>
<div
class="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end"
>
<Button
type="button"
variant="outline"
:disabled="!form.isDirty || form.processing"
@click="reset"
>
{{ t('ticketDetail.resetBlocker') }}
</Button>
<Button
type="submit"
:loading="form.processing"
:loading-label="t('ticketDetail.savingBlocker')"
:disabled="!form.isDirty"
>
{{
isBlocked
? t('ticketDetail.saveBlocker')
: t('ticketDetail.unblockTicket')
}}
</Button>
</div>
</form>
</CardContent>
<CardContent v-else>
<p
class="whitespace-pre-wrap text-sm leading-6"
:class="{ 'text-muted-foreground': !isBlocked }"
>
{{
isBlocked
? ticket.blocked_comment
: t('ticketDetail.blockerInactiveDescription')
}}
</p>
</CardContent>
</Card>
</section>
</template>
@@ -3,43 +3,54 @@ import { useLocalization } from '@/composables/useLocalization';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { useForm, useRemember } from '@inertiajs/vue3';
import { computed, ref, toRef, unref } from 'vue';
import {
computed,
ref,
toRef,
unref,
useTemplateRef,
nextTick,
watch,
} from 'vue';
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
import InputError from '@/components/InputError.vue';
import AcceptanceCriteriaChecklist from '@/components/tickets/AcceptanceCriteriaChecklist.vue';
import FormFeedback from '@/components/FormFeedback.vue';
import { Button } from '@/components/ui/button';
import TicketFormField from '@/components/tickets/TicketFormField.vue';
import TicketFormFields from '@/components/tickets/TicketFormFields.vue';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import {
acceptanceCriteriaToEditorState,
buildAcceptanceCriteria,
formatChecklistLines,
normalizeAcceptanceCriteria,
syncAcceptanceCriteriaDescendantCheckedState,
} from '@/lib/acceptance-criteria';
import type { TicketDetailContentProps } from '@/types/ticket-detail';
const props = defineProps<TicketDetailContentProps>();
const { t } = useLocalization();
const editor = useTemplateRef<HTMLDetailsElement>('editor');
async function editParent(): Promise<void> {
if (!props.canEditTicket || !editor.value) {
return;
}
editor.value.open = true;
await nextTick();
editor.value
?.querySelector<HTMLSelectElement>('#parent_ticket_id')
?.focus();
}
defineExpose({ editParent });
const canEditAuthor = computed(
() => props.canEditTicket && props.canManageProject,
);
const canViewAuthor = computed(
() => props.project.show_author || props.canManageProject,
);
const acceptanceCriteriaEditorState = acceptanceCriteriaToEditorState(
props.ticket.acceptance_criteria,
);
const draftKey = `projects:${props.project.id}:tickets:${props.ticket.id}:edit`;
const acceptanceCriteriaDraft = useRemember(
{ checklist: acceptanceCriteriaEditorState.checklist },
`${draftKey}:checklist`,
);
const acceptanceCriteriaChecklist = toRef(
unref(acceptanceCriteriaDraft),
'checklist',
{ items: normalizeAcceptanceCriteria(props.ticket.acceptance_criteria) },
`${draftKey}:criteria`,
);
const acceptanceCriteriaItems = toRef(unref(acceptanceCriteriaDraft), 'items');
const savedAcceptanceCriteria = ref(
normalizeAcceptanceCriteria(props.ticket.acceptance_criteria),
);
@@ -47,8 +58,8 @@ const isPersistingAcceptanceCriteriaToggle = ref(false);
const acceptanceCriteriaToggleError = ref('');
const savedFormData = () => ({
acceptance_criteria: buildAcceptanceCriteria(
acceptanceCriteriaChecklist.value,
acceptance_criteria: normalizeAcceptanceCriteria(
savedAcceptanceCriteria.value,
),
assignee_user_id: props.ticket.assignee_user_id ?? '',
author_user_id: props.ticket.author_user_id ?? '',
@@ -77,14 +88,20 @@ const savedFormData = () => ({
const form = useForm(draftKey, savedFormData());
const acceptanceCriteriaPreviewItems = computed(
() => savedAcceptanceCriteria.value,
watch(
() => [props.ticket.is_blocked, props.ticket.blocked_comment] as const,
([isBlocked, blockedComment]) => {
form.is_blocked = isBlocked;
form.blocked_comment = blockedComment ?? '';
form.defaults('is_blocked', isBlocked);
form.defaults('blocked_comment', blockedComment ?? '');
},
);
const hasUnsavedAcceptanceCriteriaDraft = computed(
() =>
acceptanceCriteriaChecklist.value !==
formatChecklistLines(savedAcceptanceCriteria.value),
JSON.stringify(acceptanceCriteriaItems.value) !==
JSON.stringify(savedAcceptanceCriteria.value),
);
const isDirty = computed(
@@ -103,9 +120,10 @@ const reset = (): void => {
}
form.reset();
form.clearErrors();
acceptanceCriteriaChecklist.value = formatChecklistLines(
acceptanceCriteriaItems.value = normalizeAcceptanceCriteria(
savedAcceptanceCriteria.value,
);
acceptanceCriteriaToggleError.value = '';
protection.clearFeedback();
};
@@ -119,19 +137,6 @@ const acceptanceCriteriaError = computed(() => {
);
});
const buildAcceptanceCriteriaForSubmit = () => {
const draftItems = buildAcceptanceCriteria(
acceptanceCriteriaChecklist.value,
);
return draftItems.map((item, index) => ({
...item,
...(savedAcceptanceCriteria.value[index]?.id !== undefined
? { id: savedAcceptanceCriteria.value[index].id }
: {}),
}));
};
const buildSavedTicketPayload = (
acceptanceCriteria = savedAcceptanceCriteria.value,
) => ({
@@ -171,10 +176,6 @@ const persistAcceptanceCriteriaToggle = async (
index,
checked,
);
const previousChecklist = formatChecklistLines(
savedAcceptanceCriteria.value,
);
const nextChecklist = formatChecklistLines(nextAcceptanceCriteria);
const csrfToken = document
.querySelector('meta[name="csrf-token"]')
?.getAttribute('content');
@@ -209,34 +210,18 @@ const persistAcceptanceCriteriaToggle = async (
savedAcceptanceCriteria.value = nextAcceptanceCriteria;
if (acceptanceCriteriaChecklist.value === previousChecklist) {
acceptanceCriteriaChecklist.value = nextChecklist;
form.acceptance_criteria = nextAcceptanceCriteria;
form.defaults('acceptance_criteria', nextAcceptanceCriteria);
}
} catch (_error) {
acceptanceCriteriaToggleError.value = t(
'ticketDetail.checklistSaveFailed',
acceptanceCriteriaItems.value = normalizeAcceptanceCriteria(
nextAcceptanceCriteria,
);
form.acceptance_criteria = nextAcceptanceCriteria;
form.defaults('acceptance_criteria', nextAcceptanceCriteria);
} catch (_error) {
acceptanceCriteriaToggleError.value = t('criteria.saveError');
} finally {
isPersistingAcceptanceCriteriaToggle.value = false;
}
};
const onAcceptanceCriteriaCheckboxChange = async (
index: number,
event: Event,
): Promise<void> => {
const target = event.target;
if (!(target instanceof HTMLInputElement)) {
return;
}
await persistAcceptanceCriteriaToggle(index, target.checked);
target.checked = savedAcceptanceCriteria.value[index]?.checked ?? false;
};
const submit = (): void => {
if (
!props.canEditTicket ||
@@ -246,7 +231,7 @@ const submit = (): void => {
return;
}
form.acceptance_criteria = buildAcceptanceCriteriaForSubmit();
form.acceptance_criteria = acceptanceCriteriaItems.value;
protection.submit(
ProjectTicketController.update({
@@ -259,7 +244,7 @@ const submit = (): void => {
savedAcceptanceCriteria.value = normalizeAcceptanceCriteria(
props.ticket.acceptance_criteria,
);
acceptanceCriteriaChecklist.value = formatChecklistLines(
acceptanceCriteriaItems.value = normalizeAcceptanceCriteria(
savedAcceptanceCriteria.value,
);
Object.assign(form, savedFormData());
@@ -301,85 +286,29 @@ const submit = (): void => {
</CardContent>
</Card>
</section>
<section
id="ticket-checklist"
aria-labelledby="ticket-checklist-title"
class="scroll-mt-6"
>
<Card>
<CardHeader
><h2
id="ticket-checklist-title"
class="text-lg font-semibold"
>
{{ t('ticketDetail.checklist') }}
</h2></CardHeader
>
<CardContent class="space-y-3">
<div
v-if="acceptanceCriteriaPreviewItems.length > 0"
class="border-border rounded-xl border p-4 text-sm"
>
<ul class="space-y-2">
<li
v-for="(
item, index
) in acceptanceCriteriaPreviewItems"
:key="item.id ?? index"
:style="{
marginInlineStart: `${item.indent * 0.75}rem`,
}"
class="flex min-w-0 items-start gap-2"
>
<input
:id="`acceptance-criteria-${item.id ?? index}`"
:checked="item.checked"
type="checkbox"
:disabled="
!canEditTicket ||
form.processing ||
isPersistingAcceptanceCriteriaToggle ||
hasUnsavedAcceptanceCriteriaDraft
"
class="border-input text-primary focus:ring-ring mt-3.5 size-4 shrink-0 rounded"
@change="
onAcceptanceCriteriaCheckboxChange(
index,
$event,
)
"
/>
<label
:for="`acceptance-criteria-${item.id ?? index}`"
class="min-h-control wrap-anywhere flex min-w-0 flex-1 items-center py-2"
>
{{ item.text }}
</label>
</li>
</ul>
</div>
<p v-else class="text-muted-foreground text-sm">
{{ t('ticketDetail.noChecklist') }}
</p>
<p
v-if="hasUnsavedAcceptanceCriteriaDraft"
class="text-muted-foreground text-sm"
>
{{ t('ticketDetail.saveChecklistFirst') }}
</p>
<p
v-if="acceptanceCriteriaToggleError"
role="alert"
class="text-destructive text-sm"
>
{{ acceptanceCriteriaToggleError }}
</p>
</CardContent>
</Card>
</section>
<div id="ticket-checklist" class="scroll-mt-6">
<AcceptanceCriteriaChecklist
:model-value="savedAcceptanceCriteria"
:readonly="!canEditTicket"
:disabled="
form.processing ||
isPersistingAcceptanceCriteriaToggle ||
hasUnsavedAcceptanceCriteriaDraft
"
:saving="
form.processing || isPersistingAcceptanceCriteriaToggle
"
:allow-editing="false"
persist-toggles
:dirty="hasUnsavedAcceptanceCriteriaDraft"
:error="acceptanceCriteriaToggleError"
@toggle="persistAcceptanceCriteriaToggle"
/>
</div>
<details
v-if="canEditTicket"
id="ticket-edit"
ref="editor"
class="bg-card group rounded-xl border shadow-sm"
>
<summary
@@ -416,20 +345,13 @@ const submit = (): void => {
"
>
<template #content>
<TicketFormField
id="acceptance_criteria_checklist"
v-model="acceptanceCriteriaChecklist"
kind="textarea"
:label="t('ticketDetail.checklistEditor')"
:hint="t('ticketForm.checklistHelp')"
:placeholder="
t('ticketDetail.checklistPlaceholder')
"
:error="acceptanceCriteriaError"
<AcceptanceCriteriaChecklist
v-model="acceptanceCriteriaItems"
:disabled="
form.processing ||
isPersistingAcceptanceCriteriaToggle
"
:error="acceptanceCriteriaError"
/>
</template>
<template #metadata>
@@ -459,44 +381,6 @@ const submit = (): void => {
/>
</template>
</TicketFormFields>
<fieldset
class="grid min-w-0 gap-4 rounded-xl border p-4 sm:p-5"
:disabled="
form.processing ||
isPersistingAcceptanceCriteriaToggle
"
>
<legend class="px-2 text-sm font-semibold">
{{ t('ticketForm.blocker') }}
</legend>
<div class="min-h-control flex items-center gap-3">
<input
id="is_blocked"
v-model="form.is_blocked"
type="checkbox"
:aria-invalid="Boolean(form.errors.is_blocked)"
aria-describedby="is_blocked-error"
class="border-input text-primary focus-visible:ring-ring size-4 rounded focus-visible:ring-2"
/>
<Label for="is_blocked">{{
t('ticketDetail.blocked')
}}</Label>
</div>
<InputError
id="is_blocked-error"
:message="form.errors.is_blocked"
/>
<TicketFormField
id="blocked_comment"
v-model="form.blocked_comment"
kind="textarea"
:rows="3"
:label="t('ticketDetail.blockerComment')"
:placeholder="t('ticketDetail.blockerPlaceholder')"
:error="form.errors.blocked_comment"
:required="form.is_blocked"
/>
</fieldset>
<fieldset
class="grid min-w-0 gap-4 rounded-xl border p-4 sm:p-5"
:disabled="
@@ -1,273 +1,12 @@
<script setup lang="ts">
import TicketGitBranches from '@/components/tickets/detail/TicketGitBranches.vue';
import TicketGitPullRequests from '@/components/tickets/detail/TicketGitPullRequests.vue';
import { useLocalization } from '@/composables/useLocalization';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import type { TicketDetailGitProps } from '@/types/ticket-detail';
defineProps<TicketDetailGitProps>();
import { Link, useForm, usePage } from '@inertiajs/vue3';
import { CheckCircle2, GitBranchPlus, XCircle } from 'lucide-vue-next';
import { computed, ref, watch } from 'vue';
import ProjectTicketBranchController from '@/actions/App/Http/Controllers/ProjectTicketBranchController';
import FormFeedback from '@/components/FormFeedback.vue';
import FeedbackState from '@/components/FeedbackState.vue';
import InputError from '@/components/InputError.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { edit as editGitConnections } from '@/routes/git-connections';
import type { BranchNameValidationModel } from '@/types/models/branch-name-validation-model';
import type { RepositoryConnectionModel } from '@/types/models/repository-connection-model';
import type {
TicketDetailGitProps,
TicketBranchFormData,
} from '@/types/ticket-detail';
const props = defineProps<TicketDetailGitProps>();
const { t } = useLocalization();
const appName = String(usePage().props.name);
const draftKey = `projects:${props.project.id}:tickets:${props.ticket.id}:edit`;
const visibleRepositoryBranches = computed(() =>
props.repositoryBranches.filter(
(repositoryBranch) =>
repositoryBranch.branches.length > 0 ||
repositoryBranch.error !== null ||
!repositoryBranch.repository_connection.has_personal_access,
),
);
const visibleRepositoryPullRequests = computed(() =>
props.repositoryPullRequests.filter(
(repositoryPullRequest) =>
repositoryPullRequest.pull_requests.length > 0 ||
repositoryPullRequest.error !== null ||
!repositoryPullRequest.repository_connection.has_personal_access,
),
);
const linkedRepositoryConnections = computed<RepositoryConnectionModel[]>(() =>
props.repositoryBranches.map(
(repositoryBranch) => repositoryBranch.repository_connection,
),
);
const canCreateBranches = computed(
() => props.canEditTicket && linkedRepositoryConnections.value.length > 0,
);
const defaultBranchRepositoryConnectionId = computed(() =>
String(
linkedRepositoryConnections.value.find(
(repositoryConnection) => repositoryConnection.has_personal_access,
)?.id ??
linkedRepositoryConnections.value[0]?.id ??
'',
),
);
const isCreateBranchDialogOpen = ref(false);
const isCheckingBranchName = ref(false);
const branchNameValidation = ref<BranchNameValidationModel | null>(null);
const branchCreationForm = useForm<TicketBranchFormData>(`${draftKey}:branch`, {
branch_name: props.suggestedBranchName,
repository_connection_id: defaultBranchRepositoryConnectionId.value,
});
const branchProtection = useFormProtection(branchCreationForm, {
isDirty: () => isCreateBranchDialogOpen.value && branchCreationForm.isDirty,
});
const updateBranchDialogOpen = (open: boolean): void => {
if (!open && !branchProtection.confirmLeave()) {
return;
}
isCreateBranchDialogOpen.value = open;
};
const selectedBranchRepositoryConnection = computed(
(): RepositoryConnectionModel | null =>
linkedRepositoryConnections.value.find(
(repositoryConnection) =>
String(repositoryConnection.id) ===
branchCreationForm.repository_connection_id,
) ?? null,
);
const selectedBranchRepositoryHasPersonalAccess = computed(
() =>
selectedBranchRepositoryConnection.value?.has_personal_access ?? false,
);
const initializeBranchCreationForm = (): void => {
if (branchCreationForm.isDirty) {
return;
}
branchCreationForm.clearErrors();
branchCreationForm.branch_name = props.suggestedBranchName;
branchCreationForm.repository_connection_id =
defaultBranchRepositoryConnectionId.value;
branchNameValidation.value = null;
isCheckingBranchName.value = false;
};
watch(
() => isCreateBranchDialogOpen.value,
(isOpen) => {
if (!isOpen) {
branchCreationForm.clearErrors();
branchNameValidation.value = null;
isCheckingBranchName.value = false;
return;
}
initializeBranchCreationForm();
},
);
watch(
[
() => isCreateBranchDialogOpen.value,
() => branchCreationForm.repository_connection_id,
() => branchCreationForm.branch_name,
],
(
[isOpen, repositoryConnectionId, branchName],
_previousValues,
onCleanup,
) => {
if (!isOpen) {
return;
}
const repositoryConnection = selectedBranchRepositoryConnection.value;
if (repositoryConnection === null || repositoryConnectionId === '') {
branchNameValidation.value = null;
isCheckingBranchName.value = false;
return;
}
if (!repositoryConnection.has_personal_access) {
branchNameValidation.value = {
is_valid: false,
message: t('ticketDetail.connectGitFirst'),
};
isCheckingBranchName.value = false;
return;
}
if (branchName.trim() === '') {
branchNameValidation.value = {
is_valid: false,
message: t('ticketDetail.branchRequired'),
};
isCheckingBranchName.value = false;
return;
}
const abortController = new AbortController();
const timeoutId = window.setTimeout(async () => {
isCheckingBranchName.value = true;
try {
const response = await fetch(
ProjectTicketBranchController.validate.url(
{
project: props.project.id,
ticket: props.ticket.id,
},
{
query: {
branch_name: branchName,
repository_connection_id:
repositoryConnectionId,
},
},
),
{
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
method: 'GET',
signal: abortController.signal,
},
);
if (!response.ok) {
throw new Error('Branch validation failed.');
}
branchNameValidation.value =
(await response.json()) as BranchNameValidationModel;
} catch (error) {
if (
error instanceof DOMException &&
error.name === 'AbortError'
) {
return;
}
branchNameValidation.value = {
is_valid: false,
message: t('ticketDetail.branchValidationFailed', {
app: appName,
}),
};
} finally {
if (!abortController.signal.aborted) {
isCheckingBranchName.value = false;
}
}
}, 300);
onCleanup(() => {
abortController.abort();
window.clearTimeout(timeoutId);
isCheckingBranchName.value = false;
});
},
{ immediate: true },
);
const submitBranchCreation = (): void => {
if (
!canCreateBranches.value ||
!selectedBranchRepositoryHasPersonalAccess.value ||
branchNameValidation.value?.is_valid !== true
) {
return;
}
branchProtection.submit(
ProjectTicketBranchController.store({
project: props.project.id,
ticket: props.ticket.id,
}),
{
onSuccess: () => {
isCreateBranchDialogOpen.value = false;
},
preserveScroll: true,
},
);
};
</script>
<template>
@@ -279,380 +18,18 @@ const submitBranchCreation = (): void => {
<h2 id="ticket-git-title" class="text-lg font-semibold">
{{ t('ticketDetail.git') }}
</h2>
<div class="grid min-w-0 gap-6">
<Card class="h-fit">
<CardHeader
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
<div class="space-y-1.5">
<CardTitle>{{ t('ticketDetail.branches') }}</CardTitle>
<CardDescription>{{
t('ticketDetail.branchesDescription')
}}</CardDescription>
</div>
<Dialog
:open="isCreateBranchDialogOpen"
@update:open="updateBranchDialogOpen"
>
<Button
v-if="canCreateBranches"
type="button"
variant="outline"
class="gap-2"
@click="isCreateBranchDialogOpen = true"
>
<GitBranchPlus class="size-4" />{{
t('ticketDetail.createBranch')
}}</Button
>
<DialogContent class="sm:max-w-lg">
<DialogHeader class="space-y-3">
<DialogTitle>{{
t('ticketDetail.createBranchTitle')
}}</DialogTitle>
<DialogDescription>{{
t('ticketDetail.createBranchDescription')
}}</DialogDescription>
</DialogHeader>
<form
class="grid gap-4"
@submit.prevent="submitBranchCreation"
>
<FormFeedback
:state="branchProtection.state.value"
@retry="submitBranchCreation"
/>
<div class="grid min-w-0 gap-2">
<Label for="repository_connection_id">{{
t('ticketDetail.repository')
}}</Label>
<select
id="repository_connection_id"
v-model="
branchCreationForm.repository_connection_id
"
:disabled="
branchCreationForm.processing
"
class="min-h-control border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
>
<option
v-for="repositoryConnection in linkedRepositoryConnections"
:key="repositoryConnection.id"
:value="
String(repositoryConnection.id)
"
>
{{
`${repositoryConnection.repository_full_name}${repositoryConnection.has_personal_access ? '' : ' (connect personal Git access)'}`
}}
</option>
</select>
<InputError
:message="
branchCreationForm.errors
.repository_connection_id
"
/>
</div>
<div class="grid min-w-0 gap-2">
<Label for="branch_name">{{
t('ticketDetail.branchName')
}}</Label>
<Input
id="branch_name"
v-model="branchCreationForm.branch_name"
:disabled="
branchCreationForm.processing ||
!selectedBranchRepositoryHasPersonalAccess
"
/>
<InputError
:message="
branchCreationForm.errors
.branch_name
"
/>
</div>
<div
class="rounded-xl border px-4 py-3"
:class="
branchNameValidation?.is_valid
? 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100'
: 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-100'
"
>
<div class="flex items-start gap-3">
<CheckCircle2
v-if="
branchNameValidation?.is_valid
"
class="mt-0.5 size-4 shrink-0"
/>
<XCircle
v-else-if="
branchNameValidation !== null
"
class="mt-0.5 size-4 shrink-0"
/>
<p class="text-sm">
{{
isCheckingBranchName
? t(
'ticketDetail.checkingBranch',
)
: (branchNameValidation?.message ??
t(
'ticketDetail.selectRepository',
))
}}
</p>
</div>
</div>
<div
v-if="
!selectedBranchRepositoryHasPersonalAccess &&
selectedBranchRepositoryConnection !==
null
"
class="border-border rounded-xl border border-dashed px-4 py-4"
>
<p class="text-muted-foreground text-sm">
{{ t('ticketDetail.branchAccess') }}
</p>
<Button
as-child
variant="outline"
size="sm"
class="mt-3"
>
<Link :href="editGitConnections()">{{
t('ticketDetail.openGitConnections')
}}</Link>
</Button>
</div>
<DialogFooter class="gap-2">
<Button
type="button"
variant="secondary"
@click="updateBranchDialogOpen(false)"
>{{ t('common.cancel') }}</Button
>
<Button
type="submit"
:disabled="
branchCreationForm.processing ||
isCheckingBranchName ||
branchNameValidation?.is_valid !==
true
"
>{{
t('ticketDetail.createNow')
}}</Button
>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</CardHeader>
<CardContent class="space-y-4">
<FeedbackState
v-if="visibleRepositoryBranches.length === 0"
state="empty"
:title="t('tickets.noBranches')"
:description="t('tickets.noBranchesDescription')"
/>
<div v-else class="space-y-3">
<div
v-for="repositoryBranch in visibleRepositoryBranches"
:key="repositoryBranch.repository_connection.id"
class="border-border rounded-xl border px-4 py-4"
>
<div class="flex flex-wrap items-center gap-2">
<Badge variant="secondary">
{{
repositoryBranch.repository_connection
.provider_label
}}
</Badge>
<span class="font-medium">
{{
repositoryBranch.repository_connection
.repository_full_name
}}
</span>
</div>
<p class="text-muted-foreground mt-2 text-sm">
{{
repositoryBranch.repository_connection
.base_url
}}
</p>
<div
v-if="
!repositoryBranch.repository_connection
.has_personal_access
"
class="mt-3 space-y-3"
>
<p class="text-muted-foreground text-sm">
{{ t('ticketDetail.inspectBranches') }}
</p>
<Button as-child variant="outline" size="sm">
<Link :href="editGitConnections()">{{
t('ticketDetail.openGitConnections')
}}</Link>
</Button>
</div>
<div
v-else-if="repositoryBranch.error"
class="mt-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-100"
>
{{ repositoryBranch.error }}
</div>
<div v-else class="mt-3 flex flex-wrap gap-2">
<Badge
v-for="branch in repositoryBranch.branches"
:key="branch.name"
variant="outline"
class="px-3 py-1"
>
{{ branch.name }}
</Badge>
</div>
</div>
</div>
</CardContent>
</Card>
<Card class="h-fit">
<CardHeader>
<CardTitle>{{ t('ticketDetail.pullRequests') }}</CardTitle>
<CardDescription>{{
t('ticketDetail.pullRequestsDescription')
}}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<FeedbackState
v-if="visibleRepositoryPullRequests.length === 0"
state="empty"
:title="t('tickets.noPullRequests')"
:description="t('tickets.noPullRequestsDescription')"
/>
<div v-else class="space-y-3">
<div
v-for="repositoryPullRequest in visibleRepositoryPullRequests"
:key="
repositoryPullRequest.repository_connection.id
"
class="border-border rounded-xl border px-4 py-4"
>
<div class="flex flex-wrap items-center gap-2">
<Badge variant="secondary">
{{
repositoryPullRequest
.repository_connection
.provider_label
}}
</Badge>
<span class="font-medium">
{{
repositoryPullRequest
.repository_connection
.repository_full_name
}}
</span>
</div>
<p class="text-muted-foreground mt-2 text-sm">
{{
repositoryPullRequest.repository_connection
.base_url
}}
</p>
<div
v-if="
!repositoryPullRequest.repository_connection
.has_personal_access
"
class="mt-3 space-y-3"
>
<p class="text-muted-foreground text-sm">
{{ t('ticketDetail.inspectPullRequests') }}
</p>
<Button as-child variant="outline" size="sm">
<Link :href="editGitConnections()">{{
t('ticketDetail.openGitConnections')
}}</Link>
</Button>
</div>
<div
v-else-if="repositoryPullRequest.error"
class="mt-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-100"
>
{{ repositoryPullRequest.error }}
</div>
<div v-else class="mt-3 space-y-3">
<div
v-for="pullRequest in repositoryPullRequest.pull_requests"
:key="pullRequest.html_url"
class="border-border/70 rounded-xl border px-4 py-3"
>
<div
class="flex flex-wrap items-center gap-2"
>
<Badge variant="outline">
#{{ pullRequest.number }}
</Badge>
<Badge variant="secondary">
{{ pullRequest.status }}
</Badge>
<a
:href="pullRequest.html_url"
target="_blank"
rel="noreferrer"
class="text-foreground hover:text-primary font-medium transition"
>
{{ pullRequest.title }}
</a>
</div>
<p
class="text-muted-foreground mt-2 text-sm"
>
{{
t('ticketDetail.sourceBranch', {
name: pullRequest.source_branch,
})
}}
</p>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
<TicketGitBranches
:ticket="ticket"
:project="project"
:can-edit-ticket="canEditTicket"
:repository-branches="repositoryBranches"
:suggested-branch-name="suggestedBranchName"
/>
<TicketGitPullRequests
:repository-pull-requests="repositoryPullRequests"
/>
</div>
</section>
</template>
@@ -15,6 +15,7 @@ const sections = [
'description',
'checklist',
'metadata',
'blocker',
'relationships',
'history',
'git',
@@ -8,11 +8,21 @@ import {
} from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { UserAvatar } from '@/components/ui/avatar';
import type { TicketDetailHistoryProps } from '@/types/ticket-detail';
import type { TranslationKey } from '@/types/localization';
import type { TicketWorklogEntryType } from '@/types/models/ticket-worklog-entry-model';
defineProps<TicketDetailHistoryProps>();
const { t, date } = useLocalization();
const formatDateTime = (value: string): string =>
date(new Date(value), { dateStyle: 'medium', timeStyle: 'short' });
const entryTypeLabels: Record<TicketWorklogEntryType, TranslationKey> = {
created: 'ticketDetail.historyType.created',
link_removed: 'ticketDetail.historyType.linkRemoved',
link_updated: 'ticketDetail.historyType.linkUpdated',
status_changed: 'ticketDetail.historyType.statusChanged',
updated: 'ticketDetail.historyType.updated',
};
</script>
<template>
@@ -39,31 +49,70 @@ const formatDateTime = (value: string): string =>
{{ t('ticketDetail.noHistory') }}
</div>
<div v-else class="flex flex-col gap-4">
<div
<ol v-else class="relative grid gap-0" role="list">
<li
v-for="worklogEntry in ticket.worklog_entries"
:key="worklogEntry.id"
class="border-border rounded-xl border px-4 py-4"
class="border-border relative grid min-w-0 grid-cols-[2.5rem_minmax(0,1fr)] gap-3 border-l pb-6 last:border-transparent last:pb-0 sm:grid-cols-[3rem_minmax(0,1fr)] sm:gap-4"
>
<div class="flex flex-wrap items-center gap-2">
<Badge variant="outline">
{{ worklogEntry.entry_type }}
</Badge>
<span class="text-foreground text-sm font-medium">
{{
worklogEntry.actor?.name ??
t('ticketDetail.system')
}}
<div
class="bg-card relative -left-px flex size-10 -translate-x-1/2 items-center justify-center rounded-full sm:size-12"
>
<UserAvatar
v-if="worklogEntry.actor"
:user="worklogEntry.actor"
size="lg"
/>
<span
v-else
aria-hidden="true"
class="bg-muted text-muted-foreground flex size-10 items-center justify-center rounded-full border text-sm font-semibold"
>
S
</span>
</div>
<p class="text-foreground mt-3 text-sm">
{{ worklogEntry.summary }}
</p>
<p class="text-muted-foreground mt-2 text-xs">
{{ formatDateTime(worklogEntry.created_at) }}
</p>
</div>
</div>
<article
class="bg-muted/20 min-w-0 rounded-xl border p-4"
>
<div
class="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-start sm:justify-between"
>
<div
class="flex min-w-0 flex-wrap items-center gap-2"
>
<span
class="text-foreground text-sm font-semibold"
>
{{
worklogEntry.actor?.name ??
t('ticketDetail.system')
}}
</span>
<Badge variant="outline">
{{
t(
entryTypeLabels[
worklogEntry.entry_type
],
)
}}
</Badge>
</div>
<time
:datetime="worklogEntry.created_at"
class="text-muted-foreground shrink-0 text-xs"
>
{{
formatDateTime(worklogEntry.created_at)
}}
</time>
</div>
<p class="text-foreground mt-3 text-sm leading-6">
{{ worklogEntry.summary }}
</p>
</article>
</li>
</ol>
</CardContent>
</Card>
</section>
@@ -1,33 +1,76 @@
<script setup lang="ts">
import { useLocalization } from '@/composables/useLocalization';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Form, Link } from '@inertiajs/vue3';
import { computed } from 'vue';
import ProjectTicketLinkController from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
import InputError from '@/components/InputError.vue';
import { Link, useForm } from '@inertiajs/vue3';
import { ArrowRight, GitFork, Link2, Plus } from 'lucide-vue-next';
import { computed, nextTick, useTemplateRef } from 'vue';
import { store } from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
import FormFeedback from '@/components/FormFeedback.vue';
import TicketTreeList from '@/components/TicketTreeList.vue';
import TicketFormField from '@/components/tickets/TicketFormField.vue';
import TicketRelationshipLink from '@/components/tickets/detail/TicketRelationshipLink.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import { show as showTicket } from '@/routes/projects/tickets';
import type { TicketDetailRelationshipsProps } from '@/types/ticket-detail';
const props = defineProps<TicketDetailRelationshipsProps>();
defineEmits<{ editParent: [] }>();
const { t } = useLocalization();
const linksHeading = useTemplateRef<HTMLHeadingElement>('linksHeading');
const currentParentTicket = computed(
() =>
props.ticket.parent_ticket ??
props.parentTicketOptions.find(
(parentTicket) => parentTicket.id === props.ticket.parent_ticket_id,
(parent) => parent.id === props.ticket.parent_ticket_id,
) ??
null,
);
const canAddLink = computed(
() =>
props.canEditTicket &&
props.ticketLinkTypes.length > 0 &&
props.ticketLinkOptions.length > 0,
);
const form = useForm({
ticket_link_type_id: props.ticketLinkTypes[0]?.id ?? '',
linked_ticket_id: '',
});
const protection = useFormProtection(form, {
isDirty: () => props.canEditTicket && form.isDirty,
});
const selectedType = computed(() =>
props.ticketLinkTypes.find(
(type) => String(type.id) === String(form.ticket_link_type_id),
),
);
const selectedTicket = computed(() =>
props.ticketLinkOptions.find(
(ticket) => String(ticket.id) === String(form.linked_ticket_id),
),
);
async function focusLinks(): Promise<void> {
await nextTick();
linksHeading.value?.focus();
}
function submit(): void {
if (!canAddLink.value) {
return;
}
protection.submit(
store({ project: props.project.id, ticket: props.ticket.id }),
{
preserveScroll: true,
onSuccess: () => {
form.linked_ticket_id = '';
form.clearErrors();
void focusLinks();
},
},
);
}
</script>
<template>
@@ -39,184 +82,223 @@ const currentParentTicket = computed(
<h2 id="ticket-relationships-title" class="text-lg font-semibold">
{{ t('ticketDetail.relationships') }}
</h2>
<Card v-if="ticket.ticket_type?.is_parent" class="h-fit">
<CardHeader>
<CardTitle>{{ t('ticketDetail.children') }}</CardTitle>
<CardDescription>{{
t('ticketDetail.childrenDescription')
}}</CardDescription>
</CardHeader>
<CardContent>
<div
v-if="childTicketTree.length === 0"
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
<Card class="min-w-0 gap-5 overflow-hidden">
<CardHeader
class="flex flex-wrap items-center justify-between gap-3"
>
<h3 class="flex items-center gap-2 font-semibold">
<GitFork class="text-primary size-5" aria-hidden="true" />{{
t('ticketRelationships.hierarchy')
}}
</h3>
<Button
v-if="canEditTicket"
type="button"
variant="outline"
size="sm"
@click="$emit('editParent')"
>{{ t('ticketRelationships.editParent') }}</Button
>
{{ t('ticketDetail.noChildren') }}
</CardHeader>
<CardContent class="grid min-w-0 gap-5">
<div class="grid min-w-0 gap-2">
<h4
class="text-muted-foreground text-xs font-semibold uppercase tracking-wide"
>
{{ t('ticketDetail.parent') }}
</h4>
<Link
v-if="currentParentTicket"
:href="
showTicket({
project: project.id,
ticket: currentParentTicket.id,
})
"
class="border-primary/20 bg-primary/5 hover:border-primary focus-visible:ring-ring/35 group grid min-w-0 gap-2 rounded-xl border p-4 outline-none transition-colors focus-visible:ring-2"
>
<span
class="text-primary font-mono text-xs font-semibold"
>{{ currentParentTicket.ticket_key }}</span
>
<span
class="group-hover:text-primary text-sm font-medium"
>{{ currentParentTicket.title }}</span
>
</Link>
<p
v-else
class="text-muted-foreground rounded-xl border border-dashed p-4 text-sm"
>
{{ t('ticketDetail.noParent') }}
</p>
<p
v-if="canEditTicket"
class="text-muted-foreground text-xs"
>
{{ t('ticketRelationships.parentHelp') }}
</p>
</div>
<TicketTreeList
v-else
:nodes="childTicketTree"
:project-id="project.id"
/>
</CardContent>
</Card>
<Card v-else-if="currentParentTicket !== null" class="h-fit">
<CardHeader>
<CardTitle>{{ t('ticketDetail.parent') }}</CardTitle>
<CardDescription>{{
t('ticketDetail.parentDescription')
}}</CardDescription>
</CardHeader>
<CardContent>
<Link
:href="
showTicket({
project: project.id,
ticket: currentParentTicket.id,
})
"
class="border-border hover:border-primary/60 block rounded-xl border px-4 py-4 transition"
>
<div class="font-medium">
{{ currentParentTicket.ticket_key }}
</div>
<div class="text-muted-foreground mt-1 text-sm">
{{ currentParentTicket.title }}
</div>
</Link>
</CardContent>
</Card>
<Card class="h-fit">
<CardHeader>
<CardTitle>{{ t('ticketDetail.linkedTickets') }}</CardTitle>
<CardDescription>{{
t('ticketDetail.linkedTicketsDescription')
}}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<Form
v-if="
canEditTicket &&
ticketLinkTypes.length > 0 &&
ticketLinkOptions.length > 0
"
v-bind="
ProjectTicketLinkController.store.form({
project: project.id,
ticket: ticket.id,
})
"
class="border-border grid gap-4 rounded-xl border border-dashed p-4"
v-slot="{ errors, processing }"
>
<div class="grid min-w-0 gap-2">
<Label for="ticket_link_type_id">{{
t('ticketDetail.linkType')
}}</Label>
<select
id="ticket_link_type_id"
name="ticket_link_type_id"
class="min-h-control border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
>
<option
v-for="ticketLinkType in ticketLinkTypes"
:key="ticketLinkType.id"
:value="ticketLinkType.id"
>
{{ ticketLinkType.label }}
</option>
</select>
<InputError :message="errors.ticket_link_type_id" />
</div>
<div class="grid min-w-0 gap-2">
<Label for="linked_ticket_id">{{
t('ticketDetail.linkedTicket')
}}</Label>
<select
id="linked_ticket_id"
name="linked_ticket_id"
class="min-h-control border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
>
<option
v-for="ticketLinkOption in ticketLinkOptions"
:key="ticketLinkOption.id"
:value="ticketLinkOption.id"
>
{{
`${ticketLinkOption.ticket_key} - ${ticketLinkOption.title}`
}}
</option>
</select>
<InputError :message="errors.linked_ticket_id" />
</div>
<Button type="submit" :disabled="processing">{{
t('ticketDetail.saveLink')
}}</Button>
</Form>
<div
v-if="ticket.ticket_type?.is_parent"
class="grid min-w-0 gap-3 border-t pt-5"
>
<div class="flex flex-wrap items-center gap-2">
<h4 class="text-sm font-semibold">
{{ t('ticketDetail.children') }}
</h4>
<Badge variant="secondary">{{
childTicketTree.length
}}</Badge>
</div>
<p class="text-muted-foreground text-xs">
{{ t('ticketRelationships.childrenHelp') }}
</p>
<p
v-if="childTicketTree.length === 0"
class="text-muted-foreground rounded-xl border border-dashed p-4 text-sm"
>
{{ t('ticketDetail.noChildren') }}
</p>
<TicketTreeList
v-else
:nodes="childTicketTree"
:project-id="project.id"
/>
</div>
</CardContent>
</Card>
<Card class="min-w-0 gap-5 overflow-hidden">
<CardHeader>
<h3
ref="linksHeading"
tabindex="-1"
class="focus-visible:ring-ring/35 flex flex-wrap items-center gap-2 rounded-md font-semibold outline-none focus-visible:ring-2"
>
<Link2 class="text-primary size-5" aria-hidden="true" />{{
t('ticketDetail.linkedTickets')
}}<Badge variant="secondary">{{
linkedTickets.length
}}</Badge>
</h3>
<p class="text-muted-foreground text-sm">
{{ t('ticketDetail.linkedTicketsDescription') }}
</p>
</CardHeader>
<CardContent class="grid min-w-0 gap-5">
<p
v-if="linkedTickets.length === 0"
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
class="text-muted-foreground rounded-xl border border-dashed p-4 text-sm"
>
{{ t('ticketDetail.noLinks') }}
</div>
<div v-else class="space-y-3">
<div
v-for="linkedTicket in linkedTickets"
:key="linkedTicket.id"
class="border-border rounded-xl border px-4 py-4"
</p>
<ul v-else class="grid min-w-0 gap-3">
<TicketRelationshipLink
v-for="link in linkedTickets"
:key="link.id"
:link="link"
:project-id="project.id"
:ticket-id="ticket.id"
:ticket-key="ticket.ticket_key"
:can-edit="canEditTicket"
@removed="focusLinks"
/>
</ul>
<form
v-if="canAddLink"
class="bg-muted/30 grid min-w-0 gap-4 rounded-xl border p-4"
@submit.prevent="submit"
>
<h4 class="flex items-center gap-2 text-sm font-semibold">
<Plus class="size-4" aria-hidden="true" />{{
t('ticketRelationships.addLink')
}}
</h4>
<FormFeedback
:state="protection.state.value"
@retry="submit"
/>
<fieldset
:disabled="form.processing"
class="grid min-w-0 gap-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,2fr)]"
>
<div class="flex flex-wrap items-center gap-2">
<Badge variant="secondary">
{{ linkedTicket.ticket_link_type.label }}
</Badge>
<Link
:href="
showTicket({
project: project.id,
ticket: linkedTicket.linked_ticket.id,
})
"
class="text-foreground hover:text-primary font-medium transition"
>
{{ linkedTicket.linked_ticket.ticket_key }}
</Link>
</div>
<p class="text-muted-foreground mt-2 text-sm">
{{ linkedTicket.linked_ticket.title }}
</p>
<Form
v-if="canEditTicket"
v-bind="
ProjectTicketLinkController.destroy.form({
project: project.id,
ticket: ticket.id,
ticketLink: linkedTicket.id,
})
<TicketFormField
id="ticket_link_type_id"
v-model="form.ticket_link_type_id"
kind="select"
:label="t('ticketDetail.linkType')"
:options="
ticketLinkTypes.map((type) => ({
value: type.id,
label: type.label,
}))
"
class="mt-3 flex justify-end"
v-slot="{ processing: deleting }"
:error="form.errors.ticket_link_type_id"
:disabled="form.processing"
required
/>
<TicketFormField
id="linked_ticket_id"
v-model="form.linked_ticket_id"
kind="select"
:label="t('ticketDetail.linkedTicket')"
:options="[
{
value: '',
label: t(
'ticketRelationships.selectTicket',
),
},
...ticketLinkOptions.map((ticket) => ({
value: ticket.id,
label: `${ticket.ticket_key} — ${ticket.title}`,
})),
]"
:error="form.errors.linked_ticket_id"
:disabled="form.processing"
required
/>
</fieldset>
<p
v-if="selectedType && selectedTicket"
aria-live="polite"
class="text-muted-foreground flex flex-wrap items-center gap-2 text-sm"
>
<span class="font-mono text-xs">{{
ticket.ticket_key
}}</span
><span>{{ selectedType.label }}</span
><ArrowRight class="size-4" aria-hidden="true" /><span
class="font-mono text-xs"
>{{ selectedTicket.ticket_key }}</span
>
<Button
type="submit"
variant="destructive"
size="sm"
:disabled="deleting"
>{{ t('ticketDetail.removeLink') }}</Button
>
</Form>
</div>
</div>
</p>
<p class="text-muted-foreground text-xs">
{{ t('ticketRelationships.directionHelp') }}
</p>
<Button
type="submit"
:disabled="form.processing || !selectedTicket"
class="sm:justify-self-start"
>{{
form.processing
? t('forms.saving')
: t('ticketDetail.saveLink')
}}</Button
>
</form>
<p
v-else-if="canEditTicket"
class="text-muted-foreground text-sm"
>
{{
t(
ticketLinkTypes.length === 0
? 'ticketRelationships.noTypes'
: 'ticketRelationships.noTargets',
)
}}
</p>
</CardContent>
</Card>
</section>
@@ -0,0 +1,178 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { ExternalLink, GitBranch } from 'lucide-vue-next';
import { computed } from 'vue';
import FeedbackState from '@/components/FeedbackState.vue';
import TicketBranchCreateDialog from '@/components/tickets/detail/TicketBranchCreateDialog.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { useLocalization } from '@/composables/useLocalization';
import { edit as editGitConnections } from '@/routes/git-connections';
import type { RepositoryConnectionModel } from '@/types/models/repository-connection-model';
import type { TicketGitBranchesProps } from '@/types/ticket-detail';
const props = defineProps<TicketGitBranchesProps>();
const { t } = useLocalization();
const visibleRepositories = computed(() =>
props.repositoryBranches.filter(
(repositoryBranch) =>
repositoryBranch.branches.length > 0 ||
repositoryBranch.error !== null ||
!repositoryBranch.repository_connection.has_personal_access,
),
);
const repositoryConnections = computed<RepositoryConnectionModel[]>(() =>
props.repositoryBranches.map(
(repositoryBranch) => repositoryBranch.repository_connection,
),
);
const canCreateBranch = computed(
() => props.canEditTicket && repositoryConnections.value.length > 0,
);
</script>
<template>
<Card class="min-w-0 gap-0 overflow-hidden">
<CardHeader
class="flex flex-col gap-4 border-b sm:flex-row sm:items-start sm:justify-between"
>
<div class="grid min-w-0 gap-1.5">
<CardTitle class="flex items-center gap-2">
<GitBranch
class="text-primary size-5 shrink-0"
aria-hidden="true"
/>
{{ t('ticketDetail.branches') }}
</CardTitle>
<CardDescription
>{ t('ticketDetail.branchesDescription') }}</CardDescription
>
</div>
<TicketBranchCreateDialog
v-if="canCreateBranch"
:ticket="ticket"
:project="project"
:repository-connections="repositoryConnections"
:suggested-branch-name="suggestedBranchName"
/>
</CardHeader>
<CardContent class="grid gap-4 pt-6">
<FeedbackState
v-if="repositoryBranches.length === 0"
state="empty"
:title="t('ticketDetail.noLinkedRepositories')"
:description="t('ticketDetail.noLinkedRepositoriesDescription')"
/>
<FeedbackState
v-else-if="visibleRepositories.length === 0"
state="empty"
:title="t('tickets.noBranches')"
:description="t('tickets.noBranchesDescription')"
/>
<article
v-for="repositoryBranch in visibleRepositories"
v-else
:key="repositoryBranch.repository_connection.id"
class="border-border grid min-w-0 gap-4 rounded-lg border p-4"
>
<header class="grid min-w-0 gap-2">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<Badge variant="secondary">
{{
repositoryBranch.repository_connection
.provider_label
}}
</Badge>
<a
:href="
repositoryBranch.repository_connection
.repository_url
"
target="_blank"
rel="noopener noreferrer"
:aria-label="
t('ticketDetail.openRepositoryExternal', {
repository:
repositoryBranch.repository_connection
.repository_full_name,
provider:
repositoryBranch.repository_connection
.provider_label,
})
"
class="hover:text-primary focus-visible:ring-ring/35 group flex min-w-0 items-center gap-1.5 rounded-sm font-medium outline-none transition-colors focus-visible:ring-2"
>
<span class="wrap-anywhere">{{
repositoryBranch.repository_connection
.repository_full_name
}}</span>
<ExternalLink
class="size-3.5 shrink-0 opacity-60 transition-opacity group-hover:opacity-100"
aria-hidden="true"
/>
</a>
</div>
<p class="text-muted-foreground text-xs">
{{ repositoryBranch.repository_connection.base_url }}
</p>
</header>
<FeedbackState
v-if="
!repositoryBranch.repository_connection
.has_personal_access
"
state="empty"
:title="t('ticketDetail.gitAccessRequired')"
:description="t('ticketDetail.inspectBranches')"
>
<Button
as-child
variant="outline"
size="sm"
class="justify-self-start"
>
<Link :href="editGitConnections()">{{
t('ticketDetail.openGitConnections')
}}</Link>
</Button>
</FeedbackState>
<FeedbackState
v-else-if="repositoryBranch.error"
state="error"
:title="t('ticketDetail.branchesUnavailable')"
:description="repositoryBranch.error"
/>
<ul v-else class="grid min-w-0 gap-2 sm:grid-cols-2">
<li
v-for="branch in repositoryBranch.branches"
:key="branch.name"
class="bg-muted/30 flex min-w-0 items-center gap-2 rounded-md border px-3 py-2"
>
<GitBranch
class="text-muted-foreground size-3.5 shrink-0"
aria-hidden="true"
/>
<span class="wrap-anywhere font-mono text-xs">{{
branch.name
}}</span>
</li>
</ul>
</article>
</CardContent>
</Card>
</template>
@@ -0,0 +1,191 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { ExternalLink, GitPullRequest } from 'lucide-vue-next';
import { computed } from 'vue';
import FeedbackState from '@/components/FeedbackState.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { useLocalization } from '@/composables/useLocalization';
import { edit as editGitConnections } from '@/routes/git-connections';
import type { TicketGitPullRequestsProps } from '@/types/ticket-detail';
const props = defineProps<TicketGitPullRequestsProps>();
const { t } = useLocalization();
const visibleRepositories = computed(() =>
props.repositoryPullRequests.filter(
(repositoryPullRequest) =>
repositoryPullRequest.pull_requests.length > 0 ||
repositoryPullRequest.error !== null ||
!repositoryPullRequest.repository_connection.has_personal_access,
),
);
</script>
<template>
<Card class="min-w-0 gap-0 overflow-hidden">
<CardHeader class="border-b">
<CardTitle class="flex items-center gap-2">
<GitPullRequest
class="text-primary size-5 shrink-0"
aria-hidden="true"
/>
{{ t('ticketDetail.pullRequests') }}
</CardTitle>
<CardDescription
>{ t('ticketDetail.pullRequestsDescription') }}</CardDescription
>
</CardHeader>
<CardContent class="grid gap-4 pt-6">
<FeedbackState
v-if="repositoryPullRequests.length === 0"
state="empty"
:title="t('ticketDetail.noLinkedRepositories')"
:description="t('ticketDetail.noLinkedRepositoriesDescription')"
/>
<FeedbackState
v-else-if="visibleRepositories.length === 0"
state="empty"
:title="t('tickets.noPullRequests')"
:description="t('tickets.noPullRequestsDescription')"
/>
<article
v-for="repositoryPullRequest in visibleRepositories"
v-else
:key="repositoryPullRequest.repository_connection.id"
class="border-border grid min-w-0 gap-4 rounded-lg border p-4"
>
<header class="grid min-w-0 gap-2">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<Badge variant="secondary">
{{
repositoryPullRequest.repository_connection
.provider_label
}}
</Badge>
<a
:href="
repositoryPullRequest.repository_connection
.repository_url
"
target="_blank"
rel="noopener noreferrer"
:aria-label="
t('ticketDetail.openRepositoryExternal', {
repository:
repositoryPullRequest
.repository_connection
.repository_full_name,
provider:
repositoryPullRequest
.repository_connection
.provider_label,
})
"
class="hover:text-primary focus-visible:ring-ring/35 group flex min-w-0 items-center gap-1.5 rounded-sm font-medium outline-none transition-colors focus-visible:ring-2"
>
<span class="wrap-anywhere">{{
repositoryPullRequest.repository_connection
.repository_full_name
}}</span>
<ExternalLink
class="size-3.5 shrink-0 opacity-60 transition-opacity group-hover:opacity-100"
aria-hidden="true"
/>
</a>
</div>
<p class="text-muted-foreground text-xs">
{{
repositoryPullRequest.repository_connection.base_url
}}
</p>
</header>
<FeedbackState
v-if="
!repositoryPullRequest.repository_connection
.has_personal_access
"
state="empty"
:title="t('ticketDetail.gitAccessRequired')"
:description="t('ticketDetail.inspectPullRequests')"
>
<Button
as-child
variant="outline"
size="sm"
class="justify-self-start"
>
<Link :href="editGitConnections()">{{
t('ticketDetail.openGitConnections')
}}</Link>
</Button>
</FeedbackState>
<FeedbackState
v-else-if="repositoryPullRequest.error"
state="error"
:title="t('ticketDetail.pullRequestsUnavailable')"
:description="repositoryPullRequest.error"
/>
<ul v-else class="grid min-w-0 gap-3">
<li
v-for="pullRequest in repositoryPullRequest.pull_requests"
:key="pullRequest.html_url"
class="border-border/70 bg-muted/20 grid min-w-0 gap-2 rounded-md border px-4 py-3"
>
<div class="flex min-w-0 flex-wrap items-center gap-2">
<Badge variant="outline">
#{{ pullRequest.number }}
</Badge>
<Badge variant="secondary">
{{ pullRequest.status }}
</Badge>
<a
:href="pullRequest.html_url"
target="_blank"
rel="noopener noreferrer"
:aria-label="
t('ticketDetail.openPullRequestExternal', {
number: pullRequest.number,
provider:
repositoryPullRequest
.repository_connection
.provider_label,
})
"
class="hover:text-primary focus-visible:ring-ring/35 group flex min-w-0 items-center gap-1.5 rounded-sm font-medium outline-none transition-colors focus-visible:ring-2"
>
<span class="wrap-anywhere">{{
pullRequest.title
}}</span>
<ExternalLink
class="size-3.5 shrink-0 opacity-60 transition-opacity group-hover:opacity-100"
aria-hidden="true"
/>
</a>
</div>
<p class="text-muted-foreground text-xs">
{{
t('ticketDetail.sourceBranch', {
name: pullRequest.source_branch,
})
}}
</p>
</li>
</ul>
</article>
</CardContent>
</Card>
</template>
@@ -0,0 +1,116 @@
<script setup lang="ts">
import { Link, useForm } from '@inertiajs/vue3';
import { ArrowDownLeft, ArrowUpRight, Unlink } from 'lucide-vue-next';
import { computed } from 'vue';
import { destroy } from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
import FormFeedback from '@/components/FormFeedback.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import { show } from '@/routes/projects/tickets';
import type { TicketLinkModel } from '@/types/models/ticket-link-model';
const props = defineProps<{
link: TicketLinkModel;
projectId: number;
ticketId: number;
ticketKey: string;
canEdit: boolean;
}>();
const emit = defineEmits<{ removed: [] }>();
const { t } = useLocalization();
const outgoing = computed(() => props.link.source_ticket_id === props.ticketId);
const form = useForm({});
const protection = useFormProtection(form);
function remove(): void {
if (!props.canEdit) {
return;
}
protection.submit(
destroy({
project: props.projectId,
ticket: props.ticketId,
ticketLink: props.link.id,
}),
{
preserveScroll: true,
onSuccess: () => emit('removed'),
},
);
}
</script>
<template>
<li
class="grid min-w-0 gap-3 rounded-xl border p-4"
:data-ticket-link="link.id"
>
<div class="flex min-w-0 flex-wrap items-center gap-2">
<Badge
variant="outline"
class="text-muted-foreground max-w-full whitespace-normal"
>
<ArrowUpRight
v-if="outgoing"
class="size-3.5 shrink-0"
aria-hidden="true"
/><ArrowDownLeft
v-else
class="size-3.5 shrink-0"
aria-hidden="true"
/>
{{
t(
outgoing
? 'ticketRelationships.outgoing'
: 'ticketRelationships.incoming',
)
}}
</Badge>
<span class="text-muted-foreground font-mono text-xs">{{
ticketKey
}}</span>
<span class="text-sm font-medium">{{
link.ticket_link_type.label
}}</span>
</div>
<div class="flex min-w-0 flex-wrap items-start justify-between gap-3">
<Link
:href="
show({ project: projectId, ticket: link.linked_ticket.id })
"
class="hover:text-primary focus-visible:ring-ring/35 grid min-w-0 flex-1 basis-40 gap-1 rounded-md outline-none transition-colors focus-visible:ring-2"
>
<span class="text-primary font-mono text-xs font-semibold">{{
link.linked_ticket.ticket_key
}}</span>
<span class="wrap-anywhere text-sm font-medium">{{
link.linked_ticket.title
}}</span>
</Link>
<form v-if="canEdit" @submit.prevent="remove">
<Button
type="submit"
variant="ghost"
size="sm"
class="text-muted-foreground hover:text-destructive"
:aria-label="
t('ticketRelationships.removeLink', {
key: link.linked_ticket.ticket_key,
})
"
:disabled="form.processing"
><Unlink class="size-4" aria-hidden="true" />{{
t('ticketDetail.removeLink')
}}</Button
>
</form>
</div>
<FormFeedback
v-if="canEdit"
:state="protection.state.value"
@retry="remove"
/>
</li>
</template>
@@ -0,0 +1,27 @@
import { useLocalization } from '@/composables/useLocalization';
const areaKeys = {
project_settings: 'projectCreation.areas.projectSettings',
board_columns: 'projectCreation.areas.boardColumns',
ticket_statuses: 'projectCreation.areas.ticketStatuses',
status_transitions: 'projectCreation.areas.statusTransitions',
ticket_types: 'projectCreation.areas.ticketTypes',
ticket_priorities: 'projectCreation.areas.ticketPriorities',
ticket_fields: 'projectCreation.areas.ticketFields',
ticket_link_types: 'projectCreation.areas.ticketLinkTypes',
repository_connections: 'projectCreation.areas.repositories',
notification_actions: 'projectCreation.areas.notifications',
} as const;
export function useProjectTemplateAreas() {
const { t } = useLocalization();
function areaText(
value: string,
part: 'label' | 'description',
fallback = value,
): string {
const key = areaKeys[value as keyof typeof areaKeys];
return key ? t(`${key}.${part}`) : fallback;
}
return { areaText };
}
+87 -100
View File
@@ -1,120 +1,107 @@
import type { ComputedRef, Ref } from 'vue';
import { computed, ref } from 'vue';
import { computed, onScopeDispose, ref } from 'vue';
import { useLocalization } from '@/composables/useLocalization';
import { qrCode, recoveryCodes, secretKey } from '@/routes/two-factor';
export type UseTwoFactorAuthReturn = {
qrCodeSvg: Ref<string | null>;
manualSetupKey: Ref<string | null>;
recoveryCodesList: Ref<string[]>;
errors: Ref<string[]>;
hasSetupData: ComputedRef<boolean>;
clearSetupData: () => void;
clearErrors: () => void;
clearTwoFactorAuthData: () => void;
fetchQrCode: () => Promise<void>;
fetchSetupKey: () => Promise<void>;
fetchSetupData: () => Promise<void>;
fetchRecoveryCodes: () => Promise<void>;
};
/** Keep secrets within the consuming component and discard late responses after hiding them. */
export function useTwoFactorAuth() {
const { t } = useLocalization();
const errors = ref<string[]>([]);
const manualSetupKey = ref<string | null>(null);
const qrCodeSvg = ref<string | null>(null);
const recoveryCodesList = ref<string[]>([]);
const loading = ref(false);
let pending: AbortController | null = null;
const fetchJson = async <T>(url: string): Promise<T> => {
const response = await fetch(url, {
headers: { Accept: 'application/json' },
});
if (!response.ok) {
throw new Error(`Failed to fetch: ${response.status}`);
async function fetchJson<T>(url: string, signal: AbortSignal): Promise<T> {
const response = await fetch(url, {
headers: { Accept: 'application/json' },
credentials: 'same-origin',
signal,
});
if (!response.ok || response.redirected) {
throw new Error(String(response.status));
}
return response.json();
}
return response.json();
};
function clearErrors(): void {
errors.value = [];
}
const errors = ref<string[]>([]);
const manualSetupKey = ref<string | null>(null);
const qrCodeSvg = ref<string | null>(null);
const recoveryCodesList = ref<string[]>([]);
const hasSetupData = computed<boolean>(
() => qrCodeSvg.value !== null && manualSetupKey.value !== null,
);
export const useTwoFactorAuth = (): UseTwoFactorAuthReturn => {
const fetchQrCode = async (): Promise<void> => {
try {
const { svg } = await fetchJson<{ svg: string; url: string }>(
qrCode.url(),
);
qrCodeSvg.value = svg;
} catch {
errors.value.push('Failed to fetch QR code');
qrCodeSvg.value = null;
}
};
const fetchSetupKey = async (): Promise<void> => {
try {
const { secretKey: key } = await fetchJson<{ secretKey: string }>(
secretKey.url(),
);
manualSetupKey.value = key;
} catch {
errors.value.push('Failed to fetch a setup key');
manualSetupKey.value = null;
}
};
const clearSetupData = (): void => {
function clearTwoFactorAuthData(): void {
pending?.abort();
pending = null;
loading.value = false;
manualSetupKey.value = null;
qrCodeSvg.value = null;
clearErrors();
};
const clearErrors = (): void => {
errors.value = [];
};
const clearTwoFactorAuthData = (): void => {
clearSetupData();
clearErrors();
recoveryCodesList.value = [];
};
clearErrors();
}
const fetchRecoveryCodes = async (): Promise<void> => {
async function load(kind: 'setup' | 'recovery'): Promise<void> {
clearTwoFactorAuthData();
const request = new AbortController();
pending = request;
loading.value = true;
try {
clearErrors();
recoveryCodesList.value = await fetchJson<string[]>(
recoveryCodes.url(),
);
} catch {
errors.value.push('Failed to fetch recovery codes');
recoveryCodesList.value = [];
if (kind === 'setup') {
const [qr, key] = await Promise.all([
fetchJson<{ svg: string }>(qrCode.url(), request.signal),
fetchJson<{ secretKey: string }>(
secretKey.url(),
request.signal,
),
]);
if (pending !== request) {
return;
}
qrCodeSvg.value = qr.svg;
manualSetupKey.value = key.secretKey;
} else {
const codes = await fetchJson<string[]>(
recoveryCodes.url(),
request.signal,
);
if (pending === request) {
recoveryCodesList.value = codes;
}
}
} catch (error) {
if (pending === request && !request.signal.aborted) {
const status = error instanceof Error ? error.message : '';
errors.value = [
t(
status === '423' || status === '401' || status === '419'
? 'security.confirmAgain'
: kind === 'setup'
? 'security.setupError'
: 'security.recoveryError',
),
];
}
} finally {
if (pending === request) {
pending = null;
loading.value = false;
}
}
};
const fetchSetupData = async (): Promise<void> => {
try {
clearErrors();
await Promise.all([fetchQrCode(), fetchSetupKey()]);
} catch {
qrCodeSvg.value = null;
manualSetupKey.value = null;
}
};
}
onScopeDispose(clearTwoFactorAuthData);
return {
qrCodeSvg,
manualSetupKey,
recoveryCodesList,
errors,
hasSetupData,
clearSetupData,
clearErrors,
loading,
hasSetupData: computed(() =>
Boolean(qrCodeSvg.value && manualSetupKey.value),
),
clearSetupData: clearTwoFactorAuthData,
clearRecoveryCodes: clearTwoFactorAuthData,
clearTwoFactorAuthData,
fetchQrCode,
fetchSetupKey,
fetchSetupData,
fetchRecoveryCodes,
clearErrors,
fetchSetupData: () => load('setup'),
fetchRecoveryCodes: () => load('recovery'),
};
};
}
@@ -0,0 +1,73 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { Settings2 } from 'lucide-vue-next';
import { useAppNavigation } from '@/composables/useAppNavigation';
import { useCurrentUrl } from '@/composables/useCurrentUrl';
import { useLocalization } from '@/composables/useLocalization';
import { toUrl } from '@/lib/utils';
defineProps<{ projectName: string; projectKey: string }>();
const { projectSettingsItems } = useAppNavigation();
const { isCurrentUrl } = useCurrentUrl();
const { t } = useLocalization();
</script>
<template>
<div
class="gap-page md:p-page mx-auto flex w-full min-w-0 max-w-7xl flex-1 flex-col p-4"
>
<header class="gap-stack grid min-w-0">
<div class="text-primary flex items-center gap-2">
<Settings2 class="size-4" aria-hidden="true" />
<p class="text-label text-muted-foreground break-all uppercase">
{{ projectKey }}
</p>
</div>
<h1 class="text-headline-mobile md:text-headline break-words">
{{ t('navigation.projectSettings') }}
</h1>
<p class="text-muted-foreground break-words">
{{ t('projectSettings.description', { project: projectName }) }}
</p>
</header>
<div
class="bg-card grid min-w-0 grid-cols-1 overflow-hidden rounded-xl border shadow-sm lg:grid-cols-[15rem_minmax(0,1fr)]"
>
<aside
class="bg-muted/35 min-w-0 border-b p-3 lg:border-b-0 lg:border-r lg:p-4"
>
<nav
class="flex gap-2 overflow-x-auto pb-1 lg:flex-col lg:overflow-visible lg:pb-0"
:aria-label="t('navigation.projectSettings')"
>
<Link
v-for="item in projectSettingsItems"
:key="toUrl(item.href)"
:href="item.href"
:aria-current="
isCurrentUrl(item.href) ? 'page' : undefined
"
:class="[
'text-body focus-visible:ring-ring/40 flex min-h-11 shrink-0 items-center gap-2 rounded-lg border px-3 font-medium outline-none transition-colors focus-visible:ring-2 lg:w-full',
isCurrentUrl(item.href)
? 'border-primary/25 bg-accent text-accent-foreground shadow-xs'
: 'text-muted-foreground hover:border-border hover:bg-background hover:text-foreground border-transparent',
]"
>
<component
:is="item.icon"
class="size-4 shrink-0"
aria-hidden="true"
/>
<span class="whitespace-nowrap">{{ item.title }}</span>
</Link>
</nav>
</aside>
<div class="min-w-0 p-4 sm:p-6 lg:p-8">
<section class="gap-page mx-auto grid w-full min-w-0 max-w-3xl">
<slot />
</section>
</div>
</div>
</div>
</template>
+55 -32
View File
@@ -1,9 +1,14 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import {
GitBranch,
KeyRound,
Palette,
Settings2,
ShieldCheck,
UserRound,
} from 'lucide-vue-next';
import { computed } from 'vue';
import Heading from '@/components/Heading.vue';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { useCurrentUrl } from '@/composables/useCurrentUrl';
import { useLocalization } from '@/composables/useLocalization';
import { toUrl } from '@/lib/utils';
@@ -19,22 +24,27 @@ const sidebarNavItems = computed<NavItem[]>(() => [
{
title: t('navigation.profile'),
href: editProfile(),
icon: UserRound,
},
{
title: t('navigation.security'),
href: editSecurity(),
icon: ShieldCheck,
},
{
title: t('navigation.gitConnections'),
href: editGitConnections(),
icon: GitBranch,
},
{
title: t('navigation.applications'),
href: editApplications(),
icon: KeyRound,
},
{
title: t('navigation.appearance'),
href: editAppearance(),
icon: Palette,
},
]);
@@ -42,47 +52,60 @@ const { isCurrentOrParentUrl } = useCurrentUrl();
</script>
<template>
<div class="px-4 py-6">
<Heading
:title="t('navigation.settings')"
:description="t('navigation.settingsDescription')"
/>
<div
class="gap-page md:p-page mx-auto flex w-full min-w-0 max-w-6xl flex-1 flex-col p-4"
>
<header class="gap-stack grid min-w-0">
<div class="text-primary flex items-center gap-2">
<Settings2 class="size-4" aria-hidden="true" />
<p class="text-label text-muted-foreground uppercase">
{{ t('settings.eyebrow') }}
</p>
</div>
<h1 class="text-headline-mobile md:text-headline break-words">
{{ t('navigation.settings') }}
</h1>
<p class="text-muted-foreground max-w-2xl">
{{ t('navigation.settingsDescription') }}
</p>
</header>
<div class="flex min-w-0 flex-col gap-6 lg:flex-row lg:gap-12">
<aside class="w-full min-w-0 lg:w-52 lg:shrink-0">
<div
class="bg-card grid min-w-0 grid-cols-1 overflow-hidden rounded-xl border shadow-sm lg:grid-cols-[15rem_minmax(0,1fr)]"
>
<aside
class="bg-muted/35 border-b p-3 lg:border-b-0 lg:border-r lg:p-4"
>
<nav
class="flex gap-1 overflow-x-auto lg:flex-col"
class="flex gap-2 overflow-x-auto pb-1 lg:flex-col lg:overflow-visible lg:pb-0"
:aria-label="t('navigation.settings')"
>
<Button
<Link
v-for="item in sidebarNavItems"
:key="toUrl(item.href)"
variant="ghost"
:href="item.href"
:aria-current="
isCurrentOrParentUrl(item.href) ? 'page' : undefined
"
:class="[
'shrink-0 justify-start lg:w-full',
{ 'bg-muted': isCurrentOrParentUrl(item.href) },
'text-body focus-visible:ring-ring/40 flex min-h-11 shrink-0 items-center gap-2 rounded-lg border px-3 font-medium outline-none transition-colors focus-visible:ring-2 lg:w-full',
isCurrentOrParentUrl(item.href)
? 'border-primary/25 bg-accent text-accent-foreground shadow-xs'
: 'text-muted-foreground hover:border-border hover:bg-background hover:text-foreground border-transparent',
]"
as-child
>
<Link
:href="item.href"
:aria-current="
isCurrentOrParentUrl(item.href)
? 'page'
: undefined
"
>
<component :is="item.icon" class="h-4 w-4" />
{{ item.title }}
</Link>
</Button>
<component
:is="item.icon"
class="size-4 shrink-0"
aria-hidden="true"
/>
<span class="whitespace-nowrap">{{ item.title }}</span>
</Link>
</nav>
</aside>
<Separator class="lg:hidden" />
<div class="min-w-0 flex-1 md:max-w-2xl">
<section class="max-w-xl space-y-12">
<div class="min-w-0 p-4 sm:p-6 lg:p-8">
<section class="mx-auto grid w-full min-w-0 max-w-3xl gap-8">
<slot />
</section>
</div>
+70
View File
@@ -5,6 +5,76 @@ import type {
const MAX_INDENT_LEVEL = 8;
export const canIndentAcceptanceCriterion = (
items: AcceptanceCriteriaModel,
index: number,
): boolean => {
const item = items[index];
const previous = items[index - 1];
if (!item || !previous || item.indent > previous.indent) {
return false;
}
return items
.slice(index, acceptanceCriteriaSubtreeEnd(items, index))
.every((descendant) => descendant.indent < MAX_INDENT_LEVEL);
};
const acceptanceCriteriaSubtreeEnd = (
items: AcceptanceCriteriaModel,
index: number,
): number => {
let end = index + 1;
while (end < items.length && items[end].indent > items[index].indent) {
end++;
}
return end;
};
export const indentAcceptanceCriterion = (
items: AcceptanceCriteriaModel,
index: number,
direction: 1 | -1,
): AcceptanceCriteriaModel => {
if (
!items[index] ||
(direction === 1
? !canIndentAcceptanceCriterion(items, index)
: items[index].indent === 0)
) {
return items;
}
const end = acceptanceCriteriaSubtreeEnd(items, index);
return items.map((item, position) =>
position >= index && position < end
? { ...item, indent: item.indent + direction }
: item,
);
};
/** Remove only the selected criterion, promoting its children without losing them. */
export const removeAcceptanceCriterion = (
items: AcceptanceCriteriaModel,
index: number,
): AcceptanceCriteriaModel => {
if (!items[index]) {
return items;
}
const end = acceptanceCriteriaSubtreeEnd(items, index);
return items.flatMap((item, position) =>
position === index
? []
: [
{
...item,
indent:
position > index && position < end
? item.indent - 1
: item.indent,
},
],
);
};
export type AcceptanceCriteriaEditorState = {
checklist: string;
};
+23
View File
@@ -0,0 +1,23 @@
import type { NotificationActionFilterModel } from '@/types/models/notification-action-filter-model';
import type { NotificationFilterFormState } from '@/types/project-notifications';
export const supportsFilterValue = (operator: string): boolean =>
!['exists', 'not_exists'].includes(operator);
export function serializeNotificationFilters(
filters: NotificationFilterFormState[],
): NotificationActionFilterModel[] {
return filters.map(({ attribute, operator, value }) => ({
attribute,
operator,
value:
operator === 'in'
? value
.split(/[\n,]/)
.map((entry) => entry.trim())
.filter(Boolean)
: supportsFilterValue(operator)
? value.trim()
: null,
}));
}
+13
View File
@@ -0,0 +1,13 @@
import type { Localization } from '@/lib/localization';
import { en } from '@/locales/en';
import type { TranslationKey } from '@/types/localization';
export function notificationLabel(
t: Localization['t'],
group: string,
value: string,
fallback: string,
): string {
const key = `notifications.${group}.${value}`;
return Object.hasOwn(en, key) ? t(key as TranslationKey) : fallback;
}
+840
View File
@@ -1,4 +1,426 @@
export const en = {
'notifications.title': 'Notifications',
'notifications.pageTitle': '{project} · Notifications',
'notifications.description':
'Send ticket updates to your team with webhook and Discord actions.',
'notifications.saved': 'Notification settings saved.',
'notifications.actions': 'Notification actions',
'notifications.create': 'Add notification',
'notifications.edit': 'Edit notification',
'notifications.delete': 'Delete notification?',
'notifications.editNamed': 'Edit {name}',
'notifications.deleteNamed': 'Delete {name}',
'notifications.empty': 'No notifications yet',
'notifications.emptyHelp':
'Add an action to send updates when a ticket event matches your rules.',
'notifications.enabled': 'Enabled',
'notifications.disabled': 'Disabled',
'notifications.delivery': 'Delivery',
'notifications.urlSaved': 'Secret URL saved',
'notifications.urlMissing': 'No delivery URL saved',
'notifications.filterCount': {
one: '{count} filter · all must match',
other: '{count} filters · all must match',
},
'notifications.noFiltersShort': 'All matching events',
'notifications.noChanges': 'No saved changes yet',
'notifications.createdAt': 'Created {date}',
'notifications.updatedAt': 'Updated {date}',
'notifications.deleteHelp':
'Delete “{name}”? This action will stop sending notifications. This cannot be undone.',
'notifications.editorHelp':
'Choose a ticket event, a delivery channel and optional filters.',
'notifications.name': 'Name',
'notifications.event': 'Trigger',
'notifications.channel': 'Channel',
'notifications.keepUrl':
'A secret URL is saved. Leave the field blank to keep it.',
'notifications.replaceUrl': 'Replace delivery URL (optional)',
'notifications.url': 'Delivery URL',
'notifications.secretHelp':
'URLs are stored encrypted and never shown again after saving. Enter the complete webhook URL.',
'notifications.message': 'Discord message template',
'notifications.messageHelp':
'Use the variables below to include ticket details. Maximum 2,000 characters.',
'notifications.variables': 'Available template variables',
'notifications.mentions': 'Allowed mentions',
'notifications.mentionsHelp':
'Leave all unchecked to disable mentions. Only the selected mention types may notify people.',
'notifications.filters': 'Filters',
'notifications.filtersHelp':
'All filters must match before this action sends a notification.',
'notifications.noFilters':
'No filters. This action runs for every matching event.',
'notifications.filterNumber': 'Filter {number}',
'notifications.attribute': 'Attribute',
'notifications.operator': 'Operator',
'notifications.value': 'Value',
'notifications.values': 'Values (comma or newline separated)',
'notifications.removeFilter': 'Remove filter',
'notifications.removeFilterNumber': 'Remove filter {number}',
'notifications.addFilter': 'Add filter',
'notifications.enabledHelp':
'Turn off this action to pause delivery while keeping its configuration.',
'notifications.confirmDelete': 'Delete notification',
'notifications.channel.discord': 'Discord',
'notifications.channel.webhook': 'Webhook',
'notifications.event.ticket_status_changed': 'Ticket status changed',
'notifications.event.ticket_create': 'Ticket create',
'notifications.event.ticket_assignee_changed': 'Assignee changed',
'notifications.attribute.project.id': 'Project ID',
'notifications.attribute.project.key': 'Project key',
'notifications.attribute.project.name': 'Project name',
'notifications.attribute.ticket.id': 'Ticket ID',
'notifications.attribute.ticket.key': 'Ticket key',
'notifications.attribute.ticket.title': 'Ticket title',
'notifications.attribute.ticket.url': 'Ticket URL',
'notifications.attribute.actor.id': 'Actor ID',
'notifications.attribute.actor.name': 'Actor name',
'notifications.attribute.assignee.from.id': 'From assignee ID',
'notifications.attribute.assignee.from.name': 'From assignee name',
'notifications.attribute.assignee.to.id': 'To assignee ID',
'notifications.attribute.assignee.to.name': 'To assignee name',
'notifications.attribute.status.from.id': 'From status ID',
'notifications.attribute.status.from.label': 'From status label',
'notifications.attribute.status.to.id': 'To status ID',
'notifications.attribute.status.to.label': 'To status label',
'notifications.operator.equals': 'Equals',
'notifications.operator.not_equals': 'Not equals',
'notifications.operator.exists': 'Exists',
'notifications.operator.not_exists': 'Not exists',
'notifications.operator.in': 'In list',
'notifications.mention.everyone': '@everyone and @here',
'notifications.mention.roles': 'Role mentions (@role)',
'notifications.mention.users': 'User mentions (@username)',
'notifications.variable.url':
'Absolute ticket URL for the notification event.',
'notifications.variable.ticket_key': 'Project ticket key such as CORE-42.',
'notifications.variable.ticket_title':
'Ticket title for the notification event.',
'notifications.variable.project_name': 'Project name that owns the ticket.',
'notifications.variable.status_from':
'Origin status label for status moves; blank for ticket create and assignee changes.',
'notifications.variable.status_to':
'Destination or initial status label for the ticket event.',
'notifications.variable.actor_name':
'Actor name when the event has a known user.',
'notifications.variable.assignee_from':
'Previous assignee name when the event updates assignment; blank otherwise.',
'notifications.variable.assignee_to':
'New assignee name when the event updates assignment; blank when unassigned.',
'templates.description':
'Reuse a proven project setup. Manage published templates and see which projects inherit their configuration.',
'templates.updated': 'Project templates updated.',
'templates.fromProject': 'Start with an existing project',
'templates.fromProjectHelp':
'Open a project and choose “Save as new template” to capture its configuration.',
'templates.browseProjects': 'Browse projects',
'templates.empty': 'No published templates yet',
'templates.emptyHelp':
'Save a project as a template to reuse its settings. Internal project baselines are kept out of this list.',
'templates.published': 'Published project templates',
'templates.linkedCount': {
one: '{count} linked project',
other: '{count} linked projects',
},
'templates.savedFrom': 'Saved from {project} ({key}).',
'templates.sourceUnavailable': 'Source project unavailable',
'templates.edit': 'Edit',
'templates.use': 'Use template',
'templates.createTitle': 'Save {project} as a template',
'templates.createDescription':
'Choose the configuration to reuse in future projects.',
'templates.editTitle': 'Edit {name}',
'templates.editDescription':
'Refresh the template from its source project and review the impact on linked projects.',
'templates.defaultName': '{project} template',
'templates.details': 'Template details',
'templates.name': 'Template name',
'templates.namePlaceholder': 'Foundation template',
'templates.slug': 'Template slug',
'templates.slugPlaceholder': 'foundation-template',
'templates.slugHelp':
'Slugs are normalized automatically. After creation, the slug cannot be changed.',
'templates.slugLocked':
'The slug is locked so existing template references remain stable.',
'templates.groups': 'Configuration groups',
'templates.createGroupsHelp':
'Only selected groups are saved. Related groups required by the workflow are included automatically.',
'templates.editGroupsHelp':
'Saving rebuilds selected groups from the current source project. Required workflow groups are included automatically.',
'templates.impact': 'Template impact',
'templates.inheritance': 'Inheritance',
'templates.cleanStart':
'Tickets and other operational ticket data are never copied. The source project stays unchanged.',
'templates.refreshHelp':
'Inherited groups follow the refreshed template. Local overrides stay unchanged. Removed groups stay local to each project.',
'templates.linkedWarning': 'Linked projects will be affected',
'templates.linkedImpact':
'Saving updates the baseline for {count} linked projects. Inherited groups follow the new configuration; existing local overrides stay unchanged.',
'templates.acknowledge':
'I understand that projects inheriting from this template will be affected.',
'templates.saveNew': 'Save new template',
'templates.saveChanges': 'Save template changes',
'templates.missingSource': 'This template cannot be refreshed',
'templates.missingSourceHelp':
'The source project is no longer available. You can still use the saved template configuration to create a project.',
'templates.deleteTitle': 'Delete {name}',
'templates.delete': 'Delete template',
'templates.deleting': 'Deleting template…',
'templates.deleteHelp':
'This permanently removes the reusable template. It will no longer be available for new projects.',
'templates.deleteImpact':
'{count} linked projects will stop inheriting from this template. Their current configuration and tickets are kept.',
'workflow.title': 'Board workflow',
'workflow.pageTitle': '{project} · Board workflow',
'workflow.description':
'Organize your board into columns and give each stage the statuses your team needs.',
'workflow.updated': 'Workflow updated.',
'workflow.backlog': 'Backlog',
'workflow.offBoard': 'Off the board',
'workflow.backlogDescription':
'Your intake queue. Tickets with these statuses stay outside the board columns.',
'workflow.columns': 'Board columns',
'workflow.columnsDescription':
'Columns appear from left to right in this order. Edit a column to change its name or position.',
'workflow.emptyColumns': 'No board columns yet',
'workflow.emptyColumnsDescription':
'Add your first column to start organizing tickets on the board.',
'workflow.addColumn': 'Add column',
'workflow.editColumn': 'Edit column',
'workflow.deleteColumn': 'Delete column?',
'workflow.addColumnDescription':
'The column is added at the end of the board with a first status of the same name.',
'workflow.editColumnDescription':
'Rename this column or move it to a different position. Its statuses keep their names.',
'workflow.deleteColumnDescription':
'Delete “{name}”? Its statuses move to the backlog and their tickets keep their status. Remaining columns are reordered automatically.',
'workflow.addStatus': 'Add status',
'workflow.editStatus': 'Rename status',
'workflow.deleteStatus': 'Delete status?',
'workflow.statusContext': 'This status belongs to {context}.',
'workflow.deleteStatusDescription':
'Delete “{name}”? Tickets using it are assigned to “{replacement}”, the first remaining status in {context}. Its move rules are removed.',
'workflow.columnName': 'Column name',
'workflow.statusName': 'Status name',
'workflow.position': 'Position',
'workflow.positionValue': 'Position {position}',
'workflow.positionHelp':
'Choose a position from 1 to {count}. Other columns shift automatically.',
'workflow.statusesIn': 'Statuses in {context}',
'workflow.renameStatus': 'Rename status {name}',
'workflow.removeStatus': 'Delete status {name}',
'workflow.changeColumn': 'Edit column {name}',
'workflow.removeColumn': 'Delete column {name}',
'workflow.addStatusTo': 'Add status to {context}',
'workflow.lastStatus':
'Keep at least one status in this group. The last status cannot be deleted.',
'workflow.transitions': 'Status moves',
'workflow.transitionsDescription':
'Choose exactly where each status is allowed to move next.',
'workflow.sourceStatus': 'From status',
'workflow.directionHelp':
'Rules apply in one direction. Select another source to configure the return move.',
'workflow.targetsFrom': 'Allowed destinations from {name}',
'workflow.targetHelp':
'Checked destinations are allowed. Unchecked destinations are forbidden. Save to apply these rules to ticket moves and board drop targets.',
'workflow.allowed': 'Allowed',
'workflow.notAllowed': 'Not allowed',
'workflow.noMovesHelp':
'With no destinations selected, tickets cannot leave this status after you save.',
'workflow.noStatuses':
'Add a status to configure its allowed destinations.',
'workflow.unsavedMoves': 'Unsaved changes',
'workflow.savedMoves': 'Saved destinations: {count}',
'workflow.resetMoves': 'Reset changes',
'workflow.saveMoves': 'Save moves',
'workflow.noTargets': 'No other statuses exist yet.',
'workflow.confirmDelete': 'Delete',
'security.title': 'Security settings',
'security.passwordTitle': 'Update password',
'security.passwordDescription':
'Use a long, unique password to keep your account secure.',
'security.currentPassword': 'Current password',
'security.newPassword': 'New password',
'security.confirmPassword': 'Confirm password',
'security.savePassword': 'Save password',
'security.savingPassword': 'Saving password…',
'security.passwordSaved': 'Password saved.',
'security.twoFactorTitle': 'Two-factor authentication',
'security.twoFactorDescription':
'Add a second layer of protection with a code from your authenticator app.',
'security.enabledDescription':
'Your authenticator app provides a six-digit code when you sign in. Keep your recovery codes somewhere safe.',
'security.enabled': 'Enabled',
'security.disabled': 'Disabled',
'security.pending': 'Awaiting confirmation',
'security.enable': 'Enable 2FA',
'security.disable': 'Disable 2FA',
'security.continueSetup': 'Continue setup',
'security.disableTitle': 'Disable two-factor authentication?',
'security.disableDescription':
'You will no longer need an authenticator code to sign in. Your existing recovery codes will be removed.',
'security.setupTitle': 'Set up your authenticator',
'security.setupDescription':
'Scan this QR code with a TOTP authenticator app, or enter the setup key manually.',
'security.setupEnabled': 'Two-factor authentication enabled',
'security.setupEnabledDescription':
'Finish adding this account to your authenticator app and save your recovery codes.',
'security.verifyTitle': 'Verify authentication code',
'security.verifyDescription':
'Enter the six-digit code from your authenticator app to finish enabling two-factor authentication.',
'security.authenticationCode': 'Authentication code',
'security.manualKey': 'Manual setup key',
'security.copyKey': 'Copy key',
'security.copied': 'Copied',
'security.copyError':
'Copying failed. Select the key and copy it manually.',
'security.confirm': 'Confirm',
'security.continue': 'Continue',
'security.loadingSetup': 'Loading authenticator setup…',
'security.setupPendingDescription':
'Protection starts after you confirm a code. Closing this dialog keeps setup unfinished; you can continue from this page.',
'security.confirmAgain':
'Your session or password confirmation has expired. Reload this page and confirm your password to continue.',
'security.setupError':
'Authenticator setup could not be loaded. Please try again.',
'security.recoveryError':
'Recovery codes could not be loaded. Please try again.',
'security.recoveryTitle': '2FA recovery codes',
'security.recoveryDescription':
'Recovery codes let you regain access if you lose your authenticator. Store them in a secure password manager.',
'security.hideRecovery': 'Hide recovery codes',
'security.viewRecovery': 'View recovery codes',
'security.loadingRecovery': 'Loading recovery codes…',
'security.regenerate': 'Regenerate codes',
'security.recoveryOnce':
'Each recovery code can be used once. Hiding this section removes the codes from this page.',
'security.regenerateTitle': 'Regenerate recovery codes?',
'security.regenerateDescription':
'All previous recovery codes will stop working. Save the new codes in a secure place.',
'applications.title': 'Applications',
'applications.description':
'Create API keys for tools that use KanbanGit with your account permissions. Delete an application to revoke its access.',
'applications.saveKey': 'Save this API key now.',
'applications.keyOnce':
'This key is shown only after creation. It will not be shown again after you leave this page.',
'applications.apiKey': 'API key',
'applications.create': 'Create application',
'applications.nameDescription':
'Name the tool that will receive an API key.',
'applications.name': 'Application name',
'applications.namePlaceholder': 'For example, Claude Desktop',
'applications.creating': 'Creating application…',
'applications.saved': 'Saved applications',
'applications.hiddenKeys':
'Existing keys stay hidden. Create a new application when another key is needed.',
'applications.empty': 'No applications have been created yet.',
'applications.created': 'Created:',
'applications.lastUsed': 'Last used:',
'applications.never': 'Never',
'applications.delete': 'Delete',
'applications.deleteNamed': 'Delete {name}',
'applications.deleteTitle': 'Delete application?',
'applications.deleteDescription':
'{name} will lose access immediately. This cannot be undone.',
'common.back': 'Back',
'common.done': 'Done',
'adminUsers.eyebrow': 'Workspace administration',
'adminUsers.title': 'User management',
'adminUsers.description':
'Manage workspace accounts, password requirements, and the global permissions that apply across KanbanGit.',
'adminUsers.managedAccount': 'Managed account',
'adminUsers.createAction': 'Create user',
'adminUsers.updated': 'User management updated',
'adminUsers.summary.label': 'User management summary',
'adminUsers.summary.total': 'Workspace users',
'adminUsers.summary.admins': 'Global administrators',
'adminUsers.summary.passwordChanges': 'Password changes pending',
'adminUsers.listTitle': 'Users ({count})',
'adminUsers.listDescription':
'Review account status, edit profile details, or confirm global access changes.',
'adminUsers.empty.title': 'No users yet',
'adminUsers.empty.description':
'Create the first managed account for this workspace.',
'adminUsers.passwordChangePending': 'Password change required',
'adminUsers.permissions.none': 'No direct global permissions',
'adminUsers.permissions.action': 'Manage access',
'adminUsers.permissions.available': 'Available global permissions',
'adminUsers.permissions.globalAdmin': 'Global administrator',
'adminUsers.permissions.globalAdminHelp':
'Allows access to workspace-wide administration, including this user management area.',
'adminUsers.permissions.projectCreate': 'Create projects',
'adminUsers.permissions.projectCreateHelp':
'Allows the user to create new projects without granting global administration.',
'adminUsers.permissions.title': 'Global access for {name}',
'adminUsers.permissions.description':
'Select the direct permissions this account should receive.',
'adminUsers.permissions.warning':
'These changes take effect across the workspace after confirmation.',
'adminUsers.permissions.confirm': 'Confirm access changes',
'adminUsers.create.title': 'Create user',
'adminUsers.create.description':
'Add a workspace account with an initial password and a clear first-login requirement.',
'adminUsers.edit.breadcrumb': 'Edit {name}',
'adminUsers.edit.pageTitle': 'Edit {name}',
'adminUsers.edit.title': 'Edit {name}',
'adminUsers.edit.action': 'Edit account details for {name}',
'adminUsers.edit.shortAction': 'Edit details',
'adminUsers.form.detailsTitle': 'Account details',
'adminUsers.form.createDescription':
'Set the identity and initial security state for the new account.',
'adminUsers.form.editDescription':
'Update account details and review security-sensitive changes before saving.',
'adminUsers.form.name': 'Name',
'adminUsers.form.namePlaceholder': 'Jane Admin',
'adminUsers.form.email': 'Email address',
'adminUsers.form.emailPlaceholder': 'jane@example.com',
'adminUsers.form.passwordTitle': 'Password and first sign-in',
'adminUsers.form.passwordCreateHelp':
'Set a temporary password that the user can replace after signing in.',
'adminUsers.form.passwordEditHelp':
'Leave both password fields empty to keep the current password.',
'adminUsers.form.password': 'Temporary password',
'adminUsers.form.newPassword': 'New password',
'adminUsers.form.passwordPlaceholder': 'Enter a temporary password',
'adminUsers.form.passwordEditPlaceholder':
'Leave blank to keep the current password',
'adminUsers.form.confirmPassword': 'Confirm password',
'adminUsers.form.confirmPasswordPlaceholder': 'Repeat the new password',
'adminUsers.form.forcePasswordChange':
'Require a password change on next sign-in',
'adminUsers.form.forcePasswordChangeHelp':
'The user stays in security settings until a new password has been saved.',
'adminUsers.form.createAction': 'Create user',
'adminUsers.form.reviewChanges': 'Review changes',
'adminUsers.form.confirmTitle': 'Update {name}?',
'adminUsers.form.confirmDescription':
'Confirm the account and security changes before they are applied.',
'adminUsers.form.confirmWarning':
'A new password takes effect immediately. Requiring a password change restricts the user to security settings on their next sign-in.',
'adminUsers.form.confirmAction': 'Confirm and save',
'criteria.title': 'Acceptance criteria',
'criteria.progress': '{completed} of {total} complete',
'criteria.empty': 'No acceptance criteria yet.',
'criteria.required': 'Enter a criterion or remove this row.',
'criteria.edit': 'Edit checklist',
'criteria.preview': 'Preview checklist',
'criteria.item': 'Criterion {number}',
'criteria.complete': 'Complete criterion {number}: {text}',
'criteria.indent': 'Indent criterion {number}',
'criteria.outdent': 'Outdent criterion {number}',
'criteria.remove': 'Remove criterion {number}',
'criteria.add': 'Add criterion',
'criteria.hint':
'Use the indent buttons or Alt + Left/Right in a text field to nest criteria. Checking a parent also checks its children. Removing a parent keeps its children.',
'criteria.unsaved': 'Checklist changes will be saved with the ticket.',
'criteria.saving': 'Saving checklist…',
'criteria.saveError':
'Checklist status could not be saved. Please try again.',
'board.title': '{name} board',
'board.description':
'Work in your configured workflow. Tickets without a board column stay in the backlog.',
@@ -184,6 +606,40 @@ export const en = {
'Inherited areas follow the linked template. Overridden areas use this project’s own configuration.',
'projects.inheritedArea': '{name} · inherited',
'projects.overriddenArea': '{name} · overridden',
'welcome.metaTitle': 'Project work with Git context',
'welcome.homeLabel': 'KanbanGit home',
'welcome.navigation': 'Account navigation',
'welcome.eyebrow': 'Self-hosted project management',
'welcome.title': 'Plan the work. Keep Git context close.',
'welcome.description':
'Plan projects on configurable boards, keep ticket decisions visible, and connect delivery work to the repositories your team already uses.',
'welcome.action.signIn': 'Sign in to KanbanGit',
'welcome.action.signInShort': 'Sign in',
'welcome.action.dashboard': 'Open dashboard',
'welcome.action.dashboardShort': 'Dashboard',
'welcome.actionHint': 'One workspace for boards, tickets, and Git context.',
'welcome.preview.label': 'KanbanGit board preview',
'welcome.preview.project': 'Product delivery board',
'welcome.preview.connected': 'Git connected',
'welcome.preview.backlog': 'Backlog',
'welcome.preview.inProgress': 'In progress',
'welcome.preview.review': 'Review',
'welcome.preview.ticketOnboarding': 'Refine onboarding flow',
'welcome.preview.ticketRelease': 'Plan release workflow',
'welcome.preview.ticketRepository': 'Connect project repository',
'welcome.preview.ticketPullRequest': 'Review pull request context',
'welcome.features.eyebrow': 'A focused workflow',
'welcome.features.title': 'The project context your team needs',
'welcome.features.workflows.title': 'Configurable workflows',
'welcome.features.workflows.description':
'Shape project boards, backlog, statuses, and transitions around the way your team works.',
'welcome.features.tickets.title': 'Structured ticket context',
'welcome.features.tickets.description':
'Keep metadata, acceptance criteria, relationships, blockers, and history with the work.',
'welcome.features.git.title': 'Repository-aware delivery',
'welcome.features.git.description':
'Link project repositories and see matching branches and pull requests on the ticket.',
'welcome.footer': 'Self-hosted project and workflow management.',
'dashboard.eyebrow': 'Personal workspace',
'dashboard.greeting': 'Welcome back, {name}',
'dashboard.description':
@@ -340,6 +796,7 @@ export const en = {
'navigation.appearance': 'Appearance',
'navigation.settingsDescription':
'Manage your profile, security, and Git integration settings.',
'settings.eyebrow': 'Account settings',
'navigation.createTicket': 'New ticket',
'navigation.createProject': 'New project',
'navigation.open': 'Open navigation',
@@ -354,10 +811,62 @@ export const en = {
'navigation.breadcrumbs': 'Breadcrumbs',
'appearance.label': 'Color theme',
'appearance.light': 'Light',
'appearance.lightDescription':
'Use a bright workspace with dark text and clear borders.',
'appearance.dark': 'Dark',
'appearance.darkDescription':
'Reduce glare with a darker workspace and balanced contrast.',
'appearance.system': 'System',
'appearance.systemDescription':
'Follow the light or dark preference set on this device.',
'appearance.title': 'Appearance',
'appearance.description': 'Choose how KanbanGit looks on this device.',
'appearance.persistenceHint':
'Your choice is saved on this device and restored when you open KanbanGit again.',
'profile.title': 'Profile',
'profile.description':
'Keep your personal details and profile picture up to date.',
'profile.information': 'Profile information',
'profile.picture': 'Profile picture',
'profile.pictureDescription':
'Help teammates recognize you throughout the workspace.',
'profile.picturePreview': 'Profile picture preview',
'profile.choosePicture': 'Choose picture',
'profile.changePicture': 'Choose another',
'profile.discardPicture': 'Discard selection',
'profile.removePicture': 'Remove picture',
'profile.removingPicture': 'Removing picture…',
'profile.selectedPicture': 'Selected: {name}',
'profile.pictureHelp':
'Choose a PNG, JPG, or WebP image up to 2 MB. On mobile, you can select from your photo library or camera.',
'profile.name': 'Name',
'profile.namePlaceholder': 'Full name',
'profile.email': 'Email address',
'profile.emailPlaceholder': 'Email address',
'profile.emailUnverified': 'Your email address is unverified.',
'profile.resendVerification': 'Resend the verification email',
'profile.verificationSent':
'A new verification link has been sent to your email address.',
'profile.save': 'Save profile',
'profile.saving': 'Saving profile…',
'profile.unsaved': 'You have unsaved profile changes.',
'profile.delete.title': 'Delete account',
'profile.delete.description':
'Permanently delete your account and all of its resources.',
'profile.delete.warning': 'This action cannot be undone',
'profile.delete.warningDescription':
'Your account and associated data will be permanently removed.',
'profile.delete.action': 'Delete account',
'profile.delete.confirmTitle':
'Are you sure you want to delete your account?',
'profile.delete.confirmDescription':
'Enter your password to confirm that you want to permanently delete your account and its data.',
'profile.delete.password': 'Password',
'profile.delete.confirmAction': 'Permanently delete account',
'common.cancel': 'Cancel',
'common.close': 'Close',
'common.edit': 'Edit',
'common.remove': 'Remove',
'common.retry': 'Try again',
'feedback.empty': 'Nothing here yet',
'feedback.saved': 'Changes saved',
@@ -437,9 +946,24 @@ export const en = {
'ticketDetail.checklistPlaceholder':
'[ ] User can sign in\n [x] Validation is shown for invalid credentials',
'ticketDetail.blocked': 'Mark ticket as blocked',
'ticketDetail.blocker': 'Blocker',
'ticketDetail.blockerDescription':
'Record whether progress is blocked and why. Changes are added to the ticket worklog.',
'ticketDetail.blockerActive': 'Blocked',
'ticketDetail.blockerInactive': 'Not blocked',
'ticketDetail.blockerActiveDescription':
'A blocker reason is required while this ticket is blocked.',
'ticketDetail.blockerInactiveDescription':
'This ticket currently has no active blocker.',
'ticketDetail.blockerComment': 'Blocker comment',
'ticketDetail.blockerCommentHelp':
'Explain the concrete dependency or decision preventing progress.',
'ticketDetail.blockerPlaceholder':
'Describe what currently blocks progress on this ticket.',
'ticketDetail.saveBlocker': 'Save blocker',
'ticketDetail.unblockTicket': 'Unblock ticket',
'ticketDetail.resetBlocker': 'Discard blocker changes',
'ticketDetail.savingBlocker': 'Saving blocker…',
'ticketDetail.customFields': 'Custom fields',
'ticketDetail.customFieldsDescription':
'Project-configured fields can be tracked directly on the ticket.',
@@ -457,8 +981,20 @@ export const en = {
'ticketDetail.branchName': 'Branch name',
'ticketDetail.branchAccess':
'You can only create branches in repositories that match one of your personal Git connections.',
'ticketDetail.gitAccessRequired': 'Personal Git access required',
'ticketDetail.personalAccessRequired': 'personal Git access required',
'ticketDetail.openGitConnections': 'Open Git connections',
'ticketDetail.createNow': 'Create now',
'ticketDetail.creatingBranch': 'Creating branch…',
'ticketDetail.noLinkedRepositories': 'No linked repositories',
'ticketDetail.noLinkedRepositoriesDescription':
'A project administrator needs to link a repository before Git activity can appear here.',
'ticketDetail.branchesUnavailable': 'Branches unavailable',
'ticketDetail.pullRequestsUnavailable': 'Pull requests unavailable',
'ticketDetail.openRepositoryExternal':
'Open {repository} on {provider} (opens in a new tab)',
'ticketDetail.openPullRequestExternal':
'Open pull request #{number} on {provider} (opens in a new tab)',
'ticketDetail.inspectBranches':
'Connect a matching Git account in your user settings to inspect branches for this repository.',
'ticketDetail.pullRequests': 'Pull requests',
@@ -493,10 +1029,32 @@ export const en = {
'ticketDetail.noLinks':
'No ticket links are configured for this ticket yet.',
'ticketDetail.removeLink': 'Remove',
'ticketRelationships.hierarchy': 'Hierarchy',
'ticketRelationships.editParent': 'Change parent',
'ticketRelationships.parentHelp':
'Change parent to assign this ticket or choose “No parent ticket” to detach it.',
'ticketRelationships.childrenHelp':
'Open a child ticket to change its parent. Expand a group to see its descendants.',
'ticketRelationships.addLink': 'Add or update a link',
'ticketRelationships.selectTicket': 'Select a ticket',
'ticketRelationships.directionHelp':
'The selected relationship points from this ticket to the linked ticket. Saving an existing pair updates its type and direction.',
'ticketRelationships.outgoing': 'Outgoing',
'ticketRelationships.incoming': 'Incoming',
'ticketRelationships.removeLink': 'Remove link to {key}',
'ticketRelationships.noTypes':
'No link types are configured for this project.',
'ticketRelationships.noTargets':
'No other tickets are available to link in this project.',
'ticketDetail.historyDescription':
'Every tracked ticket change is recorded with actor and timestamp.',
'ticketDetail.noHistory': 'No worklog entries exist for this ticket yet.',
'ticketDetail.system': 'System',
'ticketDetail.historyType.created': 'Ticket created',
'ticketDetail.historyType.updated': 'Ticket updated',
'ticketDetail.historyType.statusChanged': 'Status changed',
'ticketDetail.historyType.linkUpdated': 'Link added',
'ticketDetail.historyType.linkRemoved': 'Link removed',
'ticketDetail.sourceBranch': 'Source branch: {name}',
'ticketForm.create': 'Create ticket',
'ticketForm.pageTitle': 'Create {project} ticket',
@@ -522,4 +1080,286 @@ export const en = {
'ticketForm.saved': 'All changes saved',
'ticketForm.authorRestricted': 'Only project admins can change the author.',
'ticketForm.blocker': 'Blocker',
'projectSettings.pageTitle': '{project} general settings',
'projectSettings.description':
'Manage settings and template inheritance for {project}.',
'projectSettings.generalDescription':
'Review project identity, setting sources, and administrative actions.',
'projectSettings.identity': 'Project identity',
'projectSettings.identityDescription':
'The name and key belong to this project and are never inherited from a template.',
'projectSettings.name': 'Project name',
'projectSettings.key': 'Project key',
'projectSettings.local': 'Local',
'projectSettings.inherited': 'Inherited',
'projectSettings.override': 'Local override',
'projectSettings.display': 'Ticket display',
'projectSettings.showAuthor': 'Show author',
'projectSettings.showStoryPoints': 'Show story points',
'projectSettings.enabled': 'Enabled',
'projectSettings.disabled': 'Disabled',
'projectSettings.displayDescription':
'Manage these display options in Ticket settings.',
'projectSettings.template': 'Template baseline',
'projectSettings.templateDescription':
'Manage the linked template or reset individual areas using the inheritance panel.',
'projectSettings.currentTemplate': 'Current linked template',
'projectSettings.noTemplate': 'No linked template',
'projectSettings.linked': 'Template inheritance active',
'projectSettings.standalone': 'Standalone',
'projectSettings.standaloneDescription':
'This project manages its settings locally. Published template updates are not applied.',
'projectSettings.noAlternatives': 'No other published templates',
'projectSettings.noAlternativesDescription':
'A different published template is required to switch this project.',
'projectSettings.switch': 'Switch template',
'projectSettings.switching': 'Switching template…',
'projectSettings.switchWarning':
'Switching templates can replace or remove project configuration in copied areas, including workflows, ticket metadata, repositories, and notifications.',
'projectSettings.publishedTemplate': 'Published template',
'projectSettings.selectTemplate': 'Select a template',
'projectSettings.confirmation': 'Confirmation key',
'projectSettings.confirmPlaceholder': 'Type {key} exactly',
'projectSettings.confirmHelp':
'Enter the exact project key {key} to confirm this action. The key is case sensitive.',
'projectSettings.danger': 'Danger zone',
'projectSettings.deleteDescription':
'Permanently delete this project and all of its tickets, board columns, ticket metadata, repository connections, and project permissions.',
'projectSettings.irreversible': 'This action cannot be undone.',
'projectSettings.delete': 'Delete project',
'projectSettings.deleting': 'Deleting project…',
'projectSettings.updated': 'Project settings updated.',
'inheritance.title': 'Template inheritance',
'inheritance.description': 'Settings linked to {template}.',
'inheritance.help':
'Each area inherits independently. Settings outside the template are managed locally.',
'inheritance.areas': 'Setting sources',
'inheritance.overrideHelp':
'Local changes stay in place when the template is updated.',
'inheritance.reset': 'Reset to template',
'inheritance.resetArea': 'Reset {area} to template',
'inheritance.resetting': 'Resetting…',
'inheritance.confirmTitle': 'Reset this setting area?',
'inheritance.confirmDescription':
'Replace local changes in {area} with the current baseline from {template}. Other setting areas keep their current values.',
'inheritance.resetWarning':
'Local configuration in this area may be replaced or removed.',
'inheritance.statusHelp':
'Choose where tickets in local statuses should move before those statuses are removed.',
'inheritance.statusLabel': '{status} ({count} tickets)',
'inheritance.selectStatus': 'Select replacement status',
'inheritance.area.projectSettings': 'Project settings',
'inheritance.area.boardColumns': 'Board columns',
'inheritance.area.ticketStatuses': 'Ticket statuses',
'inheritance.area.statusTransitions': 'Status transitions',
'inheritance.area.ticketTypes': 'Ticket types',
'inheritance.area.ticketPriorities': 'Ticket priorities',
'inheritance.area.ticketFields': 'Custom ticket fields',
'inheritance.area.ticketLinkTypes': 'Ticket link types',
'inheritance.area.repositoryConnections': 'Repository connections',
'inheritance.area.notificationActions': 'Notification actions',
'repositorySettings.pageTitle': '{project} repository settings',
'repositorySettings.title': 'Project repositories',
'repositorySettings.description':
'Choose the repositories this project uses for ticket branches and pull requests.',
'repositorySettings.projectScope':
'Repository links belong to the project. Every team member keeps using the same linked repositories, while access is granted through their own personal Git connections.',
'repositorySettings.linkedRepositories': 'Linked repositories',
'repositorySettings.linkAction': 'Link repository',
'repositorySettings.empty': 'No project repositories yet',
'repositorySettings.emptyDescription':
'Link a repository from one of your personal Git connections to make it available to project tickets.',
'repositorySettings.personalAccessReady': 'Personal access ready',
'repositorySettings.personalAccessMissing': 'Personal access needed',
'repositorySettings.ticketUsage':
'Tickets use this project link to discover matching branches and pull requests.',
'repositorySettings.openRepository':
'Open {repository} in the Git provider (opens in a new tab)',
'repositorySettings.open': 'Open repository',
'repositorySettings.connectAccess': 'Connect Git access',
'repositorySettings.editRepository':
'Change project repository {repository}',
'repositorySettings.removeRepository':
'Remove project repository {repository}',
'repositorySettings.personalSources': 'Personal Git access',
'repositorySettings.personalSourcesDescription':
'These account connections are private to you and are only used to discover repositories you can link to this project.',
'repositorySettings.manageConnections': 'Manage Git connections',
'repositorySettings.refresh': 'Refresh repositories',
'repositorySettings.refreshing': 'Loading repositories…',
'repositorySettings.refreshingDescription':
'The latest repositories are being requested from your Git providers.',
'repositorySettings.noConnections': 'No personal Git connection',
'repositorySettings.noConnectionsDescription':
'Add a personal Git connection before linking or changing a project repository.',
'repositorySettings.detectedSources': 'Detected personal sources',
'repositorySettings.unavailable': 'Provider unavailable',
'repositorySettings.repositoryCount': '{count} repositories',
'repositorySettings.linkTitle': 'Link a project repository',
'repositorySettings.linkDescription':
'Choose a personal connection, find a repository, and add it to this project.',
'repositorySettings.editTitle': 'Change project repository',
'repositorySettings.editDescription':
'Replace this project link with another repository. Tickets will use the updated link after saving.',
'repositorySettings.removeTitle': 'Remove repository',
'repositorySettings.removeDescription':
'Remove this repository from the project? Tickets will no longer discover its branches or pull requests.',
'repositorySettings.linked': 'Repository linked to the project.',
'repositorySettings.updated': 'Project repository updated.',
'repositorySettings.removed': 'Repository removed from the project.',
'repositorySettings.matchingConnectionMissing':
'Matching personal access is missing',
'repositorySettings.matchingConnectionMissingDescription':
'The project link remains available to the team, but this account cannot query it until you add a matching provider connection. You can still choose a different accessible repository below.',
'repositorySettings.personalConnection': 'Personal Git connection',
'repositorySettings.personalConnectionHelp':
'Used for discovery only; it is not shared with the project.',
'repositorySettings.providerError':
'Repositories could not be loaded from this provider',
'repositorySettings.searchLabel': 'Find a repository',
'repositorySettings.searchPlaceholder':
'Search by repository, owner, or namespace',
'repositorySettings.noRepositories': 'No repositories available',
'repositorySettings.noRepositoriesDescription':
'This connection is working, but the provider returned no repositories you can access.',
'repositorySettings.noSearchResults': 'No matching repositories',
'repositorySettings.noSearchResultsDescription':
'No repository matches “{search}”. Try a different name or clear the search.',
'repositorySettings.repositoryLabel': 'Available repositories',
'repositorySettings.forbidden': 'Project access denied',
'repositorySettings.forbiddenDescription':
'You no longer have permission to manage repositories for this project. Close this dialog and refresh the page.',
'repositorySettings.linking': 'Linking repository…',
'repositorySettings.saving': 'Saving repository…',
'repositorySettings.save': 'Save repository',
'repositorySettings.removing': 'Removing repository…',
'gitConnections.description':
'Connect your personal Git account to browse repositories, branches, and pull requests you can access.',
'gitConnections.privacy':
'Connections belong to your account. Saved tokens stay hidden. Without a matching connection, only repository names are available.',
'gitConnections.add': 'Add connection',
'gitConnections.editAction': 'Edit',
'gitConnections.edit': 'Edit connection',
'gitConnections.remove': 'Remove',
'gitConnections.removeTitle': 'Remove connection',
'gitConnections.saved': 'Saved connections',
'gitConnections.empty': 'No Git connections yet',
'gitConnections.emptyDescription':
'Add a Gitea connection to access your repositories and their Git activity.',
'gitConnections.unnamedAccount': 'Unnamed remote account',
'gitConnections.remoteId': 'Remote user ID: {id}',
'gitConnections.editAccount': 'Edit {account} at {server}',
'gitConnections.removeAccount': 'Remove {account} at {server}',
'gitConnections.addDescription':
'Connect a Gitea account using its server URL and a personal application token.',
'gitConnections.editDescription':
'Update the server URL and enter a fresh token to verify this connection.',
'gitConnections.removeDescription':
'Remove this personal connection? Repository links remain, but Git activity will require another matching connection.',
'gitConnections.gitea': 'Gitea',
'gitConnections.baseUrl': 'Gitea base URL',
'gitConnections.token': 'Application token',
'gitConnections.newToken': 'New application token',
'gitConnections.tokenPlaceholder': 'Paste your Gitea application token',
'gitConnections.tokenHelp':
'Your token is verified with Gitea before saving. It is never shown in the saved connection.',
'gitConnections.verifying': 'Verifying connection…',
'gitConnections.save': 'Save changes',
'gitConnections.removing': 'Removing connection…',
'gitConnections.forbidden': 'Connection access denied',
'gitConnections.forbiddenDescription':
'You can only manage connections belonging to your account. Close this dialog and refresh the page.',
'gitConnections.added': 'Git connection added.',
'gitConnections.updated': 'Git connection updated.',
'gitConnections.removed': 'Git connection removed.',
'metadata.title': 'Ticket settings',
'metadata.pageTitle': '{project} ticket settings',
'metadata.description':
'Manage ticket types, priorities, and the information shown on your tickets.',
'metadata.updated': 'Ticket settings updated.',
'metadata.types': 'Ticket types',
'metadata.priorities': 'Priorities',
'metadata.addType': 'Add type',
'metadata.addPriority': 'Add priority',
'metadata.typesDescription':
'Define your project’s ticket types and their order. Parent types can contain child tickets.',
'metadata.prioritiesDescription':
'Define and order project-specific priorities. The last priority must be kept.',
'metadata.parentType': 'Parent type',
'metadata.standardType': 'Standard type',
'metadata.assignedTickets': 'Assigned tickets: {count}',
'metadata.editEntry': 'Edit {name}',
'metadata.deleteEntry': 'Delete {name}',
'metadata.lastType':
'The last ticket type cannot be deleted. Add another type first.',
'metadata.lastPriority':
'The last priority cannot be deleted. Add another priority first.',
'metadata.display': 'Display options',
'metadata.displayDescription':
'Choose which optional information is available on this project’s tickets.',
'metadata.author': 'Show ticket author',
'metadata.authorHelp': 'Display the author on ticket details.',
'metadata.storyPoints': 'Enable story points',
'metadata.storyPointsHelp': 'Allow estimates in story points on tickets.',
'metadata.name': 'Name',
'metadata.positionHelp':
'Choose a position from 1 to {count}. Other entries shift automatically.',
'metadata.allowChildren': 'Allow child tickets',
'metadata.parentFixed':
'Whether a type allows child tickets is fixed after creation. Its name and position remain editable.',
'metadata.editHelp':
'Choose a project-specific name. Changes apply to tickets using this entry.',
'metadata.deleteTypeHelp':
'Delete this type? Assigned tickets must be moved to a replacement type first. This action cannot be undone.',
'metadata.deletePriorityHelp':
'Delete this priority? All tickets assigned to this priority will also be permanently deleted. There is no replacement mapping for priorities. This action cannot be undone.',
'metadata.replacementHelp':
'Move {count} assigned tickets to another type before deleting this one.',
'metadata.replacement': 'Replacement ticket type',
'metadata.chooseType': 'Choose a type',
'metadata.parentReplacement':
'If any affected ticket has child tickets, choose a parent type. The server checks this before deleting.',
'metadata.customFields': 'Custom fields',
'metadata.customFieldsDescription':
'Define the project-specific user and number fields shown on tickets.',
'metadata.addField': 'Add field',
'metadata.noCustomFields': 'No custom fields',
'metadata.noCustomFieldsDescription':
'Add a field when this project needs structured information beyond the standard ticket details.',
'metadata.storedValues': 'Stored values: {count}',
'metadata.editField': 'Edit {name}',
'metadata.deleteField': 'Delete {name}',
'metadata.fieldEditorHelp':
'Choose a label and one of the supported field types. Existing ticket values remain attached when this definition is edited.',
'metadata.deleteFieldHelp':
'Delete this custom field? This action cannot be undone.',
'metadata.fieldDeletionImpact':
'Deleting this field also permanently removes {count} stored ticket values.',
'metadata.fieldLabel': 'Field label',
'metadata.fieldType': 'Field type',
'metadata.fieldTypeHelp':
'Only the field types already supported by this project are available.',
'metadata.linkTypes': 'Link types',
'metadata.linkTypesDescription':
'Define the labels shown from each side of a ticket relationship.',
'metadata.addLinkType': 'Add link type',
'metadata.noLinkTypes': 'No link types',
'metadata.noLinkTypesDescription':
'Add a link type before tickets can be connected with a project-specific relationship.',
'metadata.ticketRelationships': 'Ticket relationships: {count}',
'metadata.linkDirection': '{source} points to {counterpart}',
'metadata.editLinkType': 'Edit {name}',
'metadata.deleteLinkType': 'Delete {name}',
'metadata.linkTypeEditorHelp':
'The source label is shown on the originating ticket; the counterpart label is shown on the linked ticket.',
'metadata.deleteLinkTypeHelp':
'Delete this link type? This action cannot be undone.',
'metadata.linkTypeDeletionImpact':
'Deleting this link type also permanently removes {count} ticket relationships.',
'metadata.sourceLabel': 'Source label',
'metadata.sourceLabelHelp':
'Shown when viewing the ticket where the relationship starts.',
'metadata.counterpartLabel': 'Counterpart label',
'metadata.counterpartLabelHelp':
'Shown when viewing the ticket at the other end of the relationship.',
} as const;

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