graph works

This commit is contained in:
Paul Lützner committed 2026-07-24 22:26:19 +02:00
1 parent 3730cea3af
commit 119f2f03de
14 files changed
+510 -36

No files matched your search

+2 -2
View File
@@ -1508,8 +1508,8 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* }
* @psalm-type MercureConfig = array{
* hubs?: array<string, array{ // Default: []
* url?: scalar|Param|null, // URL of the hub's publish endpoint // Default: null
* public_url?: scalar|Param|null, // URL of the hub's public endpoint
* url?: scalar|Param|null, // URL of the hub's publish endpoint
* public_url?: scalar|Param|null, // URL of the hub's public endpoint // Default: null
* jwt?: string|array{ // JSON Web Token configuration.
* value?: scalar|Param|null, // JSON Web Token to use to publish to this hub.
* provider?: scalar|Param|null, // The ID of a service to call to provide the JSON Web Token.
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260724135613 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE account ALTER imap_host DROP NOT NULL');
$this->addSql('ALTER TABLE account ALTER imap_port DROP NOT NULL');
$this->addSql('ALTER TABLE account ALTER imap_encryption DROP NOT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE account ALTER imap_host SET NOT NULL');
$this->addSql('ALTER TABLE account ALTER imap_port SET NOT NULL');
$this->addSql('ALTER TABLE account ALTER imap_encryption SET NOT NULL');
}
}
+245
View File
@@ -0,0 +1,245 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\Account;
use App\Repository\AccountRepository;
use App\Service\OAuth\OAuthTokenManager;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Contracts\HttpClient\HttpClientInterface;
/**
* Probes a Microsoft account's Graph access endpoint by endpoint, in order of
* increasing privilege, so a 403 can be attributed rather than guessed at.
*
* Deliberately does NOT go through GraphApiClient — the point is to see raw
* status codes and Graph error codes without the client's exception mapping
* swallowing the detail.
*
* On token decoding: work/school accounts issue JWT access tokens whose `scp`
* claim can be inspected directly. Personal Microsoft accounts do not — they
* issue opaque compact tokens (recognisable by the `Ew…` prefix, paired with an
* `M.C…_BAY…` refresh token). There is nothing to decode there, which is
* exactly why this command probes live endpoints instead.
*/
#[AsCommand(
name: 'app:graph:diagnose',
description: 'Probe Microsoft Graph access for one account and report what works',
)]
final class GraphDiagnoseCommand extends Command
{
private const string BASE = 'https://graph.microsoft.com/v1.0';
public function __construct(
private readonly AccountRepository $accountRepository,
private readonly OAuthTokenManager $tokenManager,
private readonly HttpClientInterface $httpClient,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addArgument('accountId', InputArgument::REQUIRED, 'Local Account id');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$account = $this->accountRepository->find((int) $input->getArgument('accountId'));
if (null === $account) {
$io->error('No such account.');
return Command::FAILURE;
}
if (false === $account->isMicrosoft()) {
$io->error(sprintf('Account %d is not a Microsoft account.', $account->getId()));
return Command::FAILURE;
}
$io->title(sprintf('Graph diagnosis — #%d %s', $account->getId(), $account->getEmail()));
$this->reportToken($io, $account);
try {
$token = $this->tokenManager->getValidAccessToken($account);
} catch (\Throwable $e) {
$io->error('Could not obtain an access token: ' . $e->getMessage());
return Command::FAILURE;
}
// Ordered by increasing privilege: identity, then mailbox existence,
// then read, then the optional extras. The first failure localises the
// problem.
// 'required' marks probes that must pass for sync to work at all.
// Master categories are optional: without MailboxSettings.ReadWrite the
// category axis degrades, but folders, messages and send are unaffected.
$probes = [
['identity', 'User.Read — identity', self::BASE . '/me', [], true],
['inbox', 'Mail — inbox folder', self::BASE . '/me/mailFolders/inbox', [], true],
['folders', 'Mail — folder list (delta)', self::BASE . '/me/mailFolders/delta', [], true],
['messages', 'Mail — first message', self::BASE . '/me/messages?$top=1&$select=id', ['Prefer' => 'IdType="ImmutableId"'], true],
['categories', 'Categories — master list', self::BASE . '/me/outlook/masterCategories', [], false],
];
$rows = [];
$results = [];
foreach ($probes as [$key, $label, $url, $headers, $required]) {
[$status, $detail, $applied] = $this->probe('GET', $url, $token, $headers);
$ok = $status >= 200 && $status < 300;
$results[$key] = ['ok' => $ok, 'status' => $status, 'required' => $required];
$rows[] = [
$label . (true === $required ? '' : ' (optional)'),
$status,
$applied,
$detail,
];
}
$io->table(['Probe', 'Status', 'Preference-Applied', 'Detail'], $rows);
$this->interpret($io, $results);
return Command::SUCCESS;
}
// ── Private ───────────────────────────────────────────────────────────────
private function reportToken(SymfonyStyle $io, Account $account): void
{
$access = (string) $account->getOauthAccessToken();
$kind = match (true) {
str_starts_with($access, 'eyJ') => 'JWT (work/school account)',
str_starts_with($access, 'Ew') => 'opaque compact (personal Microsoft account)',
default => 'unrecognised',
};
$io->definitionList(
['Token type' => $kind],
['Expires' => $account->getOauthTokenExpiry()?->format('Y-m-d H:i:s') ?? '(unset)'],
['Immutable ids' => match ($account->getGraphImmutableIds()) {
true => 'supported',
false => 'NOT supported (dedup falls back to RFC Message-ID)',
default => 'not yet probed',
}],
);
if ('opaque compact (personal Microsoft account)' === $kind) {
$io->note(
'Personal accounts issue opaque tokens with no inspectable scope claim, '
. 'so granted permissions can only be inferred from the probes below.'
);
}
}
/**
* @param array<string,string> $headers
* @return array{int, string, string} status, detail, Preference-Applied
*/
private function probe(string $method, string $url, string $token, array $headers): array
{
try {
$response = $this->httpClient->request($method, $url, [
'auth_bearer' => $token,
'headers' => $headers,
]);
$status = $response->getStatusCode();
$applied = $response->getHeaders(false)['preference-applied'][0] ?? '';
if ($status >= 200 && $status < 300) {
return [$status, 'ok', (string) $applied];
}
$body = json_decode($response->getContent(false), true);
$code = $body['error']['code'] ?? 'unknown';
$message = $body['error']['message'] ?? '';
return [$status, sprintf('%s — %s', $code, mb_substr((string) $message, 0, 90)), (string) $applied];
} catch (\Throwable $e) {
return [0, 'transport: ' . mb_substr($e->getMessage(), 0, 90), ''];
}
}
/**
* @param array<string, array{ok: bool, status: int, required: bool}> $results
*/
private function interpret(SymfonyStyle $io, array $results): void
{
$identityOk = true === ($results['identity']['ok'] ?? false);
$mailboxOk = true;
foreach ($results as $result) {
if (true === $result['required'] && false === $result['ok']) {
$mailboxOk = false;
}
}
if (true === $identityOk && false === $mailboxOk) {
$io->warning(
"Identity works but every mail endpoint fails.\n\n"
. "That combination means the token is valid and Graph accepts it — the problem is the\n"
. "mailbox, not the credentials. Two causes:\n\n"
. " 1. No Outlook mailbox exists behind this Microsoft account. An MSA created from an\n"
. " external address (a custom domain hosted elsewhere) has no mailbox provisioned\n"
. " unless one was explicitly added. Confirm at outlook.live.com — if you land in a\n"
. " setup prompt rather than an inbox, this is it, and no configuration will fix it.\n\n"
. " 2. The token predates the Mail.* permissions. Adding scopes in the app registration\n"
. " does NOT upgrade already-issued tokens. Delete the account in plMail and\n"
. " reconnect so a fresh consent issues a token carrying them."
);
return;
}
if (false === $identityOk) {
$io->error(
'Even /me fails, so the token itself is not being accepted. Check that '
. 'OAuthProviderFactory points at https://graph.microsoft.com/ and that the account '
. 'was connected after the Graph scopes were configured.'
);
return;
}
if (false === $mailboxOk) {
return;
}
// Core sync works. Report optional gaps separately so they are not
// mistaken for a broken account.
if (false === ($results['categories']['ok'] ?? false)) {
$io->warning(
"Mail sync is healthy, but master categories are not accessible.\n\n"
. "/me/outlook/masterCategories is NOT covered by Mail.ReadWrite — it lives under the\n"
. "Outlook user-settings resource and needs MailboxSettings.ReadWrite.\n\n"
. "Effect while missing: folders, messages, attachments and send all work. Categories\n"
. "do not sync in either direction, so labels that are not folder-backed will not\n"
. "appear in Outlook and Outlook categories will not appear in plMail.\n\n"
. "Fix: add MailboxSettings.ReadWrite (delegated) in the app registration, then\n"
. "delete and reconnect the account — adding a scope does not upgrade issued tokens."
);
return;
}
$io->success('All probes passed — Graph access is healthy for this account.');
}
}
+16 -7
View File
@@ -132,17 +132,26 @@ class OAuthController extends AbstractController
/** @var User $user */
$user = $this->getUser();
// Email alone does NOT identify an account: an OAuth provider's login
// address is independent of where the mail is hosted, so the same
// address can legitimately exist as both an IMAP account and an OAuth
// one. Adopting by email would silently convert the IMAP account and
// null its password. Match on the full identity instead.
$account = $this->accountRepository->findOneBy([
'usr' => $user,
'email' => $email,
'usr' => $user,
'email' => $email,
'authType' => AuthType::OAuth2->value,
'oauthProvider' => $provider->value,
]);
if (null === $account) {
$account = new Account();
$account->setUsr($user);
$account->setEmail($email);
$account->setName($email);
$account->setIsActive(true);
$duplicate = $this->accountRepository->count(['usr' => $user, 'email' => $email]) > 0;
$account = new Account()
->setUsr($user)
->setEmail($email)
->setName($duplicate ? sprintf('%s (%s)', $email, ucfirst($provider->value)) : $email)
->setIsActive(true);
}
$account->setUsername($email);
@@ -118,7 +118,10 @@ final class AccountPushController extends AbstractController
$status = $status->withFailure();
}
return $this->render('settings/accounts/_push_toggle.html.twig', [
// The compact row control, since both actions are triggered from the
// accounts list. If the roomier card in the edit pane ever posts here
// too, pass the template name in rather than branching on the referer.
return $this->render('settings/accounts/_push_control.html.twig', [
'account' => $account,
'status' => $status,
]);
+6
View File
@@ -45,6 +45,12 @@ enum MailProvider: string
'https://graph.microsoft.com/User.Read',
'https://graph.microsoft.com/Mail.ReadWrite',
'https://graph.microsoft.com/Mail.Send',
// Master categories live under the Outlook user-settings
// resource, NOT under Mail.* — /me/outlook/masterCategories
// returns ErrorAccessDenied without this. ReadWrite rather than
// Read because GraphCategorySyncer and ApplyGraphChangesHandler
// both create category definitions.
'https://graph.microsoft.com/MailboxSettings.ReadWrite',
],
};
}
+10 -1
View File
@@ -31,8 +31,17 @@ class ImapConnectionFactory
default => false,
};
$host = $account->getImapHost();
if (null === $host || '' === $host) {
throw new \RuntimeException(sprintf(
'Account %d (%s) has no IMAP host — it is API-synced and must not be opened over IMAP.',
$account->getId(),
$account->getEmail(),
));
}
$accountConfig = [
'host' => $account->getImapHost(),
'host' => $host,
'port' => $account->getImapPort(),
'encryption' => $encryption,
'validate_cert' => true,
+6 -6
View File
@@ -37,13 +37,13 @@ class Account extends AccountModel
#[ORM\Column(length: 255, nullable: true)]
private ?string $email = null;
#[ORM\Column(length: 255)]
#[ORM\Column(length: 255, nullable: true)]
private ?string $imapHost = null;
#[ORM\Column]
#[ORM\Column(nullable: true)]
private ?int $imapPort = null;
#[ORM\Column(length: 20)]
#[ORM\Column(length: 20, nullable: true)]
private ?string $imapEncryption = null;
#[ORM\Column(length: 255)]
@@ -214,7 +214,7 @@ class Account extends AccountModel
return $this->imapHost;
}
public function setImapHost(string $imapHost): static
public function setImapHost(?string $imapHost): static
{
$this->imapHost = $imapHost;
@@ -226,7 +226,7 @@ class Account extends AccountModel
return $this->imapPort;
}
public function setImapPort(int $imapPort): static
public function setImapPort(?int $imapPort): static
{
$this->imapPort = $imapPort;
@@ -238,7 +238,7 @@ class Account extends AccountModel
return $this->imapEncryption;
}
public function setImapEncryption(string $imapEncryption): static
public function setImapEncryption(?string $imapEncryption): static
{
$this->imapEncryption = $imapEncryption;
+23
View File
@@ -7,6 +7,7 @@ namespace App\Service\Graph;
use App\Entity\Account;
use App\Service\Label\LabelResolver;
use App\Service\Mail\GraphApiClient;
use App\Service\Mail\GraphApiException;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
@@ -39,6 +40,28 @@ final readonly class GraphCategorySyncer
{
try {
$categories = $this->apiClient->listMasterCategories($account);
} catch (GraphApiException $e) {
// 403 here almost always means the MailboxSettings.ReadWrite scope
// is missing rather than anything being broken: master categories
// live under the Outlook user-settings resource, not under Mail.*.
// Degrade quietly — folders, messages and send are unaffected, only
// the category axis is unavailable.
if (403 === $e->getStatus()) {
$this->logger->warning(
'GraphCategorySyncer: no access to master categories — '
. 'the account likely needs reconnecting with MailboxSettings.ReadWrite',
['accountId' => $account->getId()],
);
return;
}
$this->logger->error('GraphCategorySyncer: listing failed', [
'accountId' => $account->getId(),
'error' => $e->getMessage(),
]);
return;
} catch (\Throwable $e) {
$this->logger->error('GraphCategorySyncer: listing failed', [
'accountId' => $account->getId(),
+46 -12
View File
@@ -29,13 +29,42 @@ use Psr\Log\LoggerInterface;
* reliable: a folder literally named "A/B" would break the Gmail approach but
* is handled correctly here.
*
* Immutable IDs do NOT cover mailFolder resources, so folder ids can change.
* System folders are therefore always re-resolved by wellKnownName rather than
* trusting a stored id, and a custom folder whose id has rotated is re-linked
* by its name chain on the next run.
* ── Why roles are resolved by a separate lookup ───────────────────────────
* The obvious implementation reads a `wellKnownName` property off each folder
* in the list. The v1.0 endpoint refuses to project that property onto
* microsoft.graph.mailFolder and fails the whole request with
* "Could not find a property named 'wellKnownName'". So instead each well-known
* name is resolved to an id up front — Graph accepts a well-known name in place
* of an id on the mailFolders path — and the resulting id => name map is
* consulted while walking the list.
*
* That is also the more correct approach: immutable ids do not cover mailFolder
* resources, so system folder ids can rotate, and resolving by name every sync
* means a rotated id re-links itself.
*/
final readonly class GraphFolderSyncer
{
/**
* Well-known names we resolve to ids. Ones the mailbox does not have are
* skipped silently by the client — `archive` is missing on some mailboxes,
* and the recoverable-items folders are absent on consumer accounts.
*/
private const array WELL_KNOWN_NAMES = [
'msgfolderroot',
'inbox',
'sentitems',
'drafts',
'deleteditems',
'junkemail',
'archive',
'outbox',
'searchfolders',
'conversationhistory',
'recoverableitemsdeletions',
'serverfailures',
'syncissues',
];
/**
* Folders we deliberately do not model as labels — Exchange plumbing that
* has no user-facing meaning.
@@ -75,6 +104,12 @@ final readonly class GraphFolderSyncer
{
$folders = $this->apiClient->listFolders($account);
// wellKnownName => folderId, then inverted so the walk can ask
// "what role, if any, does this folder id have?"
$wellKnownByName = $this->apiClient->resolveWellKnownFolders($account, self::WELL_KNOWN_NAMES);
$nameByFolderId = array_flip($wellKnownByName);
$rootId = $wellKnownByName['msgfolderroot'] ?? '';
/** @var array<string, array<string,mixed>> $byId */
$byId = [];
@@ -92,7 +127,7 @@ final readonly class GraphFolderSyncer
$synced = 0;
foreach ($byId as $id => $folder) {
$wellKnown = strtolower((string) ($folder['wellKnownName'] ?? ''));
$wellKnown = $nameByFolderId[$id] ?? '';
if (true === in_array($wellKnown, self::IGNORED_WELL_KNOWN, true)) {
continue;
@@ -103,7 +138,7 @@ final readonly class GraphFolderSyncer
if (true === array_key_exists($wellKnown, self::SYSTEM_MAP)) {
$label = $this->labelResolver->systemLabel(self::SYSTEM_MAP[$wellKnown], $account);
} else {
$segments = $this->nameChain($id, $byId);
$segments = $this->nameChain($id, $byId, $rootId);
if (count($segments) === 0) {
continue;
@@ -157,9 +192,10 @@ final readonly class GraphFolderSyncer
* Walk parentFolderId upward, collecting display names root-first.
*
* @param array<string, array<string,mixed>> $byId
* @param string $rootId msgfolderroot id, walk stops there
* @return list<string>
*/
private function nameChain(string $folderId, array $byId): array
private function nameChain(string $folderId, array $byId, string $rootId): array
{
$segments = [];
$cursor = $folderId;
@@ -173,14 +209,12 @@ final readonly class GraphFolderSyncer
break;
}
$folder = $byId[$cursor];
$wellKnown = strtolower((string) ($folder['wellKnownName'] ?? ''));
if ('msgfolderroot' === $wellKnown) {
if ('' !== $rootId && $cursor === $rootId) {
break;
}
$name = trim((string) ($folder['displayName'] ?? ''));
$folder = $byId[$cursor];
$name = trim((string) ($folder['displayName'] ?? ''));
if ('' !== $name) {
array_unshift($segments, $name);
+53 -5
View File
@@ -4,6 +4,9 @@ declare(strict_types=1);
namespace App\Service\Mail;
use App\Domain\Exception\GraphApiException;
use App\Domain\Exception\GraphResyncRequiredException;
use App\Domain\Exception\GraphThrottledException;
use App\Entity\Account;
use App\Service\OAuth\OAuthTokenManager;
use Symfony\Contracts\HttpClient\HttpClientInterface;
@@ -64,6 +67,14 @@ final class GraphApiClient
/**
* Every mail folder in the mailbox, flattened, following pagination.
*
* Uses the delta endpoint because plain /me/mailFolders returns only
* top-level folders — delta returns the whole tree in one flattened pass.
*
* Deliberately does NOT $select wellKnownName: the v1.0 endpoint refuses
* it on microsoft.graph.mailFolder ("Could not find a property named
* 'wellKnownName'"), so roles are resolved by name instead — see
* resolveWellKnownFolders().
*
* @return list<array<string,mixed>>
*/
public function listFolders(Account $account): array
@@ -71,21 +82,58 @@ final class GraphApiClient
return $this->collectPages(
$account,
self::ME . '/mailFolders/delta',
['$select' => 'id,displayName,parentFolderId,wellKnownName,totalItemCount'],
['$select' => 'id,displayName,parentFolderId,childFolderCount,totalItemCount'],
)['items'];
}
/**
* Resolve a well-known folder (inbox, sentitems, drafts, deleteditems,
* junkemail, archive) to its current id. Folder ids are not immutable, so
* this is called rather than cached across syncs.
* Map each well-known folder name to its current folder id.
*
* Graph accepts a well-known name in place of an id on the mailFolders
* path, which is the supported way to identify system folders. It is also
* the only reliable way: immutable ids do not cover mailFolder resources,
* so a stored system-folder id can rotate, and wellKnownName cannot be
* projected onto the folder list in v1.0.
*
* Names that do not exist on the mailbox are skipped — `archive` is absent
* on some mailboxes, and the recoverable-items folders are absent on
* consumer accounts.
*
* @param list<string> $names
* @return array<string, string> wellKnownName => folderId
*/
public function resolveWellKnownFolders(Account $account, array $names): array
{
$map = [];
foreach ($names as $name) {
try {
$folder = $this->getWellKnownFolder($account, $name);
} catch (GraphApiException) {
// 404 for a folder this mailbox does not have. Not an error.
continue;
}
$id = (string) ($folder['id'] ?? '');
if ('' !== $id) {
$map[$name] = $id;
}
}
return $map;
}
/**
* Resolve a single well-known folder (inbox, sentitems, drafts,
* deleteditems, junkemail, archive, msgfolderroot) to its current id.
*
* @return array<string,mixed>
*/
public function getWellKnownFolder(Account $account, string $wellKnownName): array
{
return $this->request($account, 'GET', self::ME . '/mailFolders/' . rawurlencode($wellKnownName), [
'query' => ['$select' => 'id,displayName,parentFolderId,wellKnownName'],
'query' => ['$select' => 'id,displayName,parentFolderId'],
])->toArray();
}
+1 -1
View File
@@ -57,7 +57,7 @@
</button>
{% endif %}
{% if account.microsoft or account.gmail %}
{{ include('settings/accounts/_push_toggle.html.twig', {account: account, failed: false}) }}
{{ include('settings/accounts/_push_control.html.twig', {account: account, failed: false}) }}
{% endif %}
<form method="post" action="{{ path('app_account_toggle', { id: account.id }) }}">
<input type="hidden" name="_token" value="{{ csrf_token('toggle' ~ account.id) }}">
@@ -0,0 +1,62 @@
{# Compact push control for a single account row in the accounts settings list.
Include with just the account; it resolves its own state:
{{ include('settings/accounts/_push_control.html.twig', {account: account}) }}
Renders nothing for providers without push (IMAP), so no conditional is
needed at the call site — the row simply has one fewer control.
For the roomier card version (headings, hint text, labelled repair button)
use _push_toggle.html.twig in the account edit pane instead.
NOTE: `class="contents"` on the turbo-frame is load-bearing. <turbo-frame>
is display:block by default, which breaks it out of the row's flex layout
and pushes every sibling control out of alignment. #}
{% set status = status|default(push_status(account)) %}
<turbo-frame id="account-push-{{ account.id }}" class="contents">
{% if status.supported %}
{% set degraded = status.health.value == 'degraded' %}
{# When push is registered but not delivering, the useful action is to
re-register, not to toggle off. Same control, different target. #}
{% set action = degraded
? path('settings_account_push_repair', {id: account.id})
: path('settings_account_push_toggle', {id: account.id}) %}
{% if status.failed %}
{% set tooltip = 'settings.accounts.push.failed'|trans %}
{% elseif degraded %}
{% set tooltip = 'settings.accounts.push.degraded_hint'|trans %}
{% elseif not status.configured and not status.enabled %}
{% set tooltip = 'settings.accounts.push.not_configured'|trans %}
{% elseif status.enabled %}
{% set tooltip = 'settings.accounts.push.on_hint'|trans %}
{% else %}
{% set tooltip = 'settings.accounts.push.off_hint'|trans %}
{% endif %}
<form method="post" action="{{ action }}" class="contents">
<input type="hidden" name="_token" value="{{ csrf_token('account_push_' ~ account.id) }}">
<button type="submit"
title="{{ tooltip }}"
aria-label="{{ 'settings.accounts.push.title'|trans }}: {{ status.health.translationKey|trans }}"
{{ not status.isToggleable and not degraded ? 'disabled' : '' }}
class="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-sm transition-colors
focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500
disabled:cursor-not-allowed disabled:opacity-40
{% if status.failed or degraded %}
text-amber-600 hover:bg-amber-100/60 dark:text-amber-400 dark:hover:bg-amber-950/40
{% elseif status.enabled %}
text-emerald-600 hover:bg-emerald-100/60 dark:text-emerald-400 dark:hover:bg-emerald-950/40
{% else %}
text-zinc-400 hover:bg-zinc-100/60 dark:text-zinc-500 dark:hover:bg-zinc-800/60
{% endif %}">
<i class="fa-solid {{ status.failed ? 'fa-triangle-exclamation' : status.health.icon }}"></i>
</button>
</form>
{% endif %}
</turbo-frame>
@@ -15,7 +15,7 @@
{% set status = status|default(push_status(account)) %}
{% if status.supported %}
<turbo-frame id="account-push-{{ account.id }}">
<turbo-frame id="account-push-{{ account.id }}" class="block">
<div class="rounded-xl border border-white/40 dark:border-white/10 bg-white/80 dark:bg-gray-900/80 backdrop-blur-xl p-4">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">