Adds account settings tests and feature test fixes; purges old Breeze profile remnants in favor of account; slight tweaks to event display in time views

This commit is contained in:
Andrew Gioia 2026-02-04 15:56:28 -05:00
parent 9957e8d8a1
commit 7ba5041ba6
Signed by: andrew
GPG Key ID: FC09694A000800C8
12 changed files with 175 additions and 91 deletions

View File

@ -20,9 +20,9 @@ class PasswordController extends Controller
'password' => ['required', Password::defaults(), 'confirmed'], 'password' => ['required', Password::defaults(), 'confirmed'],
]); ]);
$request->user()->update([ $request->user()->forceFill([
'password' => Hash::make($validated['password']), 'password' => Hash::make($validated['password']),
]); ])->save();
return back()->with('status', 'password-updated'); return back()->with('status', 'password-updated');
} }

View File

@ -35,11 +35,11 @@ class RegisteredUserController extends Controller
'password' => ['required', 'confirmed', Rules\Password::defaults()], 'password' => ['required', 'confirmed', Rules\Password::defaults()],
]); ]);
$user = User::create([ $user = new User();
'name' => $request->name, $user->name = $request->name;
'email' => $request->email, $user->email = $request->email;
'password' => Hash::make($request->password), $user->password = $request->password;
]); $user->save();
event(new Registered($user)); event(new Registered($user));

View File

@ -30,6 +30,7 @@ class User extends Authenticatable
'firstname', 'firstname',
'lastname', 'lastname',
'displayname', 'displayname',
'name',
'email', 'email',
'timezone', 'timezone',
'phone', 'phone',
@ -59,6 +60,62 @@ class User extends Authenticatable
]; ];
} }
/**
* Expose a Breeze-compatible "name" attribute without a physical column.
* Preference: displayname (explicit override), then first + last, then email.
*/
public function getNameAttribute(): string
{
$displayname = is_string($this->displayname) ? trim($this->displayname) : '';
if ($displayname !== '') {
return $displayname;
}
$first = is_string($this->firstname) ? trim($this->firstname) : '';
$last = is_string($this->lastname) ? trim($this->lastname) : '';
$full = trim($first . ' ' . $last);
if ($full !== '') {
return $full;
}
return (string) ($this->email ?? '');
}
/**
* Map "name" writes to first/last names, keeping displayname optional.
*/
public function setNameAttribute(?string $value): void
{
$incoming = trim((string) $value);
$currentFirst = is_string($this->attributes['firstname'] ?? null)
? trim((string) $this->attributes['firstname'])
: '';
$currentLast = is_string($this->attributes['lastname'] ?? null)
? trim((string) $this->attributes['lastname'])
: '';
$currentGenerated = trim($currentFirst . ' ' . $currentLast);
if ($incoming === '') {
$this->attributes['firstname'] = null;
$this->attributes['lastname'] = null;
return;
}
$parts = preg_split('/\s+/', $incoming, 2);
$this->attributes['firstname'] = $parts[0] ?? null;
$this->attributes['lastname'] = $parts[1] ?? null;
$displayname = is_string($this->attributes['displayname'] ?? null)
? trim((string) $this->attributes['displayname'])
: '';
if ($displayname !== '' && $displayname === $currentGenerated) {
$this->attributes['displayname'] = $incoming;
}
}
/** /**
* user can own many calendars * user can own many calendars
*/ */

View File

@ -3,6 +3,7 @@
use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Schema;
return new class extends Migration return new class extends Migration
{ {
@ -12,26 +13,38 @@ return new class extends Migration
return base_path("vendor/sabre/dav/examples/sql/{$file}"); return base_path("vendor/sabre/dav/examples/sql/{$file}");
} }
private function prefix(): string
{
$driver = DB::connection()->getDriverName();
return match ($driver) {
'sqlite' => 'sqlite',
'pgsql' => 'pgsql',
default => 'mysql',
};
}
public function up(): void public function up(): void
{ {
// Disable FK checks for smooth batch execution $prefix = $this->prefix();
DB::statement('SET FOREIGN_KEY_CHECKS = 0'); Schema::disableForeignKeyConstraints();
// Principals (users & groups) // Principals (users & groups)
DB::unprepared(File::get($this->sql('mysql.principals.sql'))); DB::unprepared(File::get($this->sql("{$prefix}.principals.sql")));
// CalDAV calendars + objects // CalDAV calendars + objects
DB::unprepared(File::get($this->sql('mysql.calendars.sql'))); DB::unprepared(File::get($this->sql("{$prefix}.calendars.sql")));
// CardDAV address books + cards // CardDAV address books + cards
DB::unprepared(File::get($this->sql('mysql.addressbooks.sql'))); DB::unprepared(File::get($this->sql("{$prefix}.addressbooks.sql")));
DB::statement('SET FOREIGN_KEY_CHECKS = 1'); Schema::enableForeignKeyConstraints();
} }
public function down(): void public function down(): void
{ {
DB::statement('SET FOREIGN_KEY_CHECKS = 0'); $this->prefix();
Schema::disableForeignKeyConstraints();
// Drop in reverse dependency order // Drop in reverse dependency order
DB::statement('DROP TABLE IF EXISTS DB::statement('DROP TABLE IF EXISTS
@ -47,6 +60,6 @@ return new class extends Migration
groupmembers groupmembers
'); ');
DB::statement('SET FOREIGN_KEY_CHECKS = 1'); Schema::enableForeignKeyConstraints();
} }
}; };

