Compare commits

...

3 Commits

Author SHA1 Message Date
11108bdfcc Basic user management 2026-02-05 00:46:22 +01:00
e280fcfba8 Basic design created 2026-02-03 09:33:18 +01:00
3570f442f5 Basic tenant structure 2026-01-31 20:07:41 +01:00
94 changed files with 3161 additions and 174 deletions

1
.gitignore vendored
View File

@@ -22,3 +22,4 @@
Homestead.json Homestead.json
Homestead.yaml Homestead.yaml
Thumbs.db Thumbs.db
/docker-compose.yaml

View File

@@ -2,6 +2,11 @@
FRONTEND_DIR ?= . FRONTEND_DIR ?= .
setup:
rm -f docker-compose.yaml
cp docker-compose.dev docker-compose.yaml
docker-compose up -d
frontend: frontend:
@cd $(FRONTEND_DIR) && \ @cd $(FRONTEND_DIR) && \
export QT_QPA_PLATFORM=offscreen && \ export QT_QPA_PLATFORM=offscreen && \

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Domains\Dashboard\Controllers;
use App\Providers\AuthCheckProvider;
use App\Providers\InertiaProvider;
use App\Scopes\CommonController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class DashboardController extends CommonController {
public function __invoke(Request $request) {
if ($this->checkAuth()) {
return $this->renderForLoggedInUser($request);
}
return redirect()->intended('/login');
dd('U');
return $this->renderForGuest($request);
}
private function renderForLoggedInUser(Request $request) {
$authCheckProvider = new AuthCheckProvider;
$inertiaProvider = new InertiaProvider('Dashboard/Dashboard', ['appName' => app('tenant')->name]);
return $inertiaProvider->render();
}
private function renderForGuest(Request $request) {
}
}

View File

@@ -0,0 +1,42 @@
<script setup>
import AppLayout from '../../../../resources/js/layouts/AppLayout.vue'
import ShadowedBox from "../../../Views/Components/ShadowedBox.vue";
const props = defineProps({
navbar: Object,
tenant: String,
user: Object,
currentPath: String,
})
</script>
<template>
<AppLayout title='Dashboard' :user="props.user" :navbar="props.navbar" :tenant="props.tenant" :currentPath="props.currentPath">
<diV class="dashboard-widget-container">
<shadowed-box class="dashboard-widget-box" style="width: 60%;">
Meine Anmeldungen
</shadowed-box>
<shadowed-box class="dashboard-widget-box">
Meine Abrechnungen
</shadowed-box>
</diV>
</AppLayout>
</template>
<style>
.dashboard-widget-container {
display: flex;
justify-content: space-around;
flex-wrap: wrap;
position: relative;
}
.dashboard-widget-box {
flex-grow: 1; display: inline-block;
height: 150px;
margin: 0 10px;
}
</style>

View File

