Compare commits

...
Author SHA1 Message Date
envoyr 642e518259 Give trusted apps the user's role
Anikeen ID now sends trusted apps the user's role (admin or user) in
GET v1/user, a user read by ID and user.updated, so an app like the
Console can take its admins from Anikeen ID. Enums\Role, the Socialite
user's role and Resources\User::role()/isAdmin() expose it.
2026-11-07 15:24:48 +00:00
envoyr 31de31aa47 Merge pull request #26 from anikeen-com/release/3.4.0
Release 3.4.0
2026-11-07 11:55:58 +01:00
envoyr 0205696719 Release 3.4.0 2026-11-07 10:55:18 +00:00
8 changed files with 165 additions and 19 deletions

No files matched your search

+22 -18
View File
@@ -2,29 +2,19 @@
## Unreleased
User profiles and the user and preference webhooks; they need an Anikeen ID release that supports
them.
The user's role for trusted apps; it needs an Anikeen ID release that sends it.
### Added
- Profile updates with the user's token (scope `user`): `updateProfile()` (name, username, email;
a new email address waits for the user's confirmation), `uploadAvatar()` and `removeAvatar()` on
`Billable`. They return the updated profile and clear the cached `getUserData()`.
- Reading a connected user's profile by ID for trusted apps: `findUserById()` (`null` for an
unknown, deleted or unconnected user).
- `Resources\User` with `emailVerified()`, `version()` (`profile_version` or a webhook's
`version`), `pendingEmail()` and `pendingEmailExpiresAt()`; it also reads the object of a
`user.updated` event.
- `ValidationFailedException` (a subclass of `ApiException`) for 422 responses, with
`errorCodes()`, `errorCodeFor()`, `hasErrorCode()` and `errors()`; catching `ApiException` keeps
working.
- `AnikeenId::requestMultipart()` for file uploads.
- Opt-in webhook events `user.updated`, `user.deleted` and `preference.updated`
(`EventType::USER_UPDATED`, `USER_DELETED`, `PREFERENCE_UPDATED`).
- `Enums\Role` (`ADMIN`, `USER`): the user's Anikeen ID role, seen by trusted apps only.
- The Socialite user carries it as `$user->role` (`null` for other apps).
- `Resources\User::role()` and `isAdmin()`, for a user read by ID (`findUserById()`) and the
`data.object` of `user.updated`, which for trusted apps carries `role` (and `role` in `changed`).
## 3.4.0-beta.1 (2026-11-06)
## 3.4.0 (2026-11-07)
Preferences, charges and the billing limit; they need an Anikeen ID release that supports them.
Preferences, charges, the billing limit, user profiles and the user and preference webhooks; they
need an Anikeen ID release that supports them.
### Added
@@ -49,6 +39,20 @@ Preferences, charges and the billing limit; they need an Anikeen ID release that
(`EventType::BILLING_LIMIT_REACHED`, `BILLING_LIMIT_AVAILABLE`).
- An optional trailing `$headers` argument on `AnikeenId::request()`, `get()`, `post()`, `put()`,
`delete()` and `AppRequest::send()`; calls without it send the same request as before.
- Profile updates with the user's token (scope `user`): `updateProfile()` (name, username, email;
a new email address waits for the user's confirmation), `uploadAvatar()` and `removeAvatar()` on
`Billable`. They return the updated profile and clear the cached `getUserData()`.
- Reading a connected user's profile by ID for trusted apps: `findUserById()` (`null` for an
unknown, deleted or unconnected user).
- `Resources\User` with `emailVerified()`, `version()` (`profile_version` or a webhook's
`version`), `pendingEmail()` and `pendingEmailExpiresAt()`; it also reads the object of a
`user.updated` event.
- `ValidationFailedException` (a subclass of `ApiException`) for 422 responses, with
`errorCodes()`, `errorCodeFor()`, `hasErrorCode()` and `errors()`; catching `ApiException` keeps
working.
- `AnikeenId::requestMultipart()` for file uploads.
- Opt-in webhook events `user.updated`, `user.deleted` and `preference.updated`
(`EventType::USER_UPDATED`, `USER_DELETED`, `PREFERENCE_UPDATED`).
## 3.3.0 (2026-11-06)
+22
View File
@@ -148,6 +148,22 @@ Route::get('auth/callback', function () {
});
```
### The user's role (trusted apps)
[Trusted apps](#trusted-apps) also get the user's Anikeen ID role, `admin` (runs Anikeen) or
`user` (constants on `Anikeen\Id\Enums\Role`); for other apps it is `null`. An app can make
Anikeen ID admins its own admins this way instead of keeping a list of its own. Read it at sign-in
and keep it current with the `user.updated` webhook (`role` in `changed`, see
[User profiles](#user-profiles)):
```php
use Anikeen\Id\Enums\Role;
$account = Socialite::driver('anikeen')->user();
$user->update(['role' => $account->role ?? Role::USER]);
```
Access tokens expire. Get a new one with the refresh token (or send the user through the login
again):
@@ -1207,8 +1223,14 @@ $profile?->emailVerified();
$profile?->avatar();
$profile?->version(); // compare with the version of user.updated
$profile?->locale; // also username, created_at
$profile?->role(); // Role::ADMIN or Role::USER (trusted apps only)
$profile?->isAdmin();
```
The `data.object` of `user.updated` is the same profile (`new \Anikeen\Id\Resources\User($event['data']['object'])`);
for trusted apps it carries `role`, and a change of the role alone (`changed` is `["role"]`) is
sent to them only.
It throws `ApiException` for 403 `client_not_allowed` (not a trusted app), 429
`too_many_requests` and other failures. The profile carries no billing or account data; read those
through their own endpoints.
+22
View File
@@ -148,6 +148,22 @@ Route::get('auth/callback', function () {
});
```
### The user's role (trusted apps)
[Trusted apps](#trusted-apps) also get the user's Anikeen ID role, `admin` (runs Anikeen) or
`user` (constants on `Anikeen\Id\Enums\Role`); for other apps it is `null`. An app can make
Anikeen ID admins its own admins this way instead of keeping a list of its own. Read it at sign-in
and keep it current with the `user.updated` webhook (`role` in `changed`, see
[User profiles](#user-profiles)):
```php
use Anikeen\Id\Enums\Role;
$account = Socialite::driver('anikeen')->user();
$user->update(['role' => $account->role ?? Role::USER]);
```
Access tokens expire. Get a new one with the refresh token (or send the user through the login
again):
@@ -1207,8 +1223,14 @@ $profile?->emailVerified();
$profile?->avatar();
$profile?->version(); // compare with the version of user.updated
$profile?->locale; // also username, created_at
$profile?->role(); // Role::ADMIN or Role::USER (trusted apps only)
$profile?->isAdmin();
```
The `data.object` of `user.updated` is the same profile (`new \Anikeen\Id\Resources\User($event['data']['object'])`);
for trusted apps it carries `role`, and a change of the role alone (`changed` is `["role"]`) is
sent to them only.
It throws `ApiException` for 403 `client_not_allowed` (not a trusted app), 429
`too_many_requests` and other failures. The profile carries no billing or account data; read those
through their own endpoints.
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace Anikeen\Id\Enums;
/**
* A user's role on Anikeen ID. Only trusted apps see it: `role` of GET v1/user (the Socialite
* user), of a user read by ID and of user.updated; other apps get none.
*/
class Role
{
/** Runs Anikeen (the Anikeen ID admin panel). */
const ADMIN = 'admin';
const USER = 'user';
/**
* @return list<string>
*/
public static function all(): array
{
return [self::ADMIN, self::USER];
}
}
+18
View File
@@ -2,6 +2,7 @@
namespace Anikeen\Id\Resources;
use Anikeen\Id\Enums\Role;
use DateTimeImmutable;
use JsonSerializable;
@@ -27,6 +28,7 @@ use JsonSerializable;
* @property ?string $created_at REST shapes
* @property ?string $pending_email profile updates only: a new address waiting for confirmation
* @property ?string $pending_email_expires_at profile updates only
* @property ?string $role trusted apps only: Role::ADMIN or Role::USER
*/
class User implements JsonSerializable
{
@@ -97,6 +99,22 @@ class User implements JsonSerializable
return (int) ($this->data->profile_version ?? $this->data->version ?? 0);
}
/**
* Role::ADMIN or Role::USER; null for apps that aren't trusted (they never see it).
*/
public function role(): ?string
{
return $this->data->role ?? null;
}
/**
* An Anikeen ID admin (trusted apps only; false when the role isn't known).
*/
public function isAdmin(): bool
{
return $this->role() === Role::ADMIN;
}
/**
* A new email address waiting for the user's confirmation (profile updates only); `email`
* stays the current address until then.
+2
View File
@@ -83,6 +83,8 @@ class Provider extends AbstractProvider implements ProviderInterface
'name' => $user['name'],
'email' => Arr::get($user, 'email'),
'avatar' => $user['avatar'],
// trusted apps only (Enums\Role), null for others: $socialiteUser->role
'role' => Arr::get($user, 'role'),
]);
}
+2 -1
View File
@@ -92,7 +92,8 @@ final class EventType
* the changed fields (never their old values). An email address appears only once verified
* (`email` is null while it isn't); `avatar` in `changed` means the picture behind the stable URL
* changed. Keep the highest `version` and ignore older events. Opt-in (not through `*`); also
* sent to the app that made the change.
* sent to the app that made the change. Trusted apps also get `role` (Enums\Role) and `role` in
* `changed`; a change of the role alone reaches only them.
*/
public const USER_UPDATED = 'user.updated';
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\AnikeenId;
use Anikeen\Id\Enums\Role;
use Anikeen\Id\Resources\User;
use Anikeen\Id\Socialite\Provider;
use Illuminate\Http\Request;
use ReflectionMethod;
/**
* The user's role, seen by trusted apps only: at sign-in, read by ID and in user.updated.
*/
class UserRoleTest extends TestCase
{
private function socialiteUser(array $raw): \SocialiteProviders\Manager\OAuth2\User
{
$provider = new Provider(Request::create('/'), 'client', 'secret', 'https://app.test/callback');
$map = new ReflectionMethod($provider, 'mapUserToObject');
return $map->invoke($provider, $raw);
}
public function test_the_socialite_user_carries_the_role(): void
{
$raw = ['id' => '01JUSER', 'username' => 'ada', 'name' => 'Ada', 'email' => 'ada@example.com', 'avatar' => null];
$this->assertSame(Role::ADMIN, $this->socialiteUser([...$raw, 'role' => 'admin'])->role);
// apps that aren't trusted never get it
$this->assertNull($this->socialiteUser($raw)->role);
}
public function test_a_user_read_by_id_has_the_role(): void
{
$this->respond(200, ['data' => ['id' => '01JUSER', 'name' => 'Ada', 'role' => 'admin']]);
$user = app(AnikeenId::class)->findUserById('01JUSER');
$this->assertSame(Role::ADMIN, $user->role());
$this->assertTrue($user->isAdmin());
}
public function test_the_webhook_object_has_it_for_trusted_apps_only(): void
{
$trusted = new User(['object' => 'user', 'id' => '01JUSER', 'version' => 3, 'role' => 'user']);
$other = new User(['object' => 'user', 'id' => '01JUSER', 'version' => 3]);
$this->assertSame(Role::USER, $trusted->role());
$this->assertFalse($trusted->isAdmin());
$this->assertNull($other->role());
$this->assertFalse($other->isAdmin());
$this->assertSame([Role::ADMIN, Role::USER], Role::all());
}
}