Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions lang/en/messages.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -234,6 +234,8 @@
'selections_item_unselected' => ':title is not selected. Click to select.',
'selections_limit_reached' => 'Selection limit reached. Cannot select :title',
'selections_select_all' => ':selected of :total items selected. Check to select all items.',
'session_expiry_dismissed_banner' => 'Your session is about to expire. Click here to extend it and stay signed in.',
'session_expiry_dismissed_login_banner' => 'Your session has expired. Click here to log back in.',
'session_expiry_enter_password' => 'Enter your password to continue.',
'session_expiry_enter_two_factor_code' => 'Enter your authenticator code to continue.',
'session_expiry_enter_two_factor_recovery_code' => 'Enter a recovery code to continue.',
Expand Down
91 changes: 86 additions & 5 deletions resources/js/components/SessionExpiry.vue
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,33 @@
<template>
<div class="session-expiry">
<Modal
v-if="isWarning && !isShowingLogin && !isShowingTwoFactorChallenge"
:open="isWarning && !isShowingLogin && !isShowingTwoFactorChallenge"
v-if="isShowingWarningModal"
:open="isShowingWarningModal"
:title="__('Your Session is Expiring')"
class="max-w-[500px]!"
:dismissible="false"
>
<ui-description v-text="warningText" />
<Button @click="extend" variant="primary" icon="rewind" :text="__('Extend Session')" class="w-full" />

<template #footer>
<div class="flex items-center justify-end space-x-3 pt-3 pb-1">
<Button @click="dismissWarning" variant="ghost" :text="__('Cancel')" />
<Button @click="extend" variant="primary" icon="rewind" :text="__('Extend Session')" />
</div>
</template>
</Modal>

<Modal :title="__('Resume Your Session')" :open="isShowingLogin" height="auto" class="max-w-[500px]!" :dismissable="false">
<button
v-if="banner"
type="button"
@click="banner.resume"
class="fixed top-0 inset-x-0 z-(--z-index-portal) flex items-center justify-center gap-2 bg-red-600 px-4 py-2 text-sm font-medium text-white shadow-md hover:bg-red-700"
>
<ui-icon name="alert-alarm-bell" class="size-4" />
<span v-text="banner.text" />
</button>

<Modal :title="__('Resume Your Session')" :open="isShowingLoginModal" height="auto" class="max-w-[500px]!" :dismissible="false">
<div v-if="isUsingOauth" class="space-y-3">
<ui-description v-text="__('messages.session_expiry_new_window')" />
<ui-button variant="primary" class="w-full" :href="oauthProvider.loginUrl" target="_blank" :text="__('Log in with :provider', { provider: oauthProvider.label })" />
Expand All@@ -34,9 +50,15 @@
</div>
</ui-field>
</div>

<template #footer>
<div class="flex items-center justify-end pt-3 pb-1">
<Button @click="dismissLogin" variant="ghost" :text="__('Cancel')" />
</div>
</template>
</Modal>

<Modal :title="__('Resume Your Session')" :open="isShowingTwoFactorChallenge" height="auto" class="max-w-[500px]!" :dismissable="false">
<Modal :title="__('Resume Your Session')" :open="isShowingTwoFactorChallenge" height="auto" class="max-w-[500px]!" :dismissible="false">
<div>
<div v-if="twoFactorMode === 'code'" class="space-y-3">
<ui-description v-text="__('messages.session_expiry_enter_two_factor_code')" />
Expand DownExpand Up@@ -138,6 +160,8 @@ export default {
pinging: false,
lastCount: new Date(),
isPageHidden: false,
dismissedWarning: false,
dismissedLogin: false,
};
},

Expand All@@ -146,6 +170,34 @@ export default {
return this.count <= this.warnAt;
},

isShowingWarningModal() {
return this.isWarning && !this.isShowingLogin && !this.isShowingTwoFactorChallenge && !this.dismissedWarning;
},

isShowingLoginModal() {
return this.isShowingLogin && !this.dismissedLogin;
},

