From 82575bf95df72202e51941c507d588c7a4e6cd64 Mon Sep 17 00:00:00 2001 From: nananankona <185404318+nananankona@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:44:20 +0900 Subject: [PATCH 1/6] feat: add invitation codes and invitation links with quota and usage limits- Add invitation links (/apps/registration/invite/{code}) with optional email/domain restriction, storage quota, maximum number of uses and expiry date; the usage counter is incremented and the quota applied when an account is created- Add allowed email addresses setting (in addition to allowed domains)- Add option to require an invitation code for all registrations- Add invitation management UI to the admin settings Signed-off-by: nananankona <185404318+nananankona@users.noreply.github.com> --- README.md | 3 +- appinfo/routes.php | 5 + lib/Controller/InvitationController.php | 93 +++++ lib/Controller/RegisterController.php | 89 ++++- lib/Controller/SettingsController.php | 14 +- lib/Db/Invitation.php | 56 +++ lib/Db/InvitationMapper.php | 102 ++++++ lib/Db/Registration.php | 4 + .../Version0006Date20260814120000.php | 78 ++++ lib/Service/InvitationService.php | 172 +++++++++ lib/Service/RegistrationService.php | 57 ++- lib/Settings/RegistrationSettings.php | 8 + src/AdminSettings.vue | 26 ++ src/components/InvitationSettings.vue | 332 ++++++++++++++++++ src/components/RegistrationEmail.vue | 25 +- .../Controller/RegisterControllerTest.php | 5 + tests/Unit/Service/InvitationServiceTest.php | 165 +++++++++ .../Unit/Service/RegistrationServiceTest.php | 14 +- 18 files changed, 1230 insertions(+), 18 deletions(-) create mode 100644 lib/Controller/InvitationController.php create mode 100644 lib/Db/Invitation.php create mode 100644 lib/Db/InvitationMapper.php create mode 100644 lib/Migration/Version0006Date20260814120000.php create mode 100644 lib/Service/InvitationService.php create mode 100644 src/components/InvitationSettings.vue create mode 100644 tests/Unit/Service/InvitationServiceTest.php diff --git a/README.md b/README.md index 8f5a8a11..25816424 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,8 @@ Release tarballs are hosted at https://github.com/nextcloud-releases/registratio ## ✨ Features * 👥 Add users to a given group -* 🛃 Allow-list with email domains (including wildcard) to register with +* 🛃 Allow-list with email domains (including wildcard) or exact email addresses to register with +* 🎟️ Invitation codes and invitation links: restrict who can register, limit the number of uses per link and set a storage quota for invited users * 🔔 Administrator will be notified via email for new user creation or require approval * 📱 Supports Nextcloud's Client [Login Flow v1 and v2](https://docs.nextcloud.com/server/stable/developer_manual/client_apis/LoginFlow/index.html) - allowing registration in the mobile Apps and Desktop clients * 📜 Integrates with [Terms of service](https://apps.nextcloud.com/apps/terms_of_service) diff --git a/appinfo/routes.php b/appinfo/routes.php index 75f08c4f..66e9a1c4 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -11,9 +11,14 @@ ['name' => 'settings#admin', 'url' => '/settings', 'verb' => 'POST'], ['name' => 'register#showEmailForm', 'url' => '/', 'verb' => 'GET'], ['name' => 'register#submitEmailForm', 'url' => '/', 'verb' => 'POST'], + ['name' => 'register#showInviteForm', 'url' => '/invite/{code}', 'verb' => 'GET'], + ['name' => 'register#submitInviteForm', 'url' => '/invite/{code}', 'verb' => 'POST'], ['name' => 'register#showVerificationForm', 'url' => '/verify/{secret}', 'verb' => 'GET'], ['name' => 'register#submitVerificationForm', 'url' => '/verify/{secret}', 'verb' => 'POST'], ['name' => 'register#showUserForm', 'url' => '/register/{secret}/{token}', 'verb' => 'GET'], ['name' => 'register#submitUserForm', 'url' => '/register/{secret}/{token}', 'verb' => 'POST'], + ['name' => 'invitation#index', 'url' => '/admin/invitations', 'verb' => 'GET'], + ['name' => 'invitation#create', 'url' => '/admin/invitations', 'verb' => 'POST'], + ['name' => 'invitation#destroy', 'url' => '/admin/invitations/{id}', 'verb' => 'DELETE'], ], ]; diff --git a/lib/Controller/InvitationController.php b/lib/Controller/InvitationController.php new file mode 100644 index 00000000..04a1b517 --- /dev/null +++ b/lib/Controller/InvitationController.php @@ -0,0 +1,93 @@ + $this->serialize($invitation), + $this->invitationService->getAll() + ); + + return new DataResponse($invitations); + } + + #[AdminRequired] + public function create(string $code = '', string $email = '', string $domain = '', string $quota = '', string $max_uses = '', string $expires = ''): DataResponse { + if ($code === '') { + $code = $this->invitationService->generateCode(); + } + + try { + $invitation = $this->invitationService->createInvitation( + $code, + $email, + $domain, + $quota, + $max_uses !== '' ? (int)$max_uses : null, + $expires !== '' ? $expires : null + ); + } catch (RegistrationException $e) { + return new DataResponse( + [ + 'status' => 'error', + 'message' => $e->getMessage(), + ], + Http::STATUS_BAD_REQUEST + ); + } + + return new DataResponse($this->serialize($invitation)); + } + + #[AdminRequired] + public function destroy(int $id): DataResponse { + $this->invitationService->deleteById($id); + + return new DataResponse([ + 'status' => 'success', + ]); + } + + private function serialize(Invitation $invitation): array { + return [ + 'id' => $invitation->getId(), + 'code' => $invitation->getCode(), + 'email' => $invitation->getEmail(), + 'domain' => $invitation->getDomain(), + 'quota' => $invitation->getQuota(), + 'max_uses' => $invitation->getMaxUses(), + 'uses' => $invitation->getUses(), + 'expires' => $invitation->getExpires(), + 'created_at' => $invitation->getCreatedAt(), + 'link' => $this->invitationService->generateLink($invitation), + ]; + } +} \ No newline at end of file diff --git a/lib/Controller/RegisterController.php b/lib/Controller/RegisterController.php index 997c3a5a..21069b26 100644 --- a/lib/Controller/RegisterController.php +++ b/lib/Controller/RegisterController.php @@ -12,10 +12,12 @@ namespace OCA\Registration\Controller; use Exception; +use OCA\Registration\Db\Invitation; use OCA\Registration\Db\Registration; use OCA\Registration\Events\PassedFormEvent; use OCA\Registration\Events\ShowFormEvent; use OCA\Registration\Events\ValidateFormEvent; +use OCA\Registration\Service\InvitationService; use OCA\Registration\Service\LoginFlowService; use OCA\Registration\Service\MailService; use OCA\Registration\Service\RegistrationException; @@ -51,6 +53,7 @@ public function __construct( private RegistrationService $registrationService, private LoginFlowService $loginFlowService, private MailService $mailService, + private InvitationService $invitationService, private IEventDispatcher $eventDispatcher, private IInitialState $initialState, ) { @@ -59,7 +62,7 @@ public function __construct( #[PublicPage] #[NoCSRFRequired] - public function showEmailForm(string $email = '', string $message = ''): TemplateResponse { + public function showEmailForm(string $email = '', string $message = '', string $code = ''): TemplateResponse { $emailHint = ''; $domainList = $this->registrationService->getAllowedDomains(); if (!empty($domainList) && $this->config->getAppValueBool('show_domains')) { @@ -77,6 +80,14 @@ public function showEmailForm(string $email = '', string $message = ''): Templat } } + $emailList = $this->registrationService->getAllowedEmails(); + if (!empty($emailList) && $this->config->getAppValueBool('show_domains')) { + $emailHint = $this->l10n->t( + 'Registration is only allowed with the following email addresses: %s', + [implode(', ', $emailList)] + ); + } + $this->eventDispatcher->dispatchTyped(new ShowFormEvent(ShowFormEvent::STEP_EMAIL)); $this->initialState->provideInitialState('email', $email); @@ -85,33 +96,62 @@ public function showEmailForm(string $email = '', string $message = ''): Templat $this->initialState->provideInitialState('disableEmailVerification', $this->config->getAppValueBool('disable_email_verification')); $this->initialState->provideInitialState('isLoginFlow', $this->loginFlowService->isUsingLoginFlow()); $this->initialState->provideInitialState('loginFormLink', $this->urlGenerator->linkToRoute('core.login.showLoginForm')); + $this->initialState->provideInitialState('invitationCode', $code); + $this->initialState->provideInitialState('invitationCodeRequired', $this->config->getAppValueBool('invitation_code_required')); + $this->initialState->provideInitialState('invitationCodeLocked', $code !== ''); + $this->initialState->provideInitialState('invitationsEnabled', $this->config->getAppValueBool('invitation_code_required') || $code !== ''); return new TemplateResponse('registration', 'form/email', [], 'guest'); } + #[PublicPage] + #[NoCSRFRequired] + public function showInviteForm(string $code): Response { + try { + $invitation = $this->invitationService->getByCode($code); + $this->invitationService->assertUsable($invitation); + } catch (DoesNotExistException $e) { + return $this->validateSecretAndTokenErrorPage(); + } catch (RegistrationException $e) { + return $this->showEmailForm('', $e->getMessage(), $code); + } + + return $this->showEmailForm('', '', $code); + } + #[PublicPage] #[AnonRateLimit(limit: 5, period: 300)] - public function submitEmailForm(string $email): Response { + public function submitEmailForm(string $email, string $code = ''): Response { $validateFormEvent = new ValidateFormEvent(ValidateFormEvent::STEP_EMAIL); $this->eventDispatcher->dispatchTyped($validateFormEvent); if (!empty($validateFormEvent->getErrors())) { - return $this->showEmailForm($email, implode(' ', $validateFormEvent->getErrors())); + return $this->showEmailForm($email, implode(' ', $validateFormEvent->getErrors()), $code); + } + + try { + $invitation = $this->resolveInvitation($email, $code); + } catch (RegistrationException $e) { + return $this->showEmailForm($email, $e->getMessage(), $code); } try { // Registration already in progress, update token and continue with verification $registration = $this->registrationService->getRegistrationForEmail($email); $this->registrationService->generateNewToken($registration); + if ($invitation !== null && $registration->getInvitationId() === null) { + $registration->setInvitationId($invitation->getId()); + $this->registrationService->updateInvitation($registration); + } } catch (DoesNotExistException $e) { // No registration in progress try { $email = trim($email); - $this->registrationService->validateEmail($email); + $this->registrationService->validateEmail($email, $invitation); } catch (RegistrationException $e) { - return $this->showEmailForm($email, $e->getMessage()); + return $this->showEmailForm($email, $e->getMessage(), $code); } - $registration = $this->registrationService->createRegistration($email); + $registration = $this->registrationService->createRegistration($email, '', '', '', $invitation?->getId()); } if ($this->config->getAppValueBool('disable_email_verification')) { @@ -131,9 +171,9 @@ public function submitEmailForm(string $email): Response { try { $this->mailService->sendTokenByMail($registration); } catch (RegistrationException $e) { - return $this->showEmailForm($email, $e->getMessage()); + return $this->showEmailForm($email, $e->getMessage(), $code); } catch (\Exception $e) { - return $this->showEmailForm($email, $this->l10n->t('A problem occurred sending email, please contact your administrator.')); + return $this->showEmailForm($email, $this->l10n->t('A problem occurred sending email, please contact your administrator.'), $code); } $this->eventDispatcher->dispatchTyped(new PassedFormEvent(PassedFormEvent::STEP_EMAIL, $registration->getClientSecret())); @@ -146,6 +186,12 @@ public function submitEmailForm(string $email): Response { ); } + #[PublicPage] + #[AnonRateLimit(limit: 5, period: 300)] + public function submitInviteForm(string $code, string $email): Response { + return $this->submitEmailForm($email, $code); + } + #[PublicPage] #[NoCSRFRequired] public function showVerificationForm(string $secret, string $message = ''): TemplateResponse { @@ -327,4 +373,31 @@ protected function validateSecretAndTokenErrorPage(): TemplateResponse { ], ], 'error'); } + + /** + * Resolve and validate the invitation code, if any is required + * + * @param string $email + * @param string $code + * @return Invitation|null + * @throws RegistrationException + */ + protected function resolveInvitation(string $email, string $code): ?Invitation { + if ($code !== '') { + try { + $invitation = $this->invitationService->getByCode($code); + } catch (DoesNotExistException $e) { + throw new RegistrationException($this->l10n->t('This invitation code is not valid.')); + } + + $this->invitationService->validate($invitation, $email); + return $invitation; + } + + if ($this->config->getAppValueBool('invitation_code_required')) { + throw new RegistrationException($this->l10n->t('Please provide an invitation code.')); + } + + return null; + } } diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php index a32ba5da..a1be8eca 100644 --- a/lib/Controller/SettingsController.php +++ b/lib/Controller/SettingsController.php @@ -34,6 +34,7 @@ public function __construct( * * @param string|null $registered_user_group all newly registered user will be put in this group * @param string $allowed_domains Registrations are only allowed for E-Mailadresses with these domains + * @param string $allowed_emails Registrations are only allowed for these exact E-Mailadresses * @param string $additional_hint show Text at user-creation form * @param string $email_verification_hint if filled embed Text in Verification mail send to user * @param string $username_policy_regex optional regex to check usernames against a pattern @@ -42,10 +43,12 @@ public function __construct( * @param bool|null $email_is_login email address is forced as user id * @param bool|null $domains_is_blocklist is the domain list an allow or block list * @param bool|null $show_domains should the email list be shown to the user or not + * @param bool|null $invitation_code_required all registrations need an invitation code * @return DataResponse */ public function admin(?string $registered_user_group, string $allowed_domains, + string $allowed_emails, string $additional_hint, string $email_verification_hint, string $username_policy_regex, @@ -58,7 +61,8 @@ public function admin(?string $registered_user_group, ?bool $enforce_phone, ?bool $domains_is_blocklist, ?bool $show_domains, - ?bool $disable_email_verification): DataResponse { + ?bool $disable_email_verification, + ?bool $invitation_code_required): DataResponse { // handle domains if ($allowed_domains === '') { $this->config->deleteAppValue('allowed_domains'); @@ -66,6 +70,13 @@ public function admin(?string $registered_user_group, $this->config->setAppValueString('allowed_domains', $allowed_domains); } + // handle allowed email addresses + if ($allowed_emails === '') { + $this->config->deleteAppValue('allowed_emails'); + } else { + $this->config->setAppValueString('allowed_emails', $allowed_emails); + } + // handle hints if ($additional_hint === '') { $this->config->deleteAppValue('additional_hint'); @@ -104,6 +115,7 @@ public function admin(?string $registered_user_group, $this->config->setAppValueBool('domains_is_blocklist', $domains_is_blocklist); $this->config->setAppValueBool('show_domains', $show_domains); $this->config->setAppValueBool('disable_email_verification', $disable_email_verification); + $this->config->setAppValueBool('invitation_code_required', $invitation_code_required); if ($registered_user_group === null) { $this->config->deleteAppValue('registered_user_group'); diff --git a/lib/Db/Invitation.php b/lib/Db/Invitation.php new file mode 100644 index 00000000..ee9d4a07 --- /dev/null +++ b/lib/Db/Invitation.php @@ -0,0 +1,56 @@ +addType('code', 'string'); + $this->addType('email', 'string'); + $this->addType('domain', 'string'); + $this->addType('quota', 'string'); + $this->addType('maxUses', 'integer'); + $this->addType('uses', 'integer'); + $this->addType('expires', 'datetime'); + $this->addType('createdBy', 'string'); + $this->addType('createdAt', 'datetime'); + } +} \ No newline at end of file diff --git a/lib/Db/InvitationMapper.php b/lib/Db/InvitationMapper.php new file mode 100644 index 00000000..0cf54e87 --- /dev/null +++ b/lib/Db/InvitationMapper.php @@ -0,0 +1,102 @@ + + */ +class InvitationMapper extends QBMapper { + public function __construct( + IDBConnection $db, + protected ISecureRandom $random, + ) { + parent::__construct($db, 'registration_invitation', Invitation::class); + } + + /** + * @param string $code + * @return Invitation + * @throws DoesNotExistException + * @throws MultipleObjectsReturnedException + */ + public function findByCode(string $code): Entity { + $query = $this->db->getQueryBuilder(); + $query->select('*') + ->from($this->getTableName()) + ->where($query->expr()->eq('code', $query->createNamedParameter($code))); + + return $this->findEntity($query); + } + + /** + * @param int $id + * @return Invitation + * @throws DoesNotExistException + * @throws MultipleObjectsReturnedException + */ + public function findById(int $id): Entity { + $query = $this->db->getQueryBuilder(); + $query->select('*') + ->from($this->getTableName()) + ->where($query->expr()->eq('id', $query->createNamedParameter($id, IQueryBuilder::PARAM_INT))); + + return $this->findEntity($query); + } + + /** + * @return Invitation[] + */ + public function findAllInvitations(): array { + $query = $this->db->getQueryBuilder(); + $query->select('*') + ->from($this->getTableName()) + ->orderBy('created_at', 'DESC'); + + return $this->findEntities($query); + } + + /** + * @param int $id + */ + public function deleteById(int $id): void { + $query = $this->db->getQueryBuilder(); + $query->delete($this->getTableName()) + ->where($query->expr()->eq('id', $query->createNamedParameter($id, IQueryBuilder::PARAM_INT))) + ->executeStatement(); + } + + #[\Override] + public function insert(Entity $entity): Entity { + $entity->setCreatedAt(date('Y-m-d H:i:s')); + return parent::insert($entity); + } + + /** + * @param Invitation $invitation + */ + public function incrementUses(Invitation $invitation): void { + $query = $this->db->getQueryBuilder(); + $query->update($this->getTableName()) + ->set('uses', $query->createFunction('`uses` + 1')) + ->where($query->expr()->eq('id', $query->createNamedParameter($invitation->getId(), IQueryBuilder::PARAM_INT))) + ->executeStatement(); + } + + public function generateCode(): string { + return $this->random->generate(8, ISecureRandom::CHAR_HUMAN_READABLE); + } +} \ No newline at end of file diff --git a/lib/Db/Registration.php b/lib/Db/Registration.php index 17575658..f8441bc0 100644 --- a/lib/Db/Registration.php +++ b/lib/Db/Registration.php @@ -27,6 +27,8 @@ * @method void setClientSecret(string $clientSecret) * @method string getRequested() * @method void setRequested(string $requested) + * @method int|null getInvitationId() + * @method void setInvitationId(?int $invitationId) */ class Registration extends Entity { public $id; @@ -38,6 +40,7 @@ class Registration extends Entity { protected $requested; protected $emailConfirmed; protected $clientSecret; + protected $invitationId; public function __construct() { $this->addType('email', 'string'); @@ -48,5 +51,6 @@ public function __construct() { $this->addType('token', 'string'); $this->addType('clientSecret', 'string'); $this->addType('requested', 'datetime'); + $this->addType('invitationId', 'integer'); } } diff --git a/lib/Migration/Version0006Date20260814120000.php b/lib/Migration/Version0006Date20260814120000.php new file mode 100644 index 00000000..aa27b40d --- /dev/null +++ b/lib/Migration/Version0006Date20260814120000.php @@ -0,0 +1,78 @@ +hasTable('registration_invitation')) { + $table = $schema->createTable('registration_invitation'); + $table->addColumn('id', Types::INTEGER, [ + 'autoincrement' => true, + 'notnull' => true, + 'unsigned' => true, + ]); + $table->addColumn('code', Types::STRING, [ + 'notnull' => true, + ]); + $table->addColumn('email', Types::STRING, [ + 'notnull' => false, + ]); + $table->addColumn('domain', Types::STRING, [ + 'notnull' => false, + ]); + $table->addColumn('quota', Types::STRING, [ + 'notnull' => false, + ]); + $table->addColumn('max_uses', Types::INTEGER, [ + 'notnull' => false, + ]); + $table->addColumn('uses', Types::INTEGER, [ + 'notnull' => true, + 'default' => 0, + ]); + $table->addColumn('expires', Types::DATETIME, [ + 'notnull' => false, + ]); + $table->addColumn('created_by', Types::STRING, [ + 'notnull' => false, + ]); + $table->addColumn('created_at', Types::DATETIME, [ + 'notnull' => true, + ]); + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['code'], 'registration_invitation_code_idx'); + } + + $registrationTable = $schema->getTable('registration'); + if (!$registrationTable->hasColumn('invitation_id')) { + $registrationTable->addColumn('invitation_id', Types::INTEGER, [ + 'notnull' => false, + 'unsigned' => true, + ]); + } + + return $schema; + } +} \ No newline at end of file diff --git a/lib/Service/InvitationService.php b/lib/Service/InvitationService.php new file mode 100644 index 00000000..0cfa8247 --- /dev/null +++ b/lib/Service/InvitationService.php @@ -0,0 +1,172 @@ +l10n->t('Please provide an invitation code.')); + } + + try { + $this->invitationMapper->findByCode($code); + throw new RegistrationException($this->l10n->t('An invitation with this code already exists.')); + } catch (DoesNotExistException $e) { + } + + if ($maxUses !== null && $maxUses < 1) { + throw new RegistrationException($this->l10n->t('The maximum number of uses needs to be at least one.')); + } + + $invitation = new Invitation(); + $invitation->setCode($code); + $invitation->setEmail($email !== null && $email !== '' ? strtolower($email) : null); + $invitation->setDomain($domain !== null && $domain !== '' ? strtolower($domain) : null); + $invitation->setQuota($quota !== null && $quota !== '' ? $quota : null); + $invitation->setMaxUses($maxUses); + $invitation->setUses(0); + $invitation->setExpires($expires); + + return $this->invitationMapper->insert($invitation); + } + + /** + * @param string $code + * @return Invitation + * @throws DoesNotExistException + */ + public function getByCode(string $code): Invitation { + return $this->invitationMapper->findByCode($code); + } + + /** + * @param int $id + * @return Invitation + * @throws DoesNotExistException + */ + public function getById(int $id): Invitation { + return $this->invitationMapper->findById($id); + } + + /** + * @return Invitation[] + */ + public function getAll(): array { + return $this->invitationMapper->findAllInvitations(); + } + + public function deleteById(int $id): void { + $this->invitationMapper->deleteById($id); + } + + public function generateCode(): string { + return $this->invitationMapper->generateCode(); + } + + public function generateLink(Invitation $invitation): string { + return $this->urlGenerator->linkToRouteAbsolute('registration.register.showInviteForm', [ + 'code' => $invitation->getCode(), + ]); + } + + public function isExpired(Invitation $invitation): bool { + $expires = $invitation->getExpires(); + if ($expires === null) { + return false; + } + + $expireTimestamp = strtotime($expires); + return $expireTimestamp !== false && $expireTimestamp < $this->timeFactory->getTime(); + } + + public function isMaxUsesReached(Invitation $invitation): bool { + $maxUses = $invitation->getMaxUses(); + if ($maxUses === null) { + return false; + } + + return $invitation->getUses() >= $maxUses; + } + + /** + * @param Invitation $invitation + * @throws RegistrationException + */ + public function assertUsable(Invitation $invitation): void { + if ($this->isExpired($invitation)) { + throw new RegistrationException($this->l10n->t('This invitation is no longer valid.')); + } + + if ($this->isMaxUsesReached($invitation)) { + throw new RegistrationException($this->l10n->t('This invitation has already been used up.')); + } + } + + /** + * @param Invitation $invitation + * @param string $email + * @throws RegistrationException + */ + public function validate(Invitation $invitation, string $email): void { + $this->assertUsable($invitation); + + $allowedEmail = $invitation->getEmail(); + if ($allowedEmail !== null && strtolower($email) !== strtolower($allowedEmail)) { + throw new RegistrationException($this->l10n->t('This invitation is not valid for this email address.')); + } + + $allowedDomain = $invitation->getDomain(); + if ($allowedDomain !== null && !$this->domainMatches($email, $allowedDomain)) { + throw new RegistrationException($this->l10n->t('This invitation is not valid for this email domain.')); + } + } + + public function incrementUses(Invitation $invitation): void { + $this->invitationMapper->incrementUses($invitation); + } + + private function domainMatches(string $email, string $allowedDomain): bool { + [,$mailDomain] = explode('@', strtolower($email), 2); + + if (str_contains($allowedDomain, '*')) { + $regexDomain = preg_quote($allowedDomain, '\\'); + $regexDomain = '/^' . str_replace('\\*', '.+', $regexDomain) . '$/'; + return (bool)preg_match($regexDomain, $mailDomain); + } + + return $mailDomain === $allowedDomain; + } +} \ No newline at end of file diff --git a/lib/Service/RegistrationService.php b/lib/Service/RegistrationService.php index 94ebe847..920e79e9 100644 --- a/lib/Service/RegistrationService.php +++ b/lib/Service/RegistrationService.php @@ -14,6 +14,7 @@ use OC\Authentication\Exceptions\PasswordlessTokenException; use OC\Authentication\Token\IProvider; use OCA\Registration\AppInfo\Application; +use OCA\Registration\Db\Invitation; use OCA\Registration\Db\Registration; use OCA\Registration\Db\RegistrationMapper; use OCA\Settings\Mailer\NewUserMailHelper; @@ -58,6 +59,7 @@ public function __construct( private IProvider $tokenProvider, private ICrypto $crypto, private IPhoneNumberUtil $phoneNumberUtil, + private InvitationService $invitationService, ) { } @@ -71,10 +73,14 @@ public function generateNewToken(Registration $registration): void { $this->registrationMapper->update($registration); } + public function updateInvitation(Registration $registration): void { + $this->registrationMapper->update($registration); + } + /** * Create registration request, used by both the API and form */ - public function createRegistration(string $email, string $username = '', string $password = '', string $displayname = ''): Registration { + public function createRegistration(string $email, string $username = '', string $password = '', string $displayname = '', ?int $invitationId = null): Registration { $registration = new Registration(); $registration->setEmail($email); $registration->setUsername($username); @@ -83,6 +89,7 @@ public function createRegistration(string $email, string $username = '', string $password = $this->crypto->encrypt($password); $registration->setPassword($password); } + $registration->setInvitationId($invitationId); $this->registrationMapper->generateNewToken($registration); $this->registrationMapper->generateClientSecret($registration); $this->registrationMapper->insert($registration); @@ -91,9 +98,10 @@ public function createRegistration(string $email, string $username = '', string /** * @param string $email + * @param Invitation|null $invitation an admin-issued invitation bypasses the general allow-list * @throws RegistrationException */ - public function validateEmail(string $email): void { + public function validateEmail(string $email, ?Invitation $invitation = null): void { if ($email === '' && $this->appConfig->getAppValueBool('email_is_optional')) { return; } @@ -116,9 +124,20 @@ public function validateEmail(string $email): void { ); } + // An admin-issued invitation bypasses the general allow-list + if ($invitation !== null) { + return; + } + $allowedDomains = $this->getAllowedDomains(); + $allowedEmails = $this->getAllowedEmails(); + $emailIsInEmailList = in_array(strtolower($email), $allowedEmails, true); - if (empty($allowedDomains)) { + if ($emailIsInEmailList) { + return; + } + + if (empty($allowedDomains) && empty($allowedEmails)) { return; } @@ -248,6 +267,17 @@ public function getAllowedDomains(): array { return array_map('strtolower', $allowedDomains); } + /** + * @return string[] exact email addresses allowed to register + */ + public function getAllowedEmails(): array { + $allowedEmails = $this->appConfig->getAppValueString('allowed_emails'); + $allowedEmails = explode(';', $allowedEmails); + $allowedEmails = array_map('trim', $allowedEmails); + $allowedEmails = array_filter($allowedEmails); + return array_map('strtolower', $allowedEmails); + } + /** * @param Registration $registration * @param string|null $loginName @@ -277,6 +307,18 @@ public function createAccount(Registration $registration, ?string $loginName = n $this->validateDisplayname($fullName); } + // Load the invitation and re-validate it before creating the account + $invitation = null; + $invitationId = $registration->getInvitationId(); + if ($invitationId !== null) { + try { + $invitation = $this->invitationService->getById($invitationId); + $this->invitationService->validate($invitation, $registration->getEmail()); + } catch (DoesNotExistException $e) { + // The invitation was deleted in the meantime, continue without it + } + } + if (class_exists(PhoneNumberUtil::class) && $this->appConfig->getAppValueBool('show_phone')) { if ($phone) { @@ -299,6 +341,15 @@ public function createAccount(Registration $registration, ?string $loginName = n } $userId = $user->getUID(); + // Apply the quota and consume a use of the invitation, if any + if ($invitation !== null) { + $quota = $invitation->getQuota(); + if ($quota !== null && $quota !== '') { + $user->setQuota($quota); + } + $this->invitationService->incrementUses($invitation); + } + // Set user email try { $user->setEMailAddress($registration->getEmail()); diff --git a/lib/Settings/RegistrationSettings.php b/lib/Settings/RegistrationSettings.php index 183113ca..ae47cf1c 100644 --- a/lib/Settings/RegistrationSettings.php +++ b/lib/Settings/RegistrationSettings.php @@ -43,6 +43,10 @@ public function getForm(): TemplateResponse { 'allowed_domains', $this->config->getAppValueString('allowed_domains') ); + $this->initialState->provideInitialState( + 'allowed_emails', + $this->config->getAppValueString('allowed_emails') + ); $this->initialState->provideInitialState( 'domains_is_blocklist', $this->config->getAppValueBool('domains_is_blocklist') @@ -55,6 +59,10 @@ public function getForm(): TemplateResponse { 'disable_email_verification', $this->config->getAppValueBool('disable_email_verification') ); + $this->initialState->provideInitialState( + 'invitation_code_required', + $this->config->getAppValueBool('invitation_code_required') + ); $this->initialState->provideInitialState( 'email_is_optional', $this->config->getAppValueBool('email_is_optional') diff --git a/src/AdminSettings.vue b/src/AdminSettings.vue index f0d69c18..278c2731 100644 --- a/src/AdminSettings.vue +++ b/src/AdminSettings.vue @@ -53,6 +53,14 @@ placeholder="nextcloud.com;*.example.com" @update:modelValue="debounceSavingSlow" /> + + {{ t('registration', 'Disable email verification') }} + + + {{ t('registration', 'Require an invitation code for all registrations') }} + +

