Compare commits

...

11 Commits

Author SHA1 Message Date
98893924ac feat(ISPConfig Webhosting): add creation script for members with website
All checks were successful
Deploy Roxane to Preprod / deploy (push) Successful in 1m23s
2026-08-19 18:31:02 +02:00
7dff670122 fix(Readme)
All checks were successful
Deploy Roxane to Preprod / deploy (push) Successful in 1m19s
2026-08-18 19:51:25 +02:00
7aef0210fd init(ISPWeb account creation)
All checks were successful
Deploy Roxane to Preprod / deploy (push) Successful in 1m33s
2026-08-18 19:12:51 +02:00
6d300ac79c feat(Membership, FAQ, Relance): update membership form (member type radio,retzien email field, calendar-year pricing), create FAQ page with accordion, wire renewal reminder notification
All checks were successful
Deploy Roxane to Preprod / deploy (push) Successful in 1m23s
2026-07-27 16:30:48 +02:00
87e23ea50f fix(homepage typo)
All checks were successful
Deploy Roxane to Preprod / deploy (push) Successful in 1m21s
2026-07-24 17:27:00 +02:00
81dbe4275d feat(Notification process: text, app name, membership validation + project update)
All checks were successful
Deploy Roxane to Preprod / deploy (push) Successful in 1m21s
2026-07-18 15:19:48 +02:00
b4845511a6 feat(Membership): add notification for user and admin during registration
All checks were successful
Deploy Roxane to Preprod / deploy (push) Successful in 1m30s
2026-07-15 13:58:48 +02:00
a886bdd947 debug(IPV6): change ipv6 address
All checks were successful
Deploy Roxane to Preprod / deploy (push) Successful in 1m31s
2026-07-15 12:13:51 +02:00
bb9b5d405f debug(SMTP): add test for mail
Some checks failed
Deploy Roxane to Preprod / deploy (push) Has been cancelled
2026-07-15 11:50:50 +02:00
7111b70c65 fix(Notifications): Add supervisor and worker during deployment)
All checks were successful
Deploy Roxane to Preprod / deploy (push) Successful in 26h10m24s
2026-04-29 17:09:34 +02:00
3710bccd5a fix(Membership creation): model type
All checks were successful
Deploy Roxane to Preprod / deploy (push) Successful in 26h10m33s
2026-04-29 16:21:14 +02:00
57 changed files with 2693 additions and 565 deletions

View File

@@ -1,4 +1,5 @@
APP_NAME="Roxane - Le Retzien Libre"
FRONT_NAME="Le Retzien Libre"
APP_ENV=local
APP_KEY=
APP_DEBUG=true

View File

@@ -26,7 +26,7 @@ jobs:
- name: Ping IPv6 preprod server
run: |
ping6 -c 3 2a01:e0a:bfe:a8a0::205
ping6 -c 3 2a01:e0a:ef9:d10::205
- name: Configure SSH
env:
@@ -118,6 +118,9 @@ jobs:
echo "[<>] Restarting queue workers..."
php artisan queue:restart || true
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl restart roxane-worker:*
echo "[OK] Roxane deployed successfully to preprod!"
EOF

View File

@@ -88,7 +88,7 @@ Elle gère les membres, les cotisations, et s'intègre avec des services tiers (
| MemberService | Inscription et desactivation de membres |
| DolibarrService | Integration ERP via API REST |
| ISPConfigMailService | Gestion comptes mail via SOAP |
| ISPConfigWebService | Gestion hebergement web via SOAP (avec cache) |
| ISPConfigWebService | Gestion hebergement web via SOAP (avec cache). Creation de clients et reassignation de sites |
| NextcloudService | Gestion comptes Nextcloud via OCS (avec cache 7 jours) |
---
@@ -101,6 +101,7 @@ Elle gère les membres, les cotisations, et s'intègre avec des services tiers (
| `members:cleanup-expired` | Desactive les membres expires (Dolibarr + ISPConfig + Nextcloud). `--dry-run` |
| `sync:ispconfig-mail-members` | Lie les membres a leurs comptes mail ISPConfig (@retzien.fr) |
| `sync:ispconfig-web-members` | Lie les membres a leurs comptes d'hebergement web |
| `ext:create-isp-accounts` | Cree les clients ISPConfig pour les membres et reassigne leurs sites. `--dry-run`, `--force` |
| `nextcloud:sync-members` | Lie les membres a leurs comptes Nextcloud |
| `memberships:sync-services` | Synchronise les services associes aux membres |
@@ -165,9 +166,24 @@ resources/js/components/
## Notifications
| Classe | Canal | Description |
| ------------------------- | ----- | --------------------------------------------- |
| SubscriptionExpiredPhase1 | Email | Notification d'expiration d'adhesion (queued) |
Toutes les notifications sont queueable (`ShouldQueue`). Le template Blade `notifications/mail-template.blade.php` est partagé et accepte une variable `$appName` pour différencier les emails membres des emails admin.
| Classe | Canal | Destinataire | Déclencheur |
| ----------------------------------- | ----- | ------------ | ------------------------------------------------ |
| ContactNewRequestNotification | Email | Admin | Soumission formulaire de contact |
| MemberNewRequestAdminNotification | Email | Admin | Soumission formulaire d'adhésion |
| MemberNewRequestMemberNotification | Email | Membre | Soumission formulaire d'adhésion (confirmation) |
| MembershipValidatedNotification | Email | Membre | Validation admin d'une adhésion (action Filament)|
| MemberDeactivatedMemberNotification | Email | Membre | Désactivation d'un membre |
| MemberDeactivatedAdminNotification | Email | Admin | Désactivation d'un membre |
| AdminInvitationNotification | Email | Admin | Création d'un compte administrateur |
| AdminPasswordResetNotification | Email | Admin | Réinitialisation de mot de passe admin |
| ServiceActivationRequestNotification| Email | Admin | Demande d'activation de service (dashboard) |
| SubscriptionExpiredPhase1 | Email | Membre | Job planifié d'expiration d'adhésion |
### Config app_name dans les emails
- Emails membres : `config('app.front_name')` (variable `FRONT_NAME` dans `.env`)
- Emails admin : `config('app.name')` (variable `APP_NAME` dans `.env`)
---
@@ -179,15 +195,16 @@ Langues : **fr**, **en** — fichiers dans `lang/{locale}/` : contacts, members,
## TODOs identifies dans le code
| Fichier | TODO |
|---------------------------|----------------------------------------------------------------------------|
| ContactService | Envoyer un email a l'administrateur |
| MemberService | Envoyer des emails au membre + admin a la desactivation |
| SubscriptionExpiredPhase1 | Creer un template generique + UI backend pour le contenu |
| User.php | Restreindre l'acces admin en prod aux emails @retzien.fr |
| SyncDolibarrMembers | Exporter la methode toDate() dans un service/helper |
| SyncISPConfigMailMembers | Gerer plusieurs emails par membre |
| SyncISPConfigMailMembers | Ajouter le suivi ispconfig_client_id |
| Global | Refactoriser pour rendre générique le projet Roxane (ERP pour association) |
| Traduction | Crawler le prrojet pour retrouver toutes les clés manquantes |
| Global | PHPstan niveau 8 |
| Fichier | TODO |
|--------------------------------|--------------------------------------------------------------------------------|
| Notifications (x9) | Propager `appName` dans `->view()` pour toutes les notifications restantes |
| SubscriptionExpiredPhase1 | UI backend pour éditer le contenu du template de notification |
| User.php | Restreindre l'acces admin en prod aux emails @retzien.fr |
| SyncDolibarrMembers | Exporter la methode toDate() dans un service/helper |
| SyncISPConfigMailMembers | Gerer plusieurs emails par membre |
| SyncISPConfigMailMembers | Ajouter le suivi ispconfig_client_id |
| MembershipValidatedNotification| Ajouter lien HelloAsso vers le paiement quand disponible |
| Global | Refactoriser pour rendre générique le projet Roxane (ERP pour association) |
| Traduction | Crawler le projet pour retrouver toutes les clés manquantes |
| Global | PHPstan niveau 8 |
| dev-routes.php | Supprimer la route de test mail (/test/mail) avant mise en production |

View File

@@ -1,5 +1,7 @@
# Roxane
![Roxane Illustration](./assets/LRL-roxane.png)
Roxane is an open source membership management application designed for associations. It centralizes member management, subscriptions, and integration with self-hosted third-party services (Dolibarr, ISPConfig, Nextcloud, Sympa).
The project is developed in the context of **Le Retzien Libre**, a non-profit association promoting digital freedom and self-hosting. It is intended to be generic enough to be adapted by other associations with similar needs.
@@ -12,17 +14,24 @@ The project is developed in the context of **Le Retzien Libre**, a non-profit as
**Back office (administrators)**
- Member management (status, nature, groups)
- Subscription and package management
- Subscription and package management with validation workflow (pending → active)
- Manual and automated synchronization with third-party services
- Role and permission management (Spatie Permissions + Filament Shield)
- Two-factor authentication for admin accounts
**Front office (members)**
- Registration and membership form
- Registration and membership form with real-time field validation
- Personal dashboard with access to associated services
- Profile and password management
- Two-factor authentication
**Email notifications**
- Admin notified on new contact request and new membership request (with direct link to the membership record)
- Member receives a confirmation email on registration and a validation email when approved
- Member notified on account deactivation and subscription expiry
- Queued notifications (`ShouldQueue`) — requires a running queue worker
- Separate application name for member emails (`FRONT_NAME`) vs admin emails (`APP_NAME`)
**Integrations**
- Dolibarr ERP (member and subscription import via REST API)
- ISPConfig (mail and web hosting account management via SOAP)
@@ -65,7 +74,7 @@ The project is developed in the context of **Le Retzien Libre**, a non-profit as
### 1. Clone the repository
```bash
git clone https://github.com/your-org/roxane.git
git clone https://github.com/SabrinaNebulae/roxane.git
cd roxane
```
@@ -87,6 +96,8 @@ Edit `.env` and configure:
- Database connection (`DB_*`)
- Redis connection (`REDIS_*`)
- Mail configuration (`MAIL_*`)
- Application names: `APP_NAME` (admin panel), `FRONT_NAME` (member-facing emails)
- Admin email: `ADMIN_EMAIL` (recipient for all admin notifications)
- Third-party service credentials (Dolibarr, ISPConfig, Nextcloud)
### 4. Database setup
@@ -186,19 +197,20 @@ php artisan test --compact
## Known TODOs
| Area | Description |
|---------------------------|---------------------------------------------------------------------|
| ContactService | Send email notification to administrator on new contact request |
| MemberService | Send emails to member and admin on deactivation |
| SubscriptionExpiredPhase1 | Generic template + backend UI for notification content management |
| User.php | Restrict admin access in production to @retzien.fr emails |
| SyncDolibarrMembers | Extract `toDate()` method into a shared service or helper |
| SyncISPConfigMailMembers | Handle multiple email addresses per member |
| SyncISPConfigMailMembers | Track `ispconfig_client_id` |
| Global | Make Roxane fully generic for any association ERP use case |
| Translations | Audit project for missing translation keys |
| Global | Raise PHPStan to level 8 |
| V2 | Keycloak SSO integration (OIDC) for front office authentication |
| Area | Description |
|-----------------------------|----------------------------------------------------------------------------------|
| Notifications | Propagate `appName` view variable to all remaining notifications |
| SubscriptionExpiredPhase1 | Backend UI for editing notification template content |
| MembershipValidatedNotif. | Add HelloAsso payment link once integration is available |
| User.php | Restrict admin access in production to @retzien.fr emails |
| SyncDolibarrMembers | Extract `toDate()` method into a shared service or helper |
| SyncISPConfigMailMembers | Handle multiple email addresses per member |
| SyncISPConfigMailMembers | Track `ispconfig_client_id` |
| Global | Make Roxane fully generic for any association ERP use case |
| Translations | Audit project for missing translation keys |
| Global | Raise PHPStan to level 8 |
| dev-routes.php | Remove `/test/mail` debug route before production deployment |
| V2 | Keycloak SSO integration (OIDC) for front office authentication |
---

View File

