Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ Vrij en open source onder de EUPL-1.2-licentie.

**Ondersteuning:** support@conduction.nl
]]></description>
<version>0.3.9-beta.20261005130000</version>
<version>0.3.9-beta.20261015200000</version>
<licence>EUPL-1.2</licence>
<author mail="info@conduction.nl" homepage="https://www.conduction.nl/">Conduction</author>
<namespace>Learniq</namespace>
Expand Down
9 changes: 8 additions & 1 deletion l10n/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -3752,7 +3752,14 @@ OC.L10N.register(
"Level %s": "Level %s",
"Learning path": "Learning path",
"or": "or",
"Training course": "Training course"
"Training course": "Training course",
"Wallet": "Wallet",
"Add to wallet": "Add to wallet",
"Wallet offer link": "Wallet offer link",
"The link a wallet app opens to collect this certificate, while the offer is open. Cleared once it is collected, revoked or the offer fails.": "The link a wallet app opens to collect this certificate, while the offer is open. Cleared once it is collected, revoked or the offer fails.",
"Learniq wallet offers": "Learniq wallet offers",
"Certificates can be offered to a wallet through integriq.": "Certificates can be offered to a wallet through integriq.",
"No integriq API token is set (%s), so a certificate cannot be offered to a wallet.": "No integriq API token is set (%s), so a certificate cannot be offered to a wallet."
},
"nplurals=2; plural=(n != 1);"
)
9 changes: 8 additions & 1 deletion l10n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -3790,7 +3790,14 @@
"Level %s": "Level %s",
"Learning path": "Learning path",
"or": "or",
"Training course": "Training course"
"Training course": "Training course",
"Wallet": "Wallet",
"Add to wallet": "Add to wallet",
"Wallet offer link": "Wallet offer link",
"The link a wallet app opens to collect this certificate, while the offer is open. Cleared once it is collected, revoked or the offer fails.": "The link a wallet app opens to collect this certificate, while the offer is open. Cleared once it is collected, revoked or the offer fails.",
"Learniq wallet offers": "Learniq wallet offers",
"Certificates can be offered to a wallet through integriq.": "Certificates can be offered to a wallet through integriq.",
"No integriq API token is set (%s), so a certificate cannot be offered to a wallet.": "No integriq API token is set (%s), so a certificate cannot be offered to a wallet."
},
"plurals": "",
"pluralForm": "nplurals=2; plural=(n != 1);"
Expand Down
9 changes: 8 additions & 1 deletion l10n/nl.js
Original file line number Diff line number Diff line change
Expand Up @@ -4206,7 +4206,14 @@ OC.L10N.register(
"Level %s": "Niveau %s",
"Learning path": "Leerweg",
"or": "of",
"Training course": "Cursus"
"Training course": "Cursus",
"Wallet": "Wallet",
"Add to wallet": "In wallet zetten",
"Wallet offer link": "Wallet-link",
"The link a wallet app opens to collect this certificate, while the offer is open. Cleared once it is collected, revoked or the offer fails.": "De link die een wallet-app opent om dit certificaat op te halen, zolang het aanbod openstaat. Wordt leeggemaakt zodra het is opgehaald, ingetrokken of het aanbod mislukt.",
"Learniq wallet offers": "Learniq-walletaanbiedingen",
"Certificates can be offered to a wallet through integriq.": "Certificaten kunnen via integriq in een wallet worden aangeboden.",
"No integriq API token is set (%s), so a certificate cannot be offered to a wallet.": "Er is geen integriq-API-token ingesteld (%s), dus een certificaat kan niet in een wallet worden aangeboden."
},
"nplurals=2; plural=(n != 1);"
)
9 changes: 8 additions & 1 deletion l10n/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -4259,7 +4259,14 @@
"Level %s": "Niveau %s",
"Learning path": "Leerweg",
"or": "of",
"Training course": "Cursus"
"Training course": "Cursus",
"Wallet": "Wallet",
"Add to wallet": "In wallet zetten",
"Wallet offer link": "Wallet-link",
"The link a wallet app opens to collect this certificate, while the offer is open. Cleared once it is collected, revoked or the offer fails.": "De link die een wallet-app opent om dit certificaat op te halen, zolang het aanbod openstaat. Wordt leeggemaakt zodra het is opgehaald, ingetrokken of het aanbod mislukt.",
"Learniq wallet offers": "Learniq-walletaanbiedingen",
"Certificates can be offered to a wallet through integriq.": "Certificaten kunnen via integriq in een wallet worden aangeboden.",
"No integriq API token is set (%s), so a certificate cannot be offered to a wallet.": "Er is geen integriq-API-token ingesteld (%s), dus een certificaat kan niet in een wallet worden aangeboden."
},
"plurals": "",
"pluralForm": "nplurals=2; plural=(n != 1);"
Expand Down
5 changes: 5 additions & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
use OCA\OpenRegister\AppHost\Bootstrap;
use OCA\Learniq\AppInfo\Registrar\EventListenerWiring;
use OCA\Learniq\Middleware\ActionForbiddenMiddleware;
use OCA\Learniq\SetupCheck\WalletOfferTokenCheck;
use OCA\Learniq\AppInfo\Registrar\ServiceOverrideRegistrar;
use OCA\Learniq\Mcp\LearniqScannableServices;
use OCP\AppFramework\App;
Expand Down Expand Up @@ -90,6 +91,10 @@ public function register(IRegistrationContext $context): void {
// First, so nothing below that throws can leave it unregistered.
$context->registerMiddleware(ActionForbiddenMiddleware::class);

// The admin overview names a missing integriq token, which makes every
// wallet offer fail (credential-wallet-offer-for-the-learner).
$context->registerSetupCheck(WalletOfferTokenCheck::class);

// ADR-040: adopt the OpenRegister AppHost. One call wires the generic
// SPA/settings/preferences/health/metrics controllers, the settings +
// action-auth services, the install repair steps, the admin settings
Expand Down
3 changes: 3 additions & 0 deletions lib/Portal/ParticipantSitePages.php
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ public function collections(): array {
'expiryLabel',
'renewalLine',
'verificationUrl',
'walletOfferUri',
'lifecycle',
],
extra: [
Expand All @@ -140,6 +141,8 @@ public function collections(): array {
['field' => 'expiresAt', 'label' => 'Valid until', 'render' => 'date'],
['field' => 'expiryLabel', 'label' => 'Status'],
['field' => 'verificationUrl', 'label' => 'Check or download', 'render' => 'link'],
// The open wallet offer as a QR code with its link (portaliq link-field-qr-code).
['field' => 'walletOfferUri', 'label' => 'Wallet', 'render' => 'qr', 'linkLabel' => 'Add to wallet'],
],
]
),
Expand Down
2 changes: 2 additions & 0 deletions lib/Portal/PortalLabelTranslator.php
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ class PortalLabelTranslator {
'composeHint',
// A task's title sentence; its `{field}` places stay as they are (portaliq lookup-by-row-field).
'titleTemplate',
// A link or QR column's words for its link (portaliq link-field-qr-code).
'linkLabel',
];