{{ t('registration', 'If enabled, users have to enter a valid invitation code. Administrators can create invitation links and codes below.') }}

+ + (null) const adminApproval = ref(loadState('registration', 'admin_approval_required')) const registeredUserGroup = ref(loadState('registration', 'registered_user_group')) const allowedDomains = ref(loadState('registration', 'allowed_domains')) +const allowedEmails = ref(loadState('registration', 'allowed_emails')) const domainsIsBlocklist = ref(loadState('registration', 'domains_is_blocklist')) const showDomains = ref(loadState('registration', 'show_domains')) const emailIsOptional = ref(loadState('registration', 'email_is_optional')) const disableEmailVerification = ref(loadState('registration', 'disable_email_verification')) const emailIsLogin = ref(loadState('registration', 'email_is_login')) +const invitationCodeRequired = ref(loadState('registration', 'invitation_code_required')) const usernamePolicyRegex = ref(loadState('registration', 'username_policy_regex')) const showFullname = ref(loadState('registration', 'show_fullname')) const enforceFullname = ref(loadState('registration', 'enforce_fullname')) @@ -220,6 +242,8 @@ const domainListLabel = computed(() => { return t('registration', 'Allowed email domains') }) +const emailListLabel = computed(() => t('registration', 'Allowed email addresses')) + const showDomainListLabel = computed(() => { if (domainsIsBlocklist.value) { return t('registration', 'Show the blocked email domains to users') @@ -246,11 +270,13 @@ async function saveData() { admin_approval_required: adminApproval.value, registered_user_group: registeredUserGroup.value?.id, allowed_domains: allowedDomains.value, + allowed_emails: allowedEmails.value, domains_is_blocklist: domainsIsBlocklist.value, show_domains: showDomains.value, email_is_optional: emailIsOptional.value, disable_email_verification: emailIsOptional.value || disableEmailVerification.value, email_is_login: !emailIsOptional.value && emailIsLogin.value, + invitation_code_required: invitationCodeRequired.value, username_policy_regex: usernamePolicyRegex.value, show_fullname: showFullname.value, enforce_fullname: enforceFullname.value, diff --git a/src/components/InvitationSettings.vue b/src/components/InvitationSettings.vue new file mode 100644 index 00000000..0f974232 --- /dev/null +++ b/src/components/InvitationSettings.vue @@ -0,0 +1,332 @@ + + + + + + diff --git a/src/components/RegistrationEmail.vue b/src/components/RegistrationEmail.vue index 1d39cdff..c42a49b6 100644 --- a/src/components/RegistrationEmail.vue +++ b/src/components/RegistrationEmail.vue @@ -10,6 +10,19 @@ {{ message }} + + + + ('registration', 'emailIsOptional') const message = loadState('registration', 'message') @@ -59,7 +73,16 @@ const requesttoken = getRequestToken() const disableEmailVerification = loadState('registration', 'disableEmailVerification') const isLoginFlow = loadState('registration', 'isLoginFlow') const loginFormLink = loadState('registration', 'loginFormLink') +const invitationsEnabled = loadState('registration', 'invitationsEnabled') +const invitationCodeRequired = loadState('registration', 'invitationCodeRequired') +const invitationCodeLocked = loadState('registration', 'invitationCodeLocked') +const invitationCode = ref(loadState('registration', 'invitationCode')) +const invitationLabel = computed(() => { + return invitationCodeLocked + ? t('registration', 'Invitation code') + : t('registration', invitationCodeRequired ? 'Invitation code' : 'Invitation code (optional)') +}) const emailLabel = computed(() => { return emailIsOptional ? t('registration', 'Email (optional)') diff --git a/tests/Unit/Controller/RegisterControllerTest.php b/tests/Unit/Controller/RegisterControllerTest.php index 5215fc6e..8dbe59dc 100644 --- a/tests/Unit/Controller/RegisterControllerTest.php +++ b/tests/Unit/Controller/RegisterControllerTest.php @@ -11,6 +11,7 @@ use ChristophWurst\Nextcloud\Testing\TestCase; use OCA\Registration\Controller\RegisterController; use OCA\Registration\Db\Registration; +use OCA\Registration\Service\InvitationService; use OCA\Registration\Service\LoginFlowService; use OCA\Registration\Service\MailService; use OCA\Registration\Service\RegistrationException; @@ -39,6 +40,7 @@ class RegisterControllerTest extends TestCase { private RegistrationService&MockObject $registrationService; private LoginFlowService&MockObject $loginFlowService; private MailService&MockObject $mailService; + private InvitationService&MockObject $invitationService; private IEventDispatcher&MockObject $eventDispatcher; private IInitialState&MockObject $initialState; @@ -51,6 +53,7 @@ public function setUp(): void { $this->registrationService = $this->createMock(RegistrationService::class); $this->loginFlowService = $this->createMock(LoginFlowService::class); $this->mailService = $this->createMock(MailService::class); + $this->invitationService = $this->createMock(InvitationService::class); $this->eventDispatcher = $this->createMock(IEventDispatcher::class); $this->initialState = $this->createMock(IInitialState::class); @@ -76,6 +79,7 @@ protected function getController(array $methods = []) { $this->registrationService, $this->loginFlowService, $this->mailService, + $this->invitationService, $this->eventDispatcher, $this->initialState ); @@ -92,6 +96,7 @@ protected function getController(array $methods = []) { $this->registrationService, $this->loginFlowService, $this->mailService, + $this->invitationService, $this->eventDispatcher, $this->initialState, ]) diff --git a/tests/Unit/Service/InvitationServiceTest.php b/tests/Unit/Service/InvitationServiceTest.php new file mode 100644 index 00000000..c1cb55e2 --- /dev/null +++ b/tests/Unit/Service/InvitationServiceTest.php @@ -0,0 +1,165 @@ +get(ISecureRandom::class); + $mapper = new InvitationMapper( + \OC::$server->get(IDBConnection::class), + $random + ); + $urlGenerator = $this->createMock(IURLGenerator::class); + $urlGenerator->method('linkToRouteAbsolute') + ->willReturn('https://example.com/apps/registration/invite/CODE'); + $l10n = $this->createMock(IL10N::class); + $l10n->method('t') + ->willReturnCallback(function ($text, $parameters = []) { + return vsprintf($text, $parameters); + }); + $this->timeFactory = $this->createMock(ITimeFactory::class); + $this->timeFactory->method('getTime') + ->willReturn(1000000000); + + $this->service = new InvitationService( + $mapper, + $urlGenerator, + $l10n, + $this->timeFactory, + ); + } + + private function createInvitation(array $overrides = []): Invitation { + $params = array_merge([ + 'code' => 'TESTCODE', + 'email' => '', + 'domain' => '', + 'quota' => '5 GB', + 'max_uses' => '', + 'expires' => '', + ], $overrides); + + return $this->service->createInvitation( + $params['code'], + $params['email'], + $params['domain'], + $params['quota'], + $params['max_uses'] !== '' ? (int)$params['max_uses'] : null, + $params['expires'] !== '' ? $params['expires'] : null, + ); + } + + public function testCreateAndGetByCode(): void { + $invitation = $this->createInvitation(); + + $found = $this->service->getByCode('TESTCODE'); + + self::assertSame('TESTCODE', $found->getCode()); + self::assertSame('5 GB', $found->getQuota()); + self::assertSame(0, $found->getUses()); + self::assertNotNull($invitation->getId()); + } + + public function testCreateDuplicateCodeThrows(): void { + $this->createInvitation(); + + $this->expectException(RegistrationException::class); + $this->createInvitation(['quota' => '']); + } + + public function testCreateEmptyCodeThrows(): void { + $this->expectException(RegistrationException::class); + $this->createInvitation(['code' => '']); + } + + public function testGetUnknownCodeThrows(): void { + $this->expectException(DoesNotExistException::class); + $this->service->getByCode('UNKNOWN'); + } + + public function testDeleteById(): void { + $invitation = $this->createInvitation(); + $this->service->deleteById($invitation->getId()); + + $this->expectException(DoesNotExistException::class); + $this->service->getByCode('TESTCODE'); + } + + public function testValidateOk(): void { + $invitation = $this->createInvitation(); + $this->service->validate($invitation, 'user@example.com'); + self::assertTrue(true); + } + + public function testValidateEmailRestriction(): void { + $invitation = $this->createInvitation(['email' => 'foo@example.com']); + $this->service->validate($invitation, 'foo@example.com'); + + $this->expectException(RegistrationException::class); + $this->service->validate($invitation, 'bar@example.com'); + } + + public function testValidateDomainRestriction(): void { + $invitation = $this->createInvitation(['domain' => 'example.com']); + $this->service->validate($invitation, 'foo@example.com'); + + $this->expectException(RegistrationException::class); + $this->service->validate($invitation, 'foo@example.tld'); + } + + public function testValidateExpired(): void { + $invitation = $this->createInvitation(['expires' => '2000-01-01 00:00:00']); + + $this->expectException(RegistrationException::class); + $this->service->validate($invitation, 'foo@example.com'); + } + + public function testValidateMaxUsesReached(): void { + $invitation = $this->createInvitation(['max_uses' => '1']); + $this->service->incrementUses($invitation); + + $this->expectException(RegistrationException::class); + $this->service->validate($invitation, 'foo@example.com'); + } + + public function testIncrementUses(): void { + $invitation = $this->createInvitation(['max_uses' => '2']); + $this->service->incrementUses($invitation); + + $found = $this->service->getById($invitation->getId()); + self::assertSame(1, $found->getUses()); + } + + public function testGenerateLink(): void { + $invitation = $this->createInvitation(); + self::assertSame('https://example.com/apps/registration/invite/CODE', $this->service->generateLink($invitation)); + } +} diff --git a/tests/Unit/Service/RegistrationServiceTest.php b/tests/Unit/Service/RegistrationServiceTest.php index 680ec037..1cac039d 100644 --- a/tests/Unit/Service/RegistrationServiceTest.php +++ b/tests/Unit/Service/RegistrationServiceTest.php @@ -12,6 +12,7 @@ use OC\Authentication\Token\IProvider; use OCA\Registration\Db\Registration; use OCA\Registration\Db\RegistrationMapper; +use OCA\Registration\Service\InvitationService; use OCA\Registration\Service\MailService; use OCA\Registration\Service\RegistrationException; use OCA\Registration\Service\RegistrationService; @@ -97,7 +98,8 @@ public function setUp(): void { $session, $tokenProvider, $this->crypto, - $this->phoneNumberUtil + $this->phoneNumberUtil, + $this->createMock(InvitationService::class) ); } @@ -125,10 +127,14 @@ public static function dataValidateEmail(): array { */ #[DataProvider('dataValidateEmail')] public function testValidateEmail(string $email, string $allowedDomains, bool $blocked) { - $this->appConfig->expects($this->once()) + $this->appConfig->expects($this->atLeastOnce()) ->method('getAppValueString') - ->with('allowed_domains') - ->willReturn($allowedDomains); + ->willReturnCallback(function ($key) use ($allowedDomains) { + if ($key === 'allowed_domains') { + return $allowedDomains; + } + return ''; + }); $this->appConfig->expects($this->exactly($allowedDomains === '' ? 0 : 2)) ->method('getAppValueBool') From a7ed978341b91147474d5fa3c0c7405baaf55997 Mon Sep 17 00:00:00 2001 From: nananankona <185404318+nananankona@users.noreply.github.com> Date: Sat, 15 Aug 2026 00:07:43 +0900 Subject: [PATCH 2/6] feat: add Japanese translations and per-invitation email verification bypass- Add skip_email_verification option to invitations: registrations via such an invitation skip the email verification step and go straight to the account creation form- Show the invitation code and shareable link in a dialog after creating an invitation, each with a copy button; add a Details button to the invitation list for the same dialog- Add Japanese translations (l10n/ja) for the invitation strings Signed-off-by: nananankona <185404318+nananankona@users.noreply.github.com> --- README.md | 2 +- lib/Controller/InvitationController.php | 6 +- lib/Controller/RegisterController.php | 25 ++++- lib/Db/Invitation.php | 4 + .../Version0007Date20260814190000.php | 41 +++++++ lib/Service/InvitationService.php | 4 +- src/components/InvitationSettings.vue | 103 +++++++++++++++++- .../Controller/RegisterControllerTest.php | 51 ++++++++- tests/Unit/Service/InvitationServiceTest.php | 10 ++ 9 files changed, 232 insertions(+), 14 deletions(-) create mode 100644 lib/Migration/Version0007Date20260814190000.php diff --git a/README.md b/README.md index 25816424..184c4a34 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Release tarballs are hosted at https://github.com/nextcloud-releases/registratio * 👥 Add users to a given group * 🛃 Allow-list with email domains (including wildcard) or exact email addresses to register with -* 🎟️ Invitation codes and invitation links: restrict who can register, limit the number of uses per link and set a storage quota for invited users +* 🎟️ Invitation codes and invitation links: restrict who can register, limit the number of uses per link, set a storage quota for invited users and optionally skip the email verification step * 🔔 Administrator will be notified via email for new user creation or require approval * 📱 Supports Nextcloud's Client [Login Flow v1 and v2](https://docs.nextcloud.com/server/stable/developer_manual/client_apis/LoginFlow/index.html) - allowing registration in the mobile Apps and Desktop clients * 📜 Integrates with [Terms of service](https://apps.nextcloud.com/apps/terms_of_service) diff --git a/lib/Controller/InvitationController.php b/lib/Controller/InvitationController.php index 04a1b517..d05d61d4 100644 --- a/lib/Controller/InvitationController.php +++ b/lib/Controller/InvitationController.php @@ -40,7 +40,7 @@ public function index(): DataResponse { } #[AdminRequired] - public function create(string $code = '', string $email = '', string $domain = '', string $quota = '', string $max_uses = '', string $expires = ''): DataResponse { + public function create(string $code = '', string $email = '', string $domain = '', string $quota = '', string $max_uses = '', string $expires = '', string $skip_email_verification = ''): DataResponse { if ($code === '') { $code = $this->invitationService->generateCode(); } @@ -52,7 +52,8 @@ public function create(string $code = '', string $email = '', string $domain = ' $domain, $quota, $max_uses !== '' ? (int)$max_uses : null, - $expires !== '' ? $expires : null + $expires !== '' ? $expires : null, + $skip_email_verification === 'true' || $skip_email_verification === '1' ); } catch (RegistrationException $e) { return new DataResponse( @@ -87,6 +88,7 @@ private function serialize(Invitation $invitation): array { 'uses' => $invitation->getUses(), 'expires' => $invitation->getExpires(), 'created_at' => $invitation->getCreatedAt(), + 'skip_email_verification' => $invitation->getSkipEmailVerification(), 'link' => $this->invitationService->generateLink($invitation), ]; } diff --git a/lib/Controller/RegisterController.php b/lib/Controller/RegisterController.php index 21069b26..9d54bb8d 100644 --- a/lib/Controller/RegisterController.php +++ b/lib/Controller/RegisterController.php @@ -93,7 +93,7 @@ public function showEmailForm(string $email = '', string $message = '', string $ $this->initialState->provideInitialState('email', $email); $this->initialState->provideInitialState('message', $message ?: $emailHint); $this->initialState->provideInitialState('emailIsOptional', $this->config->getAppValueBool('email_is_optional')); - $this->initialState->provideInitialState('disableEmailVerification', $this->config->getAppValueBool('disable_email_verification')); + $this->initialState->provideInitialState('disableEmailVerification', $this->config->getAppValueBool('disable_email_verification') || $this->invitationSkipsVerification($code)); $this->initialState->provideInitialState('isLoginFlow', $this->loginFlowService->isUsingLoginFlow()); $this->initialState->provideInitialState('loginFormLink', $this->urlGenerator->linkToRoute('core.login.showLoginForm')); $this->initialState->provideInitialState('invitationCode', $code); @@ -154,7 +154,8 @@ public function submitEmailForm(string $email, string $code = ''): Response { $registration = $this->registrationService->createRegistration($email, '', '', '', $invitation?->getId()); } - if ($this->config->getAppValueBool('disable_email_verification')) { + if ($this->config->getAppValueBool('disable_email_verification') + || ($invitation !== null && $invitation->getSkipEmailVerification())) { $this->eventDispatcher->dispatchTyped(new PassedFormEvent(PassedFormEvent::STEP_EMAIL, $registration->getClientSecret())); return new RedirectResponse( @@ -400,4 +401,24 @@ protected function resolveInvitation(string $email, string $code): ?Invitation { return null; } + + /** + * Check whether an invitation code would skip the email verification step + * + * @param string $code + * @return bool + */ + protected function invitationSkipsVerification(string $code): bool { + if ($code === '') { + return false; + } + + try { + $invitation = $this->invitationService->getByCode($code); + } catch (DoesNotExistException $e) { + return false; + } + + return $invitation->getSkipEmailVerification(); + } } diff --git a/lib/Db/Invitation.php b/lib/Db/Invitation.php index ee9d4a07..73e9cce6 100644 --- a/lib/Db/Invitation.php +++ b/lib/Db/Invitation.php @@ -29,6 +29,8 @@ * @method void setCreatedBy(?string $createdBy) * @method string getCreatedAt() * @method void setCreatedAt(string $createdAt) + * @method bool getSkipEmailVerification() + * @method void setSkipEmailVerification(bool $skipEmailVerification) */ class Invitation extends Entity { public $id; @@ -41,6 +43,7 @@ class Invitation extends Entity { protected $expires; protected $createdBy; protected $createdAt; + protected $skipEmailVerification; public function __construct() { $this->addType('code', 'string'); @@ -52,5 +55,6 @@ public function __construct() { $this->addType('expires', 'datetime'); $this->addType('createdBy', 'string'); $this->addType('createdAt', 'datetime'); + $this->addType('skipEmailVerification', 'boolean'); } } \ No newline at end of file diff --git a/lib/Migration/Version0007Date20260814190000.php b/lib/Migration/Version0007Date20260814190000.php new file mode 100644 index 00000000..9c91ca9a --- /dev/null +++ b/lib/Migration/Version0007Date20260814190000.php @@ -0,0 +1,41 @@ +hasTable('registration_invitation')) { + $table = $schema->getTable('registration_invitation'); + if (!$table->hasColumn('skip_email_verification')) { + $table->addColumn('skip_email_verification', Types::BOOLEAN, [ + 'notnull' => true, + 'default' => false, + ]); + } + } + + return $schema; + } +} \ No newline at end of file diff --git a/lib/Service/InvitationService.php b/lib/Service/InvitationService.php index 0cfa8247..23361ec8 100644 --- a/lib/Service/InvitationService.php +++ b/lib/Service/InvitationService.php @@ -32,10 +32,11 @@ public function __construct( * @param string|null $quota * @param int|null $maxUses * @param string|null $expires (Y-m-d H:i:s or null) + * @param bool $skipEmailVerification * @return Invitation * @throws RegistrationException */ - public function createInvitation(string $code, ?string $email, ?string $domain, ?string $quota, ?int $maxUses, ?string $expires): Invitation { + public function createInvitation(string $code, ?string $email, ?string $domain, ?string $quota, ?int $maxUses, ?string $expires, bool $skipEmailVerification = false): Invitation { $code = trim($code); if ($code === '') { throw new RegistrationException($this->l10n->t('Please provide an invitation code.')); @@ -59,6 +60,7 @@ public function createInvitation(string $code, ?string $email, ?string $domain, $invitation->setMaxUses($maxUses); $invitation->setUses(0); $invitation->setExpires($expires); + $invitation->setSkipEmailVerification($skipEmailVerification); return $this->invitationMapper->insert($invitation); } diff --git a/src/components/InvitationSettings.vue b/src/components/InvitationSettings.vue index 0f974232..1a4f5e45 100644 --- a/src/components/InvitationSettings.vue +++ b/src/components/InvitationSettings.vue @@ -66,6 +66,14 @@ :placeholder="t('registration', 'No expiry (optional)')" /> + + {{ t('registration', 'Skip email verification') }} + +

{{ t('registration', 'If enabled, the email address does not need to be verified and the user can create the account right after entering their email address.') }}

+
+ + {{ t('registration', 'Details') }} +
+ + +

+ {{ t('registration', 'Share the link or the code below with the person you want to invite.') }} +

+
+ + + {{ t('registration', 'Copy code') }} + +
+
+ + + {{ t('registration', 'Copy link') }} + +
+
@@ -130,6 +177,8 @@ import { t } from '@nextcloud/l10n' import { generateUrl } from '@nextcloud/router' import { onMounted, ref } from 'vue' import NcButton from '@nextcloud/vue/components/NcButton' +import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' +import NcDialog from '@nextcloud/vue/components/NcDialog' import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection' import NcTextField from '@nextcloud/vue/components/NcTextField' @@ -144,6 +193,7 @@ type Invitation = { uses: number expires: string | null created_at: string + skip_email_verification: boolean link: string } @@ -155,8 +205,11 @@ const domain = ref('') const quota = ref('') const maxUses = ref('') const expires = ref('') +const skipEmailVerification = ref(false) const listMessage = ref('') const listMessageType = ref<'error' | 'info'>('error') +const showDialog = ref(false) +const selectedInvitation = ref(null) /** * Human readable label for the restriction of an invitation @@ -219,6 +272,7 @@ async function createInvitation() { quota: quota.value, max_uses: maxUses.value, expires: expires.value ? expires.value.replace('T', ' ') : '', + skip_email_verification: skipEmailVerification.value ? 'true' : '', }) if (response.data?.status === 'error') { @@ -233,6 +287,9 @@ async function createInvitation() { quota.value = '' maxUses.value = '' expires.value = '' + skipEmailVerification.value = false + selectedInvitation.value = response.data + showDialog.value = true } } catch (e) { const msg = e.response?.data?.message @@ -260,20 +317,39 @@ async function deleteInvitation(invitation: Invitation) { } /** - * Copy the invitation link to the clipboard + * Copy an arbitrary text to the clipboard * - * @param invitation invitation whose link is copied + * @param text text to copy */ -async function copyLink(invitation: Invitation) { +async function copyText(text: string) { try { - await navigator.clipboard.writeText(invitation.link) - showSuccess(t('registration', 'Invitation link copied')) + await navigator.clipboard.writeText(text) + showSuccess(t('registration', 'Copied to clipboard')) } catch (e) { - showError(t('registration', 'Could not copy the invitation link')) + showError(t('registration', 'Could not copy to clipboard')) console.error(e) } } +/** + * Copy the invitation link to the clipboard + * + * @param invitation invitation whose link is copied + */ +async function copyLink(invitation: Invitation) { + await copyText(invitation.link) +} + +/** + * Open the details dialog for an invitation + * + * @param invitation invitation to show + */ +function showDetails(invitation: Invitation) { + selectedInvitation.value = invitation + showDialog.value = true +} + onMounted(() => { loadInvitations() }) @@ -329,4 +405,19 @@ onMounted(() => { } } } + +.invitation-dialog-text { + margin-top: 0; +} + +.invitation-dialog-row { + display: flex; + align-items: flex-end; + gap: .5rem; + margin-bottom: .75rem; + + > :deep(*) { + flex: 1; + } +} diff --git a/tests/Unit/Controller/RegisterControllerTest.php b/tests/Unit/Controller/RegisterControllerTest.php index 8dbe59dc..696d0f3d 100644 --- a/tests/Unit/Controller/RegisterControllerTest.php +++ b/tests/Unit/Controller/RegisterControllerTest.php @@ -291,8 +291,55 @@ public function testSubmitEmailFormResendPendingRequest(): void { self::assertSame('["registration.register.showVerificationForm",{"secret":"clientSecret"}]', $response->getRedirectURL()); } - public static function dataShowVerificationForm(): array { - return [ + public function testSubmitEmailFormSkipVerificationWithInvitation(): void { + $email = 'nextcloud@example.tld'; + $code = 'INVITE'; + + $this->registrationService + ->method('getRegistrationForEmail') + ->with($email) + ->willThrowException(new DoesNotExistException($email)); + + $invitation = new \OCA\Registration\Db\Invitation(); + $invitation->setCode($code); + $invitation->setSkipEmailVerification(true); + + $this->invitationService + ->expects($this->once()) + ->method('getByCode') + ->with($code) + ->willReturn($invitation); + + $this->registrationService + ->expects($this->once()) + ->method('createRegistration') + ->with($email, '', '', '', $this->anything()) + ->willReturnCallback(function ($email, $username, $password, $displayname, $invitationId) { + return Registration::fromParams([ + 'clientSecret' => 'clientSecret', + 'token' => 'token', + ]); + }); + + $this->mailService + ->expects($this->never()) + ->method('sendTokenByMail'); + + $this->urlGenerator + ->method('linkToRoute') + ->willReturnCallback(function () { + return json_encode(func_get_args()); + }); + + $controller = $this->getController(); + $response = $controller->submitEmailForm($email, $code); + + self::assertInstanceOf(RedirectResponse::class, $response); + /** @var RedirectResponse $response */ + self::assertSame('["registration.register.showUserForm",{"secret":"clientSecret","token":"token"}]', $response->getRedirectURL()); + } + + public static function dataShowVerificationForm(): array { return [ [''], ['The entered verification code is wrong'], ]; diff --git a/tests/Unit/Service/InvitationServiceTest.php b/tests/Unit/Service/InvitationServiceTest.php index c1cb55e2..c19cec28 100644 --- a/tests/Unit/Service/InvitationServiceTest.php +++ b/tests/Unit/Service/InvitationServiceTest.php @@ -65,6 +65,7 @@ private function createInvitation(array $overrides = []): Invitation { 'quota' => '5 GB', 'max_uses' => '', 'expires' => '', + 'skip_email_verification' => false, ], $overrides); return $this->service->createInvitation( @@ -74,6 +75,7 @@ private function createInvitation(array $overrides = []): Invitation { $params['quota'], $params['max_uses'] !== '' ? (int)$params['max_uses'] : null, $params['expires'] !== '' ? $params['expires'] : null, + $params['skip_email_verification'], ); } @@ -162,4 +164,12 @@ public function testGenerateLink(): void { $invitation = $this->createInvitation(); self::assertSame('https://example.com/apps/registration/invite/CODE', $this->service->generateLink($invitation)); } + + public function testSkipEmailVerificationFlag(): void { + $invitation = $this->createInvitation(['skip_email_verification' => true]); + self::assertTrue($invitation->getSkipEmailVerification()); + + $default = $this->createInvitation(['code' => 'DEFAULT']); + self::assertFalse($default->getSkipEmailVerification()); + } } From 492c60e395ff204167bafe8b69e3fa99090b5af1 Mon Sep 17 00:00:00 2001 From: nananankona <185404318+nananankona@users.noreply.github.com> Date: Sat, 15 Aug 2026 00:14:18 +0900 Subject: [PATCH 3/6] fix: serialize invitation dates as strings for the admin listThe datetime-typed expires/created_at fields can be returned as DateTimeobjects from the entity, which JSON-encode as objects and break thefrontend formatting (TypeError: b.replace is not a function). Normalizedates to Y-m-d H:i:s strings in the controller and harden isExpired andformatDate against non-string values. Signed-off-by: nananankona <185404318+nananankona@users.noreply.github.com> --- lib/Controller/InvitationController.php | 16 ++++++++++++++-- lib/Service/InvitationService.php | 7 ++++++- src/components/InvitationSettings.vue | 4 ++-- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/lib/Controller/InvitationController.php b/lib/Controller/InvitationController.php index d05d61d4..eda3d58b 100644 --- a/lib/Controller/InvitationController.php +++ b/lib/Controller/InvitationController.php @@ -86,10 +86,22 @@ private function serialize(Invitation $invitation): array { 'quota' => $invitation->getQuota(), 'max_uses' => $invitation->getMaxUses(), 'uses' => $invitation->getUses(), - 'expires' => $invitation->getExpires(), - 'created_at' => $invitation->getCreatedAt(), + 'expires' => $this->formatDateTime($invitation->getExpires()), + 'created_at' => $this->formatDateTime($invitation->getCreatedAt()), 'skip_email_verification' => $invitation->getSkipEmailVerification(), 'link' => $this->invitationService->generateLink($invitation), ]; } + + private function formatDateTime(mixed $value): ?string { + if ($value === null) { + return null; + } + + if ($value instanceof \DateTimeInterface) { + return $value->format('Y-m-d H:i:s'); + } + + return (string)$value; + } } \ No newline at end of file diff --git a/lib/Service/InvitationService.php b/lib/Service/InvitationService.php index 23361ec8..6230c168 100644 --- a/lib/Service/InvitationService.php +++ b/lib/Service/InvitationService.php @@ -110,7 +110,12 @@ public function isExpired(Invitation $invitation): bool { return false; } - $expireTimestamp = strtotime($expires); + if ($expires instanceof \DateTimeInterface) { + $expireTimestamp = $expires->getTimestamp(); + } else { + $expireTimestamp = strtotime((string)$expires); + } + return $expireTimestamp !== false && $expireTimestamp < $this->timeFactory->getTime(); } diff --git a/src/components/InvitationSettings.vue b/src/components/InvitationSettings.vue index 1a4f5e45..ca58def5 100644 --- a/src/components/InvitationSettings.vue +++ b/src/components/InvitationSettings.vue @@ -229,10 +229,10 @@ function restrictionLabel(invitation: Invitation): string { /** * Make a datetime string human friendly * - * @param value datetime string + * @param value datetime value */ function formatDate(value: string): string { - return value.replace('T', ' ') + return (value ?? '').replace('T', ' ') } /** From a48f74985f1c285dace83b6a585e33824953d4b1 Mon Sep 17 00:00:00 2001 From: nananankona <185404318+nananankona@users.noreply.github.com> Date: Sat, 15 Aug 2026 00:39:20 +0900 Subject: [PATCH 4/6] fix: default skipEmailVerification to false and guard null valuesThe skip_email_verification flag can return null from the entity when thecolumn is missing or unset, which broke the bool return type ofinvitationSkipsVerification() with a TypeError on invite links. Defaultthe flag to false in the entity constructor and compare with === trueinstead of relying on the raw value. Signed-off-by: nananankona <185404318+nananankona@users.noreply.github.com> --- lib/Controller/InvitationController.php | 2 +- lib/Controller/RegisterController.php | 2 +- lib/Db/Invitation.php | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/Controller/InvitationController.php b/lib/Controller/InvitationController.php index eda3d58b..2d60a156 100644 --- a/lib/Controller/InvitationController.php +++ b/lib/Controller/InvitationController.php @@ -88,7 +88,7 @@ private function serialize(Invitation $invitation): array { 'uses' => $invitation->getUses(), 'expires' => $this->formatDateTime($invitation->getExpires()), 'created_at' => $this->formatDateTime($invitation->getCreatedAt()), - 'skip_email_verification' => $invitation->getSkipEmailVerification(), + 'skip_email_verification' => $invitation->getSkipEmailVerification() === true, 'link' => $this->invitationService->generateLink($invitation), ]; } diff --git a/lib/Controller/RegisterController.php b/lib/Controller/RegisterController.php index 9d54bb8d..004aa4ca 100644 --- a/lib/Controller/RegisterController.php +++ b/lib/Controller/RegisterController.php @@ -419,6 +419,6 @@ protected function invitationSkipsVerification(string $code): bool { return false; } - return $invitation->getSkipEmailVerification(); + return $invitation->getSkipEmailVerification() === true; } } diff --git a/lib/Db/Invitation.php b/lib/Db/Invitation.php index 73e9cce6..8bdc8313 100644 --- a/lib/Db/Invitation.php +++ b/lib/Db/Invitation.php @@ -56,5 +56,6 @@ public function __construct() { $this->addType('createdBy', 'string'); $this->addType('createdAt', 'datetime'); $this->addType('skipEmailVerification', 'boolean'); + $this->skipEmailVerification = false; } } \ No newline at end of file From 22f13728b0a22efb891443251e8406d313facbca Mon Sep 17 00:00:00 2001 From: nananankona <185404318+nananankona@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:44:54 +0900 Subject: [PATCH 5/6] feat: add per-invitation administrator approval bypass Signed-off-by: nananankona <185404318+nananankona@users.noreply.github.com> --- lib/Controller/InvitationController.php | 6 ++- lib/Db/Invitation.php | 9 +++- .../Version0008Date20260815000000.php | 41 +++++++++++++++++++ lib/Service/InvitationService.php | 4 +- lib/Service/RegistrationService.php | 5 ++- src/components/InvitationSettings.vue | 12 ++++++ tests/Unit/Service/InvitationServiceTest.php | 10 +++++ 7 files changed, 80 insertions(+), 7 deletions(-) create mode 100644 lib/Migration/Version0008Date20260815000000.php diff --git a/lib/Controller/InvitationController.php b/lib/Controller/InvitationController.php index 2d60a156..005674f3 100644 --- a/lib/Controller/InvitationController.php +++ b/lib/Controller/InvitationController.php @@ -40,7 +40,7 @@ public function index(): DataResponse { } #[AdminRequired] - public function create(string $code = '', string $email = '', string $domain = '', string $quota = '', string $max_uses = '', string $expires = '', string $skip_email_verification = ''): DataResponse { + public function create(string $code = '', string $email = '', string $domain = '', string $quota = '', string $max_uses = '', string $expires = '', string $skip_email_verification = '', string $skip_admin_approval = ''): DataResponse { if ($code === '') { $code = $this->invitationService->generateCode(); } @@ -53,7 +53,8 @@ public function create(string $code = '', string $email = '', string $domain = ' $quota, $max_uses !== '' ? (int)$max_uses : null, $expires !== '' ? $expires : null, - $skip_email_verification === 'true' || $skip_email_verification === '1' + $skip_email_verification === 'true' || $skip_email_verification === '1', + $skip_admin_approval === 'true' || $skip_admin_approval === '1' ); } catch (RegistrationException $e) { return new DataResponse( @@ -89,6 +90,7 @@ private function serialize(Invitation $invitation): array { 'expires' => $this->formatDateTime($invitation->getExpires()), 'created_at' => $this->formatDateTime($invitation->getCreatedAt()), 'skip_email_verification' => $invitation->getSkipEmailVerification() === true, + 'skip_admin_approval' => $invitation->getSkipAdminApproval() === true, 'link' => $this->invitationService->generateLink($invitation), ]; } diff --git a/lib/Db/Invitation.php b/lib/Db/Invitation.php index 8bdc8313..3f3717ca 100644 --- a/lib/Db/Invitation.php +++ b/lib/Db/Invitation.php @@ -29,8 +29,10 @@ * @method void setCreatedBy(?string $createdBy) * @method string getCreatedAt() * @method void setCreatedAt(string $createdAt) - * @method bool getSkipEmailVerification() - * @method void setSkipEmailVerification(bool $skipEmailVerification) + * @method bool getSkipEmailVerification() + * @method void setSkipEmailVerification(bool $skipEmailVerification) + * @method bool getSkipAdminApproval() + * @method void setSkipAdminApproval(bool $skipAdminApproval) */ class Invitation extends Entity { public $id; @@ -44,6 +46,7 @@ class Invitation extends Entity { protected $createdBy; protected $createdAt; protected $skipEmailVerification; + protected $skipAdminApproval; public function __construct() { $this->addType('code', 'string'); @@ -56,6 +59,8 @@ public function __construct() { $this->addType('createdBy', 'string'); $this->addType('createdAt', 'datetime'); $this->addType('skipEmailVerification', 'boolean'); + $this->addType('skipAdminApproval', 'boolean'); $this->skipEmailVerification = false; + $this->skipAdminApproval = false; } } \ No newline at end of file diff --git a/lib/Migration/Version0008Date20260815000000.php b/lib/Migration/Version0008Date20260815000000.php new file mode 100644 index 00000000..4565ed91 --- /dev/null +++ b/lib/Migration/Version0008Date20260815000000.php @@ -0,0 +1,41 @@ +hasTable('registration_invitation')) { + $table = $schema->getTable('registration_invitation'); + if (!$table->hasColumn('skip_admin_approval')) { + $table->addColumn('skip_admin_approval', Types::BOOLEAN, [ + 'notnull' => true, + 'default' => false, + ]); + } + } + + return $schema; + } +} \ No newline at end of file diff --git a/lib/Service/InvitationService.php b/lib/Service/InvitationService.php index 6230c168..882eb988 100644 --- a/lib/Service/InvitationService.php +++ b/lib/Service/InvitationService.php @@ -33,10 +33,11 @@ public function __construct( * @param int|null $maxUses * @param string|null $expires (Y-m-d H:i:s or null) * @param bool $skipEmailVerification + * @param bool $skipAdminApproval * @return Invitation * @throws RegistrationException */ - public function createInvitation(string $code, ?string $email, ?string $domain, ?string $quota, ?int $maxUses, ?string $expires, bool $skipEmailVerification = false): Invitation { + public function createInvitation(string $code, ?string $email, ?string $domain, ?string $quota, ?int $maxUses, ?string $expires, bool $skipEmailVerification = false, bool $skipAdminApproval = false): Invitation { $code = trim($code); if ($code === '') { throw new RegistrationException($this->l10n->t('Please provide an invitation code.')); @@ -61,6 +62,7 @@ public function createInvitation(string $code, ?string $email, ?string $domain, $invitation->setUses(0); $invitation->setExpires($expires); $invitation->setSkipEmailVerification($skipEmailVerification); + $invitation->setSkipAdminApproval($skipAdminApproval); return $this->invitationMapper->insert($invitation); } diff --git a/lib/Service/RegistrationService.php b/lib/Service/RegistrationService.php index 920e79e9..ed06a32c 100644 --- a/lib/Service/RegistrationService.php +++ b/lib/Service/RegistrationService.php @@ -394,8 +394,9 @@ public function createAccount(Registration $registration, ?string $loginName = n $groupId = ''; } - // disable user if this is requested by config - $adminApprovalRequired = $this->appConfig->getAppValueBool('admin_approval_required'); + // disable user if this is requested by config (unless the invitation opts out) + $adminApprovalRequired = $this->appConfig->getAppValueBool('admin_approval_required') + && !($invitation !== null && $invitation->getSkipAdminApproval() === true); if ($adminApprovalRequired) { $user->setEnabled(false); $this->config->setUserValue($userId, Application::APP_ID, 'send_welcome_mail_on_enable', 'yes'); diff --git a/src/components/InvitationSettings.vue b/src/components/InvitationSettings.vue index ca58def5..1dcc4f7c 100644 --- a/src/components/InvitationSettings.vue +++ b/src/components/InvitationSettings.vue @@ -74,6 +74,14 @@

{{ t('registration', 'If enabled, the email address does not need to be verified and the user can create the account right after entering their email address.') }}

+ + {{ t('registration', 'Skip administrator approval') }} + +

{{ t('registration', 'If enabled, the account is enabled immediately even when administrator approval is required.') }}

+
('error') const showDialog = ref(false) @@ -273,6 +283,7 @@ async function createInvitation() { max_uses: maxUses.value, expires: expires.value ? expires.value.replace('T', ' ') : '', skip_email_verification: skipEmailVerification.value ? 'true' : '', + skip_admin_approval: skipAdminApproval.value ? 'true' : '', }) if (response.data?.status === 'error') { @@ -288,6 +299,7 @@ async function createInvitation() { maxUses.value = '' expires.value = '' skipEmailVerification.value = false + skipAdminApproval.value = false selectedInvitation.value = response.data showDialog.value = true } diff --git a/tests/Unit/Service/InvitationServiceTest.php b/tests/Unit/Service/InvitationServiceTest.php index c19cec28..48528ba7 100644 --- a/tests/Unit/Service/InvitationServiceTest.php +++ b/tests/Unit/Service/InvitationServiceTest.php @@ -66,6 +66,7 @@ private function createInvitation(array $overrides = []): Invitation { 'max_uses' => '', 'expires' => '', 'skip_email_verification' => false, + 'skip_admin_approval' => false, ], $overrides); return $this->service->createInvitation( @@ -76,6 +77,7 @@ private function createInvitation(array $overrides = []): Invitation { $params['max_uses'] !== '' ? (int)$params['max_uses'] : null, $params['expires'] !== '' ? $params['expires'] : null, $params['skip_email_verification'], + $params['skip_admin_approval'], ); } @@ -172,4 +174,12 @@ public function testSkipEmailVerificationFlag(): void { $default = $this->createInvitation(['code' => 'DEFAULT']); self::assertFalse($default->getSkipEmailVerification()); } + + public function testSkipAdminApprovalFlag(): void { + $invitation = $this->createInvitation(['skip_admin_approval' => true]); + self::assertTrue($invitation->getSkipAdminApproval()); + + $default = $this->createInvitation(['code' => 'DEFAULT']); + self::assertFalse($default->getSkipAdminApproval()); + } } From 8e7e8476690ee562b51cbadfea5ec1bfba0f4ef6 Mon Sep 17 00:00:00 2001 From: nananankona <185404318+nananankona@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:58:07 +0900 Subject: [PATCH 6/6] fix: portable uses increment and reject malformed emails in domain check The backtick-quoted uses + 1 expression breaks on PostgreSQL and Oracle, which do not accept backticks as identifier quotes. Use the unquoted column reference instead (uses is not a reserved word). Also guard domainMatches() against email addresses without an @ sign, which previously raised an undefined key warning and a TypeError in the wildcard branch instead of a clean RegistrationException. Signed-off-by: nananankona <185404318+nananankona@users.noreply.github.com> --- lib/Db/InvitationMapper.php | 2 +- lib/Service/InvitationService.php | 6 +++++- tests/Unit/Service/InvitationServiceTest.php | 7 +++++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/lib/Db/InvitationMapper.php b/lib/Db/InvitationMapper.php index 0cf54e87..c7a9ffe2 100644 --- a/lib/Db/InvitationMapper.php +++ b/lib/Db/InvitationMapper.php @@ -91,7 +91,7 @@ public function insert(Entity $entity): Entity { public function incrementUses(Invitation $invitation): void { $query = $this->db->getQueryBuilder(); $query->update($this->getTableName()) - ->set('uses', $query->createFunction('`uses` + 1')) + ->set('uses', $query->createFunction('uses + 1')) ->where($query->expr()->eq('id', $query->createNamedParameter($invitation->getId(), IQueryBuilder::PARAM_INT))) ->executeStatement(); } diff --git a/lib/Service/InvitationService.php b/lib/Service/InvitationService.php index 882eb988..7be7e787 100644 --- a/lib/Service/InvitationService.php +++ b/lib/Service/InvitationService.php @@ -168,7 +168,11 @@ public function incrementUses(Invitation $invitation): void { } private function domainMatches(string $email, string $allowedDomain): bool { - [,$mailDomain] = explode('@', strtolower($email), 2); + $parts = explode('@', strtolower($email), 2); + if (count($parts) !== 2 || $parts[1] === '') { + return false; + } + $mailDomain = $parts[1]; if (str_contains($allowedDomain, '*')) { $regexDomain = preg_quote($allowedDomain, '\\'); diff --git a/tests/Unit/Service/InvitationServiceTest.php b/tests/Unit/Service/InvitationServiceTest.php index 48528ba7..c1f8154b 100644 --- a/tests/Unit/Service/InvitationServiceTest.php +++ b/tests/Unit/Service/InvitationServiceTest.php @@ -139,6 +139,13 @@ public function testValidateDomainRestriction(): void { $this->service->validate($invitation, 'foo@example.tld'); } + public function testValidateDomainRestrictionInvalidEmail(): void { + $invitation = $this->createInvitation(['domain' => '*.example.com']); + + $this->expectException(RegistrationException::class); + $this->service->validate($invitation, 'not-an-email-address'); + } + public function testValidateExpired(): void { $invitation = $this->createInvitation(['expires' => '2000-01-01 00:00:00']);