Initial commit

This commit is contained in:
2025-12-20 15:43:22 +01:00
commit c693f7c7ed
127 changed files with 17921 additions and 0 deletions

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Laravel\Fortify\Contracts\CreatesNewUsers;
class CreateNewUser implements CreatesNewUsers
{
use PasswordValidationRules;
/**
* Validate and create a newly registered user.
*
* @param array<string, string> $input
*/
public function create(array $input): User
{
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'email',
'max:255',
Rule::unique(User::class),
],
'password' => $this->passwordRules(),
])->validate();
return User::create([
'name' => $input['name'],
'email' => $input['email'],
'password' => $input['password'],
]);
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Actions\Fortify;
use Illuminate\Validation\Rules\Password;
trait PasswordValidationRules
{
/**
* Get the validation rules used to validate passwords.
*
* @return array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>
*/
protected function passwordRules(): array
{
return ['required', 'string', Password::default(), 'confirmed'];
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Support\Facades\Validator;
use Laravel\Fortify\Contracts\ResetsUserPasswords;
class ResetUserPassword implements ResetsUserPasswords
{
use PasswordValidationRules;
/**
* Validate and reset the user's forgotten password.
*
* @param array<string, string> $input
*/
public function reset(User $user, array $input): void
{
Validator::make($input, [
'password' => $this->passwordRules(),
])->validate();
$user->forceFill([
'password' => $input['password'],
])->save();
}
}

29
app/Enums/PartyEnum.php Normal file
View File

@@ -0,0 +1,29 @@
<?php
namespace App\Enums;
enum PartyEnum: string
{
case SOCIAL_DEMOCRATS = 'S';
case CENTER_PARTY = 'C';
case LIBERALS = 'L';
case GREEN_PARTY = 'MP';
case LEFT_PARTY = 'V';
case MODERATES = 'M';
case CHRIST_DEMOCRATS = 'KD';
case SWEDEN_DEMOCRATS = 'SD';
public function label(): string
{
return match ($this) {
self::SOCIAL_DEMOCRATS => 'Socialdemokraterna',
self::CENTER_PARTY => 'Centerpartiet',
self::LIBERALS => 'Liberalerna',
self::GREEN_PARTY => 'Miljöpartiet',
self::LEFT_PARTY => 'Vänsterpartiet',
self::MODERATES => 'Moderaterna',
self::CHRIST_DEMOCRATS => 'Kristdemokraterna',
self::SWEDEN_DEMOCRATS => 'Sverigedemokraterna',
};
}
}

View File

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

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Livewire\Actions;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Session;
class Logout
{
/**
* Log the current user out of the application.
*/
public function __invoke()
{
Auth::guard('web')->logout();
Session::invalidate();
Session::regenerateToken();
return redirect('/');
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Livewire\Person;
use Livewire\Component;
use App\Services\RiksdagenService;
use App\Enums\PartyEnum;
class Search extends Component
{
public $firstName = '';
public $lastName = '';
public $party = '';
public $results = [];
public $parties = [];
public function mount()
{
$this->parties = PartyEnum::cases();
}
public function search()
{
$service = app(RiksdagenService::class);
$this->results = $service->searchPerson(
firstName: $this->firstName,
lastName: $this->lastName,
party: $this->party
)->original;
}
public function render()
{
return view('livewire.person.search');
}
}

View File

@@ -0,0 +1,157 @@
<?php
namespace App\Livewire\Person;
use Livewire\Component;
use App\Services\RiksdagenService;
use Livewire\Attributes\Computed;
use Asantibanez\LivewireCharts\Models\PieChartModel;
class Show extends Component
{
public $personId;
public $person;
public $votes = [];
public $votesByYear = [];
public $selectedYear;
public $selectedUppdragTab = 'current';
public $currentUppdrag = [];
public $previousUppdrag = [];
public function mount($personId)
{
$this->personId = $personId;
$service = app(RiksdagenService::class);
$result = $service->searchPerson(mp_id: $personId);
$this->person = $result->original->personlista->person ?? null;
$this->getPersonVotes();
$this->groupUppdrag();
}
#[Computed()]
public function riksdagenUrl()
{
return "https://www.riksdagen.se/sv/ledamoter-partier/ledamot/" . $this->person->tilltalsnamn . "-" . $this->person->efternamn . "_" . $this->personId;
}
public function getPersonVotes()
{
$service = app(RiksdagenService::class);
$result = $service->searchVotes(mp_id: $this->personId);
$votesList = $result->original->voteringlista->votering ?? [];
// Group votes by year (from rm field like 2025/26)
foreach ($votesList as $vote) {
$year = explode('/', $vote->rm)[0]; // Extract year from rm like "2025/26"
if (!isset($this->votesByYear[$year])) {
$this->votesByYear[$year] = [];
}
$this->votesByYear[$year][] = $vote;
}
// Set default selected year to the most recent
if (!empty($this->votesByYear)) {
$this->selectedYear = max(array_keys($this->votesByYear));
}
}
public function selectYear($year)
{
$this->selectedYear = $year;
}
public function selectUppdragTab($tab)
{
$this->selectedUppdragTab = $tab;
}
public function groupUppdrag()
{
if (!$this->person || !isset($this->person->personuppdrag->uppdrag)) {
return;
}
$uppdrag = $this->person->personuppdrag->uppdrag;
$now = now();
foreach ($uppdrag as $assignment) {
// Check if assignment is current (tom is empty or in the future)
$isCurrent = empty($assignment->tom) ||
(isset($assignment->tom) && $assignment->tom &&
\Carbon\Carbon::parse($assignment->tom)->isFuture());
if ($isCurrent) {
$this->currentUppdrag[] = $assignment;
} else {
$this->previousUppdrag[] = $assignment;
}
}
// Sort by date (most recent first)
usort($this->currentUppdrag, function($a, $b) {
return \Carbon\Carbon::parse($b->from)->timestamp - \Carbon\Carbon::parse($a->from)->timestamp;
});
usort($this->previousUppdrag, function($a, $b) {
return \Carbon\Carbon::parse($b->tom ?: $b->from)->timestamp - \Carbon\Carbon::parse($a->tom ?: $a->from)->timestamp;
});
}
#[Computed()]
public function votingStatistics()
{
if (!$this->selectedYear || !isset($this->votesByYear[$this->selectedYear])) {
return [];
}
$votes = $this->votesByYear[$this->selectedYear];
$statistics = [];
foreach ($votes as $vote) {
$voteType = $vote->rost;
if (!isset($statistics[$voteType])) {
$statistics[$voteType] = 0;
}
$statistics[$voteType]++;
}
return $statistics;
}
#[Computed()]
public function pieChartModel()
{
$statistics = $this->votingStatistics;
$pieChart = (new PieChartModel())
->setTitle('Röststatistik för ' . $this->selectedYear)
->setAnimated(true);
$colors = [
'Ja' => '#10b981', // Green
'Nej' => '#ef4444', // Red
'Frånvarande' => '#6b7280', // Gray
'Avstår' => '#f59e0b', // Yellow
];
foreach ($statistics as $voteType => $count) {
$color = $colors[$voteType] ?? '#8b5cf6'; // Default purple
$pieChart->addSlice($voteType, $count, $color);
}
return $pieChart;
}
public function render()
{
return view('livewire.person.show');
}
}

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Livewire\Settings;
use Livewire\Component;
class Appearance extends Component
{
//
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Livewire\Settings;
use App\Livewire\Actions\Logout;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class DeleteUserForm extends Component
{
public string $password = '';
/**
* Delete the currently authenticated user.
*/
public function deleteUser(Logout $logout): void
{
$this->validate([
'password' => ['required', 'string', 'current_password'],
]);
tap(Auth::user(), $logout(...))->delete();
$this->redirect('/', navigate: true);
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Livewire\Settings;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rules\Password as PasswordRule;
use Illuminate\Validation\ValidationException;
use Livewire\Component;
class Password extends Component
{
public string $current_password = '';
public string $password = '';
public string $password_confirmation = '';
/**
* Update the password for the currently authenticated user.
*/
public function updatePassword(): void
{
try {
$validated = $this->validate([
'current_password' => ['required', 'string', 'current_password'],
'password' => ['required', 'string', PasswordRule::defaults(), 'confirmed'],
]);
} catch (ValidationException $e) {
$this->reset('current_password', 'password', 'password_confirmation');
throw $e;
}
Auth::user()->update([
'password' => $validated['password'],
]);
$this->reset('current_password', 'password', 'password_confirmation');
$this->dispatch('password-updated');
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace App\Livewire\Settings;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Session;
use Illuminate\Validation\Rule;
use Livewire\Component;
class Profile extends Component
{
public string $name = '';
public string $email = '';
/**
* Mount the component.
*/
public function mount(): void
{
$this->name = Auth::user()->name;
$this->email = Auth::user()->email;
}
/**
* Update the profile information for the currently authenticated user.
*/
public function updateProfileInformation(): void
{
$user = Auth::user();
$validated = $this->validate([
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'lowercase',
'email',
'max:255',
Rule::unique(User::class)->ignore($user->id),
],
]);
$user->fill($validated);
if ($user->isDirty('email')) {
$user->email_verified_at = null;
}
$user->save();
$this->dispatch('profile-updated', name: $user->name);
}
/**
* Send an email verification notification to the current user.
*/
public function resendVerificationNotification(): void
{
$user = Auth::user();
if ($user->hasVerifiedEmail()) {
$this->redirectIntended(default: route('dashboard', absolute: false));
return;
}
$user->sendEmailVerificationNotification();
Session::flash('status', 'verification-link-sent');
}
}

View File

@@ -0,0 +1,182 @@
<?php
namespace App\Livewire\Settings;
use Exception;
use Laravel\Fortify\Actions\ConfirmTwoFactorAuthentication;
use Laravel\Fortify\Actions\DisableTwoFactorAuthentication;
use Laravel\Fortify\Actions\EnableTwoFactorAuthentication;
use Laravel\Fortify\Features;
use Laravel\Fortify\Fortify;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Symfony\Component\HttpFoundation\Response;
class TwoFactor extends Component
{
#[Locked]
public bool $twoFactorEnabled;
#[Locked]
public bool $requiresConfirmation;
#[Locked]
public string $qrCodeSvg = '';
#[Locked]
public string $manualSetupKey = '';
public bool $showModal = false;
public bool $showVerificationStep = false;
#[Validate('required|string|size:6', onUpdate: false)]
public string $code = '';
/**
* Mount the component.
*/
public function mount(DisableTwoFactorAuthentication $disableTwoFactorAuthentication): void
{
abort_unless(Features::enabled(Features::twoFactorAuthentication()), Response::HTTP_FORBIDDEN);
if (Fortify::confirmsTwoFactorAuthentication() && is_null(auth()->user()->two_factor_confirmed_at)) {
$disableTwoFactorAuthentication(auth()->user());
}
$this->twoFactorEnabled = auth()->user()->hasEnabledTwoFactorAuthentication();
$this->requiresConfirmation = Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm');
}
/**
* Enable two-factor authentication for the user.
*/
public function enable(EnableTwoFactorAuthentication $enableTwoFactorAuthentication): void
{
$enableTwoFactorAuthentication(auth()->user());
if (! $this->requiresConfirmation) {
$this->twoFactorEnabled = auth()->user()->hasEnabledTwoFactorAuthentication();
}
$this->loadSetupData();
$this->showModal = true;
}
/**
* Load the two-factor authentication setup data for the user.
*/
private function loadSetupData(): void
{
$user = auth()->user();
try {
$this->qrCodeSvg = $user?->twoFactorQrCodeSvg();
$this->manualSetupKey = decrypt($user->two_factor_secret);
} catch (Exception) {
$this->addError('setupData', 'Failed to fetch setup data.');
$this->reset('qrCodeSvg', 'manualSetupKey');
}
}
/**
* Show the two-factor verification step if necessary.
*/
public function showVerificationIfNecessary(): void
{
if ($this->requiresConfirmation) {
$this->showVerificationStep = true;
$this->resetErrorBag();
return;
}
$this->closeModal();
}
/**
* Confirm two-factor authentication for the user.
*/
public function confirmTwoFactor(ConfirmTwoFactorAuthentication $confirmTwoFactorAuthentication): void
{
$this->validate();
$confirmTwoFactorAuthentication(auth()->user(), $this->code);
$this->closeModal();
$this->twoFactorEnabled = true;
}
/**
* Reset two-factor verification state.
*/
public function resetVerification(): void
{
$this->reset('code', 'showVerificationStep');
$this->resetErrorBag();
}
/**
* Disable two-factor authentication for the user.
*/
public function disable(DisableTwoFactorAuthentication $disableTwoFactorAuthentication): void
{
$disableTwoFactorAuthentication(auth()->user());
$this->twoFactorEnabled = false;
}
/**
* Close the two-factor authentication modal.
*/
public function closeModal(): void
{
$this->reset(
'code',
'manualSetupKey',
'qrCodeSvg',
'showModal',
'showVerificationStep',
);
$this->resetErrorBag();
if (! $this->requiresConfirmation) {
$this->twoFactorEnabled = auth()->user()->hasEnabledTwoFactorAuthentication();
}
}
/**
* Get the current modal configuration state.
*/
public function getModalConfigProperty(): array
{
if ($this->twoFactorEnabled) {
return [
'title' => __('Two-Factor Authentication Enabled'),
'description' => __('Two-factor authentication is now enabled. Scan the QR code or enter the setup key in your authenticator app.'),
'buttonText' => __('Close'),
];
}
if ($this->showVerificationStep) {
return [
'title' => __('Verify Authentication Code'),
'description' => __('Enter the 6-digit code from your authenticator app.'),
'buttonText' => __('Continue'),
];
}
return [
'title' => __('Enable Two-Factor Authentication'),
'description' => __('To finish enabling two-factor authentication, scan the QR code or enter the setup key in your authenticator app.'),
'buttonText' => __('Continue'),
];
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Livewire\Settings\TwoFactor;
use Exception;
use Laravel\Fortify\Actions\GenerateNewRecoveryCodes;
use Livewire\Attributes\Locked;
use Livewire\Component;
class RecoveryCodes extends Component
{
#[Locked]
public array $recoveryCodes = [];
/**
* Mount the component.
*/
public function mount(): void
{
$this->loadRecoveryCodes();
}
/**
* Generate new recovery codes for the user.
*/
public function regenerateRecoveryCodes(GenerateNewRecoveryCodes $generateNewRecoveryCodes): void
{
$generateNewRecoveryCodes(auth()->user());
$this->loadRecoveryCodes();
}
/**
* Load the recovery codes for the user.
*/
private function loadRecoveryCodes(): void
{
$user = auth()->user();
if ($user->hasEnabledTwoFactorAuthentication() && $user->two_factor_recovery_codes) {
try {
$this->recoveryCodes = json_decode(decrypt($user->two_factor_recovery_codes), true);
} catch (Exception) {
$this->addError('recoveryCodes', 'Failed to load recovery codes');
$this->recoveryCodes = [];
}
}
}
}

64
app/Models/User.php Normal file
View File

@@ -0,0 +1,64 @@
<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Str;
use Laravel\Fortify\TwoFactorAuthenticatable;
class User extends Authenticatable
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable, TwoFactorAuthenticatable;
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [
'name',
'email',
'password',
];
/**
* The attributes that should be hidden for serialization.
*
* @var list<string>
*/
protected $hidden = [
'password',
'two_factor_secret',
'two_factor_recovery_codes',
'remember_token',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
/**
* Get the user's initials
*/
public function initials(): string
{
return Str::of($this->name)
->explode(' ')
->take(2)
->map(fn ($word) => Str::substr($word, 0, 1))
->implode('');
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Providers;
use App\Services\RiksdagenService;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
$this->app->bind(RiksdagenService::class, function () {
return new RiksdagenService();
});
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
//
}
}

View File

@@ -0,0 +1,72 @@
<?php
namespace App\Providers;
use App\Actions\Fortify\CreateNewUser;
use App\Actions\Fortify\ResetUserPassword;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Laravel\Fortify\Fortify;
class FortifyServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
$this->configureActions();
$this->configureViews();
$this->configureRateLimiting();
}
/**
* Configure Fortify actions.
*/
private function configureActions(): void
{
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
Fortify::createUsersUsing(CreateNewUser::class);
}
/**
* Configure Fortify views.
*/
private function configureViews(): void
{
Fortify::loginView(fn () => view('livewire.auth.login'));
Fortify::verifyEmailView(fn () => view('livewire.auth.verify-email'));
Fortify::twoFactorChallengeView(fn () => view('livewire.auth.two-factor-challenge'));
Fortify::confirmPasswordView(fn () => view('livewire.auth.confirm-password'));
Fortify::registerView(fn () => view('livewire.auth.register'));
Fortify::resetPasswordView(fn () => view('livewire.auth.reset-password'));
Fortify::requestPasswordResetLinkView(fn () => view('livewire.auth.forgot-password'));
}
/**
* Configure rate limiting.
*/
private function configureRateLimiting(): void
{
RateLimiter::for('two-factor', function (Request $request) {
return Limit::perMinute(5)->by($request->session()->get('login.id'));
});
RateLimiter::for('login', function (Request $request) {
$throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip());
return Limit::perMinute(5)->by($throttleKey);
});
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Services;
use GuzzleHttp\Client;
use Symfony\Component\HttpFoundation\JsonResponse;
class RiksdagenService
{
private Client $http;
public function __construct()
{
$this->http = new Client([
'base_uri' => 'https://data.riksdagen.se/',
]);
}
public function searchPerson(
string $mp_id = '',
string $firstName = '',
string $lastName = '',
string $party = ''
): JsonResponse {
$response = $this->http->get('personlista/', [
'query' => [
'iid' => $mp_id,
'parti' => $party,
'fnamn' => $firstName,
'enamn' => $lastName,
'utformat' => 'json',
'sort' => 'sorteringsnamn',
'sortorder' => 'asc'
]
])->getBody()->getContents();
$data = json_decode($response);
return response()->json($data);
}
public function searchVotes(
string $mp_id = '',
string $party = '',
string $date = '',
): JsonResponse {
$response = $this->http->get('voteringlista/', [
'query' => [
'iid' => $mp_id,
'rm' => $date,
'parti' => $party,
'utformat' => 'json',
'sz' => '500',
]
])->getBody()->getContents();
$data = json_decode($response);
return response()->json($data);
}
}