@@ -0,0 +1,312 @@
<?php
namespace App\Console\Commands;
use App\Enums\IspconfigType;
use App\Models\IspconfigMember;
use App\Models\Member;
use App\Services\ISPConfig\ISPConfigWebService;
use Exception;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use function Laravel\Prompts\progress;
class CreateISPWebAccounts extends Command
{
protected $signature = 'ext:create-isp-accounts
{--dry-run : Simulate without creating accounts}
{--force : Force recreation even if client already exists}';
protected $description = 'Create ISPConfig clients for members and reassign their websites';
/**
* @throws Exception
*/
public function handle(): int
{
$isDryRun = $this->option('dry-run');
$isForce = $this->option('force');
$this->info('ISPConfig Client Creation & Website Reassignment');
if ($isDryRun) {
$this->warn('DRY RUN MODE - No changes will be made');
}
$ispWeb = new ISPConfigWebService;
// Récupération de tous les membres ayant un site web
$membersQuery = Member::whereNotNull('website_url')
->where('website_url', '!=', '');
$totalMembers = $membersQuery->count();
if ($totalMembers === 0) {
$this->info('No members with websites found');
return self::SUCCESS;
}
$this->info("Found {$totalMembers} members with websites");
// Récupération de tous les sites web depuis ISPConfig (avec cache)
$allWebsites = collect($ispWeb->getAllWebsites());
// Récupération des clients déjà créés dans Roxane (pour éviter les doublons)
$existingClients = IspconfigMember::where('type', IspconfigType::WEB)
->whereIn('member_id', $membersQuery->pluck('id'))
->get()
->keyBy('member_id');
$progressBar = progress(
label: 'Processing members',
steps: $totalMembers
);
$progressBar->start();
// Statistiques de traitement
$stats = [
'clients_created' => 0,
'websites_reassigned' => 0,
'errors' => 0,
'skipped' => 0,
];
// Résultats détaillés pour le mode dry-run
$dryRunResults = [];
// Traitement par lots de 50 membres
$membersQuery->chunk(50, function ($members) use (
$ispWeb,
$allWebsites,
$existingClients,
$progressBar,
$isDryRun,
$isForce,
&$stats,
&$dryRunResults
) {
foreach ($members as $member) {
try {
/** @var IspconfigMember|null $existingClient */
$existingClient = $existingClients->get($member->id);
// Extraction et normalisation des domaines depuis member->website_url
$memberDomains = $this->extractDomains($member->website_url);
if ($memberDomains->isEmpty()) {
$this->warn("No valid domains for member: {$member->full_name}");
$stats['skipped']++;
$progressBar->advance();
continue;
}
// Recherche des sites web ISPConfig correspondant aux domaines du membre
$matchedWebsites = $ispWeb->findWebsitesForDomains($allWebsites, $memberDomains);
if ($matchedWebsites->isEmpty()) {
$this->warn("No ISPConfig websites found for member: {$member->full_name}");
$stats['skipped']++;
$progressBar->advance();
continue;
}
$email = $member->retzien_email ?? $member->email;
$clientInfo = null;
// Vérification 1 : Le client existe-t-il dans ISPConfig ?
$existingIspClient = $ispWeb->findClientByEmail($email);
if ($existingIspClient && ! $isForce) {
// Client trouvé dans ISPConfig par email
$clientInfo = $existingIspClient;
$this->info("ISPConfig client found for {$member->full_name} with email {$email} (Client ID: {$clientInfo['client_id']})");
} elseif ($existingClient && ! $isForce && $existingClient->ispconfig_client_id > 0) {
// Client déjà lié dans Roxane (fallback) - uniquement si client_id valide
$clientInfo = [
'client_id' => (int) $existingClient->ispconfig_client_id,
'groupid' => (int) $existingClient->ispconfig_client_id,
];
$this->info("Member {$member->full_name} already linked to ISPConfig client (ID: {$clientInfo['client_id']})");
}
if (! $isDryRun) {
// MODE EXECUTION RÉELLE
// Création du client ISPConfig si nécessaire
if ($clientInfo === null) {
$clientInfo = $this->createClient($member, $ispWeb, $email);
$stats['clients_created']++;
}
// Réassignation de tous les sites web au client ISPConfig
foreach ($matchedWebsites as $website) {
$domainId = $website['domain_id'];
$ispWeb->updateWebsiteClient($domainId, $clientInfo['groupid']);
$stats['websites_reassigned']++;
$this->info("Reassigned {$website['domain']} to client {$clientInfo['client_id']}");
}
// Sauvegarde dans Roxane de la relation membre <-> client ISPConfig
$siteData = $matchedWebsites->map(fn ($site) => [
'domain_id' => $site['domain_id'],
'domain' => $site['domain'],
])->toArray();
IspconfigMember::updateOrCreate(
[
'member_id' => $member->id,
'type' => IspconfigType::WEB,
],
[
'ispconfig_client_id' => $clientInfo['client_id'],
'data' => [
'sites' => $siteData,
],
]
);
} else {
// MODE DRY-RUN : Collecte des informations sans modifications
$willCreateClient = $clientInfo === null;
if ($willCreateClient) {
$stats['clients_created']++;
}
$dryRunResults[] = [
'member' => $member->full_name,
'email' => $email,
'action' => $willCreateClient ? 'CREATE ISP CLIENT' : 'USE EXISTING ISP CLIENT',
'client_id' => $clientInfo['client_id'] ?? 'NEW',
'websites' => $matchedWebsites->pluck('domain')->implode(', '),
'sites_count' => $matchedWebsites->count(),
];
}
} catch (Exception $e) {
$this->error("Error processing {$member->full_name}: {$e->getMessage()}");
$stats['errors']++;
}
$progressBar->advance();
}
});
$progressBar->finish();
$this->newLine();
$this->info('Summary:');
$this->table(
['Metric', 'Count'],
[
['Clients created', $stats['clients_created']],
['Websites reassigned', $stats['websites_reassigned']],
['Skipped', $stats['skipped']],
['Errors', $stats['errors']],
]
);
if ($isDryRun && ! empty($dryRunResults)) {
$this->newLine();
$this->info('Dry Run Details:');
$this->table(
['Member', 'Email', 'Action', 'Client ID', 'Websites', 'Sites Count'],
collect($dryRunResults)->map(fn ($r) => [
$r['member'],
$r['email'],
$r['action'],
$r['client_id'],
$r['websites'],
$r['sites_count'],
])
);
}
return $stats['errors'] > 0 ? self::FAILURE : self::SUCCESS;
}
/**
* Extrait les domaines depuis la chaîne website_url (séparés par ;)
* et les normalise en minuscules
*/
private function extractDomains(string $websiteUrl): Collection
{
return collect(explode(';', $websiteUrl))
->map(fn ($url) => $this->normalizeDomain($url))
->filter()
->unique()
->values();
}
/**
* Normalise une URL pour extraire uniquement le nom de domaine
* Exemple: "https://www.example.com/path" "www.example.com"
*/
private function normalizeDomain(string $url): ?string
{
$url = trim($url);
if (! str_starts_with($url, 'http')) {
$url = 'https://'.$url;
}
$host = parse_url($url, PHP_URL_HOST);
return $host ? strtolower($host) : null;
}
/**
* Crée un nouveau client dans ISPConfig avec toutes les données du membre
*
* @throws Exception
*/
private function createClient(Member $member, ISPConfigWebService $ispWeb, string $email): array
{
$username = $this->generateUsername($member);
$password = Str::random(16);
// Préparation des données client pour ISPConfig
$clientData = [
'company_name' => $member->company ?? $member->full_name,
'contact_name' => $member->full_name,
'email' => $email,
'username' => $username,
'password' => $password,
'customer_no' => (string) $member->id,
'street' => $member->address ?? '',
'zip' => $member->zipcode ?? '',
'city' => $member->city ?? '',
'country' => $member->country ?? 'FR',
'telephone' => $member->phone1 ?? '',
'mobile' => $member->phone2 ?? '',
'internet' => $member->website_url ?? '',
];
// Appel API ISPConfig pour créer le client
$clientInfo = $ispWeb->createClient($clientData);
$this->info("Created ISPConfig client for {$member->full_name} (Client ID: {$clientInfo['client_id']})");
return $clientInfo;
}
/**
* Génère un nom d'utilisateur pour ISPConfig
* Format: prenomnom (en minuscules, sans espaces ni accents)
* Exemple: "jeandurand", "johndoe"
*/
private function generateUsername(Member $member): string
{
$username = Str::slug($member->firstname.$member->lastname);
return str_replace('-', '', $username);
}
}

View File