View File

@ -9,7 +9,9 @@ return new class extends Migration
// add composite + geo + optional fulltext indexes to locations // add composite + geo + optional fulltext indexes to locations
public function up(): void public function up(): void
{ {
Schema::table('locations', function (Blueprint $table) { $driver = Schema::getConnection()->getDriverName();
Schema::table('locations', function (Blueprint $table) use ($driver) {
// composite btree index for common lookups // composite btree index for common lookups
$table->index( $table->index(
['display_name', 'city', 'state', 'postal', 'country'], ['display_name', 'city', 'state', 'postal', 'country'],
@ -21,17 +23,23 @@ return new class extends Migration
// optional: fulltext index for free-form text searching // optional: fulltext index for free-form text searching
// note: requires mysql/mariadb version with innodb fulltext support // note: requires mysql/mariadb version with innodb fulltext support
$table->fullText('raw_address', 'locations_raw_address_fulltext'); if (in_array($driver, ['mysql', 'pgsql'], true)) {
$table->fullText('raw_address', 'locations_raw_address_fulltext');
}
}); });
} }
// drop the indexes added in up() // drop the indexes added in up()
public function down(): void public function down(): void
{ {
Schema::table('locations', function (Blueprint $table) { $driver = Schema::getConnection()->getDriverName();
Schema::table('locations', function (Blueprint $table) use ($driver) {
$table->dropIndex('locations_name_city_idx'); $table->dropIndex('locations_name_city_idx');
$table->dropIndex('locations_lat_lon_idx'); $table->dropIndex('locations_lat_lon_idx');
$table->dropFullText('locations_raw_address_fulltext'); if (in_array($driver, ['mysql', 'pgsql'], true)) {
$table->dropFullText('locations_raw_address_fulltext');
}
}); });
} }
}; };

View File