@@ -0,0 +1,29 @@
<script setup>
import AppLayout from '../../../../resources/js/layouts/AppLayout.vue'
const props = defineProps({
navbar: Object,
tenant: String,
user: Object,
currentPath: String,
})
</script>
<template>
<AppLayout title='Neue Abrechnung' :user="props.user" :navbar="props.navbar" :tenant="props.tenant" :currentPath="props.currentPath">
<!-- Alles hier wird in den Slot von AppLayout eingefügt -->
<h2>Dashboard Content</h2>
<p>Test 1!
Hier wird mal eine Rechnung erstellt.
Wenn es geht oder auch nicht</p>
{{props.tenant}}
<button @click="$toast.success('Hallo vom Dashboard!')">Test Toaster</button>
<button @click="$toast.error('Soe sieht ein Fehler aus')">Error Toaster</button>
</AppLayout>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Domains\MessageSystem\Actions\SendMessage;
use App\Enumerations\MessageType;
use Illuminate\Support\Facades\Mail;
class SendMessageCommand {
private SendMessageRequest $request;
public function __construct(SendMessageRequest $request) {
$this->request = $request;
}
public function send() : SendMessageResponse {
$response = new SendMessageResponse();
foreach ($this->request->messageTypes as $messageType) {
switch (true) {
case $messageType->value === MessageType::EMAIL->value:
$this->sendAsEmail();
break;
case $messageType->value === MessageType::INTERNAL->value:
$this->sendAsInternalMessage();
break;
}
}
return $response;
}
private function sendAsEmail() {
foreach ($this->request->recipient->getEmailAddresses() as $emailAddress) {
Mail::html($this->request->message, function ($message) use ($emailAddress) {
$message
->to($emailAddress->getValue(), $this->request->recipient->getName())
->subject($this->request->subject);
});
}
}
private function sendAsInternalMessage() {
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Domains\MessageSystem\Actions\SendMessage;
use App\Enumerations\MessageType;
use App\Models\User;
use App\ValueObjects\EmailAddress;
use App\ValueObjects\MessageRecipient;
class SendMessageRequest {
public string $message;
public string $subject;
public MessageRecipient $recipient;
/** @var MessageType[] */
public array $messageTypes;
public function __construct(
string $message,
string $subject,
MessageRecipient $recipient,
array $messageTypes
) {
$this->message = $message;
$this->recipient = $recipient;
$this->subject = $subject;
$this->messageTypes = $messageTypes;
}
}

View File

@@ -0,0 +1,7 @@
<?php
namespace App\Domains\MessageSystem\Actions\SendMessage;
class SendMessageResponse {
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Domains\UserManagement\Actions\GenerateActivationToken;
use App\Domains\MessageSystem\Actions\SendMessage\SendMessageCommand;
use App\Domains\MessageSystem\Actions\SendMessage\SendMessageRequest;
use App\Enumerations\MessageType;
use App\MessageTemplates\activationCodeTemplate;
use App\ValueObjects\EmailAddress;
use App\ValueObjects\MessageRecipient;
use Illuminate\Support\Str;
class GenerateActivationTokenCommand {
private GenerateActivationTokenRequest $request;
public function __construct(GenerateActivationTokenRequest $request) {
$this->request = $request;
}
public function execute() : GenerateActivationTokenResponse {
$response = new GenerateActivationTokenResponse;
$activationCode = Str::password(24,true,true,false,false);
$this->request->user->activation_token = $activationCode;
if (null !== $this->request->expirationDate) {
$this->request->user->activation_token_expires_at = $this->request->expirationDate;
}
$this->request->user->save();
$response->activationCode = $activationCode;
$activationMessage = new activationCodeTemplate();
$activationMessage->composeMessage(EmailAddress::fromString($this->request->user->email), $activationCode);
$recipient = new MessageRecipient();
$recipient->addEmailAddress(EmailAddress::fromString($this->request->user->email));
$recipient->setName($this->request->user->getFullname());
$message = activationCodeTemplate::createForUser(EmailAddress::fromString($this->request->user->email), $activationCode);
$userMessageRequests = new SendMessageRequest($message->getMessage(), $message->getSubject(), $recipient, [MessageType::EMAIL]);
$userMessageCommand = new SendMessageCommand($userMessageRequests);
$userMessageCommand->send();
return $response;
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Domains\UserManagement\Actions\GenerateActivationToken;
use App\Models\User;
class GenerateActivationTokenRequest {
public User $user;
public ?\DateTime $expirationDate;
public function __construct(User $user, ?\DateTime $expirationDate = null) {
$this->user = $user;
$this->expirationDate = $expirationDate;
}
}

View File

@@ -0,0 +1,7 @@
<?php
namespace App\Domains\UserManagement\Actions\GenerateActivationToken;
class GenerateActivationTokenResponse {
public string $activationCode;
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Domains\UserManagement\Actions\UserActivation;
class UserActivationCommand {
private UserActivationRequest $request;
public function __construct(UserActivationRequest $request) {
$this->request = $request;
}
public function execute() : UserActivationResponse {
$response = new UserActivationResponse();
$this->request->user->active = true;
$this->request->user->activation_token = null;
$this->request->user->activation_token_expires_at = null;
$this->request->user->save();
$response->success = true;
return $response;
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace App\Domains\UserManagement\Actions\UserActivation;
use App\Models\User;
class UserActivationRequest {
public User $user;
public function __construct(User $user) {
$this->user = $user;
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Domains\UserManagement\Actions\UserActivation;
class UserActivationResponse {
public bool $success;
public function __construct() {
$this->success = false;
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Domains\UserManagement\Actions\UserChangePassword;
class UserChangePasswordCommand {
private UserChangePasswordRequest $request;
public function __construct(UserChangePasswordRequest $request)
{
$this->request = $request;
}
public function execute() : UserChangePasswordResponse {
$response = new UserChangePasswordResponse();
$this->request->user->password = $this->request->newPassword;
$this->request->user->save();
$response->success = true;
return $response;
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Domains\UserManagement\Actions\UserChangePassword;
use App\Models\User;
class UserChangePasswordRequest {
public User $user;
public string $newPassword;
public function __construct(User $user, string $newPassword) {
$this->user = $user;
$this->newPassword = $newPassword;
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Domains\UserManagement\Actions\UserChangePassword;
class UserChangePasswordResponse {
public bool $success;
public function __construct() {
$this->success = false;
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Domains\UserManagement\Actions\UserActivation;
class UserDeactivationCommand {
private UserDeactivationRequest $request;
public function __construct(UserDeactivationRequest $request) {
$this->request = $request;
}
public function execute() : UserDeactivationResponse {
$response = new UserDeactivationResponse();
$this->request->user->active = false;
$this->request->user->password = NULL;
$this->request->user->username = 'deleted-' . $this->request->user->username;
$this->request->user->email = 'null@example.com';
$this->request->user->save();
$response->success = true;
return $response;
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace App\Domains\UserManagement\Actions\UserActivation;
use App\Models\User;
class UserDeactivationRequest {
public User $user;
public function __construct(User $user) {
$this->user = $user;
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Domains\UserManagement\Actions\UserActivation;
class UserDeactivationResponse {
public bool $success;
public function __construct() {
$this->success = false;
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Domains\UserManagement\Actions\UserRegistration;
use App\Domains\MessageSystem\Actions\SendMessage\SendMessageCommand;
use App\Domains\MessageSystem\Actions\SendMessage\SendMessageRequest;
use App\Domains\UserManagement\Actions\GenerateActivationToken\GenerateActivationTokenCommand;
use App\Domains\UserManagement\Actions\GenerateActivationToken\GenerateActivationTokenRequest;
use App\Enumerations\MessageType;
use App\MessageTemplates\Registration\InformAdminAboutNewUserTemplate;
use App\Models\User;
use App\ValueObjects\EmailAddress;
use App\ValueObjects\MessageRecipient;
class UserRegistrationCommand {
private UserRegistrationRequest $request;
public function __construct(UserRegistrationRequest $request) {
$this->request = $request;
}
public function execute() : UserRegistrationResponse {
$response = new UserRegistrationResponse;
$user = User::create([
'user_role_main' => $this->request->userRoleMain,
'user_role_local_group' => $this->request->userRoleLocalGroup,
'username' => $this->request->email->getValue(),
'local_group' => $this->request->localGroup,
'firstname' => $this->request->firstname,
'lastname' => $this->request->lastname,
'nickname' => $this->request->nickname !== '' ? $this->request->nickname : null,
'email' => $this->request->email->getValue(),
]);
if ($user === null) {
return $response;
}
$generateActivationCoedeRequest = new GenerateActivationTokenRequest($user);
$generateActivationCoedeDommand = new GenerateActivationTokenCommand($generateActivationCoedeRequest);
$result = $generateActivationCoedeDommand->execute();
$user->activation_token = $result->activationCode;
$siteAdmin = new MessageRecipient();
$siteAdmin->addEmailAddress(EmailAddress::fromString(env('APP_ADMIN_MAIL')));
$siteAdmin->setName(env('APP_ADMIN_NAME'));
$registrationMessage = InformAdminAboutNewUserTemplate::createNew($user);
$registrationMessageRequest = new SendMessageRequest($registrationMessage->getMessage(), $registrationMessage->getSubject(), $siteAdmin, [MessageType::EMAIL]);
$registrationMessageCommand = new SendMessageCommand($registrationMessageRequest);
$registrationMessageCommand->send();
$response->user = $user;
$response->success = true;
return $response;
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Domains\UserManagement\Actions\UserRegistration;
use App\ValueObjects\EmailAddress;
class UserRegistrationRequest {
public string $firstname;
public string $lastname;
public EmailAddress $email;
public string $nickname;
public string $userRoleMain;
public string $userRoleLocalGroup;
public string $localGroup;
public function __construct(string $firstname, string $lastname, string $nickname, EmailAddress $email, string $userRoleMain, string $userRoleLocalGroup, string $localGroup) {
$this->firstname = $firstname;
$this->lastname = $lastname;
$this->nickname = $nickname;
$this->email = $email;
$this->userRoleMain = $userRoleMain;
$this->userRoleLocalGroup = $userRoleLocalGroup;
$this->localGroup = $localGroup;
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Domains\UserManagement\Actions\UserRegistration;
use App\Models\User;
class UserRegistrationResponse {
public ?User $user;
public bool $success;
public function __construct() {
$this->user = null;
$this->success = false;
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace App\Domains\UserManagement\Controllers;
use App\Domains\UserManagement\Actions\UserActivation\UserActivationCommand;
use App\Domains\UserManagement\Actions\UserActivation\UserActivationRequest;
use App\Domains\UserManagement\Actions\UserActivation\UserDeactivationCommand;
use App\Domains\UserManagement\Actions\UserActivation\UserDeactivationRequest;
use App\Domains\UserManagement\Actions\UserChangePassword\UserChangePasswordCommand;
use App\Domains\UserManagement\Actions\UserChangePassword\UserChangePasswordRequest;
use App\Domains\UserManagement\Actions\UserRegistration\UserRegistrationCommand;
use App\Domains\UserManagement\Actions\UserRegistration\UserRegistrationRequest;
use App\Enumerations\UserRole;
use App\Models\User;
use App\Providers\InertiaProvider;
use App\Scopes\CommonController;
use App\ValueObjects\EmailAddress;
use Carbon\Traits\Date;
use DateTime;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class EmailVerificationController extends CommonController
{
public function verifyEmailForm(Request $request) {
$inertiaProvider = new InertiaProvider('UserManagement/VerifyEmail', ['appName' => app('tenant')->name]);
return $inertiaProvider->render();
}
public function doVerification(Request $request) : JsonResponse
{
$user = $this->users->findByUsername($request->get('email'));
if ($user === null) {
return response()->json([
'error_types' => [
'email' => 'Die E-Mail-Adresse konnte nicht zugeordnet werden.',
],
]);
}
if (new DateTime() > DateTime::createFromFormat('Y-m-d H:i:s', $user->activation_token_expires_at)) {
return response()->json([
'error_types' => [
'verificationToken' => 'Der Sicherheitsschlüssel ist abgelaufen.',
],
]);
}
if (!$this->users->checkVerificationToken($user, $request->get('verificationToken'))) {
return response()->json([
'error_types' => [
'verificationToken' => 'Der Sicherheitsschlüssel ist nicht korrekt',
],
]);
}
$userActivationRequest = new UserActivationRequest($user);
$userActivationCommand = new UserActivationCommand($userActivationRequest);
$activationResult = $userActivationCommand->execute();
if (!$activationResult->success) {
return response()->json([
'error_types' => [
'verificationToken' => 'Ein allgemeiner Fehler ist aufgetreten. Bitte versuche es später noch einmal.',
],
]);
}
$userPasswordResetRequest = new UserChangePasswordRequest($user, $request->get('password'));
$userPasswordResetCommand = new UserChangePasswordCommand($userPasswordResetRequest);
$userPasswordResetCommand->execute();
return response()->json([
'status' => 'success',
'message' => 'Dein Account wurde aktiviert.Du kannst dich nun mit deinem neuen Passwort einloggen.'
]);
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Domains\UserManagement\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class LogOutController {
public function __invoke(Request $request) {
Auth::logout();
// Session invalidieren
$request->session()->invalidate();
// CSRF-Token regenerieren (für Sicherheit)
$request->session()->regenerateToken();
// Redirect z.B. zur Login-Seite
return redirect()->intended('/')->with('status', 'Erfolgreich abgemeldet!');
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Domains\UserManagement\Controllers;
use App\Providers\InertiaProvider;
use App\Scopes\CommonController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class LoginController extends CommonController {
public function loginForm(Request $request) {
$errors = [];
if ($request->session()->has('errors')) {
$errors = $request->session()->get('errors')->getBag('default')->getMessages();
}
$inertiaProvider = new InertiaProvider('UserManagement/Login', ['errors' => $errors, 'appName' => app('tenant')->name]);
return $inertiaProvider->render();
}
public function doLogin(Request $request)
{
$credentials = $request->validate([
'username' => ['required', 'string'],
'password' => ['required'],
],
[
'username.required' => 'Bitte gib deinen Anmeldenamen ein.',
'username.string' => 'Der Anmeldename muss eine E-Mail-Adresse sein.',
'password.required' => 'Bitte gib dein Passwort ein.',
]);
$user = $this->users->findByUsername($request->get('username'));
if ($user !== null && $user->password === null) {
return redirect()->intended('/register/verifyEmail');
}
#$credentials = ['username' => 'development', 'password' => 'development'];
if (!Auth::attempt($credentials)) {
return back()->withErrors([
'username' => 'Diese Zugangsdaten sind ungültig.',
]);
}
$request->session()->regenerate();
$user = Auth::user();
# dd($user->firstname . ' ' . $user->lastname);
return redirect()->intended('/');
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace App\Domains\UserManagement\Controllers;
use App\Domains\MessageSystem\Actions\SendMessage\SendMessageCommand;
use App\Domains\MessageSystem\Actions\SendMessage\SendMessageRequest;
use App\Domains\UserManagement\Actions\UserRegistration\UserRegistrationCommand;
use App\Domains\UserManagement\Actions\UserRegistration\UserRegistrationRequest;
use App\Enumerations\MessageType;
use App\Enumerations\UserRole;
use App\Providers\InertiaProvider;
use App\Scopes\CommonController;
use App\ValueObjects\EmailAddress;
use App\ValueObjects\MessageRecipient;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class RegistrationController extends CommonController {
public function loginForm(Request $request) {
$errors = [];
if ($request->session()->has('errors')) {
$errors = $request->session()->get('errors')->getBag('default')->getMessages();
}
$inertiaProvider = new InertiaProvider('UserManagement/Registration', ['errors' => $errors, 'appName' => app('tenant')->name]);
return $inertiaProvider->render();
}
public function doRegistration(Request $request) : JsonResponse {
$user = $this->users->findByUsername($request->get('email'));
if ($user !== null) {
return response()->json([
'status' => 'error',
'message' => 'Dieser Account existiert bereits.'
]);
}
$email = EmailAddress::fromString($request->get('email'));
$userRoleMain = UserRole::USER_ROLE_USER;
$userRoleLocalGroup = UserRole::USER_ROLE_USER;
$localGroup = app('tenant')->slug === 'lv' ? $request->get('localGroup') : app('tenant')->slug;
$registrationRequest = new UserRegistrationRequest(
$request->get('firstname'),
$request->get('lastname'),
$request->get('nickname'),
$email,
$userRoleMain,
$userRoleLocalGroup,
$localGroup
);
$registrationCommand = new UserRegistrationCommand($registrationRequest);
$result = $registrationCommand->execute();
if (!$result->success) {
return response()->json([
'status' => 'error',
'message' => 'Beim Erstellen des Accounts ist ein Fehler aufgetreten.'
]);
}
return response()->json([
'status' => 'success',
'message' => 'Registrierung erfolgreich! Bitte prüfe nun dein E-Mail-Postfach'
]);
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Domains\UserManagement\Controllers;
use App\Domains\UserManagement\Actions\GenerateActivationToken\GenerateActivationTokenCommand;
use App\Domains\UserManagement\Actions\GenerateActivationToken\GenerateActivationTokenRequest;
use App\Domains\UserManagement\Actions\GenerateActivationToken\GenerateActivationTokenResponse;
use App\Domains\UserManagement\Actions\UserActivation\UserActivationCommand;
use App\Domains\UserManagement\Actions\UserActivation\UserActivationRequest;
use App\Providers\InertiaProvider;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ResetPasswordController extends CommonController {
public function resetPasswordForm() {
$inertiaProvider = new InertiaProvider('UserManagement/ResetPassword', []);
return $inertiaProvider->render();
}
public function doResetPassword(Request $request) : JsonResponse {
$user = $this->users->findByUsername($request->get('email'));
if (null !== $user) {
$expirationDate = new \DateTime()->modify('+15 Minutes');
$resetAccountRequest = new GenerateActivationTokenRequest($user, $expirationDate);
$resetAccountCommand = new GenerateActivationTokenCommand($resetAccountRequest);
$resetAccountCommand->execute();
}
return response()->json([
'status' => 'success',
'message' => 'Falls deine E-Mail-Adresse gefunden wurde, erhältst du nun eine E-Mail mit weiteren Schritten zum Zurücksetzen deines Passwortes.'
]);
}
}

View File

@@ -0,0 +1,17 @@
<?php
use App\Domains\UserManagement\Controllers\EmailVerificationController;
use App\Domains\UserManagement\Controllers\RegistrationController;
use App\Domains\UserManagement\Controllers\ResetPasswordController;
use App\Middleware\IdentifyTenant;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
Route::prefix('v1')
->group(function () {
Route::middleware(IdentifyTenant::class)->group(function () {
Route::post('/register', [RegistrationController::class, 'doRegistration']);
Route::post('/register/confirmEmail', [EmailVerificationController::class, 'doVerification']);
Route::post('/reset-password', [ResetPasswordController::class, 'doResetPassword']);
});
});

View File

@@ -0,0 +1,31 @@
<?php
use App\Domains\Dashboard\Controllers\DashboardController;
use App\Domains\UserManagement\Controllers\EmailVerificationController;
use App\Domains\UserManagement\Controllers\LoginController;
use App\Domains\UserManagement\Controllers\LogOutController;
use App\Domains\UserManagement\Controllers\RegistrationController;
use App\Domains\UserManagement\Controllers\ResetPasswordController;
use App\Http\Controllers\TestRenderInertiaProvider;
use App\Middleware\IdentifyTenant;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
Route::middleware(IdentifyTenant::class)->group(function () {
Route::get('/register', [RegistrationController::class, 'loginForm']);
Route::get('/register/verifyEmail', [EmailVerificationController::class, 'verifyEmailForm']);
Route::get('/reset-password', [ResetPasswordController::class, 'resetPasswordForm']);
route::get('/logout', LogOutController::class);
route::post('/login', [LoginController::class, 'doLogin']);
route::get('/login', [LoginController::class, 'loginForm']);
Route::middleware(['auth'])->group(function () {
Route::post('/logout', [LogoutController::class, 'logout']);
});
});

View File

@@ -0,0 +1,69 @@
<script setup>
import AppLayout from '../../../../resources/js/layouts/AppLayout.vue'
import {onMounted, ref} from 'vue'
import { toast } from 'vue3-toastify'
import ShadowedBox from "../../../Views/Components/ShadowedBox.vue";
const props = defineProps({
navbar: Object,
tenant: String,
user: Object,
currentPath: String,
errors: Object,
})
onMounted(() => {
if (undefined !== props.errors && undefined !== props.errors.username) {
toast.error(props.errors.username[0])
}
})
//console.log(props.errors.password[0])
const username = ref('')
const password = ref('')
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content')
function resetPassword() {
window.location.href = '/reset-password';
}
</script>
<template>
<AppLayout title='Anmelden' :user="props.user" :navbar="props.navbar" :tenant="props.tenant" :currentPath="props.currentPath">
<form method="POST" action="/login">
<input type="hidden" name="_token" :value="csrfToken" />
<shadowed-box style="width: 50%; margin: 150px auto; padding: 20px;">
<table>
<tr>
<th>Anmeldename</th>
<td>
<input type="text" name="username" id="username"></input>
</td>
</tr>
<tr>
<th>Passwort</th>
<td><input type="password" name="password" id="password"></input></td>
</tr>
<tr>
<td colspan="2">
<input type="submit" value="Anmelden" style="margin-top: 20px;" />
<input type="button" @click="resetPassword" style="margin-top: 20px; margin-left: 20px;" value="Passwort vergessen" />
</td>
</tr>
</table>
</shadowed-box>
</form>
</AppLayout>
</template>
<style>
th {
width: 100px;
}
</style>

View File

@@ -0,0 +1,166 @@
<script setup>
import AppLayout from '../../../../resources/js/layouts/AppLayout.vue'
import {computed, onMounted, reactive, ref} from 'vue'
import { toast } from 'vue3-toastify'
import ShadowedBox from "../../../Views/Components/ShadowedBox.vue";
import {useAjax} from "../../../../resources/js/components/ajaxHandler.js";
import ErrorText from "../../../Views/Components/ErrorText.vue";
const props = defineProps({
navbar: Object,
tenant: String,
user: Object,
currentPath: String,
errors: Object,
availableLocalGroups: Array,
})
onMounted(() => {
if (undefined !== props.errors && undefined !== props.errors.username) {
toast.error(props.errors.username[0])
}
})
//console.log(props.errors.password[0])
const username = ref('')
const password = ref('')
const form = reactive({
firstname: '',
lastname: '',
nickname: '',
email: '',
localGroup: '',
password: '',
password_confirmation: ''
})
const errors = reactive({})
const { request } = useAjax()
const isValid = computed(() => {
errors.firstname = ''
errors.lastname = ''
errors.localGroup = ''
errors.email = ''
if (!form.firstname) {
errors.firstname = 'Bitte gib deinen Vornamen ein'
}
if (!form.lastname) {
errors.lastname = 'Bitte gib deinen Nachnamen ein'
}
if (!form.localGroup) {
errors.localGroup = 'Bitte gib deinen Stamm an'
}
if (!form.email) {
errors.email = 'Bitte gib deine E-Mail-Adresse ein'
} else if (!/^\S+@\S+\.\S+$/.test(form.email)) {
errors.email = 'Ungültige E-Mail'
}
return !errors.password && !errors.firstname && !errors.lastname && !errors.localGroup
})
async function submit() {
if (!isValid.value) return false
const data = await request("/api/v1/register", {
method: "POST",
body: {
"firstname": form.firstname,
"lastname": form.lastname,
'nickname': form.nickname,
"email": form.email,
"localGroup": form.localGroup,
}
});
if (data.status === 'error') {
toast.error(data.message);
} else {
toast.success(data.message)
window.location.href = '/register/verifyEmail';
}
return;
}
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content')
</script>
<template>
<AppLayout title='Registrieren' :user="props.user" :navbar="props.navbar" :tenant="props.tenant" :currentPath="props.currentPath">
<form method="POST" action="/register" @submit.prevent="submit">
<input type="hidden" name="_token" :value="csrfToken" />
<shadowed-box style="width: 75%; margin: 150px auto; padding: 20px;">
<table>
<tr>
<th>Vorname</th>
<td>
<input type="text" name="firstname" id="firstname" v-model="form.firstname" />
<ErrorText :message="errors.firstname" />
</td>
</tr>
<tr>
<th>Nachname</th>
<td>
<input type="text" name="lastname" id="lastname" v-model="form.lastname" />
<ErrorText :message="errors.lastname" />
</td>
</tr>
<tr>
<th>
Pfadi-Name*<br />
<small>*Falls vorhanden</small>
</th>
<td>
<input type="text" name="nickname" id="nickname" v-model="form.nickname" />
</td>
</tr>
<tr>
<th>E-Mail-Adresse</th>
<td>
<input type="email" name="email" id="email" v-model="form.email" />
<ErrorText :message="errors.email" />
</td>
</tr>
<tr v-if="props.tenant.slug === 'lv'">
<th>Stamm</th>
<td>
<select name="localgroup" v-model="form.localGroup">
<option v-for="localGroup in props.availableLocalGroups" :value="localGroup.slug">{{localGroup.name}}</option>
</select>
<ErrorText :message="errors.localGroup" />
</td>
</tr>
<tr>
<td colspan="2">
<input type="submit" value="Registrieren" style="margin-top: 20px;" />
</td>
</tr>
</table>
</shadowed-box>
</form>
</AppLayout>
</template>
<style>
th {
width: 150px;
}
</style>

View File

@@ -0,0 +1,102 @@
<script setup>
import AppLayout from '../../../../resources/js/layouts/AppLayout.vue'
import {computed, onMounted, reactive, ref} from 'vue'
import { toast } from 'vue3-toastify'
import ShadowedBox from "../../../Views/Components/ShadowedBox.vue";
import {useAjax} from "../../../../resources/js/components/ajaxHandler.js";
import ErrorText from "../../../Views/Components/ErrorText.vue";
const props = defineProps({
navbar: Object,
tenant: String,
user: Object,
currentPath: String,
errors: Object,
availableLocalGroups: Array,
})
onMounted(() => {
if (undefined !== props.errors && undefined !== props.errors.username) {
toast.error(props.errors.username[0])
}
})
const form = reactive({
email: '',
verificationToken: '',
})
const errors = reactive({})
const { request } = useAjax()
const isValid = computed(() => {
errors.email = ''
if (!form.email) {
errors.email = 'Bitte gib deine E-Mail-Adresse ein'
}
return !errors.email
})
async function submit() {
if (!isValid.value) return false
const data = await request("/api/v1/reset-password", {
method: "POST",
body: {
"email": form.email,
}
});
if (data.error_types) {
Object.keys(data.error_types).forEach((key) => {
if (key in errors) {
errors[key] = data.error_types[key]
}
});
} else {
window.location.href = '/register/verifyEmail';
toast.success(data.message)
}
return;
}
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content')
</script>
<template>
<AppLayout title='Passwort zurücksetzen' :user="props.user" :navbar="props.navbar" :tenant="props.tenant" :currentPath="props.currentPath">
<form method="POST" action="/reset-password" @submit.prevent="submit">
<input type="hidden" name="_token" :value="csrfToken" />
<shadowed-box style="width: 75%; margin: 150px auto; padding: 20px;">
<table>
<tr>
<th>E-Mail-Adresse</th>
<td>
<input type="email" name="email" id="email" v-model="form.email" />
<ErrorText :message="errors.email" />
</td>
</tr>
<tr>
<td colspan="2">
<input type="submit" value="Verifizierung starten" style="margin-top: 20px;" />
</td>
</tr>
</table>
</shadowed-box>
</form>
</AppLayout>
</template>
<style>
th {
width: 150px;
}
</style>

View File

@@ -0,0 +1,152 @@
<script setup>
import AppLayout from '../../../../resources/js/layouts/AppLayout.vue'
import {computed, onMounted, reactive, ref} from 'vue'
import { toast } from 'vue3-toastify'
import ShadowedBox from "../../../Views/Components/ShadowedBox.vue";
import {useAjax} from "../../../../resources/js/components/ajaxHandler.js";
import ErrorText from "../../../Views/Components/ErrorText.vue";
const props = defineProps({
navbar: Object,
tenant: String,
user: Object,
currentPath: String,
errors: Object,
availableLocalGroups: Array,
})
onMounted(() => {
if (undefined !== props.errors && undefined !== props.errors.username) {
toast.error(props.errors.username[0])
}
})
const form = reactive({
email: '',
verificationToken: '',
password: '',
})
const errors = reactive({})
const { request } = useAjax()
const isValid = computed(() => {
errors.email = '';
errors.verificationToken = '';
errors.password = '';
if (!form.email) {
errors.email = 'Bitte gib deine E-Mail-Adresse ein'
} else if (!/^\S+@\S+\.\S+$/.test(form.email)) {
errors.email = 'Ungültige E-Mail'
}
if (!form.verificationToken) {
errors.verificationToken = 'Bitte gib den Sicherheitsschlüssel, den du per E-Mail erhalten hast, ein'
}
if (!form.password) {
errors.password = 'Bitte gib ein Passwort ein'
} else if (form.password.length < 2) {
errors.password = 'Das Passwort muss mindestens 12 Zeichen lang sein'
}
if (form.password !== form.password_confirmation) {
errors.password = 'Das Passwort und die Wiederholung stimmen nicht überein'
}
return !errors.password && !errors.email && !errors.verificationToken
})
async function submit() {
if (!isValid.value) return false
const data = await request("/api/v1/register/confirmEmail", {
method: "POST",
body: {
"email": form.email,
"verificationToken": form.verificationToken,
"password": form.password,
}
});
if (data.error_types) {
Object.keys(data.error_types).forEach((key) => {
if (key in errors) {
errors[key] = data.error_types[key]
}
});
} else {
toast.success(data.message)
window.location.href = '/login';
}
return;
}
function resetPassword() {
window.location.href = '/reset-password';
}
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content')
</script>
<template>
<AppLayout title='E-Mail bestätigen' :user="props.user" :navbar="props.navbar" :tenant="props.tenant" :currentPath="props.currentPath">
<form method="POST" action="/register" @submit.prevent="submit">
<input type="hidden" name="_token" :value="csrfToken" />
<shadowed-box style="width: 75%; margin: 150px auto; padding: 20px;">
<p>
Bitte prüfe dein E-Mail-Postfach.<br />
Solltest du keinen Aktivierungsschlüssel erhalten haben, kontaktiere bitte einen Administrator.
</p>
<table>
<tr>
<th>E-Mail-Adresse</th>
<td>
<input type="email" name="email" id="email" v-model="form.email" />
<ErrorText :message="errors.email" />
</td>
</tr>
<tr>
<th>Sicherheitsschlüssel</th>
<td>
<input type="text" name="verificationToken" id="verificationToken" v-model="form.verificationToken" />
<ErrorText :message="errors.verificationToken" />
</td>
</tr>
<tr>
<th>Dein neues Passwort</th>
<td><input type="password" name="password" id="password" v-model="form.password" /></td>
</tr>
<tr>
<th>Passwort (wiederholen)</th>
<td>
<input type="password" name="password_retype" id="password_retype" v-model="form.password_confirmation" />
<ErrorText :message="errors.password" />
</td>
</tr>
<tr>
<td colspan="2">
<input type="submit" value="E-Mail validieren" style="margin-top: 20px;" />
<input type="button" @click="resetPassword" style="margin-top: 20px; margin-left: 20px;" value="Validierungscode neu zusenden" />
</td>
</tr>
</table>
</shadowed-box>
</form>
</AppLayout>
</template>
<style>
th {
width: 150px;
}
</style>

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Enumerations;
use App\Scopes\CommonModel;
use Illuminate\Database\Eloquent\Factories\HasFactory;
/**
* @property string $slug
* @property string $name
*/
class CostUnitType extends CommonModel
{
public const COST_UNIT_TYPE_EVENT = 'event';
public const COST_UNIT_TYPE_RUNNING_JOB = 'running_job';
protected $fillable = [
'slug',
'name',
];
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Enumerations;
use App\Scopes\CommonModel;
use Illuminate\Database\Eloquent\Factories\HasFactory;
/**
* @property string $slug
* @property string $name
*/
class EatingHabit extends CommonModel
{
public const string EATING_HABIT_OMNIVOR = 'EATING_HABIT_OMNIVOR';
public const string EATING_HABIT_VEGETARIAN = 'EATING_HABIT_VEGETARIAN';
public const string EATING_HABIT_VEGAN = 'EATING_HABIT_VEGAN';
protected $fillable = [
'slug',
'name',
];
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Enumerations;
use App\Scopes\CommonModel;
/**
* @property string $slug
* @property string $name
*/
class FirstAidPermission extends CommonModel
{
public const string FIRST_AID_PERMISSION_ALLOWED = 'FIRST_AID_PERMISSION_ALLOWED';
public const string FIRST_AID_PERMISSION_DENIED = 'FIRST_AID_PERMISSION_DENIED';
protected $fillable = [
'slug',
'name',
'description'
];
}

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Enumerations;
enum MessageType : string {
case INTERNAL = 'INTERNAL';
case EMAIL = 'EMAIL';
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Enumerations;
use App\Scopes\CommonModel;
use Illuminate\Database\Eloquent\Factories\HasFactory;
/**
* @property string $slug
* @property string $name
*/
class SwimmingPermission extends CommonModel
{
public const string SWIMMING_PERMISSION_ALLOWED = 'SWIMMING_PERMISSION_ALLOWED';
public const string SWIMMING_PERMISSION_LIMITED = 'SWIMMING_PERMISSION_LIMITED';
public const string SWIMMING_PERMISSION_DENIED = 'SWIMMING_PERMISSION_DENIED';
use HasFactory;
protected $fillable = [
'slug',
'name',
];
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Enumerations;
use App\Scopes\CommonModel;
class UserRole extends CommonModel {
public const USER_ROLE_ADMIN = 'ROLE_ADMINISTRATOR';
public const USER_ROLE_GROUP_LEADER = 'ROLE_GROUP_LEADER';
public const USER_ROLE_USER = 'ROLE_USER';
protected $fillable = [
'name',
'slug'
];
}

View File

@@ -1,8 +0,0 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Http\Controllers;
use App\Providers\InertiaProvider;
class TestRenderInertiaProvider
{
public function index() {
$inertiaProvider = new InertiaProvider('Invoice/CreateInvoice', ['appName' => app('tenant')->name]);
return $inertiaProvider->render();
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Installer;
use App\Enumerations\UserRole;
use App\Models\Tenant;
use App\Models\User;
class DevelopmentDataSeeder {
public function execute() {
$this->installTenants();
$this->installUsers();
}
private function installTenants() {
Tenant::create([
'slug' => 'wilde-moehre',
'name' => 'Stamm Wilde Möhre',
'url' => 'wilde-moehre.mareike.local',
'account_iban' => 'DE12345678901234567890',
'email' => 'test@example1.com',
'city' => 'Halle (Saale)',
'postcode' => '06120',
'is_active_local_group' => true,
'has_active_instance' => true,
]);
}
private function installUsers() {
User::create([
'firstname' => 'Development',
'lastname' => 'User',
'user_role_main' => UserRole::USER_ROLE_ADMIN,
'user_role_local_group' => UserRole::USER_ROLE_GROUP_LEADER,
'local_group' => 'wilde-moehre',
'email' => 'th.guenther@saale-mail.de',
'password' => bcrypt('development'),
'username' => 'development',
]);
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Installer;
use App\Enumerations\CostUnitType;
use App\Enumerations\EatingHabit;
use App\Enumerations\FirstAidPermission;
use App\Enumerations\SwimmingPermission;
use App\Enumerations\UserRole;
use App\Models\Tenant;
class ProductionDataSeeder {
public function execute() {
$this->installUserRoles();
$this->installCostUnitTypes();
$this->installSwimmingPermissions();
$this->installEatingHabits();
$this->installFirstAidPermissions();
$this->installTenants();
}
private function installUserRoles() {
UserRole::create(['name' => 'Administrator*in', 'slug' => UserRole::USER_ROLE_ADMIN]);
UserRole::create(['name' => 'Vorstandsmitglied', 'slug' => UserRole::USER_ROLE_GROUP_LEADER]);
UserRole::create(['name' => 'Benutzer*in', 'slug' => UserRole::USER_ROLE_USER]);
}
private function installSwimmingPermissions() {
SwimmingPermission::create(['name' => 'Mein Kind darf baden und kann schwimmen', 'slug' => SwimmingPermission::SWIMMING_PERMISSION_ALLOWED]);
SwimmingPermission::create(['name' => 'Mein Kind darf baden und kann NICHT schwimmen', 'slug' => SwimmingPermission::SWIMMING_PERMISSION_LIMITED]);
SwimmingPermission::create(['name' => 'Mein Kind darf nicht baden', 'slug' => SwimmingPermission::SWIMMING_PERMISSION_DENIED]);
}
private function installEatingHabits() {
EatingHabit::create(['name' => 'Vegan', 'slug' => EatingHabit::EATING_HABIT_VEGAN]);
EatingHabit::create(['name' => 'Vegetarisch', 'slug' => EatingHabit::EATING_HABIT_VEGETARIAN]);
EatingHabit::create(['name' => 'Omnivor', 'slug' => EatingHabit::EATING_HABIT_OMNIVOR]);
}
private function installFirstAidPermissions() {
FirstAidPermission::create([
'name' => 'Zugestimmt',
'description' => 'Ich STIMME der Anwendung von erweiteren Erste-Hilfe-Maßnahmen an meinem Kind explizit ZU.',
'slug' => FirstAidPermission::FIRST_AID_PERMISSION_ALLOWED]);
FirstAidPermission::create([
'name' => 'Verweigert',
'description' => 'Ich LEHNE die Anwendung von erweiteren Erste-Hilfe-Maßnahmen an meinem Kind explizit AB.',
'slug' => FirstAidPermission::FIRST_AID_PERMISSION_DENIED]);
}
private function installCostUnitTypes() {
CostUnitType::create(['slug' => CostUnitType::COST_UNIT_TYPE_EVENT, 'name' => 'Veranstaltung']);
CostUnitType::create(['slug' => CostUnitType::COST_UNIT_TYPE_RUNNING_JOB, 'name' => 'Laufende Tätigkeit']);
}
private function installTenants() {
Tenant::create([
'slug' => 'lv',
'name' => 'Landesunmittelbare Mitglieder',
'url' => 'mareike.local',
'account_iban' => 'DE12345678901234567890',
'email' => 'test@example.com',
'city' => 'Lommatzsch',
'postcode' => '01623',
'is_active_local_group' => true,
'has_active_instance' => true,
]);
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\MessageTemplates;
use App\Enumerations\MessageType;
abstract class MessageTemplate {
protected string $subject;
protected string $message;
public function __construct() {
}
public function getSubject(): string
{
return $this->subject;
}
public function setSubject(string $subject): void
{
$this->subject = $subject;
}
public function getMessage(): string
{
return $this->message;
}
public function setMessage(string $message): void
{
$this->message = $message;
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace App\MessageTemplates\Registration;
use App\MessageTemplates\activationCodeTemplate;
use App\MessageTemplates\MessageTemplate;
use App\Models\User;
use App\ValueObjects\EmailAddress;
class InformAdminAboutNewUserTemplate extends MessageTemplate
{
public static function createNew(User $user) : InformAdminAboutNewUserTemplate {
$template = new InformAdminAboutNewUserTemplate();
$template->composeMessage($user);
return $template;
}
public function __construct() {
$this->subject = "Eine Person hat sich auf mareike registriert";
}
public function composeMessage(User $user): void {
$this->message =
<<<HTML
<h1>Eine Person hat sich auf mareike angemeldet</h1>
<p>Soeben hat sich eine neue Person auf mareike angemeldet:</p>
<table>
<tr>
<th>Name:</th>
<td>%1\$s</td>
</tr>
<tr>
<th>Email:</th>
<td>%2\$s</td>
</tr>
<tr>
<th>Stamm:</th>
<td>%3\$s</td>
</tr>
<tr>
<th>Freischaltcode:</th>
<td>%4\$s</td>
</tr>
</table>
<p>
Sollte die Person unberechtigt angemeldet sein, lösche den Account.
</p>
HTML;
$this->message = sprintf($this->message,
$user->getFullname(),
$user->email,
$user->local_group,
$user->activation_token
);
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\MessageTemplates;
use App\ValueObjects\EmailAddress;
class activationCodeTemplate extends MessageTemplate {
public static function createForUser(EmailAddress $emailAddress, string $activationCode) : activationCodeTemplate {
$template = new activationCodeTemplate();
$template->composeMessage($emailAddress, $activationCode);
return $template;
}
public function __construct() {
$this->subject = "Dein Aktivierungscode";
}
public function composeMessage(EmailAddress $emailAddress, string $activationCode): void {
$this->message = "Dein Aktivierungscode lautet: {$activationCode}" . PHP_EOL .
"Gib diesen zusammen mit der Mailadresse {$emailAddress->getValue()} ein.";
}
}

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace App\Http\Middleware; namespace App\Middleware;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Inertia\Middleware; use Inertia\Middleware;

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Middleware;
use App\Models\Tenant;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class IdentifyTenant
{
public function handle(Request $request, Closure $next)
{
$host = $request->getHost();
$tenant = Tenant::where(['url' => $host, 'has_active_instance' => true])->first();
if (! $tenant) {
throw new NotFoundHttpException('Tenant not found');
}
app()->instance('tenant', $tenant);
return $next($request);
}
}

39
app/Models/Tenant.php Normal file
View File

@@ -0,0 +1,39 @@
<?php
namespace App\Models;
use App\Scopes\CommonModel;
/**
* @property string $slug
* @property string $local_group
* @property string $email
* @property string $url
* @property string $account_iban
* @property string $city
* @property string $postcode
* @property string $gdpr_text
* @property string $impress_text
* @property string $url_participation_rules
* @property boolean $events_allowed
* @property boolean $has_active_instance
*/
class Tenant extends CommonModel
{
public const PRIMARY_TENANT_NAME = 'LV';
protected $fillable = [
'slug',
'name',
'email',
'url',
'account_iban',
'city',
'postcode',
'gdpr_text',
'impress_text',
'url_participation_rules',
'is_active_local_group',
'has_active_instance'
];
}

View File

@@ -2,15 +2,40 @@
namespace App\Models; namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
/**
* @property string $username
* @property string $local_group
* @property string $firstname
* @property string $nickname
* @property string $lastname
* @property string $membership_id
* @property string $address_1
* @property string $address_2
* @property string $postcode
* @property string $city
* @property string $email
* @property string $phone
* @property string $birthday
* @property string $medications
* @property string $allergies
* @property string $intolerances
* @property string $eating_habits
* @property string $swimming_permission
* @property string $first_aid_permission
* @property string $bank_account_iban
* @property string $password
* @property boolean $active
* @property string $user_role_main
* @property string $user_role_local_group
* @property string $activation_token
* @property string $activation_token_expires_at
*/
class User extends Authenticatable class User extends Authenticatable
{ {
/** @use HasFactory<\Database\Factories\UserFactory> */ use Notifiable;
use HasFactory, Notifiable;
/** /**
* The attributes that are mass assignable. * The attributes that are mass assignable.
@@ -18,9 +43,32 @@ class User extends Authenticatable
* @var list<string> * @var list<string>
*/ */
protected $fillable = [ protected $fillable = [
'name', 'user_role_main',
'user_role_local_group',
'activation_token',
'activation_token_expires_at',
'username',
'local_group',
'firstname',
'nickname',
'lastname',
'membership_id',
'address_1',
'address_2',
'postcode',
'city',
'email', 'email',
'phone',
'birthday',
'medications',
'allergies',
'intolerances',
'eating_habits',
'swimming_permission',
'first_aid_permission',
'bank_account_iban',
'password', 'password',
'active',
]; ];
/** /**
@@ -29,7 +77,6 @@ class User extends Authenticatable
* @var list<string> * @var list<string>
*/ */
protected $hidden = [ protected $hidden = [
'password',
'remember_token', 'remember_token',
]; ];
@@ -41,8 +88,19 @@ class User extends Authenticatable
protected function casts(): array protected function casts(): array
{ {
return [ return [
'email_verified_at' => 'datetime',
'password' => 'hashed', 'password' => 'hashed',
]; ];
} }
public function getFullname() : string {
return sprintf('%1$1s %2$s %3$s',
$this->firstname,
$this->nickname !== '' ? '(' . $this->nickname . ')' : '',
$this->lastname
);
}
public function getNicename() : string {
return $this->nickname !== '' ? $this->nickname : $this->firstname;
}
} }

View File

@@ -2,6 +2,7 @@
namespace App\Providers; namespace App\Providers;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
@@ -19,6 +20,11 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function boot(): void public function boot(): void
{ {
// Auth::provider('tenant-users', function ($app, array $config) {
return new TenantUserProvider(
$app['hash'],
$config['model']
);
});
} }
} }

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Providers;
class AuthCheckProvider {
public function checkLoggedIn() : bool {
if (!auth()->check()) {
return false;
}
$user = auth()->user();
$tenant = app('tenant');
if ($tenant->slug === 'lv') {
return $user->active;
}
return $user->active && $tenant->slug === $user->tenant;
}
public function getUserRole() : ?string {
if (!$this->checkLoggedIn()) {
return null;
}
if (app('tenant')->slug === 'lv') {
return auth()->user()->user_role_main;
}
return auth()->user()->user_role_local_group;
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Providers;
use App\Models\Tenant;
use App\Models\User;
use Inertia\Inertia;
use Inertia\Response;
final class InertiaProvider
{
private string $vueFile;
private array $props;
private ?User $user;
public function __construct(string $vueFile, array $props) {
$this->user = auth()->user();
$this->vueFile = $vueFile;
$this->props = $props;
}
public function render() : Response {
$this->props['navbar'] = $this->generateNavbar();
$this->props['tenant'] = app('tenant');
$this->props['user'] = $this->user;
$this->props['currentPath'] = request()->path();
$this->props['availableLocalGroups'] = Tenant::where(['is_active_local_group' => true])->get();
return Inertia::render(
str_replace('/', '/Views/', $this->vueFile),
$this->props
);
}
private function generateNavbar() : array {
$navigation = [
'personal' => [],
'common' => [],
'costunits' => [],
'events' => [],
];
$navigation['personal'][] = ['url' => '/', 'display' => 'Home'];
if (null !== $this->user) {
$navigation['personal'][] = ['url' => '/personal-data', 'display' => 'Meine Daten'];
$navigation['personal'][] = ['url' => '/messages', 'display' => 'Meine Nachrichten'];
}
$navigation['common'][] = ['url' => '/capture-invoice', 'display' => 'Neue Abrechnung'];
$navigation['common'][] = ['url' => '/available-events', 'display' => 'Verfügbare Veranstaltungen'];
return $navigation;
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Providers;
use Illuminate\Auth\EloquentUserProvider;
class TenantUserProvider extends EloquentUserProvider
{
public function retrieveByCredentials(array $credentials)
{
$credentials['active'] = true;
$query = $this->createModel()->newQuery();
foreach ($credentials as $key => $value) {
if (! str_contains($key, 'password')) {
$query->where($key, $value);
}
}
if (app('tenant')->slug === 'lv') {
return $query->first();
}
$query->where([
'local_group' => app('tenant')->slug,
'active' => true
]);
return $query->first();
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Repositories;
use App\Models\User;
use DateTime;
class UserRepository {
public function findByUsername(string $username) : ?User {
return User::where(['username' => $username])->first();
}
public function checkVerificationToken(User $user, string $token) : bool {
if (new DateTime() > DateTime::createFromFormat('Y-m-d H:i:s', $user->activation_token_expires_at)) {
return false;
}
return $token === $user->activation_token;
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Scopes;
use App\Providers\AuthCheckProvider;
use App\Repositories\UserRepository;
abstract class CommonController {
protected UserRepository $users;
public function __construct() {
$this->users = new UserRepository();
}
protected function checkAuth() {
$authCheckProvider = new AuthCheckProvider;
return $authCheckProvider->checkLoggedIn();
}
}

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Scopes;
use Illuminate\Database\Eloquent\Model;
abstract class CommonModel extends Model
{
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Scopes;
use Illuminate\Database\Eloquent\Model;
abstract class InstancedModel extends Model
{
protected static function booted()
{
static::addGlobalScope(new SiteScope());
}
}

14
app/Scopes/SiteScope.php Normal file
View File

@@ -0,0 +1,14 @@
<?php
namespace App\Scopes;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
class SiteScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
$builder->where($model->getTable() . '.tenant', app('tenant')->slug);
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\ValueObjects;
class EmailAddress {
private string $value;
public static function fromString(string $value) : EmailAddress {
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException("Invalid email address: $value");
}
$emailAddress = new EmailAddress();
$emailAddress->setValue($value);
return $emailAddress;
}
public function getValue(): string
{
return $this->value;
}
public function setValue(string $value): void
{
$this->value = $value;
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\ValueObjects;
use App\Models\User;
class MessageRecipient {
private ?User $user;
/** @var EmailAddress[] */
private array $emailAddresses;
private string $name;
public function __construct() {
$this->user = null;
$this->emailAddresses = [];
$this->name = "";
}
public function getUser(): ?User
{
return $this->user;
}
public function setUser(?User $user): void
{
$this->user = $user;
}
public function getEmailAddresses(): array
{
return $this->emailAddresses;
}
public function addEmailAddress(EmailAddress $emailAddresses): void
{
$this->emailAddresses[] = $emailAddresses;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): void
{
$this->name = $name;
}
}

View File

@@ -0,0 +1,10 @@
<script setup>
const props = defineProps({
message: String,
})
</script>
<template>
<small class="error_text" v-if="props.message">{{ props.message }}</small>
</template>

View File

@@ -0,0 +1,17 @@
<script setup>
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { library } from '@fortawesome/fontawesome-svg-core'
import * as SolidIcons from '@fortawesome/free-solid-svg-icons'
const props = defineProps({
name: { type: String, required: true },
})
if (SolidIcons[`fa${props.name.charAt(0).toUpperCase()}${props.name.slice(1)}`]) {
library.add(SolidIcons[`fa${props.name.charAt(0).toUpperCase()}${props.name.slice(1)}`])
}
</script>
<template>
<font-awesome-icon :icon="name" />
</template>

View File

@@ -0,0 +1,23 @@
<script setup>
const props = defineProps({
style: { type: String},
class: { type: String},
})
console.log(props.style)
</script>
<template>
<div class="shadowed-box" :style=props.style :class=props.class>
<slot></slot>
</div>
</template>
<style>
.shadowed-box {
box-shadow: 2px 2px 5px #c0c0c0;
border-radius: 10px;
padding: 10px;
background-color: #ffffff;
}
</style>

View File

@@ -0,0 +1,41 @@
<script setup>
import ShadowedBox from "../../Components/ShadowedBox.vue";
</script>
<template>
<diV class="widget-container">
<shadowed-box class="widget-box">
Widget 1
</shadowed-box>
<shadowed-box class="widget-box">
Widget 2
</shadowed-box>
<shadowed-box class="widget-box">
Widget 3
</shadowed-box>
<shadowed-box class="widget-box">
Widget 4
</shadowed-box>
</diV>
</template>
<style>
.widget-container {
display: flex;
justify-content: space-around;
flex-wrap: wrap;
position: relative;
}
.widget-box {
flex-grow: 1; display: inline-block;
width: calc(25% - 40px);
height: 150px;
margin: 0 10px;
}
</style>

View File

@@ -1,5 +1,6 @@
<?php <?php
use App\Middleware\IdentifyTenant;
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Foundation\Configuration\Middleware;
@@ -8,10 +9,11 @@ return Application::configure(basePath: dirname(__DIR__))
->withRouting( ->withRouting(
web: __DIR__.'/../routes/web.php', web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php', commands: __DIR__.'/../routes/console.php',
api: __DIR__.'/../routes/api.php',
health: '/up', health: '/up',
) )
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
// $middleware->append(IdentifyTenant::class);
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {
// //

View File

@@ -2,4 +2,5 @@
return [ return [
App\Providers\AppServiceProvider::class, App\Providers\AppServiceProvider::class,
]; ];

View File

@@ -6,7 +6,7 @@
"keywords": ["laravel", "framework"], "keywords": ["laravel", "framework"],
"license": "MIT", "license": "MIT",
"require": { "require": {
"php": "^8.2", "php": "^8.5",
"inertiajs/inertia-laravel": "^2.0", "inertiajs/inertia-laravel": "^2.0",
"laravel/framework": "^12.0", "laravel/framework": "^12.0",
"laravel/tinker": "^2.10.1" "laravel/tinker": "^2.10.1"

20
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "0e42fe1a7066e7a110e956ae26703d94", "content-hash": "587caff9de06de75c1e22cceac366334",
"packages": [ "packages": [
{ {
"name": "brick/math", "name": "brick/math",
@@ -2194,16 +2194,16 @@
}, },
{ {
"name": "nesbot/carbon", "name": "nesbot/carbon",
"version": "3.11.0", "version": "3.11.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/CarbonPHP/carbon.git", "url": "https://github.com/CarbonPHP/carbon.git",
"reference": "bdb375400dcd162624531666db4799b36b64e4a1" "reference": "f438fcc98f92babee98381d399c65336f3a3827f"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/bdb375400dcd162624531666db4799b36b64e4a1", "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/f438fcc98f92babee98381d399c65336f3a3827f",
"reference": "bdb375400dcd162624531666db4799b36b64e4a1", "reference": "f438fcc98f92babee98381d399c65336f3a3827f",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -2227,7 +2227,7 @@
"phpstan/extension-installer": "^1.4.3", "phpstan/extension-installer": "^1.4.3",
"phpstan/phpstan": "^2.1.22", "phpstan/phpstan": "^2.1.22",
"phpunit/phpunit": "^10.5.53", "phpunit/phpunit": "^10.5.53",
"squizlabs/php_codesniffer": "^3.13.4" "squizlabs/php_codesniffer": "^3.13.4 || ^4.0.0"
}, },
"bin": [ "bin": [
"bin/carbon" "bin/carbon"
@@ -2270,14 +2270,14 @@
} }
], ],
"description": "An API extension for DateTime that supports 281 different languages.", "description": "An API extension for DateTime that supports 281 different languages.",
"homepage": "https://carbon.nesbot.com", "homepage": "https://carbonphp.github.io/carbon/",
"keywords": [ "keywords": [
"date", "date",
"datetime", "datetime",
"time" "time"
], ],
"support": { "support": {
"docs": "https://carbon.nesbot.com/docs", "docs": "https://carbonphp.github.io/carbon/guide/getting-started/introduction.html",
"issues": "https://github.com/CarbonPHP/carbon/issues", "issues": "https://github.com/CarbonPHP/carbon/issues",
"source": "https://github.com/CarbonPHP/carbon" "source": "https://github.com/CarbonPHP/carbon"
}, },
@@ -2295,7 +2295,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2025-12-02T21:04:28+00:00" "time": "2026-01-29T09:26:29+00:00"
}, },
{ {
"name": "nette/schema", "name": "nette/schema",
@@ -8436,7 +8436,7 @@
"prefer-stable": true, "prefer-stable": true,
"prefer-lowest": false, "prefer-lowest": false,
"platform": { "platform": {
"php": "^8.2" "php": "^8.5"
}, },
"platform-dev": {}, "platform-dev": {},
"plugin-api-version": "2.9.0" "plugin-api-version": "2.9.0"

View File

@@ -65,7 +65,7 @@ return [
| |
*/ */
'timezone' => 'UTC', 'timezone' => 'Europe/Berlin',
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------

View File

@@ -61,8 +61,8 @@ return [
'providers' => [ 'providers' => [
'users' => [ 'users' => [
'driver' => 'eloquent', 'driver' => 'tenant-users',
'model' => env('AUTH_MODEL', App\Models\User::class), 'model' => App\Models\User::class,
], ],
// 'users' => [ // 'users' => [

View File

@@ -32,7 +32,7 @@ return [
| |
*/ */
'lifetime' => (int) env('SESSION_LIFETIME', 120), 'lifetime' => (int) env('SESSION_LIFETIME', 43200), // 30 days
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false), 'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),

View File

@@ -1,44 +0,0 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User>
*/
class UserFactory extends Factory
{
/**
* The current password being used by the factory.
*/
protected static ?string $password;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10),
];
}
/**
* Indicate that the model's email address should be unverified.
*/
public function unverified(): static
{
return $this->state(fn (array $attributes) => [
'email_verified_at' => null,
]);
}
}

View File

@@ -1,49 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->rememberToken();
$table->timestamps();
});
Schema::create('password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('users');
Schema::dropIfExists('password_reset_tokens');
Schema::dropIfExists('sessions');
}
};

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('tenants', function (Blueprint $table) {
$table->id();
$table->string('slug')->unique();
$table->string('name');
$table->string('email');
$table->string('url');
$table->string('account_iban');
$table->string('city');
$table->string('postcode');
$table->string('gdpr_text')-> nullable();
$table->string('impress_text')->nullable();
$table->string('url_participation_rules')->nullable();
$table->boolean('has_active_instance')->default(true);
$table->boolean('is_active_local_group')->default(true);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('tenants');
}
};

View File

@@ -0,0 +1,108 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('user_roles', function (Blueprint $table) {
$table->string('slug')->unique()->primary();
$table->string('name');
$table->timestamps();
});
Schema::create('eating_habits', function (Blueprint $table) {
$table->string('slug')->unique()->primary();
$table->string('name');
$table->timestamps();
});
Schema::create('swimming_permissions', function (Blueprint $table) {
$table->string('slug')->unique()->primary();
$table->string('name');
$table->timestamps();
});
Schema::create('first_aid_permissions', function (Blueprint $table) {
$table->string('slug')->unique()->primary();
$table->string('name');
$table->string('description');
$table->timestamps();
});
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('user_role_main');
$table->string('user_role_local_group');
$table->string('username')->unique();
$table->string('password')->nullable();
$table->string('local_group');
$table->string('firstname');
$table->string('nickname')->nullable();
$table->string('lastname');
$table->string('membership_id')->nullable();
$table->string('address_1')->nullable();
$table->string('address_2')->nullable();
$table->string('postcode')->nullable();
$table->string('city')->nullable();
$table->string('email')->nullable();
$table->string('phone')->nullable();
$table->date('birthday')->nullable();
$table->string('medications')->nullable();
$table->string('allergies')->nullable();
$table->string('intolerances')->nullable();
$table->string('eating_habits')->nullable();
$table->string('swimming_permission')->nullable();
$table->string('first_aid_permission')->nullable();
$table->string('bank_account_iban')->nullable();
$table->string('activation_token')->nullable();
$table->dateTime('activation_token_expires_at')->nullable()->default(date('Y-m-d H:i:s', strtotime('+3 days')));
$table->boolean('active')->default(false);
$table->foreign('local_group')->references('slug')->on('tenants')->cascadeOnDelete()->cascadeOnUpdate();
$table->foreign('user_role_main')->references('slug')->on('user_roles')->cascadeOnDelete()->cascadeOnUpdate();
$table->foreign('user_role_local_group')->references('slug')->on('user_roles')->cascadeOnDelete()->cascadeOnUpdate();
$table->foreign('swimming_permission')->references('slug')->on('swimming_permissions')->cascadeOnDelete()->cascadeOnUpdate();
$table->foreign('eating_habits')->references('slug')->on('eating_habits')->cascadeOnDelete()->cascadeOnUpdate();
$table->foreign('first_aid_permission')->references('slug')->on('first_aid_permissions')->cascadeOnDelete()->cascadeOnUpdate();
$table->rememberToken();
$table->timestamps();
});
Schema::create('password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('users');
Schema::dropIfExists('password_reset_tokens');
Schema::dropIfExists('sessions');
Schema::dropIfExists('user_roles');
Schema::dropIfExists('eating_habits');
Schema::dropIfExists('swimming_permissions');
Schema::dropIfExists('first_aid_permissions');
}
};

View File

@@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('cost_unit_types', function (Blueprint $table) {
$table->string('slug')->primary();
$table->string('name');
$table->timestamps();
});
Schema::create('cost_units', function (Blueprint $table) {
$table->id();
$table->string('tenant');
$table->string('name');
$table->string('type');
$table->dateTime('billing_deadline');
$table->float('distance_allowance');
$table->boolean('mail_on_new')->default(true);
$table->boolean('allow_new')->default(true);
$table->boolean('archived')->default(false);
$table->string('treasurers');
$table->foreign('tenant')->references('slug')->on('tenants')->cascadeOnDelete()->cascadeOnUpdate();
$table->foreign('type')->references('slug')->on('cost_unit_types')->cascadeOnDelete()->cascadeOnUpdate();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('cost_units');
Schema::dropIfExists('cost_unit_types');
}
};

View File

@@ -2,7 +2,9 @@
namespace Database\Seeders; namespace Database\Seeders;
use App\Models\User; use App\Installer\DevelopmentDataSeeder;
use App\Installer\ProductionData;
use App\Installer\ProductionDataSeeder;
use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
@@ -15,11 +17,12 @@ class DatabaseSeeder extends Seeder
*/ */
public function run(): void public function run(): void
{ {
// User::factory(10)->create(); $productionSeeeder = new ProductionDataSeeder();
$productionSeeeder->execute();
User::factory()->create([ if (str_ends_with(env('APP_URL'), 'mareike.local')) {
'name' => 'Test User', $deveopmentDataSeeder = new DevelopmentDataSeeder();
'email' => 'test@example.com', $deveopmentDataSeeder->execute();
]); }
} }
} }

View File

@@ -18,12 +18,12 @@ services:
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.mareike.rule=Host(`mareike.local`)" - "traefik.http.routers.mareike.rule=Host(`mareike.local`) || Host(`admin.mareike.local`) || Host(`wilde-moehre.mareike.local`) || Host(`fennek.mareike.local`)"
- "traefik.http.routers.mareike.entrypoints=websecure" - "traefik.http.routers.mareike.entrypoints=websecure"
- "traefik.http.routers.mareike.tls=true" - "traefik.http.routers.mareike.tls=true"
- "traefik.http.services.mareike.loadbalancer.server.port=80" - "traefik.http.services.mareike.loadbalancer.server.port=80"
- "traefik.http.routers.mareike-http.rule=Host(`mareike.local`)" - "traefik.http.routers.mareike-http.rule=Host(`mareike.local`) || Host(`admin.mareike.local`) || Host(`wilde-moehre.mareike.local`)"
- "traefik.http.routers.mareike-http.entrypoints=web" - "traefik.http.routers.mareike-http.entrypoints=web"
- "traefik.http.routers.mareike-http.middlewares=redirect-to-https" - "traefik.http.routers.mareike-http.middlewares=redirect-to-https"
@@ -46,8 +46,6 @@ services:
- ./:/var/www/html - ./:/var/www/html
command: > command: >
sh -c " sh -c "
npm install &&
npm install vue3-toastify && npm install @inertiajs/progress && npm install @inertiajs/progress &&
while true; do while true; do
npm run build npm run build
echo 'Vite Dev-Server beendet. Neustart in 3 Sekunden...' echo 'Vite Dev-Server beendet. Neustart in 3 Sekunden...'

View File

@@ -30,5 +30,16 @@ server {
fastcgi_index index.php; fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
} }
# In der Nginx-Konfiguration von mareike.local
location /build/assets/ {
# Erlaubt explizit deine Subdomain
add_header 'Access-Control-Allow-Origin' "$http_origin" always;
add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Content-Type, X-Inertia' always;
# Falls du alle Subdomains von mareike.local erlauben willst:
#
}
} }

218
package-lock.json generated
View File

@@ -5,6 +5,9 @@
"packages": { "packages": {
"": { "": {
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0",
"@fortawesome/vue-fontawesome": "^3.1.3",
"@inertiajs/progress": "^0.2.7", "@inertiajs/progress": "^0.2.7",
"@inertiajs/vue3": "^2.3.12", "@inertiajs/vue3": "^2.3.12",
"vue": "^3.5.27", "vue": "^3.5.27",
@@ -13,10 +16,12 @@
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
"@vitejs/plugin-vue": "^6.0.3", "@vitejs/plugin-vue": "^6.0.3",
"autoprefixer": "^10.4.24",
"axios": "^1.11.0", "axios": "^1.11.0",
"concurrently": "^9.0.1", "concurrently": "^9.0.1",
"laravel-vite-plugin": "^2.0.0", "laravel-vite-plugin": "^2.0.0",
"tailwindcss": "^4.0.0", "postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"vite": "^7.0.7" "vite": "^7.0.7"
} }
}, },
@@ -508,6 +513,49 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@fortawesome/fontawesome-common-types": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-7.1.0.tgz",
"integrity": "sha512-l/BQM7fYntsCI//du+6sEnHOP6a74UixFyOYUyz2DLMXKx+6DEhfR3F2NYGE45XH1JJuIamacb4IZs9S0ZOWLA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/fontawesome-svg-core": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-7.1.0.tgz",
"integrity": "sha512-fNxRUk1KhjSbnbuBxlWSnBLKLBNun52ZBTcs22H/xEEzM6Ap81ZFTQ4bZBxVQGQgVY0xugKGoRcCbaKjLQ3XZA==",
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-common-types": "7.1.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-solid-svg-icons": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-7.1.0.tgz",
"integrity": "sha512-Udu3K7SzAo9N013qt7qmm22/wo2hADdheXtBfxFTecp+ogsc0caQNRKEb7pkvvagUGOpG9wJC1ViH6WXs8oXIA==",
"license": "(CC-BY-4.0 AND MIT)",
"dependencies": {
"@fortawesome/fontawesome-common-types": "7.1.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/vue-fontawesome": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@fortawesome/vue-fontawesome/-/vue-fontawesome-3.1.3.tgz",
"integrity": "sha512-OHHUTLPEzdwP8kcYIzhioUdUOjZ4zzmi+midwa4bqscza4OJCOvTKJEHkXNz8PgZ23kWci1HkKVX0bm8f9t9gQ==",
"license": "MIT",
"peerDependencies": {
"@fortawesome/fontawesome-svg-core": "~1 || ~6 || ~7",
"vue": ">= 3.0.0 < 4"
}
},
"node_modules/@inertiajs/core": { "node_modules/@inertiajs/core": {
"version": "2.3.12", "version": "2.3.12",
"resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-2.3.12.tgz", "resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-2.3.12.tgz",
@@ -1421,6 +1469,43 @@
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/autoprefixer": {
"version": "10.4.24",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz",
"integrity": "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/autoprefixer"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"browserslist": "^4.28.1",
"caniuse-lite": "^1.0.30001766",
"fraction.js": "^5.3.4",
"picocolors": "^1.1.1",
"postcss-value-parser": "^4.2.0"
},
"bin": {
"autoprefixer": "bin/autoprefixer"
},
"engines": {
"node": "^10 || ^12 || >=14"
},
"peerDependencies": {
"postcss": "^8.1.0"
}
},
"node_modules/axios": { "node_modules/axios": {
"version": "1.13.4", "version": "1.13.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz",
@@ -1432,6 +1517,50 @@
"proxy-from-env": "^1.1.0" "proxy-from-env": "^1.1.0"
} }
}, },
"node_modules/baseline-browser-mapping": {
"version": "2.9.19",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
"integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.js"
}
},
"node_modules/browserslist": {
"version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/browserslist"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
"electron-to-chromium": "^1.5.263",
"node-releases": "^2.0.27",
"update-browserslist-db": "^1.2.0"
},
"bin": {
"browserslist": "cli.js"
},
"engines": {
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/call-bind-apply-helpers": { "node_modules/call-bind-apply-helpers": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@@ -1461,6 +1590,27 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/caniuse-lite": {
"version": "1.0.30001766",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz",
"integrity": "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/caniuse-lite"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "CC-BY-4.0"
},
"node_modules/chalk": { "node_modules/chalk": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -1612,6 +1762,13 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/electron-to-chromium": {
"version": "1.5.283",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.283.tgz",
"integrity": "sha512-3vifjt1HgrGW/h76UEeny+adYApveS9dH2h3p57JYzBSXJIKUJAvtmIytDKjcSCt9xHfrNCFJ7gts6vkhuq++w==",
"dev": true,
"license": "ISC"
},
"node_modules/emoji-regex": { "node_modules/emoji-regex": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
@@ -1802,6 +1959,20 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/fraction.js": {
"version": "5.3.4",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
"integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "*"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/fsevents": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -2315,6 +2486,13 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
} }
}, },
"node_modules/node-releases": {
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
"dev": true,
"license": "MIT"
},
"node_modules/nprogress": { "node_modules/nprogress": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/nprogress/-/nprogress-0.2.0.tgz", "resolved": "https://registry.npmjs.org/nprogress/-/nprogress-0.2.0.tgz",
@@ -2380,6 +2558,13 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/postcss-value-parser": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"dev": true,
"license": "MIT"
},
"node_modules/proxy-from-env": { "node_modules/proxy-from-env": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
@@ -2659,6 +2844,37 @@
"dev": true, "dev": true,
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/browserslist"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"escalade": "^3.2.0",
"picocolors": "^1.1.1"
},
"bin": {
"update-browserslist-db": "cli.js"
},
"peerDependencies": {
"browserslist": ">= 4.21.0"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "7.3.1", "version": "7.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",

View File

@@ -10,13 +10,18 @@
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
"@vitejs/plugin-vue": "^6.0.3", "@vitejs/plugin-vue": "^6.0.3",
"autoprefixer": "^10.4.24",
"axios": "^1.11.0", "axios": "^1.11.0",
"concurrently": "^9.0.1", "concurrently": "^9.0.1",
"laravel-vite-plugin": "^2.0.0", "laravel-vite-plugin": "^2.0.0",
"tailwindcss": "^4.0.0", "postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"vite": "^7.0.7" "vite": "^7.0.7"
}, },
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0",
"@fortawesome/vue-fontawesome": "^3.1.3",
"@inertiajs/progress": "^0.2.7", "@inertiajs/progress": "^0.2.7",
"@inertiajs/vue3": "^2.3.12", "@inertiajs/vue3": "^2.3.12",
"vue": "^3.5.27", "vue": "^3.5.27",

126
public/css/app.css Normal file
View File

@@ -0,0 +1,126 @@
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php';
@source '../**/*.blade.php';
@source '../**/*.js';
html {
background-color: #FAFAFB !important;
}
.content {
padding: 50px 0px;
}
.main {
margin: 0 auto;
box-shadow: 20px 54px 15px rgba(0, 0, 0, 0.1);
border-radius: 0 10px 0 0;
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.header {
height: 80px; /* Höhe anpassen */
align-items: center;
justify-content: space-between;
width: calc(100% - 40px);
}
.header .anonymous-actions {
position: relative;
right: calc(-100% + 330px);
width: 350px;
overflow: hidden;
border-radius: 50px 0 0 50px;
background-color: #FAFAFB !important;
text-align: right;
padding: 10px;
top: -75px;
}
.header .user-info {
position: relative;
right: calc(-100% + 275px);
width: 295px;
overflow: hidden;
border-radius: 50px 0 0 50px;
background-color: #FAFAFB !important;
text-align: right;
padding: 10px;
top: -75px;
}
.header .left-side {
height: 75px;
background: #ffffff;
align-content: center;
padding: 0 50px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
width: 100%;
margin-top: -5px;
}
.flexbox {
display: flex;
background-color: #FAFAFB;
height: 100%;
padding: 0;
gap: 1px;
margin: 0;
}
/* Layout */
.app-layout {
display: flex;
height: 95vh;
margin: 20px;
background: #f0f2f5;
font-family: sans-serif;
}
.sidebar {
flex-basis:275px;
box-shadow: 2px 0 5px rgba(0,0,0,0.1);
display: flex;
flex-direction: column;
height: 100%;
background-color: #ffffff !important;
}
.logo {
display: flex;
align-items: center;
justify-content: center;
}
.logo img {
width: 135px !important;
height: 70px !important;
}
.footer {
height: 40px;
background: #666666;
border-top: 1px solid #ddd;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: #ffffff;
}
th {
text-align: left;
padding-right: 1em;
vertical-align: top;
}
th:after {
content: ":";
}

62
public/css/elements.css Normal file
View File

@@ -0,0 +1,62 @@
/* Toaster */
.toaster {
position: fixed;
bottom: 20px;
right: 20px;
background: #4caf50;
color: #fff;
padding: 12px 20px;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
}
input[type="text"],
input[type="email"],
input[type="password"],
select {
width: 100%;
font-size: 13pt;
padding: 5px;
border: 1px solid #ccc;
border-radius: 5px;
color: #656363;
background-color: #ffffff;
}
select {
width: calc(100% + 10px);
}
input[type="text"]:focus,
input[type="email"]:focus,
input[type="password"]:focus,
select:focus {
outline: none;
border-color: #1d4899;
color: #1d4899;
}
table {
width: 100%;
}
input[type="button"],
input[type="submit"] {
cursor: pointer;
background-color: #ffffff;
border: 1px solid #809dd5 !important;
padding: 10px 20px;
font-weight: bold;
}
input[type="button"]:hover,
input[type="submit"]:hover {
background-color: #1d4899;
color: #ffffff;
}
.error_text {
color: red;
display: block;
}

BIN
public/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

View File

@@ -1,11 +0,0 @@
@import 'tailwindcss';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php';
@source '../**/*.blade.php';
@source '../**/*.js';
@theme {
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
}

View File

@@ -5,43 +5,57 @@ import { InertiaProgress } from '@inertiajs/progress'
import Vue3Toastify, { toast } from 'vue3-toastify' import Vue3Toastify, { toast } from 'vue3-toastify'
import 'vue3-toastify/dist/index.css' import 'vue3-toastify/dist/index.css'
// Optional: Lade-Balken für Inertia import { library } from '@fortawesome/fontawesome-svg-core'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
// Icons importieren
import { faUser, faTrash, faCheck } from '@fortawesome/free-solid-svg-icons'
library.add(faUser, faTrash, faCheck)
InertiaProgress.init() InertiaProgress.init()
// Inertia App starten
createInertiaApp({ createInertiaApp({
// Alle Pages in app/Views/Pages/**/*.vue werden automatisch importiert
resolve: name => { resolve: name => {
// Vite scannt die Pages dynamisch const pages = import.meta.glob('@domains/**/*.vue')
const pages = import.meta.glob('@views/**/*.vue')
// Suche nach der richtigen Page-Datei
const key = Object.keys(pages).find(k => const key = Object.keys(pages).find(k =>
k.endsWith(`/${name}.vue`) || k.endsWith(`/${name}/index.vue`) k.endsWith(`/${name}.vue`) || k.endsWith(`/${name}/index.vue`)
) )
if (!key) throw new Error(`Page not found: ${name}`) if (!key) throw new Error(`Page not found: ${name}`)
// Unterstützt sowohl <script setup> als auch klassische Exports
return pages[key]() return pages[key]()
}, },
// Setup der App
setup({ el, App, props, plugin }) { setup({ el, App, props, plugin }) {
const vueApp = createApp({ render: () => h(App, props) }) const vueApp = createApp({ render: () => h(App, props) })
// Inertia Plugin
vueApp.use(plugin) vueApp.use(plugin)
// Toastify global verfügbar machen vueApp.component('font-awesome-icon', FontAwesomeIcon)
vueApp.use(Vue3Toastify, {
autoClose: 3000,
position: 'top-right',
pauseOnHover: true,
})
vueApp.config.globalProperties.$toast = toast
// Mounten auf das DOM vueApp.use(Vue3Toastify, {
autoClose: 10000,
position: 'bottom-right',
pauseOnHover: true,
hideProgressBar: false, // Progressbar anzeigen
toastDefaults: {
success: {
style: {background: '#4caf50', color: '#fff'}, // grün
progressStyle: {background: '#2e7d32', height: '4px'},
},
error: {
style: {background: '#f44336', color: '#fff'}, // rot
progressStyle: {background: '#c62828', height: '4px'},
},
},
})
vueApp.config.globalProperties.$toast = toast
vueApp.mount(el) vueApp.mount(el)
}, },
}) })

View File

@@ -0,0 +1,80 @@
import { ref } from "vue"
export function useAjax() {
const loading = ref(false)
const error = ref(null)
const data = ref(null)
async function request(url, options = {}) {
loading.value = true
error.value = null
data.value = null
try {
const response = await fetch(url, {
method: options.method || "GET",
headers: {
"Content-Type": "application/json",
...(options.headers || {}),
},
body: options.body ? JSON.stringify(options.body) : null,
})
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const result = await response.json()
data.value = result
return result
} catch (err) {
error.value = err
console.error("AJAX Error:", err)
return null
} finally {
loading.value = false
}
}
async function download(url, options = {}) {
loading.value = true
error.value = null
try {
const response = await fetch(url, {
method: options.method || "GET",
headers: {
...(options.headers || {}),
},
body: options.body ? JSON.stringify(options.body) : null,
})
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const blob = await response.blob()
const filename =
options.filename ||
response.headers
.get("Content-Disposition")
?.split("filename=")[1]
?.replace(/["']/g, "") ||
"download.bin"
const downloadUrl = window.URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = downloadUrl
a.download = filename
document.body.appendChild(a)
a.click()
a.remove()
window.URL.revokeObjectURL(downloadUrl)
return true
} catch (err) {
error.value = err
console.error("Download Error:", err)
return false
} finally {
loading.value = false
}
}
return {data, loading, error, request, download}
}

View File

@@ -0,0 +1,195 @@
<script setup>
import Icon from "../../../app/Views/Components/Icon.vue";
import GlobalWidgets from "../../../app/Views/Partials/GlobalWidgets/GlobalWidgets.vue";
const props = defineProps({
title: { type: String, default: 'App' },
user: { type: Object, },
flash: { type: Object, default: () => ({}) },
navbar: { type: Object },
tenant: String,
currentPath: String,
})
console.log(props.currentPath)
</script>
<template>
<div class="app-layout">
<!-- Sidebar -->
<!-- Main content -->
<div class="main">
<!-- Header -->
<div class="flexbox">
<div class="sidebar">
<div class="logo">
<img src="../../../public/images/logo.png" alt="Logo" />
</div>
<nav class="nav">
<ul class="nav-links" v-if="props.navbar.personal.length > 0">
<li v-for="navlink in props.navbar.personal">
<a
:class="{ navlink_active: navlink.url.endsWith(props.currentPath) }"
:href="navlink.url"
>{{navlink.display}}</a>
</li>
</ul>
<ul class="nav-links" v-if="props.navbar.common.length > 0">
<li v-for="navlink in props.navbar.common">
<a
:class="{ navlink_active: navlink.url.endsWith(props.currentPath) }"
:href="navlink.url"
>{{navlink.display}}</a>
</li>
</ul>
<ul class="nav-links" v-if="props.navbar.costunits.length > 0">
<li v-for="navlink in props.navbar.costunits">
<a
:class="{ navlink_active: navlink.url.endsWith(props.currentPath) }"
:href="navlink.url"
>{{navlink.display}}</a>
</li>
</ul>
<ul class="nav-links" v-if="props.navbar.events.length > 0">
<li v-for="navlink in props.navbar.events">
<a
:class="{ navlink_active: navlink.url.endsWith(props.currentPath) }"
:href="navlink.url"
>{{navlink.display}}</a>
</li>
</ul>
</nav>
</div>
<div class="main">
<div class="header">
<div class="left-side"><h1>{{ props.title }}</h1></div>
<div class="user-info" v-if="props.user !== null">
<a href="/messages" class="header-link-anonymous" title="Meine Nachrichten">
<Icon name="envelope" />
</a>
<a href="/profile" class="header-link-anonymous" title="Mein Profil">
<Icon name="user" />
</a>
<a href="/logout" class="header-link-anonymous-logout" title="Abmelden">
<Icon name="lock" />
</a>
</div>
<div class="anonymous-actions" v-else>
<a href="/register" class="header-link-anonymous">Registrieren</a>
<a href="/login" class="header-link-anonymous"> Anmelden </a>
</div>
</div>
<global-widgets :user="props.user" :tenant="props.tenant" v-if="props.user !== null" />
<div class="content">
<slot />
</div>
</div>
</div>
<!-- Footer -->
<footer class="footer">
&copy; 2026 Your Company
</footer>
</div>
<!-- Toaster -->
<transition name="fade">
<div v-if="flash.message" class="toaster">
{{ flash.message }}
</div>
</transition>
</div>
</template>
<style scoped>
.header-link-anonymous,
.header-link-anonymous-logout {
color: #000000;
font-weight: bold;
text-decoration: none;
background-color: #ffffff;
padding: 10px 30px;
margin: 0 -20px 0 30px;
overflow: hidden !important;
}
.header-link-anonymous-logout {
padding-right: 35px !important;
}
.header-link-anonymous:hover {
background-color: #1d4899;
color: #ffffff;
}
.header-link-anonymous-logout:hover {
background-color: #ff0000;
color: #ffffff;
}
.nav-links {
border-bottom: 1px solid #ddd;
width: 285px;
}
.nav-links li a {
color: #b6b6b6;
background-color: #fff;
padding: 20px 25px;
display: block;
text-decoration: none;
width: calc(100% - 50px);
font-weight: bold;
margin-bottom: 0px;
}
.nav {
flex: 1;
margin-left: -10px;
width: 275px;
}
.nav ul {
list-style: none;
padding: 0;
}
.nav li {
margin-bottom: 10px;
}
.nav a {
text-decoration: none;
color: #333;
padding: 8px 12px;
display: block;
transition: background 0.2s;
}
.nav a:hover {
background-color: #1d4899;
color: #ffffff;
}
.navlink_active {
background-color: #fae39c !important;
color: #1d4899 !important; /* Dunklere Schrift beim Hover */
}
</style>

View File

@@ -1,10 +1,19 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="de"> <html lang="de">
<head> <head>
<link rel="stylesheet" href="/css/app.css" />
<link rel="stylesheet" href="/css/elements.css" />
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="csrf-token" content="{{ csrf_token() }}">
<!-- Base href für relative Assets -->
<base href="/">
<!-- Vite Assets -->
@vite('resources/js/app.js') @vite('resources/js/app.js')
@inertiaHead @inertiaHead
</head> </head>
<body> <body>

3
routes/api.php Normal file
View File

@@ -0,0 +1,3 @@
<?php
require __DIR__.'/../app/Domains/UserManagement/Routes/api.php';

View File

@@ -1,16 +1,37 @@
<?php <?php
use App\Domains\Dashboard\Controllers\DashboardController;
use App\Domains\UserManagement\Controllers\EmailVerificationController;
use App\Domains\UserManagement\Controllers\LoginController;
use App\Domains\UserManagement\Controllers\LogOutController;
use App\Domains\UserManagement\Controllers\RegistrationController;
use App\Domains\UserManagement\Controllers\ResetPasswordController;
use App\Http\Controllers\TestRenderInertiaProvider;
use App\Middleware\IdentifyTenant;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Inertia\Inertia; use Inertia\Inertia;
require_once __DIR__ . '/../app/Domains/UserManagement/Routes/web.php';
Route::get('/', function () { Route::middleware(IdentifyTenant::class)->group(function () {
return view('welcome'); Route::get('/', DashboardController::class);
Route::middleware(['auth'])->group(function () {
Route::get('/messages', fn () => inertia('Messages'));
});
Route::get('/messages', [TestRenderInertiaProvider::class, 'index']);
}); });
Route::get('/inertia', function () {
return Inertia::render('Pages/Home', [
'appName' => config('app.name'),
]);
});

View File

@@ -4,6 +4,7 @@ import laravel from 'laravel-vite-plugin'
import path from 'path' import path from 'path'
export default defineConfig({ export default defineConfig({
base: '',
plugins: [ plugins: [
laravel({ laravel({
input: 'resources/js/app.js', input: 'resources/js/app.js',
@@ -15,6 +16,12 @@ export default defineConfig({
alias: { alias: {
'@': path.resolve(__dirname, 'resources/js'), '@': path.resolve(__dirname, 'resources/js'),
'@views': path.resolve(__dirname, 'app/Views'), '@views': path.resolve(__dirname, 'app/Views'),
'@domains': path.resolve(__dirname, 'app/Domains'),
}, },
}, },
server: {
host: true, // Dev-Server auch über Subdomains erreichbar
strictPort: true, // verhindert zufällige Portwechsel
cors: true, // erlaubt Dev-HMR über Subdomains
},
}) })