expires_at === null || $token->expires_at->isFuture(); } /** * Totals for one account. * * @return array{total: int, active: int} */ public function summarize(User $user): array { return $this->summarizeMany([$user->id])[$user->id] ?? ['total' => 0, 'active' => 0]; } /** * Totals for a page of accounts, in one query — a listing that asked * per row would be an N+1 on a screen that already paginates 25 at a * time. * * Counted in the database rather than by loading the rows: nothing * here needs a token's name or abilities, only how many there are. * Expiry is compared in SQL for the same reason. * * @param iterable $userIds * @return array */ public function summarizeMany(iterable $userIds): array { $ids = Collection::make($userIds)->map(fn ($id): int => (int) $id)->unique()->values(); if ($ids->isEmpty()) { return []; } return PersonalAccessToken::query() ->where('tokenable_type', User::class) ->whereIn('tokenable_id', $ids) ->selectRaw('tokenable_id') ->selectRaw('count(*) as total') ->selectRaw('sum(case when expires_at is null or expires_at > ? then 1 else 0 end) as active', [now()]) ->groupBy('tokenable_id') ->get() ->mapWithKeys(fn (PersonalAccessToken $row): array => [ (int) $row->getAttribute('tokenable_id') => [ 'total' => (int) $row->getAttribute('total'), 'active' => (int) $row->getAttribute('active'), ], ]) ->all(); } /** * Every token an account holds, with its abilities resolved to the * labels the permission screens use — bare keys like * `edit_others_files` are not what an administrator should have to * read to answer "what can this integration do". * * @return list> */ public function detailFor(User $user): array { $stillGranted = $this->abilities->availableFor($user); return array_values($user->tokens() ->orderByDesc('created_at') ->get() ->map(fn (PersonalAccessToken $token): array => [ 'id' => (string) $token->getKey(), 'name' => $token->name, 'active' => self::isActive($token), 'created_at' => $token->created_at?->toIso8601String(), 'last_used_at' => $token->last_used_at?->toIso8601String(), 'expires_at' => $token->expires_at?->toIso8601String(), // array_values because Sanctum casts the column straight // from JSON, so nothing guarantees the keys are sequential. 'abilities' => $this->describeAbilities(array_values($token->abilities ?? []), $stillGranted), ]) ->all()); } /** * @param list $keys * @param list $stillGranted * @return list */ private function describeAbilities(array $keys, array $stillGranted): array { $described = []; foreach ($keys as $key) { $permission = Permission::tryFrom($key); $described[] = [ 'key' => $key, // An unrecognised key is still worth showing rather than // hiding: it means the token carries something this // vocabulary no longer has, which is exactly the sort of // leftover an administrator is looking at this list for. 'label' => $permission?->label() ?? $key, 'category' => $permission?->category()->label() ?? '', // Whether it does anything today. A token keeps the // abilities it was issued with, but EnsureTokenCan // re-checks the owner's live permissions on every // request, so an ability the owner has since lost is // carried and ignored. 'effective' => in_array($key, $stillGranted, true), ]; } return $described; } }