Compare commits
11 Commits
9f970808ca
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| c0825d94d4 | |||
| d10ce1ca57 | |||
| ec4f687ca8 | |||
| 3c119d425e | |||
| 7e7d2847fe | |||
| b32d6bf001 | |||
| cdfc8763a5 | |||
| d30dd971d7 | |||
| 9390db18c2 | |||
| d4cf4a66b7 | |||
| d80ed1a3e8 |
30
.env.example
30
.env.example
@@ -76,6 +76,21 @@ PIREON_CLIENT_SECRET=
|
||||
PIREON_REDIRECT_URI="${APP_URL}/auth/callback"
|
||||
PIREON_SCOPES=account:read
|
||||
|
||||
# «Entrar con Pireon» (Authorization Code + PKCE) under the login form. A Pireon
|
||||
# client allows one grant type: the one above is the password client, so
|
||||
# register another for authorization_code with the redirect URI above and put
|
||||
# it here; the button shows up once it is set. PIREON_REDIRECT_LOGIN=true shows
|
||||
# it with the client above instead (when that one allows authorization_code).
|
||||
PIREON_REDIRECT_CLIENT_ID=
|
||||
PIREON_REDIRECT_CLIENT_SECRET=
|
||||
# PIREON_REDIRECT_LOGIN=
|
||||
|
||||
# Webhooks at ${APP_URL}/api/v1/webhooks/pireon (user.deactivated, token.revoked):
|
||||
# register it on each client and paste the secret each one returns. Empty: the
|
||||
# endpoint refuses everything and a revocation waits for the cache below.
|
||||
PIREON_WEBHOOK_SECRET=
|
||||
PIREON_REDIRECT_WEBHOOK_SECRET=
|
||||
|
||||
# Grants. The panel's sign-in form uses the password grant, so turning this off
|
||||
# locks everyone out of the panel as well as out of POST /api/v1/auth/token.
|
||||
PIREON_PASSWORD_GRANT=true
|
||||
@@ -186,3 +201,18 @@ GIT_SYNC_CRON=hourly
|
||||
GIT_SYNC_TTL=55
|
||||
GIT_MAX_ISSUES=25
|
||||
GIT_HTTP_TIMEOUT=15
|
||||
|
||||
###############################################################################
|
||||
# TaskStream — the issue tracker
|
||||
###############################################################################
|
||||
# `taskstream: true` on POST /v1/projects opens a public board there too.
|
||||
# Both default to production.
|
||||
TASKSTREAM_API_URL=https://api.taskstream.pireon.dev
|
||||
TASKSTREAM_WEB_URL=https://taskstream.pireon.dev
|
||||
|
||||
# A Pireon personal access token (oauth.pireon.dev/tokens), only used when the
|
||||
# caller signs in with a machine token (pmt_…), which TaskStream cannot read.
|
||||
# People with a Pireon token have theirs passed on instead. Empty: machine
|
||||
# tokens cannot ask for a board.
|
||||
TASKSTREAM_TOKEN=
|
||||
TASKSTREAM_HTTP_TIMEOUT=10
|
||||
|
||||
112
README.md
112
README.md
@@ -70,9 +70,20 @@ al remoto por si alguien comitea con `--no-verify`.
|
||||
En `https://oauth.pireon.dev` crea un cliente OAuth con:
|
||||
|
||||
- **Grant types**: `password` — es el que usa el formulario de acceso del panel
|
||||
|
||||
Para ofrecer también **«Entrar con Pireon»** (redirección con Authorization
|
||||
Code + PKCE, donde la contraseña solo se escribe en el servidor de identidad),
|
||||
crea un segundo cliente, porque cada cliente admite un único grant:
|
||||
|
||||
- **Grant types**: `authorization_code`
|
||||
- **Redirect URI**: `https://tu-dominio/auth/callback` (en local,
|
||||
`http://localhost:8000/auth/callback`), solo si quieres conservar el flujo de
|
||||
redirección, que sigue disponible aunque el panel ya no lo use
|
||||
`http://localhost:8000/auth/callback`)
|
||||
|
||||
y pon sus credenciales en `PIREON_REDIRECT_CLIENT_ID` y
|
||||
`PIREON_REDIRECT_CLIENT_SECRET`. El botón aparece bajo el formulario en cuanto
|
||||
están; `PIREON_REDIRECT_LOGIN` lo fuerza (`true`, si el cliente principal
|
||||
admite los dos grants) o lo quita (`false`). Cada sesión renueva su token con el
|
||||
cliente que lo emitió.
|
||||
|
||||
Copia las credenciales al `.env`:
|
||||
|
||||
@@ -187,6 +198,27 @@ curl -X POST http://localhost:8000/api/v1/auth/token \
|
||||
Es el mismo grant que usa el formulario del panel, así que
|
||||
`PIREON_PASSWORD_GRANT=false` desactiva los dos a la vez.
|
||||
|
||||
### Webhooks de Pireon
|
||||
|
||||
Para que desactivar una cuenta o revocar un token en Pireon surta efecto al
|
||||
instante, sin esperar al TTL de la caché:
|
||||
|
||||
1. Registra `https://tu-dominio/api/v1/webhooks/pireon` con los eventos
|
||||
`user.deactivated` y `token.revoked` en el cliente de contraseña
|
||||
(`POST /api/clients/{client}/webhooks` del servidor de identidad) y pon el
|
||||
secret que devuelve en `PIREON_WEBHOOK_SECRET`.
|
||||
2. Si usas el cliente de «Entrar con Pireon», registra el mismo webhook en él y
|
||||
pon su secret en `PIREON_REDIRECT_WEBHOOK_SECRET`. Pireon firma cada aviso
|
||||
con el secret del cliente que lo registró, y una revocación solo la avisa el
|
||||
cliente que emitió el token.
|
||||
|
||||
`user.deactivated` desactiva la cuenta aquí (lo que corta también sus tokens de
|
||||
máquina y el panel), cierra sus sesiones del panel (con `SESSION_DRIVER=database`)
|
||||
y olvida sus tokens cacheados; vuelve a estar activa al entrar de nuevo cuando
|
||||
Pireon la reactive. `token.revoked` cierra sus sesiones y olvida sus tokens
|
||||
cacheados. La firma (`X-Webhook-Signature`, HMAC-SHA256 del cuerpo) vale con
|
||||
cualquiera de los dos secrets; sin ninguno, el endpoint lo rechaza todo.
|
||||
|
||||
## Permisos
|
||||
|
||||
El catálogo vive en un único sitio: [`app/Support/Permissions.php`](app/Support/Permissions.php).
|
||||
@@ -525,6 +557,58 @@ fuera. Añadir GitLab es escribir una clase que implemente
|
||||
[`RepositoryProvider`](app/Services/Git/Contracts/RepositoryProvider.php) y
|
||||
registrarla en `config/git.php`, sin tocar nada más.
|
||||
|
||||
## Tableros en TaskStream
|
||||
|
||||
Al crear un proyecto, la casilla «Crear también en TaskStream» del panel o
|
||||
`taskstream: true` en la API le abren también un tablero en
|
||||
[TaskStream](https://taskstream.pireon.dev), el gestor de tareas del colectivo.
|
||||
El panel avisa con el enlace y, al editar, ofrece «Abrir en TaskStream»; la API
|
||||
lo devuelve en la respuesta:
|
||||
|
||||
```json
|
||||
POST /api/v1/projects
|
||||
{ "title": { "es": "Catálogo abierto" }, "slug": { "es": "catalogo" }, "taskstream": true }
|
||||
|
||||
→ 201 { "data": { …, "taskstream_key": "CATALOGO", "taskstream_url": "https://taskstream.pireon.dev/p/CATALOGO" } }
|
||||
```
|
||||
|
||||
- **Público** siempre: es el tablero de un proyecto que se enseña.
|
||||
- **Clave**: la de `taskstream_key` (de 2 a 10 letras o cifras, empezando por
|
||||
letra) o, si no se indica, una sacada del slug en el idioma por defecto: de
|
||||
una palabra, sus diez primeros caracteres (`catalogo` → `CATALOGO`); de
|
||||
varias, sus iniciales (`mi-web-nueva` → `MWN`).
|
||||
- **Primero TaskStream**: si lo rechaza (una clave ya usada, por ejemplo), aquí
|
||||
no se crea nada y el motivo vuelve como error de validación (422); si no
|
||||
responde, 502. Así no quedan proyectos a medias ni webhooks de algo que no
|
||||
existe.
|
||||
- **A nombre de quien lo pide**: TaskStream acepta los mismos tokens de Pireon
|
||||
que esta API, y se le pasa el de quien llama (en el panel, el de su sesión). Un token de máquina (`pmt_…`)
|
||||
allí no significa nada, así que esas peticiones usan `TASKSTREAM_TOKEN`; sin
|
||||
él, no pueden pedir tablero.
|
||||
- **Después**: un proyecto que se creó sin tablero puede tenerlo luego, con
|
||||
«Crear en TaskStream» en su ficha o `taskstream: true` en un `PATCH` (si
|
||||
TaskStream lo rechaza, la edición tampoco se guarda). Uno que ya tiene tablero
|
||||
lo conserva, y `taskstream_key` sin la casilla se ignora, así que un cliente
|
||||
puede devolver tal cual lo que leyó. Un proyecto duplicado no hereda el
|
||||
tablero del original.
|
||||
|
||||
```dotenv
|
||||
TASKSTREAM_API_URL=https://api.taskstream.pireon.dev # por defecto
|
||||
TASKSTREAM_WEB_URL=https://taskstream.pireon.dev # de aquí sale el enlace
|
||||
TASKSTREAM_TOKEN= # token personal de Pireon, solo para tokens de máquina
|
||||
TASKSTREAM_HTTP_TIMEOUT=10
|
||||
```
|
||||
|
||||
El cliente está en [`TaskStreamClient`](app/Services/TaskStream/TaskStreamClient.php)
|
||||
y la lógica (clave, token, errores) en
|
||||
[`OpenInTaskStream`](app/Actions/Projects/OpenInTaskStream.php).
|
||||
|
||||
|
||||
En la API pública, un proyecto con tablero trae `taskstream` (`key`, `url`,
|
||||
`web_url` y `showcase_url`). `showcase_url` es público en TaskStream y devuelve
|
||||
su hoja de ruta, sus versiones, el sprint en curso y los tickets etiquetados
|
||||
`good-first-issue`, para que la web los enseñe en la página del proyecto.
|
||||
|
||||
## API pública (para el frontend)
|
||||
|
||||
Sin autenticación y de solo lectura. Todo lo que devuelve ya está publicado en
|
||||
@@ -1456,6 +1540,14 @@ concedía el permiso sin preguntar al token.
|
||||
|
||||
### Webhooks salientes
|
||||
|
||||
En el panel, **Administración → Webhooks** (solo con `webhooks.*`): alta con la
|
||||
URL y los eventos marcados, y al guardar sale **el secreto una única vez**, en
|
||||
un aviso que no se cierra solo. En la ficha, «Enviar prueba» manda un `ping`,
|
||||
«Cambiar secreto» genera otro (el anterior deja de valer en el acto) y abajo
|
||||
están **los envíos**: estado, respuesta, intentos, error, el contenido enviado y
|
||||
«Reenviar». El menú se marca en rojo si alguno se apagó por fallos. Lo mismo
|
||||
por API:
|
||||
|
||||
```
|
||||
/api/v1/webhooks CRUD (permiso webhooks.*, solo admin)
|
||||
GET /api/v1/webhooks/{id}/deliveries qué se envió y cómo fue
|
||||
@@ -1464,7 +1556,21 @@ POST /api/v1/webhook-deliveries/{id}/redeliver
|
||||
```
|
||||
|
||||
Avisan a otro sistema de `application.received`, `content.published`,
|
||||
`content.unpublished`, `review.requested` y `review.returned` (o `*`). Salen de
|
||||
`content.unpublished`, `review.requested` y `review.returned`, y además de
|
||||
**cualquier cambio que haga una persona en una ficha**: `{tipo}.created`,
|
||||
`.updated`, `.deleted` y `.restored` para páginas, categorías, etiquetas,
|
||||
proyectos, miembros, servicios, clientes, testimonios, charlas, tecnologías,
|
||||
convocatorias, redirecciones, repositorios, medios y el buzón. Se puede
|
||||
suscribir a uno, a `*`, a todo un tipo (`projects.*`) o a una acción en
|
||||
cualquiera (`*.deleted`).
|
||||
|
||||
Esos eventos salen del [registro de auditoría](#registro-de-auditoría), así
|
||||
que dicen lo mismo que él —qué ficha, quién, por dónde (panel, API, token) y,
|
||||
al editar, qué campos cambiaron con su valor anterior y el nuevo— y dejan fuera
|
||||
lo mismo: lo que hace la aplicación sola y los secretos. Del buzón solo se
|
||||
cuenta cómo se está atendiendo, salvo con `include_personal_data`. Usuarios,
|
||||
tokens, webhooks y ajustes no se envían nunca: son la seguridad de la
|
||||
aplicación, no su contenido. Salen de
|
||||
los propios registros —el historial ya distingue publicar de editar, venga del
|
||||
panel, de la API o de un lote— y no de los controladores, así que no hay forma
|
||||
de publicar algo sin que se avise.
|
||||
|
||||
79
app/Actions/Auth/HandlePireonWebhook.php
Normal file
79
app/Actions/Auth/HandlePireonWebhook.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Auth;
|
||||
|
||||
use App\Auth\PireonTokenGuard;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* What a Pireon webhook changes here.
|
||||
*
|
||||
* - `user.deactivated`: the account goes inactive (which also stops its
|
||||
* machine tokens and the panel), its panel sessions end and its cached
|
||||
* bearer tokens are forgotten. Signing in again once Pireon reactivates it
|
||||
* turns it back on, as every sign-in copies the upstream state.
|
||||
* - `token.revoked`: only token hashes are cached, so every cached token and
|
||||
* panel session of that person goes; they sign in again.
|
||||
*
|
||||
* Anything else, or a person who never signed in here, is ignored. Repeating
|
||||
* a delivery (one arrives per registered client) changes nothing.
|
||||
*/
|
||||
class HandlePireonWebhook
|
||||
{
|
||||
public const EVENTS = ['user.deactivated', 'token.revoked'];
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function handle(string $event, array $data): void
|
||||
{
|
||||
if (! in_array($event, self::EVENTS, true)) {
|
||||
Log::debug('Unhandled Pireon webhook', ['event' => $event]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$oauthId = $data['user_id'] ?? $data['userId'] ?? ($data['user']['id'] ?? null);
|
||||
|
||||
if ($oauthId === null || $oauthId === '') {
|
||||
Log::warning('Pireon webhook without a user id, ignored', ['event' => $event]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
PireonTokenGuard::forgetUser((string) $oauthId);
|
||||
|
||||
$user = User::query()->where('oauth_id', $oauthId)->first();
|
||||
|
||||
if ($user === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($event === 'user.deactivated' && $user->is_active) {
|
||||
$user->forceFill(['is_active' => false])->save();
|
||||
}
|
||||
|
||||
$this->endSessions($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sessions kept in the database can be ended one person at a time. With
|
||||
* any other driver they cannot, but an inactive account is still turned
|
||||
* away on its next request (canAccessPanel).
|
||||
*/
|
||||
protected function endSessions(User $user): void
|
||||
{
|
||||
if (config('session.driver') !== 'database') {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::connection(config('session.connection'))
|
||||
->table((string) config('session.table', 'sessions'))
|
||||
->where('user_id', $user->getKey())
|
||||
->delete();
|
||||
}
|
||||
}
|
||||
111
app/Actions/Projects/OpenInTaskStream.php
Normal file
111
app/Actions/Projects/OpenInTaskStream.php
Normal file
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Projects;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\User;
|
||||
use App\Services\TaskStream\TaskStreamClient;
|
||||
use App\Services\TaskStream\TaskStreamException;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
/**
|
||||
* Opens a board in TaskStream for a project, new or one without a board
|
||||
* yet, and points the project at it.
|
||||
*
|
||||
* It runs before the project is saved: if TaskStream says no, nothing is
|
||||
* saved here either, and the caller gets the reason to fix and retry.
|
||||
* The board is public, like the project it tracks is meant to be.
|
||||
*/
|
||||
class OpenInTaskStream
|
||||
{
|
||||
/** TaskStream's rule for a project key. */
|
||||
public const KEY_PATTERN = '/^[A-Z][A-Z0-9]{1,9}$/';
|
||||
|
||||
public function __construct(protected TaskStreamClient $client) {}
|
||||
|
||||
/**
|
||||
* @param string|null $bearer The token the caller presented here.
|
||||
* @param string|null $key The key asked for; derived from the slug when null.
|
||||
*
|
||||
* @throws ValidationException when the caller can fix the request
|
||||
*/
|
||||
public function handle(Project $project, User $user, ?string $bearer, ?string $key = null): void
|
||||
{
|
||||
$locale = (string) config('locales.default');
|
||||
$key = $key !== null ? Str::upper($key) : $this->keyFrom((string) $project->getTranslation('slug', $locale, false));
|
||||
|
||||
if ($key === null) {
|
||||
throw ValidationException::withMessages(['taskstream_key' => __('rules.taskstream.key_needed')]);
|
||||
}
|
||||
|
||||
try {
|
||||
$created = $this->client->createProject(
|
||||
token: $this->token($user, $bearer),
|
||||
key: $key,
|
||||
name: Str::limit((string) $project->getTranslation('title', $locale, false), 80, '…'),
|
||||
description: $project->getTranslation('tagline', $locale, false) ?: null,
|
||||
public: true,
|
||||
);
|
||||
} catch (TaskStreamException $e) {
|
||||
throw $this->explain($e);
|
||||
}
|
||||
|
||||
$project->forceFill([
|
||||
'taskstream_project_id' => $created['id'],
|
||||
'taskstream_key' => $created['key'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* A Pireon token is passed on, so the board belongs to whoever asked. A
|
||||
* machine token is this application's alone and means nothing there.
|
||||
*/
|
||||
protected function token(User $user, ?string $bearer): string
|
||||
{
|
||||
$token = $user->currentAccessToken() === null ? $bearer : $this->client->serviceToken();
|
||||
|
||||
if (blank($token)) {
|
||||
throw ValidationException::withMessages(['taskstream' => __('rules.taskstream.machine')]);
|
||||
}
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* A key in TaskStream's style: one word gives its first ten characters
|
||||
* ("catalogo" → CATALOGO), several give their initials ("mi-web-nueva"
|
||||
* → MWN). Null when nothing usable is left.
|
||||
*/
|
||||
public function keyFrom(string $slug): ?string
|
||||
{
|
||||
$words = array_values(array_filter(preg_split('/[^A-Z0-9]+/', Str::upper(Str::ascii($slug))) ?: []));
|
||||
|
||||
$key = count($words) === 1
|
||||
? $words[0]
|
||||
: implode('', array_map(fn (string $word) => $word[0], $words));
|
||||
|
||||
// It must start with a letter.
|
||||
$key = substr((string) preg_replace('/^[0-9]+/', '', $key), 0, 10);
|
||||
|
||||
return preg_match(self::KEY_PATTERN, $key) === 1 ? $key : null;
|
||||
}
|
||||
|
||||
protected function explain(TaskStreamException $e): ValidationException|HttpException
|
||||
{
|
||||
if ($e->isRefusal()) {
|
||||
return ValidationException::withMessages([
|
||||
'taskstream_key' => __('rules.taskstream.refused', ['reason' => $e->getMessage()]),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($e->isUnauthorised()) {
|
||||
return ValidationException::withMessages(['taskstream' => __('rules.taskstream.unauthorised')]);
|
||||
}
|
||||
|
||||
return new HttpException(502, __('rules.taskstream.unavailable'), $e);
|
||||
}
|
||||
}
|
||||
@@ -104,9 +104,33 @@ class PireonTokenGuard
|
||||
|
||||
Cache::put($key, $identity->toArray(), $ttl);
|
||||
|
||||
// Which cached tokens are whose, so a webhook can drop a person's.
|
||||
$index = self::indexKey($identity->id);
|
||||
Cache::put($index, array_values(array_unique([...(array) Cache::get($index, []), $key])), $ttl);
|
||||
|
||||
return $identity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget every token of that person checked lately: the next request with
|
||||
* any of them asks the identity server again.
|
||||
*/
|
||||
public static function forgetUser(int|string $oauthId): void
|
||||
{
|
||||
$index = self::indexKey($oauthId);
|
||||
|
||||
foreach ((array) Cache::get($index, []) as $key) {
|
||||
Cache::forget((string) $key);
|
||||
}
|
||||
|
||||
Cache::forget($index);
|
||||
}
|
||||
|
||||
protected static function indexKey(int|string $oauthId): string
|
||||
{
|
||||
return 'pireon:introspect:user:'.$oauthId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror the identity locally so policies, ownership checks and foreign
|
||||
* keys have a real row to work with. Local roles are left untouched.
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Services\Pireon\PireonClient;
|
||||
use App\Services\Pireon\PireonException;
|
||||
use App\Services\Pireon\TokenStore;
|
||||
use DanHarrin\LivewireRateLimiting\Exceptions\TooManyRequestsException;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Auth\Http\Responses\Contracts\LoginResponse;
|
||||
use Filament\Auth\Pages\Login;
|
||||
use Filament\Facades\Filament;
|
||||
@@ -27,6 +28,9 @@ use SensitiveParameter;
|
||||
* usable password column — but the person signing in never sees that. They
|
||||
* type their credentials here and land in the panel.
|
||||
*
|
||||
* «Entrar con Pireon», under the form, takes the redirect flow instead: the
|
||||
* password is then typed at the identity server only.
|
||||
*
|
||||
* The cost of that convenience is stated plainly because it is real: this
|
||||
* application now handles the password rather than never seeing it. It is sent
|
||||
* straight to the identity server over the password grant and is never stored,
|
||||
@@ -53,6 +57,25 @@ class PireonLogin extends Login
|
||||
return __('pireon.sign_in_intro');
|
||||
}
|
||||
|
||||
/**
|
||||
* Under «Entrar», «Entrar con Pireon»: the redirect flow, for whoever would
|
||||
* rather not type their password here (or signs in some other way there).
|
||||
*/
|
||||
protected function getFormActions(): array
|
||||
{
|
||||
$actions = parent::getFormActions();
|
||||
|
||||
if (config('pireon.redirect_login') && PireonClient::forRedirect()->isConfigured()) {
|
||||
$actions[] = Action::make('pireon')
|
||||
->label(__('pireon.continue_with_pireon'))
|
||||
->url(route('auth.redirect'))
|
||||
->color('gray')
|
||||
->outlined();
|
||||
}
|
||||
|
||||
return $actions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filament's own flow checks the credentials against the local user table.
|
||||
* There are no local passwords here, so the whole of it is replaced: the
|
||||
@@ -110,7 +133,7 @@ class PireonLogin extends Login
|
||||
|
||||
Auth::login($user, (bool) ($data['remember'] ?? false));
|
||||
|
||||
app(TokenStore::class)->put($token);
|
||||
app(TokenStore::class)->put($token, viaRedirect: false);
|
||||
|
||||
if ($user->locale) {
|
||||
session()->put(config('locales.session_key'), $user->locale);
|
||||
|
||||
@@ -88,7 +88,7 @@ class ApplicationInfolist
|
||||
->state(fn (ApplicationReply $record) => __('panel.replies.by', [
|
||||
'name' => $record->user_name ?? '—',
|
||||
'when' => $record->created_at?->isoFormat('LLL'),
|
||||
]).' · '.__('panel.replies.channels.'.$record->channel))
|
||||
]).' · '.__("panel.replies.channels.{$record->channel}"))
|
||||
->color('gray'),
|
||||
|
||||
TextEntry::make('body')
|
||||
|
||||
@@ -4,10 +4,79 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Projects\Pages;
|
||||
|
||||
use App\Actions\Projects\OpenInTaskStream;
|
||||
use App\Filament\Resources\Projects\ProjectResource;
|
||||
use App\Models\Project;
|
||||
use App\Services\Pireon\TokenStore;
|
||||
use App\Services\TaskStream\TaskStreamClient;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
use Filament\Support\Exceptions\Halt;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
class CreateProject extends CreateRecord
|
||||
{
|
||||
protected static string $resource = ProjectResource::class;
|
||||
|
||||
/**
|
||||
* With the TaskStream toggle on, the board is opened before the project
|
||||
* is saved, as the person signed in (their Pireon token from the session).
|
||||
*/
|
||||
protected function handleRecordCreation(array $data): Model
|
||||
{
|
||||
$taskstream = (bool) Arr::pull($data, 'taskstream', false);
|
||||
$key = Arr::pull($data, 'taskstream_key');
|
||||
|
||||
$project = new Project($data);
|
||||
|
||||
if ($taskstream) {
|
||||
$this->openInTaskStream($project, filled($key) ? $key : null);
|
||||
}
|
||||
|
||||
$project->save();
|
||||
|
||||
return $project;
|
||||
}
|
||||
|
||||
protected function openInTaskStream(Project $project, ?string $key): void
|
||||
{
|
||||
try {
|
||||
app(OpenInTaskStream::class)->handle($project, Auth::user(), app(TokenStore::class)->accessToken(), $key);
|
||||
} catch (ValidationException $e) {
|
||||
// The action names the API's fields; here they live under `data`.
|
||||
throw ValidationException::withMessages(
|
||||
collect($e->errors())->mapWithKeys(fn (array $messages, string $field) => ["data.{$field}" => $messages])->all(),
|
||||
);
|
||||
} catch (HttpException $e) {
|
||||
Notification::make()->danger()->title($e->getMessage())->send();
|
||||
|
||||
throw (new Halt)->rollBackDatabaseTransaction();
|
||||
}
|
||||
}
|
||||
|
||||
protected function getCreatedNotification(): ?Notification
|
||||
{
|
||||
$notification = parent::getCreatedNotification();
|
||||
$key = $this->getRecord()->taskstream_key;
|
||||
|
||||
if ($notification === null || $key === null) {
|
||||
return $notification;
|
||||
}
|
||||
|
||||
$url = app(TaskStreamClient::class)->projectUrl($key);
|
||||
|
||||
return $notification
|
||||
->body(__('panel.projects.taskstream.created', ['url' => $url]))
|
||||
->actions([
|
||||
Action::make('taskstream')
|
||||
->label(__('panel.projects.taskstream.open'))
|
||||
->url($url, shouldOpenInNewTab: true),
|
||||
])
|
||||
->persistent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,22 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Projects\Pages;
|
||||
|
||||
use App\Actions\Projects\OpenInTaskStream;
|
||||
use App\Filament\Concerns\WarnsOfConcurrentEditing;
|
||||
use App\Filament\Resources\Projects\ProjectResource;
|
||||
use App\Filament\Support\DuplicateAction;
|
||||
use App\Filament\Support\PreviewAction;
|
||||
use App\Filament\Support\TranslateAction;
|
||||
use App\Services\Pireon\TokenStore;
|
||||
use App\Services\TaskStream\TaskStreamClient;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
class EditProject extends EditRecord
|
||||
{
|
||||
@@ -23,9 +32,79 @@ class EditProject extends EditRecord
|
||||
return [
|
||||
// Seeing it on the real site without leaving the form.
|
||||
PreviewAction::for('project'),
|
||||
Action::make('taskstream')
|
||||
->label(__('panel.projects.taskstream.open'))
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->color('gray')
|
||||
->url(fn () => app(TaskStreamClient::class)->projectUrl((string) $this->record->taskstream_key))
|
||||
->openUrlInNewTab()
|
||||
->visible(fn () => filled($this->record->taskstream_key)),
|
||||
$this->createInTaskStream(),
|
||||
DuplicateAction::make(),
|
||||
TranslateAction::make(),
|
||||
DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* For a project made without a board: opens one now, as the person
|
||||
* signed in, and saves only the link (whatever is being edited in the
|
||||
* form stays as it is, unsaved).
|
||||
*/
|
||||
protected function createInTaskStream(): Action
|
||||
{
|
||||
return Action::make('createInTaskStream')
|
||||
->label(__('panel.projects.taskstream.create'))
|
||||
->icon('heroicon-o-plus')
|
||||
->color('gray')
|
||||
->visible(fn () => blank($this->record->taskstream_key))
|
||||
->authorize('update')
|
||||
->modalHeading(__('panel.projects.taskstream.create_heading'))
|
||||
->modalDescription(__('panel.projects.taskstream.section_hint'))
|
||||
->modalSubmitActionLabel(__('panel.projects.taskstream.create'))
|
||||
->schema([
|
||||
TextInput::make('key')
|
||||
->label(__('panel.projects.taskstream.key'))
|
||||
->helperText(__('panel.projects.taskstream.key_hint'))
|
||||
->placeholder(fn () => app(OpenInTaskStream::class)
|
||||
->keyFrom((string) $this->record->getTranslation('slug', (string) config('locales.default'), false)))
|
||||
->maxLength(10)
|
||||
->regex('/^[A-Za-z][A-Za-z0-9]{1,9}$/'),
|
||||
])
|
||||
->action(function (array $data, Action $action): void {
|
||||
$project = $this->record;
|
||||
|
||||
try {
|
||||
app(OpenInTaskStream::class)->handle(
|
||||
$project,
|
||||
Auth::user(),
|
||||
app(TokenStore::class)->accessToken(),
|
||||
filled($data['key'] ?? null) ? $data['key'] : null,
|
||||
);
|
||||
} catch (ValidationException|HttpException $e) {
|
||||
// The modal stays open, to change the key and try again.
|
||||
Notification::make()
|
||||
->danger()
|
||||
->title($e instanceof ValidationException ? collect($e->errors())->flatten()->first() : $e->getMessage())
|
||||
->send();
|
||||
|
||||
$action->halt();
|
||||
}
|
||||
|
||||
$project->save();
|
||||
|
||||
$url = app(TaskStreamClient::class)->projectUrl((string) $project->taskstream_key);
|
||||
|
||||
Notification::make()
|
||||
->success()
|
||||
->title(__('panel.projects.taskstream.created', ['url' => $url]))
|
||||
->actions([
|
||||
Action::make('taskstream')
|
||||
->label(__('panel.projects.taskstream.open'))
|
||||
->url($url, shouldOpenInNewTab: true),
|
||||
])
|
||||
->persistent()
|
||||
->send();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Projects\Schemas;
|
||||
|
||||
use App\Actions\Projects\OpenInTaskStream;
|
||||
use App\Enums\ProjectStatus;
|
||||
use App\Filament\Resources\Projects\ProjectResource;
|
||||
use App\Filament\Support\TranslatableTabs;
|
||||
@@ -221,6 +222,27 @@ class ProjectForm
|
||||
->label(__('panel.projects.fields.needs_help'))
|
||||
->helperText(__('panel.projects.fields.needs_help_hint')),
|
||||
]),
|
||||
|
||||
// Only when creating: CreateProject opens the board before
|
||||
// saving, so a refusal there leaves nothing half made here.
|
||||
Section::make(__('panel.projects.taskstream.section'))
|
||||
->description(__('panel.projects.taskstream.section_hint'))
|
||||
->visibleOn('create')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Toggle::make('taskstream')
|
||||
->label(__('panel.projects.taskstream.toggle'))
|
||||
->live(),
|
||||
|
||||
TextInput::make('taskstream_key')
|
||||
->label(__('panel.projects.taskstream.key'))
|
||||
->helperText(__('panel.projects.taskstream.key_hint'))
|
||||
->placeholder(fn (callable $get) => app(OpenInTaskStream::class)
|
||||
->keyFrom((string) $get('slug.'.config('locales.default'))))
|
||||
->maxLength(10)
|
||||
->regex('/^[A-Za-z][A-Za-z0-9]{1,9}$/')
|
||||
->visible(fn (callable $get) => (bool) $get('taskstream')),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
44
app/Filament/Resources/Webhooks/Pages/CreateWebhook.php
Normal file
44
app/Filament/Resources/Webhooks/Pages/CreateWebhook.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Webhooks\Pages;
|
||||
|
||||
use App\Filament\Resources\Webhooks\WebhookResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* The secret is made here and shown once, on the page that opens next.
|
||||
*/
|
||||
class CreateWebhook extends CreateRecord
|
||||
{
|
||||
protected static string $resource = WebhookResource::class;
|
||||
|
||||
protected string $secret = '';
|
||||
|
||||
protected function mutateFormDataBeforeCreate(array $data): array
|
||||
{
|
||||
$this->secret = Str::random(40);
|
||||
|
||||
return [...$data, 'secret' => $this->secret];
|
||||
}
|
||||
|
||||
protected function afterCreate(): void
|
||||
{
|
||||
$this->record->forceFill(['created_by' => Auth::id()])->save();
|
||||
|
||||
WebhookResource::showSecret($this->secret);
|
||||
}
|
||||
|
||||
protected function getCreatedNotification(): null
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function getRedirectUrl(): string
|
||||
{
|
||||
return WebhookResource::getUrl('edit', ['record' => $this->record]);
|
||||
}
|
||||
}
|
||||
35
app/Filament/Resources/Webhooks/Pages/EditWebhook.php
Normal file
35
app/Filament/Resources/Webhooks/Pages/EditWebhook.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Webhooks\Pages;
|
||||
|
||||
use App\Filament\Resources\Webhooks\WebhookResource;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditWebhook extends EditRecord
|
||||
{
|
||||
protected static string $resource = WebhookResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
WebhookResource::pingAction(),
|
||||
WebhookResource::rotateAction(),
|
||||
DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Switching it back on starts its failure count again, as through the API.
|
||||
*/
|
||||
protected function mutateFormDataBeforeSave(array $data): array
|
||||
{
|
||||
if (($data['is_active'] ?? false) && ! $this->record->is_active) {
|
||||
$this->record->consecutive_failures = 0;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
21
app/Filament/Resources/Webhooks/Pages/ListWebhooks.php
Normal file
21
app/Filament/Resources/Webhooks/Pages/ListWebhooks.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Webhooks\Pages;
|
||||
|
||||
use App\Filament\Resources\Webhooks\WebhookResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListWebhooks extends ListRecords
|
||||
{
|
||||
protected static string $resource = WebhookResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Webhooks\RelationManagers;
|
||||
|
||||
use App\Models\WebhookDelivery;
|
||||
use App\Support\Webhooks\Webhooks;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\HtmlString;
|
||||
|
||||
/**
|
||||
* What was sent, and what the other end answered.
|
||||
*/
|
||||
class DeliveriesRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'deliveries';
|
||||
|
||||
public static function getTitle(Model $ownerRecord, string $pageClass): string
|
||||
{
|
||||
return __('panel.webhooks.deliveries.title');
|
||||
}
|
||||
|
||||
public function isReadOnly(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('id', 'desc')
|
||||
->poll('10s')
|
||||
->columns([
|
||||
TextColumn::make('event')
|
||||
->label(__('panel.webhooks.deliveries.event'))
|
||||
->badge()
|
||||
->color('gray'),
|
||||
|
||||
TextColumn::make('status')
|
||||
->label(__('panel.webhooks.deliveries.status'))
|
||||
->badge()
|
||||
->formatStateUsing(fn (string $state) => __("panel.webhooks.deliveries.statuses.{$state}"))
|
||||
->color(fn (string $state) => match ($state) {
|
||||
WebhookDelivery::SUCCEEDED => 'success',
|
||||
WebhookDelivery::FAILED => 'danger',
|
||||
default => 'warning',
|
||||
}),
|
||||
|
||||
TextColumn::make('response_status')
|
||||
->label(__('panel.webhooks.deliveries.response'))
|
||||
->placeholder('—'),
|
||||
|
||||
TextColumn::make('attempts')
|
||||
->label(__('panel.webhooks.deliveries.attempts')),
|
||||
|
||||
TextColumn::make('error')
|
||||
->label(__('panel.webhooks.deliveries.error'))
|
||||
->limit(60)
|
||||
->tooltip(fn (WebhookDelivery $record) => $record->error)
|
||||
->placeholder('—')
|
||||
->color('danger'),
|
||||
|
||||
TextColumn::make('created_at')
|
||||
->label(__('panel.webhooks.deliveries.created_at'))
|
||||
->since()
|
||||
->dateTimeTooltip(),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('status')
|
||||
->label(__('panel.webhooks.deliveries.status'))
|
||||
->options(collect([WebhookDelivery::PENDING, WebhookDelivery::SUCCEEDED, WebhookDelivery::FAILED])
|
||||
->mapWithKeys(fn (string $s) => [$s => __("panel.webhooks.deliveries.statuses.{$s}")])
|
||||
->all()),
|
||||
])
|
||||
->recordActions([
|
||||
Action::make('payload')
|
||||
->label(__('panel.webhooks.deliveries.payload'))
|
||||
->icon('heroicon-o-code-bracket')
|
||||
->color('gray')
|
||||
->modalHeading(fn (WebhookDelivery $record) => $record->event.' · '.$record->uuid)
|
||||
->modalSubmitAction(false)
|
||||
->modalContent(fn (WebhookDelivery $record) => new HtmlString(
|
||||
'<pre style="white-space: pre-wrap; overflow-wrap: anywhere; font-size: .8125rem;">'
|
||||
.e((string) json_encode($record->payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE))
|
||||
.'</pre>'
|
||||
)),
|
||||
|
||||
Action::make('redeliver')
|
||||
->label(__('panel.webhooks.deliveries.redeliver'))
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->requiresConfirmation()
|
||||
->authorize(fn () => $this->getOwnerRecord() && auth()->user()?->can('update', $this->getOwnerRecord()))
|
||||
->action(function (WebhookDelivery $record): void {
|
||||
Webhooks::redeliver($record);
|
||||
|
||||
Notification::make()->title(__('panel.webhooks.deliveries.redelivered'))->success()->send();
|
||||
}),
|
||||
]);
|
||||
}
|
||||
}
|
||||
263
app/Filament/Resources/Webhooks/WebhookResource.php
Normal file
263
app/Filament/Resources/Webhooks/WebhookResource.php
Normal file
@@ -0,0 +1,263 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Webhooks;
|
||||
|
||||
use App\Filament\Resources\Webhooks\Pages\CreateWebhook;
|
||||
use App\Filament\Resources\Webhooks\Pages\EditWebhook;
|
||||
use App\Filament\Resources\Webhooks\Pages\ListWebhooks;
|
||||
use App\Filament\Resources\Webhooks\RelationManagers\DeliveriesRelationManager;
|
||||
use App\Models\Webhook;
|
||||
use App\Rules\PublicHttpsUrl;
|
||||
use App\Support\Webhooks\Webhooks;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Support\Str;
|
||||
use UnitEnum;
|
||||
|
||||
/**
|
||||
* Outgoing webhooks: where to tell when something happens here, and how each
|
||||
* delivery went.
|
||||
*/
|
||||
class WebhookResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Webhook::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedBolt;
|
||||
|
||||
protected static ?int $navigationSort = 85;
|
||||
|
||||
protected static ?string $recordTitleAttribute = 'name';
|
||||
|
||||
public static function getModelLabel(): string
|
||||
{
|
||||
return __('panel.webhooks.label');
|
||||
}
|
||||
|
||||
public static function getPluralModelLabel(): string
|
||||
{
|
||||
return __('panel.webhooks.plural');
|
||||
}
|
||||
|
||||
public static function getNavigationGroup(): string|UnitEnum|null
|
||||
{
|
||||
return __('panel.groups.administration');
|
||||
}
|
||||
|
||||
/**
|
||||
* Red while one is switched off by failures: something is not hearing
|
||||
* what it signed up for.
|
||||
*/
|
||||
public static function getNavigationBadge(): ?string
|
||||
{
|
||||
$off = static::getModel()::query()
|
||||
->where('is_active', false)
|
||||
->where('consecutive_failures', '>=', Webhook::MAX_FAILURES)
|
||||
->count();
|
||||
|
||||
return $off > 0 ? (string) $off : null;
|
||||
}
|
||||
|
||||
public static function getNavigationBadgeColor(): ?string
|
||||
{
|
||||
return 'danger';
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema->components([
|
||||
Section::make()
|
||||
->columns(2)
|
||||
->schema([
|
||||
TextInput::make('name')
|
||||
->label(__('panel.webhooks.fields.name'))
|
||||
->required()
|
||||
->maxLength(120),
|
||||
|
||||
TextInput::make('url')
|
||||
->label(__('panel.webhooks.fields.url'))
|
||||
->helperText(__('panel.webhooks.fields.url_hint'))
|
||||
->required()
|
||||
->url()
|
||||
->maxLength(2048)
|
||||
->rule(new PublicHttpsUrl),
|
||||
|
||||
Select::make('events')
|
||||
->label(__('panel.webhooks.fields.events'))
|
||||
->helperText(__('panel.webhooks.events_hint'))
|
||||
->multiple()
|
||||
->searchable()
|
||||
->options(fn () => self::eventOptions())
|
||||
->required()
|
||||
->columnSpanFull(),
|
||||
|
||||
Toggle::make('include_personal_data')
|
||||
->label(__('panel.webhooks.fields.include_personal_data'))
|
||||
->helperText(__('panel.webhooks.fields.include_personal_data_hint')),
|
||||
|
||||
Toggle::make('is_active')
|
||||
->label(__('panel.webhooks.fields.is_active'))
|
||||
->default(true),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->label(__('panel.webhooks.fields.name'))
|
||||
->weight('bold')
|
||||
->description(fn (Webhook $record) => $record->url)
|
||||
->searchable(),
|
||||
|
||||
TextColumn::make('events')
|
||||
->label(__('panel.webhooks.fields.events'))
|
||||
->badge()
|
||||
->color('gray'),
|
||||
|
||||
IconColumn::make('is_active')
|
||||
->label(__('panel.webhooks.fields.is_active'))
|
||||
->boolean(),
|
||||
|
||||
TextColumn::make('consecutive_failures')
|
||||
->label(__('panel.webhooks.fields.failures'))
|
||||
->color(fn (int $state) => $state > 0 ? 'danger' : 'gray'),
|
||||
|
||||
TextColumn::make('last_delivered_at')
|
||||
->label(__('panel.webhooks.fields.last_delivered_at'))
|
||||
->since()
|
||||
->dateTimeTooltip()
|
||||
->placeholder('—'),
|
||||
])
|
||||
->recordActions([
|
||||
self::pingAction(),
|
||||
EditAction::make(),
|
||||
DeleteAction::make(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function eventLabel(string $event): string
|
||||
{
|
||||
$key = str_replace('.', '_', $event);
|
||||
|
||||
return __("panel.webhooks.events.{$key}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Grouped for the select: everything, the events with a meaning of their
|
||||
* own, any record by action, then each kind of record.
|
||||
*
|
||||
* @return array<string, array<string, string>>
|
||||
*/
|
||||
public static function eventOptions(): array
|
||||
{
|
||||
$groups = [
|
||||
__('panel.webhooks.groups.everything') => [
|
||||
'*' => __('panel.webhooks.events.*'),
|
||||
...collect(Webhook::ACTIONS)->mapWithKeys(fn (string $action) => [
|
||||
"*.{$action}" => __('panel.webhooks.any_record', ['action' => self::actionLabel($action)]),
|
||||
])->all(),
|
||||
],
|
||||
__('panel.webhooks.groups.notices') => collect(Webhook::EVENTS)
|
||||
->mapWithKeys(fn (string $event) => [$event => self::eventLabel($event)])
|
||||
->all(),
|
||||
];
|
||||
|
||||
foreach (Webhook::RESOURCES as $resource) {
|
||||
$groups[__("panel.webhooks.resources.{$resource}")] = [
|
||||
"{$resource}.*" => __('panel.webhooks.all_of', ['resource' => __("panel.webhooks.resources.{$resource}")]),
|
||||
...collect(Webhook::ACTIONS)->mapWithKeys(fn (string $action) => [
|
||||
"{$resource}.{$action}" => __("panel.webhooks.resources.{$resource}").' · '.self::actionLabel($action),
|
||||
])->all(),
|
||||
];
|
||||
}
|
||||
|
||||
return $groups;
|
||||
}
|
||||
|
||||
protected static function actionLabel(string $action): string
|
||||
{
|
||||
return __("panel.webhooks.actions_on.{$action}");
|
||||
}
|
||||
|
||||
public static function pingAction(): Action
|
||||
{
|
||||
return Action::make('ping')
|
||||
->label(__('panel.webhooks.actions.ping'))
|
||||
->icon('heroicon-o-paper-airplane')
|
||||
->color('gray')
|
||||
->authorize('update')
|
||||
->action(function (Webhook $record): void {
|
||||
Webhooks::ping($record);
|
||||
|
||||
Notification::make()
|
||||
->title(__('panel.webhooks.actions.ping_sent'))
|
||||
->body(__('panel.webhooks.actions.ping_hint'))
|
||||
->success()
|
||||
->send();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A new secret, shown once. The old one stops being valid at once, so the
|
||||
* receiving end has to be updated straight after.
|
||||
*/
|
||||
public static function rotateAction(): Action
|
||||
{
|
||||
return Action::make('rotate_secret')
|
||||
->label(__('panel.webhooks.actions.rotate'))
|
||||
->icon('heroicon-o-key')
|
||||
->color('warning')
|
||||
->requiresConfirmation()
|
||||
->modalDescription(__('panel.webhooks.actions.rotate_hint'))
|
||||
->authorize('update')
|
||||
->action(function (Webhook $record): void {
|
||||
$secret = Str::random(40);
|
||||
$record->forceFill(['secret' => $secret])->save();
|
||||
|
||||
self::showSecret($secret);
|
||||
});
|
||||
}
|
||||
|
||||
public static function showSecret(string $secret): void
|
||||
{
|
||||
Notification::make()
|
||||
->title(__('panel.webhooks.secret.title'))
|
||||
->body(__('panel.webhooks.secret.body', ['secret' => $secret]))
|
||||
->warning()
|
||||
->persistent()
|
||||
->send();
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
DeliveriesRelationManager::class,
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListWebhooks::route('/'),
|
||||
'create' => CreateWebhook::route('/create'),
|
||||
'edit' => EditWebhook::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
61
app/Http/Controllers/Api/V1/PireonWebhookController.php
Normal file
61
app/Http/Controllers/Api/V1/PireonWebhookController.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Actions\Auth\HandlePireonWebhook;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
/**
|
||||
* Receives `user.deactivated` and `token.revoked` from the Pireon identity
|
||||
* server, called by it and not by apps.
|
||||
*
|
||||
* `X-Webhook-Signature` is the HMAC-SHA256 (hex) of the raw body with the
|
||||
* secret of the Pireon client that registered the webhook. This application
|
||||
* has two clients (password and redirect), so a delivery is good if it
|
||||
* verifies with either secret. With none configured the endpoint is off: an
|
||||
* open one would let anyone switch accounts off.
|
||||
*/
|
||||
class PireonWebhookController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, HandlePireonWebhook $handle): Response|JsonResponse
|
||||
{
|
||||
$secrets = (array) config('pireon.webhook_secrets', []);
|
||||
|
||||
if ($secrets === []) {
|
||||
return $this->refuse('Pireon webhooks are disabled (PIREON_WEBHOOK_SECRET is not set).');
|
||||
}
|
||||
|
||||
$signature = preg_replace('/^sha256=/', '', (string) $request->header('X-Webhook-Signature'));
|
||||
$body = $request->getContent();
|
||||
|
||||
$valid = $signature !== '' && collect($secrets)->contains(
|
||||
fn (string $secret): bool => hash_equals(hash_hmac('sha256', $body, $secret), $signature),
|
||||
);
|
||||
|
||||
if (! $valid) {
|
||||
return $this->refuse('Missing or invalid signature.');
|
||||
}
|
||||
|
||||
$event = (string) ($request->input('event') ?? $request->input('type') ?? '');
|
||||
|
||||
if ($event === '') {
|
||||
return response()->json(['message' => 'Missing event name.'], 400);
|
||||
}
|
||||
|
||||
$data = (array) ($request->input('data') ?? $request->input('payload') ?? $request->all());
|
||||
|
||||
$handle->handle($event, $data);
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
protected function refuse(string $message): JsonResponse
|
||||
{
|
||||
return response()->json(['message' => $message], 401);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Actions\Projects\OpenInTaskStream;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Api\V1\ProjectRequest;
|
||||
use App\Http\Resources\ProjectResource;
|
||||
@@ -45,11 +46,21 @@ class ProjectController extends Controller
|
||||
return ProjectResource::collection($projects);
|
||||
}
|
||||
|
||||
public function store(ProjectRequest $request): JsonResponse
|
||||
/**
|
||||
* With `taskstream: true`, the project's board is opened in TaskStream
|
||||
* first; if that fails, nothing is created here either.
|
||||
*/
|
||||
public function store(ProjectRequest $request, OpenInTaskStream $openInTaskStream): JsonResponse
|
||||
{
|
||||
$this->authorize('create', Project::class);
|
||||
|
||||
$project = $this->save(new Project, $request);
|
||||
$project = $this->fill(new Project, $request);
|
||||
|
||||
if ($request->boolean('taskstream')) {
|
||||
$openInTaskStream->handle($project, $request->user(), $request->bearerToken(), $request->validated('taskstream_key'));
|
||||
}
|
||||
|
||||
$project = $this->persist($project, $request);
|
||||
|
||||
return ProjectResource::make($project)->response()->setStatusCode(201);
|
||||
}
|
||||
@@ -61,11 +72,22 @@ class ProjectController extends Controller
|
||||
return ProjectResource::make($project->load(self::WITH));
|
||||
}
|
||||
|
||||
public function update(ProjectRequest $request, Project $project): ProjectResource
|
||||
/**
|
||||
* `taskstream: true` opens a board for a project that has none yet, as on
|
||||
* creation (and nothing is saved if that fails); one that has a board
|
||||
* keeps it.
|
||||
*/
|
||||
public function update(ProjectRequest $request, Project $project, OpenInTaskStream $openInTaskStream): ProjectResource
|
||||
{
|
||||
$this->authorize('update', $project);
|
||||
|
||||
return ProjectResource::make($this->save($project, $request));
|
||||
$project = $this->fill($project, $request);
|
||||
|
||||
if ($request->boolean('taskstream') && $project->taskstream_project_id === null) {
|
||||
$openInTaskStream->handle($project, $request->user(), $request->bearerToken(), $request->validated('taskstream_key'));
|
||||
}
|
||||
|
||||
return ProjectResource::make($this->persist($project, $request));
|
||||
}
|
||||
|
||||
public function destroy(Project $project): JsonResponse
|
||||
@@ -86,20 +108,24 @@ class ProjectController extends Controller
|
||||
return ProjectResource::make($project->load(self::WITH));
|
||||
}
|
||||
|
||||
protected function fill(Project $project, ProjectRequest $request): Project
|
||||
{
|
||||
return $project->fill(TranslatablePayload::merge(
|
||||
$project,
|
||||
collect($request->validated())->except(['technology_ids', 'service_ids', 'members', 'taskstream', 'taskstream_key'])->all(),
|
||||
$project->translatable,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* The record and its relations in one transaction: a project that saved
|
||||
* but lost its team halfway would be worse than one that did not save.
|
||||
*/
|
||||
protected function save(Project $project, ProjectRequest $request): Project
|
||||
protected function persist(Project $project, ProjectRequest $request): Project
|
||||
{
|
||||
$data = $request->validated();
|
||||
|
||||
DB::transaction(function () use ($project, $data): void {
|
||||
$project->fill(TranslatablePayload::merge(
|
||||
$project,
|
||||
collect($data)->except(['technology_ids', 'service_ids', 'members'])->all(),
|
||||
$project->translatable,
|
||||
));
|
||||
$project->save();
|
||||
|
||||
if (array_key_exists('technology_ids', $data)) {
|
||||
|
||||
@@ -8,9 +8,9 @@ use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Api\V1\WebhookRequest;
|
||||
use App\Http\Resources\WebhookDeliveryResource;
|
||||
use App\Http\Resources\WebhookResource;
|
||||
use App\Jobs\DeliverWebhook;
|
||||
use App\Models\Webhook;
|
||||
use App\Models\WebhookDelivery;
|
||||
use App\Support\Webhooks\Webhooks;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
@@ -115,15 +115,9 @@ class WebhookController extends Controller
|
||||
{
|
||||
$this->authorize('update', $webhook);
|
||||
|
||||
$delivery = $webhook->deliveries()->create([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'event' => 'ping',
|
||||
'payload' => ['webhook' => $webhook->id, 'events' => $webhook->events],
|
||||
]);
|
||||
$delivery = Webhooks::ping($webhook);
|
||||
|
||||
DeliverWebhook::dispatch($delivery);
|
||||
|
||||
return WebhookDeliveryResource::make($delivery->refresh())->response()->setStatusCode(202);
|
||||
return WebhookDeliveryResource::make($delivery)->response()->setStatusCode(202);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -133,14 +127,8 @@ class WebhookController extends Controller
|
||||
{
|
||||
$this->authorize('update', $delivery->webhook);
|
||||
|
||||
$again = $delivery->webhook->deliveries()->create([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'event' => $delivery->event,
|
||||
'payload' => $delivery->payload,
|
||||
]);
|
||||
$again = Webhooks::redeliver($delivery);
|
||||
|
||||
DeliverWebhook::dispatch($again);
|
||||
|
||||
return WebhookDeliveryResource::make($again->refresh())->response()->setStatusCode(202);
|
||||
return WebhookDeliveryResource::make($again)->response()->setStatusCode(202);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ use Illuminate\Support\Facades\Log;
|
||||
*
|
||||
* The browser never sees a password belonging to this application, and the
|
||||
* authorization code alone is useless without the verifier we keep server-side.
|
||||
* It is «Entrar con Pireon» on the login page; it uses the redirect client
|
||||
* (`PireonClient::forRedirect()`), its own one when configured.
|
||||
*/
|
||||
class PireonAuthController extends Controller
|
||||
{
|
||||
@@ -38,7 +40,7 @@ class PireonAuthController extends Controller
|
||||
}
|
||||
|
||||
try {
|
||||
['url' => $url, 'state' => $state, 'code_verifier' => $verifier] = $this->client->authorizationUrl();
|
||||
['url' => $url, 'state' => $state, 'code_verifier' => $verifier] = PireonClient::forRedirect()->authorizationUrl();
|
||||
} catch (PireonException $e) {
|
||||
return $this->fail($e, route('login'));
|
||||
}
|
||||
@@ -81,7 +83,7 @@ class PireonAuthController extends Controller
|
||||
}
|
||||
|
||||
try {
|
||||
$token = $this->client->exchangeCode((string) $request->query('code'), (string) $verifier);
|
||||
$token = PireonClient::forRedirect()->exchangeCode((string) $request->query('code'), (string) $verifier);
|
||||
$identity = $this->client->identity($token->accessToken);
|
||||
} catch (PireonException $e) {
|
||||
return $this->fail($e, route('login'));
|
||||
@@ -100,7 +102,7 @@ class PireonAuthController extends Controller
|
||||
$request->session()->regenerate();
|
||||
|
||||
Auth::login($user, remember: true);
|
||||
$this->tokens->put($token);
|
||||
$this->tokens->put($token, viaRedirect: true);
|
||||
|
||||
if ($user->locale) {
|
||||
$request->session()->put(config('locales.session_key'), $user->locale);
|
||||
|
||||
@@ -19,6 +19,10 @@ use Illuminate\Validation\Validator;
|
||||
*
|
||||
* Relations travel as ids and are replaced whole when sent: `technology_ids`,
|
||||
* `service_ids`, and `members` with each one's role on the project.
|
||||
*
|
||||
* `taskstream` opens a public board in TaskStream for the project, keyed
|
||||
* `taskstream_key` or one derived from the slug; on an update, only when the
|
||||
* project has none yet.
|
||||
*/
|
||||
class ProjectRequest extends FormRequest
|
||||
{
|
||||
@@ -74,6 +78,11 @@ class ProjectRequest extends FormRequest
|
||||
'members' => ['sometimes', 'array'],
|
||||
'members.*.id' => ['required', 'integer', 'distinct', Rule::exists('members', 'id')->whereNull('deleted_at')],
|
||||
'members.*.role' => ['nullable', 'string', 'max:120'],
|
||||
|
||||
// The key is only read along with `taskstream`: a client PATCHing
|
||||
// back what it read sends the key it got, which changes nothing.
|
||||
'taskstream' => ['sometimes', 'boolean'],
|
||||
'taskstream_key' => ['sometimes', 'nullable', 'string', 'regex:/^[A-Za-z][A-Za-z0-9]{1,9}$/'],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -5,10 +5,9 @@ declare(strict_types=1);
|
||||
namespace App\Http\Requests\Api\V1;
|
||||
|
||||
use App\Models\Webhook;
|
||||
use App\Support\Webhooks\PublicEndpoint;
|
||||
use App\Rules\PublicHttpsUrl;
|
||||
use Closure;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class WebhookRequest extends FormRequest
|
||||
{
|
||||
@@ -34,16 +33,18 @@ class WebhookRequest extends FormRequest
|
||||
'max:2048',
|
||||
// Refused now rather than at the first delivery; checked
|
||||
// again at every delivery all the same.
|
||||
new PublicHttpsUrl,
|
||||
],
|
||||
'events' => [$sometimes, 'array', 'min:1'],
|
||||
'events.*' => [
|
||||
'string',
|
||||
'distinct',
|
||||
function (string $attribute, mixed $value, Closure $fail): void {
|
||||
[, $reason] = app(PublicEndpoint::class)->check((string) $value);
|
||||
|
||||
if ($reason !== null) {
|
||||
$fail(__("rules.media.remote.{$reason}"));
|
||||
if (! is_string($value) || ! Webhook::isSubscribable($value)) {
|
||||
$fail(__('rules.webhooks.event', ['event' => (string) (is_scalar($value) ? $value : '')]));
|
||||
}
|
||||
},
|
||||
],
|
||||
'events' => [$sometimes, 'array', 'min:1'],
|
||||
'events.*' => ['string', 'distinct', Rule::in(['*', ...Webhook::EVENTS])],
|
||||
'secret' => [$creating ? 'sometimes' : 'prohibited', 'nullable', 'string', 'min:16', 'max:255'],
|
||||
'rotate_secret' => [$creating ? 'prohibited' : 'sometimes', 'boolean'],
|
||||
'include_personal_data' => ['sometimes', 'boolean'],
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Services\TaskStream\TaskStreamClient;
|
||||
use App\Support\ImagePayload;
|
||||
use App\Support\TranslatablePayload;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -33,6 +34,10 @@ class ProjectResource extends JsonResource
|
||||
'is_featured' => (bool) $this->is_featured,
|
||||
'needs_help' => (bool) $this->needs_help,
|
||||
'demo_url' => $this->demo_url,
|
||||
'taskstream_key' => $this->taskstream_key,
|
||||
'taskstream_url' => $this->taskstream_key !== null
|
||||
? app(TaskStreamClient::class)->projectUrl($this->taskstream_key)
|
||||
: null,
|
||||
// The stored path, which is what a client PATCHes back; what was
|
||||
// derived from it travels alongside, as on a page.
|
||||
'cover_image' => $this->cover_image,
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Http\Resources\PublicApi;
|
||||
|
||||
use App\Models\Media;
|
||||
use App\Models\Project;
|
||||
use App\Services\TaskStream\TaskStreamClient;
|
||||
use App\Support\ImagePayload;
|
||||
use App\Support\SiteUrls;
|
||||
use App\Support\TranslatablePayload;
|
||||
@@ -110,11 +111,30 @@ class PublicProjectResource extends JsonResource
|
||||
// alive without anyone maintaining it.
|
||||
'repositories' => PublicRepositoryResource::collection($this->whenLoaded('repositories')),
|
||||
|
||||
// Its board in TaskStream, when it has one: the public site reads
|
||||
// the roadmap, releases and tickets for newcomers from there.
|
||||
'taskstream' => $this->taskstream_key === null ? null : $this->taskstream(),
|
||||
|
||||
'alternates' => SiteUrls::alternates($this->resource, 'project'),
|
||||
'path' => SiteUrls::alternates($this->resource, 'project')[$locale] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{key: string, url: string, web_url: string, showcase_url: string}
|
||||
*/
|
||||
protected function taskstream(): array
|
||||
{
|
||||
$client = app(TaskStreamClient::class);
|
||||
|
||||
return [
|
||||
'key' => $this->taskstream_key,
|
||||
'url' => $client->projectUrl($this->taskstream_key),
|
||||
'web_url' => $client->webUrl(),
|
||||
'showcase_url' => $client->showcaseUrl($this->taskstream_key),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The media row when the caller loaded it, and null otherwise — never a
|
||||
* lazy load, because this runs once per row in a listing.
|
||||
|
||||
@@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Somewhere to tell when something happens here.
|
||||
@@ -21,7 +22,7 @@ class Webhook extends Model
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* What can be subscribed to. `*` is all of them.
|
||||
* Events with a meaning of their own, whatever record they are about.
|
||||
*/
|
||||
public const EVENTS = [
|
||||
'application.received',
|
||||
@@ -31,6 +32,53 @@ class Webhook extends Model
|
||||
'review.returned',
|
||||
];
|
||||
|
||||
/**
|
||||
* Kinds of record whose every change can be subscribed to, as
|
||||
* `{resource}.{action}`. Not users, tokens, webhooks or settings: those
|
||||
* are the application's security, not its content, and do not leave it.
|
||||
*/
|
||||
public const RESOURCES = [
|
||||
'pages', 'categories', 'tags', 'projects', 'members', 'services',
|
||||
'clients', 'testimonials', 'talks', 'technologies', 'calls',
|
||||
'redirects', 'repositories', 'media', 'applications',
|
||||
];
|
||||
|
||||
public const ACTIONS = ['created', 'updated', 'deleted', 'restored'];
|
||||
|
||||
/**
|
||||
* Every event that is ever sent.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function events(): array
|
||||
{
|
||||
$changes = [];
|
||||
|
||||
foreach (self::RESOURCES as $resource) {
|
||||
foreach (self::ACTIONS as $action) {
|
||||
$changes[] = "{$resource}.{$action}";
|
||||
}
|
||||
}
|
||||
|
||||
return [...self::EVENTS, ...$changes];
|
||||
}
|
||||
|
||||
/**
|
||||
* What a webhook may subscribe to: an event, `*`, everything about one
|
||||
* kind of record (`projects.*`) or one action on any (`*.deleted`).
|
||||
*/
|
||||
public static function isSubscribable(string $pattern): bool
|
||||
{
|
||||
if ($pattern === '*' || in_array($pattern, self::events(), true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
[$resource, $action] = array_pad(explode('.', $pattern, 2), 2, null);
|
||||
|
||||
return ($action === '*' && in_array($resource, self::RESOURCES, true))
|
||||
|| ($resource === '*' && in_array($action, self::ACTIONS, true));
|
||||
}
|
||||
|
||||
/**
|
||||
* Switched off after this many failed deliveries in a row, so a dead
|
||||
* endpoint does not keep a queue busy forever. Switching it back on is a
|
||||
@@ -64,9 +112,13 @@ class Webhook extends Model
|
||||
|
||||
public function listensTo(string $event): bool
|
||||
{
|
||||
$events = $this->events ?? [];
|
||||
foreach ($this->events ?? [] as $pattern) {
|
||||
if ($pattern === $event || Str::is($pattern, $event)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return in_array('*', $events, true) || in_array($event, $events, true);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -32,7 +32,7 @@ class ExpiringSoon extends Notification
|
||||
return FilamentNotification::make()
|
||||
->title(trans_choice('panel.expiring.notification.title', $this->items->count(), ['count' => $this->items->count()]))
|
||||
->body($this->items
|
||||
->map(fn (array $item) => $item['title'].' — '.__('panel.expiring.events.'.$item['event'], [
|
||||
->map(fn (array $item) => $item['title'].' — '.__("panel.expiring.events.{$item['event']}", [
|
||||
'when' => $item['at']->diffForHumans(),
|
||||
'date' => $item['at']->isoFormat('LL'),
|
||||
]))
|
||||
|
||||
@@ -6,12 +6,14 @@ namespace App\Providers;
|
||||
|
||||
use App\Auth\PireonTokenGuard;
|
||||
use App\Models\Application;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\ReviewComment;
|
||||
use App\Models\Revision;
|
||||
use App\Models\User;
|
||||
use App\Services\Git\GitManager;
|
||||
use App\Services\Pireon\PireonClient;
|
||||
use App\Services\Pireon\TokenStore;
|
||||
use App\Services\TaskStream\TaskStreamClient;
|
||||
use App\Support\Audit\Auditor;
|
||||
use App\Support\Webhooks\Webhooks;
|
||||
use Illuminate\Auth\Events\Login;
|
||||
@@ -42,6 +44,8 @@ class AppServiceProvider extends ServiceProvider
|
||||
|
||||
// Resolves git hosting drivers; see config/git.php.
|
||||
$this->app->singleton(GitManager::class);
|
||||
|
||||
$this->app->singleton(TaskStreamClient::class, fn () => TaskStreamClient::fromConfig(config('services.taskstream')));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,6 +68,7 @@ class AppServiceProvider extends ServiceProvider
|
||||
Application::created(fn (Application $application) => Webhooks::applicationReceived($application));
|
||||
Revision::created(fn (Revision $revision) => Webhooks::revisionRecorded($revision));
|
||||
ReviewComment::created(fn (ReviewComment $comment) => Webhooks::reviewRecorded($comment));
|
||||
AuditLog::created(fn (AuditLog $entry) => Webhooks::auditRecorded($entry));
|
||||
|
||||
// The audit log: every change a person makes to an audited record,
|
||||
// role changes, and signing in to the panel.
|
||||
|
||||
25
app/Rules/PublicHttpsUrl.php
Normal file
25
app/Rules/PublicHttpsUrl.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use App\Support\Webhooks\PublicEndpoint;
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
|
||||
/**
|
||||
* An HTTPS address whose host resolves only to public addresses — somewhere
|
||||
* this server may send a request to without reaching into its own network.
|
||||
*/
|
||||
class PublicHttpsUrl implements ValidationRule
|
||||
{
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
[, $reason] = app(PublicEndpoint::class)->check((string) $value);
|
||||
|
||||
if ($reason !== null) {
|
||||
$fail(__("rules.media.remote.{$reason}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,22 @@ class PireonClient
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The client for the redirect flow: its own when PIREON_REDIRECT_CLIENT_ID
|
||||
* is set (a Pireon client allows one grant type), the main one otherwise.
|
||||
*/
|
||||
public static function forRedirect(): self
|
||||
{
|
||||
$own = filled(config('pireon.redirect_client_id'));
|
||||
|
||||
return new self(
|
||||
server: (string) config('pireon.server'),
|
||||
clientId: $own ? config('pireon.redirect_client_id') : config('pireon.client_id'),
|
||||
clientSecret: $own ? config('pireon.redirect_client_secret') : config('pireon.client_secret'),
|
||||
redirectUri: (string) config('pireon.redirect_uri'),
|
||||
);
|
||||
}
|
||||
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return filled($this->clientId);
|
||||
|
||||
@@ -18,14 +18,25 @@ class TokenStore
|
||||
{
|
||||
protected const KEY = 'pireon.token';
|
||||
|
||||
/** Which client issued it: a refresh token only works with that one. */
|
||||
protected const ISSUER = 'pireon.token_issuer';
|
||||
|
||||
public function __construct(
|
||||
protected Session $session,
|
||||
protected PireonClient $client,
|
||||
) {}
|
||||
|
||||
public function put(AccessToken $token): void
|
||||
/**
|
||||
* @param bool|null $viaRedirect Whether the redirect flow's client
|
||||
* issued it; null keeps what was said.
|
||||
*/
|
||||
public function put(AccessToken $token, ?bool $viaRedirect = null): void
|
||||
{
|
||||
$this->session->put(self::KEY, $token->toArray());
|
||||
|
||||
if ($viaRedirect !== null) {
|
||||
$this->session->put(self::ISSUER, $viaRedirect ? 'redirect' : 'password');
|
||||
}
|
||||
}
|
||||
|
||||
public function get(): ?AccessToken
|
||||
@@ -37,7 +48,7 @@ class TokenStore
|
||||
|
||||
public function forget(): void
|
||||
{
|
||||
$this->session->forget(self::KEY);
|
||||
$this->session->forget([self::KEY, self::ISSUER]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,7 +77,10 @@ class TokenStore
|
||||
}
|
||||
|
||||
try {
|
||||
$refreshed = $this->client->refresh($token->refreshToken);
|
||||
$issuer = $this->session->get(self::ISSUER) === 'redirect'
|
||||
? PireonClient::forRedirect()
|
||||
: $this->client;
|
||||
$refreshed = $issuer->refresh($token->refreshToken);
|
||||
} catch (PireonException) {
|
||||
// The refresh token was revoked or expired upstream.
|
||||
$this->forget();
|
||||
|
||||
105
app/Services/TaskStream/TaskStreamClient.php
Normal file
105
app/Services/TaskStream/TaskStreamClient.php
Normal file
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\TaskStream;
|
||||
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
/**
|
||||
* The few calls this application makes to TaskStream's REST API.
|
||||
*
|
||||
* Every call is made as somebody: TaskStream accepts the same Pireon tokens
|
||||
* this application does, and whoever's token it is becomes the owner of
|
||||
* what is created.
|
||||
*/
|
||||
class TaskStreamClient
|
||||
{
|
||||
public function __construct(
|
||||
protected string $apiUrl,
|
||||
protected string $webUrl,
|
||||
protected ?string $serviceToken = null,
|
||||
protected int $timeout = 10,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $config
|
||||
*/
|
||||
public static function fromConfig(array $config): self
|
||||
{
|
||||
return new self(
|
||||
apiUrl: rtrim((string) ($config['api_url'] ?? ''), '/'),
|
||||
webUrl: rtrim((string) ($config['web_url'] ?? ''), '/'),
|
||||
serviceToken: filled($config['token'] ?? null) ? $config['token'] : null,
|
||||
timeout: (int) ($config['timeout'] ?? 10),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The token for requests that do not carry a Pireon token of their own.
|
||||
*/
|
||||
public function serviceToken(): ?string
|
||||
{
|
||||
return $this->serviceToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{id: string, key: string}
|
||||
*
|
||||
* @throws TaskStreamException
|
||||
*/
|
||||
public function createProject(string $token, string $key, string $name, ?string $description, bool $public): array
|
||||
{
|
||||
try {
|
||||
$response = Http::withToken($token)
|
||||
->acceptJson()
|
||||
->timeout($this->timeout)
|
||||
->post("{$this->apiUrl}/api/projects", array_filter([
|
||||
'key' => $key,
|
||||
'name' => $name,
|
||||
'description' => $description,
|
||||
'isPublic' => $public,
|
||||
], fn ($value) => $value !== null));
|
||||
} catch (ConnectionException $e) {
|
||||
throw TaskStreamException::unreachable($this->apiUrl, $e);
|
||||
}
|
||||
|
||||
if ($response->failed()) {
|
||||
// Nest answers `message` as a string, or as a list from validation.
|
||||
$message = $response->json('message');
|
||||
|
||||
throw new TaskStreamException(
|
||||
is_array($message) ? implode(' ', $message) : (string) ($message ?: $response->reason()),
|
||||
status: $response->status(),
|
||||
);
|
||||
}
|
||||
|
||||
return ['id' => (string) $response->json('id'), 'key' => (string) $response->json('key')];
|
||||
}
|
||||
|
||||
/**
|
||||
* Where people open a project's board.
|
||||
*/
|
||||
public function projectUrl(string $key): string
|
||||
{
|
||||
return "{$this->webUrl}/p/{$key}";
|
||||
}
|
||||
|
||||
/**
|
||||
* The public summary of a board (roadmap, releases, sprint, tickets for
|
||||
* newcomers). Anyone may read it, so the public site can ask directly.
|
||||
*/
|
||||
public function showcaseUrl(string $key): string
|
||||
{
|
||||
return "{$this->apiUrl}/api/explore/projects/{$key}/showcase";
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the paths of a showcase (`/p/KEY/issues/KEY-3`) are opened.
|
||||
*/
|
||||
public function webUrl(): string
|
||||
{
|
||||
return $this->webUrl;
|
||||
}
|
||||
}
|
||||
41
app/Services/TaskStream/TaskStreamException.php
Normal file
41
app/Services/TaskStream/TaskStreamException.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\TaskStream;
|
||||
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class TaskStreamException extends RuntimeException
|
||||
{
|
||||
public function __construct(
|
||||
string $message,
|
||||
public readonly ?int $status = null,
|
||||
?Throwable $previous = null,
|
||||
) {
|
||||
parent::__construct($message, $status ?? 0, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* TaskStream refused what it was asked for (a key in use, a bad name):
|
||||
* the caller can fix it and try again.
|
||||
*/
|
||||
public function isRefusal(): bool
|
||||
{
|
||||
return in_array($this->status, [400, 409, 422], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* The token was not good enough there: expired, or without access.
|
||||
*/
|
||||
public function isUnauthorised(): bool
|
||||
{
|
||||
return in_array($this->status, [401, 403], true);
|
||||
}
|
||||
|
||||
public static function unreachable(string $url, ?Throwable $previous = null): self
|
||||
{
|
||||
return new self("TaskStream could not be reached at [{$url}].", previous: $previous);
|
||||
}
|
||||
}
|
||||
@@ -76,19 +76,19 @@ final class Auditor
|
||||
/**
|
||||
* @param array<string, mixed>|null $changes
|
||||
*/
|
||||
public static function record(string $action, ?Model $subject = null, ?array $changes = null, ?User $actor = null): void
|
||||
public static function record(string $action, ?Model $subject = null, ?array $changes = null, ?User $actor = null): ?AuditLog
|
||||
{
|
||||
$actor ??= Auth::user();
|
||||
|
||||
if (! $actor instanceof User) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
$request = app()->bound('request') ? request() : null;
|
||||
$route = $request?->route();
|
||||
$token = $actor->currentAccessToken();
|
||||
|
||||
AuditLog::query()->create([
|
||||
return AuditLog::query()->create([
|
||||
'user_id' => $actor->id,
|
||||
'user_name' => $actor->name,
|
||||
'via' => self::via($route?->getName(), $token !== null),
|
||||
|
||||
@@ -32,10 +32,12 @@ final class Duplicator
|
||||
];
|
||||
|
||||
/**
|
||||
* Never copied: where it stands rather than what it says.
|
||||
* Never copied: where it stands rather than what it says — and the
|
||||
* TaskStream board, which belongs to the original alone.
|
||||
*/
|
||||
protected const RESET = [
|
||||
'published_at', 'unpublish_at', 'review_requested_at', 'review_requested_by', 'reviewer_id',
|
||||
'taskstream_project_id', 'taskstream_key',
|
||||
];
|
||||
|
||||
public static function supports(Model $record): bool
|
||||
|
||||
@@ -6,10 +6,14 @@ namespace App\Support\Webhooks;
|
||||
|
||||
use App\Jobs\DeliverWebhook;
|
||||
use App\Models\Application;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Member;
|
||||
use App\Models\ReviewComment;
|
||||
use App\Models\Revision;
|
||||
use App\Models\Webhook;
|
||||
use App\Models\WebhookDelivery;
|
||||
use App\Support\AdminContent;
|
||||
use App\Support\Audit\Auditor;
|
||||
use App\Support\ContentTypes;
|
||||
use App\Support\SiteUrls;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@@ -43,6 +47,38 @@ final class Webhooks
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A `ping`, to check the receiving end before anything real happens.
|
||||
*/
|
||||
public static function ping(Webhook $webhook): WebhookDelivery
|
||||
{
|
||||
$delivery = $webhook->deliveries()->create([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'event' => 'ping',
|
||||
'payload' => ['webhook' => $webhook->id, 'events' => $webhook->events],
|
||||
]);
|
||||
|
||||
DeliverWebhook::dispatch($delivery);
|
||||
|
||||
return $delivery->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* One sent again, as a new delivery of the same event and payload.
|
||||
*/
|
||||
public static function redeliver(WebhookDelivery $delivery): WebhookDelivery
|
||||
{
|
||||
$again = $delivery->webhook->deliveries()->create([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'event' => $delivery->event,
|
||||
'payload' => $delivery->payload,
|
||||
]);
|
||||
|
||||
DeliverWebhook::dispatch($again);
|
||||
|
||||
return $again->refresh();
|
||||
}
|
||||
|
||||
public static function applicationReceived(Application $application): void
|
||||
{
|
||||
self::dispatch('application.received', [
|
||||
@@ -60,6 +96,90 @@ final class Webhooks
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* `projects.created`, `pages.updated`, `media.deleted`…: whatever a person
|
||||
* did to a record, from the audit log — which already leaves out what the
|
||||
* application does by itself, and already masks secrets, so the two
|
||||
* cannot disagree about what happened.
|
||||
*/
|
||||
public static function auditRecorded(AuditLog $entry): void
|
||||
{
|
||||
[$resource, $action] = array_pad(explode('.', $entry->action, 2), 2, null);
|
||||
|
||||
if (! in_array($resource, Webhook::RESOURCES, true) || ! in_array($action, Webhook::ACTIONS, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$record = self::find($resource, $entry->subject_id);
|
||||
$changes = $entry->changes;
|
||||
|
||||
$data = [
|
||||
'type' => $resource,
|
||||
'id' => is_numeric($entry->subject_id) ? (int) $entry->subject_id : $entry->subject_id,
|
||||
'title' => $entry->subject_label,
|
||||
'action' => $action,
|
||||
'by' => ['id' => $entry->user_id, 'name' => $entry->user_name],
|
||||
'via' => $entry->via,
|
||||
'changes' => $changes,
|
||||
'url' => $action === 'deleted' || $record === null ? null : self::publicUrl($record),
|
||||
];
|
||||
|
||||
$personal = [];
|
||||
|
||||
// A submission is somebody else's words: what is sent about it by
|
||||
// default is how it is being handled, nothing they wrote.
|
||||
if ($resource === 'applications') {
|
||||
$data['title'] = $record instanceof Application ? $record->subject() : null;
|
||||
$data['changes'] = $changes === null ? null : array_intersect_key($changes, array_flip(['status', 'handled_by', 'handled_at']));
|
||||
$personal = [
|
||||
'name' => $record?->name,
|
||||
'email' => $record?->email,
|
||||
'changes' => $changes,
|
||||
];
|
||||
}
|
||||
|
||||
self::dispatch($entry->action, $data, $personal);
|
||||
}
|
||||
|
||||
protected static function find(string $resource, ?string $id): ?Model
|
||||
{
|
||||
$model = array_search($resource, Auditor::AUDITED, true);
|
||||
|
||||
if ($model === false || $id === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$query = $model::query();
|
||||
|
||||
if (method_exists($model, 'bootSoftDeletes')) {
|
||||
$query->withTrashed();
|
||||
}
|
||||
|
||||
return $query->find($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where it can be seen on the site, when it can be.
|
||||
*/
|
||||
protected static function publicUrl(Model $record): ?string
|
||||
{
|
||||
if (method_exists($record, 'isPublishedNow') && ! $record->isPublishedNow()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($record instanceof Member) {
|
||||
return $record->is_public
|
||||
? rtrim((string) config('site.url'), '/').SiteUrls::path('member', (string) config('locales.default'), $record->slug)
|
||||
: null;
|
||||
}
|
||||
|
||||
$siteType = AdminContent::PREVIEWABLE[AdminContent::nameOf($record) ?? ''] ?? null;
|
||||
|
||||
return $siteType
|
||||
? (SiteUrls::absoluteAlternates($record, $siteType)[(string) config('locales.default')] ?? null)
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishing and unpublishing are already told apart by the history, for
|
||||
* every kind of record and from wherever it happened; this listens there.
|
||||
|
||||
@@ -31,7 +31,28 @@ return [
|
||||
| Where the identity server sends the user back after they authorise us.
|
||||
| Must match one of the redirect URIs registered for the client.
|
||||
*/
|
||||
'redirect_uri' => env('PIREON_REDIRECT_URI', env('APP_URL', 'http://localhost').'/auth/callback'),
|
||||
// Compose passes an unset variable as an empty one: empty means default.
|
||||
'redirect_uri' => env('PIREON_REDIRECT_URI') ?: rtrim((string) env('APP_URL', 'http://localhost'), '/').'/auth/callback',
|
||||
|
||||
/*
|
||||
| The redirect flow («Entrar con Pireon», Authorization Code + PKCE) can
|
||||
| use a client of its own. A Pireon client allows one grant type, so when
|
||||
| the one above is a password client, register another for
|
||||
| authorization_code (redirect URI as above) and put it here. Left empty,
|
||||
| the client above is used for both.
|
||||
*/
|
||||
'redirect_client_id' => env('PIREON_REDIRECT_CLIENT_ID'),
|
||||
'redirect_client_secret' => env('PIREON_REDIRECT_CLIENT_SECRET'),
|
||||
|
||||
/*
|
||||
| Offer «Entrar con Pireon» under the e-mail and password form. By default
|
||||
| only once a redirect client is set: the main one is usually a password
|
||||
| client, and the button would fail with it. Set it to true when the main
|
||||
| client allows authorization_code too.
|
||||
*/
|
||||
'redirect_login' => filled(env('PIREON_REDIRECT_LOGIN'))
|
||||
? (bool) env('PIREON_REDIRECT_LOGIN')
|
||||
: filled(env('PIREON_REDIRECT_CLIENT_ID')),
|
||||
|
||||
/*
|
||||
| Scopes requested for panel sessions. We only ever need to read the
|
||||
@@ -77,6 +98,25 @@ return [
|
||||
|
||||
'introspection_ttl' => (int) env('PIREON_INTROSPECTION_TTL', 60),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Webhooks (POST /api/v1/webhooks/pireon)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| `user.deactivated` and `token.revoked` take effect at once instead of
|
||||
| when the cache above runs out. Pireon signs each delivery with the secret
|
||||
| of the client that registered the webhook, and only the client that
|
||||
| issued a token tells when it is revoked: register the webhook on both
|
||||
| clients (password and redirect) and put each secret here. With none, the
|
||||
| endpoint refuses everything.
|
||||
|
|
||||
*/
|
||||
|
||||
'webhook_secrets' => array_values(array_filter([
|
||||
env('PIREON_WEBHOOK_SECRET'),
|
||||
env('PIREON_REDIRECT_WEBHOOK_SECRET'),
|
||||
])),
|
||||
|
||||
'http_timeout' => (int) env('PIREON_HTTP_TIMEOUT', 10),
|
||||
|
||||
/*
|
||||
|
||||
@@ -35,4 +35,23 @@ return [
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
| TaskStream, the collective's issue tracker. Creating a project here can
|
||||
| open its board there too (`taskstream: true`); it is made public.
|
||||
*/
|
||||
'taskstream' => [
|
||||
'api_url' => env('TASKSTREAM_API_URL', 'https://api.taskstream.pireon.dev'),
|
||||
'web_url' => env('TASKSTREAM_WEB_URL', 'https://taskstream.pireon.dev'),
|
||||
|
||||
/*
|
||||
| TaskStream only understands Pireon tokens. A person signed in with
|
||||
| one has it forwarded, so the board is theirs; a machine token means
|
||||
| nothing there, so its requests use this one instead — a Pireon
|
||||
| personal access token. Without it, they cannot ask for a board.
|
||||
*/
|
||||
'token' => env('TASKSTREAM_TOKEN'),
|
||||
|
||||
'timeout' => (int) env('TASKSTREAM_HTTP_TIMEOUT', 10),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* The board a project has in TaskStream, when it was opened from here: its
|
||||
* id there, and the key its address is made from.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('projects', function (Blueprint $table) {
|
||||
$table->string('taskstream_project_id', 64)->nullable()->after('demo_url');
|
||||
$table->string('taskstream_key', 10)->nullable()->after('taskstream_project_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('projects', function (Blueprint $table) {
|
||||
$table->dropColumn(['taskstream_project_id', 'taskstream_key']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -91,6 +91,13 @@ x-app-env: &app-env
|
||||
PIREON_CLIENT_ID: ${PIREON_CLIENT_ID}
|
||||
PIREON_CLIENT_SECRET: ${PIREON_CLIENT_SECRET}
|
||||
PIREON_REDIRECT_URI: ${PIREON_REDIRECT_URI}
|
||||
# «Entrar con Pireon»: its own authorization_code client (the button shows
|
||||
# up once it is set), and the webhook secret of each client.
|
||||
PIREON_REDIRECT_CLIENT_ID: ${PIREON_REDIRECT_CLIENT_ID:-}
|
||||
PIREON_REDIRECT_CLIENT_SECRET: ${PIREON_REDIRECT_CLIENT_SECRET:-}
|
||||
PIREON_REDIRECT_LOGIN: ${PIREON_REDIRECT_LOGIN:-}
|
||||
PIREON_WEBHOOK_SECRET: ${PIREON_WEBHOOK_SECRET:-}
|
||||
PIREON_REDIRECT_WEBHOOK_SECRET: ${PIREON_REDIRECT_WEBHOOK_SECRET:-}
|
||||
PIREON_SCOPES: ${PIREON_SCOPES:-account:read}
|
||||
PIREON_PASSWORD_GRANT: ${PIREON_PASSWORD_GRANT:-true}
|
||||
PIREON_INTROSPECTION_TTL: ${PIREON_INTROSPECTION_TTL:-60}
|
||||
|
||||
@@ -372,6 +372,22 @@ catalán /ca/projectes/gestor-de-socis</code></pre>
|
||||
<tr><td>Busca gente</td><td>Lo saca en la sección de colaboración de la web.</td></tr>
|
||||
</table>
|
||||
|
||||
<h3>Su tablero en TaskStream</h3>
|
||||
<p>
|
||||
Al crearlo, la casilla <strong>Crear también en TaskStream</strong> le abre un tablero
|
||||
público en <a href="https://taskstream.pireon.dev">TaskStream</a>, el gestor de tareas
|
||||
del colectivo, a tu nombre. La clave (la de los tickets: <code>WEB-1</code>,
|
||||
<code>WEB-2</code>…) se saca del slug, o puedes escribirla tú: de 2 a 10 letras o
|
||||
cifras, empezando por letra. Al guardar, el aviso trae el enlace, y desde entonces
|
||||
el botón <strong>Abrir en TaskStream</strong> de la ficha lleva al tablero.
|
||||
</p>
|
||||
<p>
|
||||
Si TaskStream no lo acepta (porque esa clave ya existe, por ejemplo), el proyecto
|
||||
tampoco se guarda aquí: cambia la clave y vuelve a guardar. Si lo creaste sin tablero,
|
||||
el botón <strong>Crear en TaskStream</strong> de su ficha se lo abre después, con la
|
||||
misma clave a elegir.
|
||||
</p>
|
||||
|
||||
<h3>Decisiones y postmortem</h3>
|
||||
<p>
|
||||
Es la sección plegada del formulario, y es la que hace que un proyecto merezca
|
||||
|
||||
Binary file not shown.
@@ -146,6 +146,88 @@ return [
|
||||
'select_all' => 'Selecciona-ho tot',
|
||||
],
|
||||
|
||||
'webhooks' => [
|
||||
'label' => 'Webhook',
|
||||
'events_hint' => 'Pots triar-ne diversos. «Tot de…» inclou qualsevol canvi en aquest tipus de fitxa.',
|
||||
'any_record' => 'Qualsevol fitxa · :action',
|
||||
'all_of' => 'Tot de :resource',
|
||||
'groups' => [
|
||||
'everything' => 'General',
|
||||
'notices' => 'Avisos',
|
||||
],
|
||||
'actions_on' => [
|
||||
'created' => 'Creada',
|
||||
'updated' => 'Editada',
|
||||
'deleted' => 'Esborrada',
|
||||
'restored' => 'Recuperada',
|
||||
],
|
||||
'resources' => [
|
||||
'pages' => 'Pàgines',
|
||||
'categories' => 'Categories',
|
||||
'tags' => 'Etiquetes',
|
||||
'projects' => 'Projectes',
|
||||
'members' => 'Membres',
|
||||
'services' => 'Serveis',
|
||||
'clients' => 'Clients',
|
||||
'testimonials' => 'Testimonis',
|
||||
'talks' => 'Xerrades',
|
||||
'technologies' => 'Tecnologies',
|
||||
'calls' => 'Convocatòries',
|
||||
'redirects' => 'Redireccions',
|
||||
'repositories' => 'Repositoris',
|
||||
'media' => 'Mitjans',
|
||||
'applications' => 'Bústia',
|
||||
],
|
||||
'plural' => 'Webhooks',
|
||||
'fields' => [
|
||||
'name' => 'Nom',
|
||||
'url' => 'URL',
|
||||
'url_hint' => 'HTTPS i una adreça pública: no s\'accepten localhost, IPs privades ni noms interns.',
|
||||
'events' => 'Esdeveniments',
|
||||
'include_personal_data' => 'Incloure dades personals',
|
||||
'include_personal_data_hint' => 'Només afecta «Sol·licitud rebuda»: afegeix el nom, el correu i el missatge de qui va escriure.',
|
||||
'is_active' => 'Actiu',
|
||||
'failures' => 'Errors seguits',
|
||||
'last_delivered_at' => 'Últim enviament correcte',
|
||||
],
|
||||
'events' => [
|
||||
'*' => 'Tots',
|
||||
'application_received' => 'Sol·licitud rebuda a la bústia',
|
||||
'content_published' => 'Contingut publicat',
|
||||
'content_unpublished' => 'Contingut retirat del web',
|
||||
'review_requested' => 'Revisió demanada',
|
||||
'review_returned' => 'Revisió retornada amb canvis',
|
||||
],
|
||||
'actions' => [
|
||||
'ping' => 'Enviar prova',
|
||||
'ping_sent' => 'Prova en camí',
|
||||
'ping_hint' => 'Mira el resultat a «Enviaments», més avall a la fitxa.',
|
||||
'rotate' => 'Canviar secret',
|
||||
'rotate_hint' => 'El secret actual deixa de valer a l\'acte: actualitza el servei que rep els avisos just després.',
|
||||
],
|
||||
'secret' => [
|
||||
'title' => 'Desa aquest secret',
|
||||
'body' => 'Signa cada enviament i no es tornarà a mostrar: :secret',
|
||||
],
|
||||
'deliveries' => [
|
||||
'title' => 'Enviaments',
|
||||
'event' => 'Esdeveniment',
|
||||
'status' => 'Estat',
|
||||
'response' => 'Resposta',
|
||||
'attempts' => 'Intents',
|
||||
'error' => 'Error',
|
||||
'created_at' => 'Quan',
|
||||
'payload' => 'Veure contingut',
|
||||
'redeliver' => 'Reenviar',
|
||||
'redelivered' => 'Reenviament en camí',
|
||||
'statuses' => [
|
||||
'pending' => 'Pendent',
|
||||
'succeeded' => 'Lliurat',
|
||||
'failed' => 'Fallit',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'tags' => [
|
||||
'label' => 'Etiqueta',
|
||||
'plural' => 'Etiquetes',
|
||||
@@ -439,6 +521,17 @@ return [
|
||||
'needs_help' => 'Busca gent',
|
||||
'needs_help_hint' => 'El mostra a la secció de col·laboració del web.',
|
||||
],
|
||||
'taskstream' => [
|
||||
'section' => 'TaskStream',
|
||||
'section_hint' => 'Un tauler públic a TaskStream, el gestor de tasques del col·lectiu, al teu nom.',
|
||||
'toggle' => 'Crear-lo també a TaskStream',
|
||||
'key' => 'Clau a TaskStream',
|
||||
'key_hint' => 'De 2 a 10 lletres o xifres, començant per lletra. Si la deixes buida, es treu de l\'slug.',
|
||||
'created' => 'Tauler creat a TaskStream: :url',
|
||||
'open' => 'Obrir a TaskStream',
|
||||
'create' => 'Crear a TaskStream',
|
||||
'create_heading' => 'Obrir un tauler a TaskStream',
|
||||
],
|
||||
'filters' => ['unpublished' => 'Sense publicar'],
|
||||
'actions' => ['publish' => 'Publica'],
|
||||
],
|
||||
|
||||
@@ -4,7 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'sign_in' => 'Entra',
|
||||
'sign_in_intro' => 'Accedeix amb el teu correu i la teva contrasenya.',
|
||||
'sign_in_intro' => 'Accedeix amb el teu correu i la teva contrasenya, o amb el teu compte de Pireon.',
|
||||
'continue_with_pireon' => 'Entra amb Pireon',
|
||||
'signing_in' => 'Comprovant les teves credencials…',
|
||||
'password_grant_disabled' => 'L\'accés amb contrasenya està desactivat en aquesta instal·lació. Avisa un administrador.',
|
||||
'server_unavailable' => 'El servidor d\'identitat no respon. No és cosa de les teves credencials; avisa un administrador.',
|
||||
|
||||
@@ -44,6 +44,18 @@ return [
|
||||
'too_many' => 'Com a molt :max files per importació.',
|
||||
],
|
||||
|
||||
'webhooks' => [
|
||||
'event' => '«:event» no és un esdeveniment al qual subscriure\'s.',
|
||||
],
|
||||
|
||||
'taskstream' => [
|
||||
'machine' => 'TaskStream només entén tokens de Pireon: entra amb un, o configura TASKSTREAM_TOKEN per als tokens de màquina.',
|
||||
'key_needed' => 'No es pot treure una clau de l\'slug: indica\'n una a «taskstream_key» (de 2 a 10 lletres o xifres, començant per lletra).',
|
||||
'refused' => 'TaskStream no ha creat el projecte: :reason',
|
||||
'unauthorised' => 'TaskStream ha rebutjat el token: potser ha caducat o no hi té accés.',
|
||||
'unavailable' => 'TaskStream no respon ara mateix; no s\'ha creat res, torna-ho a provar d\'aquí a una estona.',
|
||||
],
|
||||
|
||||
'tokens' => [
|
||||
'ability' => '«:ability» no és un permís que tinguis, així que un token teu no el pot tenir.',
|
||||
'machine' => 'Els tokens els gestiona una persona, no un altre token.',
|
||||
|
||||
@@ -146,6 +146,88 @@ return [
|
||||
'select_all' => 'Select all',
|
||||
],
|
||||
|
||||
'webhooks' => [
|
||||
'label' => 'Webhook',
|
||||
'events_hint' => 'Pick as many as you like. "All of…" covers any change to that kind of record.',
|
||||
'any_record' => 'Any record · :action',
|
||||
'all_of' => 'All of :resource',
|
||||
'groups' => [
|
||||
'everything' => 'General',
|
||||
'notices' => 'Notices',
|
||||
],
|
||||
'actions_on' => [
|
||||
'created' => 'Created',
|
||||
'updated' => 'Edited',
|
||||
'deleted' => 'Deleted',
|
||||
'restored' => 'Restored',
|
||||
],
|
||||
'resources' => [
|
||||
'pages' => 'Pages',
|
||||
'categories' => 'Categories',
|
||||
'tags' => 'Tags',
|
||||
'projects' => 'Projects',
|
||||
'members' => 'Members',
|
||||
'services' => 'Services',
|
||||
'clients' => 'Clients',
|
||||
'testimonials' => 'Testimonials',
|
||||
'talks' => 'Talks',
|
||||
'technologies' => 'Technologies',
|
||||
'calls' => 'Calls',
|
||||
'redirects' => 'Redirects',
|
||||
'repositories' => 'Repositories',
|
||||
'media' => 'Media',
|
||||
'applications' => 'Inbox',
|
||||
],
|
||||
'plural' => 'Webhooks',
|
||||
'fields' => [
|
||||
'name' => 'Name',
|
||||
'url' => 'URL',
|
||||
'url_hint' => 'HTTPS and a public address: localhost, private IPs and internal names are refused.',
|
||||
'events' => 'Events',
|
||||
'include_personal_data' => 'Include personal data',
|
||||
'include_personal_data_hint' => 'Only for "Submission received": adds the name, e-mail and message of whoever wrote.',
|
||||
'is_active' => 'Active',
|
||||
'failures' => 'Failures in a row',
|
||||
'last_delivered_at' => 'Last successful delivery',
|
||||
],
|
||||
'events' => [
|
||||
'*' => 'All',
|
||||
'application_received' => 'Submission received in the inbox',
|
||||
'content_published' => 'Content published',
|
||||
'content_unpublished' => 'Content taken off the site',
|
||||
'review_requested' => 'Review requested',
|
||||
'review_returned' => 'Review returned for changes',
|
||||
],
|
||||
'actions' => [
|
||||
'ping' => 'Send a test',
|
||||
'ping_sent' => 'Test on its way',
|
||||
'ping_hint' => 'See how it went under "Deliveries", further down.',
|
||||
'rotate' => 'Change secret',
|
||||
'rotate_hint' => 'The current secret stops working at once: update the receiving service straight after.',
|
||||
],
|
||||
'secret' => [
|
||||
'title' => 'Keep this secret',
|
||||
'body' => 'It signs every delivery and will not be shown again: :secret',
|
||||
],
|
||||
'deliveries' => [
|
||||
'title' => 'Deliveries',
|
||||
'event' => 'Event',
|
||||
'status' => 'Status',
|
||||
'response' => 'Response',
|
||||
'attempts' => 'Attempts',
|
||||
'error' => 'Error',
|
||||
'created_at' => 'When',
|
||||
'payload' => 'View content',
|
||||
'redeliver' => 'Send again',
|
||||
'redelivered' => 'Sending again',
|
||||
'statuses' => [
|
||||
'pending' => 'Pending',
|
||||
'succeeded' => 'Delivered',
|
||||
'failed' => 'Failed',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'tags' => [
|
||||
'label' => 'Tag',
|
||||
'plural' => 'Tags',
|
||||
@@ -437,6 +519,17 @@ return [
|
||||
'needs_help' => 'Looking for help',
|
||||
'needs_help_hint' => 'Surfaces it in the contribute section of the site.',
|
||||
],
|
||||
'taskstream' => [
|
||||
'section' => 'TaskStream',
|
||||
'section_hint' => 'A public board in TaskStream, the collective\'s issue tracker, in your name.',
|
||||
'toggle' => 'Also create it in TaskStream',
|
||||
'key' => 'Key in TaskStream',
|
||||
'key_hint' => '2 to 10 letters or digits, starting with a letter. Left empty, it is made from the slug.',
|
||||
'created' => 'Board created in TaskStream: :url',
|
||||
'open' => 'Open in TaskStream',
|
||||
'create' => 'Create in TaskStream',
|
||||
'create_heading' => 'Open a board in TaskStream',
|
||||
],
|
||||
'filters' => ['unpublished' => 'Unpublished'],
|
||||
'actions' => ['publish' => 'Publish'],
|
||||
],
|
||||
|
||||
@@ -4,7 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'sign_in' => 'Sign in',
|
||||
'sign_in_intro' => 'Sign in with your e-mail and password.',
|
||||
'sign_in_intro' => 'Sign in with your e-mail and password, or with your Pireon account.',
|
||||
'continue_with_pireon' => 'Sign in with Pireon',
|
||||
'signing_in' => 'Checking your credentials…',
|
||||
'password_grant_disabled' => 'Password sign-in is switched off on this installation. Please tell an administrator.',
|
||||
'server_unavailable' => 'The identity server is not responding. This is not about your credentials; please tell an administrator.',
|
||||
|
||||
@@ -44,6 +44,18 @@ return [
|
||||
'too_many' => 'At most :max rows per import.',
|
||||
],
|
||||
|
||||
'webhooks' => [
|
||||
'event' => '":event" is not an event that can be subscribed to.',
|
||||
],
|
||||
|
||||
'taskstream' => [
|
||||
'machine' => 'TaskStream only understands Pireon tokens: sign in with one, or set TASKSTREAM_TOKEN for machine tokens.',
|
||||
'key_needed' => 'No key can be made from the slug: give one in "taskstream_key" (2 to 10 letters or digits, starting with a letter).',
|
||||
'refused' => 'TaskStream did not create the project: :reason',
|
||||
'unauthorised' => 'TaskStream rejected the token: it may have expired or lack access.',
|
||||
'unavailable' => 'TaskStream is not answering right now; nothing was created, try again in a while.',
|
||||
],
|
||||
|
||||
'tokens' => [
|
||||
'ability' => '":ability" is not a permission you hold, so a token of yours cannot hold it.',
|
||||
'machine' => 'Tokens are managed by a person, not by another token.',
|
||||
|
||||
@@ -146,6 +146,88 @@ return [
|
||||
'select_all' => 'Seleccionar todos',
|
||||
],
|
||||
|
||||
'webhooks' => [
|
||||
'label' => 'Webhook',
|
||||
'events_hint' => 'Puedes elegir varios. «Todo de…» incluye cualquier cambio en ese tipo de ficha.',
|
||||
'any_record' => 'Cualquier ficha · :action',
|
||||
'all_of' => 'Todo de :resource',
|
||||
'groups' => [
|
||||
'everything' => 'General',
|
||||
'notices' => 'Avisos',
|
||||
],
|
||||
'actions_on' => [
|
||||
'created' => 'Creada',
|
||||
'updated' => 'Editada',
|
||||
'deleted' => 'Borrada',
|
||||
'restored' => 'Recuperada',
|
||||
],
|
||||
'resources' => [
|
||||
'pages' => 'Páginas',
|
||||
'categories' => 'Categorías',
|
||||
'tags' => 'Etiquetas',
|
||||
'projects' => 'Proyectos',
|
||||
'members' => 'Miembros',
|
||||
'services' => 'Servicios',
|
||||
'clients' => 'Clientes',
|
||||
'testimonials' => 'Testimonios',
|
||||
'talks' => 'Charlas',
|
||||
'technologies' => 'Tecnologías',
|
||||
'calls' => 'Convocatorias',
|
||||
'redirects' => 'Redirecciones',
|
||||
'repositories' => 'Repositorios',
|
||||
'media' => 'Medios',
|
||||
'applications' => 'Buzón',
|
||||
],
|
||||
'plural' => 'Webhooks',
|
||||
'fields' => [
|
||||
'name' => 'Nombre',
|
||||
'url' => 'URL',
|
||||
'url_hint' => 'HTTPS y una dirección pública: no se aceptan localhost, IPs privadas ni nombres internos.',
|
||||
'events' => 'Eventos',
|
||||
'include_personal_data' => 'Incluir datos personales',
|
||||
'include_personal_data_hint' => 'Solo afecta a «Solicitud recibida»: añade el nombre, el correo y el mensaje de quien escribió.',
|
||||
'is_active' => 'Activo',
|
||||
'failures' => 'Fallos seguidos',
|
||||
'last_delivered_at' => 'Último envío correcto',
|
||||
],
|
||||
'events' => [
|
||||
'*' => 'Todos',
|
||||
'application_received' => 'Solicitud recibida en el buzón',
|
||||
'content_published' => 'Contenido publicado',
|
||||
'content_unpublished' => 'Contenido retirado de la web',
|
||||
'review_requested' => 'Revisión pedida',
|
||||
'review_returned' => 'Revisión devuelta con cambios',
|
||||
],
|
||||
'actions' => [
|
||||
'ping' => 'Enviar prueba',
|
||||
'ping_sent' => 'Prueba en camino',
|
||||
'ping_hint' => 'Mira el resultado en «Envíos», más abajo en la ficha.',
|
||||
'rotate' => 'Cambiar secreto',
|
||||
'rotate_hint' => 'El secreto actual deja de valer en el acto: actualiza el servicio que recibe los avisos justo después.',
|
||||
],
|
||||
'secret' => [
|
||||
'title' => 'Guarda este secreto',
|
||||
'body' => 'Firma cada envío y no se volverá a mostrar: :secret',
|
||||
],
|
||||
'deliveries' => [
|
||||
'title' => 'Envíos',
|
||||
'event' => 'Evento',
|
||||
'status' => 'Estado',
|
||||
'response' => 'Respuesta',
|
||||
'attempts' => 'Intentos',
|
||||
'error' => 'Error',
|
||||
'created_at' => 'Cuándo',
|
||||
'payload' => 'Ver contenido',
|
||||
'redeliver' => 'Reenviar',
|
||||
'redelivered' => 'Reenvío en camino',
|
||||
'statuses' => [
|
||||
'pending' => 'Pendiente',
|
||||
'succeeded' => 'Entregado',
|
||||
'failed' => 'Fallido',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'tags' => [
|
||||
'label' => 'Etiqueta',
|
||||
'plural' => 'Etiquetas',
|
||||
@@ -439,6 +521,17 @@ return [
|
||||
'needs_help' => 'Busca gente',
|
||||
'needs_help_hint' => 'Lo muestra en la sección de colaboración de la web.',
|
||||
],
|
||||
'taskstream' => [
|
||||
'section' => 'TaskStream',
|
||||
'section_hint' => 'Un tablero público en TaskStream, el gestor de tareas del colectivo, a tu nombre.',
|
||||
'toggle' => 'Crear también en TaskStream',
|
||||
'key' => 'Clave en TaskStream',
|
||||
'key_hint' => 'De 2 a 10 letras o cifras, empezando por letra. Si la dejas vacía, se saca del slug.',
|
||||
'created' => 'Tablero creado en TaskStream: :url',
|
||||
'open' => 'Abrir en TaskStream',
|
||||
'create' => 'Crear en TaskStream',
|
||||
'create_heading' => 'Abrir un tablero en TaskStream',
|
||||
],
|
||||
'filters' => ['unpublished' => 'Sin publicar'],
|
||||
'actions' => ['publish' => 'Publicar'],
|
||||
],
|
||||
|
||||
@@ -4,7 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'sign_in' => 'Entrar',
|
||||
'sign_in_intro' => 'Accede con tu email y tu contraseña.',
|
||||
'sign_in_intro' => 'Accede con tu email y tu contraseña, o con tu cuenta de Pireon.',
|
||||
'continue_with_pireon' => 'Entrar con Pireon',
|
||||
'signing_in' => 'Comprobando tus credenciales…',
|
||||
'password_grant_disabled' => 'El acceso con contraseña está desactivado en esta instalación. Avisa a un administrador.',
|
||||
'server_unavailable' => 'El servidor de identidad no está respondiendo. No es cosa de tus credenciales; avisa a un administrador.',
|
||||
|
||||
@@ -44,6 +44,18 @@ return [
|
||||
'too_many' => 'Como mucho :max filas por importación.',
|
||||
],
|
||||
|
||||
'webhooks' => [
|
||||
'event' => '«:event» no es un evento al que suscribirse.',
|
||||
],
|
||||
|
||||
'taskstream' => [
|
||||
'machine' => 'TaskStream solo entiende tokens de Pireon: entra con uno, o configura TASKSTREAM_TOKEN para los tokens de máquina.',
|
||||
'key_needed' => 'No se puede sacar una clave del slug: indica una en «taskstream_key» (de 2 a 10 letras o cifras, empezando por letra).',
|
||||
'refused' => 'TaskStream no ha creado el proyecto: :reason',
|
||||
'unauthorised' => 'TaskStream ha rechazado el token: puede que haya caducado o que no tenga acceso.',
|
||||
'unavailable' => 'TaskStream no responde ahora mismo; no se ha creado nada, prueba otra vez en un rato.',
|
||||
],
|
||||
|
||||
'tokens' => [
|
||||
'ability' => '«:ability» no es un permiso que tengas, así que un token tuyo no puede tenerlo.',
|
||||
'machine' => 'Los tokens los gestiona una persona, no otro token.',
|
||||
|
||||
4
public/api-types.d.ts
vendored
4
public/api-types.d.ts
vendored
@@ -241,6 +241,10 @@ export interface PublicProject {
|
||||
lead: PublicMember;
|
||||
calls: PublicCall[];
|
||||
repositories: PublicRepository[];
|
||||
/**
|
||||
* Its board in TaskStream, if it has one. `showcase_url` answers without a token: roadmap, releases, the sprint under way and tickets labelled good-first-issue.
|
||||
*/
|
||||
taskstream?: { key: string; url: string; web_url: string; showcase_url: string };
|
||||
alternates: Alternates;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
@@ -1054,6 +1054,41 @@ paths:
|
||||
"403": { description: Account inactive or awaiting approval }
|
||||
"422": { $ref: "#/components/responses/ValidationError" }
|
||||
|
||||
/v1/webhooks/pireon:
|
||||
post:
|
||||
tags: [Auth]
|
||||
summary: Pireon events (called by the identity server, not by apps)
|
||||
description: |
|
||||
`user.deactivated` turns the account off here, ends its panel sessions
|
||||
and forgets its cached bearer tokens; `token.revoked` ends its sessions
|
||||
and forgets its cached tokens. Signed in `X-Webhook-Signature` with the
|
||||
HMAC-SHA256 (hex) of the raw body and the webhook secret of the Pireon
|
||||
client that registered it (`PIREON_WEBHOOK_SECRET` or
|
||||
`PIREON_REDIRECT_WEBHOOK_SECRET`). Disabled while neither is set.
|
||||
security: []
|
||||
parameters:
|
||||
- name: X-Webhook-Signature
|
||||
in: header
|
||||
required: true
|
||||
schema: { type: string }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [event]
|
||||
properties:
|
||||
event: { type: string, example: user.deactivated }
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
user_id: { type: integer, example: 4242 }
|
||||
responses:
|
||||
"204": { description: "Processed (or nothing to do)" }
|
||||
"400": { description: No event name }
|
||||
"401": { description: "Missing or invalid signature, or webhooks disabled" }
|
||||
|
||||
/v1/me:
|
||||
get:
|
||||
tags: [Auth]
|
||||
@@ -1566,6 +1601,16 @@ paths:
|
||||
`technology_ids`, `service_ids`, and `members` with each one's role.
|
||||
`published_at` and `unpublish_at` require `projects.publish`. For a
|
||||
cover, upload it with `POST /v1/media` and `for: projects`.
|
||||
|
||||
With `taskstream: true` the project also gets a **public** board in
|
||||
TaskStream, the collective's issue tracker, and the answer carries its
|
||||
link in `taskstream_url`. Its key is `taskstream_key`, or one made from
|
||||
the slug in the default language (one word: its first ten characters;
|
||||
several: their initials). The board is opened first: if TaskStream
|
||||
refuses it (a key in use, say), nothing is created here either and the
|
||||
reason comes back as a validation error. It is opened as the caller,
|
||||
whose Pireon token is passed on; a machine token cannot ask for one
|
||||
unless the server has a `TASKSTREAM_TOKEN` of its own.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -1582,6 +1627,8 @@ paths:
|
||||
data: { $ref: "#/components/schemas/Project" }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
"422": { $ref: "#/components/responses/ValidationError" }
|
||||
"502":
|
||||
description: TaskStream did not answer; nothing was created.
|
||||
|
||||
/v1/projects/{project}:
|
||||
parameters:
|
||||
@@ -1609,7 +1656,14 @@ paths:
|
||||
patch:
|
||||
tags: [Projects]
|
||||
summary: Update a project
|
||||
description: Partial update. Translatable fields merge into the existing map.
|
||||
description: |
|
||||
Partial update. Translatable fields merge into the existing map.
|
||||
|
||||
`taskstream: true` opens a TaskStream board for a project that has none
|
||||
yet, as on creation (`taskstream_key`, or one made from the slug); if
|
||||
TaskStream refuses, the update is not saved either. A project that
|
||||
already has a board keeps it, and `taskstream_key` without the flag is
|
||||
ignored, so a client can PATCH back what it read.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -3945,9 +3999,26 @@ paths:
|
||||
times over about two and a half hours; twenty failures in a row switch the
|
||||
webhook off.
|
||||
|
||||
Events: `application.received` (its sender's details only with
|
||||
`include_personal_data`), `content.published`, `content.unpublished`,
|
||||
`review.requested`, `review.returned` — or `*`.
|
||||
Events with a meaning of their own: `application.received` (its
|
||||
sender's details only with `include_personal_data`),
|
||||
`content.published`, `content.unpublished`, `review.requested`,
|
||||
`review.returned`.
|
||||
|
||||
And every change a person makes to a record, as `{resource}.{action}`
|
||||
— `projects.created`, `pages.updated`, `media.deleted`,
|
||||
`calls.restored` — for pages, categories, tags, projects, members,
|
||||
services, clients, testimonials, talks, technologies, calls,
|
||||
redirects, repositories, media and applications. Its `data` says
|
||||
what (`type`, `id`, `title`, `url` when it is on the site), who
|
||||
(`by`), how they came in (`via`: panel, api, token) and, on an
|
||||
update, `changes` as field → `{old, new}`. What the application does
|
||||
by itself — a sync, a scheduled publication — is not sent. Changes to
|
||||
an application carry only how it is handled unless
|
||||
`include_personal_data`. Users, tokens, webhooks and settings are
|
||||
never sent.
|
||||
|
||||
Subscribe to an event, to `*`, to all of one kind of record
|
||||
(`projects.*`) or to one action on any (`*.deleted`).
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -5012,6 +5083,15 @@ components:
|
||||
repositories:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/PublicRepository" }
|
||||
taskstream:
|
||||
type: object
|
||||
nullable: true
|
||||
description: "Its board in TaskStream, if it has one. `showcase_url` answers without a token: roadmap, releases, the sprint under way and tickets labelled good-first-issue."
|
||||
properties:
|
||||
key: { type: string, example: WEB }
|
||||
url: { type: string, format: uri }
|
||||
web_url: { type: string, format: uri }
|
||||
showcase_url: { type: string, format: uri }
|
||||
alternates: { $ref: "#/components/schemas/Alternates" }
|
||||
path: { type: string, nullable: true, example: /proyectos/mi-web }
|
||||
|
||||
@@ -5486,6 +5566,8 @@ components:
|
||||
is_featured: { type: boolean }
|
||||
needs_help: { type: boolean }
|
||||
demo_url: { type: string, nullable: true, format: uri }
|
||||
taskstream_key: { type: string, nullable: true, description: "The project's key in TaskStream, when it has a board there." }
|
||||
taskstream_url: { type: string, nullable: true, format: uri, readOnly: true, description: Where its TaskStream board opens. }
|
||||
cover_image: { type: string, nullable: true, description: The stored path — what a client PATCHes back. }
|
||||
cover_image_media:
|
||||
allOf: [{ $ref: "#/components/schemas/Image" }]
|
||||
@@ -5532,6 +5614,14 @@ components:
|
||||
is_featured: { type: boolean }
|
||||
needs_help: { type: boolean }
|
||||
demo_url: { type: string, format: uri, nullable: true }
|
||||
taskstream:
|
||||
type: boolean
|
||||
description: Also open a public board for it in TaskStream, on creation or when it has none yet; see `POST /v1/projects`.
|
||||
taskstream_key:
|
||||
type: string
|
||||
nullable: true
|
||||
pattern: "^[A-Za-z][A-Za-z0-9]{1,9}$"
|
||||
description: Only read with `taskstream`. The key in TaskStream; made from the slug when left out.
|
||||
cover_image: { type: string, nullable: true, description: A path from `POST /v1/media` with `for=projects`. }
|
||||
started_at: { type: string, format: date, nullable: true }
|
||||
ended_at: { type: string, format: date, nullable: true }
|
||||
@@ -6200,7 +6290,10 @@ components:
|
||||
url: { type: string, format: uri, description: "HTTPS, to a public address." }
|
||||
events:
|
||||
type: array
|
||||
items: { type: string, enum: ["*", application.received, content.published, content.unpublished, review.requested, review.returned] }
|
||||
items:
|
||||
type: string
|
||||
description: "An event, `*`, `{resource}.*` or `*.{action}`."
|
||||
example: projects.*
|
||||
secret: { type: string, minLength: 16, description: On creation only; generated when left out. }
|
||||
rotate_secret: { type: boolean, description: On update only. }
|
||||
include_personal_data: { type: boolean, default: false }
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
<div class="ec-legend" aria-label="{{ __('panel.calendar.legend') }}">
|
||||
@foreach (['published', 'scheduled', 'unpublishes', 'opens', 'closes'] as $event)
|
||||
<span class="ec-event ec-{{ $event }}">{{ __('panel.calendar.events.'.$event) }}</span>
|
||||
<span class="ec-event ec-{{ $event }}">{{ __("panel.calendar.events.{$event}") }}</span>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@@ -73,8 +73,8 @@
|
||||
|
||||
@foreach ($day['events'] as $event)
|
||||
<a href="{{ $event['url'] }}" class="ec-event ec-{{ $event['event'] }}">
|
||||
<span class="sr-only">{{ __('panel.calendar.events.'.$event['event']) }}:</span>
|
||||
{{ __('panel.calendar.types.'.$event['type']) }} · {{ $event['title'] }}
|
||||
<span class="sr-only">{{ __("panel.calendar.events.{$event['event']}") }}:</span>
|
||||
{{ __("panel.calendar.types.{$event['type']}") }} · {{ $event['title'] }}
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
{{ $row['title'] }}
|
||||
@endif
|
||||
</td>
|
||||
<td>{{ __('panel.statistics.types.'.$row['type']) }}</td>
|
||||
<td>{{ __("panel.statistics.types.{$row['type']}") }}</td>
|
||||
<td class="num">{{ number_format($row['views'], 0, ',', '.') }}</td>
|
||||
<td @class(['num', 'st-up' => str_starts_with($delta, '+'), 'st-down' => str_starts_with($delta, '−')])>{{ $delta }}</td>
|
||||
</tr>
|
||||
@@ -88,7 +88,7 @@
|
||||
<ul class="st-unread">
|
||||
@foreach ($unread as $row)
|
||||
<li>
|
||||
<x-filament::badge color="gray" size="sm" style="display: inline-flex">{{ __('panel.statistics.types.'.$row['type']) }}</x-filament::badge>
|
||||
<x-filament::badge color="gray" size="sm" style="display: inline-flex">{{ __("panel.statistics.types.{$row['type']}") }}</x-filament::badge>
|
||||
@if ($row['url'])
|
||||
<x-filament::link :href="$row['url']">{{ $row['title'] }}</x-filament::link>
|
||||
@else
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
@foreach ($pending as $item)
|
||||
<tr>
|
||||
<td>
|
||||
<x-filament::badge color="gray" size="sm" style="display: inline-flex">{{ __('panel.statistics.types.'.$item['type']) }}</x-filament::badge>
|
||||
<x-filament::badge color="gray" size="sm" style="display: inline-flex">{{ __("panel.statistics.types.{$item['type']}") }}</x-filament::badge>
|
||||
{{ $item['title'] }}
|
||||
</td>
|
||||
<td>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<li class="flex flex-wrap items-center justify-between gap-2 py-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<x-filament::badge :color="$item['event'] === 'closes' ? 'warning' : 'gray'">
|
||||
{{ __('panel.expiring.types.'.$item['type']) }}
|
||||
{{ __("panel.expiring.types.{$item['type']}") }}
|
||||
</x-filament::badge>
|
||||
|
||||
<x-filament::link :href="$item['url']">
|
||||
@@ -18,7 +18,7 @@
|
||||
</div>
|
||||
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ __('panel.expiring.events.'.$item['event'], ['when' => $item['at']->diffForHumans(), 'date' => $item['at']->isoFormat('LL')]) }}
|
||||
{{ __("panel.expiring.events.{$item['event']}", ['when' => $item['at']->diffForHumans(), 'date' => $item['at']->isoFormat('LL')]) }}
|
||||
</span>
|
||||
</li>
|
||||
@endforeach
|
||||
|
||||
@@ -27,6 +27,7 @@ use App\Http\Controllers\Api\V1\MediaController;
|
||||
use App\Http\Controllers\Api\V1\MemberController;
|
||||
use App\Http\Controllers\Api\V1\MissingUrlController;
|
||||
use App\Http\Controllers\Api\V1\PageController;
|
||||
use App\Http\Controllers\Api\V1\PireonWebhookController;
|
||||
use App\Http\Controllers\Api\V1\PreviewController;
|
||||
use App\Http\Controllers\Api\V1\ProjectController;
|
||||
use App\Http\Controllers\Api\V1\RedirectController;
|
||||
@@ -185,6 +186,11 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
||||
->middleware('throttle:auth-token')
|
||||
->name('auth.token');
|
||||
|
||||
// Called by the identity server, signed with a client's webhook secret.
|
||||
Route::post('webhooks/pireon', PireonWebhookController::class)
|
||||
->middleware('throttle:60,1')
|
||||
->name('webhooks.pireon');
|
||||
|
||||
Route::middleware(['auth:api', 'throttle:api'])->group(function () {
|
||||
Route::get('me', MeController::class)->name('me');
|
||||
|
||||
|
||||
@@ -14,8 +14,9 @@ Route::redirect('/', '/admin');
|
||||
| Authentication — Pireon OAuth2 (Authorization Code + PKCE)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| `login` is the page Filament bounces guests to; it renders a single button
|
||||
| that starts the flow at `auth.redirect`.
|
||||
| `login` is the page Filament bounces guests to: an e-mail and password form
|
||||
| (password grant) with «Entrar con Pireon» under it, which starts this flow at
|
||||
| `auth.redirect`.
|
||||
|
|
||||
*/
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Models\Member;
|
||||
use App\Models\Project;
|
||||
use App\Models\Repository;
|
||||
use App\Models\Technology;
|
||||
use App\Services\TaskStream\TaskStreamClient;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
@@ -63,6 +64,32 @@ class PublicApiTest extends TestCase
|
||||
$this->getJson('/api/public/v1/projects/el-meu-projecte')->assertNotFound();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_points_to_the_board_in_taskstream_when_there_is_one(): void
|
||||
{
|
||||
config([
|
||||
'services.taskstream.api_url' => 'https://api.taskstream.test',
|
||||
'services.taskstream.web_url' => 'https://taskstream.test',
|
||||
]);
|
||||
$this->app->forgetInstance(TaskStreamClient::class);
|
||||
Project::factory()->published()->create([
|
||||
'slug' => ['es' => 'con-tablero'],
|
||||
'taskstream_key' => 'WEB',
|
||||
]);
|
||||
Project::factory()->published()->create(['slug' => ['es' => 'sin-tablero']]);
|
||||
|
||||
$this->getJson('/api/public/v1/projects/con-tablero')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.taskstream', [
|
||||
'key' => 'WEB',
|
||||
'url' => 'https://taskstream.test/p/WEB',
|
||||
'web_url' => 'https://taskstream.test',
|
||||
'showcase_url' => 'https://api.taskstream.test/api/explore/projects/WEB/showcase',
|
||||
]);
|
||||
$this->getJson('/api/public/v1/projects/sin-tablero')
|
||||
->assertJsonPath('data.taskstream', null);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_returns_hreflang_alternates_for_every_translated_locale(): void
|
||||
{
|
||||
|
||||
285
tests/Feature/Api/TaskStreamProjectTest.php
Normal file
285
tests/Feature/Api/TaskStreamProjectTest.php
Normal file
@@ -0,0 +1,285 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api;
|
||||
|
||||
use App\Actions\Projects\OpenInTaskStream;
|
||||
use App\Models\ApiToken;
|
||||
use App\Models\Project;
|
||||
use App\Models\User;
|
||||
use App\Services\TaskStream\TaskStreamClient;
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Client\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\FakesPireon;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* `taskstream: true` on a new project, or on one without a board yet, opens
|
||||
* its board in TaskStream too, and the answer carries the link.
|
||||
*/
|
||||
class TaskStreamProjectTest extends TestCase
|
||||
{
|
||||
use FakesPireon, RefreshDatabase;
|
||||
|
||||
protected string $taskstream = 'https://api.taskstream.test';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
|
||||
config([
|
||||
'services.taskstream.api_url' => $this->taskstream,
|
||||
'services.taskstream.web_url' => 'https://taskstream.test',
|
||||
'services.taskstream.token' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
protected function editor(): User
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$user->syncRoles(['editor']);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* TaskStream creating whatever it is asked for, or answering $status.
|
||||
*/
|
||||
protected function fakeTaskStream(int $status = 201, array $body = []): void
|
||||
{
|
||||
Http::fake([
|
||||
"{$this->taskstream}/api/projects" => fn (Request $request) => Http::response(
|
||||
$status === 201 ? ['id' => 'ts-1', 'key' => $request['key'], 'version' => 1] : $body,
|
||||
$status,
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function sentToTaskStream(): int
|
||||
{
|
||||
return Http::recorded(fn (Request $request) => str_starts_with($request->url(), $this->taskstream))->count();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_new_project_gets_a_public_board_as_the_person_who_asked(): void
|
||||
{
|
||||
$user = $this->editor();
|
||||
$this->fakeTaskStream();
|
||||
$this->withHeaders($this->apiHeadersFor($user));
|
||||
|
||||
$this->postJson('/api/v1/projects', [
|
||||
'title' => ['es' => 'Catálogo abierto'],
|
||||
'slug' => ['es' => 'catalogo'],
|
||||
'tagline' => ['es' => 'Una herramienta pequeña.'],
|
||||
'taskstream' => true,
|
||||
])
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.taskstream_key', 'CATALOGO')
|
||||
->assertJsonPath('data.taskstream_url', 'https://taskstream.test/p/CATALOGO');
|
||||
|
||||
Http::assertSent(fn (Request $request) => $request->url() === "{$this->taskstream}/api/projects"
|
||||
&& $request->hasHeader('Authorization', 'Bearer token-for-'.$user->oauth_id)
|
||||
&& $request->data() === [
|
||||
'key' => 'CATALOGO',
|
||||
'name' => 'Catálogo abierto',
|
||||
'description' => 'Una herramienta pequeña.',
|
||||
'isPublic' => true,
|
||||
]);
|
||||
|
||||
$project = Project::query()->sole();
|
||||
$this->assertSame('ts-1', $project->taskstream_project_id);
|
||||
$this->assertSame('CATALOGO', $project->taskstream_key);
|
||||
|
||||
$this->getJson("/api/v1/projects/{$project->id}")
|
||||
->assertJsonPath('data.taskstream_url', 'https://taskstream.test/p/CATALOGO');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_key_can_be_chosen(): void
|
||||
{
|
||||
$this->fakeTaskStream();
|
||||
$this->withHeaders($this->apiHeadersFor($this->editor()));
|
||||
|
||||
$this->postJson('/api/v1/projects', [
|
||||
'title' => ['es' => 'Web'],
|
||||
'slug' => ['es' => 'web'],
|
||||
'taskstream' => true,
|
||||
'taskstream_key' => 'web2',
|
||||
])
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.taskstream_key', 'WEB2');
|
||||
|
||||
$this->postJson('/api/v1/projects', [
|
||||
'title' => ['es' => 'Otra'],
|
||||
'slug' => ['es' => 'otra'],
|
||||
'taskstream' => true,
|
||||
'taskstream_key' => '1-no',
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('taskstream_key');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function without_the_flag_taskstream_is_left_alone(): void
|
||||
{
|
||||
$this->fakeTaskStream();
|
||||
$this->withHeaders($this->apiHeadersFor($this->editor()));
|
||||
|
||||
$this->postJson('/api/v1/projects', [
|
||||
'title' => ['es' => 'Solo aquí'],
|
||||
'slug' => ['es' => 'solo-aqui'],
|
||||
'taskstream_key' => 'SOLO',
|
||||
])
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.taskstream_key', null)
|
||||
->assertJsonPath('data.taskstream_url', null);
|
||||
|
||||
$this->assertSame(0, $this->sentToTaskStream());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_refusal_from_taskstream_creates_nothing_here(): void
|
||||
{
|
||||
$this->fakeTaskStream(409, ['statusCode' => 409, 'message' => 'Project key CATALOGO is already in use']);
|
||||
$this->withHeaders($this->apiHeadersFor($this->editor()));
|
||||
|
||||
$response = $this->postJson('/api/v1/projects', [
|
||||
'title' => ['es' => 'Catálogo'],
|
||||
'slug' => ['es' => 'catalogo'],
|
||||
'taskstream' => true,
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('taskstream_key');
|
||||
|
||||
// Whatever language the person reads, TaskStream's reason is passed on.
|
||||
$this->assertStringContainsString('Project key CATALOGO is already in use', $response->json('errors.taskstream_key.0'));
|
||||
|
||||
$this->assertSame(0, Project::withTrashed()->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function taskstream_being_down_creates_nothing_here(): void
|
||||
{
|
||||
Http::fake(["{$this->taskstream}/*" => Http::failedConnection()]);
|
||||
$this->withHeaders($this->apiHeadersFor($this->editor()));
|
||||
|
||||
$this->postJson('/api/v1/projects', [
|
||||
'title' => ['es' => 'Catálogo'],
|
||||
'slug' => ['es' => 'catalogo'],
|
||||
'taskstream' => true,
|
||||
])->assertStatus(502);
|
||||
|
||||
$this->assertSame(0, Project::withTrashed()->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_machine_token_needs_the_servers_own_taskstream_token(): void
|
||||
{
|
||||
$this->fakeTaskStream();
|
||||
[, $plain] = ApiToken::issue($this->editor(), 'CI', ['projects.*'], now()->addDay());
|
||||
$this->withHeaders(['Authorization' => 'Bearer '.$plain, 'Accept' => 'application/json']);
|
||||
|
||||
$this->postJson('/api/v1/projects', [
|
||||
'title' => ['es' => 'Catálogo'],
|
||||
'slug' => ['es' => 'catalogo'],
|
||||
'taskstream' => true,
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('taskstream');
|
||||
|
||||
$this->assertSame(0, $this->sentToTaskStream());
|
||||
$this->assertSame(0, Project::withTrashed()->count());
|
||||
|
||||
// With one configured, that token is the one TaskStream sees — never ours.
|
||||
config(['services.taskstream.token' => 'pireon-service-token']);
|
||||
$this->app->forgetInstance(TaskStreamClient::class);
|
||||
|
||||
$this->postJson('/api/v1/projects', [
|
||||
'title' => ['es' => 'Catálogo'],
|
||||
'slug' => ['es' => 'catalogo'],
|
||||
'taskstream' => true,
|
||||
])->assertCreated();
|
||||
|
||||
Http::assertSent(fn (Request $request) => str_starts_with($request->url(), $this->taskstream)
|
||||
&& $request->hasHeader('Authorization', 'Bearer pireon-service-token'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function an_update_opens_a_board_for_a_project_without_one(): void
|
||||
{
|
||||
$this->fakeTaskStream();
|
||||
$this->withHeaders($this->apiHeadersFor($this->editor()));
|
||||
$project = Project::factory()->create(['slug' => ['es' => 'catalogo']]);
|
||||
|
||||
$this->patchJson("/api/v1/projects/{$project->id}", ['taskstream' => true, 'needs_help' => true])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.taskstream_key', 'CATALOGO')
|
||||
->assertJsonPath('data.taskstream_url', 'https://taskstream.test/p/CATALOGO')
|
||||
->assertJsonPath('data.needs_help', true);
|
||||
|
||||
$this->assertSame('ts-1', $project->fresh()->taskstream_project_id);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_project_with_a_board_keeps_it(): void
|
||||
{
|
||||
$this->fakeTaskStream();
|
||||
$this->withHeaders($this->apiHeadersFor($this->editor()));
|
||||
$project = Project::factory()->create(['taskstream_project_id' => 'ts-0', 'taskstream_key' => 'CAT']);
|
||||
|
||||
$this->patchJson("/api/v1/projects/{$project->id}", ['taskstream' => true, 'taskstream_key' => 'OTRA'])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.taskstream_key', 'CAT');
|
||||
|
||||
$this->assertSame(0, $this->sentToTaskStream());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function an_update_without_the_flag_ignores_the_key(): void
|
||||
{
|
||||
$this->fakeTaskStream();
|
||||
$this->withHeaders($this->apiHeadersFor($this->editor()));
|
||||
$project = Project::factory()->create();
|
||||
|
||||
// What a client sends back after reading a project.
|
||||
$this->patchJson("/api/v1/projects/{$project->id}", ['taskstream_key' => 'OTRA', 'needs_help' => true])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.taskstream_key', null);
|
||||
|
||||
$this->assertSame(0, $this->sentToTaskStream());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_refused_board_leaves_the_update_unsaved(): void
|
||||
{
|
||||
$this->fakeTaskStream(409, ['statusCode' => 409, 'message' => 'Project key WEB is already in use']);
|
||||
$this->withHeaders($this->apiHeadersFor($this->editor()));
|
||||
$project = Project::factory()->create(['needs_help' => false]);
|
||||
|
||||
$this->patchJson("/api/v1/projects/{$project->id}", ['taskstream' => true, 'taskstream_key' => 'WEB', 'needs_help' => true])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('taskstream_key');
|
||||
|
||||
$this->assertFalse($project->fresh()->needs_help);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function keys_are_made_from_the_slug_in_taskstreams_style(): void
|
||||
{
|
||||
$action = app(OpenInTaskStream::class);
|
||||
|
||||
$this->assertSame('CATALOGO', $action->keyFrom('catalogo'));
|
||||
$this->assertSame('TRANSPAREN', $action->keyFrom('transparencia'));
|
||||
$this->assertSame('MWN', $action->keyFrom('mi-web-nueva'));
|
||||
$this->assertSame('ANO', $action->keyFrom('año-nuevo-ok'));
|
||||
$this->assertSame('WEB', $action->keyFrom('2026web'));
|
||||
$this->assertNull($action->keyFrom('2026-web'));
|
||||
$this->assertNull($action->keyFrom('x'));
|
||||
}
|
||||
}
|
||||
158
tests/Feature/Api/WebhookRecordEventsTest.php
Normal file
158
tests/Feature/Api/WebhookRecordEventsTest.php
Normal file
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api;
|
||||
|
||||
use App\Enums\ApplicationStatus;
|
||||
use App\Models\Application;
|
||||
use App\Models\Page;
|
||||
use App\Models\Project;
|
||||
use App\Models\Repository;
|
||||
use App\Models\User;
|
||||
use App\Models\Webhook;
|
||||
use App\Models\WebhookDelivery;
|
||||
use App\Services\Media\HostResolver;
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\FakesPireon;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* `{resource}.{action}`: every change a person makes to a record, as a
|
||||
* webhook event, with wildcards to subscribe broadly.
|
||||
*/
|
||||
class WebhookRecordEventsTest extends TestCase
|
||||
{
|
||||
use FakesPireon, RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
Queue::fake();
|
||||
|
||||
$this->app->instance(HostResolver::class, new class extends HostResolver
|
||||
{
|
||||
public function resolve(string $host): array
|
||||
{
|
||||
return ['93.184.216.34'];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected function actingWithRole(string $role): User
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$user->syncRoles([$role]);
|
||||
|
||||
$this->withHeaders($this->apiHeadersFor($user));
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function wildcards_cover_a_kind_of_record_or_an_action(): void
|
||||
{
|
||||
$webhook = Webhook::factory()->make(['events' => ['projects.*', '*.deleted']]);
|
||||
|
||||
$this->assertTrue($webhook->listensTo('projects.updated'));
|
||||
$this->assertTrue($webhook->listensTo('pages.deleted'));
|
||||
$this->assertFalse($webhook->listensTo('pages.updated'));
|
||||
$this->assertFalse($webhook->listensTo('content.published'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function editing_a_project_says_what_changed_who_did_it_and_how(): void
|
||||
{
|
||||
$editor = $this->actingWithRole('editor');
|
||||
|
||||
$webhook = Webhook::factory()->create(['events' => ['projects.*']]);
|
||||
$project = Project::factory()->create(['status' => 'active']);
|
||||
|
||||
$this->patchJson("/api/v1/projects/{$project->id}", ['status' => 'paused'])->assertOk();
|
||||
|
||||
$delivery = $webhook->deliveries()->where('event', 'projects.updated')->sole();
|
||||
|
||||
$this->assertSame($project->id, $delivery->payload['id']);
|
||||
$this->assertSame(['old' => 'active', 'new' => 'paused'], $delivery->payload['changes']['status']);
|
||||
$this->assertSame($editor->id, $delivery->payload['by']['id']);
|
||||
$this->assertSame('api', $delivery->payload['via']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function creating_and_deleting_are_events_too(): void
|
||||
{
|
||||
$this->actingWithRole('editor');
|
||||
|
||||
$webhook = Webhook::factory()->create(['events' => ['pages.created', '*.deleted']]);
|
||||
|
||||
$id = $this->postJson('/api/v1/pages', ['title' => ['es' => 'Hola'], 'slug' => ['es' => 'hola']])->json('data.id');
|
||||
$this->deleteJson("/api/v1/pages/{$id}")->assertNoContent();
|
||||
|
||||
$this->assertSame(['pages.deleted', 'pages.created'], $webhook->deliveries()->pluck('event')->all());
|
||||
$this->assertNull($webhook->deliveries()->first()->payload['url']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function what_the_application_does_by_itself_is_not_announced(): void
|
||||
{
|
||||
$webhook = Webhook::factory()->create(['events' => ['*']]);
|
||||
|
||||
Repository::factory()->create()->forceFill(['stars' => 10])->save();
|
||||
|
||||
$this->assertSame(0, $webhook->deliveries()->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function security_records_cannot_be_subscribed_to(): void
|
||||
{
|
||||
$this->actingWithRole('admin');
|
||||
|
||||
foreach (['users.updated', 'tokens.*', 'settings.updated', 'webhooks.created', 'projects.exploded'] as $event) {
|
||||
$this->postJson('/api/v1/webhooks', ['name' => 'X', 'url' => 'https://hooks.example.org/x', 'events' => [$event]])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('events.0');
|
||||
}
|
||||
|
||||
$this->postJson('/api/v1/webhooks', ['name' => 'X', 'url' => 'https://hooks.example.org/x', 'events' => ['projects.*', '*.restored']])
|
||||
->assertCreated();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_inbox_tells_how_it_is_handled_and_its_words_only_when_asked(): void
|
||||
{
|
||||
$this->actingWithRole('editor');
|
||||
|
||||
$plain = Webhook::factory()->create(['events' => ['applications.updated']]);
|
||||
$personal = Webhook::factory()->create(['events' => ['applications.*'], 'include_personal_data' => true]);
|
||||
|
||||
$application = Application::factory()->create(['email' => 'ada@example.org']);
|
||||
|
||||
$this->patchJson("/api/v1/applications/{$application->id}", ['status' => 'contacted', 'notes' => 'Llamar el lunes'])->assertOk();
|
||||
|
||||
$sent = $plain->deliveries()->latest('id')->first()->payload;
|
||||
$full = $personal->deliveries()->latest('id')->first()->payload;
|
||||
|
||||
$this->assertArrayNotHasKey('notes', $sent['changes'] ?? []);
|
||||
$this->assertArrayNotHasKey('email', $sent);
|
||||
$this->assertSame('ada@example.org', $full['email']);
|
||||
$this->assertSame(ApplicationStatus::Contacted->value, $application->fresh()->status->value);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_events_answer_as_queued_deliveries(): void
|
||||
{
|
||||
$this->actingWithRole('editor');
|
||||
|
||||
Webhook::factory()->create(['events' => ['*']]);
|
||||
$page = Page::factory()->create();
|
||||
|
||||
$this->patchJson("/api/v1/pages/{$page->id}", ['is_featured' => ! $page->is_featured])->assertOk();
|
||||
|
||||
$this->assertTrue(WebhookDelivery::query()->where('event', 'pages.updated')->exists());
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,13 @@ declare(strict_types=1);
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\Pireon\AccessToken;
|
||||
use App\Services\Pireon\TokenStore;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Client\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\FakesPireon;
|
||||
use Tests\TestCase;
|
||||
@@ -144,4 +149,57 @@ class PireonLoginTest extends TestCase
|
||||
$this->assertGuest();
|
||||
$this->assertNull(session('pireon.token'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_redirect_flow_uses_its_own_client_when_one_is_set(): void
|
||||
{
|
||||
config(['pireon.redirect_client_id' => 'code-client', 'pireon.redirect_client_secret' => 'code-secret']);
|
||||
|
||||
$response = $this->get(route('auth.redirect'));
|
||||
|
||||
$query = [];
|
||||
parse_str((string) parse_url($response->headers->get('Location'), PHP_URL_QUERY), $query);
|
||||
$this->assertSame('code-client', $query['client_id']);
|
||||
|
||||
$this->fakePireon();
|
||||
$this
|
||||
->withSession(['pireon.state' => 'expected-state', 'pireon.code_verifier' => 'the-verifier'])
|
||||
->get(route('auth.callback', ['code' => 'auth-code', 'state' => 'expected-state']))
|
||||
->assertRedirect();
|
||||
|
||||
// The code is redeemed with the client it was issued for.
|
||||
Http::assertSent(fn (Request $request) => str_ends_with($request->url(), '/oauth/token')
|
||||
&& $request['grant_type'] === 'authorization_code'
|
||||
&& $request['client_id'] === 'code-client'
|
||||
&& $request['client_secret'] === 'code-secret');
|
||||
$this->assertSame('redirect', session('pireon.token_issuer'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_redirect_session_renews_its_token_with_the_redirect_client(): void
|
||||
{
|
||||
config(['pireon.redirect_client_id' => 'code-client', 'pireon.redirect_client_secret' => null]);
|
||||
$this->fakePireon(token: ['access_token' => 'renewed-token']);
|
||||
|
||||
$store = app(TokenStore::class);
|
||||
$store->put(new AccessToken('old-token', 'the-refresh-token', CarbonImmutable::now()->subMinute()), viaRedirect: true);
|
||||
|
||||
$this->assertSame('renewed-token', $store->accessToken());
|
||||
Http::assertSent(fn (Request $request) => str_ends_with($request->url(), '/oauth/token')
|
||||
&& $request['grant_type'] === 'refresh_token'
|
||||
&& $request['client_id'] === 'code-client');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_login_page_offers_pireon_only_with_a_redirect_client(): void
|
||||
{
|
||||
config(['pireon.redirect_login' => false]);
|
||||
$this->get('/admin/login')->assertOk()->assertDontSee(__('pireon.continue_with_pireon'));
|
||||
|
||||
config(['pireon.redirect_login' => true, 'pireon.redirect_client_id' => 'code-client']);
|
||||
$this->get('/admin/login')
|
||||
->assertOk()
|
||||
->assertSee(__('pireon.continue_with_pireon'))
|
||||
->assertSee(route('auth.redirect'), escape: false);
|
||||
}
|
||||
}
|
||||
|
||||
110
tests/Feature/Auth/PireonWebhookTest.php
Normal file
110
tests/Feature/Auth/PireonWebhookTest.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Testing\TestResponse;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\FakesPireon;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PireonWebhookTest extends TestCase
|
||||
{
|
||||
use FakesPireon, RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
config(['pireon.webhook_secrets' => ['password-secret', 'redirect-secret']]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
protected function deliver(array $payload, ?string $secret = 'password-secret'): TestResponse
|
||||
{
|
||||
$body = (string) json_encode($payload);
|
||||
$headers = ['CONTENT_TYPE' => 'application/json', 'HTTP_ACCEPT' => 'application/json'];
|
||||
|
||||
if ($secret !== null) {
|
||||
$headers['HTTP_X_WEBHOOK_SIGNATURE'] = hash_hmac('sha256', $body, $secret);
|
||||
}
|
||||
|
||||
return $this->call('POST', route('api.v1.webhooks.pireon'), [], [], [], $headers, $body);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_refuses_everything_without_a_secret(): void
|
||||
{
|
||||
config(['pireon.webhook_secrets' => []]);
|
||||
|
||||
$this->deliver(['event' => 'token.revoked', 'data' => ['user_id' => 1]])->assertUnauthorized();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_refuses_a_delivery_that_is_not_signed_with_either_secret(): void
|
||||
{
|
||||
$user = $this->userWithRoles('editor', ['oauth_id' => 77]);
|
||||
|
||||
$this->deliver(['event' => 'user.deactivated', 'data' => ['user_id' => 77]], 'someone-else')->assertUnauthorized();
|
||||
$this->deliver(['event' => 'user.deactivated', 'data' => ['user_id' => 77]], null)->assertUnauthorized();
|
||||
|
||||
$this->assertTrue($user->fresh()->is_active);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_deactivated_account_goes_inactive_and_loses_its_sessions(): void
|
||||
{
|
||||
$user = $this->userWithRoles('editor', ['oauth_id' => 77]);
|
||||
config(['session.driver' => 'database']);
|
||||
DB::table('sessions')->insert([
|
||||
'id' => 'a-session', 'user_id' => $user->id, 'payload' => '', 'last_activity' => time(),
|
||||
]);
|
||||
|
||||
// Signed by the redirect client: either secret is good.
|
||||
$this->deliver(['event' => 'user.deactivated', 'data' => ['user_id' => 77]], 'redirect-secret')
|
||||
->assertNoContent();
|
||||
|
||||
$this->assertFalse($user->fresh()->is_active);
|
||||
$this->assertDatabaseMissing('sessions', ['id' => 'a-session']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_revoked_token_stops_working_at_once_instead_of_after_the_cache(): void
|
||||
{
|
||||
$user = $this->userWithRoles('editor', ['oauth_id' => 77]);
|
||||
$headers = $this->apiHeadersFor($user);
|
||||
|
||||
$this->getJson(route('api.v1.me'), $headers)->assertOk();
|
||||
|
||||
// Revoked upstream: the cached answer would still let it through…
|
||||
$this->pireonIdentities = array_fill_keys(['nobody'], []);
|
||||
$this->app['auth']->forgetGuards();
|
||||
$this->getJson(route('api.v1.me'), $headers)->assertOk();
|
||||
|
||||
// …until Pireon says so.
|
||||
$this->deliver(['event' => 'token.revoked', 'data' => ['user_id' => 77]])->assertNoContent();
|
||||
$this->app['auth']->forgetGuards();
|
||||
|
||||
$this->getJson(route('api.v1.me'), $headers)->assertUnauthorized();
|
||||
$this->assertTrue($user->fresh()->is_active);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function other_events_and_unknown_people_change_nothing(): void
|
||||
{
|
||||
$user = $this->userWithRoles('editor', ['oauth_id' => 77]);
|
||||
|
||||
$this->deliver(['event' => 'user.updated', 'data' => ['user_id' => 77]])->assertNoContent();
|
||||
$this->deliver(['event' => 'user.deactivated', 'data' => ['user_id' => 999]])->assertNoContent();
|
||||
$this->deliver(['data' => ['user_id' => 77]])->assertStatus(400);
|
||||
|
||||
$this->assertTrue($user->fresh()->is_active);
|
||||
}
|
||||
}
|
||||
172
tests/Feature/Panel/TaskStreamPanelTest.php
Normal file
172
tests/Feature/Panel/TaskStreamPanelTest.php
Normal file
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Panel;
|
||||
|
||||
use App\Filament\Resources\Projects\Pages\CreateProject;
|
||||
use App\Filament\Resources\Projects\Pages\EditProject;
|
||||
use App\Models\Project;
|
||||
use App\Services\Pireon\AccessToken;
|
||||
use App\Services\Pireon\TokenStore;
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Client\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Livewire;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\FakesPireon;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* The panel's way to open a project's board in TaskStream, as the person
|
||||
* signed in: a toggle on the create form, or an action on a project that
|
||||
* has none yet.
|
||||
*/
|
||||
class TaskStreamPanelTest extends TestCase
|
||||
{
|
||||
use FakesPireon, RefreshDatabase;
|
||||
|
||||
protected string $taskstream = 'https://api.taskstream.test';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
$this->actingAs($this->userWithRoles('editor'));
|
||||
|
||||
config([
|
||||
'services.taskstream.api_url' => $this->taskstream,
|
||||
'services.taskstream.web_url' => 'https://taskstream.test',
|
||||
]);
|
||||
|
||||
// What signing in to the panel leaves in the session.
|
||||
app(TokenStore::class)->put(new AccessToken('session-token', null, now()->addHour()->toImmutable()));
|
||||
}
|
||||
|
||||
protected function fakeTaskStream(int $status = 201, array $body = []): void
|
||||
{
|
||||
Http::fake([
|
||||
"{$this->taskstream}/api/projects" => fn (Request $request) => Http::response(
|
||||
$status === 201 ? ['id' => 'ts-1', 'key' => $request['key'], 'version' => 1] : $body,
|
||||
$status,
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_toggle_opens_a_board_with_the_session_token(): void
|
||||
{
|
||||
$this->fakeTaskStream();
|
||||
|
||||
Livewire::test(CreateProject::class)
|
||||
->fillForm([
|
||||
'title' => ['es' => 'Catálogo abierto'],
|
||||
'slug' => ['es' => 'catalogo'],
|
||||
'taskstream' => true,
|
||||
])
|
||||
->assertFormFieldExists('taskstream_key')
|
||||
->call('create')
|
||||
->assertHasNoFormErrors()
|
||||
->assertNotified();
|
||||
|
||||
Http::assertSent(fn (Request $request) => $request->url() === "{$this->taskstream}/api/projects"
|
||||
&& $request->hasHeader('Authorization', 'Bearer session-token')
|
||||
&& $request['key'] === 'CATALOGO'
|
||||
&& $request['isPublic'] === true);
|
||||
|
||||
$project = Project::query()->sole();
|
||||
$this->assertSame('CATALOGO', $project->taskstream_key);
|
||||
|
||||
Livewire::test(EditProject::class, ['record' => $project->getRouteKey()])
|
||||
->assertActionVisible('taskstream')
|
||||
->assertActionHasUrl('taskstream', 'https://taskstream.test/p/CATALOGO')
|
||||
->assertFormFieldDoesNotExist('taskstream');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function without_the_toggle_taskstream_is_left_alone(): void
|
||||
{
|
||||
$this->fakeTaskStream();
|
||||
|
||||
Livewire::test(CreateProject::class)
|
||||
->fillForm(['title' => ['es' => 'Solo aquí'], 'slug' => ['es' => 'solo-aqui']])
|
||||
->assertFormFieldHidden('taskstream_key')
|
||||
->call('create')
|
||||
->assertHasNoFormErrors();
|
||||
|
||||
Http::assertNothingSent();
|
||||
|
||||
Livewire::test(EditProject::class, ['record' => Project::query()->sole()->getRouteKey()])
|
||||
->assertActionHidden('taskstream');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_refusal_shows_on_the_key_and_saves_nothing(): void
|
||||
{
|
||||
$this->fakeTaskStream(409, ['statusCode' => 409, 'message' => 'Project key WEB is already in use']);
|
||||
|
||||
Livewire::test(CreateProject::class)
|
||||
->fillForm([
|
||||
'title' => ['es' => 'Web'],
|
||||
'slug' => ['es' => 'web'],
|
||||
'taskstream' => true,
|
||||
'taskstream_key' => 'web',
|
||||
])
|
||||
->call('create')
|
||||
->assertHasFormErrors(['taskstream_key']);
|
||||
|
||||
$this->assertSame(0, Project::withTrashed()->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function taskstream_being_down_saves_nothing(): void
|
||||
{
|
||||
Http::fake(["{$this->taskstream}/*" => Http::failedConnection()]);
|
||||
|
||||
Livewire::test(CreateProject::class)
|
||||
->fillForm(['title' => ['es' => 'Web'], 'slug' => ['es' => 'web'], 'taskstream' => true])
|
||||
->call('create')
|
||||
->assertNotified(__('rules.taskstream.unavailable'));
|
||||
|
||||
$this->assertSame(0, Project::withTrashed()->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_project_without_a_board_can_get_one_later(): void
|
||||
{
|
||||
$this->fakeTaskStream();
|
||||
$project = Project::factory()->create(['slug' => ['es' => 'catalogo']]);
|
||||
|
||||
Livewire::test(EditProject::class, ['record' => $project->getRouteKey()])
|
||||
->assertActionHidden('taskstream')
|
||||
->assertActionVisible('createInTaskStream')
|
||||
->callAction('createInTaskStream', data: ['key' => 'cat'])
|
||||
->assertHasNoActionErrors()
|
||||
->assertNotified();
|
||||
|
||||
Http::assertSent(fn (Request $request) => $request->hasHeader('Authorization', 'Bearer session-token')
|
||||
&& $request['key'] === 'CAT'
|
||||
&& $request['isPublic'] === true);
|
||||
|
||||
$this->assertSame('CAT', $project->fresh()->taskstream_key);
|
||||
|
||||
Livewire::test(EditProject::class, ['record' => $project->getRouteKey()])
|
||||
->assertActionVisible('taskstream')
|
||||
->assertActionHidden('createInTaskStream');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_refusal_later_keeps_the_project_without_a_board(): void
|
||||
{
|
||||
$this->fakeTaskStream(409, ['statusCode' => 409, 'message' => 'Project key WEB is already in use']);
|
||||
$project = Project::factory()->create();
|
||||
|
||||
Livewire::test(EditProject::class, ['record' => $project->getRouteKey()])
|
||||
->callAction('createInTaskStream', data: ['key' => 'WEB'])
|
||||
->assertNotified(__('rules.taskstream.refused', ['reason' => 'Project key WEB is already in use']));
|
||||
|
||||
$this->assertNull($project->fresh()->taskstream_key);
|
||||
}
|
||||
}
|
||||
132
tests/Feature/Panel/WebhookPanelTest.php
Normal file
132
tests/Feature/Panel/WebhookPanelTest.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Panel;
|
||||
|
||||
use App\Filament\Resources\Webhooks\Pages\CreateWebhook;
|
||||
use App\Filament\Resources\Webhooks\Pages\EditWebhook;
|
||||
use App\Filament\Resources\Webhooks\RelationManagers\DeliveriesRelationManager;
|
||||
use App\Filament\Resources\Webhooks\WebhookResource;
|
||||
use App\Models\Webhook;
|
||||
use App\Services\Media\HostResolver;
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Filament\Notifications\Notification;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Livewire;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\FakesPireon;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Webhooks from the panel: the same rules as the API, plus the secret shown
|
||||
* once and the deliveries at hand.
|
||||
*/
|
||||
class WebhookPanelTest extends TestCase
|
||||
{
|
||||
use FakesPireon, RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
|
||||
$this->app->instance(HostResolver::class, new class extends HostResolver
|
||||
{
|
||||
public function resolve(string $host): array
|
||||
{
|
||||
return ['hooks.example.org' => ['93.184.216.34'], 'internal.example.org' => ['10.0.0.5']][$host] ?? [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function an_admin_registers_one_and_sees_its_secret_once(): void
|
||||
{
|
||||
$this->actingAs($admin = $this->userWithRoles('admin'));
|
||||
|
||||
Livewire::test(CreateWebhook::class)
|
||||
->fillForm([
|
||||
'name' => 'Avisos',
|
||||
'url' => 'https://hooks.example.org/pireon',
|
||||
'events' => ['application.received'],
|
||||
])
|
||||
->call('create')
|
||||
->assertHasNoFormErrors();
|
||||
|
||||
$webhook = Webhook::query()->sole();
|
||||
|
||||
$this->assertSame(40, strlen($webhook->secret));
|
||||
$this->assertSame($admin->id, $webhook->created_by);
|
||||
|
||||
Notification::assertNotified(
|
||||
Notification::make()
|
||||
->title(__('panel.webhooks.secret.title'))
|
||||
->body(__('panel.webhooks.secret.body', ['secret' => $webhook->secret]))
|
||||
->warning()
|
||||
->persistent(),
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function an_address_inside_the_network_is_refused(): void
|
||||
{
|
||||
$this->actingAs($this->userWithRoles('admin'));
|
||||
|
||||
Livewire::test(CreateWebhook::class)
|
||||
->fillForm(['name' => 'X', 'url' => 'https://internal.example.org/x', 'events' => ['*']])
|
||||
->call('create')
|
||||
->assertHasFormErrors(['url']);
|
||||
|
||||
$this->assertSame(0, Webhook::query()->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_test_is_sent_and_shows_among_the_deliveries(): void
|
||||
{
|
||||
Http::fake(['hooks.example.org/*' => Http::response('ok')]);
|
||||
|
||||
$this->actingAs($this->userWithRoles('admin'));
|
||||
|
||||
$webhook = Webhook::factory()->create(['url' => 'https://hooks.example.org/x']);
|
||||
|
||||
Livewire::test(EditWebhook::class, ['record' => $webhook->getRouteKey()])
|
||||
->callAction('ping');
|
||||
|
||||
Livewire::test(DeliveriesRelationManager::class, ['ownerRecord' => $webhook, 'pageClass' => EditWebhook::class])
|
||||
->assertCanSeeTableRecords($webhook->deliveries()->get())
|
||||
->assertSee('ping');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function rotating_the_secret_shows_the_new_one(): void
|
||||
{
|
||||
$this->actingAs($this->userWithRoles('admin'));
|
||||
|
||||
$webhook = Webhook::factory()->create(['secret' => 'old-secret-old-secret']);
|
||||
|
||||
Livewire::test(EditWebhook::class, ['record' => $webhook->getRouteKey()])
|
||||
->callAction('rotate_secret');
|
||||
|
||||
$new = $webhook->fresh()->secret;
|
||||
|
||||
$this->assertNotSame('old-secret-old-secret', $new);
|
||||
Notification::assertNotified(
|
||||
Notification::make()
|
||||
->title(__('panel.webhooks.secret.title'))
|
||||
->body(__('panel.webhooks.secret.body', ['secret' => $new]))
|
||||
->warning()
|
||||
->persistent(),
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function an_editor_does_not_see_them(): void
|
||||
{
|
||||
$this->actingAs($this->userWithRoles('editor'));
|
||||
|
||||
$this->get(WebhookResource::getUrl('index'))->assertForbidden();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user