feat: integrate reCAPTCHA for contact form validation and submission WIP #8

This commit is contained in:
Marek Lesko
2025-10-31 15:57:16 +00:00
parent 426b4c55fc
commit 7b22f2d237
5 changed files with 228 additions and 79 deletions

View File

@@ -10,6 +10,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Google.Cloud.RecaptchaEnterprise.V1" Version="2.18.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.18" NoWarn="NU1605" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.0.18" NoWarn="NU1605" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.18">

View File

@@ -0,0 +1,62 @@
using System;
using Google.Api.Gax.ResourceNames;
using Google.Cloud.RecaptchaEnterprise.V1;
namespace Api.Helpers
{
public static class ReCaptchaAssessment
{
// Checks the supplied token with Recaptcha Enterprise and returns true if valid.
// Outputs a short reason when false (invalid reason or exception message).
public static bool CheckToken(string token, out string reason, string projectId = "webserverfarm", string recaptchaKey = "6Lf6df0rAAAAAMXcAx1umneXl1QJo9rTrflpWCvB")
{
reason = string.Empty;
if (string.IsNullOrWhiteSpace(token))
{
reason = "empty-token";
return false;
}
try
{
var client = RecaptchaEnterpriseServiceClient.Create();
var request = new CreateAssessmentRequest
{
Parent = $"projects/{projectId}",
Assessment = new Assessment
{
Event = new Event
{
SiteKey = recaptchaKey,
Token = token
}
}
};
var response = client.CreateAssessment(request);
if (response?.TokenProperties == null)
{
reason = "missing-token-properties";
return false;
}
if (!response.TokenProperties.Valid)
{
reason = response.TokenProperties.InvalidReason.ToString();
return false;
}
reason = "valid";
return true;
}
catch (Exception ex)
{
reason = ex.Message;
return false;
}
}
}
}

View File

