Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32dac91620 | ||
|
|
39d0887180 | ||
|
|
340e6bfff1 | ||
|
|
f3f1ffdc52 | ||
|
|
2a56d03dbf | ||
|
|
7e19364757 | ||
|
|
de0c19a86b | ||
|
|
ce250d8be5 | ||
|
|
5c2b62de2a | ||
|
|
b5030e6acc | ||
|
|
6224880219 | ||
|
|
19d102d140 | ||
|
|
e70b6b4de0 | ||
|
|
d9a1288d73 | ||
|
|
1d9df6897d |
@@ -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
|
## Wayfinder + Inertia
|
||||||
|
|
||||||
Use Wayfinder with the `<Form>` component:
|
Use Wayfinder with `useForm`:
|
||||||
<!-- Wayfinder Form (Vue) -->
|
|
||||||
```vue
|
<!-- Wayfinder useForm -->
|
||||||
<Form v-bind="store.form()"><input name="title" /></Form>
|
```typescript
|
||||||
|
import { store } from "@/actions/App/Http/Controllers/ExampleController";
|
||||||
|
|
||||||
|
const form = useForm({ name: "My Big Post" });
|
||||||
|
form.submit(store());
|
||||||
```
|
```
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|||||||
@@ -1,18 +1,78 @@
|
|||||||
name: test_and_check_app
|
name: "Lint and Test APP"
|
||||||
description: Build the test image and run all application checks
|
description: "Run Pint and the Pest test suite"
|
||||||
|
|
||||||
runs:
|
runs:
|
||||||
using: composite
|
using: composite
|
||||||
steps:
|
steps:
|
||||||
- name: Test and check app
|
- name: Setup PHP
|
||||||
shell: bash
|
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 #v2.37.2
|
||||||
run: |
|
with:
|
||||||
set -Eeuo pipefail
|
php-version: "8.5"
|
||||||
|
extensions: mbstring, pdo_sqlite
|
||||||
|
coverage: none
|
||||||
|
tools: composer:v2
|
||||||
|
|
||||||
docker buildx build \
|
- name: Verify Composer lockfile
|
||||||
--file Dockerfile \
|
run: |
|
||||||
--target test \
|
if [ ! -f composer.lock ]; then
|
||||||
--tag "local/kanbangit-test:${GITHUB_SHA}" \
|
echo "::error::composer.lock not found"
|
||||||
--progress plain \
|
exit 1
|
||||||
--load \
|
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
|
||||||
|
|||||||
@@ -15,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.
|
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
|
- inertiajs/inertia-laravel (INERTIA_LARAVEL) - v2
|
||||||
- laravel/fortify (FORTIFY) - v1
|
- laravel/fortify (FORTIFY) - v1
|
||||||
- laravel/framework (LARAVEL) - v13
|
- laravel/framework (LARAVEL) - v13
|
||||||
@@ -27,12 +27,6 @@ This application is a Laravel application and its main Laravel ecosystems packag
|
|||||||
- laravel/pint (PINT) - v1
|
- laravel/pint (PINT) - v1
|
||||||
- laravel/sail (SAIL) - v1
|
- laravel/sail (SAIL) - v1
|
||||||
- phpunit/phpunit (PHPUNIT) - v12
|
- 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
|
## Skills Activation
|
||||||
|
|
||||||
@@ -42,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.
|
- `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.
|
- `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
|
- `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
|
## Conventions
|
||||||
|
|
||||||
@@ -62,7 +54,7 @@ This project has domain-specific skills available. You MUST activate the relevan
|
|||||||
|
|
||||||
## Frontend Bundling
|
## 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
|
## Documentation Files
|
||||||
|
|
||||||
@@ -136,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.
|
- 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.
|
- 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.
|
- 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
|
# Inertia v2
|
||||||
|
|
||||||
@@ -172,7 +163,7 @@ This project has domain-specific skills available. You MUST activate the relevan
|
|||||||
|
|
||||||
## Vite Error
|
## 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
|
## Deployment
|
||||||
|
|
||||||
@@ -209,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 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).
|
- 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>
|
</laravel-boost-guidelines>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use App\Models\Project;
|
|||||||
use App\Models\Team;
|
use App\Models\Team;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\DB;
|
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>>
|
* @return array<string, array<int, string>>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -182,25 +182,22 @@ class ProjectController extends Controller
|
|||||||
403,
|
403,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$users = User::query()->orderBy('name')->get();
|
||||||
|
|
||||||
return Inertia::render('projects/Access', [
|
return Inertia::render('projects/Access', [
|
||||||
'directPermissions' => $this->permissionService->assignmentMap(User::class, $project),
|
...$this->permissionService->projectAccessMap($users, $project),
|
||||||
'permissionOptions' => PermissionOptionResource::collection(
|
'permissionOptions' => PermissionOptionResource::collection(
|
||||||
$this->permissionService->permissionOptions(User::class, Project::class),
|
$this->permissionService->permissionOptions(User::class, Project::class),
|
||||||
)->resolve(),
|
)->resolve(),
|
||||||
'project' => ProjectResource::make($project)->resolve(),
|
'project' => ProjectResource::make($project)->resolve(),
|
||||||
'status' => session('status'),
|
'status' => session('status'),
|
||||||
'teamPermissions' => $this->permissionService->assignmentMap(Team::class, $project),
|
|
||||||
'teams' => TeamResource::collection(
|
'teams' => TeamResource::collection(
|
||||||
Team::query()
|
Team::query()
|
||||||
->with('users')
|
->with('users')
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->get(),
|
->get(),
|
||||||
)->resolve(),
|
)->resolve(),
|
||||||
'users' => UserResource::collection(
|
'users' => UserResource::collection($users)->resolve(),
|
||||||
User::query()
|
|
||||||
->orderBy('name')
|
|
||||||
->get(),
|
|
||||||
)->resolve(),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,7 +217,9 @@ class ProjectController extends Controller
|
|||||||
|
|
||||||
$this->permissionService->sync($user, $project, $permissions);
|
$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.");
|
->with('status', "{$user->name} permissions were updated.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,7 +238,9 @@ class ProjectController extends Controller
|
|||||||
|
|
||||||
$this->permissionService->sync($team, $project, $permissions);
|
$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.");
|
->with('status', "{$team->name} permissions were updated.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-3
@@ -10,8 +10,6 @@
|
|||||||
"fortify-development",
|
"fortify-development",
|
||||||
"laravel-best-practices",
|
"laravel-best-practices",
|
||||||
"mcp-development",
|
"mcp-development",
|
||||||
"wayfinder-development",
|
"wayfinder-development"
|
||||||
"inertia-vue-development",
|
|
||||||
"tailwindcss-development"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-2
@@ -20,6 +20,7 @@
|
|||||||
"scalar/laravel": "^0.2.1"
|
"scalar/laravel": "^0.2.1"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
|
"brianium/paratest": "^7.20",
|
||||||
"fakerphp/faker": "^1.24",
|
"fakerphp/faker": "^1.24",
|
||||||
"laravel/boost": "^2.0",
|
"laravel/boost": "^2.0",
|
||||||
"laravel/pail": "^1.2.5",
|
"laravel/pail": "^1.2.5",
|
||||||
@@ -67,15 +68,27 @@
|
|||||||
],
|
],
|
||||||
"ci:check": [
|
"ci:check": [
|
||||||
"Composer\\Config::disableProcessTimeout",
|
"Composer\\Config::disableProcessTimeout",
|
||||||
|
"@ci:frontend",
|
||||||
|
"@ci:backend",
|
||||||
|
"@ci:test"
|
||||||
|
],
|
||||||
|
"ci:frontend": [
|
||||||
"pnpm audit --audit-level=high",
|
"pnpm audit --audit-level=high",
|
||||||
"pnpm run lint:check",
|
"pnpm run lint:check",
|
||||||
"pnpm run types: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": [
|
"test": [
|
||||||
"@php artisan config:clear --ansi",
|
"@php artisan config:clear --ansi",
|
||||||
"@lint:check",
|
"@lint:check",
|
||||||
"@php artisan test"
|
"@php artisan test --parallel --processes=2"
|
||||||
],
|
],
|
||||||
"post-autoload-dump": [
|
"post-autoload-dump": [
|
||||||
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||||
|
|||||||
Generated
+215
-1
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "7d965721310f81816807d6b78c5f9654",
|
"content-hash": "4d18577105a240f2d8cce1c098398730",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "aws/aws-crt-php",
|
"name": "aws/aws-crt-php",
|
||||||
@@ -7020,6 +7020,99 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"packages-dev": [
|
"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",
|
"name": "fakerphp/faker",
|
||||||
"version": "v1.24.1",
|
"version": "v1.24.1",
|
||||||
@@ -7083,6 +7176,67 @@
|
|||||||
},
|
},
|
||||||
"time": "2024-11-21T13:46:39+00:00"
|
"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",
|
"name": "filp/whoops",
|
||||||
"version": "2.18.4",
|
"version": "2.18.4",
|
||||||
@@ -7205,6 +7359,66 @@
|
|||||||
},
|
},
|
||||||
"time": "2025-04-30T06:54:44+00:00"
|
"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",
|
"name": "laravel/boost",
|
||||||
"version": "v2.4.3",
|
"version": "v2.4.3",
|
||||||
|
|||||||
@@ -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,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>
|
||||||
@@ -1,4 +1,58 @@
|
|||||||
export const en = {
|
export const en = {
|
||||||
|
'projectAccess.pageTitle': '{project} · Project access',
|
||||||
|
'projectAccess.title': 'Project access',
|
||||||
|
'projectAccess.description':
|
||||||
|
'Manage who can access {project} and see where their permissions come from.',
|
||||||
|
'projectAccess.back': 'Back to project',
|
||||||
|
'projectAccess.saved': 'Project permissions saved.',
|
||||||
|
'projectAccess.howItWorks': 'How project access works',
|
||||||
|
'projectAccess.additiveHelp':
|
||||||
|
'Direct user permissions and permissions from every team are combined. Write includes read; administrator includes write and read.',
|
||||||
|
'projectAccess.limitsHelp':
|
||||||
|
'Project administrators can change project assignments here. Global permissions and team membership are managed separately.',
|
||||||
|
'projectAccess.search': 'Search people or teams',
|
||||||
|
'projectAccess.people': 'People and effective access',
|
||||||
|
'projectAccess.effective': 'Effective permissions',
|
||||||
|
'projectAccess.noAccess': 'No project access',
|
||||||
|
'projectAccess.globalSource':
|
||||||
|
'Global administrator · all project permissions',
|
||||||
|
'projectAccess.direct': 'Direct:',
|
||||||
|
'projectAccess.none': 'None',
|
||||||
|
'projectAccess.viaTeam': 'Via {name}:',
|
||||||
|
'projectAccess.editFor': 'Edit access for {name}',
|
||||||
|
'projectAccess.editDirect': 'Edit direct access',
|
||||||
|
'projectAccess.editTeam': 'Edit team access',
|
||||||
|
'projectAccess.teamPermissions': 'Team permissions',
|
||||||
|
'projectAccess.directPermissions': 'Direct permissions',
|
||||||
|
'projectAccess.teamHelp':
|
||||||
|
'These project permissions apply to every current and future member of the team.',
|
||||||
|
'projectAccess.directHelp':
|
||||||
|
'Change only this person’s direct project permissions. Team and global permissions continue to apply.',
|
||||||
|
'projectAccess.noPeopleResults': 'No matching people',
|
||||||
|
'projectAccess.noPeople': 'No people available',
|
||||||
|
'projectAccess.noTeamResults': 'No matching teams',
|
||||||
|
'projectAccess.noTeams': 'No teams yet',
|
||||||
|
'projectAccess.noTeamsHelp':
|
||||||
|
'Create a team and add members before assigning shared project access.',
|
||||||
|
'projectAccess.noTeamAccess': 'No project permissions assigned',
|
||||||
|
'projectAccess.read': 'Read',
|
||||||
|
'projectAccess.write': 'Write',
|
||||||
|
'projectAccess.admin': 'Administrator',
|
||||||
|
'projectAccess.readHelp': 'View the project and its tickets.',
|
||||||
|
'projectAccess.writeHelp': 'Create and edit tickets. Includes read access.',
|
||||||
|
'projectAccess.adminHelp':
|
||||||
|
'Manage project access and project administration. Includes write and read access.',
|
||||||
|
'projectAccess.clearHelp':
|
||||||
|
'Clear all selections to remove this assignment. Access granted by other teams, direct assignments or global administration is preserved.',
|
||||||
|
'projectAccess.globalHelp':
|
||||||
|
'This person is a global administrator. Changing these project assignments cannot remove their global access.',
|
||||||
|
'projectAccess.selfHelp':
|
||||||
|
'If you remove your own administrator access, you will return to Projects.',
|
||||||
|
'projectAccess.save': 'Save permissions',
|
||||||
|
'projectAccess.forbidden': 'Project access can no longer be changed',
|
||||||
|
'projectAccess.forbiddenHelp':
|
||||||
|
'Your administrator permission may have changed. Close this dialog and reopen the project to check your current access.',
|
||||||
|
|
||||||
'notifications.title': 'Notifications',
|
'notifications.title': 'Notifications',
|
||||||
'notifications.pageTitle': '{project} · Notifications',
|
'notifications.pageTitle': '{project} · Notifications',
|
||||||
'notifications.description':
|
'notifications.description':
|
||||||
@@ -172,6 +226,52 @@ export const en = {
|
|||||||
'templates.deleteImpact':
|
'templates.deleteImpact':
|
||||||
'{count} linked projects will stop inheriting from this template. Their current configuration and tickets are kept.',
|
'{count} linked projects will stop inheriting from this template. Their current configuration and tickets are kept.',
|
||||||
|
|
||||||
|
'teams.eyebrow': 'People & access',
|
||||||
|
'teams.description':
|
||||||
|
'Bring people together in reusable teams. Manage membership and the permissions to administer each team.',
|
||||||
|
'teams.create': 'Create team',
|
||||||
|
'teams.createDescription':
|
||||||
|
'Group people into a team to share project access.',
|
||||||
|
'teams.name': 'Team name',
|
||||||
|
'teams.namePlaceholder': 'Platform team',
|
||||||
|
'teams.yourTeams': 'Teams you manage',
|
||||||
|
'teams.search': 'Search teams',
|
||||||
|
'teams.clearSearch': 'Clear search',
|
||||||
|
'teams.empty': 'No teams to manage',
|
||||||
|
'teams.emptyCreate': 'Create a team to start organizing your people.',
|
||||||
|
'teams.emptyDescription':
|
||||||
|
'No teams are available for your current permissions.',
|
||||||
|
'teams.noResults': 'No matching teams',
|
||||||
|
'teams.noResultsDescription': 'Try another team name or clear your search.',
|
||||||
|
'teams.manageMembers': 'Manage members',
|
||||||
|
'teams.manageMembersFor': 'Manage members of {name}',
|
||||||
|
'teams.permissionsFor': 'Manage permissions for {name}',
|
||||||
|
'teams.members': 'Members',
|
||||||
|
'teams.noMembers': 'No members yet.',
|
||||||
|
'teams.membersHelp':
|
||||||
|
'Select the people who belong to this team. Changes apply when you save.',
|
||||||
|
'teams.selected': '{count} selected',
|
||||||
|
'teams.searchPeople': 'Find people',
|
||||||
|
'teams.searchPeoplePlaceholder': 'Search by name or email',
|
||||||
|
'teams.noPeople': 'No matching people.',
|
||||||
|
'teams.saveMembers': 'Save members',
|
||||||
|
'teams.permissions': 'Team permissions',
|
||||||
|
'teams.permissionsHelp':
|
||||||
|
'Direct team permissions are separate from membership. Choose any user to review or change their assignments.',
|
||||||
|
'teams.person': 'User',
|
||||||
|
'teams.choosePerson': 'Choose a user',
|
||||||
|
'teams.directPermissions': 'Direct permissions',
|
||||||
|
'teams.permissionInvite': 'Team invite',
|
||||||
|
'teams.permissionManage': 'Team manage',
|
||||||
|
'teams.permissionAdmin': 'Team admin',
|
||||||
|
'teams.permissionsClearHelp':
|
||||||
|
'Clear every checkbox to remove direct permissions. Other existing access remains in effect.',
|
||||||
|
'teams.savePermissions': 'Save permissions',
|
||||||
|
'teams.updated': 'Team changes saved.',
|
||||||
|
'teams.forbidden': 'You no longer have permission',
|
||||||
|
'teams.forbiddenDescription':
|
||||||
|
'Your changes have not been saved. Close this dialog and reload the page to check your current access.',
|
||||||
|
'teams.memberCount': { one: '{count} member', other: '{count} members' },
|
||||||
'workflow.title': 'Board workflow',
|
'workflow.title': 'Board workflow',
|
||||||
'workflow.pageTitle': '{project} · Board workflow',
|
'workflow.pageTitle': '{project} · Board workflow',
|
||||||
'workflow.description':
|
'workflow.description':
|
||||||
|
|||||||
@@ -1,308 +1,344 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form, Head, Link } from '@inertiajs/vue3';
|
import { Head, Link, usePage } from '@inertiajs/vue3';
|
||||||
import ProjectController from '@/actions/App/Http/Controllers/ProjectController';
|
import { ArrowLeft, Search, ShieldCheck, Users } from 'lucide-vue-next';
|
||||||
import Heading from '@/components/Heading.vue';
|
import { computed, ref } from 'vue';
|
||||||
|
import FeedbackState from '@/components/FeedbackState.vue';
|
||||||
|
import ProjectAccessEditorDialog from '@/components/projects/ProjectAccessEditorDialog.vue';
|
||||||
|
import ProjectPermissionBadges from '@/components/projects/ProjectPermissionBadges.vue';
|
||||||
|
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import { Input } from '@/components/ui/input';
|
||||||
Card,
|
import { Label } from '@/components/ui/label';
|
||||||
CardContent,
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@/components/ui/card';
|
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import { access, index, show } from '@/routes/projects/index';
|
import { access, index, show } from '@/routes/projects';
|
||||||
import { general as generalSettings } from '@/routes/projects/settings';
|
import { general } from '@/routes/projects/settings';
|
||||||
import type { BreadcrumbItem, ProjectAccessPageProps } from '@/types';
|
import type { BreadcrumbItem, ProjectAccessPageProps } from '@/types';
|
||||||
|
import type { ProjectAccessTarget } from '@/types/project-access';
|
||||||
|
|
||||||
const props = defineProps<ProjectAccessPageProps>();
|
const props = defineProps<ProjectAccessPageProps>();
|
||||||
|
const page = usePage();
|
||||||
const breadcrumbs: BreadcrumbItem[] = [
|
const { t } = useLocalization();
|
||||||
{
|
const search = ref('');
|
||||||
title: 'Projects',
|
const target = ref<ProjectAccessTarget | null>(null);
|
||||||
href: index(),
|
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||||
},
|
{ title: t('navigation.projects'), href: index() },
|
||||||
{
|
{ title: props.project.name, href: show(props.project.id) },
|
||||||
title: props.project.name,
|
{ title: t('navigation.access'), href: access(props.project.id) },
|
||||||
href: show(props.project.id),
|
]);
|
||||||
},
|
const matches = (value: string) =>
|
||||||
{
|
value.toLocaleLowerCase().includes(search.value.trim().toLocaleLowerCase());
|
||||||
title: 'Access',
|
const visibleUsers = computed(() =>
|
||||||
href: access(props.project.id),
|
props.users.filter((user) => matches(`${user.name} ${user.email}`)),
|
||||||
},
|
);
|
||||||
];
|
const visibleTeams = computed(() =>
|
||||||
|
props.teams.filter((team) => matches(team.name)),
|
||||||
const hasPermission = (
|
);
|
||||||
permissions: Record<string, string[]>,
|
const inheritedTeams = (userId: number) =>
|
||||||
actorId: number,
|
props.teams.filter(
|
||||||
permission: string,
|
(team) =>
|
||||||
) => (permissions[actorId] ?? []).includes(permission);
|
team.user_ids.includes(userId) &&
|
||||||
|
(props.teamPermissions[team.id] ?? []).length > 0,
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbs">
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
<Head :title="`${project.name} access`" />
|
<Head
|
||||||
|
:title="t('projectAccess.pageTitle', { project: project.name })"
|
||||||
<div class="flex flex-1 flex-col gap-6 p-4">
|
/>
|
||||||
<div
|
<main
|
||||||
|
class="mx-auto flex w-full min-w-0 max-w-7xl flex-1 flex-col gap-6 p-4 sm:p-6 lg:p-8"
|
||||||
|
>
|
||||||
|
<header
|
||||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||||
>
|
>
|
||||||
<div class="space-y-3">
|
<div class="grid min-w-0 gap-2">
|
||||||
<Badge variant="outline" class="px-3 py-1">
|
<p
|
||||||
|
class="text-primary break-all text-xs font-semibold uppercase tracking-wider"
|
||||||
|
>
|
||||||
{{ project.key }}
|
{{ project.key }}
|
||||||
</Badge>
|
</p>
|
||||||
<Heading
|
<h1 class="text-headline-mobile sm:text-headline">
|
||||||
:title="`${project.name} access`"
|
{{ t('projectAccess.title') }}
|
||||||
description="Assign direct user permissions or inherited team permissions for this project."
|
</h1>
|
||||||
/>
|
<p
|
||||||
|
class="text-muted-foreground max-w-2xl break-words text-sm"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
t('projectAccess.description', {
|
||||||
|
project: project.name,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex shrink-0 flex-wrap gap-2">
|
||||||
<div class="flex flex-wrap gap-3">
|
|
||||||
<Button
|
<Button
|
||||||
v-if="$page.props.auth.permissions.globalAdmin"
|
v-if="page.props.auth.permissions.globalAdmin"
|
||||||
as-child
|
as-child
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
><Link :href="general(project.id)">{{
|
||||||
|
t('navigation.general')
|
||||||
|
}}</Link></Button
|
||||||
|
>
|
||||||
|
<Button as-child variant="outline"
|
||||||
|
><Link :href="show(project.id)"
|
||||||
|
><ArrowLeft class="size-4" aria-hidden="true" />{{
|
||||||
|
t('projectAccess.back')
|
||||||
|
}}</Link
|
||||||
|
></Button
|
||||||
>
|
>
|
||||||
<Link :href="generalSettings(project.id)">
|
|
||||||
General settings
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button as-child variant="outline">
|
|
||||||
<Link :href="show(project.id)">Back to project</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
|
<FeedbackState
|
||||||
<div
|
|
||||||
v-if="status"
|
v-if="status"
|
||||||
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
|
state="success"
|
||||||
|
:title="t('projectAccess.saved')"
|
||||||
|
/>
|
||||||
|
<aside
|
||||||
|
class="bg-accent/60 flex items-start gap-3 rounded-xl border p-4"
|
||||||
|
:aria-label="t('projectAccess.howItWorks')"
|
||||||
>
|
>
|
||||||
{{ status }}
|
<ShieldCheck
|
||||||
|
class="text-primary mt-0.5 size-5 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div class="grid gap-1">
|
||||||
|
<h2 class="text-sm font-semibold">
|
||||||
|
{{ t('projectAccess.howItWorks') }}
|
||||||
|
</h2>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
{{ t('projectAccess.additiveHelp') }}
|
||||||
|
</p>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
{{ t('projectAccess.limitsHelp') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<div class="relative sm:max-w-md">
|
||||||
|
<Label for="access-search" class="sr-only">{{
|
||||||
|
t('projectAccess.search')
|
||||||
|
}}</Label
|
||||||
|
><Search
|
||||||
|
class="text-muted-foreground pointer-events-none absolute left-3 top-3 size-4"
|
||||||
|
aria-hidden="true"
|
||||||
|
/><Input
|
||||||
|
id="access-search"
|
||||||
|
v-model="search"
|
||||||
|
type="search"
|
||||||
|
class="pl-9"
|
||||||
|
:placeholder="t('projectAccess.search')"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<section
|
||||||
<Card class="gap-0 overflow-hidden">
|
class="grid min-w-0 gap-4"
|
||||||
<CardHeader class="border-b">
|
aria-labelledby="people-heading"
|
||||||
<CardTitle>User permissions</CardTitle>
|
>
|
||||||
<CardDescription>
|
<div class="flex items-center gap-2">
|
||||||
Direct assignments apply only to the selected user
|
<h2 id="people-heading" class="text-lg font-semibold">
|
||||||
inside this project.
|
{{ t('projectAccess.people') }}
|
||||||
</CardDescription>
|
</h2>
|
||||||
</CardHeader>
|
<Badge variant="secondary">{{ users.length }}</Badge>
|
||||||
|
</div>
|
||||||
<CardContent class="p-0">
|
<FeedbackState
|
||||||
<div class="overflow-x-auto">
|
v-if="visibleUsers.length === 0"
|
||||||
<table
|
state="empty"
|
||||||
class="divide-border min-w-full divide-y text-sm"
|
:title="
|
||||||
>
|
t(
|
||||||
<thead
|
users.length
|
||||||
class="bg-muted/40 text-muted-foreground text-left"
|
? 'projectAccess.noPeopleResults'
|
||||||
>
|
: 'projectAccess.noPeople',
|
||||||
<tr>
|
)
|
||||||
<th class="px-4 py-3 font-medium">User</th>
|
"
|
||||||
<th class="px-4 py-3 font-medium">Email</th>
|
/>
|
||||||
<th class="px-4 py-3 font-medium">
|
<ul
|
||||||
Permissions
|
v-else
|
||||||
</th>
|
class="bg-card min-w-0 divide-y rounded-xl border"
|
||||||
<th
|
:aria-label="t('projectAccess.people')"
|
||||||
class="px-4 py-3 text-right font-medium"
|
>
|
||||||
>
|
<li
|
||||||
Action
|
v-for="user in visibleUsers"
|
||||||
</th>
|
:key="user.id"
|
||||||
</tr>
|
class="grid min-w-0 gap-4 p-4 sm:p-5 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.3fr)_auto] lg:items-center"
|
||||||
</thead>
|
:data-user-id="user.id"
|
||||||
<tbody class="divide-border divide-y">
|
|
||||||
<tr
|
|
||||||
v-for="user in users"
|
|
||||||
:key="user.id"
|
|
||||||
class="bg-background align-top"
|
|
||||||
>
|
|
||||||
<td
|
|
||||||
class="text-foreground px-4 py-4 font-medium"
|
|
||||||
>
|
|
||||||
{{ user.name }}
|
|
||||||
</td>
|
|
||||||
<td class="text-muted-foreground px-4 py-4">
|
|
||||||
{{ user.email }}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-4">
|
|
||||||
<Form
|
|
||||||
v-bind="
|
|
||||||
ProjectController.updateUserPermissions.form(
|
|
||||||
[project.id, user.id],
|
|
||||||
)
|
|
||||||
"
|
|
||||||
class="flex flex-wrap gap-3"
|
|
||||||
v-slot="{ processing }"
|
|
||||||
>
|
|
||||||
<label
|
|
||||||
v-for="option in permissionOptions"
|
|
||||||
:key="`${user.id}-${option.value}`"
|
|
||||||
class="border-border inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
name="permissions[]"
|
|
||||||
:value="option.value"
|
|
||||||
:checked="
|
|
||||||
hasPermission(
|
|
||||||
directPermissions,
|
|
||||||
user.id,
|
|
||||||
option.value,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
class="border-input text-primary focus:ring-ring size-4 rounded"
|
|
||||||
/>
|
|
||||||
<span>{{ option.label }}</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div class="w-full sm:hidden" />
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
:disabled="processing"
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
class="text-muted-foreground px-4 py-4 text-right"
|
|
||||||
>
|
|
||||||
Direct
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card class="gap-0 overflow-hidden">
|
|
||||||
<CardHeader class="border-b">
|
|
||||||
<CardTitle>Team permissions</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Team assignments are inherited by every current and
|
|
||||||
future member of that team.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent class="p-0">
|
|
||||||
<div
|
|
||||||
v-if="teams.length === 0"
|
|
||||||
class="text-muted-foreground p-6 text-sm"
|
|
||||||
>
|
>
|
||||||
No teams exist yet. Create teams first to assign
|
<div class="flex min-w-0 items-center gap-3">
|
||||||
inherited permissions.
|
<UserAvatar :user="user" class="size-10 shrink-0" />
|
||||||
</div>
|
<div class="grid min-w-0 gap-1">
|
||||||
|
<h3 class="break-words text-sm font-semibold">
|
||||||
<div v-else class="overflow-x-auto">
|
{{ user.name }}
|
||||||
<table
|
</h3>
|
||||||
class="divide-border min-w-full divide-y text-sm"
|
<p
|
||||||
>
|
class="text-muted-foreground break-all text-xs"
|
||||||
<thead
|
|
||||||
class="bg-muted/40 text-muted-foreground text-left"
|
|
||||||
>
|
|
||||||
<tr>
|
|
||||||
<th class="px-4 py-3 font-medium">Team</th>
|
|
||||||
<th class="px-4 py-3 font-medium">
|
|
||||||
Members
|
|
||||||
</th>
|
|
||||||
<th class="px-4 py-3 font-medium">
|
|
||||||
Permissions
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
class="px-4 py-3 text-right font-medium"
|
|
||||||
>
|
|
||||||
Action
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-border divide-y">
|
|
||||||
<tr
|
|
||||||
v-for="team in teams"
|
|
||||||
:key="team.id"
|
|
||||||
class="bg-background align-top"
|
|
||||||
>
|
>
|
||||||
<td
|
{{ user.email }}
|
||||||
class="text-foreground px-4 py-4 font-medium"
|
</p>
|
||||||
>
|
</div>
|
||||||
{{ team.name }}
|
</div>
|
||||||
</td>
|
<div class="grid min-w-0 gap-2">
|
||||||
<td class="px-4 py-4">
|
<p
|
||||||
<div class="flex flex-wrap gap-2">
|
class="text-muted-foreground text-xs font-medium"
|
||||||
<Badge
|
>
|
||||||
v-for="member in team.users"
|
{{ t('projectAccess.effective') }}
|
||||||
:key="member.id"
|
</p>
|
||||||
variant="secondary"
|
<ProjectPermissionBadges
|
||||||
>
|
:permissions="
|
||||||
{{ member.name }}
|
effectivePermissions[user.id] ?? []
|
||||||
</Badge>
|
"
|
||||||
<span
|
:empty-label="t('projectAccess.noAccess')"
|
||||||
v-if="team.users.length === 0"
|
/>
|
||||||
class="text-muted-foreground"
|
<div
|
||||||
>
|
class="text-muted-foreground grid gap-1 text-xs"
|
||||||
No members assigned
|
>
|
||||||
</span>
|
<span
|
||||||
</div>
|
v-if="globalAdminUserIds.includes(user.id)"
|
||||||
</td>
|
>{{ t('projectAccess.globalSource') }}</span
|
||||||
<td class="px-4 py-4">
|
>
|
||||||
<Form
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
v-bind="
|
<span>{{ t('projectAccess.direct') }}</span
|
||||||
ProjectController.updateTeamPermissions.form(
|
><ProjectPermissionBadges
|
||||||
[project.id, team.id],
|
:permissions="
|
||||||
)
|
directPermissions[user.id] ?? []
|
||||||
"
|
"
|
||||||
class="flex flex-wrap gap-3"
|
:empty-label="t('projectAccess.none')"
|
||||||
v-slot="{ processing }"
|
/>
|
||||||
>
|
</div>
|
||||||
<label
|
<div
|
||||||
v-for="option in permissionOptions"
|
v-for="team in inheritedTeams(user.id)"
|
||||||
:key="`${team.id}-${option.value}`"
|
:key="team.id"
|
||||||
class="border-border inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm"
|
class="flex min-w-0 flex-wrap items-center gap-2"
|
||||||
>
|
>
|
||||||
<input
|
<span class="break-words">{{
|
||||||
type="checkbox"
|
t('projectAccess.viaTeam', {
|
||||||
name="permissions[]"
|
name: team.name,
|
||||||
:value="option.value"
|
})
|
||||||
:checked="
|
}}</span
|
||||||
hasPermission(
|
><ProjectPermissionBadges
|
||||||
teamPermissions,
|
:permissions="
|
||||||
team.id,
|
teamPermissions[team.id] ?? []
|
||||||
option.value,
|
"
|
||||||
)
|
:empty-label="t('projectAccess.none')"
|
||||||
"
|
/>
|
||||||
class="border-input text-primary focus:ring-ring size-4 rounded"
|
</div>
|
||||||
/>
|
</div>
|
||||||
<span>{{ option.label }}</span>
|
</div>
|
||||||
</label>
|
<Button
|
||||||
|
type="button"
|
||||||
<div class="w-full sm:hidden" />
|
variant="outline"
|
||||||
|
:aria-label="
|
||||||
<Button
|
t('projectAccess.editFor', { name: user.name })
|
||||||
type="submit"
|
"
|
||||||
size="sm"
|
@click="
|
||||||
variant="outline"
|
target = {
|
||||||
:disabled="processing"
|
kind: 'user',
|
||||||
>
|
id: user.id,
|
||||||
Save
|
name: user.name,
|
||||||
</Button>
|
permissions:
|
||||||
</Form>
|
directPermissions[user.id] ?? [],
|
||||||
</td>
|
globalAdmin: globalAdminUserIds.includes(
|
||||||
<td
|
user.id,
|
||||||
class="text-muted-foreground px-4 py-4 text-right"
|
),
|
||||||
>
|
}
|
||||||
Inherited
|
"
|
||||||
</td>
|
>{{ t('projectAccess.editDirect') }}</Button
|
||||||
</tr>
|
>
|
||||||
</tbody>
|
</li>
|
||||||
</table>
|
</ul>
|
||||||
|
</section>
|
||||||
|
<section class="grid min-w-0 gap-4" aria-labelledby="teams-heading">
|
||||||
|
<div class="grid gap-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<h2 id="teams-heading" class="text-lg font-semibold">
|
||||||
|
{{ t('projectAccess.teamPermissions') }}
|
||||||
|
</h2>
|
||||||
|
<Badge variant="secondary">{{ teams.length }}</Badge>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
<p class="text-muted-foreground text-sm">
|
||||||
</Card>
|
{{ t('projectAccess.teamHelp') }}
|
||||||
</div>
|
</p>
|
||||||
|
</div>
|
||||||
|
<FeedbackState
|
||||||
|
v-if="visibleTeams.length === 0"
|
||||||
|
state="empty"
|
||||||
|
:title="
|
||||||
|
t(
|
||||||
|
teams.length
|
||||||
|
? 'projectAccess.noTeamResults'
|
||||||
|
: 'projectAccess.noTeams',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:description="
|
||||||
|
teams.length
|
||||||
|
? undefined
|
||||||
|
: t('projectAccess.noTeamsHelp')
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<ul
|
||||||
|
v-else
|
||||||
|
class="grid min-w-0 gap-4 lg:grid-cols-2"
|
||||||
|
:aria-label="t('projectAccess.teamPermissions')"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-for="team in visibleTeams"
|
||||||
|
:key="team.id"
|
||||||
|
class="bg-card grid min-w-0 gap-4 rounded-xl border p-5"
|
||||||
|
:data-team-id="team.id"
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 items-center gap-3">
|
||||||
|
<span
|
||||||
|
class="bg-accent text-primary flex size-10 shrink-0 items-center justify-center rounded-lg"
|
||||||
|
><Users class="size-5" aria-hidden="true"
|
||||||
|
/></span>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h3 class="break-words text-sm font-semibold">
|
||||||
|
{{ team.name }}
|
||||||
|
</h3>
|
||||||
|
<p class="text-muted-foreground text-xs">
|
||||||
|
{{
|
||||||
|
t('teams.memberCount', {
|
||||||
|
count: team.users.length,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ProjectPermissionBadges
|
||||||
|
:permissions="teamPermissions[team.id] ?? []"
|
||||||
|
:empty-label="t('projectAccess.noTeamAccess')"
|
||||||
|
/>
|
||||||
|
<p class="text-muted-foreground break-words text-xs">
|
||||||
|
{{
|
||||||
|
team.users
|
||||||
|
.map((user) => user.name)
|
||||||
|
.join(', ') || t('teams.noMembers')
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
:aria-label="
|
||||||
|
t('projectAccess.editFor', { name: team.name })
|
||||||
|
"
|
||||||
|
@click="
|
||||||
|
target = {
|
||||||
|
kind: 'team',
|
||||||
|
id: team.id,
|
||||||
|
name: team.name,
|
||||||
|
permissions: teamPermissions[team.id] ?? [],
|
||||||
|
}
|
||||||
|
"
|
||||||
|
>{{ t('projectAccess.editTeam') }}</Button
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
<ProjectAccessEditorDialog
|
||||||
|
:project-id="project.id"
|
||||||
|
:target="target"
|
||||||
|
:permission-options="permissionOptions"
|
||||||
|
@close="target = null"
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
+184
-336
@@ -1,356 +1,204 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form, Head } from '@inertiajs/vue3';
|
import { Head } from '@inertiajs/vue3';
|
||||||
import { Shield } from 'lucide-vue-next';
|
import { Plus, Search, Shield, Users } from 'lucide-vue-next';
|
||||||
import TeamController from '@/actions/App/Http/Controllers/TeamController';
|
import { computed, ref } from 'vue';
|
||||||
import Heading from '@/components/Heading.vue';
|
import FeedbackState from '@/components/FeedbackState.vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
import TeamEditorDialog from '@/components/teams/TeamEditorDialog.vue';
|
||||||
|
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@/components/ui/card';
|
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import { index as teamsIndex } from '@/routes/teams';
|
import { index } from '@/routes/teams';
|
||||||
import type { BreadcrumbItem, TeamsIndexPageProps } from '@/types';
|
import type { BreadcrumbItem, TeamsIndexPageProps } from '@/types';
|
||||||
|
import type { TeamEditorTarget } from '@/types/teams';
|
||||||
|
|
||||||
const props = defineProps<TeamsIndexPageProps>();
|
const props = defineProps<TeamsIndexPageProps>();
|
||||||
|
const { t } = useLocalization();
|
||||||
const breadcrumbs: BreadcrumbItem[] = [
|
const search = ref('');
|
||||||
{
|
const target = ref<TeamEditorTarget | null>(null);
|
||||||
title: 'Teams',
|
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||||
href: teamsIndex(),
|
{ title: t('navigation.teams'), href: index() },
|
||||||
},
|
]);
|
||||||
];
|
const visibleTeams = computed(() =>
|
||||||
|
props.teams.filter((team) =>
|
||||||
const hasPermission = (
|
team.name
|
||||||
permissions: Record<string, string[]>,
|
.toLocaleLowerCase()
|
||||||
actorId: number,
|
.includes(search.value.trim().toLocaleLowerCase()),
|
||||||
permission: string,
|
),
|
||||||
) => (permissions[actorId] ?? []).includes(permission);
|
);
|
||||||
|
|
||||||
const permissionsForTeam = (teamId: number): Record<string, string[]> =>
|
|
||||||
props.teamPermissions[teamId] ?? {};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbs">
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
<Head title="Teams" />
|
<Head :title="t('navigation.teams')" />
|
||||||
|
<main
|
||||||
<div class="flex flex-1 flex-col gap-6 p-4">
|
class="mx-auto flex w-full max-w-7xl flex-1 flex-col gap-6 p-4 sm:p-6 lg:p-8"
|
||||||
<div
|
>
|
||||||
|
<header
|
||||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||||
>
|
>
|
||||||
<Heading
|
<div class="grid min-w-0 gap-2">
|
||||||
title="Teams"
|
<p
|
||||||
description="Create reusable teams, manage memberships, and assign team-scoped permissions to users."
|
class="text-primary text-xs font-semibold uppercase tracking-wider"
|
||||||
/>
|
|
||||||
|
|
||||||
<Badge variant="outline" class="gap-1.5 px-3 py-1">
|
|
||||||
<Shield class="size-3.5" />
|
|
||||||
{{ canCreateTeams ? 'Global admin' : 'Team-scoped access' }}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="status"
|
|
||||||
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
|
|
||||||
>
|
|
||||||
{{ status }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="grid gap-6"
|
|
||||||
:class="
|
|
||||||
canCreateTeams
|
|
||||||
? 'xl:grid-cols-[22rem_minmax(0,1fr)]'
|
|
||||||
: 'xl:grid-cols-1'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<Card v-if="canCreateTeams">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Create team</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Teams group users so project permissions can be
|
|
||||||
granted once and inherited by every member.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent>
|
|
||||||
<Form
|
|
||||||
v-bind="TeamController.store.form()"
|
|
||||||
class="space-y-4"
|
|
||||||
v-slot="{ errors, processing }"
|
|
||||||
>
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="name">Team name</Label>
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
name="name"
|
|
||||||
required
|
|
||||||
autofocus
|
|
||||||
placeholder="Platform Team"
|
|
||||||
/>
|
|
||||||
<InputError :message="errors.name" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button type="submit" :disabled="processing">
|
|
||||||
Create team
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div class="grid gap-4">
|
|
||||||
<Card
|
|
||||||
v-for="team in teams"
|
|
||||||
:key="team.id"
|
|
||||||
class="gap-0 overflow-hidden"
|
|
||||||
>
|
>
|
||||||
<CardHeader class="border-b">
|
{{ t('teams.eyebrow') }}
|
||||||
<div
|
</p>
|
||||||
class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between"
|
<h1 class="text-headline-mobile sm:text-headline">
|
||||||
>
|
{{ t('navigation.teams') }}
|
||||||
<div>
|
</h1>
|
||||||
<CardTitle>{{ team.name }}</CardTitle>
|
<p class="text-muted-foreground max-w-2xl text-sm">
|
||||||
<CardDescription>
|
{{ t('teams.description') }}
|
||||||
{{ team.users.length }} member{{
|
</p>
|
||||||
team.users.length === 1 ? '' : 's'
|
</div>
|
||||||
}}
|
<Button
|
||||||
</CardDescription>
|
v-if="canCreateTeams"
|
||||||
</div>
|
type="button"
|
||||||
|
class="shrink-0"
|
||||||
<div class="flex flex-wrap gap-2">
|
@click="target = { kind: 'create' }"
|
||||||
<Badge
|
><Plus class="size-4" aria-hidden="true" />{{
|
||||||
v-for="member in team.users"
|
t('teams.create')
|
||||||
:key="member.id"
|
}}</Button
|
||||||
variant="secondary"
|
>
|
||||||
>
|
</header>
|
||||||
{{ member.name }}
|
<FeedbackState
|
||||||
</Badge>
|
v-if="status"
|
||||||
</div>
|
state="success"
|
||||||
</div>
|
:title="t('teams.updated')"
|
||||||
</CardHeader>
|
/>
|
||||||
|
<div
|
||||||
<CardContent class="space-y-6 pt-6">
|
class="bg-card flex flex-col gap-4 rounded-xl border p-4 sm:flex-row sm:items-center sm:justify-between"
|
||||||
<Form
|
>
|
||||||
v-bind="
|
<div class="flex items-center gap-2">
|
||||||
TeamController.updateMembers.form(team.id)
|
<Users class="text-primary size-5" aria-hidden="true" />
|
||||||
"
|
<h2 class="font-semibold">{{ t('teams.yourTeams') }}</h2>
|
||||||
class="space-y-4"
|
<Badge variant="secondary">{{ teams.length }}</Badge>
|
||||||
v-slot="{ processing }"
|
</div>
|
||||||
>
|
<div class="relative sm:w-80">
|
||||||
<div class="grid gap-2">
|
<Label for="team-search" class="sr-only">{{
|
||||||
<Label :for="`team-members-${team.id}`">
|
t('teams.search')
|
||||||
Members
|
}}</Label
|
||||||
</Label>
|
><Search
|
||||||
<select
|
class="text-muted-foreground pointer-events-none absolute left-3 top-3 size-4"
|
||||||
:id="`team-members-${team.id}`"
|
aria-hidden="true"
|
||||||
name="user_ids[]"
|
/><Input
|
||||||
multiple
|
id="team-search"
|
||||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 min-h-44 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
|
v-model="search"
|
||||||
>
|
type="search"
|
||||||
<option
|
class="pl-9"
|
||||||
v-for="user in users"
|
:placeholder="t('teams.search')"
|
||||||
:key="user.id"
|
/>
|
||||||
:value="user.id"
|
|
||||||
:selected="
|
|
||||||
team.user_ids.includes(user.id)
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{{ user.name }} ({{ user.email }})
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<p class="text-muted-foreground text-sm">
|
|
||||||
Hold Cmd or Ctrl to select multiple
|
|
||||||
users.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="outline"
|
|
||||||
:disabled="processing"
|
|
||||||
>
|
|
||||||
Save members
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
|
|
||||||
<div class="space-y-4 border-t pt-6">
|
|
||||||
<div class="space-y-1">
|
|
||||||
<h3 class="text-foreground font-medium">
|
|
||||||
Team permissions
|
|
||||||
</h3>
|
|
||||||
<p class="text-muted-foreground text-sm">
|
|
||||||
Direct user permissions on this team are
|
|
||||||
stored separately from team membership.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table
|
|
||||||
class="divide-border min-w-full divide-y text-sm"
|
|
||||||
>
|
|
||||||
<thead
|
|
||||||
class="bg-muted/40 text-muted-foreground text-left"
|
|
||||||
>
|
|
||||||
<tr>
|
|
||||||
<th
|
|
||||||
class="px-4 py-3 font-medium"
|
|
||||||
>
|
|
||||||
User
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
class="px-4 py-3 font-medium"
|
|
||||||
>
|
|
||||||
Current permissions
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
class="px-4 py-3 text-right font-medium"
|
|
||||||
>
|
|
||||||
Action
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-border divide-y">
|
|
||||||
<tr
|
|
||||||
v-for="user in users"
|
|
||||||
:key="`${team.id}-${user.id}`"
|
|
||||||
class="bg-background align-top"
|
|
||||||
>
|
|
||||||
<td class="px-4 py-4">
|
|
||||||
<div
|
|
||||||
class="text-foreground font-medium"
|
|
||||||
>
|
|
||||||
{{ user.name }}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="text-muted-foreground text-xs"
|
|
||||||
>
|
|
||||||
{{ user.email }}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-4">
|
|
||||||
<div
|
|
||||||
class="flex flex-wrap gap-2"
|
|
||||||
>
|
|
||||||
<Badge
|
|
||||||
v-for="option in permissionOptions"
|
|
||||||
v-show="
|
|
||||||
hasPermission(
|
|
||||||
permissionsForTeam(
|
|
||||||
team.id,
|
|
||||||
),
|
|
||||||
user.id,
|
|
||||||
option.value,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
:key="`${team.id}-${user.id}-${option.value}-badge`"
|
|
||||||
variant="secondary"
|
|
||||||
>
|
|
||||||
{{ option.label }}
|
|
||||||
</Badge>
|
|
||||||
<span
|
|
||||||
v-if="
|
|
||||||
(
|
|
||||||
permissionsForTeam(
|
|
||||||
team.id,
|
|
||||||
)[
|
|
||||||
user.id
|
|
||||||
] ?? []
|
|
||||||
).length === 0
|
|
||||||
"
|
|
||||||
class="text-muted-foreground"
|
|
||||||
>
|
|
||||||
No direct team
|
|
||||||
permissions
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-4">
|
|
||||||
<div
|
|
||||||
class="flex justify-end"
|
|
||||||
>
|
|
||||||
<Form
|
|
||||||
v-bind="
|
|
||||||
TeamController.updateUserPermissions.form(
|
|
||||||
[
|
|
||||||
team.id,
|
|
||||||
user.id,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
"
|
|
||||||
class="flex flex-wrap justify-end gap-3"
|
|
||||||
v-slot="{
|
|
||||||
processing,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<label
|
|
||||||
v-for="option in permissionOptions"
|
|
||||||
:key="`${team.id}-${user.id}-${option.value}`"
|
|
||||||
class="border-border inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
name="permissions[]"
|
|
||||||
:value="
|
|
||||||
option.value
|
|
||||||
"
|
|
||||||
:checked="
|
|
||||||
hasPermission(
|
|
||||||
permissionsForTeam(
|
|
||||||
team.id,
|
|
||||||
),
|
|
||||||
user.id,
|
|
||||||
option.value,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
class="border-input text-primary focus:ring-ring size-4 rounded"
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
{{
|
|
||||||
option.label
|
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="w-full sm:hidden"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
:disabled="
|
|
||||||
processing
|
|
||||||
"
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card v-if="teams.length === 0">
|
|
||||||
<CardContent class="text-muted-foreground p-6 text-sm">
|
|
||||||
No teams are available for your current permissions.
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<FeedbackState
|
||||||
|
v-if="teams.length === 0"
|
||||||
|
state="empty"
|
||||||
|
:title="t('teams.empty')"
|
||||||
|
:description="
|
||||||
|
t(
|
||||||
|
canCreateTeams
|
||||||
|
? 'teams.emptyCreate'
|
||||||
|
: 'teams.emptyDescription',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<FeedbackState
|
||||||
|
v-else-if="visibleTeams.length === 0"
|
||||||
|
state="empty"
|
||||||
|
:title="t('teams.noResults')"
|
||||||
|
:description="t('teams.noResultsDescription')"
|
||||||
|
><Button type="button" variant="outline" @click="search = ''">{{
|
||||||
|
t('teams.clearSearch')
|
||||||
|
}}</Button></FeedbackState
|
||||||
|
>
|
||||||
|
<ul
|
||||||
|
v-else
|
||||||
|
class="grid min-w-0 gap-4 lg:grid-cols-2"
|
||||||
|
:aria-label="t('teams.yourTeams')"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-for="team in visibleTeams"
|
||||||
|
:key="team.id"
|
||||||
|
class="bg-card flex min-w-0 flex-col rounded-xl border"
|
||||||
|
:data-team-id="team.id"
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 items-start gap-3 p-5">
|
||||||
|
<span
|
||||||
|
class="bg-accent text-primary flex size-11 shrink-0 items-center justify-center rounded-lg"
|
||||||
|
><Users class="size-5" aria-hidden="true"
|
||||||
|
/></span>
|
||||||
|
<div class="grid min-w-0 gap-1">
|
||||||
|
<h3 class="break-words font-semibold">
|
||||||
|
{{ team.name }}
|
||||||
|
</h3>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
{{
|
||||||
|
t('teams.memberCount', {
|
||||||
|
count: team.users.length,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 px-5 pb-5">
|
||||||
|
<ul
|
||||||
|
v-if="team.users.length"
|
||||||
|
class="flex flex-wrap gap-2"
|
||||||
|
:aria-label="t('teams.members')"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-for="member in team.users"
|
||||||
|
:key="member.id"
|
||||||
|
class="bg-muted/60 flex min-w-0 max-w-full items-center gap-2 rounded-full py-1 pl-1 pr-3"
|
||||||
|
>
|
||||||
|
<UserAvatar
|
||||||
|
:user="member"
|
||||||
|
class="size-6 shrink-0"
|
||||||
|
/><span class="min-w-0 break-words text-xs">{{
|
||||||
|
member.name
|
||||||
|
}}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p v-else class="text-muted-foreground text-sm">
|
||||||
|
{{ t('teams.noMembers') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="bg-muted/20 grid gap-2 border-t p-4 sm:grid-cols-2"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
:aria-label="
|
||||||
|
t('teams.manageMembersFor', { name: team.name })
|
||||||
|
"
|
||||||
|
@click="target = { kind: 'members', team }"
|
||||||
|
><Users class="size-4" aria-hidden="true" />{{
|
||||||
|
t('teams.manageMembers')
|
||||||
|
}}</Button
|
||||||
|
><Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
:aria-label="
|
||||||
|
t('teams.permissionsFor', { name: team.name })
|
||||||
|
"
|
||||||
|
@click="target = { kind: 'permissions', team }"
|
||||||
|
><Shield class="size-4" aria-hidden="true" />{{
|
||||||
|
t('teams.permissions')
|
||||||
|
}}</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</main>
|
||||||
|
<TeamEditorDialog
|
||||||
|
:target="target"
|
||||||
|
:users="users"
|
||||||
|
:permission-options="permissionOptions"
|
||||||
|
:team-permissions="teamPermissions"
|
||||||
|
@close="target = null"
|
||||||
|
/>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -117,6 +117,8 @@ export type PermissionOption = {
|
|||||||
|
|
||||||
export type ProjectAccessPageProps = {
|
export type ProjectAccessPageProps = {
|
||||||
directPermissions: Record<string, string[]>;
|
directPermissions: Record<string, string[]>;
|
||||||
|
effectivePermissions: Record<string, string[]>;
|
||||||
|
globalAdminUserIds: number[];
|
||||||
permissionOptions: PermissionOption[];
|
permissionOptions: PermissionOption[];
|
||||||
project: ProjectModel;
|
project: ProjectModel;
|
||||||
status?: string | null;
|
status?: string | null;
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
export const projectPermissionLabels = {
|
||||||
|
'project:read': 'projectAccess.read',
|
||||||
|
'project:write': 'projectAccess.write',
|
||||||
|
'project:admin': 'projectAccess.admin',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const projectPermissionDescriptions = {
|
||||||
|
'project:read': 'projectAccess.readHelp',
|
||||||
|
'project:write': 'projectAccess.writeHelp',
|
||||||
|
'project:admin': 'projectAccess.adminHelp',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type ProjectAccessTarget = {
|
||||||
|
kind: 'user' | 'team';
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
permissions: string[];
|
||||||
|
globalAdmin?: boolean;
|
||||||
|
};
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { PermissionOption } from '@/types';
|
||||||
|
import type { TeamModel } from '@/types/models/team-model';
|
||||||
|
import type { UserModel } from '@/types/models/user-model';
|
||||||
|
|
||||||
|
export type TeamEditorTarget =
|
||||||
|
| { kind: 'create' }
|
||||||
|
| { kind: 'members' | 'permissions'; team: TeamModel };
|
||||||
|
|
||||||
|
export type TeamEditorProps = {
|
||||||
|
target: TeamEditorTarget | null;
|
||||||
|
users: UserModel[];
|
||||||
|
permissionOptions: PermissionOption[];
|
||||||
|
teamPermissions: Record<string, Record<string, string[]>>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Projects;
|
||||||
|
|
||||||
|
use App\Actions\Permissions\PermissionService;
|
||||||
|
use App\Enums\Permission;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Team;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Inertia\Testing\AssertableInertia as Assert;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ProjectAccessTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_access_summary_matches_existing_permission_rules_and_excludes_other_scopes(): void
|
||||||
|
{
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
$writer = User::factory()->create();
|
||||||
|
$member = User::factory()->create();
|
||||||
|
$outsider = User::factory()->create();
|
||||||
|
$project = Project::factory()->create();
|
||||||
|
$otherProject = Project::factory()->create();
|
||||||
|
$team = Team::factory()->create();
|
||||||
|
$otherTeam = Team::factory()->create();
|
||||||
|
$team->users()->attach([$writer->id, $member->id]);
|
||||||
|
$otherTeam->users()->attach($writer);
|
||||||
|
$service = app(PermissionService::class);
|
||||||
|
$service->sync($writer, $project, [Permission::ProjectRead]);
|
||||||
|
$service->sync($team, $project, [Permission::ProjectWrite]);
|
||||||
|
$service->sync($otherTeam, $project, [Permission::ProjectRead]);
|
||||||
|
$service->sync($outsider, $otherProject, [Permission::ProjectAdmin]);
|
||||||
|
$service->sync($outsider, null, [Permission::ProjectCreate]);
|
||||||
|
$service->sync($outsider, $team, [Permission::TeamAdmin]);
|
||||||
|
|
||||||
|
$map = $service->projectAccessMap(User::all(), $project);
|
||||||
|
foreach (User::all() as $user) {
|
||||||
|
$expected = array_values(array_filter(Permission::projectAssignable(), fn (Permission $permission): bool => $service->allows($user, $permission, $project)));
|
||||||
|
$this->assertSame(Permission::values($expected), $map['effectivePermissions'][$user->id]);
|
||||||
|
}
|
||||||
|
$this->assertSame([], $map['effectivePermissions'][$outsider->id]);
|
||||||
|
$this->assertSame(['project:read', 'project:write'], $map['effectivePermissions'][$writer->id]);
|
||||||
|
$this->assertSame([$admin->id], $map['globalAdminUserIds']);
|
||||||
|
$this->actingAs($admin)->get(route('projects.access', $project))->assertOk()
|
||||||
|
->assertInertia(fn (Assert $page) => $page->component('projects/Access')
|
||||||
|
->where("effectivePermissions.{$writer->id}", ['project:read', 'project:write'])
|
||||||
|
->where("directPermissions.{$writer->id}", ['project:read'])
|
||||||
|
->where("teamPermissions.{$team->id}", ['project:write'])
|
||||||
|
->where('globalAdminUserIds', [$admin->id]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_bulk_access_summary_uses_a_bounded_number_of_queries(): void
|
||||||
|
{
|
||||||
|
$project = Project::factory()->create();
|
||||||
|
User::factory()->create();
|
||||||
|
$service = app(PermissionService::class);
|
||||||
|
$countQueries = function () use ($service, $project): int {
|
||||||
|
$users = User::all();
|
||||||
|
DB::enableQueryLog();
|
||||||
|
DB::flushQueryLog();
|
||||||
|
$service->projectAccessMap($users, $project);
|
||||||
|
$count = count(DB::getQueryLog());
|
||||||
|
DB::disableQueryLog();
|
||||||
|
|
||||||
|
return $count;
|
||||||
|
};
|
||||||
|
$initialCount = $countQueries();
|
||||||
|
$users = User::factory()->count(10)->create();
|
||||||
|
$team = Team::factory()->create();
|
||||||
|
$team->users()->attach($users);
|
||||||
|
$service->sync($team, $project, [Permission::ProjectWrite]);
|
||||||
|
$this->assertSame($initialCount, $countQueries());
|
||||||
|
$this->assertLessThanOrEqual(4, $initialCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_readers_writers_and_unrelated_admins_cannot_manage_project_access(): void
|
||||||
|
{
|
||||||
|
$project = Project::factory()->create();
|
||||||
|
$other = Project::factory()->create();
|
||||||
|
$member = User::factory()->create();
|
||||||
|
$team = Team::factory()->create();
|
||||||
|
$service = app(PermissionService::class);
|
||||||
|
$service->sync($member, $project, [Permission::ProjectRead]);
|
||||||
|
$service->sync($team, $project, [Permission::ProjectWrite]);
|
||||||
|
foreach ([[], [Permission::ProjectRead], [Permission::ProjectWrite]] as $permissions) {
|
||||||
|
$actor = User::factory()->create();
|
||||||
|
$service->sync($actor, $project, $permissions);
|
||||||
|
$service->sync($actor, $other, [Permission::ProjectAdmin]);
|
||||||
|
$this->actingAs($actor)->get(route('projects.access', $project))->assertForbidden();
|
||||||
|
foreach ([['users', $member], ['teams', $team]] as [$kind, $assignee]) {
|
||||||
|
$this->patch(route("projects.permissions.{$kind}.update", [$project, $assignee]), ['permissions' => ['project:admin']])->assertForbidden();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->assertSame(['project:read'], $service->assignmentMap(User::class, $project)[$member->id]);
|
||||||
|
$this->assertSame(['project:write'], $service->assignmentMap(Team::class, $project)[$team->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_invalid_assignments_preserve_both_user_and_team_permissions(): void
|
||||||
|
{
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
$project = Project::factory()->create();
|
||||||
|
$service = app(PermissionService::class);
|
||||||
|
foreach ([['users', User::factory()->create()], ['teams', Team::factory()->create()]] as [$kind, $assignee]) {
|
||||||
|
$service->sync($assignee, $project, [Permission::ProjectRead]);
|
||||||
|
foreach ([['global:admin'], ['team:admin'], ['unknown'], ['project:read', 'project:read']] as $permissions) {
|
||||||
|
$this->actingAs($admin)->patch(route("projects.permissions.{$kind}.update", [$project, $assignee]), ['permissions' => $permissions])
|
||||||
|
->assertSessionHasErrors('permissions.0');
|
||||||
|
$this->assertSame(['project:read'], $service->assignmentMap($assignee::class, $project)[$assignee->id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_clearing_assignments_preserves_access_from_other_sources(): void
|
||||||
|
{
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
$member = User::factory()->create();
|
||||||
|
$project = Project::factory()->create();
|
||||||
|
$team = Team::factory()->create();
|
||||||
|
$team->users()->attach($member);
|
||||||
|
$service = app(PermissionService::class);
|
||||||
|
$service->sync($member, $project, [Permission::ProjectRead]);
|
||||||
|
$service->sync($team, $project, [Permission::ProjectWrite]);
|
||||||
|
$this->actingAs($admin)->patch(route('projects.permissions.users.update', [$project, $member]), ['permissions' => []])
|
||||||
|
->assertRedirect(route('projects.access', $project))->assertSessionHasNoErrors();
|
||||||
|
$this->assertTrue($service->allows($member, Permission::ProjectWrite, $project));
|
||||||
|
$service->sync($member, $project, [Permission::ProjectRead]);
|
||||||
|
$this->patch(route('projects.permissions.teams.update', [$project, $team]), ['permissions' => []])
|
||||||
|
->assertRedirect(route('projects.access', $project))->assertSessionHasNoErrors();
|
||||||
|
$this->assertTrue($service->allows($member, Permission::ProjectRead, $project));
|
||||||
|
$this->assertFalse($service->allows($member, Permission::ProjectWrite, $project));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_removing_own_admin_access_redirects_to_projects_for_direct_and_team_grants(): void
|
||||||
|
{
|
||||||
|
$service = app(PermissionService::class);
|
||||||
|
foreach (['users', 'teams'] as $kind) {
|
||||||
|
$actor = User::factory()->create();
|
||||||
|
$project = Project::factory()->create();
|
||||||
|
$assignee = $actor;
|
||||||
|
if ($kind === 'teams') {
|
||||||
|
$assignee = Team::factory()->create();
|
||||||
|
$assignee->users()->attach($actor);
|
||||||
|
}
|
||||||
|
$service->sync($assignee, $project, [Permission::ProjectAdmin]);
|
||||||
|
$this->actingAs($actor)->patch(route("projects.permissions.{$kind}.update", [$project, $assignee]), ['permissions' => []])
|
||||||
|
->assertRedirect(route('projects.index'))->assertSessionHasNoErrors();
|
||||||
|
$this->assertFalse($service->allows($actor, Permission::ProjectAdmin, $project));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Projects;
|
||||||
|
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ProjectAccessUiTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_access_overview_renders_sources_empty_states_and_localization(): void
|
||||||
|
{
|
||||||
|
$process = new Process(['node', 'tests/Fixtures/project-access.mjs'], base_path());
|
||||||
|
$process->setTimeout(60);
|
||||||
|
$process->run();
|
||||||
|
|
||||||
|
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||||
|
$this->assertStringContainsString('Project access presentation checks passed.', $process->getOutput());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -214,4 +214,89 @@ class TeamControllerTest extends TestCase
|
|||||||
'scope_type' => $team->getMorphClass(),
|
'scope_type' => $team->getMorphClass(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_team_managers_only_see_their_manageable_teams(): void
|
||||||
|
{
|
||||||
|
$manager = User::factory()->create();
|
||||||
|
$managed = Team::factory()->create();
|
||||||
|
$other = Team::factory()->create();
|
||||||
|
$other->users()->attach($manager);
|
||||||
|
$this->syncTeamPermissions($managed, $manager, ['team:manage']);
|
||||||
|
|
||||||
|
$this->actingAs($manager)->get(route('teams.index'))
|
||||||
|
->assertInertia(fn (Assert $page) => $page
|
||||||
|
->has('teams', 1)
|
||||||
|
->where('teams.0.id', $managed->id)
|
||||||
|
->where('canCreateTeams', false)
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->actingAs($manager)->patch(route('teams.members.update', $other), ['user_ids' => []])
|
||||||
|
->assertForbidden();
|
||||||
|
$this->actingAs($manager)->patch(route('teams.permissions.users.update', [$other, $manager]), ['permissions' => ['team:admin']])
|
||||||
|
->assertForbidden();
|
||||||
|
$this->assertDatabaseHas('team_user', ['team_id' => $other->id, 'user_id' => $manager->id]);
|
||||||
|
$this->assertDatabaseMissing('permission_assignments', ['scope_id' => $other->id, 'scope_type' => $other->getMorphClass()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_invalid_team_names_are_rejected(): void
|
||||||
|
{
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
$team = Team::factory()->create();
|
||||||
|
|
||||||
|
foreach (['', $team->name, str_repeat('a', 256)] as $name) {
|
||||||
|
$this->actingAs($admin)->from(route('teams.index'))->post(route('teams.store'), ['name' => $name])
|
||||||
|
->assertRedirect(route('teams.index'))->assertSessionHasErrors('name');
|
||||||
|
}
|
||||||
|
$this->assertDatabaseCount('teams', 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_invalid_members_preserve_existing_membership(): void
|
||||||
|
{
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
$member = User::factory()->create();
|
||||||
|
$team = Team::factory()->create();
|
||||||
|
$team->users()->attach($member);
|
||||||
|
|
||||||
|
foreach ([[$member->id, $member->id], [999999], ['invalid']] as $ids) {
|
||||||
|
$this->actingAs($admin)->patch(route('teams.members.update', $team), ['user_ids' => $ids])
|
||||||
|
->assertSessionHasErrors('user_ids.0');
|
||||||
|
$this->assertSame([$member->id], $team->users()->pluck('users.id')->all());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_membership_and_direct_permissions_can_be_cleared_independently(): void
|
||||||
|
{
|
||||||
|
$manager = User::factory()->create();
|
||||||
|
$member = User::factory()->create();
|
||||||
|
$team = Team::factory()->create();
|
||||||
|
$team->users()->attach($member);
|
||||||
|
$this->syncTeamPermissions($team, $manager, ['team:manage']);
|
||||||
|
$this->syncTeamPermissions($team, $member, ['team:invite']);
|
||||||
|
|
||||||
|
$this->actingAs($manager)->patch(route('teams.members.update', $team), ['user_ids' => []])
|
||||||
|
->assertRedirect(route('teams.index'))->assertSessionHasNoErrors();
|
||||||
|
$this->assertDatabaseMissing('team_user', ['team_id' => $team->id]);
|
||||||
|
$this->assertDatabaseHas('permission_assignments', ['assignee_id' => $member->id, 'scope_id' => $team->id, 'permission' => 'team:invite']);
|
||||||
|
|
||||||
|
$team->users()->attach($member);
|
||||||
|
$this->actingAs($manager)->patch(route('teams.permissions.users.update', [$team, $member]), ['permissions' => []])
|
||||||
|
->assertRedirect(route('teams.index'))->assertSessionHasNoErrors();
|
||||||
|
$this->assertDatabaseHas('team_user', ['team_id' => $team->id, 'user_id' => $member->id]);
|
||||||
|
$this->assertDatabaseMissing('permission_assignments', ['assignee_id' => $member->id, 'scope_id' => $team->id, 'scope_type' => $team->getMorphClass()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_invalid_permissions_preserve_existing_assignments(): void
|
||||||
|
{
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
$member = User::factory()->create();
|
||||||
|
$team = Team::factory()->create();
|
||||||
|
$this->syncTeamPermissions($team, $member, ['team:invite']);
|
||||||
|
|
||||||
|
foreach ([['global:admin'], ['team:manage', 'team:manage'], ['unknown']] as $permissions) {
|
||||||
|
$this->actingAs($admin)->patch(route('teams.permissions.users.update', [$team, $member]), ['permissions' => $permissions])
|
||||||
|
->assertSessionHasErrors('permissions.0');
|
||||||
|
$this->assertDatabaseHas('permission_assignments', ['assignee_id' => $member->id, 'scope_id' => $team->id, 'permission' => 'team:invite']);
|
||||||
|
$this->assertDatabaseMissing('permission_assignments', ['assignee_id' => $member->id, 'permission' => 'global:admin']);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Teams;
|
||||||
|
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class TeamPresentationTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_team_overview_respects_permissions_empty_states_and_localization(): void
|
||||||
|
{
|
||||||
|
$process = new Process(['node', 'tests/Fixtures/teams.mjs'], base_path());
|
||||||
|
$process->setTimeout(60);
|
||||||
|
$process->run();
|
||||||
|
|
||||||
|
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||||
|
$this->assertStringContainsString('Teams presentation checks passed.', $process->getOutput());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import process from 'node:process';
|
||||||
|
const { chromium } = await import(
|
||||||
|
process.env.PLAYWRIGHT_MODULE ?? 'playwright'
|
||||||
|
);
|
||||||
|
const browser = await chromium.launch({ channel: 'chrome', headless: true });
|
||||||
|
const context = await browser.newContext({ reducedMotion: 'reduce' });
|
||||||
|
const page = await context.newPage();
|
||||||
|
page.setDefaultTimeout(10000);
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', (error) => errors.push(error.message));
|
||||||
|
const base = 'http://127.0.0.1:4198';
|
||||||
|
const result = (value) =>
|
||||||
|
context.addCookies([{ name: 'access_result', value, url: base }]);
|
||||||
|
const visit = async (query = '') => {
|
||||||
|
await result('success');
|
||||||
|
await page.goto(base + '/projects/1/access?reset' + query);
|
||||||
|
await page
|
||||||
|
.getByRole('heading', { name: 'Project access', exact: true })
|
||||||
|
.waitFor();
|
||||||
|
};
|
||||||
|
const button = (name) => page.getByRole('button', { name, exact: true });
|
||||||
|
const dialog = () => page.getByRole('dialog');
|
||||||
|
const checkbox = (name) =>
|
||||||
|
dialog().getByRole('checkbox', { name, exact: true });
|
||||||
|
const state = (value) =>
|
||||||
|
dialog()
|
||||||
|
.locator('[data-form-state="' + value + '"]')
|
||||||
|
.waitFor({ state: 'visible' });
|
||||||
|
const noOverflow = async () =>
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(
|
||||||
|
() => document.documentElement.scrollWidth <= innerWidth,
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const save = async () => {
|
||||||
|
const request = page.waitForRequest((r) => r.method() === 'PATCH');
|
||||||
|
await dialog()
|
||||||
|
.getByRole('button', { name: 'Save permissions', exact: true })
|
||||||
|
.click();
|
||||||
|
return await request;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
for (const width of [1440, 390, 320]) {
|
||||||
|
await page.setViewportSize({ width, height: 1000 });
|
||||||
|
for (const theme of ['light', 'dark']) {
|
||||||
|
await page.emulateMedia({ colorScheme: theme });
|
||||||
|
await visit();
|
||||||
|
await noOverflow();
|
||||||
|
const trigger = button('Edit access for Sam Taylor');
|
||||||
|
await trigger.focus();
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await dialog().waitFor();
|
||||||
|
await noOverflow();
|
||||||
|
assert.equal(await checkbox('Read').isChecked(), true);
|
||||||
|
assert.equal(
|
||||||
|
await checkbox('Write').isChecked(),
|
||||||
|
false,
|
||||||
|
'Inherited write must not be editable as a direct grant',
|
||||||
|
);
|
||||||
|
await checkbox('Write').focus();
|
||||||
|
await page.keyboard.press('Space');
|
||||||
|
assert.equal(await checkbox('Write').isChecked(), true);
|
||||||
|
await page.keyboard.press('Space');
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
assert.equal(
|
||||||
|
await dialog().evaluate((el) =>
|
||||||
|
el.contains(document.activeElement),
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await page.screenshot({
|
||||||
|
path:
|
||||||
|
'/private/tmp/kan48-dialog-' + width + '-' + theme + '.png',
|
||||||
|
});
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await dialog().waitFor({ state: 'hidden' });
|
||||||
|
assert.equal(
|
||||||
|
await trigger.evaluate((el) => el === document.activeElement),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await page.screenshot({
|
||||||
|
path: '/private/tmp/kan48-' + width + '-' + theme + '.png',
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await page.locator('#access-search').fill('SAM@EXAMPLE');
|
||||||
|
assert.equal(await page.locator('[data-user-id]').count(), 1);
|
||||||
|
await page.getByText('No matching teams', { exact: true }).waitFor();
|
||||||
|
await page.locator('#access-search').fill('unmatched');
|
||||||
|
await page.getByText('No matching people', { exact: true }).waitFor();
|
||||||
|
await visit('&empty&noUsers');
|
||||||
|
await page.getByText('No teams yet', { exact: true }).waitFor();
|
||||||
|
await page.getByText('No people available', { exact: true }).waitFor();
|
||||||
|
await visit();
|
||||||
|
await button('Edit access for Alex Morgan').click();
|
||||||
|
await dialog()
|
||||||
|
.getByText('This person is a global administrator.', { exact: false })
|
||||||
|
.waitFor();
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await button('Edit access for Sam Taylor').click();
|
||||||
|
await checkbox('Read').uncheck();
|
||||||
|
const request = await save();
|
||||||
|
assert.equal(
|
||||||
|
new URL(request.url()).pathname,
|
||||||
|
'/projects/1/permissions/users/2',
|
||||||
|
);
|
||||||
|
assert.deepEqual(request.postDataJSON(), { permissions: [] });
|
||||||
|
await dialog().waitFor({ state: 'hidden' });
|
||||||
|
await page
|
||||||
|
.locator('[data-user-id="2"]')
|
||||||
|
.getByText('Write', { exact: true })
|
||||||
|
.first()
|
||||||
|
.waitFor();
|
||||||
|
await button('Edit access for Sam Taylor').click();
|
||||||
|
assert.equal(await checkbox('Read').isChecked(), false);
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await button('Edit access for Platform team').click();
|
||||||
|
await checkbox('Write').uncheck();
|
||||||
|
await save();
|
||||||
|
await dialog().waitFor({ state: 'hidden' });
|
||||||
|
await page
|
||||||
|
.locator('[data-user-id="2"]')
|
||||||
|
.getByText('No project access', { exact: true })
|
||||||
|
.waitFor();
|
||||||
|
|
||||||
|
await button('Edit access for Sam Taylor').click();
|
||||||
|
await checkbox('Administrator').check();
|
||||||
|
await result('validation');
|
||||||
|
await save();
|
||||||
|
await state('validation');
|
||||||
|
await dialog()
|
||||||
|
.getByText('This permission is unavailable.', { exact: true })
|
||||||
|
.waitFor();
|
||||||
|
assert.equal(await checkbox('Administrator').isChecked(), true);
|
||||||
|
assert.equal(
|
||||||
|
await dialog().locator('fieldset').getAttribute('aria-invalid'),
|
||||||
|
'true',
|
||||||
|
);
|
||||||
|
await result('server');
|
||||||
|
await save();
|
||||||
|
await state('server');
|
||||||
|
await result('success');
|
||||||
|
await page.route('**/permissions/users/2', (route) =>
|
||||||
|
route.abort('failed'),
|
||||||
|
);
|
||||||
|
await save();
|
||||||
|
await state('network');
|
||||||
|
await page.unroute('**/permissions/users/2');
|
||||||
|
await dialog()
|
||||||
|
.getByRole('button', { name: 'Try again', exact: true })
|
||||||
|
.click();
|
||||||
|
await dialog().waitFor({ state: 'hidden' });
|
||||||
|
await page
|
||||||
|
.locator('[data-user-id="2"]')
|
||||||
|
.getByText('Administrator', { exact: true })
|
||||||
|
.first()
|
||||||
|
.waitFor();
|
||||||
|
await button('Edit access for Sam Taylor').click();
|
||||||
|
assert.equal(await checkbox('Administrator').isChecked(), true);
|
||||||
|
await result('forbidden');
|
||||||
|
await save();
|
||||||
|
await state('server');
|
||||||
|
await dialog()
|
||||||
|
.getByText('Project access can no longer be changed', { exact: true })
|
||||||
|
.waitFor();
|
||||||
|
assert.equal(
|
||||||
|
await dialog().getByRole('button', { name: 'Try again' }).count(),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await visit();
|
||||||
|
await button('Edit access for Sam Taylor').click();
|
||||||
|
await checkbox('Write').check();
|
||||||
|
page.once('dialog', (d) => d.dismiss());
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
assert.equal(await dialog().isVisible(), true);
|
||||||
|
page.once('dialog', (d) => d.accept());
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await dialog().waitFor({ state: 'hidden' });
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
console.log(
|
||||||
|
'Project access browser checks passed: direct/team updates and clearing, effective access refresh, errors/retry, unsaved changes, keyboard, 320/390/1440px, both themes.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { createApp, h } from 'vue';
|
||||||
|
import { createInertiaApp } from '@inertiajs/vue3';
|
||||||
|
import '../../resources/css/app.css';
|
||||||
|
import Access from '../../resources/js/pages/projects/Access.vue';
|
||||||
|
import { createAppearance } from '../../resources/js/composables/useAppearance';
|
||||||
|
import { createLocalization } from '../../resources/js/lib/localization';
|
||||||
|
createInertiaApp({
|
||||||
|
resolve: () => Access,
|
||||||
|
setup: ({ el, App, props, plugin }) =>
|
||||||
|
createApp({ render: () => h(App, props) })
|
||||||
|
.use(plugin)
|
||||||
|
.use(createLocalization())
|
||||||
|
.use(createAppearance())
|
||||||
|
.mount(el),
|
||||||
|
});
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import process from 'node:process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
import tailwind from '@tailwindcss/vite';
|
||||||
|
import { createServer } from 'vite';
|
||||||
|
import { createSSRApp, h } from 'vue';
|
||||||
|
import { renderToString } from 'vue/server-renderer';
|
||||||
|
import { createInertiaApp } from '@inertiajs/vue3';
|
||||||
|
const serve = process.argv.includes('--serve');
|
||||||
|
const server = await createServer({
|
||||||
|
configFile: false,
|
||||||
|
cacheDir: 'node_modules/.vite/project-access-fixture',
|
||||||
|
plugins: [vue(), ...(serve ? [tailwind()] : [])],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: serve
|
||||||
|
? { host: '127.0.0.1', port: 4198, strictPort: true, hmr: false }
|
||||||
|
: { middlewareMode: true, hmr: false, watch: null },
|
||||||
|
appType: 'custom',
|
||||||
|
});
|
||||||
|
const users = [
|
||||||
|
{ id: 1, name: 'Alex Morgan', email: 'alex@example.test', avatar: null },
|
||||||
|
{ id: 2, name: 'Sam Taylor', email: 'sam@example.test', avatar: null },
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: 'Jordan Longname With Several Names',
|
||||||
|
email: 'jordan.long.email.address@example.test',
|
||||||
|
avatar: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
function initial() {
|
||||||
|
return {
|
||||||
|
project: { id: 1, name: 'Core platform', key: 'CORE' },
|
||||||
|
users,
|
||||||
|
status: null,
|
||||||
|
globalAdminUserIds: [1],
|
||||||
|
directPermissions: { 2: ['project:read'] },
|
||||||
|
teamPermissions: { 1: ['project:write'] },
|
||||||
|
effectivePermissions: {
|
||||||
|
1: ['project:read', 'project:write', 'project:admin'],
|
||||||
|
2: ['project:read', 'project:write'],
|
||||||
|
3: [],
|
||||||
|
},
|
||||||
|
teams: [
|
||||||
|
{ id: 1, name: 'Platform team', user_ids: [2], users: [users[1]] },
|
||||||
|
{ id: 2, name: 'Delivery team', user_ids: [], users: [] },
|
||||||
|
],
|
||||||
|
permissionOptions: ['read', 'write', 'admin'].map((name) => ({
|
||||||
|
value: 'project:' + name,
|
||||||
|
label: name,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const makePage = (extra = {}) => ({
|
||||||
|
component: 'Access',
|
||||||
|
url: '/projects/1/access',
|
||||||
|
version: null,
|
||||||
|
clearHistory: false,
|
||||||
|
encryptHistory: false,
|
||||||
|
props: {
|
||||||
|
name: 'KanbanGit',
|
||||||
|
errors: {},
|
||||||
|
sidebarOpen: true,
|
||||||
|
appearance: 'system',
|
||||||
|
auth: {
|
||||||
|
user: users[0],
|
||||||
|
permissions: {
|
||||||
|
globalAdmin: false,
|
||||||
|
projectCreate: false,
|
||||||
|
canManageTeams: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
navigation: { project: null },
|
||||||
|
...initial(),
|
||||||
|
...extra,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (serve) {
|
||||||
|
let data = initial();
|
||||||
|
server.middlewares.use(async (request, response, next) => {
|
||||||
|
if (!request.url.startsWith('/projects/1/')) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
const url = new URL(request.url, 'http://localhost');
|
||||||
|
if (url.searchParams.has('reset')) {
|
||||||
|
data = initial();
|
||||||
|
}
|
||||||
|
if (url.searchParams.has('noUsers')) {
|
||||||
|
data.users = [];
|
||||||
|
}
|
||||||
|
if (url.searchParams.has('empty')) {
|
||||||
|
data.teams = [];
|
||||||
|
}
|
||||||
|
let errors = {};
|
||||||
|
if (request.method !== 'GET') {
|
||||||
|
const result =
|
||||||
|
request.headers.cookie?.match(/access_result=(\w+)/)?.[1] ??
|
||||||
|
'success';
|
||||||
|
if (['server', 'forbidden'].includes(result)) {
|
||||||
|
response.statusCode = result === 'forbidden' ? 403 : 503;
|
||||||
|
response.end('Simulated failure');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let body = '';
|
||||||
|
for await (const chunk of request) {
|
||||||
|
body += chunk;
|
||||||
|
}
|
||||||
|
const submitted = JSON.parse(body || '{}');
|
||||||
|
if (result === 'validation') {
|
||||||
|
errors = { 'permissions.0': 'This permission is unavailable.' };
|
||||||
|
} else {
|
||||||
|
const parts = url.pathname.split('/');
|
||||||
|
const kind = parts[4];
|
||||||
|
const id = parts[5];
|
||||||
|
data[
|
||||||
|
kind === 'users' ? 'directPermissions' : 'teamPermissions'
|
||||||
|
][id] = submitted.permissions;
|
||||||
|
for (const user of users) {
|
||||||
|
const grants = [
|
||||||
|
...(data.directPermissions[user.id] ?? []),
|
||||||
|
...data.teams
|
||||||
|
.filter((team) => team.user_ids.includes(user.id))
|
||||||
|
.flatMap(
|
||||||
|
(team) => data.teamPermissions[team.id] ?? [],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
data.effectivePermissions[user.id] =
|
||||||
|
data.globalAdminUserIds.includes(user.id) ||
|
||||||
|
grants.includes('project:admin')
|
||||||
|
? ['project:read', 'project:write', 'project:admin']
|
||||||
|
: grants.includes('project:write')
|
||||||
|
? ['project:read', 'project:write']
|
||||||
|
: grants.includes('project:read')
|
||||||
|
? ['project:read']
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(errors).length === 0) {
|
||||||
|
data.status = 'Saved';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const page = makePage({ ...data, errors });
|
||||||
|
response.setHeader('Vary', 'X-Inertia');
|
||||||
|
if (request.headers['x-inertia']) {
|
||||||
|
response.setHeader('X-Inertia', 'true');
|
||||||
|
response.setHeader('Content-Type', 'application/json');
|
||||||
|
response.end(JSON.stringify(page));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const escaped = JSON.stringify(page)
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll('<', '<');
|
||||||
|
response.setHeader('Content-Type', 'text/html');
|
||||||
|
response.end(
|
||||||
|
await server.transformIndexHtml(
|
||||||
|
request.url,
|
||||||
|
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app" data-page="' +
|
||||||
|
escaped +
|
||||||
|
'"></div><script type="module" src="/tests/Fixtures/project-access-preview.mjs"></script></body></html>',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await server.listen();
|
||||||
|
console.log('Project access fixture ready.');
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const { createLocalization } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/lib/localization.ts',
|
||||||
|
);
|
||||||
|
const { createAppearance } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/composables/useAppearance.ts',
|
||||||
|
);
|
||||||
|
const { en } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/locales/en.ts',
|
||||||
|
);
|
||||||
|
const component = (
|
||||||
|
await server.ssrLoadModule(
|
||||||
|
'/resources/js/pages/projects/Access.vue',
|
||||||
|
)
|
||||||
|
).default;
|
||||||
|
const render = async (extra = {}, locale = createLocalization()) =>
|
||||||
|
(
|
||||||
|
await createInertiaApp({
|
||||||
|
page: makePage(extra),
|
||||||
|
resolve: () => component,
|
||||||
|
render: renderToString,
|
||||||
|
setup: ({ App, props, plugin }) =>
|
||||||
|
createSSRApp({ render: () => h(App, props) })
|
||||||
|
.use(plugin)
|
||||||
|
.use(locale)
|
||||||
|
.use(createAppearance()),
|
||||||
|
})
|
||||||
|
).body;
|
||||||
|
const html = await render();
|
||||||
|
assert.match(html, /People and effective access/);
|
||||||
|
assert.match(html, /Global administrator · all project permissions/);
|
||||||
|
assert.match(html, /Via Platform team:/);
|
||||||
|
assert.match(html, /No project access/);
|
||||||
|
assert.match(html, /Edit access for Sam Taylor/);
|
||||||
|
assert.match(html, /No project permissions assigned/);
|
||||||
|
assert.doesNotMatch(html, /<table|type="checkbox"/);
|
||||||
|
assert.doesNotMatch(html, /href="[^"]*settings\/general/);
|
||||||
|
assert.match(await render({ teams: [] }), /No teams yet/);
|
||||||
|
assert.match(await render({ users: [] }), /No people available/);
|
||||||
|
const translated = await render(
|
||||||
|
{},
|
||||||
|
createLocalization(
|
||||||
|
{ locale: 'de' },
|
||||||
|
{
|
||||||
|
de: {
|
||||||
|
...en,
|
||||||
|
'projectAccess.title': 'Projektzugriff',
|
||||||
|
'projectAccess.read': 'Lesen',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.match(translated, /Projektzugriff/);
|
||||||
|
assert.match(translated, /Lesen/);
|
||||||
|
console.log('Project access presentation checks passed.');
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import process from 'node:process';
|
||||||
|
const { chromium } = await import(
|
||||||
|
process.env.PLAYWRIGHT_MODULE ?? 'playwright'
|
||||||
|
);
|
||||||
|
const browser = await chromium.launch({ channel: 'chrome', headless: true });
|
||||||
|
const context = await browser.newContext({ reducedMotion: 'reduce' });
|
||||||
|
const page = await context.newPage();
|
||||||
|
page.setDefaultTimeout(10000);
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', (error) => errors.push(error.message));
|
||||||
|
const base = 'http://127.0.0.1:4197';
|
||||||
|
const result = (value) =>
|
||||||
|
context.addCookies([{ name: 'teams_result', value, url: base }]);
|
||||||
|
const visit = async (query = '') => {
|
||||||
|
await result('success');
|
||||||
|
await page.goto(base + '/teams?reset' + query);
|
||||||
|
await page.getByRole('heading', { name: 'Teams', exact: true }).waitFor();
|
||||||
|
};
|
||||||
|
const button = (name) => page.getByRole('button', { name, exact: true });
|
||||||
|
const dialog = () => page.getByRole('dialog');
|
||||||
|
const state = (value) =>
|
||||||
|
dialog()
|
||||||
|
.locator('[data-form-state="' + value + '"]')
|
||||||
|
.waitFor({ state: 'visible' });
|
||||||
|
const noOverflow = async () =>
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(
|
||||||
|
() => document.documentElement.scrollWidth <= innerWidth,
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const save = async (label) => {
|
||||||
|
const request = page.waitForRequest((r) =>
|
||||||
|
['POST', 'PATCH'].includes(r.method()),
|
||||||
|
);
|
||||||
|
await dialog().getByRole('button', { name: label, exact: true }).click();
|
||||||
|
return (await request).postDataJSON();
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
for (const width of [1440, 390, 320]) {
|
||||||
|
await page.setViewportSize({ width, height: 900 });
|
||||||
|
for (const theme of ['light', 'dark']) {
|
||||||
|
await page.emulateMedia({ colorScheme: theme });
|
||||||
|
await visit();
|
||||||
|
await noOverflow();
|
||||||
|
await page.screenshot({
|
||||||
|
path: '/private/tmp/kan47-' + width + '-' + theme + '.png',
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
const trigger = button('Manage members of Platform team');
|
||||||
|
await trigger.focus();
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await dialog().waitFor();
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
assert.equal(
|
||||||
|
await dialog().evaluate((el) =>
|
||||||
|
el.contains(document.activeElement),
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await noOverflow();
|
||||||
|
await page.screenshot({
|
||||||
|
path:
|
||||||
|
'/private/tmp/kan47-members-' +
|
||||||
|
width +
|
||||||
|
'-' +
|
||||||
|
theme +
|
||||||
|
'.png',
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await dialog().waitFor({ state: 'hidden' });
|
||||||
|
assert.equal(
|
||||||
|
await trigger.evaluate((el) => document.activeElement === el),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await visit('&manager');
|
||||||
|
assert.equal(await button('Create team').count(), 0);
|
||||||
|
await visit('&empty');
|
||||||
|
await page.getByText('No teams to manage', { exact: true }).waitFor();
|
||||||
|
await visit();
|
||||||
|
await page.getByLabel('Search teams', { exact: true }).fill('missing');
|
||||||
|
await page.getByText('No matching teams', { exact: true }).waitFor();
|
||||||
|
await button('Clear search').click();
|
||||||
|
await button('Create team').click();
|
||||||
|
await page.getByLabel('Team name', { exact: true }).fill('New team');
|
||||||
|
await result('validation');
|
||||||
|
assert.deepEqual(await save('Create team'), { name: 'New team' });
|
||||||
|
await state('validation');
|
||||||
|
assert.equal(
|
||||||
|
await page.getByLabel('Team name', { exact: true }).inputValue(),
|
||||||
|
'New team',
|
||||||
|
);
|
||||||
|
await page
|
||||||
|
.getByText('This team name is already taken.', { exact: true })
|
||||||
|
.waitFor();
|
||||||
|
await result('success');
|
||||||
|
await save('Create team');
|
||||||
|
await dialog().waitFor({ state: 'hidden' });
|
||||||
|
await page
|
||||||
|
.getByRole('heading', { name: 'New team', exact: true })
|
||||||
|
.waitFor();
|
||||||
|
|
||||||
|
await button('Manage members of Platform team').click();
|
||||||
|
const person = (name) =>
|
||||||
|
dialog().getByRole('checkbox', { name: new RegExp(name) });
|
||||||
|
await page.getByLabel('Find people').fill('sam@');
|
||||||
|
await person('Sam Taylor').check();
|
||||||
|
await page.getByLabel('Find people').fill('');
|
||||||
|
assert.equal(await person('Alex Morgan').isChecked(), true);
|
||||||
|
let dismissed = false;
|
||||||
|
page.once('dialog', async (d) => {
|
||||||
|
dismissed = true;
|
||||||
|
await d.dismiss();
|
||||||
|
});
|
||||||
|
await dialog().getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||||
|
assert.equal(dismissed, true);
|
||||||
|
assert.equal(await dialog().isVisible(), true);
|
||||||
|
await result('validation');
|
||||||
|
assert.deepEqual(await save('Save members'), { user_ids: [1, 2] });
|
||||||
|
await state('validation');
|
||||||
|
await page
|
||||||
|
.getByText('This user is unavailable.', { exact: true })
|
||||||
|
.waitFor();
|
||||||
|
assert.equal(await person('Sam Taylor').isChecked(), true);
|
||||||
|
await page.route('**/teams/1/members', (route) => route.abort('failed'));
|
||||||
|
await save('Save members');
|
||||||
|
await state('network');
|
||||||
|
await page.unroute('**/teams/1/members');
|
||||||
|
await result('server');
|
||||||
|
await save('Save members');
|
||||||
|
await state('server');
|
||||||
|
await result('success');
|
||||||
|
await dialog()
|
||||||
|
.getByRole('button', { name: 'Try again', exact: true })
|
||||||
|
.click();
|
||||||
|
await dialog().waitFor({ state: 'hidden' });
|
||||||
|
await button('Manage members of Platform team').click();
|
||||||
|
await person('Alex Morgan').uncheck();
|
||||||
|
await person('Sam Taylor').uncheck();
|
||||||
|
assert.deepEqual(await save('Save members'), { user_ids: [] });
|
||||||
|
await dialog().waitFor({ state: 'hidden' });
|
||||||
|
|
||||||
|
await button('Manage permissions for Platform team').click();
|
||||||
|
assert.equal(
|
||||||
|
await dialog()
|
||||||
|
.getByRole('button', { name: 'Save permissions', exact: true })
|
||||||
|
.isDisabled(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await page.getByLabel('User', { exact: true }).selectOption('2');
|
||||||
|
assert.equal(await person('Team invite').isChecked(), true);
|
||||||
|
await person('Team manage').check();
|
||||||
|
page.once('dialog', (d) => d.dismiss());
|
||||||
|
await page.getByLabel('User', { exact: true }).selectOption('3');
|
||||||
|
assert.equal(
|
||||||
|
await page.getByLabel('User', { exact: true }).inputValue(),
|
||||||
|
'2',
|
||||||
|
);
|
||||||
|
await result('validation');
|
||||||
|
assert.deepEqual(await save('Save permissions'), {
|
||||||
|
permissions: ['team:invite', 'team:manage'],
|
||||||
|
});
|
||||||
|
await state('validation');
|
||||||
|
await page
|
||||||
|
.getByText('This permission is unavailable.', { exact: true })
|
||||||
|
.waitFor();
|
||||||
|
await result('forbidden');
|
||||||
|
await save('Save permissions');
|
||||||
|
await state('server');
|
||||||
|
await page
|
||||||
|
.getByText('You no longer have permission', { exact: true })
|
||||||
|
.waitFor();
|
||||||
|
assert.equal(
|
||||||
|
await dialog()
|
||||||
|
.getByRole('button', { name: 'Try again', exact: true })
|
||||||
|
.count(),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
assert.equal(await person('Team manage').isChecked(), true);
|
||||||
|
await result('success');
|
||||||
|
await save('Save permissions');
|
||||||
|
await dialog().waitFor({ state: 'hidden' });
|
||||||
|
await button('Manage permissions for Platform team').click();
|
||||||
|
await page.getByLabel('User', { exact: true }).selectOption('2');
|
||||||
|
assert.equal(await person('Team manage').isChecked(), true);
|
||||||
|
await person('Team invite').uncheck();
|
||||||
|
await person('Team manage').uncheck();
|
||||||
|
assert.deepEqual(await save('Save permissions'), { permissions: [] });
|
||||||
|
await dialog().waitFor({ state: 'hidden' });
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
console.log(
|
||||||
|
'Teams browser checks passed: creation, membership, permissions, empty selections, validation, network/server/403, draft protection, focus, keyboard, themes and mobile.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { createApp, h } from 'vue';
|
||||||
|
import { createInertiaApp } from '@inertiajs/vue3';
|
||||||
|
import '../../resources/css/app.css';
|
||||||
|
import Teams from '../../resources/js/pages/teams/Index.vue';
|
||||||
|
import { createAppearance } from '../../resources/js/composables/useAppearance';
|
||||||
|
import { createLocalization } from '../../resources/js/lib/localization';
|
||||||
|
createInertiaApp({
|
||||||
|
resolve: () => Teams,
|
||||||
|
setup: ({ el, App, props, plugin }) =>
|
||||||
|
createApp({ render: () => h(App, props) })
|
||||||
|
.use(plugin)
|
||||||
|
.use(createLocalization())
|
||||||
|
.use(createAppearance())
|
||||||
|
.mount(el),
|
||||||
|
});
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import process from 'node:process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
import tailwind from '@tailwindcss/vite';
|
||||||
|
import { createServer } from 'vite';
|
||||||
|
import { createSSRApp, h } from 'vue';
|
||||||
|
import { renderToString } from 'vue/server-renderer';
|
||||||
|
import { createInertiaApp } from '@inertiajs/vue3';
|
||||||
|
const serve = process.argv.includes('--serve');
|
||||||
|
const server = await createServer({
|
||||||
|
configFile: false,
|
||||||
|
cacheDir: 'node_modules/.vite/teams-fixture',
|
||||||
|
plugins: [vue(), ...(serve ? [tailwind()] : [])],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: serve
|
||||||
|
? { host: '127.0.0.1', port: 4197, strictPort: true, hmr: false }
|
||||||
|
: { middlewareMode: true, hmr: false, watch: null },
|
||||||
|
appType: 'custom',
|
||||||
|
});
|
||||||
|
const users = [
|
||||||
|
{ id: 1, name: 'Alex Morgan', email: 'alex@example.test', avatar: null },
|
||||||
|
{ id: 2, name: 'Sam Taylor', email: 'sam@example.test', avatar: null },
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: 'Jordan Longname With Several Names',
|
||||||
|
email: 'jordan.long.email.address@example.test',
|
||||||
|
avatar: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
function initial() {
|
||||||
|
return {
|
||||||
|
canCreateTeams: true,
|
||||||
|
users,
|
||||||
|
status: null,
|
||||||
|
teams: [
|
||||||
|
{ id: 1, name: 'Platform team', user_ids: [1], users: [users[0]] },
|
||||||
|
{ id: 2, name: 'Delivery team', user_ids: [], users: [] },
|
||||||
|
],
|
||||||
|
teamPermissions: { 1: { 2: ['team:invite'] } },
|
||||||
|
permissionOptions: ['invite', 'manage', 'admin'].map((name) => ({
|
||||||
|
value: 'team:' + name,
|
||||||
|
label: 'Team ' + name,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const makePage = (extra = {}) => ({
|
||||||
|
component: 'Teams',
|
||||||
|
url: '/teams',
|
||||||
|
version: null,
|
||||||
|
clearHistory: false,
|
||||||
|
encryptHistory: false,
|
||||||
|
props: {
|
||||||
|
name: 'KanbanGit',
|
||||||
|
errors: {},
|
||||||
|
sidebarOpen: true,
|
||||||
|
appearance: 'system',
|
||||||
|
auth: {
|
||||||
|
user: users[0],
|
||||||
|
permissions: {
|
||||||
|
globalAdmin: false,
|
||||||
|
projectCreate: false,
|
||||||
|
canManageTeams: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
navigation: { project: null },
|
||||||
|
...initial(),
|
||||||
|
...extra,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (serve) {
|
||||||
|
let data = initial();
|
||||||
|
server.middlewares.use(async (request, response, next) => {
|
||||||
|
if (!request.url.startsWith('/teams')) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
const url = new URL(request.url, 'http://localhost');
|
||||||
|
if (url.searchParams.has('reset')) {
|
||||||
|
data = initial();
|
||||||
|
}
|
||||||
|
if (url.searchParams.has('manager')) {
|
||||||
|
data.canCreateTeams = false;
|
||||||
|
}
|
||||||
|
if (url.searchParams.has('empty')) {
|
||||||
|
data.teams = [];
|
||||||
|
}
|
||||||
|
let errors = {};
|
||||||
|
if (request.method !== 'GET') {
|
||||||
|
const result =
|
||||||
|
request.headers.cookie?.match(/teams_result=(\w+)/)?.[1] ??
|
||||||
|
'success';
|
||||||
|
if (['server', 'forbidden'].includes(result)) {
|
||||||
|
response.statusCode = result === 'forbidden' ? 403 : 503;
|
||||||
|
response.end('Simulated failure');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let body = '';
|
||||||
|
for await (const chunk of request) {
|
||||||
|
body += chunk;
|
||||||
|
}
|
||||||
|
const submitted = JSON.parse(body || '{}');
|
||||||
|
if (result === 'validation') {
|
||||||
|
errors = url.pathname.endsWith('/members')
|
||||||
|
? { 'user_ids.0': 'This user is unavailable.' }
|
||||||
|
: url.pathname.includes('/permissions/')
|
||||||
|
? { 'permissions.0': 'This permission is unavailable.' }
|
||||||
|
: { name: 'This team name is already taken.' };
|
||||||
|
} else if (request.method === 'POST') {
|
||||||
|
data.teams.push({
|
||||||
|
id: 3,
|
||||||
|
name: submitted.name,
|
||||||
|
user_ids: [],
|
||||||
|
users: [],
|
||||||
|
});
|
||||||
|
} else if (url.pathname.endsWith('/members')) {
|
||||||
|
const team = data.teams.find(
|
||||||
|
(team) => team.id === Number(url.pathname.split('/')[2]),
|
||||||
|
);
|
||||||
|
team.user_ids = submitted.user_ids;
|
||||||
|
team.users = users.filter((user) =>
|
||||||
|
team.user_ids.includes(user.id),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const [, , teamId, , , userId] = url.pathname.split('/');
|
||||||
|
data.teamPermissions[teamId] ??= {};
|
||||||
|
data.teamPermissions[teamId][userId] = submitted.permissions;
|
||||||
|
}
|
||||||
|
if (Object.keys(errors).length === 0) {
|
||||||
|
data.status = 'Saved';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const page = makePage({ ...data, errors });
|
||||||
|
response.setHeader('Vary', 'X-Inertia');
|
||||||
|
if (request.headers['x-inertia']) {
|
||||||
|
response.setHeader('X-Inertia', 'true');
|
||||||
|
response.setHeader('Content-Type', 'application/json');
|
||||||
|
response.end(JSON.stringify(page));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const escaped = JSON.stringify(page)
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll('<', '<');
|
||||||
|
response.setHeader('Content-Type', 'text/html');
|
||||||
|
response.end(
|
||||||
|
await server.transformIndexHtml(
|
||||||
|
request.url,
|
||||||
|
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app" data-page="' +
|
||||||
|
escaped +
|
||||||
|
'"></div><script type="module" src="/tests/Fixtures/teams-preview.mjs"></script></body></html>',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await server.listen();
|
||||||
|
console.log('Teams fixture ready.');
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const { createLocalization } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/lib/localization.ts',
|
||||||
|
);
|
||||||
|
const { createAppearance } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/composables/useAppearance.ts',
|
||||||
|
);
|
||||||
|
const { en } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/locales/en.ts',
|
||||||
|
);
|
||||||
|
const component = (
|
||||||
|
await server.ssrLoadModule('/resources/js/pages/teams/Index.vue')
|
||||||
|
).default;
|
||||||
|
const render = async (extra = {}, locale = createLocalization()) =>
|
||||||
|
(
|
||||||
|
await createInertiaApp({
|
||||||
|
page: makePage(extra),
|
||||||
|
resolve: () => component,
|
||||||
|
render: renderToString,
|
||||||
|
setup: ({ App, props, plugin }) =>
|
||||||
|
createSSRApp({ render: () => h(App, props) })
|
||||||
|
.use(plugin)
|
||||||
|
.use(locale)
|
||||||
|
.use(createAppearance()),
|
||||||
|
})
|
||||||
|
).body;
|
||||||
|
const html = await render();
|
||||||
|
assert.match(html, /Platform team/);
|
||||||
|
assert.match(html, /1 member/);
|
||||||
|
assert.match(html, /0 members/);
|
||||||
|
assert.match(html, /Manage members of Platform team/);
|
||||||
|
assert.match(html, /Manage permissions for Platform team/);
|
||||||
|
assert.match(html, /No members yet/);
|
||||||
|
assert.match(html, /Create team/);
|
||||||
|
assert.doesNotMatch(html, /Global admin|Hold Cmd|<select/);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
await render({ canCreateTeams: false }),
|
||||||
|
/Create team/,
|
||||||
|
);
|
||||||
|
assert.match(await render({ teams: [] }), /No teams to manage/);
|
||||||
|
const translated = await render(
|
||||||
|
{},
|
||||||
|
createLocalization(
|
||||||
|
{ locale: 'de' },
|
||||||
|
{
|
||||||
|
de: {
|
||||||
|
...en,
|
||||||
|
'navigation.teams': 'Arbeitsgruppen',
|
||||||
|
'teams.create': 'Team anlegen',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.match(translated, /Arbeitsgruppen/);
|
||||||
|
assert.match(translated, /Team anlegen/);
|
||||||
|
console.log('Teams presentation checks passed.');
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user