Skip to content

Commit 7d89387

Browse files
committed
Add session management functionality; implement SessionController for handling user sessions and API tokens, update routes and UI for session management
1 parent 73f3ba9 commit 7d89387

File tree

7 files changed

+287
-30
lines changed

7 files changed

+287
-30
lines changed

app/Http/Controllers/Api/V1/AuthController.php

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,10 @@ public function login(Request $request)
3131
}
3232

3333
$deviceName = $request->device_name ?? $request->userAgent() ?? 'api-token';
34-
$token = $user->createToken($deviceName)->plainTextToken;
34+
$token = $user->createToken(
35+
name: $deviceName,
36+
expiresAt: now()->addMinutes(config('sanctum.expiration'))
37+
)->plainTextToken;
3538

3639
return response()->json([
3740
'success' => true,
@@ -63,7 +66,10 @@ public function register(Request $request)
6366
]);
6467

6568
$deviceName = $request->device_name ?? $request->userAgent() ?? 'api-token';
66-
$token = $user->createToken($deviceName)->plainTextToken;
69+
$token = $user->createToken(
70+
name: $deviceName,
71+
expiresAt: now()->addMinutes(config('sanctum.expiration'))
72+
)->plainTextToken;
6773

6874
return response()->json([
6975
'success' => true,
@@ -122,7 +128,13 @@ public function updateProfile(Request $request)
122128
$validated['password'] = Hash::make($validated['password']);
123129
}
124130

125-
$user->update($validated);
131+
$user->fill($validated);
132+
133+
if ($user->isDirty('email')) {
134+
$user->email_verified_at = null;
135+
}
136+
137+
$user->save();
126138

127139
return response()->json([
128140
'success' => true,
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Settings;
4+
5+
use Inertia\Response;
6+
use Illuminate\Http\Request;
7+
use Illuminate\Support\Carbon;
8+
use Illuminate\Support\Facades\DB;
9+
use App\Http\Controllers\Controller;
10+
use Inertia\Inertia;
11+
12+
class SessionController extends Controller
13+
{
14+
15+
public function sessions(Request $request): Response
16+
{
17+
$user = $request->user();
18+
19+
/** --------------------
20+
* Web (SPA) Sessions
21+
* -------------------*/
22+
$webSessions = DB::table('sessions')
23+
->where('user_id', $user->id)
24+
->orderBy('last_activity', 'desc')
25+
->get()
26+
->map(function ($session) {
27+
return [
28+
'id' => $session->id,
29+
'type' => 'web',
30+
'ip_address' => $session->ip_address,
31+
'user_agent' => $session->user_agent,
32+
'last_activity' => Carbon::createFromTimestamp($session->last_activity)->diffForHumans(),
33+
'is_current' => $session->id === session()->getId(),
34+
];
35+
});
36+
37+
/** --------------------
38+
* API Token Sessions
39+
* -------------------*/
40+
$apiSessions = $user->tokens->map(function ($token) {
41+
return [
42+
'id' => $token->id,
43+
'type' => 'api',
44+
'name' => $token->name,
45+
'last_activity' => optional($token->last_used_at)->diffForHumans() ?? 'Never',
46+
'expiry' => $token->expires_at?->diffForHumans() ?? 'Never',
47+
'created_at' => $token->created_at->diffForHumans(),
48+
];
49+
});
50+
51+
return Inertia::render('settings/Sessions', [
52+
'webSessions' => $webSessions,
53+
'apiSessions' => $apiSessions,
54+
]);
55+
}
56+
57+
public function revoke(Request $request)
58+
{
59+
$user = $request->user();
60+
61+
if ($request->has('clear_all')) {
62+
// Revoke all sessions except current
63+
DB::table('sessions')
64+
->where('user_id', $user->id)
65+
->where('id', '!=', session()->getId())
66+
->delete();
67+
68+
// Revoke all API tokens
69+
$user->tokens()->delete();
70+
71+
return back()->with('status', 'All sessions revoked except current.');
72+
}
73+
74+
$sessionId = $request->input('session_id');
75+
$sessionType = $request->input('session_type');
76+
77+
if ($sessionType === 'web') {
78+
// Revoke web session
79+
DB::table('sessions')
80+
->where('user_id', $user->id)
81+
->where('id', $sessionId)
82+
->delete();
83+
84+
return back()->with('status', 'Web session revoked.');
85+
} elseif ($sessionType === 'api') {
86+
// Revoke API token
87+
$user->tokens()->where('id', $sessionId)->delete();
88+
89+
return back()->with('status', 'API token revoked.');
90+
}
91+
92+
return back()->with('error', 'Invalid session type.');
93+
}
94+
}

config/sanctum.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
|
4848
*/
4949

50-
'expiration' => null,
50+
'expiration' => 15 * 24 * 60, // 15 days
5151

5252
/*
5353
|--------------------------------------------------------------------------

resources/js/layouts/settings/Layout.vue

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ const sidebarNavItems: NavItem[] = [
1414
title: 'Password',
1515
href: '/settings/password',
1616
},
17+
{
18+
title: 'Sessions',
19+
href: '/settings/sessions',
20+
},
1721
{
1822
title: 'Application',
1923
href: '/settings/preferences',
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
<script setup lang="ts">
2+
import { Head, Link } from '@inertiajs/vue3';
3+
4+
import HeadingSmall from '@/components/shared/HeadingSmall.vue';
5+
import { type BreadcrumbItem } from '@/types';
6+
7+
import Tooltip from '@/components/shared/Tooltip.vue';
8+
import { buttonVariants } from '@/components/ui/button';
9+
import AppLayout from '@/layouts/AppLayout.vue';
10+
import SettingsLayout from '@/layouts/settings/Layout.vue';
11+
import { Trash } from 'lucide-vue-next';
12+
13+
const breadcrumbItems: BreadcrumbItem[] = [
14+
{
15+
title: 'Sessions',
16+
href: '/settings/sessions',
17+
},
18+
];
19+
20+
const { webSessions, apiSessions } = defineProps<{
21+
webSessions: {
22+
id: number;
23+
ip_address: string;
24+
user_agent: string;
25+
is_current: boolean;
26+
last_activity: string;
27+
}[];
28+
apiSessions: {
29+
id: number;
30+
name: string;
31+
expiry: string;
32+
last_activity: string;
33+
}[];
34+
}>();
35+
</script>
36+
37+
<template>
38+
<AppLayout :breadcrumbs="breadcrumbItems">
39+
<Head title="Sessions" />
40+
41+
<SettingsLayout>
42+
<div class="space-y-6">
43+
<HeadingSmall title="Sessions" description="Manage your active login sessions" class="items-start justify-between">
44+
<Tooltip title="Logout from all sessions/tokens.">
45+
<Link
46+
:href="route('profile.sessions.revoke')"
47+
method="delete"
48+
:data="{ clear_all: 'true' }"
49+
:class="buttonVariants({ variant: 'destructive', size: 'sm' })"
50+
>
51+
<Trash class="inline h-4 w-4" />
52+
</Link>
53+
</Tooltip>
54+
</HeadingSmall>
55+
56+
<div class="space-y-10">
57+
<!-- WEB SESSIONS -->
58+
<section>
59+
<h2 class="mb-4 text-xl font-semibold">Web Sessions</h2>
60+
61+
<div v-if="!webSessions.length" class="text-gray-500">No active web sessions</div>
62+
63+
<ul class="space-y-3">
64+
<li v-for="session in webSessions" :key="session.id" class="rounded border p-4">
65+
<div class="flex justify-between">
66+
<div>
67+
<p class="font-medium">
68+
{{ session.ip_address }}
69+
<span v-if="session.is_current" class="text-sm text-green-600"> (Current) </span>
70+
</p>
71+
<p class="text-sm text-gray-500">
72+
{{ session.user_agent }}
73+
</p>
74+
<p class="text-sm text-gray-400">Active {{ session.last_activity }}</p>
75+
</div>
76+
77+
<div v-if="!session.is_current">
78+
<Tooltip title="Revoke or Logout">
79+
<Link
80+
:href="route('profile.sessions.revoke')"
81+
method="delete"
82+
:data="{ session_id: session.id, session_type: 'web' }"
83+
:class="buttonVariants({ variant: 'destructive', size: 'sm' })"
84+
>
85+
<Trash class="inline h-4 w-4" />
86+
</Link>
87+
</Tooltip>
88+
</div>
89+
</div>
90+
</li>
91+
</ul>
92+
</section>
93+
94+
<!-- API SESSIONS -->
95+
<section>
96+
<h2 class="mb-4 text-xl font-semibold">API / Device Sessions</h2>
97+
98+
<div v-if="!apiSessions.length" class="text-gray-500">No API tokens</div>
99+
100+
<ul class="space-y-3">
101+
<li v-for="token in apiSessions" :key="token.id" class="flex justify-between rounded border p-4">
102+
<div>
103+
<p class="font-medium">{{ token.name }}</p>
104+
<p class="text-sm text-gray-500">Expires: {{ token.expiry }}</p>
105+
<p class="text-sm text-gray-500">Last used: {{ token.last_activity }}</p>
106+
</div>
107+
108+
<div>
109+
<Tooltip title="Revoke or Logout">
110+
<Link
111+
:href="route('profile.sessions.revoke')"
112+
method="delete"
113+
:data="{ session_id: token.id, session_type: 'api' }"
114+
:class="buttonVariants({ variant: 'destructive', size: 'sm' })"
115+
>
116+
<Trash class="inline h-4 w-4" />
117+
</Link>
118+
</Tooltip>
119+
</div>
120+
</li>
121+
</ul>
122+
</section>
123+
</div>
124+
</div>
125+
</SettingsLayout>
126+
</AppLayout>
127+
</template>

routes/console.php

Lines changed: 43 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,30 +4,6 @@
44
use Illuminate\Support\Facades\Artisan;
55
use Illuminate\Support\Facades\Schedule;
66

7-
/**
8-
* Update bill statuses based on recurrence period and transactions.
9-
*
10-
* Run daily at 12:05 AM to ensure statuses are up-to-date.
11-
*/
12-
Schedule::command('bills:update-statuses')
13-
->dailyAt('00:05')
14-
->runInBackground();
15-
16-
/**
17-
* Send upcoming bill notifications to users.
18-
*
19-
* Run the job every minute.
20-
*/
21-
$schedule = Schedule::job(SendUpcomingBillNotifications::class);
22-
23-
if (app()->isProduction()) {
24-
// In production, run daily at 6 AM to avoid spamming users
25-
$schedule->everySixHours();
26-
} else {
27-
// In development, run every minute to test notifications
28-
$schedule->everyMinute();
29-
}
30-
317
Artisan::command('app:send-upcoming-bill-notifications', function () {
328
$this->comment('Sending upcoming bill notifications...');
339
SendUpcomingBillNotifications::dispatch();
@@ -40,7 +16,7 @@
4016
// Example for Bill model
4117
\App\Models\Bill::withoutGlobalScopes()->get()->each(function ($bill) {
4218
$bill->fillSlug();
43-
$this->info('Updated slug for Bill: '.$bill->title.' to '.$bill->slug);
19+
$this->info('Updated slug for Bill: ' . $bill->title . ' to ' . $bill->slug);
4420
$bill->save();
4521
});
4622

@@ -49,9 +25,50 @@
4925
// Example for Team model
5026
\App\Models\Team::withoutGlobalScopes()->get()->each(function ($team) {
5127
$team->fillSlug();
52-
$this->info('Updated slug for Team: '.$team->name.' to '.$team->slug);
28+
$this->info('Updated slug for Team: ' . $team->name . ' to ' . $team->slug);
5329
$team->save();
5430
});
5531

5632
$this->info('Slugs updated successfully.');
5733
})->describe('Update slugs for all relevant models');
34+
35+
/**
36+
* ===============================
37+
* Scheduler Commands
38+
* ===============================
39+
*/
40+
41+
/**
42+
* Send upcoming bill notifications to users.
43+
*
44+
* In production, consider running it less frequently (e.g., every six hours)
45+
* to avoid spamming users with notifications.
46+
*/
47+
$sendUpcomingNotification = Schedule::command('app:send-upcoming-bill-notifications');
48+
49+
if (app()->isProduction()) {
50+
// In production, run every six hours to avoid spamming users
51+
$sendUpcomingNotification->everySixHours();
52+
} else {
53+
// In development, run every minute to test notifications
54+
$sendUpcomingNotification->everyMinute();
55+
}
56+
$sendUpcomingNotification->runInBackground();
57+
58+
/**
59+
* Update bill statuses based on recurrence period and transactions.
60+
*
61+
* Run daily at 12:05 AM to ensure statuses are up-to-date.
62+
*/
63+
Schedule::command('bills:update-statuses')
64+
->dailyAt('00:05')
65+
->runInBackground();
66+
67+
/**
68+
* Clear expired API tokens to maintain security and performance.
69+
*
70+
* Run daily at 1:00 AM to clean up expired tokens.
71+
*/
72+
Schedule::command('sanctum:prune-expired')
73+
->dailyAt('01:00')
74+
->runInBackground();

routes/settings.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,16 @@
33
use App\Http\Controllers\Settings\PasswordController;
44
use App\Http\Controllers\Settings\PreferenceController;
55
use App\Http\Controllers\Settings\ProfileController;
6+
use App\Http\Controllers\Settings\SessionController;
67
use Illuminate\Support\Facades\Route;
78
use Inertia\Inertia;
89

910
Route::middleware('auth')->group(function () {
1011
Route::redirect('settings', '/settings/profile');
1112

1213
Route::get('settings/profile', [ProfileController::class, 'edit'])->name('profile.edit');
14+
Route::get('settings/sessions', [SessionController::class, 'sessions'])->name('profile.sessions');
15+
Route::delete('settings/sessions/revoke', [SessionController::class, 'revoke'])->name('profile.sessions.revoke');
1316
Route::patch('settings/profile', [ProfileController::class, 'update'])->name('profile.update');
1417
Route::delete('settings/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
1518

0 commit comments

Comments
 (0)