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 @@
+
+
+
+
+
+
+ {{ listMessage }}
+
+
+
+
+
+
+ | {{ 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', 'Copy link') }}
+
+ |
+
+
+ {{ t('registration', 'Delete') }}
+
+ |
+
+
+
+
+
+
+
+
+
+
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']);
|