mirror of
https://github.com/anikeen-com/id.git
synced 2026-11-08 00:28:32 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
642e518259 |
No files matched your search
@@ -1,5 +1,16 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
The user's role for trusted apps; it needs an Anikeen ID release that sends it.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `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 (2026-11-07)
|
## 3.4.0 (2026-11-07)
|
||||||
|
|
||||||
Preferences, charges, the billing limit, user profiles and the user and preference webhooks; they
|
Preferences, charges, the billing limit, user profiles and the user and preference webhooks; they
|
||||||
|
|||||||
@@ -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
|
Access tokens expire. Get a new one with the refresh token (or send the user through the login
|
||||||
again):
|
again):
|
||||||
|
|
||||||
@@ -1207,8 +1223,14 @@ $profile?->emailVerified();
|
|||||||
$profile?->avatar();
|
$profile?->avatar();
|
||||||
$profile?->version(); // compare with the version of user.updated
|
$profile?->version(); // compare with the version of user.updated
|
||||||
$profile?->locale; // also username, created_at
|
$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
|
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
|
`too_many_requests` and other failures. The profile carries no billing or account data; read those
|
||||||
through their own endpoints.
|
through their own endpoints.
|
||||||
|
|||||||
+22
@@ -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
|
Access tokens expire. Get a new one with the refresh token (or send the user through the login
|
||||||
again):
|
again):
|
||||||
|
|
||||||
@@ -1207,8 +1223,14 @@ $profile?->emailVerified();
|
|||||||
$profile?->avatar();
|
$profile?->avatar();
|
||||||
$profile?->version(); // compare with the version of user.updated
|
$profile?->version(); // compare with the version of user.updated
|
||||||
$profile?->locale; // also username, created_at
|
$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
|
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
|
`too_many_requests` and other failures. The profile carries no billing or account data; read those
|
||||||
through their own endpoints.
|
through their own endpoints.
|
||||||
|
|||||||
@@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace Anikeen\Id\Resources;
|
namespace Anikeen\Id\Resources;
|
||||||
|
|
||||||
|
use Anikeen\Id\Enums\Role;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use JsonSerializable;
|
use JsonSerializable;
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ use JsonSerializable;
|
|||||||
* @property ?string $created_at REST shapes
|
* @property ?string $created_at REST shapes
|
||||||
* @property ?string $pending_email profile updates only: a new address waiting for confirmation
|
* @property ?string $pending_email profile updates only: a new address waiting for confirmation
|
||||||
* @property ?string $pending_email_expires_at profile updates only
|
* @property ?string $pending_email_expires_at profile updates only
|
||||||
|
* @property ?string $role trusted apps only: Role::ADMIN or Role::USER
|
||||||
*/
|
*/
|
||||||
class User implements JsonSerializable
|
class User implements JsonSerializable
|
||||||
{
|
{
|
||||||
@@ -97,6 +99,22 @@ class User implements JsonSerializable
|
|||||||
return (int) ($this->data->profile_version ?? $this->data->version ?? 0);
|
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`
|
* A new email address waiting for the user's confirmation (profile updates only); `email`
|
||||||
* stays the current address until then.
|
* stays the current address until then.
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ class Provider extends AbstractProvider implements ProviderInterface
|
|||||||
'name' => $user['name'],
|
'name' => $user['name'],
|
||||||
'email' => Arr::get($user, 'email'),
|
'email' => Arr::get($user, 'email'),
|
||||||
'avatar' => $user['avatar'],
|
'avatar' => $user['avatar'],
|
||||||
|
// trusted apps only (Enums\Role), null for others: $socialiteUser->role
|
||||||
|
'role' => Arr::get($user, 'role'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -92,7 +92,8 @@ final class EventType
|
|||||||
* the changed fields (never their old values). An email address appears only once verified
|
* 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
|
* (`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
|
* 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';
|
public const USER_UPDATED = 'user.updated';
|
||||||
|
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user