Bunch of progress
Some checks failed
linter / quality (push) Successful in 3m2s
tests / ci (push) Failing after 7m37s

This commit is contained in:
Javier Feliz 2025-07-27 22:18:35 -04:00
parent 9746756a44
commit 38c0c70ded
14 changed files with 316 additions and 46 deletions

1
.gitignore vendored
View File

@ -22,3 +22,4 @@ yarn-error.log
/.vscode
/.zed
/storage/oauth/*
/storage/avatars/*

View File

@ -172,7 +172,8 @@ class OIDCController extends Controller
'sub' => (string) $user->id,
'email' => $user->email,
'name' => $user->name,
'preferred_username' => str($user->name)->slug()->toString(),
'preferred_username' => $user->preferred_username,
'picture' => $user->avatar ? $user->avatarUrl() : null
]);
}
@ -206,7 +207,14 @@ class OIDCController extends Controller
'scopes_supported' => ["openid", "profile", "email"],
'response_types_supported' => ["code"],
"jwks_uri" => route('auth.keys'),
"id_token_signing_alg_values_supported" => ["RS256"]
"id_token_signing_alg_values_supported" => ["RS256"],
'claims_supported' => [
'sub',
'email',
'name',
'preferred_username',
'picture'
]
]);
}
}

View File

@ -0,0 +1,93 @@
<?php
namespace App\Livewire\Forms;
use Illuminate\Support\Facades\Auth;
use App\Models\User;
use Illuminate\Support\Facades\Session;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Livewire\WithFileUploads;
class UserProfile extends Component
{
use WithFileUploads;
// Profile info
public string $name = '';
public string $email = '';
public ?string $preferred_username = null;
public string $avatar = '';
#[Validate('image|max:10000')]
public $avatarUpload;
// Password
public string $current_password = '';
public string $password = '';
public string $password_confirmation = '';
public function mount(): void
{
$this->name = Auth::user()->name;
$this->email = Auth::user()->email;
$this->preferred_username = Auth::user()->preferred_username;
$this->avatar = Auth::user()->avatar;
}
/**
* 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),
],
'preferred_username' => 'string|max:255'
]);
$user->fill($validated);
if (!empty($this->avatarUpload)) {
if (!empty($user->avatar)) {
Storage::disk('avatars')->delete($user->avatar);
}
$user->avatar = $this->avatarUpload->store(options: 'avatars');
}
$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');
}
public function render()
{
return view('livewire.forms.user-profile');
}
}

View File

@ -11,7 +11,6 @@ use Livewire\Component;
class Profile extends Component
{
public string $name = '';
public string $email = '';
/**

View File

@ -23,6 +23,8 @@ class User extends Authenticatable
'name',
'email',
'password',
'avatar',
'preferred_username'
];
/**
@ -64,4 +66,9 @@ class User extends Authenticatable
{
return $this->hasMany(AuthenticationToken::class);
}
public function avatarUrl()
{
return route('user.avatar', ['path' => $this->avatar]);
}
}

View File

@ -41,12 +41,21 @@ return [
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => env('APP_URL').'/storage',
'url' => env('APP_URL') . '/storage',
'visibility' => 'public',
'throw' => false,
'report' => false,
],
'avatars' => [
'driver' => 'local',
'root' => storage_path('avatars'),
'url' => env('APP_URL') . '/storage',
'visibility' => 'public',
'throw' => true,
'report' => true,
],
's3' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),

View File

@ -0,0 +1,30 @@
<?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::table('users', function (Blueprint $table) {
$table->string('avatar')->nullable()->after('email');
$table->string('preferred_username')->nullable()->after('avatar');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('avatar');
$table->dropColumn('preferred_username');
});
}
};

View File

@ -57,8 +57,10 @@
<flux:menu.separator />
<flux:menu.radio.group>
<flux:menu.item :href="route('settings.profile')" icon="cog" wire:navigate>{{ __('Settings') }}
<flux:modal.trigger name="user-profile">
<flux:menu.item>{{ __('Edit your profile') }}
</flux:menu.item>
</flux:modal.trigger>
</flux:menu.radio.group>
<flux:menu.separator />

View File

@ -1,3 +1,3 @@
<x-layouts.auth.simple :title="$title ?? null">
<x-layouts.auth.split :title="$title ?? null">
{{ $slot }}
</x-layouts.auth.simple>
</x-layouts.auth.split>

View File

@ -1,12 +1,17 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
<head>
<head>
@include('partials.head')
</head>
<body class="min-h-screen bg-white antialiased dark:bg-linear-to-b dark:from-neutral-950 dark:to-neutral-900">
<div class="relative grid h-dvh flex-col items-center justify-center px-8 sm:px-0 lg:max-w-none lg:grid-cols-2 lg:px-0">
<div class="bg-muted relative hidden h-full flex-col p-10 text-white lg:flex dark:border-e dark:border-neutral-800">
<div class="absolute inset-0 bg-neutral-900"></div>
</head>
<body class="min-h-screen bg-white antialiased dark:bg-linear-to-b dark:from-neutral-950 dark:to-neutral-900">
<div
class="relative grid h-dvh flex-col items-center justify-center px-8 sm:px-0 lg:max-w-none lg:grid-cols-2 lg:px-0">
<div
class="bg-muted relative hidden h-full flex-col p-10 text-white lg:flex dark:border-e dark:border-neutral-800">
<div style="background: url('/img/background.jpg') no-repeat center center; background-size: cover;"
class="absolute inset-0 bg-neutral-900"></div>
<a href="{{ route('home') }}" class="relative z-20 flex items-center text-lg font-medium" wire:navigate>
<span class="flex h-10 w-10 items-center justify-center rounded-md">
<x-app-logo-icon class="me-2 h-7 fill-current text-white" />
@ -21,13 +26,17 @@
<div class="relative z-20 mt-auto">
<blockquote class="space-y-2">
<flux:heading size="lg">&ldquo;{{ trim($message) }}&rdquo;</flux:heading>
<footer><flux:heading>{{ trim($author) }}</flux:heading></footer>
<footer>
<flux:heading>{{ trim($author) }}</flux:heading>
</footer>
</blockquote>
</div>
</div>
<div class="w-full lg:p-8">
<div class="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
<a href="{{ route('home') }}" class="z-20 flex flex-col items-center gap-2 font-medium lg:hidden" wire:navigate>
<a href="{{ route('home') }}" class="z-20 flex flex-col items-center gap-2 font-medium lg:hidden"
wire:navigate>
<span class="flex h-9 w-9 items-center justify-center rounded-md">
<x-app-logo-icon class="size-9 fill-current text-black dark:text-white" />
</span>
@ -39,5 +48,6 @@
</div>
</div>
@fluxScripts
</body>
</body>
</html>

View File

@ -1,5 +1,8 @@
<x-layouts.app :title="__('Dashboard')">
<div class="max-w-4xl mx-auto py-12">
<livewire:app-container />
<div class="mt-4">
<livewire:forms.user-profile />
</div>
</div>
</x-layouts.app>

View File

@ -0,0 +1,90 @@
<div>
<div x-data="{edit: false}" class="border rounded-sm dark:border-zinc-600 border-stone-300 p-4">
<div x-show="!edit" class="flex gap-4">
<div>
@if (!empty($avatarUpload))
<img src="{{$avatarUpload->temporaryUrl()}}" alt="" class="size-20">
@elseif(!empty($avatar))
<img src="{{auth()->user()->avatarUrl()}}" alt="" class="size-20">
@else
<div
class="size-20 flex items-center justify-center mx-auto rounded-sm dark:bg-zinc-600 bg-stone-300 dark:text-white text-black">
<div class="text-4xl font-bold">{{auth()->user()->initials()}}</div>
</div>
@endif
</div>
<div class="flex-1 flex flex-col justify-between">
<flux:text>{{auth()->user()->name}}</flux:text>
<flux:text>{{auth()->user()->preferred_username}} <span class="italic">(preferred username)</span>
</flux:text>
<flux:text>{{auth()->user()->email}}</flux:text>
</div>
</div>
<form x-show="edit" wire:submit="updateProfileInformation" x-on:submit="edit = false"
class="my-6 w-full space-y-6">
<div class="flex items-center gap-8">
<div class="relative">
<label for="avatar-upload"
class="flex items-center justify-center absolute bg-black opacity-0 cursor-pointer hover:opacity-80 top-0 bottom-0 left-0 right-0">
<flux:icon.pencil class="size-12" />
</label>
@if (!empty($avatarUpload))
<img src="{{$avatarUpload->temporaryUrl()}}" alt="" class="size-32">
@elseif(!empty($avatar))
<img src="{{auth()->user()->avatarUrl()}}" alt="" class="size-32">
@else
<div
class="size-32 flex items-center justify-center mx-auto rounded-sm dark:bg-zinc-600 bg-stone-300 dark:text-white text-black">
<div class="text-4xl font-bold">{{auth()->user()->initials()}}</div>
</div>
@endif
<flux:input type="file" id="avatar-upload" wire:model="avatarUpload" class="hidden" />
</div>
<div class="flex-1 flex flex-col gap-4">
<flux:input wire:model="name" :label="__('Name')" type="text" required autofocus
autocomplete="name" />
<flux:input wire:model="preferred_username" :label="__('Preferred Username')" type="text" required
autofocus autocomplete="username" />
<flux:input wire:model="email" :label="__('Email')" type="email" required autocomplete="email" />
</div>
</div>
<div class="flex items-center gap-4">
<div class="flex items-center justify-end">
<flux:button variant="primary" type="submit" class="w-full">{{ __('Save') }}</flux:button>
<flux:button variant="subtle" x-on:click="edit = false">Cancel</flux:button>
</div>
<x-action-message class="me-3" on="profile-updated">
{{ __('Saved.') }}
</x-action-message>
</div>
</form>
<div class="mt-4">
<flux:button variant="subtle" icon="pencil" x-on:click="edit = true" inset>Edit Profile information
</flux:button>
</div>
</div>
<div x-data="{edit: false}" class="mt-4">
<flux:button variant="primary" x-show="!edit" x-on:click="edit = true">Change Password</flux:button>
<form wire:submit="updatePassword" class="mt-6 space-y-6" x-show="edit">
<flux:input wire:model="current_password" :label="__('Current password')" type="password" required
autocomplete="current-password" />
<flux:input wire:model="password" :label="__('New password')" type="password" required
autocomplete="new-password" />
<flux:input wire:model="password_confirmation" :label="__('Confirm Password')" type="password" required
autocomplete="new-password" />
<div class="flex items-center gap-4">
<div class="flex items-center justify-end">
<flux:button variant="primary" type="submit" class="w-full">{{ __('Save') }}</flux:button>
<flux:button variant="subtle" x-on:click="edit = false">Cancel</flux:button>
</div>
<x-action-message class="me-3" on="password-updated">
{{ __('Saved.') }}
</x-action-message>
</div>
</form>
</div>
</div>

View File

@ -5,8 +5,10 @@ use App\Livewire\ConsentScreen;
use App\Livewire\Settings\Appearance;
use App\Livewire\Settings\Password;
use App\Livewire\Settings\Profile;
use App\Models\User;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Storage;
Route::get('/', function () {
return view('welcome');
@ -24,6 +26,12 @@ Route::middleware(['auth'])->group(function () {
Route::get('settings/appearance', Appearance::class)->name('settings.appearance');
});
Route::get('avatars/{path}', function (string $path) {
$path = Storage::disk('avatars')->path($path);
return response()->file($path);
})->name('user.avatar');
// OIDC Endpoints
Route::prefix('application/o')->group(function () {
Route::get('authorize', [OIDCController::class, 'authorize'])->middleware('auth')->name('auth.authorize');

View File

@ -3,6 +3,8 @@ import {
} from 'vite';
import laravel from 'laravel-vite-plugin';
import tailwindcss from "@tailwindcss/vite";
import fs from 'fs'
import path from 'path'
export default defineConfig({
plugins: [
@ -14,6 +16,14 @@ export default defineConfig({
],
server: {
cors: true,
host: "homelab-sso.test"
host: "homelab-sso.test",
// https: {
// key: fs.readFileSync(path.resolve(
// process.env.HOME, '.valet/Certificates/homelab-sso.test.key'
// )),
// cert: fs.readFileSync(path.resolve(
// process.env.HOME, '.valet/Certificates/homelab-sso.test.crt'
// )),
// },
},
});