@@ -7,11 +7,13 @@ use App\Models\IspconfigMember;
use App\Models\Member;
use App\Services\ISPConfig\ISPConfigWebService;
use Illuminate\Console\Command;
use function Laravel\Prompts\progress;
class SyncISPConfigWebMembers extends Command
{
protected $signature = 'sync:ispconfig-web-members {--refresh-cache : Vider le cache avant la synchronisation}';
protected $description = 'Synchronise les services WEB ISPConfig des membres (via member->website_url)';
/**
@@ -19,11 +21,9 @@ class SyncISPConfigWebMembers extends Command
*/
public function handle(): void
{
//@todo: Retrouver le client_id pour chaque adhérent
$this->info('Synchronisation ISPConfig WEB (via member->website_url)');
$ispWeb = new ISPConfigWebService();
$ispWeb = new ISPConfigWebService;
// Vider le cache si demandé
if ($this->option('refresh-cache')) {
@@ -60,38 +60,23 @@ class SyncISPConfigWebMembers extends Command
// Extraction des domaines depuis website_url
$memberDomains = collect(explode(';', $member->website_url))
->map(fn($url) => $this->normalizeDomain($url))
->map(fn ($url) => $this->normalizeDomain($url))
->filter()
->unique()
->values();
if ($memberDomains->isEmpty()) {
$progressBar->advance();
continue;
}
// Recherche des sites ISPConfig correspondants
$matchedWebsites = $allWebsites->filter(function ($site) use ($memberDomains, $ispWeb) {
$siteDomain = strtolower($site['domain']);
// Vérification du domaine principal
if ($memberDomains->contains($siteDomain)) {
return true;
}
// Récupération et vérification des alias (avec cache)
$aliases = $ispWeb->getWebsiteAliases($site['domain_id']);
foreach ($aliases as $alias) {
if ($memberDomains->contains(strtolower($alias))) {
return true;
}
}
return false;
});
$matchedWebsites = $ispWeb->findWebsitesForDomains($allWebsites, $memberDomains);
if ($matchedWebsites->isEmpty()) {
$progressBar->advance();
continue;
}
@@ -104,7 +89,7 @@ class SyncISPConfigWebMembers extends Command
$ispWeb
) {
$domainId = $site['domain_id'];
$sysGroupId = $site['sys_groupid'];
$sysGroupId = $site['sys_groupid'] ?? null;
$domain = $site['domain'];
// Récupération des alias (avec cache)
@@ -112,8 +97,8 @@ class SyncISPConfigWebMembers extends Command
// Filtrage des bases de données pour ce site
$databases = $allDatabases
->filter(fn($db) => $db['sys_groupid'] == $sysGroupId)
->map(fn($db) => [
->filter(fn ($db) => $db['parent_domain_id'] == $domainId)
->map(fn ($db) => [
'database_id' => $db['database_id'],
'database_name' => $db['database_name'],
'database_user_id' => $db['database_user_id'],
@@ -123,8 +108,8 @@ class SyncISPConfigWebMembers extends Command
// Filtrage des utilisateurs FTP pour ce site
$ftpUsers = $allFtpUsers
->filter(fn($ftp) => $ftp['parent_domain_id'] == $domainId)
->map(fn($ftp) => [
->filter(fn ($ftp) => $ftp['parent_domain_id'] == $domainId)
->map(fn ($ftp) => [
'ftp_user_id' => $ftp['ftp_user_id'],
'username' => $ftp['username'],
'dir' => $ftp['dir'],
@@ -133,8 +118,8 @@ class SyncISPConfigWebMembers extends Command
// Filtrage des utilisateurs Shell pour ce site
$shellUsers = $allShellUsers
->filter(fn($shell) => $shell['parent_domain_id'] == $domainId)
->map(fn($shell) => [
->filter(fn ($shell) => $shell['parent_domain_id'] == $domainId)
->map(fn ($shell) => [
'shell_user_id' => $shell['shell_user_id'],
'username' => $shell['username'],
'shell' => $shell['shell'],
@@ -163,7 +148,7 @@ class SyncISPConfigWebMembers extends Command
return false;
})
->map(fn($zone) => [
->map(fn ($zone) => [
'id' => $zone['id'],
'origin' => $zone['origin'],
'ns' => $zone['ns'],
@@ -176,6 +161,8 @@ class SyncISPConfigWebMembers extends Command
return [
'domain_id' => $domainId,
'domain' => $domain,
'sys_groupid' => $sysGroupId,
'system_group' => $site['system_group'] ?? null,
'document_root' => $site['document_root'],
'active' => $site['active'],
'aliases' => $aliases,
@@ -195,6 +182,7 @@ class SyncISPConfigWebMembers extends Command
'ispconfig_service_user_id' => $siteData['domain_id'],
],
[
'ispconfig_client_id' => $siteData['sys_groupid'],
'data' => $siteData,
]
);
@@ -215,8 +203,8 @@ class SyncISPConfigWebMembers extends Command
{
$url = trim($url);
if (!str_starts_with($url, 'http')) {
$url = 'https://' . $url;
if (! str_starts_with($url, 'http')) {
$url = 'https://'.$url;
}
$host = parse_url($url, PHP_URL_HOST);

View File

@@ -28,32 +28,32 @@ class ServiceToggleAction extends Action
$this->serviceIdentifier = $serviceIdentifier;
return $this
->label(fn (Member|Membership $record) => $this->getMember($record)?->hasService($serviceIdentifier)
->label(fn (Member|Membership|null $record) => $this->getMember($record)?->hasService($serviceIdentifier)
? 'Service actif'
: 'Activer le service'
)
->icon(fn (Member|Membership $record) => $this->getMember($record)?->hasService($serviceIdentifier)
->icon(fn (Member|Membership|null $record) => $this->getMember($record)?->hasService($serviceIdentifier)
? 'heroicon-o-check-circle'
: 'heroicon-o-x-circle'
)
->color(fn (Member|Membership $record) => $this->getMember($record)?->hasService($serviceIdentifier)
->color(fn (Member|Membership|null $record) => $this->getMember($record)?->hasService($serviceIdentifier)
? 'success'
: 'warning'
)
->requiresConfirmation()
->modalHeading(fn (Member|Membership $record) => $this->getMember($record)?->hasService($serviceIdentifier)
->modalHeading(fn (Member|Membership|null $record) => $this->getMember($record)?->hasService($serviceIdentifier)
? 'Désactiver le service'
: 'Activer le service'
)
->modalDescription(fn (Member|Membership $record) => $this->getMember($record)?->hasService($serviceIdentifier)
->modalDescription(fn (Member|Membership|null $record) => $this->getMember($record)?->hasService($serviceIdentifier)
? 'Êtes-vous sûr·e de vouloir désactiver ce service pour ce membre ?'
: 'Êtes-vous sûr·e de vouloir activer ce service pour ce membre ?'
)
->modalSubmitActionLabel(fn (Member|Membership $record) => $this->getMember($record)?->hasService($serviceIdentifier)
->modalSubmitActionLabel(fn (Member|Membership|null $record) => $this->getMember($record)?->hasService($serviceIdentifier)
? 'Désactiver'
: 'Activer'
)
->action(function (Member|Membership $record) {
->action(function (Member|Membership|null $record) {
$member = $this->getMember($record);
if (! $member) {
@@ -76,8 +76,12 @@ class ServiceToggleAction extends Action
/**
* Get the member associated with the given record.
*/
protected function getMember(Member|Membership $record): ?Member
protected function getMember(Member|Membership|null $record): ?Member
{
if ($record === null) {
return null;
}
return $record instanceof Member ? $record : $record->member;
}
}

View File

@@ -9,6 +9,7 @@ use App\Models\ListmonkMember;
use App\Models\Member;
use App\Models\Membership;
use App\Models\Package;
use App\Notifications\MemberRenewalReminderNotification;
use Filament\Actions\Action;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Select;
@@ -17,6 +18,7 @@ use Filament\Forms\Components\Toggle;
use Filament\Infolists\Components\RepeatableEntry;
use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry;
use Filament\Notifications\Notification;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs;
@@ -83,6 +85,11 @@ class MemberForm
->default('physical')
->required(),
Select::make('type_id')
->label(Member::getAttributeLabel('type_id'))
->relationship('type', 'name')
->default(null),
Select::make('group_id')
->label(Member::getAttributeLabel('group_id'))
->relationship('group', 'name')
@@ -98,6 +105,10 @@ class MemberForm
->email()
->required(),
TextInput::make('retzien_email')
->label(Member::getAttributeLabel('retzien_email'))
->email(),
TextInput::make('phone1')
->label(Member::getAttributeLabel('phone1'))
->tel(),
@@ -366,8 +377,28 @@ class MemberForm
->label(__('members.actions.send_renewal_mail'))
->icon('heroicon-o-envelope')
->color('primary')
->action(function () {
// Mail de relance à créer (Job)
->requiresConfirmation()
->modalHeading(__('members.actions.send_renewal_mail'))
->modalDescription('Êtes-vous sûr de vouloir envoyer un mail de relance à cet adhérent ?')
->action(function (Member $record) {
try {
$record->notify(new MemberRenewalReminderNotification($record));
Notification::make()
->title('Mail de relance envoyé')
->success()
->send();
} catch (\Throwable $e) {
\Log::error('Erreur envoi mail de relance', [
'member_id' => $record->id,
'error' => $e->getMessage(),
]);
Notification::make()
->title('Erreur lors de l\'envoi du mail')
->danger()
->send();
}
}),
])
->extraAttributes(['class' => 'sticky top-4 h-fit']),

View File

@@ -4,8 +4,12 @@ namespace App\Filament\Resources\Memberships\Pages;
use App\Filament\Resources\Memberships\MembershipResource;
use App\Models\Membership;
use App\Notifications\MembershipValidatedNotification;
use Filament\Actions\Action;
use Filament\Actions\DeleteAction;
use Filament\Forms\Components\DatePicker;
use Filament\Resources\Pages\EditRecord;
use Filament\Support\Icons\Heroicon;
use Illuminate\Contracts\Support\Htmlable;
class EditMembership extends EditRecord
@@ -15,17 +19,40 @@ class EditMembership extends EditRecord
protected function getHeaderActions(): array
{
return [
Action::make('validate')
->label(__('memberships.actions.validate'))
->icon(Heroicon::OutlinedCheckCircle)
->color('success')
->visible(fn (Membership $record) => $record->status === 'pending')
->form([
DatePicker::make('start_date')
->label(Membership::getAttributeLabel('start_date'))
->required(),
DatePicker::make('end_date')
->label(Membership::getAttributeLabel('end_date'))
->required(),
])
->fillForm(fn (Membership $record): array => [
'start_date' => $record->start_date,
'end_date' => $record->end_date,
])
->action(function (Membership $record, array $data): void {
$record->update([
'status' => 'active',
'start_date' => $data['start_date'],
'end_date' => $data['end_date'],
]);
$record->member->notify(new MembershipValidatedNotification($record->fresh(['member', 'package'])));
}),
DeleteAction::make(),
];
}
/**
* @property Membership $record
* @return string|Htmlable
*
*/
public function getTitle(): string | Htmlable
public function getTitle(): string|Htmlable
{
return Membership::getAttributeLabel('membership') . ' #' . $this->record->id;
return Membership::getAttributeLabel('membership').' #'.$this->record->id;
}
}

View File

@@ -57,7 +57,8 @@ class MembershipForm
TextEntry::make('member.full_name')
->label(Membership::getAttributeLabel('member_id')),
TextEntry::make('author.name')
->label(Membership::getAttributeLabel('admin_id')),
->label(Membership::getAttributeLabel('admin_id'))
->default(__('memberships.sections.author_not_set')),
TextEntry::make('created_at')
->label(Membership::getAttributeLabel('created_at')),
])

View File

@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Forms;
use App\Http\Controllers\Controller;
use App\Http\Requests\Forms\MembershipRequest;
use App\Models\MemberType;
use App\Models\Package;
use App\Models\Service;
use App\Services\MemberService;
@@ -18,23 +19,39 @@ class MembershipFormController extends Controller
public function create(): Response
{
$remainingMonths = 13 - now()->month;
$currentYear = now()->year;
$plans = Package::query()
->where('is_active', true)
->select('id', 'identifier', 'name', 'price', 'description')
->get()
->map(fn (Package $p) => [
'id' => $p->id,
'identifier' => $p->identifier,
'name' => $p->name,
'description' => $p->description,
'price' => $p->identifier === 'custom' ? $remainingMonths : (float) $p->price,
'months' => $p->identifier === 'custom' ? $remainingMonths : null,
]);
->map(function (Package $p) use ($remainingMonths, $currentYear) {
$months = match ($p->identifier) {
'custom' => $remainingMonths,
'one-year' => $remainingMonths + 12,
'two-years' => $remainingMonths + 24,
default => null,
};
return [
'id' => $p->id,
'identifier' => $p->identifier,
'name' => $p->name,
'description' => $p->description,
'price' => $months ?? (float) $p->price,
'months' => $months,
];
});
$memberTypes = MemberType::query()
->select('id', 'identifier', 'name')
->get();
return Inertia::render('forms/membership', [
'plans' => $plans,
'services' => Service::query()->select('name', 'description')->get(),
'member_types' => $memberTypes,
'current_year' => $currentYear,
'captcha_question' => $this->generateCaptcha(),
]);
}

View File

@@ -12,20 +12,33 @@ class MembershipRequest extends FormRequest
return true;
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'phone1.digits' => 'Le numéro de téléphone doit contenir exactement 10 chiffres.',
'zipcode.digits' => 'Le code postal doit contenir exactement 5 chiffres.',
];
}
/**
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'member_type' => 'required|string|exists:member_types,identifier',
'lastname' => 'required|string|max:255',
'firstname' => 'required|string|max:255',
'email' => 'required|email|max:255',
'company' => 'nullable|string|max:255',
'desired_retzien_email' => ['nullable', 'string', 'max:64', 'regex:/^[a-z0-9._-]+$/'],
'address' => 'required|string|max:255',
'zipcode' => 'required|string|max:255',
'zipcode' => ['required', 'digits:5'],
'city' => 'required|string|max:255',
'phone1' => 'required|string|max:255',
'phone1' => ['required', 'digits:10'],
'package' => 'required|string|max:255',
'amount' => 'required|numeric|min:0',
'cgu' => 'required|accepted',

View File

@@ -133,6 +133,11 @@ class Member extends Model
return $this->belongsTo(User::class);
}
public function type(): BelongsTo
{
return $this->belongsTo(MemberType::class);
}
public function group(): BelongsTo
{
return $this->belongsTo(MemberGroup::class);

17
app/Models/MemberType.php Normal file
View File

@@ -0,0 +1,17 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class MemberType extends Model
{
use SoftDeletes;
protected $fillable = [
'identifier',
'name',
'description',
];
}

View File

@@ -29,7 +29,7 @@ class MemberDeactivatedMemberNotification extends Notification implements Should
$vars = [
'member_name' => $this->member->full_name,
'app_name' => config('app.name'),
'app_name' => config('app.front_name'),
];
return (new MailMessage)

View File

@@ -2,7 +2,6 @@
namespace App\Notifications;
use App\Filament\Resources\Members\MemberResource;
use App\Models\Member;
use App\Models\NotificationTemplate;
use App\Models\Package;
@@ -19,6 +18,7 @@ class MemberNewRequestAdminNotification extends Notification implements ShouldQu
public readonly Member $member,
public readonly Package $package,
public readonly float $amount,
public readonly string $membershipUrl,
) {}
/**
@@ -44,7 +44,7 @@ class MemberNewRequestAdminNotification extends Notification implements ShouldQu
])),
'package_name' => $this->package->name,
'amount' => number_format($this->amount, 2, ',', ' '),
'member_url' => MemberResource::getUrl('edit', ['record' => $this->member->id]),
'membership_url' => $this->membershipUrl,
'app_name' => config('app.name'),
];

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Notifications;
use App\Models\Member;
use App\Models\NotificationTemplate;
use App\Models\Package;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class MemberNewRequestMemberNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
public readonly Member $member,
public readonly Package $package,
) {}
/**
* @return array<int, string>
*/
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
$template = NotificationTemplate::findByIdentifier('member_new_request_member');
$vars = [
'member_name' => $this->member->full_name,
'package_name' => $this->package->name,
'app_name' => config('app.front_name'),
];
return (new MailMessage)
->subject($template->renderSubject($vars))
->view('notifications.mail-template', [
'body' => $template->renderBody($vars),
]);
}
/**
* @return array<string, mixed>
*/
public function toArray(object $notifiable): array
{
return [];
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Notifications;
use App\Models\Member;
use App\Models\NotificationTemplate;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class MemberRenewalReminderNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
public readonly Member $member,
) {}
/**
* @return array<int, string>
*/
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
$template = NotificationTemplate::findByIdentifier('member_renewal_reminder');
$vars = [
'member_name' => $this->member->full_name,
'app_name' => config('app.front_name'),
];
return (new MailMessage)
->subject($template->renderSubject($vars))
->view('notifications.mail-template', [
'body' => $template->renderBody($vars),
]);
}
/**
* @return array<string, mixed>
*/
public function toArray(object $notifiable): array
{
return [];
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Notifications;
use App\Models\Membership;
use App\Models\NotificationTemplate;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class MembershipValidatedNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(public readonly Membership $membership) {}
/**
* @return array<int, string>
*/
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
$template = NotificationTemplate::findByIdentifier('membership_validated');
$vars = [
'member_name' => $this->membership->member->full_name,
'package_name' => $this->membership->package->name,
'start_date' => $this->membership->start_date ? Carbon::parse($this->membership->start_date)->format('d/m/Y') : '',
'end_date' => $this->membership->end_date ? Carbon::parse($this->membership->end_date)->format('d/m/Y') : '',
'app_name' => config('app.front_name'),
];
return (new MailMessage)
->subject($template->renderSubject($vars))
->view('notifications.mail-template', [
'body' => $template->renderBody($vars),
]);
}
/**
* @return array<string, mixed>
*/
public function toArray(object $notifiable): array
{
return [];
}
}

View File

@@ -35,6 +35,7 @@ class SubscriptionExpiredPhase1 extends Notification implements ShouldQueue
->subject($this->template->renderSubject($vars))
->view('notifications.mail-template', [
'body' => $this->template->renderBody($vars),
'appName' => config('app.front_name'),
]);
}

View File

@@ -5,8 +5,11 @@ namespace App\Providers;
use App\Listeners\PreprodMailInterceptor;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Mail\Events\MessageSending;
use Illuminate\Queue\Events\JobFailed;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\ServiceProvider;
use Queue;
class AppServiceProvider extends ServiceProvider
{
@@ -26,5 +29,13 @@ class AppServiceProvider extends ServiceProvider
JsonResource::withoutWrapping();
Event::listen(MessageSending::class, PreprodMailInterceptor::class);
// Log failed jobs
Queue::failing(function (JobFailed $event) {
Log::error('Job failed: '.$event->job->resolveName(), [
'exception' => $event->exception->getMessage(),
'trace' => $event->exception->getTraceAsString(),
]);
});
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Services\ISPConfig;
use Exception;
use Illuminate\Support\Facades\Cache;
class ISPConfigWebService extends ISPConfigService
@@ -12,71 +13,69 @@ class ISPConfigWebService extends ISPConfigService
}
/**
* @throws \Exception
* @throws Exception
*/
public function getAllWebsites(): array
{
return Cache::remember(
"ispconfig.web.websites.all",
'ispconfig.web.websites.all',
config('services.ispconfig.cache_ttl'),
fn() => $this->call('sites_web_domain_get', ['primary_id' => -1])
fn () => $this->call('sites_web_domain_get', ['primary_id' => -1])
);
}
/**
* @throws \Exception
* @throws Exception
*/
public function getAllDatabases(): array
{
return Cache::remember(
"ispconfig.web.databases.all",
'ispconfig.web.databases.all',
config('services.ispconfig.cache_ttl'),
fn() => $this->call('sites_database_get', ['primary_id' => -1])
fn () => $this->call('sites_database_get', ['primary_id' => -1])
);
}
/**
* @throws \Exception
* @throws Exception
*/
public function getAllFtpUsers(): array
{
return Cache::remember(
"ispconfig.web.ftp.all",
'ispconfig.web.ftp.all',
config('services.ispconfig.cache_ttl'),
fn() => $this->call('sites_ftp_user_get', ['primary_id' => -1])
fn () => $this->call('sites_ftp_user_get', ['primary_id' => -1])
);
}
/**
* @throws \Exception
* @throws Exception
*/
public function getAllShellUsers(): array
{
return Cache::remember(
"ispconfig.web.shell.all",
'ispconfig.web.shell.all',
config('services.ispconfig.cache_ttl'),
fn() => $this->call('sites_shell_user_get', ['primary_id' => -1])
fn () => $this->call('sites_shell_user_get', ['primary_id' => -1])
);
}
/**
* @throws \Exception
* @throws Exception
*/
public function getAllDnsZones(): array
{
return Cache::remember(
"ispconfig.web.dns-zones.all",
'ispconfig.web.dns-zones.all',
config('services.ispconfig.cache_ttl'),
fn() => $this->call('dns_zone_get', ['primary_id' => -1])
fn () => $this->call('dns_zone_get', ['primary_id' => -1])
);
}
/**
* Récupère la liste des alias d'un site web
*
* @param int $domainId
* @return array
* @throws \Exception
* @throws Exception
*/
public function getWebsiteAliases(int $domainId): array
{
@@ -98,10 +97,12 @@ class ISPConfigWebService extends ISPConfigService
}
$aliases = array_map('trim', explode(',', $site['alias']));
return array_values(array_filter($aliases, fn($alias) => !empty($alias)));
} catch (\Exception $e) {
\Log::error("Erreur lors de la récupération des alias pour le domaine {$domainId}: " . $e->getMessage());
return array_values(array_filter($aliases, fn ($alias) => ! empty($alias)));
} catch (Exception $e) {
\Log::error("Erreur lors de la récupération des alias pour le domaine {$domainId}: ".$e->getMessage());
return [];
}
}
@@ -111,9 +112,9 @@ class ISPConfigWebService extends ISPConfigService
/**
* Récupère la liste des bases de données d'un site en filtrant depuis toutes les BDD
*
* @param int $sysGroupId
* @return array
* @throws \Exception
* @todo : utiliser plutôt domainId => parent_domain_id
*
* @throws Exception
*/
public function getWebsiteDatabases(int $sysGroupId): array
{
@@ -122,15 +123,15 @@ class ISPConfigWebService extends ISPConfigService
// Filtrage par sys_groupid
return collect($allDatabases)
->filter(fn($db) => $db['sys_groupid'] == $sysGroupId)
->map(fn($db) => [
->filter(fn ($db) => $db['sys_groupid'] == $sysGroupId)
->map(fn ($db) => [
'database_id' => $db['database_id'],
'database_name' => $db['database_name'],
'database_user' => $db['database_user'],
'database_type' => $db['type'],
'active' => $db['active'],
'remote_access' => $db['remote_access'],
'remote_ips' => $db['remote_ips'] ?? ''
'remote_ips' => $db['remote_ips'] ?? '',
])
->values()
->toArray();
@@ -139,9 +140,7 @@ class ISPConfigWebService extends ISPConfigService
/**
* Récupère la liste des utilisateurs FTP d'un site en filtrant depuis tous les comptes FTP
*
* @param int $domainId
* @return array
* @throws \Exception
* @throws Exception
*/
public function getWebsiteFtpUsers(int $domainId): array
{
@@ -150,15 +149,15 @@ class ISPConfigWebService extends ISPConfigService
// Filtrage par parent_domain_id
return collect($allFtpUsers)
->filter(fn($ftp) => $ftp['parent_domain_id'] == $domainId)
->map(fn($ftp) => [
->filter(fn ($ftp) => $ftp['parent_domain_id'] == $domainId)
->map(fn ($ftp) => [
'ftp_user_id' => $ftp['ftp_user_id'],
'username' => $ftp['username'],
'dir' => $ftp['dir'],
'quota_size' => $ftp['quota_size'],
'active' => $ftp['active'],
'uid' => $ftp['uid'],
'gid' => $ftp['gid']
'gid' => $ftp['gid'],
])
->values()
->toArray();
@@ -167,9 +166,7 @@ class ISPConfigWebService extends ISPConfigService
/**
* Récupère la liste des utilisateurs Shell d'un site en filtrant depuis tous les comptes Shell
*
* @param int $domainId
* @return array
* @throws \Exception
* @throws Exception
*/
public function getWebsiteShellUsers(int $domainId): array
{
@@ -178,8 +175,8 @@ class ISPConfigWebService extends ISPConfigService
// Filtrage par parent_domain_id
return collect($allShellUsers)
->filter(fn($shell) => $shell['parent_domain_id'] == $domainId)
->map(fn($shell) => [
->filter(fn ($shell) => $shell['parent_domain_id'] == $domainId)
->map(fn ($shell) => [
'shell_user_id' => $shell['shell_user_id'],
'username' => $shell['username'],
'dir' => $shell['dir'],
@@ -189,7 +186,7 @@ class ISPConfigWebService extends ISPConfigService
'quota_size' => $shell['quota_size'],
'active' => $shell['active'],
'chroot' => $shell['chroot'],
'ssh_rsa' => !empty($shell['ssh_rsa'])
'ssh_rsa' => ! empty($shell['ssh_rsa']),
])
->values()
->toArray();
@@ -198,9 +195,7 @@ class ISPConfigWebService extends ISPConfigService
/**
* Récupère toutes les informations complètes d'un site (alias, BDD, FTP, Shell)
*
* @param int $domainId
* @return array|null
* @throws \Exception
* @throws Exception
*/
public function getWebsiteCompleteInfo(int $domainId): ?array
{
@@ -218,7 +213,7 @@ class ISPConfigWebService extends ISPConfigService
// Récupérer les alias
$aliases = [];
if (!empty($site['alias'])) {
if (! empty($site['alias'])) {
$aliases = array_values(array_filter(array_map('trim', explode(',', $site['alias']))));
}
@@ -231,7 +226,7 @@ class ISPConfigWebService extends ISPConfigService
'aliases' => $aliases,
'databases' => $this->getWebsiteDatabases($domainId, $site['sys_groupid']),
'ftp_users' => $this->getWebsiteFtpUsers($domainId),
'shell_users' => $this->getWebsiteShellUsers($domainId)
'shell_users' => $this->getWebsiteShellUsers($domainId),
];
}
);
@@ -239,9 +234,6 @@ class ISPConfigWebService extends ISPConfigService
/**
* Vide le cache pour un domaine spécifique
*
* @param int $domainId
* @return void
*/
public function clearDomainCache(int $domainId): void
{
@@ -251,16 +243,177 @@ class ISPConfigWebService extends ISPConfigService
/**
* Vide tout le cache ISPConfig Web
*
* @return void
*/
public function clearAllCache(): void
{
Cache::forget("ispconfig.web.websites.all");
Cache::forget("ispconfig.web.databases.all");
Cache::forget("ispconfig.web.ftp.all");
Cache::forget("ispconfig.web.shell.all");
Cache::forget("ispconfig.web.dns-zones.all");
Cache::forget("ispconfig.web.domain-alias.all");
Cache::forget('ispconfig.web.websites.all');
Cache::forget('ispconfig.web.databases.all');
Cache::forget('ispconfig.web.ftp.all');
Cache::forget('ispconfig.web.shell.all');
Cache::forget('ispconfig.web.dns-zones.all');
Cache::forget('ispconfig.web.domain-alias.all');
}
/**
* Find websites matching given domains (including aliases)
*/
public function findWebsitesForDomains(\Illuminate\Support\Collection $allWebsites, \Illuminate\Support\Collection $domains): \Illuminate\Support\Collection
{
return $allWebsites->filter(function ($site) use ($domains) {
$siteDomain = strtolower($site['domain']);
if ($domains->contains($siteDomain)) {
return true;
}
$aliases = $this->getWebsiteAliases($site['domain_id']);
foreach ($aliases as $alias) {
if ($domains->contains(strtolower($alias))) {
return true;
}
}
return false;
});
}
/**
* Find a client by email using sys_groupid from existing websites
* Since ISPConfig API doesn't provide a direct way to search clients by email,
* we search through websites to find the sys_groupid (client group)
*
* @throws Exception
*/
public function findClientByEmail(string $email): ?array
{
$allWebsites = $this->getAllWebsites();
$normalizedEmail = strtolower(trim($email));
foreach ($allWebsites as $website) {
$sysGroupId = $website['sys_groupid'] ?? null;
$systemGroup = $website['system_group'] ?? null;
if (! $sysGroupId || ! $systemGroup) {
continue;
}
try {
$clientData = $this->call('client_get_by_groupid', [$sysGroupId]);
if (! empty($clientData) && isset($clientData['email'])) {
if (strtolower(trim($clientData['email'])) === $normalizedEmail) {
return [
'client_id' => $clientData['client_id'] ?? $sysGroupId,
'groupid' => $sysGroupId,
'system_group' => $systemGroup,
'email' => $clientData['email'],
];
}
}
} catch (Exception $e) {
continue;
}
}
return null;
}
/**
* Create a new ISPConfig client
* Returns an array with client_id and groupid
*
* @throws Exception
*/
public function createClient(array $clientData): array
{
$resellerId = 1;
$defaultParams = [
'limit_maildomain' => -1,
'limit_mailbox' => -1,
'limit_mailalias' => -1,
'limit_mailaliasdomain' => -1,
'limit_mailforward' => -1,
'limit_mailcatchall' => -1,
'limit_mailrouting' => 0,
'limit_mail_wblist' => 0,
'limit_mailfilter' => -1,
'limit_fetchmail' => -1,
'limit_mailquota' => -1,
'limit_spamfilter_wblist' => 0,
'limit_spamfilter_user' => 0,
'limit_spamfilter_policy' => 1,
'default_webserver' => 1,
'limit_web_ip' => '',
'limit_web_domain' => -1,
'limit_web_quota' => -1,
'web_php_options' => 'no,fast-cgi,cgi,mod,suphp',
'limit_web_subdomain' => -1,
'limit_web_aliasdomain' => -1,
'limit_ftp_user' => -1,
'limit_shell_user' => 0,
'ssh_chroot' => 'no,jailkit,ssh-chroot',
'limit_webdav_user' => 0,
'default_dnsserver' => 1,
'limit_dns_zone' => -1,
'limit_dns_slave_zone' => -1,
'limit_dns_record' => -1,
'default_dbserver' => 1,
'limit_database' => -1,
'limit_cron' => 0,
'limit_cron_type' => 'url',
'limit_cron_frequency' => 5,
'limit_traffic_quota' => -1,
'limit_client' => 0,
'parent_client_id' => 0,
'language' => 'fr',
'usertheme' => 'default',
'template_master' => 0,
'template_additional' => '',
'created_at' => 0,
'default_mailserver' => 1,
];
$params = array_merge($defaultParams, $clientData);
$clientId = $this->call('client_add', [$resellerId, $params]);
$client = $this->call('client_get', [(int) $clientId]);
$this->clearAllCache();
return [
'client_id' => (int) $clientId,
'groupid' => (int) ($client['groupid'] ?? $clientId),
'system_group' => 'client'.$clientId,
];
}
/**
* Update website to assign it to a different client
*
* @throws Exception
*/
public function updateWebsiteClient(int $domainId, int $newClientId): bool
{
$websiteRecord = $this->call('sites_web_domain_get', ['domain_id' => $domainId]);
if (! is_array($websiteRecord) || empty($websiteRecord)) {
throw new \RuntimeException("Website with domain_id {$domainId} not found");
}
// sys_groupid n'est pas trouvable dans les données client
// $websiteRecord['sys_groupid'] = $newClientId;
$websiteRecord['system_group'] = 'client'.$newClientId;
$result = $this->call('sites_web_domain_update', [
0,
$domainId,
$websiteRecord,
]);
$this->clearAllCache();
return (bool) $result;
}
}

View File

@@ -5,10 +5,12 @@ namespace App\Services;
use App\Events\MemberRegistered;
use App\Models\Member;
use App\Models\MemberGroup;
use App\Models\MemberType;
use App\Models\Package;
use App\Notifications\MemberDeactivatedAdminNotification;
use App\Notifications\MemberDeactivatedMemberNotification;
use App\Notifications\MemberNewRequestAdminNotification;
use App\Notifications\MemberNewRequestMemberNotification;
use Illuminate\Support\Facades\Notification;
class MemberService
@@ -21,11 +23,14 @@ class MemberService
// Check if the member already exists
$member = Member::where('email', $data['email'])->first();
$memberType = MemberType::where('identifier', $data['member_type'])->first();
if (! $member) {
// Create a new member
$member = new Member;
$member->status = 'pending';
$member->nature = 'physical';
$member->type_id = $memberType?->id;
$member->group_id = MemberGroup::where('identifier', 'website')->first()->id ?? null;
$member->lastname = $data['lastname'];
$member->firstname = $data['firstname'];
@@ -36,6 +41,11 @@ class MemberService
$member->city = $data['city'];
$member->country = 'FR';
$member->phone1 = $data['phone1'];
if (! empty($data['desired_retzien_email'])) {
$member->retzien_email = $data['desired_retzien_email'].'@retzien.fr';
}
$member->save();
}
@@ -44,16 +54,19 @@ class MemberService
->firstOrFail();
// Create a new membership
$member->memberships()->create([
$membership = $member->memberships()->create([
'status' => 'pending',
'package_id' => $package->id ?? null,
'amount' => $data['amount'],
'payment_status' => 'unpaid',
]);
$membershipUrl = route('filament.admin.resources.memberships.edit', ['record' => $membership->id]);
Notification::route('mail', config('app.admin_email'))
->notify(new MemberNewRequestAdminNotification($member, $package, (float) $data['amount']));
->notify(new MemberNewRequestAdminNotification($member, $package, (float) $data['amount'], $membershipUrl));
$member->notify(new MemberNewRequestMemberNotification($member, $package));
event(new MemberRegistered($member));

BIN
assets/LRL-roxane.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

View File

@@ -14,6 +14,7 @@ return [
*/
'name' => env('APP_NAME', 'Laravel'),
'front_name' => env('FRONT_NAME', 'Laravel'),
/*
|--------------------------------------------------------------------------
@@ -41,6 +42,8 @@ return [
'debug' => (bool) env('APP_DEBUG', false),
'debug_mail_token' => env('DEBUG_MAIL_TOKEN'),
/*
|--------------------------------------------------------------------------
| Application URL

View File

@@ -4,7 +4,6 @@ namespace Database\Factories;
use App\Models\Member;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Carbon;
class MemberFactory extends Factory
{
@@ -13,18 +12,16 @@ class MemberFactory extends Factory
public function definition(): array
{
return [
'keycloak_id' => $this->faker->word(),
'email' => $this->faker->unique()->safeEmail(),
'firstname' => $this->faker->firstName(),
'lastname' => $this->faker->lastName(),
'phone' => $this->faker->phoneNumber(),
'address' => $this->faker->address(),
'phone1' => $this->faker->phoneNumber(),
'address' => $this->faker->streetAddress(),
'city' => $this->faker->city(),
'zipcode' => $this->faker->word(),
'last_login_at' => Carbon::now(),
'created_at' => Carbon::now(),
'updated_at' => Carbon::now(),
'deleted_at' => Carbon::now(),
'zipcode' => $this->faker->postcode(),
'country' => 'FR',
'status' => 'pending',
'nature' => 'physical',
];
}
}

View File

@@ -10,6 +10,7 @@ class DatabaseSeeder extends Seeder
{
$this->call([
MemberGroupSeeder::class,
MemberTypeSeeder::class,
PackageSeeder::class,
ServiceSeeder::class,
UserSeeder::class,

View File

@@ -0,0 +1,40 @@
<?php
namespace Database\Seeders;
use App\Models\MemberType;
use Illuminate\Database\Seeder;
class MemberTypeSeeder extends Seeder
{
public function run(): void
{
$types = [
[
'identifier' => 'individual',
'name' => 'Particulier',
'description' => 'Personne physique',
],
[
'identifier' => 'company',
'name' => 'Entreprise',
'description' => 'Personne morale (société, auto-entrepreneur, etc.)',
],
[
'identifier' => 'association',
'name' => 'Association',
'description' => 'Association loi 1901',
],
];
foreach ($types as $type) {
MemberType::updateOrCreate(
['identifier' => $type['identifier']],
[
'name' => $type['name'],
'description' => $type['description'],
]
);
}
}
}

View File

@@ -9,6 +9,45 @@ class NotificationTemplateSeeder extends Seeder
{
public function run(): void
{
NotificationTemplate::updateOrCreate(
['identifier' => 'member_new_request_member'],
[
'name' => 'Nouvelle demande d\'adhésion — membre',
'subject' => 'Votre demande d\'adhésion a bien été reçue — {app_name}',
'body' => '<p>Bonjour {member_name},</p>'
.'<p>Nous avons bien reçu votre demande d\'adhésion pour la formule <strong>{package_name}</strong>.</p>'
.'<p>Votre dossier est en attente de validation par notre équipe. Vous recevrez un e-mail dès que votre adhésion aura été traitée.</p>'
.'<p>Merci pour votre confiance et bienvenue dans l\'association !</p>',
'variables' => [
'member_name' => 'Nom complet du membre',
'package_name' => 'Nom de la formule choisie',
'app_name' => 'Nom de l\'application',
],
'is_active' => true,
]
);
NotificationTemplate::updateOrCreate(
['identifier' => 'membership_validated'],
[
'name' => 'Adhésion validée — membre',
'subject' => 'Votre adhésion a été validée — {app_name}',
'body' => '<p>Bonjour {member_name},</p>'
.'<p>Votre adhésion pour la formule <strong>{package_name}</strong> a été validée par notre équipe.</p>'
.'<p><strong>Début :</strong> {start_date}<br><strong>Fin :</strong> {end_date}</p>'
.'<p>Vous pouvez dès à présent accéder à vos services. Pour toute question, n\'hésitez pas à nous contacter.</p>'
.'<p>Merci pour votre adhésion !</p>',
'variables' => [
'member_name' => 'Nom complet du membre',
'package_name' => 'Nom de la formule',
'start_date' => 'Date de début de l\'adhésion',
'end_date' => 'Date de fin de l\'adhésion',
'app_name' => 'Nom de l\'application',
],
'is_active' => true,
]
);
NotificationTemplate::updateOrCreate(
['identifier' => 'subscription_expired_phase1'],
[
@@ -61,7 +100,7 @@ class NotificationTemplateSeeder extends Seeder
.'<strong>Formule :</strong> {package_name}<br>'
.'<strong>Montant :</strong> {amount} €'
.'</p>'
.'<p><a href="{member_url}" style="display:inline-block;padding:10px 20px;background:#f5a623;color:#000;font-weight:bold;text-decoration:none;border:3px solid #000;border-radius:6px;">Voir la fiche adhérent</a></p>',
.'<p><a href="{membership_url}" style="display:inline-block;padding:10px 20px;background:#f48fb1;color:#000;font-weight:bold;text-decoration:none;border:3px solid #000;border-radius:6px;">Voir la demande d\'adhésion</a></p>',
'variables' => [
'member_name' => 'Nom complet du membre',
'member_email' => 'Adresse email du membre',
@@ -69,7 +108,7 @@ class NotificationTemplateSeeder extends Seeder
'member_address' => 'Adresse postale du membre',
'package_name' => 'Nom de la formule choisie',
'amount' => 'Montant de la cotisation',
'member_url' => 'URL de la fiche dans le back office',
'membership_url' => 'URL de la fiche d\'adhésion dans le back office',
'app_name' => 'Nom de l\'application',
],
'is_active' => true,
@@ -130,6 +169,25 @@ class NotificationTemplateSeeder extends Seeder
]
);
NotificationTemplate::updateOrCreate(
['identifier' => 'member_renewal_reminder'],
[
'name' => 'Relance adhésion — générique',
'subject' => 'Rappel concernant votre adhésion — {app_name}',
'body' => '<p>Bonjour {member_name},</p>'
.'<p>Nous vous contactons au sujet de votre adhésion au sein de notre association.</p>'
.'<p>Si votre adhésion est arrivée à expiration ou si vous n\'avez pas encore finalisé votre inscription, '
.'nous vous invitons à régulariser votre situation afin de continuer à bénéficier de l\'ensemble de nos services.</p>'
.'<p>Pour toute question ou besoin d\'assistance, n\'hésitez pas à nous contacter.</p>'
.'<p>Cordialement,<br>L\'équipe {app_name}</p>',
'variables' => [
'member_name' => 'Nom complet du membre',
'app_name' => 'Nom de l\'application',
],
'is_active' => true,
]
);
NotificationTemplate::updateOrCreate(
['identifier' => 'admin_password_reset'],
[

View File

@@ -12,20 +12,20 @@ class PackageSeeder extends Seeder
$packages = [
[
'identifier' => 'custom',
'name' => 'Sur-mesure',
'description' => 'Calcul du nombre de mois restant dans l\'année',
'name' => 'Année en cours',
'description' => 'Mois restants dans l\'année civile en cours (1€/mois)',
'price' => '1.00',
],
[
'identifier' => 'one-year',
'name' => 'Un an',
'description' => '12 mois à compter de la date de validation de l\'adhésion du membre',
'name' => 'Année en cours + 1 an',
'description' => 'Jusqu\'à la fin de l\'année civile suivante (1€/mois)',
'price' => '12.00',
],
[
'identifier' => 'two-years',
'name' => 'Deux ans',
'description' => '24 mois à compter de la date de validation de l\'adhésion du membre',
'name' => 'Année en cours + 2 ans',
'description' => 'Jusqu\'à la fin de la 2e année civile suivante (1€/mois)',
'price' => '24.00',
],
];
@@ -42,4 +42,4 @@ class PackageSeeder extends Seeder
);
}
}
}
}

View File

@@ -18,6 +18,11 @@ return [
'country' => 'Country',
'phone1' => 'Phone 1',
'phone2' => 'Phone 2',
'type_id' => 'Member type',
'individual' => 'Individual',
'company' => 'Company',
'association' => 'Association',
'retzien_email' => 'Retzien email',
'public_membership' => 'Public membership',
'created_at' => 'Created at',
'updated_at' => 'Updated at',

View File

@@ -45,9 +45,12 @@ return [
'ispconfig_web' => 'Web Hosting',
'nextcloud' => 'NextCloud',
'listmonk' => 'Listmonk',
'author_not_set' => 'Awaiting author',
],
'actions' => [
'view_profile' => 'View member profile',
'validate' => 'Validate membership',
'validate_missing_dates' => 'Please fill in the start and end dates before validating.',
],
];

View File

@@ -28,6 +28,11 @@ return [
'country' => 'Pays',
'phone1' => 'Téléphone fixe',
'phone2' => 'Téléphone portable',
'type_id' => 'Type d\'adhérent',
'individual' => 'Particulier',
'company' => 'Entreprise',
'association' => 'Association',
'retzien_email' => 'Adresse mail Retzien',
'public_membership' => 'Adhésion publique',
'created_at' => 'Créé le',
'updated_at' => 'Mis à jour le',

View File

@@ -45,9 +45,12 @@ return [
'ispconfig_web' => 'Hébergements Web',
'nextcloud' => 'NextCloud',
'listmonk' => 'Listmonk',
'author_not_set' => 'En attente de l\'auteur',
],
'actions' => [
'view_profile' => 'Voir le profil du membre',
'validate' => 'Valider l\'adhésion',
'validate_missing_dates' => 'Veuillez renseigner les dates de début et de fin avant de valider.',
],
];

1212
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -54,6 +54,7 @@
"input-otp": "^1.4.2",
"laravel-vite-plugin": "^2.0",
"lucide-react": "^0.475.0",
"radix-ui": "^1.6.7",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^3.0.1",

View File

@@ -1,7 +1,7 @@
import { queryParams, type RouteQueryOptions, type RouteDefinition, type RouteFormDefinition } from './../../../../../wayfinder'
/**
* @see \App\Http\Controllers\Forms\MembershipFormController::create
* @see app/Http/Controllers/Forms/MembershipFormController.php:18
* @see app/Http/Controllers/Forms/MembershipFormController.php:19
* @route '/membership'
*/
export const create = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({
@@ -16,7 +16,7 @@ create.definition = {
/**
* @see \App\Http\Controllers\Forms\MembershipFormController::create
* @see app/Http/Controllers/Forms/MembershipFormController.php:18
* @see app/Http/Controllers/Forms/MembershipFormController.php:19
* @route '/membership'
*/
create.url = (options?: RouteQueryOptions) => {
@@ -25,7 +25,7 @@ create.url = (options?: RouteQueryOptions) => {
/**
* @see \App\Http\Controllers\Forms\MembershipFormController::create
* @see app/Http/Controllers/Forms/MembershipFormController.php:18
* @see app/Http/Controllers/Forms/MembershipFormController.php:19
* @route '/membership'
*/
create.get = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({
@@ -35,7 +35,7 @@ create.get = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({
/**
* @see \App\Http\Controllers\Forms\MembershipFormController::create
* @see app/Http/Controllers/Forms/MembershipFormController.php:18
* @see app/Http/Controllers/Forms/MembershipFormController.php:19
* @route '/membership'
*/
create.head = (options?: RouteQueryOptions): RouteDefinition<'head'> => ({
@@ -45,7 +45,7 @@ create.head = (options?: RouteQueryOptions): RouteDefinition<'head'> => ({
/**
* @see \App\Http\Controllers\Forms\MembershipFormController::create
* @see app/Http/Controllers/Forms/MembershipFormController.php:18
* @see app/Http/Controllers/Forms/MembershipFormController.php:19
* @route '/membership'
*/
const createForm = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
@@ -55,7 +55,7 @@ const createForm = (options?: RouteQueryOptions): RouteFormDefinition<'get'> =>
/**
* @see \App\Http\Controllers\Forms\MembershipFormController::create
* @see app/Http/Controllers/Forms/MembershipFormController.php:18
* @see app/Http/Controllers/Forms/MembershipFormController.php:19
* @route '/membership'
*/
createForm.get = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
@@ -65,7 +65,7 @@ createForm.get = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
/**
* @see \App\Http\Controllers\Forms\MembershipFormController::create
* @see app/Http/Controllers/Forms/MembershipFormController.php:18
* @see app/Http/Controllers/Forms/MembershipFormController.php:19
* @route '/membership'
*/
createForm.head = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
@@ -82,7 +82,7 @@ create.form = createForm
/**
* @see \App\Http\Controllers\Forms\MembershipFormController::store
* @see app/Http/Controllers/Forms/MembershipFormController.php:42
* @see app/Http/Controllers/Forms/MembershipFormController.php:59
* @route '/membership'
*/
export const store = (options?: RouteQueryOptions): RouteDefinition<'post'> => ({
@@ -97,7 +97,7 @@ store.definition = {
/**
* @see \App\Http\Controllers\Forms\MembershipFormController::store
* @see app/Http/Controllers/Forms/MembershipFormController.php:42
* @see app/Http/Controllers/Forms/MembershipFormController.php:59
* @route '/membership'
*/
store.url = (options?: RouteQueryOptions) => {
@@ -106,7 +106,7 @@ store.url = (options?: RouteQueryOptions) => {
/**
* @see \App\Http\Controllers\Forms\MembershipFormController::store
* @see app/Http/Controllers/Forms/MembershipFormController.php:42
* @see app/Http/Controllers/Forms/MembershipFormController.php:59
* @route '/membership'
*/
store.post = (options?: RouteQueryOptions): RouteDefinition<'post'> => ({
@@ -116,7 +116,7 @@ store.post = (options?: RouteQueryOptions): RouteDefinition<'post'> => ({
/**
* @see \App\Http\Controllers\Forms\MembershipFormController::store
* @see app/Http/Controllers/Forms/MembershipFormController.php:42
* @see app/Http/Controllers/Forms/MembershipFormController.php:59
* @route '/membership'
*/
const storeForm = (options?: RouteQueryOptions): RouteFormDefinition<'post'> => ({
@@ -126,7 +126,7 @@ const storeForm = (options?: RouteQueryOptions): RouteFormDefinition<'post'> =>
/**
* @see \App\Http\Controllers\Forms\MembershipFormController::store
* @see app/Http/Controllers/Forms/MembershipFormController.php:42
* @see app/Http/Controllers/Forms/MembershipFormController.php:59
* @route '/membership'
*/
storeForm.post = (options?: RouteQueryOptions): RouteFormDefinition<'post'> => ({

View File

@@ -6,7 +6,7 @@ export function AboutSection() {
<section id="about-section" className="w-full py-16">
<div className="max-w-7xl mx-auto px-4">
<SectionHeading title="Qui sommes-nous ?" color="secondary"
subtitle="Le Retzien Libre, cest une association qui promeut lauto-hébergement et la décentralisation des services en ligne depuis 2017."
subtitle="Le Retzien Libre est une association qui promeut lauto-hébergement et la décentralisation des services en ligne depuis 2017."
align='left'/>
<div
className="nb-shadow-static bg-white rounded-4xl mt-10 px-10 pt-20 pb-10">
@@ -14,14 +14,14 @@ export function AboutSection() {
<div className="flex flex-col gap-3 lg:border-r-2 border-black lg:pr-10 border-0">
<h3 className="text-xl text-primary font-semibold">Une association locale</h3>
<p>
Nous voulons vous proposer à travers des outils libres, ouverts et solidaires, de
Nous vous proposons, à travers des outils libres, ouverts et solidaires, de
quitter l'industrie du G.A.F.A.M.
</p>
</div>
<div className="flex flex-col gap-3 border-r-2 border-black pr-10">
<h3 className="text-xl text-primary font-semibold">Notre mission</h3>
<p>
Nous nous positionnons comme "A.M.A.P. informatique", délivrant des services "bio",
Nous sommes comme une "A.M.A.P. informatique", délivrant des services "bio",
"éthiques" et "locaux" dans le Pays de Retz et Nantes.
</p>
</div>
@@ -34,7 +34,12 @@ export function AboutSection() {
</div>
</div>
<div className="w-full flex justify-center mt-20">
<Button variant="default">En savoir plus en lisant notre Blog</Button>
<Button
variant="default"
onClick={() => window.open('https://blog.retzien.fr/index.php', '_blank')}
>
En savoir plus en lisant notre Blog
</Button>
</div>
</div>
</div>

View File

@@ -23,8 +23,8 @@ export function AlternativeSection() {
Notre alternative : Le Retzien Libre
</h2>
<p>
Ici, pas dexploitation des vos données personnelles à des fins commerciales, ni de dépense aux services centralisés.<br/>
Le Retzien Libre, cest une association locale engagée pour la promotion du logiciel libre et la protection de vos données personnelles.
Ici, pas dexploitation des vos données personnelles à des fins commerciales, ni de dépendance aux services centralisés.<br/>
Le Retzien Libre est une association locale engagée pour la promotion du logiciel libre et la protection de vos données personnelles.
</p>
{auth.user ? (
<Link href={dashboard()}>

View File

@@ -53,7 +53,7 @@ const services: Service[] = [
},
{
title: 'Partage de fichiers',
description: 'Partager facielement vos fichiers en toute sécurité',
description: 'Partager facilement vos fichiers en toute sécurité',
colorTitle: 'black',
bgTitle: 'white',
bgColor: 'primary',

View File

@@ -1,5 +1,5 @@
import {Link} from '@inertiajs/react';
import {contact, home, membership} from '@/routes';
import {contact, faq, home, membership} from '@/routes';
import AppLogoIcon from '@/components/app-logo-icon';
import LogoChaton from '@/img/utils/logo-chaton.png';
@@ -32,6 +32,8 @@ export function Footer() {
<Link href={home()} className="text-sm text-white no-underline hover:underline">Accueil</Link>
<Link href={contact()} className="text-sm text-white no-underline hover:underline">Contact</Link>
<Link href={membership()} className="text-sm text-white no-underline hover:underline">Adhérer</Link>
<Link href={faq()} className="text-sm text-white no-underline hover:underline">FAQ</Link>
<a href="https://doc.retzien.fr" target="_blank" rel="noopener noreferrer" className="text-sm text-white no-underline hover:underline">Documentation</a>
</nav>
</div>
<div className="flex flex-col lg:flex-row lg:justify-between gap-4 border-t border-black/20 pt-6 text-sm">

View File

@@ -0,0 +1,64 @@
import * as React from "react"
import { ChevronDownIcon } from "lucide-react"
import { Accordion as AccordionPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"flex flex-1 items-start justify-between gap-4 rounded-md border-0 shadow-none py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDownIcon className="pointer-events-none size-4 shrink-0 translate-y-0.5 text-muted-foreground transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@@ -34,7 +34,7 @@ function RadioGroupItem({
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary stroke-0 absolute top-1/2 -left-2 size-5 -translate-x-1/2 -translate-y-1/2" />
<CircleIcon className="fill-secondary stroke-0 absolute top-1/2 -left-2 size-5 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)

View File

@@ -66,9 +66,9 @@ export default function NavGuestLayout() {
<Link href="#services" className="px-5 py-1.5 text-lg text-[#1b1b18] dark:text-[#EDEDEC] no-underline hover:underline">
Nos Services
</Link>
<Link href="#" className="px-5 py-1.5 text-lg text-[#1b1b18] dark:text-[#EDEDEC] no-underline hover:underline">
<a href="https://blog.retzien.fr/index.php" target="_blank" rel="noopener noreferrer" className="px-5 py-1.5 text-lg text-[#1b1b18] dark:text-[#EDEDEC] no-underline hover:underline">
Le Blog
</Link>
</a>
<Link href={contact()} className="px-5 py-1.5 text-lg text-[#1b1b18] dark:text-[#EDEDEC] no-underline hover:underline">
Contact
</Link>
@@ -165,9 +165,9 @@ export default function NavGuestLayout() {
<Link href="#services" onClick={closeMenu} className="text-lg py-3 border-b border-black/10 dark:border-white/10 no-underline">
<span className="text-black hover:underline">Nos Services</span>
</Link>
<Link href="#" onClick={closeMenu} className="text-lg py-3 border-b border-black/10 dark:border-white/10 no-underline">
<a href="https://blog.retzien.fr/index.php" target="_blank" onClick={closeMenu} className="text-lg py-3 border-b border-black/10 dark:border-white/10 no-underline">
<span className="text-black hover:underline">Le Blog</span>
</Link>
</a>
<Link href={contact()} onClick={closeMenu} className="text-lg py-3 no-underline">
<span className="text-black hover:underline">Contact</span>
</Link>

112
resources/js/pages/faq.tsx Normal file
View File

@@ -0,0 +1,112 @@
import { Head } from '@inertiajs/react';
import NavGuestLayout from '@/layouts/nav-guest-layout';
import { Footer } from '@/components/footer';
import { Container } from '@/components/common/Container';
import { SectionHeading } from '@/components/common/SectionHeading';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
const faqItems = [
{
question: "Qu'est-ce que Le Retzien Libre ?",
answer: "Le Retzien Libre est une association locale engagée pour un internet éthique, libre et respectueux de la vie privée. Nous proposons des services numériques basés sur des logiciels libres, hébergés localement, en alternative aux géants du numérique.",
},
{
question: "Comment adhérer à l'association ?",
answer: "Vous pouvez adhérer directement en ligne via notre formulaire d'adhésion. Choisissez votre formule (année en cours, 1 an ou 2 ans), remplissez vos informations et validez. Notre équipe traitera votre demande dans les meilleurs délais.",
},
{
question: "Quels services sont inclus dans l'adhésion ?",
answer: "L'adhésion inclut l'accès à l'ensemble de nos services : boîte mail @retzien.fr, stockage cloud via NextCloud, hébergement web, listes de diffusion et bien plus. Consultez notre documentation pour la liste complète des services disponibles.",
},
{
question: "Combien coûte l'adhésion ?",
answer: "L'adhésion est à 1€ par mois. Le montant est calculé en fonction de la durée choisie : reste de l'année en cours, année en cours + 1 an, ou année en cours + 2 ans. Les adhésions sont basées sur l'année civile (janvier à décembre).",
},
{
question: "Comment fonctionne ma boîte mail @retzien.fr ?",
answer: "Lors de votre adhésion, vous pouvez indiquer le nom de boîte mail souhaité (ex : prenom.nom@retzien.fr). Une fois votre adhésion validée, votre boîte sera créée et vous recevrez vos identifiants de connexion. Vous pouvez la consulter via webmail ou la configurer dans votre client mail préféré.",
},
{
question: "Que se passe-t-il à l'expiration de mon adhésion ?",
answer: "Vous recevrez un rappel par e-mail avant l'expiration de votre adhésion. Si vous ne renouvelez pas, vos services seront suspendus. Vos données sont conservées pendant une période raisonnable pour vous permettre de renouveler sans perte.",
},
{
question: "Comment contacter l'association ?",
answer: "Vous pouvez nous joindre via le formulaire de contact disponible sur le site, ou directement par e-mail. Pour les questions techniques, consultez d'abord notre documentation en ligne.",
},
{
question: "Où trouver la documentation technique ?",
answer: "Notre documentation complète est disponible à l'adresse doc.retzien.fr. Vous y trouverez des guides de configuration pour tous nos services, des tutoriels et une base de connaissances.",
},
];
export default function Faq() {
return (
<>
<Head title="Foire aux questions" />
<div className="flex flex-col min-h-screen bg-white dark:bg-[#0a0a0a] text-[#1b1b18] dark:text-[#EDEDEC]">
<div className="flex flex-col items-center px-4">
<NavGuestLayout />
</div>
<main className="flex-1 py-12">
<Container className="flex flex-col gap-10 max-w-4xl mx-auto">
<SectionHeading
title="Foire aux questions"
color="primary"
subtitle="Retrouvez les réponses aux questions les plus fréquentes sur notre association et nos services."
align="left"
/>
<Accordion type="single" collapsible className="w-full flex flex-col gap-3">
{faqItems.map((item, index) => (
<AccordionItem
key={index}
value={`item-${index}`}
className="nb-shadow-static rounded-2xl border-3 border-black px-5 overflow-hidden"
>
<AccordionTrigger className="text-left font-semibold text-base py-4 hover:no-underline">
{item.question}
</AccordionTrigger>
<AccordionContent className="text-muted-foreground pb-4">
{item.answer}
</AccordionContent>
</AccordionItem>
))}
</Accordion>
<div className="bg-primary dark:bg-[#171717] nb-shadow-static rounded-2xl p-6 text-center">
<p className="text-accent font-semibold mb-2">Vous n'avez pas trouvé votre réponse ?</p>
<p className="text-sm text-muted-foreground mb-4">
Consultez notre documentation complète ou contactez-nous directement.
</p>
<div className="flex flex-wrap justify-center gap-3">
<a
href="https://doc.retzien.fr"
target="_blank"
rel="noopener noreferrer"
className="nb-shadow inline-flex items-center justify-center rounded-md text-sm font-bold bg-secondary text-secondary-foreground hover:bg-secondary/80 h-10 px-4 py-2 no-underline"
>
Documentation
</a>
<a
href="/contact"
className="nb-shadow inline-flex items-center justify-center rounded-md text-sm font-bold bg-accent text-white hover:bg-accent/80 h-10 px-4 py-2 no-underline"
>
Nous contacter
</a>
</div>
</div>
</Container>
</main>
<Footer />
</div>
</>
);
}

View File

@@ -54,6 +54,14 @@ export default function Contact() {
className="mx-auto"
/>
<p className="text-sm text-muted-foreground max-w-4xl mx-auto">
Pensez à consulter notre{' '}
<a href="/faq" className="text-primary underline hover:no-underline">FAQ</a>
{' '}ou notre{' '}
<a href="https://doc.retzien.fr" target="_blank" rel="noopener noreferrer" className="text-primary underline hover:no-underline">documentation</a>
{' '}avant de nous écrire.
</p>
{showFlashMessage && <FlashMessage messages={flash ?? {}} />}
<Form

View File

@@ -8,6 +8,7 @@ import InputError from '@/components/input-error';
import { Button } from '@/components/ui/button';
import NavGuestLayout from '@/layouts/nav-guest-layout';
import { Checkbox } from '@/components/ui/checkbox';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { useEffect, useState } from 'react';
import { PageProps } from '@/types';
import { FlashMessage } from '@/components/flash-message';
@@ -17,10 +18,26 @@ import { Footer } from '@/components/footer';
import IllustrationLogo from "@/img/utils/lrl-logo-full.svg";
export default function Membership() {
const { flash, plans, services, captcha_question } = usePage().props as PageProps;
const { flash, plans, services, member_types, current_year, captcha_question } = usePage().props as PageProps;
const [showFlashMessage, setFlashMessage] = useState(!!flash);
const [selectedPlan, setSelectedPlan] = useState(plans?.[0]?.identifier ?? null);
const [amount, setAmount] = useState(plans?.[0]?.price ?? 0);
const [memberType, setMemberType] = useState(member_types?.[0]?.identifier ?? 'individual');
const [phone1Value, setPhone1Value] = useState('');
const [zipcodeValue, setZipcodeValue] = useState('');
const [retzienEmailValue, setRetzienEmailValue] = useState('');
const phone1LocalError = phone1Value.length > 0 && !/^\d{10}$/.test(phone1Value)
? 'Le numéro doit contenir exactement 10 chiffres.'
: null;
const zipcodeLocalError = zipcodeValue.length > 0 && !/^\d{5}$/.test(zipcodeValue)
? 'Le code postal doit contenir exactement 5 chiffres.'
: null;
const retzienEmailLocalError = retzienEmailValue.length > 0 && !/^[a-z0-9._-]+$/.test(retzienEmailValue)
? 'Caractères autorisés : lettres minuscules, chiffres, points, tirets.'
: null;
useEffect(() => {
if (plans && selectedPlan) {
@@ -37,6 +54,20 @@ export default function Membership() {
}
}, [flash]);
const getPlanLabel = (plan: NonNullable<typeof plans>[number]) => {
if (!current_year || !plan.months) return plan.name;
if (plan.identifier === 'custom') {
return `Année ${current_year} (${plan.months} mois restants)`;
}
if (plan.identifier === 'one-year') {
return `Années ${current_year}${current_year + 1}`;
}
if (plan.identifier === 'two-years') {
return `Années ${current_year}${current_year + 2}`;
}
return plan.name;
};
return (
<>
<Head title="Adhérer au Retzien Libre" />
@@ -75,6 +106,28 @@ export default function Membership() {
className="rounded-lg max-w-md w-full pt-4"
/>
</div>
{/* Member type radio */}
<div className="grid gap-2">
<Label className="text-accent font-semibold">Vous êtes*</Label>
<RadioGroup
value={memberType}
onValueChange={setMemberType}
className="flex flex-wrap gap-4"
>
{member_types?.map((type) => (
<div key={type.identifier} className="flex items-center gap-2">
<RadioGroupItem value={type.identifier} id={`member-type-${type.identifier}`} />
<Label htmlFor={`member-type-${type.identifier}`} className="cursor-pointer">
{type.name}
</Label>
</div>
))}
</RadioGroup>
<input type="hidden" name="member_type" value={memberType} />
<InputError message={errors.member_type} />
</div>
<h2 className="text-lg font-semibold text-accent">Vos informations</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
@@ -98,8 +151,20 @@ export default function Membership() {
<div className="grid gap-1">
<Label htmlFor="phone1">Téléphone*</Label>
<Input id="phone1" name="phone1" type="tel" required tabIndex={4} autoComplete="tel" placeholder="Votre numéro de téléphone" />
<InputError message={errors.phone1} />
<Input
id="phone1"
name="phone1"
type="tel"
required
tabIndex={4}
autoComplete="tel"
inputMode="numeric"
maxLength={10}
placeholder="0612345678"
value={phone1Value}
onChange={(e) => setPhone1Value(e.target.value.replace(/\D/g, '').slice(0, 10))}
/>
<InputError message={phone1LocalError ?? errors.phone1} />
</div>
<div className="grid gap-1">
@@ -108,21 +173,56 @@ export default function Membership() {
<InputError message={errors.company} />
</div>
<div className="grid gap-1">
<Label htmlFor="desired_retzien_email">
Adresse mail Retzien souhaitée <span className="text-muted-foreground text-xs">(facultatif)</span>
</Label>
<div className="group/email flex items-center gap-0 rounded-md has-[:focus-visible]:shadow-[4px_4px_0px_rgba(0,0,0,1)] transition duration-100 ease-in-out">
<Input
id="desired_retzien_email"
name="desired_retzien_email"
type="text"
tabIndex={6}
autoComplete="off"
placeholder="prenom.nom"
className="rounded-r-none focus-visible:shadow-none"
value={retzienEmailValue}
onChange={(e) => setRetzienEmailValue(e.target.value.toLowerCase().replace(/[^a-z0-9._-]/g, ''))}
/>
<span className="inline-flex items-center px-3 h-9 border-3 border-l-0 border-black rounded-r-md bg-muted text-sm text-muted-foreground whitespace-nowrap">
@retzien.fr
</span>
</div>
<InputError message={retzienEmailLocalError ?? errors.desired_retzien_email} />
</div>
<div className="grid gap-1">
<Label htmlFor="address">Adresse*</Label>
<Input id="address" name="address" type="text" required tabIndex={6} autoComplete="street-address" placeholder="Votre adresse" />
<Input id="address" name="address" type="text" required tabIndex={7} autoComplete="street-address" placeholder="Votre adresse" />
<InputError message={errors.address} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-1">
<Label htmlFor="zipcode">Code postal*</Label>
<Input id="zipcode" name="zipcode" type="text" required tabIndex={7} autoComplete="postal-code" placeholder="Code postal" />
<InputError message={errors.zipcode} />
<Input
id="zipcode"
name="zipcode"
type="text"
required
tabIndex={8}
autoComplete="postal-code"
inputMode="numeric"
maxLength={5}
placeholder="44000"
value={zipcodeValue}
onChange={(e) => setZipcodeValue(e.target.value.replace(/\D/g, '').slice(0, 5))}
/>
<InputError message={zipcodeLocalError ?? errors.zipcode} />
</div>
<div className="grid gap-1">
<Label htmlFor="city">Ville*</Label>
<Input id="city" name="city" type="text" required tabIndex={8} autoComplete="address-level2" placeholder="Ville" />
<Input id="city" name="city" type="text" required tabIndex={9} autoComplete="address-level2" placeholder="Ville" />
<InputError message={errors.city} />
</div>
</div>
@@ -140,7 +240,7 @@ export default function Membership() {
<button
key={plan.id}
type="button"
tabIndex={9}
tabIndex={10}
onClick={() => setSelectedPlan(plan.identifier)}
className={cn(
'flex items-center justify-between rounded-xl border-3 border-black px-5 py-4 text-left transition-all duration-150',
@@ -151,14 +251,12 @@ export default function Membership() {
)}
>
<div className="flex flex-col">
<span className="font-bold text-base">{plan.name}</span>
{plan.months != null ? (
<span className="text-xs text-muted-foreground">
{plan.months} mois × 1/mois
</span>
) : (
<span className="text-xs text-muted-foreground">{plan.description}</span>
)}
<span className="font-bold text-base">{getPlanLabel(plan)}</span>
<span className="text-xs text-muted-foreground">
{plan.months != null
? `${plan.months} mois × 1€/mois`
: plan.description}
</span>
</div>
<span className="text-2xl font-black">{plan.price}</span>
</button>
@@ -170,7 +268,7 @@ export default function Membership() {
<InputError message={errors.package} />
<p className="text-center text-sm text-muted-foreground border-t border-border pt-3">
Montant total : <strong className="text-secondary text-lg">{amount}</strong>
Montant total : <strong className="text-accent text-2xl">{amount}</strong>
</p>
</div>
@@ -188,6 +286,9 @@ export default function Membership() {
</div>
))}
</div>
<div className="text-center">
Et plus encore ...
</div>
</div>
{/* Captcha + CGU + Submit */}
@@ -198,7 +299,7 @@ export default function Membership() {
id="captcha"
name="captcha"
type="text"
tabIndex={10}
tabIndex={11}
placeholder="Votre réponse"
autoComplete="off"
className="max-w-[180px]"
@@ -208,7 +309,7 @@ export default function Membership() {
<div className="flex flex-col gap-1">
<div className="flex items-start gap-3">
<Checkbox id="cgu" name="cgu" tabIndex={11} required className="mt-0.5" />
<Checkbox id="cgu" name="cgu" tabIndex={12} required className="mt-0.5" />
<Label htmlFor="cgu" className="text-sm leading-snug cursor-pointer">
J'ai lu et j'accepte les <a href="#">C.G.U.</a> et je comprends la
nécessité des enregistrements de mes données personnelles.
@@ -220,7 +321,7 @@ export default function Membership() {
<Button
type="submit"
variant="secondary"
tabIndex={12}
tabIndex={13}
className="nb-shadow w-full font-bold text-base py-5"
>
{processing && <LoaderCircle className="h-4 w-4 animate-spin" />}

View File

@@ -365,6 +365,80 @@ dashboardForm.head = (options?: RouteQueryOptions): RouteFormDefinition<'get'> =
dashboard.form = dashboardForm
/**
* @see routes/web.php:20
* @route '/faq'
*/
export const faq = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({
url: faq.url(options),
method: 'get',
})
faq.definition = {
methods: ["get","head"],
url: '/faq',
} satisfies RouteDefinition<["get","head"]>
/**
* @see routes/web.php:20
* @route '/faq'
*/
faq.url = (options?: RouteQueryOptions) => {
return faq.definition.url + queryParams(options)
}
/**
* @see routes/web.php:20
* @route '/faq'
*/
faq.get = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({
url: faq.url(options),
method: 'get',
})
/**
* @see routes/web.php:20
* @route '/faq'
*/
faq.head = (options?: RouteQueryOptions): RouteDefinition<'head'> => ({
url: faq.url(options),
method: 'head',
})
/**
* @see routes/web.php:20
* @route '/faq'
*/
const faqForm = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
action: faq.url(options),
method: 'get',
})
/**
* @see routes/web.php:20
* @route '/faq'
*/
faqForm.get = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
action: faq.url(options),
method: 'get',
})
/**
* @see routes/web.php:20
* @route '/faq'
*/
faqForm.head = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
action: faq.url({
[options?.mergeQuery ? 'mergeQuery' : 'query']: {
_method: 'HEAD',
...(options?.query ?? options?.mergeQuery ?? {}),
}
}),
method: 'get',
})
faq.form = faqForm
/**
* @see \App\Http\Controllers\Auth\RegisteredUserController::register
* @see app/Http/Controllers/Auth/RegisteredUserController.php:21
@@ -529,7 +603,7 @@ contact.form = contactForm
/**
* @see \App\Http\Controllers\Forms\MembershipFormController::membership
* @see app/Http/Controllers/Forms/MembershipFormController.php:18
* @see app/Http/Controllers/Forms/MembershipFormController.php:19
* @route '/membership'
*/
export const membership = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({
@@ -544,7 +618,7 @@ membership.definition = {
/**
* @see \App\Http\Controllers\Forms\MembershipFormController::membership
* @see app/Http/Controllers/Forms/MembershipFormController.php:18
* @see app/Http/Controllers/Forms/MembershipFormController.php:19
* @route '/membership'
*/
membership.url = (options?: RouteQueryOptions) => {
@@ -553,7 +627,7 @@ membership.url = (options?: RouteQueryOptions) => {
/**
* @see \App\Http\Controllers\Forms\MembershipFormController::membership
* @see app/Http/Controllers/Forms/MembershipFormController.php:18
* @see app/Http/Controllers/Forms/MembershipFormController.php:19
* @route '/membership'
*/
membership.get = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({
@@ -563,7 +637,7 @@ membership.get = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({
/**
* @see \App\Http\Controllers\Forms\MembershipFormController::membership
* @see app/Http/Controllers/Forms/MembershipFormController.php:18
* @see app/Http/Controllers/Forms/MembershipFormController.php:19
* @route '/membership'
*/
membership.head = (options?: RouteQueryOptions): RouteDefinition<'head'> => ({
@@ -573,7 +647,7 @@ membership.head = (options?: RouteQueryOptions): RouteDefinition<'head'> => ({
/**
* @see \App\Http\Controllers\Forms\MembershipFormController::membership
* @see app/Http/Controllers/Forms/MembershipFormController.php:18
* @see app/Http/Controllers/Forms/MembershipFormController.php:19
* @route '/membership'
*/
const membershipForm = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
@@ -583,7 +657,7 @@ const membershipForm = (options?: RouteQueryOptions): RouteFormDefinition<'get'>
/**
* @see \App\Http\Controllers\Forms\MembershipFormController::membership
* @see app/Http/Controllers/Forms/MembershipFormController.php:18
* @see app/Http/Controllers/Forms/MembershipFormController.php:19
* @route '/membership'
*/
membershipForm.get = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
@@ -593,7 +667,7 @@ membershipForm.get = (options?: RouteQueryOptions): RouteFormDefinition<'get'> =
/**
* @see \App\Http\Controllers\Forms\MembershipFormController::membership
* @see app/Http/Controllers/Forms/MembershipFormController.php:18
* @see app/Http/Controllers/Forms/MembershipFormController.php:19
* @route '/membership'
*/
membershipForm.head = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({

View File

@@ -1,6 +1,6 @@
import { queryParams, type RouteQueryOptions, type RouteDefinition, type RouteFormDefinition } from './../../wayfinder'
/**
* @see routes/web.php:20
* @see routes/web.php:21
* @route '/mentions-legales'
*/
export const mentions = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({
@@ -14,7 +14,7 @@ mentions.definition = {
} satisfies RouteDefinition<["get","head"]>
/**
* @see routes/web.php:20
* @see routes/web.php:21
* @route '/mentions-legales'
*/
mentions.url = (options?: RouteQueryOptions) => {
@@ -22,7 +22,7 @@ mentions.url = (options?: RouteQueryOptions) => {
}
/**
* @see routes/web.php:20
* @see routes/web.php:21
* @route '/mentions-legales'
*/
mentions.get = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({
@@ -31,7 +31,7 @@ mentions.get = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({
})
/**
* @see routes/web.php:20
* @see routes/web.php:21
* @route '/mentions-legales'
*/
mentions.head = (options?: RouteQueryOptions): RouteDefinition<'head'> => ({
@@ -40,7 +40,7 @@ mentions.head = (options?: RouteQueryOptions): RouteDefinition<'head'> => ({
})
/**
* @see routes/web.php:20
* @see routes/web.php:21
* @route '/mentions-legales'
*/
const mentionsForm = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
@@ -49,7 +49,7 @@ const mentionsForm = (options?: RouteQueryOptions): RouteFormDefinition<'get'> =
})
/**
* @see routes/web.php:20
* @see routes/web.php:21
* @route '/mentions-legales'
*/
mentionsForm.get = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
@@ -58,7 +58,7 @@ mentionsForm.get = (options?: RouteQueryOptions): RouteFormDefinition<'get'> =>
})
/**
* @see routes/web.php:20
* @see routes/web.php:21
* @route '/mentions-legales'
*/
mentionsForm.head = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
@@ -74,7 +74,7 @@ mentionsForm.head = (options?: RouteQueryOptions): RouteFormDefinition<'get'> =>
mentions.form = mentionsForm
/**
* @see routes/web.php:21
* @see routes/web.php:22
* @route '/conditions-generales'
*/
export const cgu = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({
@@ -88,7 +88,7 @@ cgu.definition = {
} satisfies RouteDefinition<["get","head"]>
/**
* @see routes/web.php:21
* @see routes/web.php:22
* @route '/conditions-generales'
*/
cgu.url = (options?: RouteQueryOptions) => {
@@ -96,7 +96,7 @@ cgu.url = (options?: RouteQueryOptions) => {
}
/**
* @see routes/web.php:21
* @see routes/web.php:22
* @route '/conditions-generales'
*/
cgu.get = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({
@@ -105,7 +105,7 @@ cgu.get = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({
})
/**
* @see routes/web.php:21
* @see routes/web.php:22
* @route '/conditions-generales'
*/
cgu.head = (options?: RouteQueryOptions): RouteDefinition<'head'> => ({
@@ -114,7 +114,7 @@ cgu.head = (options?: RouteQueryOptions): RouteDefinition<'head'> => ({
})
/**
* @see routes/web.php:21
* @see routes/web.php:22
* @route '/conditions-generales'
*/
const cguForm = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
@@ -123,7 +123,7 @@ const cguForm = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
})
/**
* @see routes/web.php:21
* @see routes/web.php:22
* @route '/conditions-generales'
*/
cguForm.get = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
@@ -132,7 +132,7 @@ cguForm.get = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
})
/**
* @see routes/web.php:21
* @see routes/web.php:22
* @route '/conditions-generales'
*/
cguForm.head = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
@@ -148,7 +148,7 @@ cguForm.head = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
cgu.form = cguForm
/**
* @see routes/web.php:22
* @see routes/web.php:23
* @route '/confidentialite'
*/
export const confidentialite = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({
@@ -162,7 +162,7 @@ confidentialite.definition = {
} satisfies RouteDefinition<["get","head"]>
/**
* @see routes/web.php:22
* @see routes/web.php:23
* @route '/confidentialite'
*/
confidentialite.url = (options?: RouteQueryOptions) => {
@@ -170,7 +170,7 @@ confidentialite.url = (options?: RouteQueryOptions) => {
}
/**
* @see routes/web.php:22
* @see routes/web.php:23
* @route '/confidentialite'
*/
confidentialite.get = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({
@@ -179,7 +179,7 @@ confidentialite.get = (options?: RouteQueryOptions): RouteDefinition<'get'> => (
})
/**
* @see routes/web.php:22
* @see routes/web.php:23
* @route '/confidentialite'
*/
confidentialite.head = (options?: RouteQueryOptions): RouteDefinition<'head'> => ({
@@ -188,7 +188,7 @@ confidentialite.head = (options?: RouteQueryOptions): RouteDefinition<'head'> =>
})
/**
* @see routes/web.php:22
* @see routes/web.php:23
* @route '/confidentialite'
*/
const confidentialiteForm = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
@@ -197,7 +197,7 @@ const confidentialiteForm = (options?: RouteQueryOptions): RouteFormDefinition<'
})
/**
* @see routes/web.php:22
* @see routes/web.php:23
* @route '/confidentialite'
*/
confidentialiteForm.get = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
@@ -206,7 +206,7 @@ confidentialiteForm.get = (options?: RouteQueryOptions): RouteFormDefinition<'ge
})
/**
* @see routes/web.php:22
* @see routes/web.php:23
* @route '/confidentialite'
*/
confidentialiteForm.head = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({

View File

@@ -1,7 +1,7 @@
import { queryParams, type RouteQueryOptions, type RouteDefinition, type RouteFormDefinition } from './../../wayfinder'
/**
* @see \App\Http\Controllers\Forms\MembershipFormController::store
* @see app/Http/Controllers/Forms/MembershipFormController.php:42
* @see app/Http/Controllers/Forms/MembershipFormController.php:59
* @route '/membership'
*/
export const store = (options?: RouteQueryOptions): RouteDefinition<'post'> => ({
@@ -16,7 +16,7 @@ store.definition = {
/**
* @see \App\Http\Controllers\Forms\MembershipFormController::store
* @see app/Http/Controllers/Forms/MembershipFormController.php:42
* @see app/Http/Controllers/Forms/MembershipFormController.php:59
* @route '/membership'
*/
store.url = (options?: RouteQueryOptions) => {
@@ -25,7 +25,7 @@ store.url = (options?: RouteQueryOptions) => {
/**
* @see \App\Http\Controllers\Forms\MembershipFormController::store
* @see app/Http/Controllers/Forms/MembershipFormController.php:42
* @see app/Http/Controllers/Forms/MembershipFormController.php:59
* @route '/membership'
*/
store.post = (options?: RouteQueryOptions): RouteDefinition<'post'> => ({
@@ -35,7 +35,7 @@ store.post = (options?: RouteQueryOptions): RouteDefinition<'post'> => ({
/**
* @see \App\Http\Controllers\Forms\MembershipFormController::store
* @see app/Http/Controllers/Forms/MembershipFormController.php:42
* @see app/Http/Controllers/Forms/MembershipFormController.php:59
* @route '/membership'
*/
const storeForm = (options?: RouteQueryOptions): RouteFormDefinition<'post'> => ({
@@ -45,7 +45,7 @@ const storeForm = (options?: RouteQueryOptions): RouteFormDefinition<'post'> =>
/**
* @see \App\Http\Controllers\Forms\MembershipFormController::store
* @see app/Http/Controllers/Forms/MembershipFormController.php:42
* @see app/Http/Controllers/Forms/MembershipFormController.php:59
* @route '/membership'
*/
storeForm.post = (options?: RouteQueryOptions): RouteFormDefinition<'post'> => ({

View File

@@ -58,6 +58,12 @@ export interface Plans {
months?: number | null;
}
export interface MemberTypeOption {
id: number;
identifier: string;
name: string;
}
export interface MembershipService {
name: string;
description: string;
@@ -114,6 +120,8 @@ export interface PageProps {
auth?: Auth;
plans?: Plans[];
services?: MembershipService[];
member_types?: MemberTypeOption[];
current_year?: number;
captcha_question?: string;
member?: DashboardMember | null;

View File

@@ -56,13 +56,13 @@
<body>
<div class="wrapper">
<div class="header">
<h1>{{ config('app.name') }}</h1>
<h1>{{ $appName ?? config('app.name') }}</h1>
</div>
<div class="content">
{!! $body !!}
</div>
<div class="footer">
&copy; {{ date('Y') }} {{ config('app.name') }}. Tous droits réservés.
&copy; {{ date('Y') }} {{ $appName ?? config('app.name') }}. Tous droits réservés.
</div>
</div>
</body>

View File

@@ -53,3 +53,24 @@ Route::get('/test/isp-mails', function() {
});*/
// Test info user on Front
// Temporary mail test route — remove after debugging
Route::get('/test/mail', function (\Illuminate\Http\Request $request) {
$token = config('app.debug_mail_token');
if (! $token || $request->query('token') !== $token) {
abort(403);
}
$to = $request->query('to');
if (! $to || ! filter_var($to, FILTER_VALIDATE_EMAIL)) {
return response()->json(['error' => 'Provide a valid ?to=email@example.com'], 422);
}
\Illuminate\Support\Facades\Mail::raw('Test mail from Roxane — SMTP is working.', function ($message) use ($to) {
$message->to($to)->subject('Roxane — SMTP test');
});
return response()->json(['status' => 'sent', 'to' => $to]);
});

View File

@@ -17,6 +17,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::post('dashboard/service-activation', [DashboardController::class, 'requestServiceActivation'])->name('dashboard.service-activation');
});
Route::get('/faq', fn () => Inertia::render('faq'))->name('faq');
Route::get('/mentions-legales', fn () => Inertia::render('legal/mentions-legales'))->name('legal.mentions');
Route::get('/conditions-generales', fn () => Inertia::render('legal/cgu'))->name('legal.cgu');
Route::get('/confidentialite', fn () => Inertia::render('legal/confidentialite'))->name('legal.confidentialite');
@@ -24,6 +25,6 @@ Route::get('/confidentialite', fn () => Inertia::render('legal/confidentialite')
require __DIR__.'/settings.php';
require __DIR__.'/auth.php';
require __DIR__.'/forms.php';
if (app()->environment('local')) {
if (app()->environment('local', 'staging', 'preprod')) {
require __DIR__.'/dev-routes.php';
}

View File

@@ -0,0 +1,68 @@
<?php
namespace Tests\Feature\Commands;
use App\Enums\IspconfigType;
use App\Models\IspconfigMember;
use App\Models\Member;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class CreateISPWebAccountsTest extends TestCase
{
use RefreshDatabase;
public function test_command_exits_successfully_when_no_members_with_websites(): void
{
$this->artisan('ext:create-isp-accounts')
->expectsOutput('No members with websites found')
->assertExitCode(0);
}
public function test_command_processes_members_regardless_of_status(): void
{
Member::factory()->create([
'status' => 'draft',
'website_url' => 'example.com',
'firstname' => 'John',
'lastname' => 'Doe',
]);
$this->artisan('ext:create-isp-accounts --dry-run')
->expectsOutput('DRY RUN MODE - No changes will be made')
->assertExitCode(0);
}
public function test_command_runs_in_dry_run_mode(): void
{
Member::factory()->create([
'status' => 'valid',
'website_url' => 'example.com',
'firstname' => 'John',
'lastname' => 'Doe',
]);
$this->artisan('ext:create-isp-accounts --dry-run')
->expectsOutput('DRY RUN MODE - No changes will be made')
->assertExitCode(0);
}
public function test_command_skips_member_with_existing_client(): void
{
$member = Member::factory()->create([
'status' => 'valid',
'website_url' => 'example.com',
]);
IspconfigMember::create([
'member_id' => $member->id,
'type' => IspconfigType::WEB,
'ispconfig_client_id' => '123',
]);
$this->artisan('ext:create-isp-accounts --dry-run')
->assertExitCode(0);
$this->assertDatabaseCount('ispconfigs_members', 1);
}
}

View File

@@ -0,0 +1,96 @@
<?php
namespace Tests\Feature;
use App\Models\Member;
use App\Models\NotificationTemplate;
use App\Models\Package;
use App\Notifications\MemberNewRequestAdminNotification;
use App\Notifications\MemberNewRequestMemberNotification;
use App\Services\MemberService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;
class MemberNewRequestMemberNotificationTest extends TestCase
{
use RefreshDatabase;
private function createPackage(): Package
{
return Package::create([
'identifier' => 'one-year',
'name' => 'Adhésion annuelle',
'price' => 12.00,
'is_active' => true,
]);
}
private function memberData(Package $package): array
{
return [
'firstname' => 'Jean',
'lastname' => 'Dupont',
'email' => 'jean.dupont@example.com',
'phone1' => '0600000000',
'address' => '1 rue de la Paix',
'zipcode' => '44000',
'city' => 'Nantes',
'package' => $package->identifier,
'amount' => 12.00,
];
}
public function test_member_receives_confirmation_notification_on_registration(): void
{
Notification::fake();
NotificationTemplate::factory()->create(['identifier' => 'member_new_request_admin', 'is_active' => true]);
NotificationTemplate::factory()->create(['identifier' => 'member_new_request_member', 'is_active' => true]);
$package = $this->createPackage();
(new MemberService)->registerNewMember($this->memberData($package));
$member = Member::where('email', 'jean.dupont@example.com')->firstOrFail();
Notification::assertSentTo($member, MemberNewRequestMemberNotification::class);
}
public function test_confirmation_notification_contains_correct_member_and_package(): void
{
Notification::fake();
NotificationTemplate::factory()->create(['identifier' => 'member_new_request_admin', 'is_active' => true]);
NotificationTemplate::factory()->create(['identifier' => 'member_new_request_member', 'is_active' => true]);
$package = $this->createPackage();
(new MemberService)->registerNewMember($this->memberData($package));
$member = Member::where('email', 'jean.dupont@example.com')->firstOrFail();
Notification::assertSentTo(
$member,
MemberNewRequestMemberNotification::class,
function (MemberNewRequestMemberNotification $notification) use ($member, $package): bool {
return $notification->member->id === $member->id
&& $notification->package->id === $package->id;
}
);
}
public function test_admin_notification_is_also_sent_on_registration(): void
{
Notification::fake();
NotificationTemplate::factory()->create(['identifier' => 'member_new_request_admin', 'is_active' => true]);
NotificationTemplate::factory()->create(['identifier' => 'member_new_request_member', 'is_active' => true]);
$package = $this->createPackage();
(new MemberService)->registerNewMember($this->memberData($package));
Notification::assertSentOnDemand(MemberNewRequestAdminNotification::class);
}
}

View File

@@ -0,0 +1,86 @@
<?php
namespace Tests\Feature;
use App\Models\Member;
use App\Models\Membership;
use App\Models\NotificationTemplate;
use App\Models\Package;
use App\Notifications\MembershipValidatedNotification;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;
class MembershipValidatedNotificationTest extends TestCase
{
use RefreshDatabase;
private function createPendingMembership(): Membership
{
$package = Package::create([
'identifier' => 'one-year',
'name' => 'Adhésion annuelle',
'price' => 12.00,
'is_active' => true,
]);
$member = Member::factory()->create([
'status' => 'pending',
'nature' => 'physical',
]);
return Membership::create([
'member_id' => $member->id,
'package_id' => $package->id,
'status' => 'pending',
'amount' => 12.00,
'payment_status' => 'unpaid',
]);
}
public function test_member_receives_notification_when_membership_is_validated(): void
{
Notification::fake();
NotificationTemplate::factory()->create(['identifier' => 'membership_validated', 'is_active' => true]);
$membership = $this->createPendingMembership();
$membership->update(['status' => 'active']);
$membership->member->notify(new MembershipValidatedNotification($membership->fresh(['member', 'package'])));
Notification::assertSentTo($membership->member, MembershipValidatedNotification::class);
}
public function test_validated_notification_contains_correct_membership(): void
{
Notification::fake();
NotificationTemplate::factory()->create(['identifier' => 'membership_validated', 'is_active' => true]);
$membership = $this->createPendingMembership();
$membership->update(['status' => 'active']);
$freshMembership = $membership->fresh(['member', 'package']);
$freshMembership->member->notify(new MembershipValidatedNotification($freshMembership));
Notification::assertSentTo(
$membership->member,
MembershipValidatedNotification::class,
function (MembershipValidatedNotification $notification) use ($membership): bool {
return $notification->membership->id === $membership->id;
}
);
}
public function test_notification_is_not_sent_when_status_is_not_active(): void
{
Notification::fake();
NotificationTemplate::factory()->create(['identifier' => 'membership_validated', 'is_active' => true]);
$membership = $this->createPendingMembership();
Notification::assertNotSentTo($membership->member, MembershipValidatedNotification::class);
}
}