From 146b7dd6e3b4cd8a3c0e8939d566c97858deebae Mon Sep 17 00:00:00 2001 From: philipp lang Date: Fri, 5 Sep 2025 15:49:03 +0200 Subject: [PATCH] Add frontend for later link --- .../Actions/FormGenerateLaterlinkAction.php | 28 +++++++++++++ app/Form/Actions/RegisterAction.php | 15 +++++-- app/Form/Resources/FormResource.php | 1 + package-lock.json | 42 +++++++++++++++++++ package.json | 1 + resources/img/svg/externallink.svg | 4 ++ resources/js/views/form/Index.vue | 9 ++++ routes/web.php | 2 + tests/EndToEnd/Form/FormIndexActionTest.php | 1 + .../Form/FormGenerateLaterLinkActionTest.php | 35 ++++++++++++++++ tests/Feature/Form/FormRegisterActionTest.php | 19 ++++++++- 11 files changed, 153 insertions(+), 4 deletions(-) create mode 100644 app/Form/Actions/FormGenerateLaterlinkAction.php create mode 100644 resources/img/svg/externallink.svg create mode 100644 tests/Feature/Form/FormGenerateLaterLinkActionTest.php 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); +});