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 01/12] 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', 'If enabled, users have to enter a valid invitation code. Administrators can create invitation links and codes below.') }}
+| {{ t('registration', 'Code') }} | +{{ t('registration', 'Restriction') }} | +{{ t('registration', 'Quota') }} | +{{ t('registration', 'Uses') }} | +{{ t('registration', 'Expires') }} | +{{ t('registration', 'Link') }} | ++ |
|---|---|---|---|---|---|---|
{{ invitation.code }} |
+ {{ restrictionLabel(invitation) }} | +{{ invitation.quota || '—' }} | +{{ invitation.uses }}{{ invitation.max_uses ? ` / ${invitation.max_uses}` : '' }} | +{{ invitation.expires ? formatDate(invitation.expires) : '—' }} | +
+ |
+
+ |
+
{{ 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', 'Share the link or the code below with the person you want to invite.') }} +
+{{ 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', 'If enabled, the account is enabled immediately even when administrator approval is required.') }}
+