/**
Expand Down
14 changes: 14 additions & 0 deletions lib/Service/ConnectionReportService.php
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,20 @@ public function observeLti(): void {
$this->observe(key: self::LTI_KEY, status: 'unavailable', reason: 'LTI tools need integriq, which is not installed.');
}//end observeLti()

/**
* The status last recorded for a connection (`configured`, `unconfigured`,
* `error` or `unavailable`), or null when nothing was recorded.
*
* @param string $key The connection key.
*
* @return string|null
*
* @spec openspec/changes/credential-wallet-offer-for-the-learner/specs/certification/spec.md#requirement-the-setup-check-names-the-missing-wallet-token
*/
public function lastStatus(string $key): ?string {
return ($this->readObservation(key: $key)['status'] ?? null);
}//end lastStatus()

/**
* The stored observation for a connection, or null when none is usable.
*
Expand Down
4 changes: 3 additions & 1 deletion lib/Service/WalletClaimSyncService.php
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,15 @@ public function check(array $object, string $action, string $userId): GuardResul
*
* @param array<string,mixed> $credential The Credential data array.
*
* @return array<string,mixed> The Credential with `walletOfferStatus=claimed` and `walletClaimedAt=now`.
* @return array<string,mixed> The Credential with `walletOfferStatus=claimed`, `walletClaimedAt=now` and no offer link.
*
* @spec openspec/specs/certification/spec.md#requirement-recordwalletclaim-transition-syncs-wallet-claim-status-back-onto-the-credential
* @spec openspec/changes/credential-wallet-offer-for-the-learner/specs/certification/spec.md#requirement-the-credential-keeps-the-wallet-offer-link-while-the-offer-is-open
*/
public function claim(array $credential): array {
$credential['walletOfferStatus'] = 'claimed';
$credential['walletClaimedAt'] = gmdate('c');
$credential['walletOfferUri'] = null;

return $credential;
}//end claim()
Expand Down
37 changes: 33 additions & 4 deletions lib/Service/WalletOfferDelegationService.php
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,13 @@ class WalletOfferDelegationService implements LifecycleGuardInterface {
*/
private const OPENCONNECTOR_TOKEN_KEY = 'openconnector_api_token';

/**
* The scheme of the link a wallet app opens (OpenID4VCI credential offer).
*
* @var string
*/
private const WALLET_SCHEME = 'openid-credential-offer://';

/**
* Refusal when the Credential carries nothing to offer.
*
Expand Down Expand Up @@ -180,18 +187,21 @@ public function check(array $object, string $action, string $userId): GuardResul
/**
* Push the Credential to the EUDI wallet through openconnector.
*
* On success sets `walletOfferStatus=offered`, `walletOfferedAt` and
* `walletAttestationRef` and clears `walletOfferError`. On any failure sets
* `walletOfferError` and leaves the offer fields as they were, so a failed
* offer is recorded rather than lost. Never throws.
* On success sets `walletOfferStatus=offered`, `walletOfferedAt`,
* `walletAttestationRef` and `walletOfferUri` (the link a wallet app opens)
* and clears `walletOfferError`. On any failure sets `walletOfferError`,
* clears `walletOfferUri` and leaves the other offer fields as they were,
* so a failed offer is recorded rather than lost. Never throws.
*
* @param array<string,mixed> $credential The Credential data array.
*
* @return array<string,mixed> The Credential with the offer outcome applied.
*
* @spec openspec/specs/certification/spec.md#requirement-offertowallet-transition-pushes-an-issued-credential-to-the-eudi-wallet
* @spec openspec/changes/credential-wallet-offer-for-the-learner/specs/certification/spec.md#requirement-the-credential-keeps-the-wallet-offer-link-while-the-offer-is-open
*/
public function offer(array $credential): array {
$credential['walletOfferUri'] = null;
$requestBody = $this->buildOfferRequest(credential: $credential);
if ($requestBody === null) {
$credential['walletOfferError'] = self::NO_PAYLOAD;
Expand All @@ -216,6 +226,7 @@ public function offer(array $credential): array {
$credential['walletOfferStatus'] = 'offered';
$credential['walletOfferedAt'] = gmdate('c');
$credential['walletAttestationRef'] = $attestationRef;
$credential['walletOfferUri'] = $this->walletLink(response: $result);
$credential['walletOfferError'] = null;

$this->logger->info(
Expand Down Expand Up @@ -309,6 +320,24 @@ private function extractOfferUuid(array $response): ?string {
return $uuid;
}//end extractOfferUuid()

/**
* The link a wallet app opens to collect the offer: integriq's `offerUrl`
* (`openid-credential-offer://?credential_offer_uri=...`, the same value
* as `qrPayload`), else that link built from `credentialOfferUri`.
*
* @param array<string,mixed> $response The decoded `createOffer` response body.
*
* @return string
*/
private function walletLink(array $response): string {
$offerUrl = ($response['offerUrl'] ?? null);
if (is_string($offerUrl) === true && str_starts_with($offerUrl, self::WALLET_SCHEME) === true) {
return $offerUrl;
}

return self::WALLET_SCHEME . '?credential_offer_uri=' . rawurlencode((string)$response['credentialOfferUri']);
}//end walletLink()

/**
* Call openconnector's app-facing, consumer-gated offer-creation
* endpoint.
Expand Down
14 changes: 10 additions & 4 deletions lib/Service/WalletRevocationPropagationService.php
Original file line number Diff line number Diff line change
Expand Up @@ -154,18 +154,24 @@ public function check(array $object, string $action, string $userId): GuardResul
/**
* Propagate a revocation to any outstanding wallet offer, best-effort.
*
* No-op (credential returned unchanged, no call made) unless there is an
* outstanding offer with a correlation key. On a confirmed revocation sets
* `walletOfferStatus=revoked`; on any failure records `walletOfferError`
* and never throws.
* The offer link is cleared first, whatever follows: a revoked
* certificate is never offered. Otherwise no call is made unless there is
* an outstanding offer with a correlation key. On a confirmed revocation
* sets `walletOfferStatus=revoked`; on any failure records
* `walletOfferError` and never throws.
*
* @param array<string,mixed> $credential The Credential data array.
*
* @return array<string,mixed> The Credential with the propagation outcome applied.
*
* @spec openspec/specs/certification/spec.md#requirement-revoking-a-credential-propagates-to-any-outstanding-wallet-offer-fail-soft
* @spec openspec/changes/credential-wallet-offer-for-the-learner/specs/certification/spec.md#requirement-the-credential-keeps-the-wallet-offer-link-while-the-offer-is-open
*/
public function propagate(array $credential): array {
if (isset($credential['walletOfferUri']) === true) {
$credential['walletOfferUri'] = null;
}

$walletOfferStatus = ($credential['walletOfferStatus'] ?? null);
if (in_array($walletOfferStatus, self::OUTSTANDING_STATUSES, true) === false) {
// Nothing outstanding to revoke — no-op.
Expand Down
13 changes: 10 additions & 3 deletions lib/Settings/learniq_register.json

Large diffs are not rendered by default.

115 changes: 115 additions & 0 deletions lib/SetupCheck/WalletOfferTokenCheck.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
<?php

/**
* Learniq Wallet Offer Token Check
*
* The admin overview names the missing integriq token that makes every
* wallet offer fail.
*
* @category SetupCheck
* @package OCA\Learniq\SetupCheck
*
* @author Conduction Development Team <dev@conductio.nl>
* @copyright 2026 Conduction B.V.
* @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
*
* SPDX-License-Identifier: EUPL-1.2
*
* @version GIT: <git-id>
*
* @link https://conduction.nl
*
* @spec openspec/changes/credential-wallet-offer-for-the-learner/specs/certification/spec.md#requirement-the-setup-check-names-the-missing-wallet-token
*/

declare(strict_types=1);

namespace OCA\Learniq\SetupCheck;

use OCA\Learniq\AppInfo\Application;
use OCA\Learniq\Service\ConnectionReportService;
use OCP\IAppConfig;
use OCP\IL10N;
use OCP\SetupCheck\ISetupCheck;
use OCP\SetupCheck\SetupResult;

/**
* Names `learniq.openconnector_api_token` when it is empty: a warning once a
* wallet offer has failed on it, a note while no offer was tried.
*
* @spec openspec/changes/credential-wallet-offer-for-the-learner/specs/certification/spec.md#requirement-the-setup-check-names-the-missing-wallet-token
*/
class WalletOfferTokenCheck implements ISetupCheck {

/**
* The app config key of the integriq bearer token.
*
* @var string
*/
public const TOKEN_KEY = 'openconnector_api_token';

/**
* Constructor.
*
* @param IAppConfig $appConfig App config, for the token.
* @param ConnectionReportService $connectionReports What the last wallet offer met.
* @param IL10N $l10n Translations.
*
* @return void
*/
public function __construct(
private readonly IAppConfig $appConfig,
private readonly ConnectionReportService $connectionReports,
private readonly IL10N $l10n,
) {
}//end __construct()

/**
* The admin overview section this check is listed under.
*
* @return string
*
* @spec openspec/changes/credential-wallet-offer-for-the-learner/specs/certification/spec.md#requirement-the-setup-check-names-the-missing-wallet-token
*/
public function getCategory(): string {
return 'system';
}//end getCategory()

/**
* The check's name in the admin overview.
*
* @return string
*
* @spec openspec/changes/credential-wallet-offer-for-the-learner/specs/certification/spec.md#requirement-the-setup-check-names-the-missing-wallet-token
*/
public function getName(): string {
return $this->l10n->t('Learniq wallet offers');
}//end getName()

/**
* Success with the token set; without it, a warning once an offer failed
* for it, else a note naming the key.
*
* @return SetupResult
*
* @SuppressWarnings(PHPMD.StaticAccess) SetupResult is built only through its static constructors.
*
* @spec openspec/changes/credential-wallet-offer-for-the-learner/specs/certification/spec.md#requirement-the-setup-check-names-the-missing-wallet-token
*/
public function run(): SetupResult {
$token = $this->appConfig->getValueString(app: Application::APP_ID, key: self::TOKEN_KEY, default: '');
if ($token !== '') {
return SetupResult::success($this->l10n->t('Certificates can be offered to a wallet through integriq.'));
}

$message = $this->l10n->t(
'No integriq API token is set (%s), so a certificate cannot be offered to a wallet.',
[Application::APP_ID . '.' . self::TOKEN_KEY]
);
if ($this->connectionReports->lastStatus(key: ConnectionReportService::WALLET_KEY) === 'unconfigured') {
return SetupResult::warning($message);
}

return SetupResult::info($message);
}//end run()
}//end class
Loading
Loading