// A single banner is shown whenever one of the modals has been explicitly
// dismissed. Clicking it resumes the flow by reopening that modal.
banner() {
if (this.isShowingLogin && this.dismissedLogin) {
return {
text: __('messages.session_expiry_dismissed_login_banner'),
resume: this.resumeLogin,
};
}

if (this.isWarning && !this.isShowingLogin && !this.isShowingTwoFactorChallenge && this.dismissedWarning) {
return {
text: __('messages.session_expiry_dismissed_banner'),
resume: this.resumeWarning,
};
}

return null;
},

warningText() {
return this.remaining === 0
? __('messages.session_expiry_logged_out_for_inactivity')
Expand DownExpand Up@@ -187,6 +239,19 @@ export default {

isShowingLogin(showing, wasShowing) {
if (showing && !wasShowing) this.updateCsrfToken();

// Whenever we stop needing to log back in - whether they did so through the
// reopened modal, or the session was extended elsewhere before they got
// around to it - reset the dismissed state so that a subsequent expiry
// shows the modal normally rather than staying stuck on the banner.
if (!showing) this.dismissedLogin = false;
},

// When we leave the warning period (e.g. the session was extended in another
// tab, or a fresh countdown began), reset the dismissed state so the modal
// will show normally the next time the warning period is entered.
isWarning(isWarning) {
if (!isWarning) this.dismissedWarning = false;
},
},

Expand DownExpand Up@@ -296,6 +361,22 @@ export default {
});
},

dismissWarning() {
this.dismissedWarning = true;
},

resumeWarning() {
this.dismissedWarning = false;
},

dismissLogin() {
this.dismissedLogin = true;
},

resumeLogin() {
this.dismissedLogin = false;
},

loginComplete() {
this.$toast.success(__('Logged in'));
this.restartCountdown();
Expand Down
7 changes: 6 additions & 1 deletion src/Http/Middleware/CP/StartSession.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,10 +6,15 @@

class StartSession extends Middleware
{
protected $routesExcludedFromExtendingSession = [
'statamic.cp.session.timeout',
'statamic.cp.token',
];

protected function saveSession($request)
{
if (
$request->route()->named('statamic.cp.session.timeout')
$request->route()->named($this->routesExcludedFromExtendingSession)
&& $request->session()->has('last_activity')
) {
return;
Expand Down
58 changes: 58 additions & 0 deletions tests/Http/Middleware/StartSessionTest.php
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
<?php

namespace Tests\Http\Middleware;

use PHPUnit\Framework\Attributes\Test;
use Statamic\Facades\User;
use Tests\PreventSavingStacheItemsToDisk;
use Tests\TestCase;

class StartSessionTest extends TestCase
{
use PreventSavingStacheItemsToDisk;

#[Test]
public function hitting_the_session_timeout_route_does_not_extend_the_session()
{
$this->freezeTime();
$user = tap(User::make()->makeSuper())->save();

$this->actingAs($user)->get(cp_route('elevated-session.status'))->assertOk();
$this->assertEquals(now()->timestamp, session('last_activity'));

$this->travel(30)->seconds();

$this->actingAs($user)->get(cp_route('session.timeout'))->assertOk();
$this->assertNotEquals(now()->timestamp, session('last_activity'));
}

#[Test]
public function hitting_the_token_route_does_not_extend_the_session()
{
$this->freezeTime();
$user = tap(User::make()->makeSuper())->save();

$this->actingAs($user)->get(cp_route('elevated-session.status'))->assertOk();
$this->assertEquals(now()->timestamp, session('last_activity'));

$this->travel(30)->seconds();

$this->actingAs($user)->get(cp_route('token'))->assertOk();
$this->assertNotEquals(now()->timestamp, session('last_activity'));
}

#[Test]
public function hitting_a_normal_cp_route_extends_the_session()
{
$this->freezeTime();
$user = tap(User::make()->makeSuper())->save();

$this->actingAs($user)->get(cp_route('elevated-session.status'))->assertOk();
$this->assertEquals(now()->timestamp, session('last_activity'));

$this->travel(30)->seconds();

$this->actingAs($user)->get(cp_route('elevated-session.status'))->assertOk();
$this->assertEquals(now()->timestamp, session('last_activity'));
}
}
Loading