@@ -11,90 +11,98 @@
</div>
<div class="form-content">
<form>
<div class="container">
<!-- Map stays inside, but will visually float out -->
<div class="map-container overflow-hidden rounded">
<iframe class="overflow-hidden rounded"
src="https://maps.google.com/maps?q=48.72388977142655,21.247448657208842&z=15&output=embed"
width="100%" height="400" style="border: 0" allowfullscreen="" loading="lazy"
referrerpolicy="no-referrer-when-downgrade"></iframe>
</div>
<div class="spacing-top-bottom">
<div class="row">
<div class="col-md-8 m-b-30">
<div class="row ">
<div class="col-md-6">
<!-- input -->
<mat-label class="f-s-14 f-w-600 m-b-8 d-block">Meno<span>*</span></mat-label>
<mat-form-field appearance="outline" class="w-100" color="primary">
<input matInput type="text" placeholder="Meno" />
</mat-form-field>
<!-- input -->
<mat-label class="f-s-14 f-w-600 m-b-8 d-block">Telefónne číslo<span>*</span></mat-label>
<mat-form-field appearance="outline" class="w-100" color="primary">
<input matInput type="tel" placeholder="xxxx xxx xxxx" />
</mat-form-field>
</div>
<div class="col-md-6">
<!-- input -->
<mat-label class="f-s-14 f-w-600 m-b-8 d-block">Priezvisko<span>*</span></mat-label>
<mat-form-field appearance="outline" class="w-100" color="primary">
<input matInput type="text" placeholder="Priezvisko" />
</mat-form-field>
<!-- input -->
<mat-label class="f-s-14 f-w-600 m-b-8 d-block">Email<span>*</span></mat-label>
<mat-form-field appearance="outline" class="w-100" color="primary">
<input matInput type="mail" placeholder="Emailová adresa" />
</mat-form-field>
</div>
<div class="container">
<!-- Map stays inside, but will visually float out -->
<div class="map-container overflow-hidden rounded">
<iframe class="overflow-hidden rounded"
src="https://maps.google.com/maps?q=48.72388977142655,21.247448657208842&z=15&output=embed" width="100%"
height="400" style="border: 0" allowfullscreen="" loading="lazy"
referrerpolicy="no-referrer-when-downgrade"></iframe>
</div>
<div class="spacing-top-bottom">
<div class="row">
<div class="col-md-8 m-b-30">
<div class="row ">
<div class="col-md-6">
<!-- input -->
<mat-label class="f-s-14 f-w-600 m-b-8 d-block">Meno<span>*</span></mat-label>
<mat-form-field appearance="outline" class="w-100" color="primary">
<input matInput type="text" placeholder="Meno" [(ngModel)]="model.name" />
</mat-form-field>
<!-- input -->
<mat-label class="f-s-14 f-w-600 m-b-8 d-block">Telefónne číslo<span>*</span></mat-label>
<mat-form-field appearance="outline" class="w-100" color="primary">
<input matInput type="tel" placeholder="xxxx xxx xxxx" [(ngModel)]="model.phone" />
</mat-form-field>
</div>
<div class="col-md-6">
<!-- input -->
<mat-label class="f-s-14 f-w-600 m-b-8 d-block">Priezvisko<span>*</span></mat-label>
<mat-form-field appearance="outline" class="w-100" color="primary">
<input matInput type="text" placeholder="Priezvisko" [(ngModel)]="model.surname" />
</mat-form-field>
<!-- input -->
<mat-label class="f-s-14 f-w-600 m-b-8 d-block">Email<span>*</span></mat-label>
<mat-form-field appearance="outline" class="w-100" color="primary">
<input matInput type="mail" placeholder="Emailová adresa" [(ngModel)]="model.email" />
</mat-form-field>
</div>
</div>
<div class="row">
<div class="col-lg-12">
<mat-label class="f-s-14 f-w-600 m-b-8 d-block">Dopyt týkajúci sa<span>*</span>
</mat-label>
<mat-form-field appearance="outline" class="w-100">
<mat-select value="General Enquiry">
<mat-option value="General Enquiry">Všeobecný dopyt</mat-option>
<mat-option value="Registration Enquiry">Žiadosť o registráciu</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="row">
<div class="col-lg-12">
<mat-label class="f-s-14 f-w-600 m-b-8 d-block">Dopyt týkajúci sa<span>*</span>
</mat-label>
<mat-form-field appearance="outline" class="w-100">
<mat-select value="General Enquiry" [(ngModel)]="model.subject">
<mat-option value="General Enquiry">Všeobecný dopyt</mat-option>
<mat-option value="Registration Enquiry">Žiadosť o registráciu</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="row">
<div class="col-lg-12">
<mat-label class="f-s-14 f-w-600 m-b-8 d-block">Správa</mat-label>
<mat-form-field appearance="outline" class="w-100" color="primary">
<textarea matInput rows="5" placeholder="Napíšte svoju správu sem"></textarea>
</mat-form-field>
</div>
</div>
<div class="row">
<div class="col-lg-12">
<mat-label class="f-s-14 f-w-600 m-b-8 d-block">Správa</mat-label>
<mat-form-field appearance="outline" class="w-100" color="primary">
<textarea matInput rows="5" placeholder="Napíšte svoju správu sem" [(ngModel)]="model.message"
name="message"></textarea>
</mat-form-field>
</div>
<button mat-flat-button>Odoslať</button>
</div>
<div class="col-md-4 right-side-content">
<mat-card class="shadow-none bg-primary rounded-8 p-8">
<mat-card-content>
<div>
<h6 class="f-s-20 m-b-16 text-white">Kontaktujte nás dnes</h6>
<p class="f-s-14 m-0 text-white">
Máte otázky alebo potrebujete pomoc? Napíšte nám správu.
</p>
</div>
<mat-divider class="m-y-30"></mat-divider>
<div>
<h6 class="f-s-20 m-b-16 text-white">Naša lokalita</h6>
<p class="f-s-14 m-0 text-white">
Navštívte nás osobne alebo nájdite naše kontaktné údaje, aby ste sa s nami mohli priamo spojiť.
</p>
</div>
</mat-card-content>
</mat-card>
<!-- reCAPTCHA widget: uses siteKey from component -->
<div class="row m-t-16">
<div class="col-lg-12">
<!-- anchor for programmatic render -->
<div #recaptcha class="g-recaptcha"></div>
</div>
</div>
<button mat-flat-button type="button" (click)="onSubmit()">Odoslať</button>
</div>
<div class="col-md-4 right-side-content">
<mat-card class="shadow-none bg-primary rounded-8 p-8">
<mat-card-content>
<div>
<h6 class="f-s-20 m-b-16 text-white">Kontaktujte nás dnes</h6>
<p class="f-s-14 m-0 text-white">
Máte otázky alebo potrebujete pomoc? Napíšte nám správu.
</p>
</div>
<mat-divider class="m-y-30"></mat-divider>
<div>
<h6 class="f-s-20 m-b-16 text-white">Naša lokalita</h6>
<p class="f-s-14 m-0 text-white">
Navštívte nás osobne alebo nájdite naše kontaktné údaje, aby ste sa s nami mohli priamo spojiť.
</p>
</div>
</mat-card-content>
</mat-card>
</div>
</div>
</div>
</form>
</div>
</div>
<div class="footer">
<app-footer></app-footer>