@ -188,9 +188,9 @@ main {
container: content / inline-size; container: content / inline-size;
/* main content title and actions */ /* main content title and actions */
> header { header {
@apply flex flex-row items-center justify-between w-full; @apply flex flex-row items-center justify-between w-full;
@apply bg-white sticky top-0 z-10; @apply bg-white sticky top-0 z-20;
/* app hedar; if h1 exists it means there's no aside, so force the width from that */ /* app hedar; if h1 exists it means there's no aside, so force the width from that */
h1 { h1 {
@ -210,7 +210,9 @@ main {
/* header menu */ /* header menu */
menu { menu {
@apply flex flex-row items-center justify-end gap-4; @apply fixed right-0 top-2 flex flex-col bg-gray-100 gap-6 p-6 rounded-l-xl;
height: calc(100dvh - 0.5rem);
width: 33dvw;
} }
} }
@ -250,6 +252,21 @@ main {
} }
} }
/* container sizing */
@container content (width >= 64rem)
{
main {
article {
header {
menu {
@apply relative top-auto right-auto h-auto w-auto rounded-none bg-transparent;
@apply flex flex-row items-center justify-end gap-4;
}
}
}
}
}
/* app logo */ /* app logo */
.logo { .logo {
@apply w-10 h-10 flex; @apply w-10 h-10 flex;

View File

@ -158,7 +158,7 @@
--event-fg: var(--color-primary); --event-fg: var(--color-primary);
li.event { li.event {
@apply flex rounded-md relative; @apply flex rounded-md relative border border-white;
background-color: var(--event-bg); background-color: var(--event-bg);
color: var(--event-fg); color: var(--event-fg);
grid-row-start: var(--event-row); grid-row-start: var(--event-row);
@ -166,22 +166,21 @@
grid-column-start: var(--event-col); grid-column-start: var(--event-col);
grid-column-end: calc(var(--event-col) + 1); grid-column-end: calc(var(--event-col) + 1);
top: 0.6rem; top: 0.6rem;
transition: translate 100ms ease-in;
> a { a.event {
@apply flex flex-col grow px-3 py-2 gap-2px; @apply flex flex-col grow px-3 py-2 gap-2px text-sm;
> span { > span {
@apply font-semibold leading-none break-all; @apply font-semibold leading-none break-all;
} }
> time { > time {
@apply text-sm; @apply text-xs;
} }
} }
&:hover { &:hover {
@apply -translate-y-2px; animation: event-hover 125ms ease forwards;
} }
} }
} }
@ -216,19 +215,36 @@
} }
/* step handling */ /* step handling */
.calendar.time[data-density="30"] { .calendar.time[data-density="30"] { /* half-hourly */
--row-height: 2rem; --row-height: 2rem;
ol.time li:nth-child(2n) { ol.time li:nth-child(2n) {
visibility: hidden; /* preserves space + row alignment */ visibility: hidden; /* preserves space + row alignment */
} }
} }
.calendar.time[data-density="60"] { .calendar.time[data-density="60"] { /* hourly */
--row-height: 1.25rem; --row-height: 1.25rem;
ol.time li:not(:nth-child(4n + 1)) { ol.time li:not(:nth-child(4n + 1)) {
visibility: hidden; /* preserves space + row alignment */ visibility: hidden; /* preserves space + row alignment */
} }
&.week {
ol.events {
li.event[data-span="1"] {
a.event > span,
a.event > time {
@apply text-xs;
}
}
li.event[data-span="1"],
li.event[data-span="2"] {
> a.event {
@apply flex-row items-center gap-3;
}
}
}
}
} }
/** /**
@ -378,7 +394,16 @@
transform: translateX(0); transform: translateX(0);
} }
} }
@keyframes event-hover {
from {
transform: translateY(0);
z-index: 1;
}
to {
transform: translateY(-2px);
z-index: 2;
}
}
@keyframes header-slide { @keyframes header-slide {
from { from {
opacity: 0; opacity: 0;

View File

@ -1,38 +0,0 @@
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
{{ __('Profile') }}
</h2>
</x-slot>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
<div class="p-4 sm:p-8 bg-white shadow-sm sm:rounded-lg">
<div class="max-w-xl">
@include('account.partials.update-profile-information-form')
</div>
</div>
<div class="p-4 sm:p-8 bg-white shadow-sm sm:rounded-lg">
<div class="max-w-xl">
@include('account.partials.addresses-form', [
'home' => $home ?? null,
'billing' => $billing ?? null,
])
</div>
</div>
<div class="p-4 sm:p-8 bg-white shadow-sm sm:rounded-lg">
<div class="max-w-xl">
@include('account.partials.update-password-form')
</div>
</div>
<div class="p-4 sm:p-8 bg-white shadow-sm sm:rounded-lg">
<div class="max-w-xl">
@include('account.partials.delete-user-form')
</div>
</div>
</div>
</div>
</x-app-layout>

View File

@ -7,6 +7,7 @@
data-calendar-id="{{ $event['calendar_slug'] }}" data-calendar-id="{{ $event['calendar_slug'] }}"
data-start="{{ $event['start_ui'] }}" data-start="{{ $event['start_ui'] }}"
data-duration="{{ $event['duration'] }}" data-duration="{{ $event['duration'] }}"
data-span="{{ $event['row_span'] }}"
style=" style="
--event-row: {{ $event['start_row'] }}; --event-row: {{ $event['start_row'] }};
--event-end: {{ $event['end_row'] }}; --event-end: {{ $event['end_row'] }};

View File

@ -28,9 +28,6 @@
<!-- bottom --> <!-- bottom -->
<section class="bottom"> <section class="bottom">
<x-button.icon type="anchor" :href="route('settings')">
<x-icon-settings class="w-7 h-7" />
</x-button.icon>
<x-button.icon type="anchor" :href="route('account.index')"> <x-button.icon type="anchor" :href="route('account.index')">
<x-icon-user-circle class="w-7 h-7" /> <x-icon-user-circle class="w-7 h-7" />
</x-button.icon> </x-button.icon>

View File

@ -2,32 +2,35 @@
use App\Models\User; use App\Models\User;
test('profile page is displayed', function () { test('account info page is displayed', function () {
$user = User::factory()->create(); $user = User::factory()->create();
$response = $this $response = $this
->actingAs($user) ->actingAs($user)
->get('/profile'); ->get('/account/info');
$response->assertOk(); $response->assertOk();
}); });
test('profile information can be updated', function () { test('account information can be updated', function () {
$user = User::factory()->create(); $user = User::factory()->create();
$response = $this $response = $this
->actingAs($user) ->actingAs($user)
->patch('/profile', [ ->patch('/account/info', [
'name' => 'Test User', 'firstname' => 'Test',
'lastname' => 'User',
'email' => 'test@example.com', 'email' => 'test@example.com',
]); ]);
$response $response
->assertSessionHasNoErrors() ->assertSessionHasNoErrors()
->assertRedirect('/profile'); ->assertRedirect('/account/info');
$user->refresh(); $user->refresh();
$this->assertSame('Test', $user->firstname);
$this->assertSame('User', $user->lastname);
$this->assertSame('Test User', $user->name); $this->assertSame('Test User', $user->name);
$this->assertSame('test@example.com', $user->email); $this->assertSame('test@example.com', $user->email);
$this->assertNull($user->email_verified_at); $this->assertNull($user->email_verified_at);
@ -38,14 +41,15 @@ test('email verification status is unchanged when the email address is unchanged
$response = $this $response = $this
->actingAs($user) ->actingAs($user)
->patch('/profile', [ ->patch('/account/info', [
'name' => 'Test User', 'firstname' => 'Test',
'lastname' => 'User',
'email' => $user->email, 'email' => $user->email,
]); ]);
$response $response
->assertSessionHasNoErrors() ->assertSessionHasNoErrors()
->assertRedirect('/profile'); ->assertRedirect('/account/info');
$this->assertNotNull($user->refresh()->email_verified_at); $this->assertNotNull($user->refresh()->email_verified_at);
}); });
@ -55,13 +59,13 @@ test('user can delete their account', function () {
$response = $this $response = $this
->actingAs($user) ->actingAs($user)
->delete('/profile', [ ->delete('/account', [
'password' => 'password', 'password' => 'password',
]); ]);
$response $response
->assertSessionHasNoErrors() ->assertSessionHasNoErrors()
->assertRedirect('/'); ->assertRedirect('/dashboard');
$this->assertGuest(); $this->assertGuest();
$this->assertNull($user->fresh()); $this->assertNull($user->fresh());
@ -72,14 +76,14 @@ test('correct password must be provided to delete account', function () {
$response = $this $response = $this
->actingAs($user) ->actingAs($user)
->from('/profile') ->from('/account/delete/confirm')
->delete('/profile', [ ->delete('/account', [
'password' => 'wrong-password', 'password' => 'wrong-password',
]); ]);
$response $response
->assertSessionHasErrorsIn('userDeletion', 'password') ->assertSessionHasErrorsIn('userDeletion', 'password')
->assertRedirect('/profile'); ->assertRedirect('/account/delete/confirm');
$this->assertNotNull($user->fresh()); $this->assertNotNull($user->fresh());
}); });

View File

@ -8,7 +8,7 @@ test('password can be updated', function () {
$response = $this $response = $this
->actingAs($user) ->actingAs($user)
->from('/profile') ->from('/account/password')
->put('/password', [ ->put('/password', [
'current_password' => 'password', 'current_password' => 'password',
'password' => 'new-password', 'password' => 'new-password',
@ -17,7 +17,7 @@ test('password can be updated', function () {
$response $response
->assertSessionHasNoErrors() ->assertSessionHasNoErrors()
->assertRedirect('/profile'); ->assertRedirect('/account/password');
$this->assertTrue(Hash::check('new-password', $user->refresh()->password)); $this->assertTrue(Hash::check('new-password', $user->refresh()->password));
}); });
@ -27,7 +27,7 @@ test('correct password must be provided to update password', function () {
$response = $this $response = $this
->actingAs($user) ->actingAs($user)
->from('/profile') ->from('/account/password')
->put('/password', [ ->put('/password', [
'current_password' => 'wrong-password', 'current_password' => 'wrong-password',
'password' => 'new-password', 'password' => 'new-password',
@ -36,5 +36,5 @@ test('correct password must be provided to update password', function () {
$response $response
->assertSessionHasErrorsIn('updatePassword', 'current_password') ->assertSessionHasErrorsIn('updatePassword', 'current_password')
->assertRedirect('/profile'); ->assertRedirect('/account/password');
}); });