diff --git a/appinfo/info.xml b/appinfo/info.xml index 4f7f9c526..c536ff906 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -36,6 +36,10 @@ Those groups of people can then be used by any other app for sharing purpose. OCA\Circles\Cron\Maintenance + OCA\Circles\Cron\ScimSyncCircles + OCA\Circles\Cron\ScimSyncFederatedModerators + OCA\Circles\Cron\OidcSyncMemberships + OCA\Circles\BackgroundJob\OidcSyncMembershipsUser @@ -76,6 +80,10 @@ Those groups of people can then be used by any other app for sharing purpose. OCA\Circles\Command\MembersRemove OCA\Circles\Command\MigrateCustomGroups + + OCA\Circles\Command\CirclesScimSyncCircles + OCA\Circles\Command\CirclesScimSyncFederatedModerators + OCA\Circles\Command\CirclesOidcSyncMemberships @@ -102,5 +110,7 @@ Those groups of people can then be used by any other app for sharing purpose. OCA\Circles\Settings\Admin OCA\Circles\Settings\AdminTeamFolders OCA\Circles\Settings\AdminSection + OCA\Circles\Settings\Personal + OCA\Circles\Settings\PersonalSection diff --git a/appinfo/routes.php b/appinfo/routes.php index 2d4b607d6..12d2ea058 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -108,6 +108,9 @@ ['name' => 'Remote#inherited', 'url' => '/inherited/{circleId}/', 'verb' => 'GET'], ['name' => 'Remote#memberships', 'url' => '/memberships/{circleId}/', 'verb' => 'GET'], + ['name' => 'Oidc#connect', 'url' => '/oidc/connect', 'verb' => 'GET'], + ['name' => 'Oidc#callback', 'url' => '/oidc/callback', 'verb' => 'GET'], + ['name' => 'Deprecated#listing', 'url' => '/listing', 'verb' => 'GET'], ] ]; diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 26ecc546e..5d7cc24c6 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -44,6 +44,7 @@ use OCA\Circles\Listeners\TeamFolderLifecycleListener; use OCA\Circles\Listeners\UserCreated; use OCA\Circles\Listeners\UserDeleted; +use OCA\Circles\Listeners\UserLoggedIn; use OCA\Circles\MountManager\CircleMountProvider; use OCA\Circles\Notification\Notifier; use OCA\Circles\Search\UnifiedSearchProvider; @@ -71,6 +72,7 @@ use OCP\User\Events\UserChangedEvent; use OCP\User\Events\UserCreatedEvent; use OCP\User\Events\UserDeletedEvent; +use OCP\User\Events\UserLoggedInEvent; use Psr\Container\ContainerInterface; use Throwable; @@ -105,6 +107,7 @@ public function register(IRegistrationContext $context): void { $context->registerEventListener(UserUpdatedEvent::class, AccountUpdated::class); $context->registerEventListener(UserChangedEvent::class, AccountUpdated::class); $context->registerEventListener(UserDeletedEvent::class, UserDeleted::class); + $context->registerEventListener(UserLoggedInEvent::class, UserLoggedIn::class); // Circle Events $context->registerEventListener(CircleMemberRemovedEvent::class, CircleMemberRemoved::class); diff --git a/lib/AppInfo/Capabilities.php b/lib/AppInfo/Capabilities.php index 48e151f78..0190d2b53 100644 --- a/lib/AppInfo/Capabilities.php +++ b/lib/AppInfo/Capabilities.php @@ -108,7 +108,8 @@ private function getCapabilitiesCircleConstants(): array { Circle::CFG_CIRCLE_INVITE => $this->l10n->t('Team invite'), Circle::CFG_FEDERATED => $this->l10n->t('Federated'), Circle::CFG_MOUNTPOINT => $this->l10n->t('Mount point'), - Circle::CFG_APP => $this->l10n->t('App') + Circle::CFG_APP => $this->l10n->t('App'), + Circle::CFG_SCIM => $this->l10n->t('SCIM'), ], 'source' => [ diff --git a/lib/BackgroundJob/OidcSyncMembershipsUser.php b/lib/BackgroundJob/OidcSyncMembershipsUser.php new file mode 100644 index 000000000..7982e8cb2 --- /dev/null +++ b/lib/BackgroundJob/OidcSyncMembershipsUser.php @@ -0,0 +1,39 @@ +appConfig->getAppValueBool(ConfigLexicon::OIDC_ENABLED)) { + return; + } + + $userId = $argument['userId'] ?? null; + if ($userId === null) { + return; + } + + $this->oidcService->syncMembershipsForUser($userId); + } +} diff --git a/lib/Command/CirclesOidcSyncMemberships.php b/lib/Command/CirclesOidcSyncMemberships.php new file mode 100644 index 000000000..f7a335d12 --- /dev/null +++ b/lib/Command/CirclesOidcSyncMemberships.php @@ -0,0 +1,37 @@ +setName('circles:oidc:sync-memberships') + ->setDescription('sync circle memberships from OIDC server'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $this->oidcService->syncMemberships(); + + $output->writeln('done'); + + return 0; + } +} diff --git a/lib/Command/CirclesScimSyncCircles.php b/lib/Command/CirclesScimSyncCircles.php new file mode 100644 index 000000000..379dd5918 --- /dev/null +++ b/lib/Command/CirclesScimSyncCircles.php @@ -0,0 +1,37 @@ +setName('circles:scim:sync-circles') + ->setDescription('sync circles from SCIM server'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $this->scimService->syncCircles(); + + $output->writeln('done'); + + return 0; + } +} diff --git a/lib/Command/CirclesScimSyncFederatedModerators.php b/lib/Command/CirclesScimSyncFederatedModerators.php new file mode 100644 index 000000000..3c271855e --- /dev/null +++ b/lib/Command/CirclesScimSyncFederatedModerators.php @@ -0,0 +1,37 @@ +setName('circles:scim:sync-federated-moderators') + ->setDescription('sync federated moderators for SCIM circles'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $this->scimService->syncFederatedModerators(); + + $output->writeln('done'); + + return 0; + } +} diff --git a/lib/ConfigLexicon.php b/lib/ConfigLexicon.php index 4337fd4d1..472557cd4 100644 --- a/lib/ConfigLexicon.php +++ b/lib/ConfigLexicon.php @@ -27,6 +27,27 @@ class ConfigLexicon implements ILexicon { public const TEAM_FOLDER_AUTO_CREATE = 'team_folder_auto_create'; public const TEAM_FOLDER_DEFAULT_QUOTA = 'team_folder_default_quota'; + // OIDC + public const OIDC_ENABLED = 'oidc_enabled'; + public const OIDC_ISSUER = 'oidc_issuer'; + public const OIDC_CLIENT_ID = 'oidc_client_id'; + public const OIDC_CLIENT_SECRET = 'oidc_client_secret'; + public const OIDC_AUTHORIZATION_ENDPOINT = 'oidc_authorization_endpoint'; + public const OIDC_TOKEN_ENDPOINT = 'oidc_token_endpoint'; + public const OIDC_USERINFO_ENDPOINT = 'oidc_userinfo_endpoint'; + public const OIDC_SCOPE = 'oidc_scope'; + public const OIDC_MEMBERSHIP_CLAIM = 'oidc_membership_claim'; + + // SCIM + public const SCIM_ENABLED = 'scim_enabled'; + public const SCIM_ENDPOINT = 'scim_endpoint'; + public const SCIM_TOKEN = 'scim_token'; + public const SCIM_FEDERATED_MODERATOR_INSTANCES = 'scim_federated_moderator_instances'; + + // Federation agent + public const FEDERATION_AGENT_ENABLED = 'federation_agent_enabled'; + public const FEDERATION_AGENT_LOCAL_ID = 'federation_agent_local_id'; + public function getStrictness(): Strictness { return Strictness::IGNORE; } @@ -38,6 +59,24 @@ public function getAppConfigs(): array { new Entry(key: self::REMOVE_SHARE_TOKENS_DONE, type: ValueType::BOOL, defaultRaw: false, definition: 'whether the remove share tokens repair step has already been executed', lazy: true), new Entry(key: self::TEAM_FOLDER_AUTO_CREATE, type: ValueType::BOOL, defaultRaw: true, definition: 'automatically create a team folder when a new team is created', lazy: true), new Entry(key: self::TEAM_FOLDER_DEFAULT_QUOTA, type: ValueType::INT, defaultRaw: 0, definition: 'default quota in bytes for auto-created team folders (0 means unlimited)', lazy: true), + // OIDC + new Entry(key: self::OIDC_ENABLED, type: ValueType::BOOL, defaultRaw: false, definition: 'disable/enable OIDC integration', lazy: true), + new Entry(key: self::OIDC_ISSUER, type: ValueType::STRING, defaultRaw: '', definition: 'provider issuer URL', lazy: true), + new Entry(key: self::OIDC_CLIENT_ID, type: ValueType::STRING, defaultRaw: '', definition: 'client id', lazy: true), + new Entry(key: self::OIDC_CLIENT_SECRET, type: ValueType::STRING, defaultRaw: '', definition: 'client secret', lazy: true), + new Entry(key: self::OIDC_AUTHORIZATION_ENDPOINT, type: ValueType::STRING, defaultRaw: '', definition: 'authorization endpoint', lazy: true), + new Entry(key: self::OIDC_TOKEN_ENDPOINT, type: ValueType::STRING, defaultRaw: '', definition: 'token endpoint', lazy: true), + new Entry(key: self::OIDC_USERINFO_ENDPOINT, type: ValueType::STRING, defaultRaw: '', definition: 'userinfo endpoint', lazy: true), + new Entry(key: self::OIDC_SCOPE, type: ValueType::STRING, defaultRaw: 'openid', definition: 'scope(s) requested during authorization', lazy: true), + new Entry(key: self::OIDC_MEMBERSHIP_CLAIM, type: ValueType::STRING, defaultRaw: '', definition: 'claim name containing group membership information', lazy: true), + // SCIM + new Entry(key: self::SCIM_ENABLED, type: ValueType::BOOL, defaultRaw: false, definition: 'disable/enable SCIM integration', lazy: true), + new Entry(key: self::SCIM_ENDPOINT, type: ValueType::STRING, defaultRaw: '', definition: 'server endpoint for group discovery', lazy: true), + new Entry(key: self::SCIM_TOKEN, type: ValueType::STRING, defaultRaw: '', definition: 'bearer token used to authenticate against the server', lazy: true), + new Entry(key: self::SCIM_FEDERATED_MODERATOR_INSTANCES, type: ValueType::ARRAY, defaultRaw: [], definition: "list of remote instances whose federation agent is trusted as a moderator on this instance's SCIM circles", lazy: true), + // Federation agent + new Entry(key: self::FEDERATION_AGENT_ENABLED, type: ValueType::BOOL, defaultRaw: false, definition: "disable/enable this instance's federation agent, used to act on behalf of this instance on remote circles", lazy: true), + new Entry(key: self::FEDERATION_AGENT_LOCAL_ID, type: ValueType::STRING, defaultRaw: '', definition: "single ID of this instance's own federation agent circle", lazy: true), ]; } diff --git a/lib/Controller/OidcController.php b/lib/Controller/OidcController.php new file mode 100644 index 000000000..6aa227252 --- /dev/null +++ b/lib/Controller/OidcController.php @@ -0,0 +1,163 @@ +appConfig->getAppValueBool(ConfigLexicon::OIDC_ENABLED)) { + return $this->redirectToPersonalSettings('disabled'); + } + + $state = $this->random->generate(32, ISecureRandom::CHAR_ALPHANUMERIC); + $userId = $this->userSession->getUser()?->getUID(); + if ($userId === null) { + return $this->redirectToPersonalSettings('error'); + } + $this->session->set(self::SESSION_STATE, $state); + $this->session->set(self::SESSION_USER_ID, $userId); + $this->session->close(); + + $authorizationEndpoint = $this->appConfig->getAppValueString(ConfigLexicon::OIDC_AUTHORIZATION_ENDPOINT); + $clientId = $this->appConfig->getAppValueString(ConfigLexicon::OIDC_CLIENT_ID); + $scope = $this->appConfig->getAppValueString(ConfigLexicon::OIDC_SCOPE); + $redirectUri = $this->urlGenerator->linkToRouteAbsolute(Application::APP_ID . '.Oidc.callback'); + + $authorizationUrl = $this->buildAuthorizationUrl($authorizationEndpoint, [ + 'response_type' => 'code', + 'client_id' => $clientId, + 'redirect_uri' => $redirectUri, + 'scope' => $scope, + 'state' => $state, + 'prompt' => 'consent', + ]); + + $this->logger->debug('Redirecting user to OIDC provider: ' . $authorizationUrl); + + return new RedirectResponse($authorizationUrl); + } + + #[NoAdminRequired] + #[NoCSRFRequired] + public function callback(string $state = '', string $code = '', string $error = '', string $error_description = ''): RedirectResponse { + if ($error !== '') { + $this->logger->error('OIDC provider returned an error: ' . $error . ' - ' . $error_description); + return $this->redirectToPersonalSettings('error'); + } + + if ($state === '' || $state !== $this->session->get(self::SESSION_STATE)) { + $this->logger->error('OIDC callback state mismatch'); + return $this->redirectToPersonalSettings('error'); + } + $userId = $this->userSession->getUser()?->getUID(); + if ($userId === null || $userId !== $this->session->get(self::SESSION_USER_ID)) { + $this->logger->error('OIDC callback user mismatch: started as ' . $this->session->get(self::SESSION_USER_ID) . ', completed as ' . $userId); + return $this->redirectToPersonalSettings('error'); + } + $this->session->remove(self::SESSION_STATE); + $this->session->remove(self::SESSION_USER_ID); + + $tokenEndpoint = $this->appConfig->getAppValueString(ConfigLexicon::OIDC_TOKEN_ENDPOINT); + $clientId = $this->appConfig->getAppValueString(ConfigLexicon::OIDC_CLIENT_ID); + $clientSecret = $this->appConfig->getAppValueString(ConfigLexicon::OIDC_CLIENT_SECRET); + $redirectUri = $this->urlGenerator->linkToRouteAbsolute(Application::APP_ID . '.Oidc.callback'); + + $client = $this->clientService->newClient(); + try { + $response = $client->post($tokenEndpoint, [ + 'auth' => [$clientId, $clientSecret], + 'body' => [ + 'grant_type' => 'authorization_code', + 'code' => $code, + 'redirect_uri' => $redirectUri, + ], + ]); + } catch (\Exception $e) { + $this->logger->error('OIDC token exchange failed', ['exception' => $e]); + return $this->redirectToPersonalSettings('error'); + } + + $data = json_decode($response->getBody(), true); + + if (empty($data['refresh_token'])) { + $this->logger->error('OIDC provider did not return a refresh_token for user ' . $userId); + return $this->redirectToPersonalSettings('error'); + } + + $this->credentialsManager->store($userId, OidcService::CREDENTIAL_REFRESH_TOKEN, $data['refresh_token']); + + // initial sync + if (!empty($data['access_token'])) { + $this->oidcService->syncMembershipsForUser($userId, $data['access_token']); + } + + return $this->redirectToPersonalSettings('success'); + } + + private function redirectToPersonalSettings(string $result): RedirectResponse { + $url = $this->urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'circles']); + + return new RedirectResponse($url . '?oidcResult=' . $result); + } + + private function buildAuthorizationUrl(string $authorizationEndpoint, array $params): string { + $parsedUrl = parse_url($authorizationEndpoint); + + $urlWithoutParams + = ($parsedUrl['scheme'] ?? '') . '://' + . ($parsedUrl['host'] ?? '') + . (isset($parsedUrl['port']) ? ':' . $parsedUrl['port'] : '') + . ($parsedUrl['path'] ?? ''); + + $queryParams = $params; + if (isset($parsedUrl['query'])) { + parse_str($parsedUrl['query'], $existingParams); + $queryParams = array_merge($queryParams, $existingParams); + } + + return $urlWithoutParams . '?' . http_build_query($queryParams); + } +} diff --git a/lib/Cron/OidcSyncMemberships.php b/lib/Cron/OidcSyncMemberships.php new file mode 100644 index 000000000..ebfbc9ba4 --- /dev/null +++ b/lib/Cron/OidcSyncMemberships.php @@ -0,0 +1,42 @@ +setInterval(24 * 3600); + // delay until low-load time + $this->setTimeSensitivity(IJob::TIME_INSENSITIVE); + // only run one instance of this job at a time + $this->setAllowParallelRuns(false); + } + + protected function run($argument) { + if (!$this->appConfig->getAppValueBool(ConfigLexicon::OIDC_ENABLED)) { + return; + } + + $this->oidcService->syncMemberships(); + } +} diff --git a/lib/Cron/ScimSyncCircles.php b/lib/Cron/ScimSyncCircles.php new file mode 100644 index 000000000..5558b0412 --- /dev/null +++ b/lib/Cron/ScimSyncCircles.php @@ -0,0 +1,37 @@ +setInterval(12 * 3600); + } + + protected function run($argument) { + if (!$this->appConfig->getAppValueBool(ConfigLexicon::SCIM_ENABLED)) { + return; + } + + $this->scimService->syncCircles(); + } +} diff --git a/lib/Cron/ScimSyncFederatedModerators.php b/lib/Cron/ScimSyncFederatedModerators.php new file mode 100644 index 000000000..032bc37f6 --- /dev/null +++ b/lib/Cron/ScimSyncFederatedModerators.php @@ -0,0 +1,42 @@ +setInterval(12 * 3600); + // delay until low-load time + $this->setTimeSensitivity(IJob::TIME_INSENSITIVE); + // only run one instance of this job at a time + $this->setAllowParallelRuns(false); + } + + protected function run($argument) { + if (!$this->appConfig->getAppValueBool(ConfigLexicon::SCIM_ENABLED)) { + return; + } + + $this->scimService->syncFederatedModerators(); + } +} diff --git a/lib/Db/CircleRequest.php b/lib/Db/CircleRequest.php index b512e07be..99436aba0 100644 --- a/lib/Db/CircleRequest.php +++ b/lib/Db/CircleRequest.php @@ -502,6 +502,19 @@ public function getFederated(): array { return $this->getItemsFromRequest($qb); } + /** + * @return Circle[] + * @throws RequestBuilderException + */ + public function getScim(): array { + $qb = $this->getCircleSelectSql(); + $qb->limitToConfigFlag(Circle::CFG_SCIM, CoreQueryBuilder::CIRCLE); + + $qb->leftJoinOwner(CoreQueryBuilder::CIRCLE); + + return $this->getItemsFromRequest($qb); + } + /** * @param Circle $circle */ diff --git a/lib/Db/MemberRequest.php b/lib/Db/MemberRequest.php index 9bd2e66b6..dd94c7f21 100644 --- a/lib/Db/MemberRequest.php +++ b/lib/Db/MemberRequest.php @@ -424,6 +424,17 @@ public function getMembersBySingleId(string $singleId): array { return $this->getItemsFromRequest($qb); } + /** + * @return Member[] + * @throws RequestBuilderException + */ + public function getMembersByUserId(string $userId): array { + $qb = $this->getMemberSelectSql(); + $qb->limitToUserId($userId); + + return $this->getItemsFromRequest($qb); + } + /** * @param Member $member * @param FederatedUser|null $initiator diff --git a/lib/Listeners/UserLoggedIn.php b/lib/Listeners/UserLoggedIn.php new file mode 100644 index 000000000..1c070e853 --- /dev/null +++ b/lib/Listeners/UserLoggedIn.php @@ -0,0 +1,40 @@ + */ +class UserLoggedIn implements IEventListener { + public function __construct( + private readonly IAppConfig $appConfig, + private readonly IJobList $jobList, + ) { + } + + #[\Override] + public function handle(Event $event): void { + if (!($event instanceof UserLoggedInEvent)) { + return; + } + + if (!$this->appConfig->getAppValueBool(ConfigLexicon::OIDC_ENABLED)) { + return; + } + + $this->jobList->add(OidcSyncMembershipsUser::class, ['userId' => $event->getUser()->getUID()]); + } +} diff --git a/lib/Model/Circle.php b/lib/Model/Circle.php index 047695db5..083ea8980 100644 --- a/lib/Model/Circle.php +++ b/lib/Model/Circle.php @@ -74,23 +74,24 @@ class Circle extends ManagedModel implements IEntity, IDeserializable, IQueryRow public const CFG_PERSONAL = 2; // Personal circle, only the owner can see it. // bitwise - public const CFG_SYSTEM = 4; // System Circle (not managed by the official front-end). Meaning some config are limited - public const CFG_VISIBLE = 8; // Visible to everyone, if not visible, people have to know its name to be able to find it - public const CFG_OPEN = 16; // Circle is open, people can join - public const CFG_INVITE = 32; // Adding a member generate an invitation that needs to be accepted - public const CFG_REQUEST = 64; // Request to join Circles needs to be confirmed by a moderator - public const CFG_FRIEND = 128; // Members of the circle can invite their friends - public const CFG_PROTECTED = 256; // Password protected to join/request - public const CFG_NO_OWNER = 512; // no owner, only members - public const CFG_HIDDEN = 1024; // hidden from listing, but available as a share entity - public const CFG_BACKEND = 2048; // Fully hidden, only backend Circles - public const CFG_LOCAL = 4096; // Local even on GlobalScale - public const CFG_ROOT = 8192; // Circle cannot be inside another Circle - public const CFG_CIRCLE_INVITE = 16384; // Circle must confirm when invited in another circle - public const CFG_FEDERATED = 32768; // Federated - public const CFG_MOUNTPOINT = 65536; // Generate a Files folder for this Circle - public const CFG_APP = 131072; // Some features are not available to the OCS API (ie. destroying Circle) - public static $DEF_CFG_MAX = 262143; + public const CFG_SYSTEM = 4; // System Circle (not managed by the official front-end). Meaning some config are limited + public const CFG_VISIBLE = 8; // Visible to everyone, if not visible, people have to know its name to be able to find it + public const CFG_OPEN = 16; // Circle is open, people can join + public const CFG_INVITE = 32; // Adding a member generate an invitation that needs to be accepted + public const CFG_REQUEST = 64; // Request to join Circles needs to be confirmed by a moderator + public const CFG_FRIEND = 128; // Members of the circle can invite their friends + public const CFG_PROTECTED = 256; // Password protected to join/request + public const CFG_NO_OWNER = 512; // no owner, only members + public const CFG_HIDDEN = 1024; // hidden from listing, but available as a share entity + public const CFG_BACKEND = 2048; // Fully hidden, only backend Circles + public const CFG_LOCAL = 4096; // Local even on GlobalScale + public const CFG_ROOT = 8192; // Circle cannot be inside another Circle + public const CFG_CIRCLE_INVITE = 16384; // Circle must confirm when invited in another circle + public const CFG_FEDERATED = 32768; // Federated + public const CFG_MOUNTPOINT = 65536; // Generate a Files folder for this Circle + public const CFG_APP = 131072; // Some features are not available to the OCS API (ie. destroying Circle) + public const CFG_SCIM = 262144; // Circle is managed by a SCIM server, not manually + public static $DEF_CFG_MAX = 524287; /** * Note: When editing those values, update lib/Application/Capabilities.php @@ -115,8 +116,9 @@ class Circle extends ManagedModel implements IEntity, IDeserializable, IQueryRow 8192 => 'T|Root', 16384 => 'CI|Circle Invite', 32768 => 'F|Federated', - 65536 => 'M|Nountpoint', + 65536 => 'M|Mountpoint', 131072 => 'A|App', + 262144 => 'SC|SCIM', ]; /** diff --git a/lib/Model/Federated/RemoteInstance.php b/lib/Model/Federated/RemoteInstance.php index 3c9bd80af..896d827c4 100644 --- a/lib/Model/Federated/RemoteInstance.php +++ b/lib/Model/Federated/RemoteInstance.php @@ -51,6 +51,7 @@ class RemoteInstance extends NCSignatory implements IQueryRow, JsonSerializable public const INHERITED = 'inherited'; public const UID = 'uid'; public const AUTH_SIGNED = 'auth-signed'; + public const FEDERATION_AGENT_ID = 'federation-agent-id'; /** @var int */ private $dbId = 0; @@ -100,6 +101,9 @@ class RemoteInstance extends NCSignatory implements IQueryRow, JsonSerializable /** @var string */ private $authSigned = ''; + /** @var string */ + private $federationAgentId = ''; + /** @var bool */ private $identityAuthed = false; @@ -433,6 +437,16 @@ public function mustBeIdentityAuthed(): void { } } + public function getFederationAgentId(): string { + return $this->federationAgentId; + } + + public function setFederationAgentId(string $federationAgentId): self { + $this->federationAgentId = $federationAgentId; + + return $this; + } + /** * @param array $data * @@ -452,7 +466,8 @@ public function import(array $data): NCSignatory { ->setMember($this->get(self::MEMBER, $data)) ->setInherited($this->get(self::INHERITED, $data)) ->setMemberships($this->get(self::MEMBERSHIPS, $data)) - ->setUid($this->get(self::UID, $data)); + ->setUid($this->get(self::UID, $data)) + ->setFederationAgentId($this->get(self::FEDERATION_AGENT_ID, $data)); $algo = ''; $authSigned = trim($this->get(self::AUTH_SIGNED, $data), ':'); @@ -481,7 +496,8 @@ public function jsonSerialize(): array { self::MEMBERS => $this->getMembers(), self::MEMBER => $this->getMember(), self::INHERITED => $this->getInherited(), - self::MEMBERSHIPS => $this->getMemberships() + self::MEMBERSHIPS => $this->getMemberships(), + self::FEDERATION_AGENT_ID => $this->getFederationAgentId() ]; if ($this->getAuthSigned() !== '') { diff --git a/lib/Model/Member.php b/lib/Model/Member.php index ab5bce2d1..423fa0f4a 100644 --- a/lib/Model/Member.php +++ b/lib/Model/Member.php @@ -676,6 +676,15 @@ public function getMemberships(): array { return $this->memberships; } + public function setManagedBy(string $managedBy): self { + $this->setNote('managedBy', $managedBy); + return $this; + } + + public function getManagedBy(): string { + return $this->getNote('managedBy'); + } + /** * @param string $singleId * @param bool $detailed diff --git a/lib/Service/FederationAgentService.php b/lib/Service/FederationAgentService.php new file mode 100644 index 000000000..5f986ea70 --- /dev/null +++ b/lib/Service/FederationAgentService.php @@ -0,0 +1,216 @@ +appConfig->getAppValueBool(ConfigLexicon::FEDERATION_AGENT_ENABLED, false)) { + return ''; + } + + $singleId = $this->appConfig->getAppValueString(ConfigLexicon::FEDERATION_AGENT_LOCAL_ID, ''); + if ($singleId !== '') { + return $singleId; + } + + try { + $outcome = $this->createFederationAgent(); + $singleId = $outcome['id']; + $this->appConfig->setAppValueString(ConfigLexicon::FEDERATION_AGENT_LOCAL_ID, $singleId); + } catch (Exception $e) { + $this->logger->error("could not create 'app:federation_agent:{singleId}' circle", ['exception' => $e]); + return ''; + } + + return $singleId; + } + + /** + * sets this instance's instance's federation agent as the current user + * + * federation agent = 'app:federation_agent:{singleId}' circle + * + * @throws OwnerNotFoundException + * @throws RequestBuilderException + * @throws FederatedUserNotFoundException + * @throws FederatedUserException + */ + public function setFederationAgentAsCurrentUser(): void { + $singleId = $this->getOrCreateFederationAgentId(); + if ($singleId === '') { + throw new Exception('could not resolve local federation agent'); + } + + $federatedUser = $this->circleRequest->getFederatedUserBySingleId($singleId); + $this->federatedUserService->setCurrentUser($federatedUser); + } + + /** + * ensures the federation agent of every given remote instance is added + * as a moderator to every given circle + * + * federation agent = 'app:federation_agent:{singleId}' circle + * + * @param list $circleIds + * @param list $remoteInstances + */ + public function ensureFederationAgentsAsModerators(array $circleIds, array $remoteInstances): void { + if ($circleIds === []) { + return; + } + + if ($remoteInstances === []) { + return; + } + + $this->federatedUserService->setLocalCurrentApp(Application::APP_ID, Member::APP_CIRCLES); + $currentApp = $this->federatedUserService->getCurrentApp(); + $this->federatedUserService->setCurrentUser($currentApp); + + foreach ($remoteInstances as $remoteInstance) { + try { + // try the cached instance first, to avoid a network request + $remote = $this->remoteStreamService->getCachedRemoteInstance($remoteInstance); + $federationAgentId = $remote->getFederationAgentId(); + if ($federationAgentId === '') { + // not found in cache, fetch it fresh from the remote instance + $remote = $this->remoteStreamService->retrieveRemoteInstance($remoteInstance); + $federationAgentId = $remote->getFederationAgentId(); + if ($federationAgentId !== '') { + // found it, persist so we don't need to fetch it again next time + $this->remoteStreamService->update($remote, RemoteStreamService::UPDATE_ITEM); + } + } + } catch (Exception $e) { + $this->logger->error("could not resolve 'app:federation_agent:{singleId}' circle ID from remote instance", ['instance' => $remoteInstance, 'exception' => $e]); + continue; + } + + if ($federationAgentId === '') { + $this->logger->warning("could not find 'app:federation_agent:{singleId}' circle ID on remote instance", ['instance' => $remoteInstance]); + continue; + } + + foreach ($circleIds as $circleId) { + $this->ensureModerator($circleId, $remoteInstance, $federationAgentId); + } + } + } + + private function createFederationAgent(): array { + $this->federatedUserService->setLocalCurrentApp(Application::APP_ID, Member::APP_CIRCLES); + $owner = $this->federatedUserService->getCurrentApp(); + + $config = Circle::CFG_BACKEND; + $singleId = $this->token(ManagedModel::ID_LENGTH); + + $circle = new Circle(); + $circle->setName('app:federation_agent:' . $singleId) + ->setSingleId($singleId) + ->setSource(Member::APP_CIRCLES) + ->setConfig($config); + + $this->circleService->confirmName($circle); + $this->permissionService->confirmAllowedCircleTypes($circle); + + $member = new Member(); + $member->importFromIFederatedUser($owner); + $member->setId($this->token(ManagedModel::ID_LENGTH)) + ->setCircleId($circle->getSingleId()) + ->setLevel(Member::LEVEL_OWNER) + ->setStatus(Member::STATUS_MEMBER); + + $this->federatedUserService->setMemberPatron($member); + + $circle->setOwner($member) + ->setInitiator($member); + + $event = new FederatedEvent(CircleCreate::class); + $event->setCircle($circle); + $this->federatedEventService->newEvent($event); + + return $event->getOutcome(); + } + + private function ensureModerator(string $circleId, string $remoteInstance, string $federatedId): void { + try { + $this->memberRequest->getMember($circleId, $federatedId); + // already a member + return; + } catch (MemberNotFoundException) { + } + + try { + $federatedUser = $this->federatedUserService->getFederatedUser($federatedId . '@' . $remoteInstance, Member::TYPE_CIRCLE); + $circle = $this->circleRequest->getCircle($circleId, $this->federatedUserService->getCurrentUser()); + + $member = new Member(); + $member->importFromIFederatedUser($federatedUser); + + $this->federatedUserService->setMemberPatron($member); + + $event = new FederatedEvent(SingleMemberAdd::class); + $event->setCircle($circle); + $event->setMember($member); + $event->setAsync(false); + $this->federatedEventService->newEvent($event); + + $addedMember = $event->getMember(); + $this->memberService->memberLevel($addedMember->getId(), Member::LEVEL_MODERATOR); + + $this->logger->debug('moderator from remote instance added to circle', ['circleId' => $circleId, 'memberId' => $addedMember->getId(), 'remoteInstance' => $remoteInstance]); + } catch (Exception $e) { + $this->logger->error('could not add moderator from remote instance to circle', ['circleId' => $circleId, 'remoteInstance' => $remoteInstance, 'exception' => $e]); + } + } +} diff --git a/lib/Service/OidcService.php b/lib/Service/OidcService.php new file mode 100644 index 000000000..18c4fff8b --- /dev/null +++ b/lib/Service/OidcService.php @@ -0,0 +1,222 @@ +userManager->callForSeenUsers(function (IUser $user): void { + $this->syncMembershipsForUser($user->getUID()); + }); + } + + public function syncMembershipsForUser(string $userId, ?string $accessToken = null): void { + if ($accessToken === null) { + $refreshToken = $this->credentialsManager->retrieve($userId, self::CREDENTIAL_REFRESH_TOKEN); + if (empty($refreshToken)) { + return; + } + + $accessToken = $this->refreshAccessToken($userId, $refreshToken); + if ($accessToken === null) { + $this->logger->error('could not refresh OIDC access token', ['userId' => $userId]); + return; + } + } + + $rawMemberships = $this->fetchMemberships($accessToken); + if ($rawMemberships === null) { + // don't assume user has no memberships on failed request, to avoid removing existing memberships + $this->logger->debug('could not fetch OIDC memberships, skipping reconciliation', ['userId' => $userId]); + return; + } + + // ensure user is a member of circles matching OIDC memberships + $desiredCircleIds = []; + foreach ($rawMemberships as $rawMembership) { + $circleId = $this->generateCircleIdFromString($rawMembership); + $desiredCircleIds[] = $circleId; + $this->ensureMember($userId, $circleId, $rawMembership); + } + + // remove user from circles they were added to via OIDC but no longer belong to + foreach ($this->memberRequest->getMembersByUserId($userId) as $member) { + if ($member->getManagedBy() !== self::MANAGED_BY_OIDC) { + continue; + } + if (in_array($member->getCircleId(), $desiredCircleIds, true)) { + continue; + } + $this->removeMember($userId, $member->getCircleId()); + } + } + + private function ensureMember(string $userId, string $circleId, string $rawMembership): void { + try { + $circle = $this->circleRequest->getCircle($circleId); + } catch (CircleNotFoundException) { + $this->logger->debug('circle not found, skipping', ['circleId' => $circleId, 'rawMembership' => $rawMembership]); + return; + } + + try { + $this->memberRequest->getMemberByUserId($circleId, $userId); + // already a member + return; + } catch (MemberNotFoundException) { + } + + try { + $this->setInitiatorForCircle($circle); + + $federatedUser = $this->federatedUserService->getLocalFederatedUser($userId); + $this->memberService->addMember($circleId, $federatedUser); + + // mark this membership as managed by OIDC + $member = $this->memberRequest->getMemberByUserId($circleId, $userId); + $member->setManagedBy(self::MANAGED_BY_OIDC); + $this->memberRequest->update($member); + } catch (Exception $e) { + $this->logger->error('could not add user to circle', ['userId' => $userId, 'circleId' => $circleId, 'exception' => $e]); + } + } + + private function removeMember(string $userId, string $circleId): void { + try { + $circle = $this->circleRequest->getCircle($circleId); + } catch (CircleNotFoundException) { + $this->logger->debug('circle not found, skipping', ['circleId' => $circleId]); + return; + } + + try { + $member = $this->memberRequest->getMemberByUserId($circleId, $userId); + + $this->setInitiatorForCircle($circle); + + $this->memberService->removeMember($member->getId()); + } catch (Exception $e) { + $this->logger->error('could not remove user from circle', ['userId' => $userId, 'circleId' => $circleId, 'exception' => $e]); + } + } + + /** + * sets the correct initiator to act on the given circle + * - local circle is managed by 'app:circles:{singleId}' + * - remote circle is managed by 'app:federation_agent:{singleId}' + */ + private function setInitiatorForCircle(Circle $circle): void { + if ($this->configService->isLocalInstance($circle->getInstance())) { + $this->federatedUserService->setLocalCurrentApp(Application::APP_ID, Member::APP_CIRCLES); + $currentApp = $this->federatedUserService->getCurrentApp(); + $this->federatedUserService->setCurrentUser($currentApp); + } else { + $this->federationAgentService->setFederationAgentAsCurrentUser(); + } + } + + /** + * @return string|null fresh access token or null on failure + */ + private function refreshAccessToken(string $userId, string $refreshToken): ?string { + $tokenEndpoint = $this->appConfig->getAppValueString(ConfigLexicon::OIDC_TOKEN_ENDPOINT); + $clientId = $this->appConfig->getAppValueString(ConfigLexicon::OIDC_CLIENT_ID); + $clientSecret = $this->appConfig->getAppValueString(ConfigLexicon::OIDC_CLIENT_SECRET); + + $client = $this->clientService->newClient(); + try { + $response = $client->post($tokenEndpoint, [ + 'auth' => [$clientId, $clientSecret], + 'body' => [ + 'grant_type' => 'refresh_token', + 'refresh_token' => $refreshToken, + ], + ]); + } catch (Exception $e) { + $this->logger->error('OIDC token refresh failed', ['exception' => $e]); + return null; + } + + $data = json_decode($response->getBody(), true); + + if (!empty($data['refresh_token'])) { + $this->credentialsManager->store($userId, self::CREDENTIAL_REFRESH_TOKEN, $data['refresh_token']); + } + + return $data['access_token'] ?? null; + } + + /** + * @return list|null raw membership entries (e.g. "urn:geant:company.co:group:my_group#login.company.co") + * null if the request failed + */ + private function fetchMemberships(string $accessToken): ?array { + $userinfoEndpoint = $this->appConfig->getAppValueString(ConfigLexicon::OIDC_USERINFO_ENDPOINT); + $membershipClaim = $this->appConfig->getAppValueString(ConfigLexicon::OIDC_MEMBERSHIP_CLAIM); + + $client = $this->clientService->newClient(); + try { + $response = $client->get($userinfoEndpoint, [ + 'headers' => ['Authorization' => 'Bearer ' . $accessToken], + ]); + } catch (Exception $e) { + $this->logger->error('OIDC userinfo request failed', ['exception' => $e]); + return null; + } + + $response = json_decode($response->getBody(), true); + $this->logger->debug('OIDC userinfo response: ' . json_encode($response)); + + $rawMemberships = $response[$membershipClaim] ?? []; + if (!is_array($rawMemberships)) { + $rawMemberships = [$rawMemberships]; + } + + $this->logger->debug('OIDC raw memberships (' . $membershipClaim . '): ' . json_encode($rawMemberships)); + + return $rawMemberships; + } +} diff --git a/lib/Service/RemoteStreamService.php b/lib/Service/RemoteStreamService.php index 5b6a63998..20b0c560d 100644 --- a/lib/Service/RemoteStreamService.php +++ b/lib/Service/RemoteStreamService.php @@ -73,6 +73,7 @@ public function __construct( private RemoteRequest $remoteRequest, private InterfaceService $interfaceService, private ConfigService $configService, + private readonly FederationAgentService $federationAgentService, ) { $this->setup('app', 'circles'); } @@ -136,6 +137,7 @@ public function getAppSignatory(bool $generate = true, string $confirmKey = ''): ) ) ); + $app->setFederationAgentId($this->federationAgentService->getOrCreateFederationAgentId()); if ($this->interfaceService->isCurrentInterfaceInternal()) { $app->setAliases(array_values(array_filter($this->interfaceService->getInterfaces(false)))); diff --git a/lib/Service/ScimService.php b/lib/Service/ScimService.php new file mode 100644 index 000000000..394f9beab --- /dev/null +++ b/lib/Service/ScimService.php @@ -0,0 +1,185 @@ +federatedUserService->setLocalCurrentApp(Application::APP_ID, Member::APP_CIRCLES); + + $circles = $this->fetchCircles(); + if ($circles === null) { + // don't assume no groups exist on a failed request, to avoid destroying existing circles + $this->logger->debug('could not fetch SCIM groups, skipping reconciliation'); + return; + } + + $desiredCircleIds = []; + foreach ($circles as $circle) { + $circleId = $this->generateCircleIdFromString($circle['id']); + $desiredCircleIds[] = $circleId; + try { + $this->circleRequest->getCircle($circleId); + // circle already exists + continue; + } catch (CircleNotFoundException) { + } + try { + $this->createCircle($circleId, $circle['displayName']); + $this->logger->debug('circle created from SCIM group', ['scimGroupId' => $circle['id'], 'circleId' => $circleId, 'displayName' => $circle['displayName']]); + } catch (Exception $e) { + $this->logger->error('could not create circle from SCIM group', ['scimGroupId' => $circle['id'], 'exception' => $e]); + } + } + + // destroy SCIM circles no longer present in SCIM server + foreach ($this->circleRequest->getScim() as $circle) { + if (in_array($circle->getSingleId(), $desiredCircleIds, true)) { + continue; + } + try { + $this->circleService->destroy($circle->getSingleId()); + $this->logger->debug('circle destroyed, no longer present in SCIM', ['circleId' => $circle->getSingleId()]); + } catch (Exception $e) { + $this->logger->error('could not destroy circle no longer present in SCIM', ['circleId' => $circle->getSingleId(), 'exception' => $e]); + } + } + } + + public function syncFederatedModerators(): void { + $remoteInstances = $this->appConfig->getAppValueArray(ConfigLexicon::SCIM_FEDERATED_MODERATOR_INSTANCES); + if ($remoteInstances === []) { + $this->logger->debug('no remote instance configured for SCIM federated moderators, skipping sync'); + return; + } + + $circleIds = array_map( + fn ($circle) => $circle->getSingleId(), + $this->circleRequest->getScim() + ); + + if ($circleIds === []) { + $this->logger->debug('no SCIM circle known, skipping federated moderators sync'); + return; + } + + $this->federationAgentService->ensureFederationAgentsAsModerators($circleIds, $remoteInstances); + } + + /** + * TODO: this method needs more work before it's usable. It hasn't been + * tested against a real/test SCIM server yet. For this first development + * iteration, it was assumed the response contains certain keys. This + * needs to be validated (and adjusted if needed) once access to a SCIM + * server is available. + */ + private function fetchCircles(): ?array { + $endpoint = $this->appConfig->getAppValueString(ConfigLexicon::SCIM_ENDPOINT); + $token = $this->appConfig->getAppValueString(ConfigLexicon::SCIM_TOKEN); + + $client = $this->clientService->newClient(); + try { + $response = $client->get(rtrim($endpoint, '/') . '/Groups', [ + 'headers' => ['Authorization' => 'Bearer ' . $token], + ]); + } catch (Exception $e) { + $this->logger->error('SCIM groups request failed', ['exception' => $e]); + return null; + } + + $response = json_decode($response->getBody(), true); + $this->logger->debug('SCIM groups response: ' . json_encode($response)); + + $resources = $response['Resources'] ?? []; + + return array_map( + static fn (array $resource): array => [ + 'id' => (string)($resource['id'] ?? ''), + 'displayName' => (string)($resource['displayName'] ?? ''), + ], + $resources + ); + } + + /** + * @throws Exception + */ + public function createCircle(string $singleId, string $name): void { + $owner = $this->federatedUserService->getCurrentApp(); + + $config = Circle::CFG_ROOT + Circle::CFG_FEDERATED + Circle::CFG_SCIM; + + $circle = new Circle(); + $circle->setName($this->circleService->cleanCircleName($name)) + ->setSingleId($singleId) + ->setSource(Member::APP_CIRCLES) + ->setConfig($config); + + $this->circleService->confirmName($circle); + $this->permissionService->confirmAllowedCircleTypes($circle); + + $member = new Member(); + $member->importFromIFederatedUser($owner); + $member->setId($this->token(ManagedModel::ID_LENGTH)) + ->setCircleId($circle->getSingleId()) + ->setLevel(Member::LEVEL_OWNER) + ->setStatus(Member::STATUS_MEMBER); + + $this->federatedUserService->setMemberPatron($member); + + $circle->setOwner($member) + ->setInitiator($member); + + $event = new FederatedEvent(CircleCreate::class); + $event->setCircle($circle); + $this->federatedEventService->newEvent($event); + } + + /** + * TODO: remove this method once fetchCircles() has been validated against a SCIM server + */ + private function mockGroups(): array { + return [ + ['id' => 'urn:geant:company.co:group:dev_vo1#login.company.co', 'displayName' => 'dev_vo1'], + ['id' => 'urn:geant:company.co:group:dev_vo2#login.company.co', 'displayName' => 'dev_vo2'], + ['id' => 'urn:geant:company.co:group:dev_vo3#login.company.co', 'displayName' => 'dev_vo3'], + ['id' => 'urn:geant:company.co:group:dev_vo4#login.company.co', 'displayName' => 'dev_vo4'], + ]; + } +} diff --git a/lib/Settings/Personal.php b/lib/Settings/Personal.php new file mode 100644 index 000000000..99502a42e --- /dev/null +++ b/lib/Settings/Personal.php @@ -0,0 +1,65 @@ +userSession->getUser()?->getUID(); + $oidcEnabled = $this->appConfig->getValueBool(Application::APP_ID, ConfigLexicon::OIDC_ENABLED, false); + + $oidcConnected = $userId !== null && $this->credentialsManager->retrieve($userId, OidcService::CREDENTIAL_REFRESH_TOKEN) !== null; + + $this->initialState->provideInitialState('oidc_enabled', $oidcEnabled); + $this->initialState->provideInitialState('oidc_connected', $oidcConnected); + + Util::addScript(Application::APP_ID, 'teams-settings-personal'); + Util::addStyle(Application::APP_ID, 'teams-settings-personal'); + + return new TemplateResponse(Application::APP_ID, 'settings-personal', renderAs: ''); + } + + #[\Override] + public function getSection(): ?string { + if (!$this->shouldDisplaySection()) { + return null; + } + return Application::APP_ID; + } + + #[\Override] + public function getPriority(): int { + return 80; + } + + private function shouldDisplaySection(): bool { + return $this->appConfig->getValueBool(Application::APP_ID, ConfigLexicon::OIDC_ENABLED, false); + } +} diff --git a/lib/Settings/PersonalSection.php b/lib/Settings/PersonalSection.php new file mode 100644 index 000000000..5cf8922de --- /dev/null +++ b/lib/Settings/PersonalSection.php @@ -0,0 +1,42 @@ +l->t('Teams'); + } + + #[\Override] + public function getPriority(): int { + return 80; + } + + #[\Override] + public function getIcon(): string { + return $this->url->imagePath('core', 'apps/circles.svg'); + } +} diff --git a/lib/Tools/Traits/TStringTools.php b/lib/Tools/Traits/TStringTools.php index 300a33050..5ac6fe38d 100644 --- a/lib/Tools/Traits/TStringTools.php +++ b/lib/Tools/Traits/TStringTools.php @@ -35,6 +35,31 @@ protected function token(int $length = 15): string { return $str; } + /** + * the same given source string always returns the same generated value + * useful for generating a circle ID from an external identifier + * (e.g. "urn:geant:company.co:group:my_group#login.company.co") + * + * @param string $source identifier to generate a circle ID from + */ + protected function generateCircleIdFromString(string $source): string { + $chars = 'qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM1234567890'; + $max = strlen($chars); + $length = \OCA\Circles\Model\ManagedModel::ID_LENGTH; + + $bytes = ''; + for ($i = 0; strlen($bytes) < $length; $i++) { + $bytes .= hash('sha256', $source . '|' . $i, true); + } + + $str = ''; + for ($i = 0; $i < $length; $i++) { + $str .= $chars[ord($bytes[$i]) % $max]; + } + + return $str; + } + /** * Generate uuid: 2b5a7a87-8db1-445f-a17b-405790f91c80 * diff --git a/src/components/PersonalSettings.vue b/src/components/PersonalSettings.vue new file mode 100644 index 000000000..46c515f81 --- /dev/null +++ b/src/components/PersonalSettings.vue @@ -0,0 +1,43 @@ + + + + + + + + + {{ t('circles', 'Your account is connected.') }} + + + {{ oidcConnected ? t('circles', 'Reconnect') : t('circles', 'Connect') }} + + + + + + diff --git a/src/settings-personal.ts b/src/settings-personal.ts new file mode 100644 index 000000000..526f734bc --- /dev/null +++ b/src/settings-personal.ts @@ -0,0 +1,10 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { createApp } from 'vue' +import PersonalSettings from './components/PersonalSettings.vue' + +const app = createApp(PersonalSettings) +app.mount('#vue-personal-circles') diff --git a/templates/settings-personal.php b/templates/settings-personal.php new file mode 100644 index 000000000..f5ccb207c --- /dev/null +++ b/templates/settings-personal.php @@ -0,0 +1,8 @@ + + + diff --git a/vite.config.ts b/vite.config.ts index e1f62a13d..7bf7c5f4e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -11,6 +11,7 @@ export default (env) => createAppConfig({ dashboard: join(import.meta.dirname, 'src/dashboard.ts'), 'settings-admin': join(import.meta.dirname, 'src/settings-admin.ts'), 'settings-team-folders': join(import.meta.dirname, 'src/settings-team-folders.ts'), + 'settings-personal': join(import.meta.dirname, 'src/settings-personal.ts'), }, { appName: 'teams', emptyOutputDirectory: { additionalDirectories: ['css'] },
+ {{ t('circles', 'Your account is connected.') }} +