diff --git a/app/Form/Actions/FormGenerateLaterlinkAction.php b/app/Form/Actions/FormGenerateLaterlinkAction.php
new file mode 100644
index 00000000..51b6bc69
--- /dev/null
+++ b/app/Form/Actions/FormGenerateLaterlinkAction.php
@@ -0,0 +1,28 @@
+registerUrl)->replace('{slug}', $form->slug)->toString();
+ $laterId = str()->uuid()->toString();
+ $laterUrl = URL::signedRoute('form.register', ['form' => $form, 'later' => '1', 'id' => $laterId]);
+ $urlParts = parse_url($laterUrl);
+
+ Cache::remember('later_'.$laterId, 2592000, fn () => $form->id); // Link ist 40 Tage gültig
+
+ return response()->json([
+ 'url' => $registerUrl.'?'.data_get($urlParts, 'query')
+ ]);
+ }
+}
diff --git a/app/Form/Actions/RegisterAction.php b/app/Form/Actions/RegisterAction.php
index 4e5b1a82..a922f7d2 100644
--- a/app/Form/Actions/RegisterAction.php
+++ b/app/Form/Actions/RegisterAction.php
@@ -7,6 +7,7 @@ use App\Form\Models\Form;
use App\Form\Models\Participant;
use App\Member\Member;
use Illuminate\Http\JsonResponse;
+use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
@@ -75,16 +76,20 @@ class RegisterAction
public function asController(ActionRequest $request, Form $form): JsonResponse
{
- if (!$form->canRegister() && !$this->isRegisteringLater($request)) {
+ if (!$form->canRegister() && !$this->isRegisteringLater($request, $form)) {
throw ValidationException::withMessages(['event' => 'Anmeldung zzt nicht möglich.']);
}
$participant = $this->handle($form, $request->validated());
+ if ($this->isRegisteringLater($request, $form)) {
+ Cache::forget('later_'.request('id'));
+ }
+
return response()->json($participant);
}
- public function isRegisteringLater(ActionRequest $request): bool {
+ public function isRegisteringLater(ActionRequest $request, Form $form): bool {
if (!is_array($request->query())) {
return false;
}
@@ -95,6 +100,10 @@ class RegisterAction
'signature' => 'required|string',
]);
- return URL::hasValidSignature($request) && $validator->passes();
+ if (!URL::hasValidSignature($request) || $validator->fails()) {
+ return false;
+ }
+
+ return Cache::get('later_'.data_get($validator->validated(), 'id')) === $form->id;
}
}
diff --git a/app/Form/Resources/FormResource.php b/app/Form/Resources/FormResource.php
index 5a0f3abc..36d06ca8 100644
--- a/app/Form/Resources/FormResource.php
+++ b/app/Form/Resources/FormResource.php
@@ -70,6 +70,7 @@ class FormResource extends JsonResource
'export' => route('form.export', $this->getModel()),
'copy' => route('form.copy', $this->getModel()),
'contribution' => route('form.contribution', $this->getModel()),
+ 'laterlink' => route('form.laterlink', $this->getModel()),
]
];
}
diff --git a/package-lock.json b/package-lock.json
index 79f50b29..7ce6ec35 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -30,6 +30,7 @@
"uuid": "^11.1.0",
"vite": "^4.5.2",
"vue": "^3.3.4",
+ "vue-clipboard3": "^2.0.0",
"vue-toastification": "^2.0.0-rc.5",
"vuedraggable": "^4.1.0",
"wnumb": "^1.2.0"
@@ -1872,6 +1873,16 @@
"node": ">= 6"
}
},
+ "node_modules/clipboard": {
+ "version": "2.0.11",
+ "resolved": "https://registry.npmjs.org/clipboard/-/clipboard-2.0.11.tgz",
+ "integrity": "sha512-C+0bbOqkezLIsmWSvlsXS0Q0bmkugu7jcfMIACB+RDEntIzQIkdr148we28AfSloQLRdZlYL/QYyrq05j/3Faw==",
+ "dependencies": {
+ "good-listener": "^1.2.2",
+ "select": "^1.1.2",
+ "tiny-emitter": "^2.0.0"
+ }
+ },
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
@@ -2140,6 +2151,11 @@
"node": ">=0.4.0"
}
},
+ "node_modules/delegate": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/delegate/-/delegate-3.2.0.tgz",
+ "integrity": "sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw=="
+ },
"node_modules/didyoumean": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
@@ -2848,6 +2864,14 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/good-listener": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/good-listener/-/good-listener-1.2.2.tgz",
+ "integrity": "sha512-goW1b+d9q/HIwbVYZzZ6SsTr4IgE+WA44A0GmPIQstuOrgsFcT7VEJ48nmr9GaRtNu0XTKacFLGnBPAM6Afouw==",
+ "dependencies": {
+ "delegate": "^3.1.2"
+ }
+ },
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -4075,6 +4099,11 @@
"node": ">=10"
}
},
+ "node_modules/select": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/select/-/select-1.1.2.tgz",
+ "integrity": "sha512-OwpTSOfy6xSs1+pwcNrv0RBMOzI39Lp3qQKUTPVVPRjCdNa5JH/oPRiqsesIskK8TVgmRiHwO4KXlV2Li9dANA=="
+ },
"node_modules/semver": {
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
@@ -4545,6 +4574,11 @@
"node": ">=0.8"
}
},
+ "node_modules/tiny-emitter": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz",
+ "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q=="
+ },
"node_modules/tinyglobby": {
"version": "0.2.14",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
@@ -4952,6 +4986,14 @@
"vue": "^3.0.0 || ^2.0.0"
}
},
+ "node_modules/vue-clipboard3": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/vue-clipboard3/-/vue-clipboard3-2.0.0.tgz",
+ "integrity": "sha512-Q9S7dzWGax7LN5iiSPcu/K1GGm2gcBBlYwmMsUc5/16N6w90cbKow3FnPmPs95sungns4yvd9/+JhbAznECS2A==",
+ "dependencies": {
+ "clipboard": "^2.0.6"
+ }
+ },
"node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
diff --git a/package.json b/package.json
index 17c10520..9acdb3c0 100644
--- a/package.json
+++ b/package.json
@@ -47,6 +47,7 @@
"uuid": "^11.1.0",
"vite": "^4.5.2",
"vue": "^3.3.4",
+ "vue-clipboard3": "^2.0.0",
"vue-toastification": "^2.0.0-rc.5",
"vuedraggable": "^4.1.0",
"wnumb": "^1.2.0"
diff --git a/resources/img/svg/externallink.svg b/resources/img/svg/externallink.svg
new file mode 100644
index 00000000..f45becbc
--- /dev/null
+++ b/resources/img/svg/externallink.svg
@@ -0,0 +1,4 @@
+
diff --git a/resources/js/views/form/Index.vue b/resources/js/views/form/Index.vue
index d293cae7..950b3c51 100644
--- a/resources/js/views/form/Index.vue
+++ b/resources/js/views/form/Index.vue
@@ -179,6 +179,7 @@
+
@@ -203,12 +204,14 @@ import ConditionsForm from './ConditionsForm.vue';
import { useToast } from 'vue-toastification';
import useSwal from '@/stores/swalStore.ts';
import useDownloads from '@/composables/useDownloads.ts';
+import useClipboard from 'vue-clipboard3';
const props = defineProps(indexProps);
const { meta, data, reloadPage, reload, create, single, edit, cancel, submit, remove, getFilter, setFilter } = useIndex(props.data, 'form');
const axios = inject('axios');
const toast = useToast();
const {download} = useDownloads();
+const {toClipboard} = useClipboard();
const showing = ref(null);
const fileSettingPopup = ref(null);
@@ -266,6 +269,12 @@ function setTemplate(template) {
single.value.mail_bottom = template.mail_bottom;
}
+async function copyLaterLink(form) {
+ const response = await axios.get(form.links.laterlink);
+ await toClipboard(response.data.url);
+ toast.success('Link in Zwischenablage kopiert');
+}
+
async function saveFileConditions(conditions) {
await axios.patch(`/mediaupload/${fileSettingPopup.value.id}`, {
properties: {
diff --git a/routes/web.php b/routes/web.php
index 65846888..9ec46127 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -24,6 +24,7 @@ use App\Fileshare\Actions\ListFilesAction;
use App\Form\Actions\ExportAction as ActionsExportAction;
use App\Form\Actions\FormCopyAction;
use App\Form\Actions\FormDestroyAction;
+use App\Form\Actions\FormGenerateLaterlinkAction;
use App\Form\Actions\FormIndexAction;
use App\Group\Actions\GroupBulkstoreAction;
use App\Group\Actions\GroupIndexAction;
@@ -180,6 +181,7 @@ Route::group(['middleware' => 'auth:web'], function (): void {
Route::post('/form/{form}/participant', ParticipantStoreAction::class)->name('form.participant.store');
Route::post('/form/{form}/copy', FormCopyAction::class)->name('form.copy');
Route::get('/form/{form}/contribution', GenerateContributionAction::class)->name('form.contribution');
+ Route::get('/form/{form}/laterlink', FormGenerateLaterlinkAction::class)->name('form.laterlink');
// ------------------------------------ fileshare -----------------------------------
Route::post('/fileshare', FileshareStoreAction::class)->name('fileshare.store');
diff --git a/tests/EndToEnd/Form/FormIndexActionTest.php b/tests/EndToEnd/Form/FormIndexActionTest.php
index 90156f10..70c3dd93 100644
--- a/tests/EndToEnd/Form/FormIndexActionTest.php
+++ b/tests/EndToEnd/Form/FormIndexActionTest.php
@@ -70,6 +70,7 @@ it('testItDisplaysForms', function () {
->assertInertiaPath('data.data.0.links.participant_index', route('form.participant.index', ['form' => $form]))
->assertInertiaPath('data.data.0.links.export', route('form.export', ['form' => $form]))
->assertInertiaPath('data.data.0.links.contribution', route('form.contribution', ['form' => $form]))
+ ->assertInertiaPath('data.data.0.links.laterlink', route('form.laterlink', ['form' => $form]))
->assertInertiaPath('data.meta.links.store', route('form.store'))
->assertInertiaPath('data.meta.links.formtemplate_index', route('formtemplate.index'))
->assertInertiaPath('data.meta.default.name', '')
diff --git a/tests/Feature/Form/FormGenerateLaterLinkActionTest.php b/tests/Feature/Form/FormGenerateLaterLinkActionTest.php
new file mode 100644
index 00000000..484e9e8c
--- /dev/null
+++ b/tests/Feature/Form/FormGenerateLaterLinkActionTest.php
@@ -0,0 +1,35 @@
+setUpForm();
+ Mail::fake();
+});
+
+it('generates a later link', function () {
+ $this->login()->loginNami()->withoutExceptionHandling();
+ app(FormSettings::class)->fill(['registerUrl' => 'https://example.com/register/{slug}'])->save();
+ $form = Form::factory()->name('fff')->create();
+
+ $url = $this->get(route('form.laterlink', ['form' => $form]))->json('url');
+ test()->assertNotNull($url);
+ $this->assertTrue(str($url)->startsWith('https://example.com/register/fff'));
+
+ $query = data_get(parse_url($url), 'query');
+ parse_str($query, $queryParts);
+ $this->assertEquals('1', $queryParts['later']);
+
+ $this->assertEquals($form->id, Cache::get('later_'.$queryParts['id']));
+});
+
diff --git a/tests/Feature/Form/FormRegisterActionTest.php b/tests/Feature/Form/FormRegisterActionTest.php
index d4ea32e6..de56eb67 100644
--- a/tests/Feature/Form/FormRegisterActionTest.php
+++ b/tests/Feature/Form/FormRegisterActionTest.php
@@ -11,6 +11,7 @@ use App\Group\Enums\Level;
use Carbon\Carbon;
use Database\Factories\Member\MemberFactory;
use Illuminate\Foundation\Testing\DatabaseTransactions;
+use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Mail;
use Tests\Lib\CreatesFormFields;
@@ -738,12 +739,15 @@ it('testItSetsRegionIfMemberIsDirectRegionMember', function () {
it('registers via later link', function () {
$this->login()->loginNami();
+ $laterId = str()->uuid()->toString();
$form = Form::factory()->fields([])
->registrationUntil(now()->subDay())
->create();
+ Cache::set('later_'.$laterId, $form->id);
- $this->registerLater($form, [], str()->uuid())->assertOk();
+ $this->registerLater($form, [], $laterId)->assertOk();
$this->assertDatabaseCount('participants', 1);
+ $this->assertNull(Cache::get('later_'.$laterId));
});
it('checks signature of later link', function () {
@@ -755,3 +759,16 @@ it('checks signature of later link', function () {
$this->registerLaterWithWrongSignature($form, [], str()->uuid())->assertStatus(422);
$this->assertDatabaseCount('participants', 0);
});
+
+it('checks if later links is from current form', function () {
+ $this->login()->loginNami();
+ $foreignForm = Form::factory()->create();
+ $form = Form::factory()->fields([])
+ ->registrationUntil(now()->subDay())
+ ->create();
+ $laterId = str()->uuid()->toString();
+ Cache::set('later_'.$laterId, $foreignForm->id);
+
+ $this->registerLater($form, [], $laterId)->assertStatus(422);
+ $this->assertDatabaseCount('participants', 0);
+});