fix(user): persist detected timezones (#296)
## Summary - store browser-detected IANA timezones on users during registration and share the value to the frontend - silently backfill missing timezones for authenticated users without overwriting existing saved values - add focused feature coverage for registration, shared props, and the timezone backfill endpoint ## Testing - php artisan test --compact tests/Feature/Auth/RegistrationTest.php tests/Feature/InertiaSharedDataTest.php tests/Feature/Settings/TimezoneTest.php - npm test -- resources/js/utils/currency.test.ts
This commit is contained in:
parent
473ac03088
commit
fde5405777
|
|
@ -28,6 +28,7 @@ class CreateNewUser implements CreatesNewUsers
|
|||
Rule::unique(User::class),
|
||||
],
|
||||
'password' => $this->passwordRules(),
|
||||
'timezone' => ['nullable', 'string', 'timezone'],
|
||||
])->validate();
|
||||
|
||||
$user = User::create([
|
||||
|
|
@ -35,6 +36,7 @@ class CreateNewUser implements CreatesNewUsers
|
|||
'email' => $input['email'],
|
||||
'password' => $input['password'],
|
||||
'locale' => $this->detectLocaleFromRequest(),
|
||||
'timezone' => $input['timezone'] ?? null,
|
||||
]);
|
||||
|
||||
if (! config('mail.email_verification_enabled')) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Settings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Settings\UpdateTimezoneRequest;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class TimezoneController extends Controller
|
||||
{
|
||||
public function update(UpdateTimezoneRequest $request): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if ($user->timezone === null) {
|
||||
$user->update(['timezone' => $request->validated('timezone')]);
|
||||
}
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\Settings;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateTimezoneRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'timezone' => ['required', 'string', 'timezone'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -37,6 +37,7 @@ class User extends Authenticatable implements HasLocalePreference, MustVerifyEma
|
|||
'paywall_seen_at',
|
||||
'currency_code',
|
||||
'locale',
|
||||
'timezone',
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
<?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('timezone')->nullable()->after('locale');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn('timezone');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -2,6 +2,7 @@ import '../css/app.css';
|
|||
|
||||
import { createInertiaApp, router } from '@inertiajs/react';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import axios from 'axios';
|
||||
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
|
||||
import {
|
||||
CircleCheckIcon,
|
||||
|
|
@ -37,6 +38,7 @@ initializeTheme();
|
|||
initializeChartColorScheme();
|
||||
|
||||
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
||||
let hasAttemptedTimezoneBackfill = false;
|
||||
|
||||
// Determine progress bar color based on current theme
|
||||
const getProgressBarColor = () => {
|
||||
|
|
@ -65,6 +67,31 @@ createInertiaApp({
|
|||
const hasEncryptedTransactions =
|
||||
(initialPageProps?.hasEncryptedTransactions as boolean) ?? false;
|
||||
|
||||
const syncUserTimezone = async (pageProps?: Partial<SharedData>) => {
|
||||
const user = pageProps?.auth?.user ?? null;
|
||||
const detectedTimezone =
|
||||
Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
if (
|
||||
hasAttemptedTimezoneBackfill ||
|
||||
!user ||
|
||||
user.timezone ||
|
||||
!detectedTimezone
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasAttemptedTimezoneBackfill = true;
|
||||
|
||||
try {
|
||||
await axios.patch('/settings/timezone', {
|
||||
timezone: detectedTimezone,
|
||||
});
|
||||
} catch {
|
||||
hasAttemptedTimezoneBackfill = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize translations from server-rendered page data
|
||||
setTranslations(
|
||||
(initialPageProps?.translations as Record<string, string>) ?? {},
|
||||
|
|
@ -72,12 +99,16 @@ createInertiaApp({
|
|||
|
||||
// Keep translations in sync on every Inertia navigation
|
||||
router.on('navigate', (event) => {
|
||||
const pageProps = event.detail.page.props as SharedData;
|
||||
const pageProps = event.detail.page.props as unknown as SharedData;
|
||||
setTranslations(
|
||||
(pageProps?.translations as Record<string, string>) ?? {},
|
||||
);
|
||||
|
||||
void syncUserTimezone(pageProps);
|
||||
});
|
||||
|
||||
void syncUserTimezone(initialPageProps);
|
||||
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<EncryptionKeyProvider
|
||||
|
|
|
|||
|
|
@ -18,6 +18,11 @@ interface RegisterProps {
|
|||
}
|
||||
|
||||
export default function Register({ hideAuthButtons = false }: RegisterProps) {
|
||||
const detectedTimezone =
|
||||
typeof window !== 'undefined'
|
||||
? Intl.DateTimeFormat().resolvedOptions().timeZone || ''
|
||||
: '';
|
||||
|
||||
useEffect(() => {
|
||||
if (hideAuthButtons) {
|
||||
router.visit(login());
|
||||
|
|
@ -48,6 +53,12 @@ export default function Register({ hideAuthButtons = false }: RegisterProps) {
|
|||
>
|
||||
{({ processing, errors }) => (
|
||||
<>
|
||||
<input
|
||||
type="hidden"
|
||||
name="timezone"
|
||||
value={detectedTimezone}
|
||||
/>
|
||||
|
||||
<div className="grid gap-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">{__('Name')}</Label>
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ export interface User {
|
|||
email: string;
|
||||
currency_code: CurrencyCode;
|
||||
locale: string | null;
|
||||
timezone: string | null;
|
||||
avatar?: string;
|
||||
email_verified_at: string | null;
|
||||
two_factor_enabled?: boolean;
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ use App\Http\Controllers\Settings\NetWorthChartLoanPreferenceController;
|
|||
use App\Http\Controllers\Settings\NetWorthChartRealEstatePreferenceController;
|
||||
use App\Http\Controllers\Settings\PasswordController;
|
||||
use App\Http\Controllers\Settings\ProfileController;
|
||||
use App\Http\Controllers\Settings\TimezoneController;
|
||||
use App\Http\Controllers\Settings\TwoFactorAuthenticationController;
|
||||
use App\Http\Controllers\SubscriptionController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
|
@ -21,6 +22,7 @@ Route::middleware('auth')->group(function () {
|
|||
|
||||
Route::get('settings/account', [ProfileController::class, 'account'])->name('account.edit');
|
||||
Route::patch('settings/profile', [ProfileController::class, 'update'])->name('profile.update');
|
||||
Route::patch('settings/timezone', [TimezoneController::class, 'update'])->name('timezone.update');
|
||||
Route::delete('settings/profile', [ProfileController::class, 'destroy'])
|
||||
->middleware('block-demo')
|
||||
->name('profile.destroy');
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use Illuminate\Support\Facades\Notification;
|
|||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
test('registration screen can be rendered', function () {
|
||||
$response = $this->get(route('register'));
|
||||
$response = $this->withoutVite()->get(route('register'));
|
||||
|
||||
$response->assertSuccessful();
|
||||
});
|
||||
|
|
@ -25,6 +25,37 @@ test('new users can register', function () {
|
|||
$response->assertRedirect(route('onboarding', absolute: false));
|
||||
});
|
||||
|
||||
test('new users store their detected timezone on registration', function () {
|
||||
Queue::fake();
|
||||
|
||||
$this->post(route('register.store'), [
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
'password' => 'password',
|
||||
'password_confirmation' => 'password',
|
||||
'timezone' => 'America/New_York',
|
||||
]);
|
||||
|
||||
$user = User::where('email', 'test@example.com')->first();
|
||||
|
||||
expect($user->timezone)->toBe('America/New_York');
|
||||
});
|
||||
|
||||
test('new users can register without a timezone', function () {
|
||||
Queue::fake();
|
||||
|
||||
$this->post(route('register.store'), [
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
'password' => 'password',
|
||||
'password_confirmation' => 'password',
|
||||
]);
|
||||
|
||||
$user = User::where('email', 'test@example.com')->first();
|
||||
|
||||
expect($user->timezone)->toBeNull();
|
||||
});
|
||||
|
||||
test('new users receive a verification email on registration', function () {
|
||||
Notification::fake();
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use Inertia\Testing\AssertableInertia as Assert;
|
|||
use function Pest\Laravel\actingAs;
|
||||
|
||||
test('guests receive null auth user in shared props', function () {
|
||||
$response = $this->get(route('home'));
|
||||
$response = $this->withoutVite()->get(route('home'));
|
||||
|
||||
$response->assertInertia(fn (Assert $page) => $page
|
||||
->where('auth.user', null)
|
||||
|
|
@ -14,18 +14,19 @@ test('guests receive null auth user in shared props', function () {
|
|||
});
|
||||
|
||||
test('authenticated users receive auth user in shared props', function () {
|
||||
$user = User::factory()->create();
|
||||
$user = User::factory()->create(['timezone' => 'Europe/Madrid']);
|
||||
|
||||
$response = actingAs($user)->get(route('home'));
|
||||
$response = actingAs($user)->withoutVite()->get(route('home'));
|
||||
|
||||
$response->assertInertia(fn (Assert $page) => $page
|
||||
->where('auth.user.id', $user->id)
|
||||
->where('auth.user.email', $user->email)
|
||||
->where('auth.user.timezone', 'Europe/Madrid')
|
||||
);
|
||||
});
|
||||
|
||||
test('all pages receive app url in shared props', function () {
|
||||
$response = $this->get(route('home'));
|
||||
$response = $this->withoutVite()->get(route('home'));
|
||||
|
||||
$response->assertInertia(fn (Assert $page) => $page
|
||||
->where('appUrl', config('app.url'))
|
||||
|
|
@ -33,7 +34,7 @@ test('all pages receive app url in shared props', function () {
|
|||
});
|
||||
|
||||
test('shared currency options split profile and account currencies', function () {
|
||||
$response = $this->get(route('home'));
|
||||
$response = $this->withoutVite()->get(route('home'));
|
||||
|
||||
$response->assertInertia(fn (Assert $page) => $page
|
||||
->where('currencies.profile.0.code', 'USD')
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
test('timezone can be backfilled for authenticated users without one', function () {
|
||||
$user = User::factory()->create(['timezone' => null]);
|
||||
|
||||
$response = $this->actingAs($user)->patchJson(route('timezone.update'), [
|
||||
'timezone' => 'Europe/Madrid',
|
||||
]);
|
||||
|
||||
$response->assertNoContent();
|
||||
|
||||
expect($user->refresh()->timezone)->toBe('Europe/Madrid');
|
||||
});
|
||||
|
||||
test('timezone backfill does not overwrite an existing timezone', function () {
|
||||
$user = User::factory()->create(['timezone' => 'America/New_York']);
|
||||
|
||||
$response = $this->actingAs($user)->patchJson(route('timezone.update'), [
|
||||
'timezone' => 'Europe/Madrid',
|
||||
]);
|
||||
|
||||
$response->assertNoContent();
|
||||
|
||||
expect($user->refresh()->timezone)->toBe('America/New_York');
|
||||
});
|
||||
|
||||
test('timezone backfill rejects invalid timezones', function () {
|
||||
$user = User::factory()->create(['timezone' => null]);
|
||||
|
||||
$response = $this->actingAs($user)->patchJson(route('timezone.update'), [
|
||||
'timezone' => 'Mars/Olympus_Mons',
|
||||
]);
|
||||
|
||||
$response->assertJsonValidationErrors(['timezone']);
|
||||
expect($user->refresh()->timezone)->toBeNull();
|
||||
});
|
||||
|
||||
test('timezone backfill requires authentication', function () {
|
||||
$response = $this->patch(route('timezone.update'), [
|
||||
'timezone' => 'Europe/Madrid',
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('login'));
|
||||
});
|
||||
Loading…
Reference in New Issue