View File

@@ -1,14 +1,91 @@
import { Component } from '@angular/core';
import { Component, AfterViewInit, ViewChild, ElementRef, NgZone } from '@angular/core';
import { IconModule } from '../../../icon/icon.module';
import { MaterialModule } from '../../../material.module';
import { FooterComponent } from '../footer/footer.component';
import { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
@Component({
selector: 'app-contact',
imports: [MaterialModule,IconModule,FooterComponent],
imports: [MaterialModule, IconModule, FooterComponent, FormsModule],
templateUrl: './contact.component.html',
styleUrl: './contact.component.scss'
})
export class ContactComponent {
export class ContactComponent implements AfterViewInit {
model: any = {
name: '',
surname: '',
phone: '',
email: '',
subject: '',
message: ''
};
siteKey: string = '6Lf6df0rAAAAAMXcAx1umneXl1QJo9rTrflpWCvB';
@ViewChild('recaptcha', { static: false }) recaptchaElem!: ElementRef;
widgetId: number | null = null;
constructor(private http: HttpClient) { }
ngAfterViewInit(): void {
const win: any = window as any;
const renderWidget = () => {
const grecaptcha = win.grecaptcha;
if (grecaptcha && grecaptcha.render && this.recaptchaElem) {
// render widget and keep id
this.widgetId = grecaptcha.render(this.recaptchaElem.nativeElement, { sitekey: this.siteKey });
}
};
// if grecaptcha already loaded, render immediately
if ((win as any).grecaptcha && (win as any).grecaptcha.render) {
renderWidget();
return;
}
// otherwise define global callback that the index.html script will call when loaded
(win as any).onRecaptchaLoad = () => {
setTimeout(renderWidget);
};
}
onSubmit() {
// get token from grecaptcha
const grecaptchaAny: any = (window as any).grecaptcha;
let token = '';
if (grecaptchaAny && grecaptchaAny.getResponse) {
token = this.widgetId !== null ? grecaptchaAny.getResponse(this.widgetId) : grecaptchaAny.getResponse();
}
if (!token) {
// optionally show UI feedback to complete captcha
alert('Prosím, potvrďte reCAPTCHA.');
return;
}
const payload = {
name: this.model.name,
surname: this.model.surname,
phone: this.model.phone,
email: this.model.email,
subject: this.model.subject,
message: this.model.message,
recaptchatoken: token
};
// POST to your backend endpoint which must verify the token with Google
this.http.post('/api/webmessages', payload).subscribe({
next: () => {
alert('Správa odoslaná');
// reset widget
if (grecaptchaAny && grecaptchaAny.reset) {
if (this.widgetId !== null) grecaptchaAny.reset(this.widgetId);
else grecaptchaAny.reset();
}
},
error: (err) => {
console.error(err);
alert('Chyba pri odosielaní — skúste znova.');
}
});
}
}

View File

@@ -16,10 +16,11 @@
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700&display=swap"
rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<!-- load recaptcha in explicit render mode and call onRecaptchaLoad when ready -->
<script src="https://www.google.com/recaptcha/api.js?onload=onRecaptchaLoad&render=explicit" async defer></script>
</head>
<body class="mat-typography">
<app-root></app-root>
</body>
</html>