Compare commits

...
46 Commits
Author SHA1 Message Date
Twyco 32dac91620 feat(KAN-53): add Android app and platform integration
CI/CD / preconditions (pull_request) Successful in 2s
CI/CD / test_and_check_app (pull_request) Successful in 6m22s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-26 20:45:58 +02:00
Twyco 39d0887180 Merge pull request 'KAN-52: Add server selection, isolated sessions and connection recovery' (#148) from feature/KAN-52-server-sessions-connection-errors into main
CI/CD / preconditions (push) Successful in 2s
CI/CD / test_and_check_app (push) Successful in 6m31s
CI/CD / Publish immutable image (push) Successful in 1m21s
Reviewed-on: #148
2026-09-26 18:44:47 +00:00
Twyco 340e6bfff1 feat(KAN-52): add server selection and isolated native sessions
CI/CD / preconditions (pull_request) Successful in 2s
CI/CD / test_and_check_app (pull_request) Successful in 6m31s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-26 20:44:27 +02:00
Twyco f3f1ffdc52 Merge pull request 'KAN-51: Add Tauri scaffold and macOS development build' (#147) from feature/KAN-51-tauri-macos-scaffold into main
CI/CD / preconditions (push) Successful in 2s
CI/CD / test_and_check_app (push) Successful in 6m27s
CI/CD / Publish immutable image (push) Successful in 1m20s
Reviewed-on: #147
2026-09-26 18:41:04 +00:00
Twyco 2a56d03dbf KAN-51: add isolated Tauri macOS app scaffold 2026-09-26 18:41:04 +00:00
Twyco 7e19364757 Merge pull request 'faster test app action' (#145) from feature/faster-test-app-action into main
CI/CD / preconditions (push) Successful in 2s
CI/CD / test_and_check_app (push) Successful in 7m14s
CI/CD / Publish immutable image (push) Successful in 1m19s
Reviewed-on: #145
2026-09-26 18:40:46 +00:00
Twyco de0c19a86b test concurrently 2026-09-26 18:40:46 +00:00
Twyco ce250d8be5 use paratest 2026-09-26 18:40:46 +00:00
Twyco 5c2b62de2a fix 2026-09-26 18:40:46 +00:00
Twyco b5030e6acc faster action test 2026-09-26 18:40:46 +00:00
Twyco 6224880219 faster test app action 2026-09-26 18:40:46 +00:00
Twyco 19d102d140 Merge pull request 'KAN-48: Rebuild project access and permission assignment' (#146) from feature/KAN-48-project-access-permissions into main
CI/CD / Publish immutable image (push) Successful in 12s
CI/CD / preconditions (push) Successful in 1s
CI/CD / test_and_check_app (push) Successful in 10s
Reviewed-on: #146
2026-09-16 11:16:44 +00:00
Twyco e70b6b4de0 feat(KAN-48): rebuild project access and permission assignment 2026-09-16 11:16:44 +00:00
Twyco d9a1288d73 Merge pull request 'KAN-47: Redesign teams and membership management' (#141) from feature/KAN-47-teams-and-membership 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: #141
2026-09-16 11:16:35 +00:00
Twyco 1d9df6897d feat(KAN-47): redesign teams and membership management
CI/CD / preconditions (pull_request) Successful in 1s
CI/CD / test_and_check_app (pull_request) Successful in 6m8s
CI/CD / Publish immutable image (pull_request) Skipped
# Conflicts:
#	resources/js/locales/en.ts
2026-09-16 09:51:26 +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
196 changed files with 27049 additions and 8381 deletions
@@ -1,394 +0,0 @@
---
name: inertia-vue-development
description: "Develops Inertia.js v2 Vue client-side applications. Activates when creating Vue pages, forms, or navigation; using <Link>, <Form>, useForm, or router; working with deferred props, prefetching, or polling; or when user mentions Vue with Inertia, Vue pages, Vue forms, or Vue navigation."
license: MIT
metadata:
author: laravel
---
# Inertia Vue Development
## When to Apply
Activate this skill when:
- Creating or modifying Vue page components for Inertia
- Working with forms in Vue (using `<Form>` or `useForm`)
- Implementing client-side navigation with `<Link>` or `router`
- Using v2 features: deferred props, prefetching, WhenVisible, InfiniteScroll, once props, flash data, or polling
- Building Vue-specific features with the Inertia protocol
## Documentation
Use `search-docs` for detailed Inertia v2 Vue patterns and documentation.
## Basic Usage
### Page Components Location
Vue page components should be placed in the `resources/js/pages` directory.
### Page Component Structure
<!-- Basic Vue Page Component -->
```vue
<script setup>
defineProps({
users: Array
})
</script>
<template>
<div>
<h1>Users</h1>
<ul>
<li v-for="user in users" :key="user.id">
{{ user.name }}
</li>
</ul>
</div>
</template>
```
## Client-Side Navigation
### Basic Link Component
Use `<Link>` for client-side navigation instead of traditional `<a>` tags:
<!-- Inertia Vue Navigation -->
```vue
<script setup>
import { Link } from '@inertiajs/vue3'
</script>
<template>
<div>
<Link href="/">Home</Link>
<Link href="/users">Users</Link>
<Link :href="`/users/${user.id}`">View User</Link>
</div>
</template>
```
### Link with Method
<!-- Link with POST Method -->
```vue
<script setup>
import { Link } from '@inertiajs/vue3'
</script>
<template>
<Link href="/logout" method="post" as="button">
Logout
</Link>
</template>
```
### Prefetching
Prefetch pages to improve perceived performance:
<!-- Prefetch on Hover -->
```vue
<script setup>
import { Link } from '@inertiajs/vue3'
</script>
<template>
<Link href="/users" prefetch>
Users
</Link>
</template>
```
### Programmatic Navigation
<!-- Router Visit -->
```vue
<script setup>
import { router } from '@inertiajs/vue3'
function handleClick() {
router.visit('/users')
}
// Or with options
function createUser() {
router.visit('/users', {
method: 'post',
data: { name: 'John' },
onSuccess: () => console.log('Done'),
})
}
</script>
<template>
<Link href="/users">Users</Link>
<Link href="/logout" method="post" as="button">Logout</Link>
</template>
```
## Form Handling
### Form Component (Recommended)
The recommended way to build forms is with the `<Form>` component:
<!-- Form Component Example -->
```vue
<script setup>
import { Form } from '@inertiajs/vue3'
</script>
<template>
<Form action="/users" method="post" #default="{ errors, processing, wasSuccessful }">
<input type="text" name="name" />
<div v-if="errors.name">{{ errors.name }}</div>
<input type="email" name="email" />
<div v-if="errors.email">{{ errors.email }}</div>
<button type="submit" :disabled="processing">
{{ processing ? 'Creating...' : 'Create User' }}
</button>
<div v-if="wasSuccessful">User created!</div>
</Form>
</template>
```
### Form Component With All Props
<!-- Form Component Full Example -->
```vue
<script setup>
import { Form } from '@inertiajs/vue3'
</script>
<template>
<Form
action="/users"
method="post"
#default="{
errors,
hasErrors,
processing,
progress,
wasSuccessful,
recentlySuccessful,
setError,
clearErrors,
resetAndClearErrors,
defaults,
isDirty,
reset,
submit
}"
>
<input type="text" name="name" :value="defaults.name" />
<div v-if="errors.name">{{ errors.name }}</div>
<button type="submit" :disabled="processing">
{{ processing ? 'Saving...' : 'Save' }}
</button>
<progress v-if="progress" :value="progress.percentage" max="100">
{{ progress.percentage }}%
</progress>
<div v-if="wasSuccessful">Saved!</div>
</Form>
</template>
```
### Form Component Reset Props
The `<Form>` component supports automatic resetting:
- `resetOnError` - Reset form data when the request fails
- `resetOnSuccess` - Reset form data when the request succeeds
- `setDefaultsOnSuccess` - Update default values on success
Use the `search-docs` tool with a query of `form component resetting` for detailed guidance.
<!-- Form with Reset Props -->
```vue
<script setup>
import { Form } from '@inertiajs/vue3'
</script>
<template>
<Form
action="/users"
method="post"
reset-on-success
set-defaults-on-success
#default="{ errors, processing, wasSuccessful }"
>
<input type="text" name="name" />
<div v-if="errors.name">{{ errors.name }}</div>
<button type="submit" :disabled="processing">
Submit
</button>
</Form>
</template>
```
Forms can also be built using the `useForm` composable for more programmatic control. Use the `search-docs` tool with a query of `useForm helper` for guidance.
### `useForm` Composable
For more programmatic control or to follow existing conventions, use the `useForm` composable:
<!-- useForm Composable Example -->
```vue
<script setup>
import { useForm } from '@inertiajs/vue3'
const form = useForm({
name: '',
email: '',
password: '',
})
function submit() {
form.post('/users', {
onSuccess: () => form.reset('password'),
})
}
</script>
<template>
<form @submit.prevent="submit">
<input type="text" v-model="form.name" />
<div v-if="form.errors.name">{{ form.errors.name }}</div>
<input type="email" v-model="form.email" />
<div v-if="form.errors.email">{{ form.errors.email }}</div>
<input type="password" v-model="form.password" />
<div v-if="form.errors.password">{{ form.errors.password }}</div>
<button type="submit" :disabled="form.processing">
Create User
</button>
</form>
</template>
```
## Inertia v2 Features
### Deferred Props
Use deferred props to load data after initial page render:
<!-- Deferred Props with Empty State -->
```vue
<script setup>
defineProps({
users: Array
})
</script>
<template>
<div>
<h1>Users</h1>
<div v-if="!users" class="animate-pulse">
<div class="h-4 bg-gray-200 rounded w-3/4 mb-2"></div>
<div class="h-4 bg-gray-200 rounded w-1/2"></div>
</div>
<ul v-else>
<li v-for="user in users" :key="user.id">
{{ user.name }}
</li>
</ul>
</div>
</template>
```
### Polling
Automatically refresh data at intervals:
<!-- Polling Example -->
```vue
<script setup>
import { router } from '@inertiajs/vue3'
import { onMounted, onUnmounted } from 'vue'
defineProps({
stats: Object
})
let interval
onMounted(() => {
interval = setInterval(() => {
router.reload({ only: ['stats'] })
}, 5000) // Poll every 5 seconds
})
onUnmounted(() => {
clearInterval(interval)
})
</script>
<template>
<div>
<h1>Dashboard</h1>
<div>Active Users: {{ stats.activeUsers }}</div>
</div>
</template>
```
### WhenVisible
Lazy-load a prop when an element scrolls into view. Useful for deferring expensive data that sits below the fold:
<!-- WhenVisible Example -->
```vue
<script setup>
import { WhenVisible } from '@inertiajs/vue3'
defineProps({
stats: Object
})
</script>
<template>
<div>
<h1>Dashboard</h1>
<!-- stats prop is loaded only when this section scrolls into view -->
<WhenVisible data="stats" :buffer="200">
<template #fallback>
<div class="animate-pulse">Loading stats...</div>
</template>
<template #default="{ fetching }">
<div>
<p>Total Users: {{ stats.total_users }}</p>
<p>Revenue: {{ stats.revenue }}</p>
<span v-if="fetching">Refreshing...</span>
</div>
</template>
</WhenVisible>
</div>
</template>
```
## Server-Side Patterns
Server-side patterns (Inertia::render, props, middleware) are covered in inertia-laravel guidelines.
## Common Pitfalls
- Using traditional `<a>` links instead of Inertia's `<Link>` component (breaks SPA behavior)
- Forgetting that Vue components must have a single root element
- Forgetting to add loading states (skeleton screens) when using deferred props
- Not handling the `undefined` state of deferred props before data loads
- Using `<form>` without preventing default submission (use `<Form>` component or `@submit.prevent`)
- Forgetting to check if `<Form>` component is available in your Inertia version
@@ -1,119 +0,0 @@
---
name: tailwindcss-development
description: "Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS."
license: MIT
metadata:
author: laravel
---
# Tailwind CSS Development
## Documentation
Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.
## Basic Usage
- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.
## Tailwind CSS v4 Specifics
- Always use Tailwind CSS v4 and avoid deprecated utilities.
- `corePlugins` is not supported in Tailwind v4.
### CSS-First Configuration
In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:
<!-- CSS-First Config -->
```css
@theme {
--color-brand: oklch(0.72 0.11 178);
}
```
### Import Syntax
In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:
<!-- v4 Import Syntax -->
```diff
- @tailwind base;
- @tailwind components;
- @tailwind utilities;
+ @import "tailwindcss";
```
### Replaced Utilities
Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric.
| Deprecated | Replacement |
|------------|-------------|
| bg-opacity-* | bg-black/* |
| text-opacity-* | text-black/* |
| border-opacity-* | border-black/* |
| divide-opacity-* | divide-black/* |
| ring-opacity-* | ring-black/* |
| placeholder-opacity-* | placeholder-black/* |
| flex-shrink-* | shrink-* |
| flex-grow-* | grow-* |
| overflow-ellipsis | text-ellipsis |
| decoration-slice | box-decoration-slice |
| decoration-clone | box-decoration-clone |
## Spacing
Use `gap` utilities instead of margins for spacing between siblings:
<!-- Gap Utilities -->
```html
<div class="flex gap-8">
<div>Item 1</div>
<div>Item 2</div>
</div>
```
## Dark Mode
If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
<!-- Dark Mode -->
```html
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
Content adapts to color scheme
</div>
```
## Common Patterns
### Flexbox Layout
<!-- Flexbox Layout -->
```html
<div class="flex items-center justify-between gap-4">
<div>Left content</div>
<div>Right content</div>
</div>
```
### Grid Layout
<!-- Grid Layout -->
```html
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div>Card 1</div>
<div>Card 2</div>
<div>Card 3</div>
</div>
```
## Common Pitfalls
- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.)
- Using `@tailwind` directives instead of `@import "tailwindcss"`
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
- Using margins for spacing between siblings instead of gap utilities
- Forgetting to add dark mode variants when the project uses dark mode
@@ -61,10 +61,14 @@ show(1, { query: { page: 1 } }) // "/posts/1?page=1"
## Wayfinder + Inertia
Use Wayfinder with the `<Form>` component:
<!-- Wayfinder Form (Vue) -->
```vue
<Form v-bind="store.form()"><input name="title" /></Form>
Use Wayfinder with `useForm`:
<!-- Wayfinder useForm -->
```typescript
import { store } from "@/actions/App/Http/Controllers/ExampleController";
const form = useForm({ name: "My Big Post" });
form.submit(store());
```
## Verification
+4
View File
@@ -43,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
+73 -13
View File
@@ -1,18 +1,78 @@
name: test_and_check_app
description: Build the test image and run all application checks
name: "Lint and Test APP"
description: "Run Pint and the Pest test suite"
runs:
using: composite
steps:
- name: Test and check app
shell: bash
run: |
set -Eeuo pipefail
- 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
docker buildx build \
--file Dockerfile \
--target test \
--tag "local/kanbangit-test:${GITHUB_SHA}" \
--progress plain \
--load \
.
- name: Verify Composer lockfile
run: |
if [ ! -f composer.lock ]; then
echo "::error::composer.lock not found"
exit 1
fi
shell: bash
- 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('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: Run independent application checks
run: |
pnpm exec concurrently \
--names frontend,backend \
--prefix-colors cyan,magenta \
--kill-others-on-fail \
"composer run ci:frontend" \
"composer run ci:backend"
shell: bash
- name: Run Laravel tests
run: composer run ci:test
shell: bash
+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/
+9 -19
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 ===
@@ -9,7 +15,7 @@ The Laravel Boost guidelines are specifically curated by Laravel maintainers for
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
- php - 8.4
- php - 8.5
- inertiajs/inertia-laravel (INERTIA_LARAVEL) - v2
- laravel/fortify (FORTIFY) - v1
- laravel/framework (LARAVEL) - v13
@@ -21,12 +27,6 @@ This application is a Laravel application and its main Laravel ecosystems packag
- laravel/pint (PINT) - v1
- laravel/sail (SAIL) - v1
- phpunit/phpunit (PHPUNIT) - v12
- @inertiajs/vue3 (INERTIA_VUE) - v2
- tailwindcss (TAILWINDCSS) - v4
- vue (VUE) - v3
- @laravel/vite-plugin-wayfinder (WAYFINDER_VITE) - v0
- eslint (ESLINT) - v9
- prettier (PRETTIER) - v3
## Skills Activation
@@ -36,8 +36,6 @@ This project has domain-specific skills available. You MUST activate the relevan
- `laravel-best-practices` — Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns.
- `mcp-development` — Use this skill for Laravel MCP development only. Trigger when creating or editing MCP tools, resources, prompts, or servers in Laravel projects. Covers: artisan make:mcp-* generators, mcp:inspector, routes/ai.php, Tool/Resource/Prompt classes, schema validation, shouldRegister(), OAuth setup, URI templates, read-only attributes, and MCP debugging. Do not use for non-Laravel MCP projects or generic AI features without MCP.
- `wayfinder-development` — Use this skill for Laravel Wayfinder which auto-generates typed functions for Laravel controllers and routes. ALWAYS use this skill when frontend code needs to call backend routes or controller actions. Trigger when: connecting any React/Vue/Svelte/Inertia frontend to Laravel controllers, routes, building end-to-end features with both frontend and backend, wiring up forms or links to backend endpoints, fixing route-related TypeScript errors, importing from @/actions or @/routes, or running wayfinder:generate. Use Wayfinder route functions instead of hardcoded URLs. Covers: wayfinder() vite plugin, .url()/.get()/.post()/.form(), query params, route model binding, tree-shaking. Do not use for backend-only task
- `inertia-vue-development` — Develops Inertia.js v2 Vue client-side applications. Activates when creating Vue pages, forms, or navigation; using <Link>, <Form>, useForm, or router; working with deferred props, prefetching, or polling; or when user mentions Vue with Inertia, Vue pages, Vue forms, or Vue navigation.
- `tailwindcss-development` — Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS.
## Conventions
@@ -56,7 +54,7 @@ This project has domain-specific skills available. You MUST activate the relevan
## Frontend Bundling
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `pnpm run build`, `pnpm run dev`, or `composer run dev`. Ask them.
## Documentation Files
@@ -130,7 +128,6 @@ This project has domain-specific skills available. You MUST activate the relevan
- Inertia creates fully client-side rendered SPAs without modern SPA complexity, leveraging existing server-side patterns.
- Components live in `resources/js/pages` (unless specified in `vite.config.js`). Use `Inertia::render()` for server-side routing instead of Blade views.
- ALWAYS use `search-docs` tool for version-specific Inertia documentation and updated code examples.
- IMPORTANT: Activate `inertia-vue-development` when working with Inertia Vue client-side patterns.
# Inertia v2
@@ -166,7 +163,7 @@ This project has domain-specific skills available. You MUST activate the relevan
## Vite Error
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `pnpm run build` or ask the user to run `pnpm run dev` or `composer run dev`.
## Deployment
@@ -203,11 +200,4 @@ Use Wayfinder to generate TypeScript functions for Laravel routes. Import from `
- To run all tests in a file: `php artisan test --compact tests/Feature/ExampleTest.php`.
- To filter on a particular test name: `php artisan test --compact --filter=testName` (recommended after making a change to a related file).
=== inertia-vue/core rules ===
# Inertia + Vue
Vue components must have a single root element.
- IMPORTANT: Activate `inertia-vue-development` when working with Inertia Vue client-side patterns.
</laravel-boost-guidelines>
@@ -8,6 +8,7 @@ use App\Models\Project;
use App\Models\Team;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
@@ -113,6 +114,41 @@ class PermissionService
);
}
/**
* @param EloquentCollection<int, User> $users
* @return array{directPermissions: array, teamPermissions: array, effectivePermissions: array, globalAdminUserIds: array<int, int>}
*/
public function projectAccessMap(EloquentCollection $users, Project $project): array
{
$users->loadMissing('teams');
$directPermissions = $this->assignmentMap(User::class, $project);
$teamPermissions = $this->assignmentMap(Team::class, $project);
$globalPermissions = $this->assignmentMap(User::class);
$effectivePermissions = [];
$globalAdminUserIds = [];
foreach ($users as $user) {
$permissions = $directPermissions[$user->id] ?? [];
foreach ($user->teams as $team) {
$permissions = array_merge($permissions, $teamPermissions[$team->id] ?? []);
}
if (in_array(Permission::GlobalAdmin->value, $globalPermissions[$user->id] ?? [], true)) {
$permissions[] = Permission::GlobalAdmin->value;
$globalAdminUserIds[] = $user->id;
}
$expanded = $this->expandPermissions(array_map(Permission::from(...), $permissions));
$effectivePermissions[$user->id] = Permission::values(array_values(array_filter(
Permission::projectAssignable(),
fn (Permission $permission): bool => in_array($permission, $expanded, true),
)));
}
return compact('directPermissions', 'teamPermissions', 'effectivePermissions', 'globalAdminUserIds');
}
/**
* @return array<string, array<int, string>>
*/
@@ -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(),
];
+10 -9
View File
@@ -182,25 +182,22 @@ class ProjectController extends Controller
403,
);
$users = User::query()->orderBy('name')->get();
return Inertia::render('projects/Access', [
'directPermissions' => $this->permissionService->assignmentMap(User::class, $project),
...$this->permissionService->projectAccessMap($users, $project),
'permissionOptions' => PermissionOptionResource::collection(
$this->permissionService->permissionOptions(User::class, Project::class),
)->resolve(),
'project' => ProjectResource::make($project)->resolve(),
'status' => session('status'),
'teamPermissions' => $this->permissionService->assignmentMap(Team::class, $project),
'teams' => TeamResource::collection(
Team::query()
->with('users')
->orderBy('name')
->get(),
)->resolve(),
'users' => UserResource::collection(
User::query()
->orderBy('name')
->get(),
)->resolve(),
'users' => UserResource::collection($users)->resolve(),
]);
}
@@ -220,7 +217,9 @@ class ProjectController extends Controller
$this->permissionService->sync($user, $project, $permissions);
return to_route('projects.access', $project)
return ($this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project)
? to_route('projects.access', $project)
: to_route('projects.index'))
->with('status', "{$user->name} permissions were updated.");
}
@@ -239,7 +238,9 @@ class ProjectController extends Controller
$this->permissionService->sync($team, $project, $permissions);
return to_route('projects.access', $project)
return ($this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project)
? to_route('projects.access', $project)
: to_route('projects.index'))
->with('status', "{$team->name} permissions were updated.");
}
@@ -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 -3
View File
@@ -10,8 +10,6 @@
"fortify-development",
"laravel-best-practices",
"mcp-development",
"wayfinder-development",
"inertia-vue-development",
"tailwindcss-development"
"wayfinder-development"
]
}
+1 -1
View File
@@ -33,5 +33,5 @@ return Application::configure(basePath: dirname(__DIR__))
]);
})
->withExceptions(function (Exceptions $exceptions): void {
//
$exceptions->dontFlash(['api_token']);
})->create();
+15 -2
View File
@@ -20,6 +20,7 @@
"scalar/laravel": "^0.2.1"
},
"require-dev": {
"brianium/paratest": "^7.20",
"fakerphp/faker": "^1.24",
"laravel/boost": "^2.0",
"laravel/pail": "^1.2.5",
@@ -67,15 +68,27 @@
],
"ci:check": [
"Composer\\Config::disableProcessTimeout",
"@ci:frontend",
"@ci:backend",
"@ci:test"
],
"ci:frontend": [
"pnpm audit --audit-level=high",
"pnpm run lint:check",
"pnpm run types:check",
"@test"
"pnpm run build"
],
"ci:backend": [
"@lint:check"
],
"ci:test": [
"@php artisan config:clear --ansi",
"@php artisan test --parallel --processes=2"
],
"test": [
"@php artisan config:clear --ansi",
"@lint:check",
"@php artisan test"
"@php artisan test --parallel --processes=2"
],
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
Generated
+215 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "7d965721310f81816807d6b78c5f9654",
"content-hash": "4d18577105a240f2d8cce1c098398730",
"packages": [
{
"name": "aws/aws-crt-php",
@@ -7020,6 +7020,99 @@
}
],
"packages-dev": [
{
"name": "brianium/paratest",
"version": "v7.20.0",
"source": {
"type": "git",
"url": "https://github.com/paratestphp/paratest.git",
"reference": "81c80677c9ec0ed4ef16b246167f11dec81a6e3d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/paratestphp/paratest/zipball/81c80677c9ec0ed4ef16b246167f11dec81a6e3d",
"reference": "81c80677c9ec0ed4ef16b246167f11dec81a6e3d",
"shasum": ""
},
"require": {
"ext-dom": "*",
"ext-pcre": "*",
"ext-reflection": "*",
"ext-simplexml": "*",
"fidry/cpu-core-counter": "^1.3.0",
"jean85/pretty-package-versions": "^2.1.1",
"php": "~8.3.0 || ~8.4.0 || ~8.5.0",
"phpunit/php-code-coverage": "^12.5.3 || ^13.0.1",
"phpunit/php-file-iterator": "^6.0.1 || ^7",
"phpunit/php-timer": "^8 || ^9",
"phpunit/phpunit": "^12.5.14 || ^13.0.5",
"sebastian/environment": "^8.0.3 || ^9",
"symfony/console": "^7.4.7 || ^8.0.7",
"symfony/process": "^7.4.5 || ^8.0.5"
},
"require-dev": {
"doctrine/coding-standard": "^14.0.0",
"ext-pcntl": "*",
"ext-pcov": "*",
"ext-posix": "*",
"phpstan/phpstan": "^2.1.44",
"phpstan/phpstan-deprecation-rules": "^2.0.4",
"phpstan/phpstan-phpunit": "^2.0.16",
"phpstan/phpstan-strict-rules": "^2.0.10",
"symfony/filesystem": "^7.4.6 || ^8.0.6"
},
"bin": [
"bin/paratest",
"bin/paratest_for_phpstorm"
],
"type": "library",
"autoload": {
"psr-4": {
"ParaTest\\": [
"src/"
]
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Brian Scaturro",
"email": "scaturrob@gmail.com",
"role": "Developer"
},
{
"name": "Filippo Tessarotto",
"email": "zoeslam@gmail.com",
"role": "Developer"
}
],
"description": "Parallel testing for PHP",
"homepage": "https://github.com/paratestphp/paratest",
"keywords": [
"concurrent",
"parallel",
"phpunit",
"testing"
],
"support": {
"issues": "https://github.com/paratestphp/paratest/issues",
"source": "https://github.com/paratestphp/paratest/tree/v7.20.0"
},
"funding": [
{
"url": "https://github.com/sponsors/Slamdunk",
"type": "github"
},
{
"url": "https://paypal.me/filippotessarotto",
"type": "paypal"
}
],
"time": "2026-03-29T15:46:14+00:00"
},
{
"name": "fakerphp/faker",
"version": "v1.24.1",
@@ -7083,6 +7176,67 @@
},
"time": "2024-11-21T13:46:39+00:00"
},
{
"name": "fidry/cpu-core-counter",
"version": "1.3.0",
"source": {
"type": "git",
"url": "https://github.com/theofidry/cpu-core-counter.git",
"reference": "db9508f7b1474469d9d3c53b86f817e344732678"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678",
"reference": "db9508f7b1474469d9d3c53b86f817e344732678",
"shasum": ""
},
"require": {
"php": "^7.2 || ^8.0"
},
"require-dev": {
"fidry/makefile": "^0.2.0",
"fidry/php-cs-fixer-config": "^1.1.2",
"phpstan/extension-installer": "^1.2.0",
"phpstan/phpstan": "^2.0",
"phpstan/phpstan-deprecation-rules": "^2.0.0",
"phpstan/phpstan-phpunit": "^2.0",
"phpstan/phpstan-strict-rules": "^2.0",
"phpunit/phpunit": "^8.5.31 || ^9.5.26",
"webmozarts/strict-phpunit": "^7.5"
},
"type": "library",
"autoload": {
"psr-4": {
"Fidry\\CpuCoreCounter\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Théo FIDRY",
"email": "theo.fidry@gmail.com"
}
],
"description": "Tiny utility to get the number of CPU cores.",
"keywords": [
"CPU",
"core"
],
"support": {
"issues": "https://github.com/theofidry/cpu-core-counter/issues",
"source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0"
},
"funding": [
{
"url": "https://github.com/theofidry",
"type": "github"
}
],
"time": "2025-08-14T07:29:31+00:00"
},
{
"name": "filp/whoops",
"version": "2.18.4",
@@ -7205,6 +7359,66 @@
},
"time": "2025-04-30T06:54:44+00:00"
},
{
"name": "jean85/pretty-package-versions",
"version": "2.1.1",
"source": {
"type": "git",
"url": "https://github.com/Jean85/pretty-package-versions.git",
"reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/4d7aa5dab42e2a76d99559706022885de0e18e1a",
"reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a",
"shasum": ""
},
"require": {
"composer-runtime-api": "^2.1.0",
"php": "^7.4|^8.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.2",
"jean85/composer-provided-replaced-stub-package": "^1.0",
"phpstan/phpstan": "^2.0",
"phpunit/phpunit": "^7.5|^8.5|^9.6",
"rector/rector": "^2.0",
"vimeo/psalm": "^4.3 || ^5.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.x-dev"
}
},
"autoload": {
"psr-4": {
"Jean85\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Alessandro Lai",
"email": "alessandro.lai85@gmail.com"
}
],
"description": "A library to get pretty versions strings of installed dependencies",
"keywords": [
"composer",
"package",
"release",
"versions"
],
"support": {
"issues": "https://github.com/Jean85/pretty-package-versions/issues",
"source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.1"
},
"time": "2025-03-19T14:43:43+00:00"
},
{
"name": "laravel/boost",
"version": "v2.4.3",
+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,199 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { computed, watch } from 'vue';
import {
updateTeamPermissions,
updateUserPermissions,
} from '@/actions/App/Http/Controllers/ProjectController';
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 { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import type { PermissionOption } from '@/types';
import {
projectPermissionDescriptions,
projectPermissionLabels,
} from '@/types/project-access';
import type { ProjectAccessTarget } from '@/types/project-access';
const props = defineProps<{
projectId: number;
target: ProjectAccessTarget | null;
permissionOptions: PermissionOption[];
}>();
const emit = defineEmits<{ close: [] }>();
const { t } = useLocalization();
const form = useForm({ permissions: [] as string[] });
const protection = useFormProtection(form, {
isDirty: () => props.target !== null && form.isDirty,
});
const selectionError = computed(
() =>
Object.entries(form.errors).find(([key]) =>
key.startsWith('permissions'),
)?.[1],
);
const options = computed(() =>
props.permissionOptions
.filter((option) => option.value in projectPermissionLabels)
.map((option) => ({
value: option.value,
label: t(
projectPermissionLabels[
option.value as keyof typeof projectPermissionLabels
],
),
description: t(
projectPermissionDescriptions[
option.value as keyof typeof projectPermissionDescriptions
],
),
})),
);
watch(
() => props.target,
(target) => {
form.defaults({ permissions: [...(target?.permissions ?? [])] });
form.resetAndClearErrors();
protection.clearFeedback();
},
);
function close(open: boolean): void {
if (open || form.processing || !protection.confirmLeave()) {
return;
}
form.resetAndClearErrors();
emit('close');
}
function submit(): void {
if (!props.target || form.processing) {
return;
}
const route =
props.target.kind === 'user'
? updateUserPermissions([props.projectId, props.target.id])
: updateTeamPermissions([props.projectId, props.target.id]);
protection.submit(route, {
preserveScroll: true,
onSuccess: () => {
form.defaults();
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">{{
t('projectAccess.editFor', { name: target?.name ?? '' })
}}</DialogTitle>
<DialogDescription>{{
t(
target?.kind === 'team'
? 'projectAccess.teamHelp'
: 'projectAccess.directHelp',
)
}}</DialogDescription>
</DialogHeader>
<form class="grid min-w-0 gap-5" @submit.prevent="submit">
<FormFeedback
:state="protection.state.value"
:title="
protection.responseStatus.value === 403
? t('projectAccess.forbidden')
: undefined
"
:description="
protection.responseStatus.value === 403
? t('projectAccess.forbiddenHelp')
: undefined
"
:retryable="protection.responseStatus.value !== 403"
@retry="submit"
/>
<fieldset
:disabled="form.processing"
class="grid min-w-0 gap-3"
:aria-invalid="!!selectionError"
aria-describedby="project-permission-error"
>
<legend class="mb-3 text-sm font-semibold">
{{
t(
target?.kind === 'team'
? 'projectAccess.teamPermissions'
: 'projectAccess.directPermissions',
)
}}
</legend>
<label
v-for="option in options"
:key="option.value"
class="hover:bg-accent flex cursor-pointer items-start gap-3 rounded-lg border p-3"
>
<input
v-model="form.permissions"
type="checkbox"
:value="option.value"
:aria-label="option.label"
class="accent-primary focus-visible:ring-ring mt-0.5 size-5 shrink-0 focus-visible:ring-2"
/>
<span class="grid gap-1"
><span class="text-sm font-medium">{{
option.label
}}</span
><span class="text-muted-foreground text-xs">{{
option.description
}}</span></span
>
</label>
<InputError
id="project-permission-error"
:message="selectionError"
/>
</fieldset>
<p class="text-muted-foreground text-sm">
{{ t('projectAccess.clearHelp') }}
</p>
<p
v-if="target?.globalAdmin"
class="bg-accent text-accent-foreground rounded-lg p-3 text-sm"
>
{{ t('projectAccess.globalHelp') }}
</p>
<p class="text-muted-foreground text-xs">
{{ t('projectAccess.selfHelp') }}
</p>
<DialogFooter>
<Button
type="button"
variant="outline"
:disabled="form.processing"
@click="close(false)"
>{{ t('common.cancel') }}</Button
>
<Button type="submit" :disabled="form.processing">{{
t('projectAccess.save')
}}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>
@@ -0,0 +1,26 @@
<script setup lang="ts">
import { Badge } from '@/components/ui/badge';
import { useLocalization } from '@/composables/useLocalization';
import { projectPermissionLabels } from '@/types/project-access';
defineProps<{ permissions: string[]; emptyLabel: string }>();
const { t } = useLocalization();
</script>
<template>
<div class="flex flex-wrap gap-1.5">
<template
v-for="(label, value) in projectPermissionLabels"
:key="value"
>
<Badge v-if="permissions.includes(value)" variant="secondary">{{
t(label)
}}</Badge>
</template>
<span
v-if="permissions.length === 0"
class="text-muted-foreground text-sm"
>{{ emptyLabel }}</span
>
</div>
</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,351 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { computed, ref, watch } from 'vue';
import {
store,
updateMembers,
updateUserPermissions,
} from '@/actions/App/Http/Controllers/TeamController';
import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.vue';
import UserAvatar from '@/components/ui/avatar/UserAvatar.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 { TeamEditorProps } from '@/types/teams';
const props = defineProps<TeamEditorProps>();
const emit = defineEmits<{ close: [] }>();
const { t } = useLocalization();
const search = ref('');
const userId = ref<number | null>(null);
const form = useForm({
name: '',
user_ids: [] as number[],
permissions: [] as string[],
});
const protection = useFormProtection(form, {
isDirty: () => props.target !== null && form.isDirty,
});
const team = computed(() =>
props.target && props.target.kind !== 'create' ? props.target.team : null,
);
const visibleUsers = computed(() =>
props.users.filter((user) =>
`${user.name} ${user.email}`
.toLocaleLowerCase()
.includes(search.value.trim().toLocaleLowerCase()),
),
);
const title = computed(() =>
t(
props.target?.kind === 'create'
? 'teams.create'
: props.target?.kind === 'members'
? 'teams.manageMembers'
: 'teams.permissions',
),
);
const selectionError = computed(
() =>
Object.entries(form.errors).find(([key]) =>
key.startsWith(
props.target?.kind === 'members' ? 'user_ids' : 'permissions',
),
)?.[1],
);
const permissionLabels = {
'team:invite': 'teams.permissionInvite',
'team:manage': 'teams.permissionManage',
'team:admin': 'teams.permissionAdmin',
} as const;
function permissionLabel(value: string, fallback: string): string {
return value in permissionLabels
? t(permissionLabels[value as keyof typeof permissionLabels])
: fallback;
}
watch(
() => props.target,
() => {
userId.value = null;
search.value = '';
form.defaults({
name: '',
user_ids: [...(team.value?.user_ids ?? [])],
permissions: [],
});
form.resetAndClearErrors();
protection.clearFeedback();
},
);
function chooseUser(event: Event): void {
const select = event.target as HTMLSelectElement;
if (!protection.confirmLeave()) {
select.value = String(userId.value ?? '');
return;
}
userId.value = select.value ? Number(select.value) : null;
form.defaults('permissions', [
...(props.teamPermissions[team.value?.id ?? '']?.[userId.value ?? ''] ??
[]),
]);
form.resetAndClearErrors('permissions');
protection.clearFeedback();
}
function close(open: boolean): void {
if (open || form.processing || !protection.confirmLeave()) {
return;
}
form.resetAndClearErrors();
emit('close');
}
function submit(): void {
const target = props.target;
if (
!target ||
form.processing ||
(target.kind === 'permissions' && userId.value === null)
) {
return;
}
const route =
target.kind === 'create'
? store()
: target.kind === 'members'
? updateMembers(target.team.id)
: updateUserPermissions([target.team.id, userId.value!]);
form.transform((data) =>
target.kind === 'create'
? { name: data.name }
: target.kind === 'members'
? { user_ids: data.user_ids }
: { permissions: data.permissions },
);
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">{{
team?.name ?? t('teams.createDescription')
}}</DialogDescription>
</DialogHeader>
<form class="grid min-w-0 gap-5" @submit.prevent="submit">
<FormFeedback
:state="protection.state.value"
:title="
protection.responseStatus.value === 403
? t('teams.forbidden')
: undefined
"
:description="
protection.responseStatus.value === 403
? t('teams.forbiddenDescription')
: undefined
"
:retryable="protection.responseStatus.value !== 403"
@retry="submit"
/>
<fieldset
:disabled="form.processing"
class="grid min-w-0 gap-4"
>
<template v-if="target?.kind === 'create'">
<div class="grid gap-2">
<Label for="team-name">{{ t('teams.name') }}</Label>
<Input
id="team-name"
v-model="form.name"
required
maxlength="255"
:placeholder="t('teams.namePlaceholder')"
:aria-invalid="!!form.errors.name"
aria-describedby="team-name-error"
/>
<InputError
id="team-name-error"
:message="form.errors.name"
/>
</div>
</template>
<template v-else-if="target?.kind === 'members'">
<p class="text-muted-foreground text-sm">
{{ t('teams.membersHelp') }}
</p>
<div class="grid gap-2">
<Label for="member-search">{{
t('teams.searchPeople')
}}</Label>
<Input
id="member-search"
v-model="search"
type="search"
:placeholder="
t('teams.searchPeoplePlaceholder')
"
/>
</div>
<p class="text-muted-foreground text-sm" role="status">
{{
t('teams.selected', {
count: form.user_ids.length,
})
}}
</p>
<div
class="grid max-h-64 gap-1 overflow-y-auto rounded-md border p-2"
role="group"
:aria-label="t('teams.members')"
:aria-invalid="!!selectionError"
aria-describedby="team-selection-error"
>
<label
v-for="user in visibleUsers"
:key="user.id"
class="hover:bg-accent flex min-w-0 cursor-pointer items-center gap-3 rounded-md p-2"
>
<input
v-model="form.user_ids"
type="checkbox"
:value="user.id"
class="accent-primary focus-visible:ring-ring size-5 shrink-0 focus-visible:ring-2"
/>
<UserAvatar
:user="user"
class="size-8 shrink-0"
/>
<span class="grid min-w-0 gap-0.5 text-sm"
><span class="break-words font-medium">{{
user.name
}}</span
><span
class="text-muted-foreground break-all text-xs"
>{{ user.email }}</span
></span
>
</label>
<p
v-if="visibleUsers.length === 0"
class="text-muted-foreground p-3 text-sm"
>
{{ t('teams.noPeople') }}
</p>
</div>
<InputError
id="team-selection-error"
:message="selectionError"
/>
</template>
<template v-else-if="target?.kind === 'permissions'">
<p class="text-muted-foreground text-sm">
{{ t('teams.permissionsHelp') }}
</p>
<div class="grid gap-2">
<Label for="permission-user">{{
t('teams.person')
}}</Label>
<select
id="permission-user"
:value="userId ?? ''"
class="border-input bg-background focus-visible:ring-ring h-11 w-full min-w-0 rounded-md border px-3 text-sm focus-visible:ring-2"
@change="chooseUser"
>
<option value="" disabled>
{{ t('teams.choosePerson') }}
</option>
<option
v-for="user in users"
:key="user.id"
:value="user.id"
>
{{ user.name }} ({{ user.email }})
</option>
</select>
</div>
<fieldset
v-if="userId !== null"
class="grid gap-2"
:aria-invalid="!!selectionError"
aria-describedby="team-selection-error"
>
<legend class="mb-2 text-sm font-medium">
{{ t('teams.directPermissions') }}
</legend>
<label
v-for="option in permissionOptions"
:key="option.value"
class="hover:bg-accent flex cursor-pointer items-center gap-3 rounded-md border p-3 text-sm"
>
<input
v-model="form.permissions"
type="checkbox"
:value="option.value"
class="accent-primary focus-visible:ring-ring size-5 shrink-0 focus-visible:ring-2"
/>
{{
permissionLabel(option.value, option.label)
}}
</label>
<p class="text-muted-foreground text-sm">
{{ t('teams.permissionsClearHelp') }}
</p>
</fieldset>
<InputError
id="team-selection-error"
:message="selectionError"
/>
</template>
</fieldset>
<DialogFooter>
<Button
type="button"
variant="outline"
:disabled="form.processing"
@click="close(false)"
>{{ t('common.cancel') }}</Button
>
<Button
type="submit"
:disabled="
form.processing ||
(target?.kind === 'permissions' && userId === null)
"
>{{
t(
target?.kind === 'create'
? 'teams.create'
: target?.kind === 'members'
? 'teams.saveMembers'
: 'teams.savePermissions',
)
}}</Button
>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</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,7 +3,15 @@ import { useLocalization } from '@/composables/useLocalization';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { useForm, useRemember } from '@inertiajs/vue3';
import { computed, ref, toRef, unref, useTemplateRef, nextTick } 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';
@@ -11,7 +19,6 @@ 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 {
normalizeAcceptanceCriteria,
@@ -81,6 +88,16 @@ const savedFormData = () => ({
const form = useForm(draftKey, savedFormData());
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(
() =>
JSON.stringify(acceptanceCriteriaItems.value) !==
@@ -364,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>
@@ -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,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'),
};
};
}

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