Session api token validation

This commit is contained in:
2026-06-29 21:42:47 +10:00
parent 164b590a6c
commit e551e202d5
18 changed files with 273 additions and 154 deletions
+27 -2
View File
@@ -16,6 +16,10 @@ use Inertia\Inertia;
class UserProfileController extends Controller
{
public function currentUserCanEditProfile(User $profileUser){
return auth()->check() && (auth()->id() === $profileUser->id || auth()->user()->hasRole('admin'));
}
public function index(){
if (auth()->check()) {
$user = auth()->user();
@@ -39,7 +43,7 @@ class UserProfileController extends Controller
return [
'user' => $user,
'canView' => Gate::allows('viewProfileData', $user),
'canEdit' => auth()->check() && (auth()->id() === $user->id || auth()->user()->hasRole('admin')),
'canEdit' => $this->currentUserCanEditProfile($user),
'initialView' => $view,
'selectedFlightId' => $selectedFlightId,
'followStatus' => auth()->check() ? auth()->user()->followStatus($user) : 'none',
@@ -85,10 +89,11 @@ class UserProfileController extends Controller
return Inertia::render('UserFlight', [
'flightCount' => $user->departedFlights()->count(),
'flight' => $userFlight->snapshot($userFlight->id),
'canEdit' => auth()->check() && auth()->id() === $user->id,
'canEdit' => $this->currentUserCanEditProfile($user),
'canView' => Gate::allows('viewProfileData', $user),
'user' => $user,
'followStatus' => auth()->check() ? auth()->user()->followStatus($user) : 'none',
'otherUsersOnFlight' => $userFlight->otherUsersOnFlight(),
]);
}
@@ -128,6 +133,26 @@ class UserProfileController extends Controller
]);
}
public function copyFlight(UserFlight $flight)
{
$loggedInUser = auth()->user();
$exists = UserFlight::where('user_id', $loggedInUser->id)
->where('departure_airport_id', $flight->departure_airport_id)
->where('arrival_airport_id', $flight->arrival_airport_id)
->exists();
if ($exists) {
return back()->withErrors(['flight' => 'You already have a flight logged between these airports on this date.']);
}
$newFlight = $flight->replicate();
$newFlight->user_id = $loggedInUser->id;
$newFlight->save();
return redirect()->route('flights.edit', ['flight' => $newFlight->id]);
}
public function achievement(User $user, Achievement $achievement)
{
$regions = match($achievement->internal_name){
@@ -39,6 +39,10 @@ class HandleInertiaRequests extends Middleware
'permissions' => $request->user()?->getAllPermissions()->pluck('name') ?? [],
'apiToken' => session('api_token'),
],
'flash' => [
'success' => $request->session()->get('success'),
'error' => $request->session()->get('error'),
],
'achievement_notifications' => fn() => $request->user()
? $request->user()
->notifications()
+14
View File
@@ -182,6 +182,20 @@ class UserFlight extends Model
return !$this->isDomestic();
}
public function otherUsersOnFlight(){
return UserFlight::where('user_id', '!=', $this->user_id)
->where('departure_airport_id', $this->departure_airport_id)
->where('arrival_airport_id', $this->arrival_airport_id)
->where('flight_number', $this->flight_number)
->where('airline_id', $this->airline_id)
->whereRaw('DATE(departure_date AT TIME ZONE \'UTC\') = ?', [
Carbon::parse($this->departure_date)->utc()->toDateString()
])
->with('user')
->get()
->pluck('user.name');
}
public static function snapshot($userFlightId): array
{
return UserFlight::with([
+1 -1
View File
@@ -14,7 +14,7 @@ class FlightObserver
//Make queued task if the site gets big
$flight->user->followers()
->get()
->each(fn ($follower) => Cache::forget("user_following_flights_{$follower->id}"));
->each(fn ($follower) => Cache::forget("user_following_flights_{$follower->user_id}"));
}
+3 -3
View File
@@ -79,7 +79,7 @@ class SettingsRegistry
'type' => 'checkbox',
'label' => 'Hide Map Filters By Default',
'category' => 'FlightsGoneBy Settings',
'default' => false,
'default' => true,
],
[
'key' => 'hide_impossible_achievements',
@@ -111,8 +111,8 @@ class SettingsRegistry
'options' => [
['value' => 'airline', 'label' => 'Airline'],
['value' => 'flight_number', 'label' => 'Flight Number'],
['value' => 'from', 'label' => 'From'],
['value' => 'to', 'label' => 'To'],
['value' => 'departure_airport', 'label' => 'From'],
['value' => 'arrival_airport', 'label' => 'To'],
['value' => 'departure_date', 'label' => 'Departure Date'],
['value' => 'departure_time', 'label' => 'Departure Time'],
['value' => 'arrival_time', 'label' => 'Arrival Time'],
@@ -24,8 +24,8 @@ const showColumn = (column: string) => columnsToShow.value.includes(column)
const allHeaders = [
{ title: '', key: 'airline', sortable: true },
{ title: 'FLIGHT', key: 'flight_number', sortable: true },
{ title: 'FROM', key: 'from', sortable: true },
{ title: 'TO', key: 'to', sortable: true },
{ title: 'FROM', key: 'departure_airport', sortable: true },
{ title: 'TO', key: 'arrival_airport', sortable: true },
{ title: 'DATE', key: 'departure_date', sortable: true },
{ title: 'DEPART', key: 'departure_time', sortable: false },
{ title: 'ARRIVE', key: 'arrival_time', sortable: false },
@@ -49,14 +49,14 @@ const showColumn = (column: string) => props.columnsToShow.includes(column)
</div>
</td>
<td v-if="showColumn('from')" class="v-data-table__td">
<td v-if="showColumn('departure_airport')" class="v-data-table__td">
<AirportToolTip :airport="flight.departure_airport">
<Mono class="iata">{{ flight.departure_airport.display_code }}</Mono><br/>
</AirportToolTip>
<span class="city-name">{{ flight.departure_airport.municipality }}</span>
</td>
<td v-if="showColumn('to')" class="v-data-table__td">
<td v-if="showColumn('arrival_airport')" class="v-data-table__td">
<AirportToolTip :airport="flight.arrival_airport">
<span class="iata"><Mono>{{ flight.arrival_airport.display_code }}</Mono></span><br/>
</AirportToolTip>
@@ -40,26 +40,4 @@ defineProps<{
gap: 0.75rem;
margin-bottom: 0.25rem;
}
.airline-logo-placeholder {
width: 42px;
height: 42px;
border-radius: 8px;
background: var(--accent-glow);
border: 1px solid rgba(56, 189, 248, 0.2);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-family: 'Share Tech Mono', monospace;
color: var(--accent);
flex-shrink: 0;
}
.airline-name {
font-size: 1rem;
font-weight: 600;
color: var(--text);
}
</style>
@@ -1,4 +1,6 @@
<script setup lang="ts">
import PanelLabel from "@/Components/FlightsGoneBy/Panels/PanelLabel.vue";
defineProps<{
label?: string
}>()
@@ -6,30 +8,16 @@ defineProps<{
<template>
<div class="panel glass glass-border">
<div v-if="label" class="panel-label">{{label}}</div>
<PanelLabel v-if="label">{{label}}</PanelLabel>
<slot />
</div>
</template>
<style scoped>
/* Panels */
.panel {
padding: 1.25rem 1.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.panel-label {
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 0.25rem;
opacity: 0.8;
}
</style>
@@ -0,0 +1,21 @@
<script setup lang="ts">
</script>
<template>
<div class="panel-label">
<slot/>
</div>
</template>
<style scoped>
.panel-label {
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 0.25rem;
opacity: 0.8;
}
</style>
@@ -0,0 +1,51 @@
<script setup lang="ts">
import { usePage } from "@inertiajs/vue3";
import { computed } from "vue";
import { SharedProps } from "@/Types/types";
const page = usePage<SharedProps>().props;
const errors = computed(() => Object.values(page.errors ?? {}));
const success = computed(() => page.flash?.success);
</script>
<template>
<div class="status-notifications">
<TransitionGroup name="toast">
<v-alert
v-for="(error, i) in errors"
:key="`error-${i}`"
type="error"
variant="flat"
rounded="0"
closable
class="mb-2"
>
{{ error }}
</v-alert>
<v-alert
v-if="success"
key="success"
type="success"
variant="flat"
rounded="0"
closable
class="mb-2"
>
{{ success }}
</v-alert>
</TransitionGroup>
</div>
</template>
<style scoped>
.status-notifications {
position: fixed;
bottom: 1.5rem;
left: 50%;
transform: translateX(-50%);
z-index: 9999;
width: 360px;
}
</style>
@@ -0,0 +1,98 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import { router } from "@inertiajs/vue3";
import { Notification } from "@/Types/types";
import axios from "axios";
const activeToasts = ref<Notification[]>([])
const seenNotificationIds = ref<Set<number>>(new Set())
const achievementSound = new Audio('/sounds/seatBelt.wav')
function handleNewNotifications(notifications: Notification[]) {
if (!notifications?.length) return
const newToasts: Notification[] = []
for (const n of notifications) {
if (!seenNotificationIds.value.has(n.id)) {
seenNotificationIds.value.add(n.id)
newToasts.push(n)
}
}
if (!newToasts.length) return
activeToasts.value.push(...newToasts)
achievementSound.play().catch(() => {})
}
async function dismissToast(notification: Notification) {
activeToasts.value = activeToasts.value.filter(n => n.id !== notification.id)
await axios.patch(`/notifications/${notification.id}/read`)
}
router.on('success', (event) => {
handleNewNotifications(event.detail.page.props.achievement_notifications as Notification[] ?? [])
})
// Handle initial page load notifications
import { usePage } from "@inertiajs/vue3";
import { SharedProps } from "@/Types/types";
const page = usePage<SharedProps>().props;
watch(
() => page.achievement_notifications,
handleNewNotifications,
{ immediate: true }
)
</script>
<template>
<div class="toast-stack">
<TransitionGroup name="toast">
<v-card
v-for="notification in activeToasts"
:key="notification.id"
class="toast-card glass"
rounded="lg"
elevation="4"
max-width="360"
>
<v-card-text class="d-flex align-center ga-3">
<v-icon icon="mdi-trophy" color="amber" size="32" />
<div>
<div class="text-subtitle-2 font-weight-bold">{{ notification.title }}</div>
<div class="text-body-2 text-medium-emphasis">{{ notification.body }}</div>
</div>
<v-btn
icon="mdi-close"
variant="text"
size="small"
class="ml-auto"
@click="dismissToast(notification)"
/>
</v-card-text>
</v-card>
</TransitionGroup>
</div>
</template>
<style scoped>
.toast-stack {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
z-index: 9999;
max-height: 50dvh;
overflow-y: scroll;
scrollbar-width: none;
}
.toast-stack::-webkit-scrollbar { display: none; }
.toast-card { flex-shrink: 0; }
.toast-enter-from { opacity: 0; transform: translateX(100%); }
.toast-leave-to { opacity: 0; transform: translateX(100%); }
.toast-enter-active,
.toast-leave-active { transition: all 0.3s ease; }
</style>
@@ -1,9 +1,11 @@
<script setup lang="ts">
import {computed, ref} from "vue";
import {Flight, User} from "@/Types/types";
import {router} from "@inertiajs/vue3";
import {Flight, SharedProps, User} from "@/Types/types";
import {router, usePage} from "@inertiajs/vue3";
import { copyToClipboard } from "@/Composables/useClipboard";
const loggedInUser = usePage<SharedProps>().props.auth.user?.id
defineProps<{
profileUser: User
flight: Flight
@@ -56,6 +58,12 @@ function copyFlightToClipboard(flight: Flight) {
title="View Details"
:href="route('profile.flight', { userFlight: flight.id, user: profileUser.name })"
/>
<v-list-item
v-if="loggedInUser && loggedInUser !== profileUser.id"
prepend-icon="mdi-plus"
title="Log Flight for Myself"
:href="route('flights.copy', { flight: flight.id })"
/>
<v-list-item
prepend-icon="mdi-content-copy"
title="Copy to Clipboard"
@@ -72,6 +80,7 @@ function copyFlightToClipboard(flight: Flight) {
@click="flightToDelete = flight"
/>
</v-list>
</v-menu>
<v-dialog v-if="canEdit" v-model="showDeleteDialog" max-width="400">
+8 -98
View File
@@ -2,54 +2,18 @@
import MainHeader from "@/Components/FlightsGoneBy/MainHeader.vue";
import MainFooter from "@/Components/FlightsGoneBy/MainFooter.vue";
import Radar from "@/Components/FlightsGoneBy/Radar.vue";
import { usePage, router } from "@inertiajs/vue3";
import { ref, watch } from "vue";
import { SharedProps, Notification } from "@/Types/types";
import axios from "axios";
import StatusNotifications from "@/Components/FlightsGoneBy/StatusNotifications.vue";
import ToastNotifications from "@/Components/FlightsGoneBy/ToastNotifications.vue";
import { router } from "@inertiajs/vue3";
import { ref } from "vue";
const page = usePage<SharedProps>().props;
const transitionKey = ref(0);
const seenNotificationIds = ref<Set<number>>(new Set())
const achievementSound = new Audio('/sounds/seatBelt.wav')
function handleNewNotifications(notifications: Notification[]) {
if (!notifications?.length) return
const newToasts: Notification[] = []
for (const n of notifications) {
if (!seenNotificationIds.value.has(n.id)) {
seenNotificationIds.value.add(n.id)
newToasts.push(n)
}
}
if (!newToasts.length) return
activeToasts.value.push(...newToasts)
achievementSound.play().catch(() => {})
}
// ── Toasts ────────────────────────────────────────────────────────────────────
const activeToasts = ref<Notification[]>([])
async function dismissToast(notification: Notification) {
activeToasts.value = activeToasts.value.filter(n => n.id !== notification.id)
await axios.patch(`/notifications/${notification.id}/read`)
}
watch(
() => page.achievement_notifications,
handleNewNotifications,
{ immediate: true }
)
router.on('success', (event) => {
router.on('success', () => {
transitionKey.value++;
handleNewNotifications(event.detail.page.props.achievement_notifications as Notification[] ?? [])
});
</script>
<template>
<Radar>
<div class="layoutContainer">
@@ -61,65 +25,11 @@ router.on('success', (event) => {
</Transition>
<MainFooter :key="`footer-${transitionKey}`" />
</div>
<div class="toast-stack">
<TransitionGroup name="toast">
<v-card
v-for="notification in activeToasts"
:key="notification.id"
class="toast-card glass"
rounded="lg"
elevation="4"
max-width="360"
>
<v-card-text class="d-flex align-center ga-3">
<v-icon icon="mdi-trophy" color="amber" size="32" />
<div>
<div class="text-subtitle-2 font-weight-bold">{{ notification.title }}</div>
<div class="text-body-2 text-medium-emphasis">{{ notification.body }}</div>
</div>
<v-btn
icon="mdi-close"
variant="text"
size="small"
class="ml-auto"
@click="dismissToast(notification)"
/>
</v-card-text>
</v-card>
</TransitionGroup>
</div>
<StatusNotifications />
<ToastNotifications />
</Radar>
</template>
<style scoped>
.toast-stack {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
z-index: 9999;
max-height: 50dvh;
overflow-y: scroll;
scrollbar-width: none;
}
.toast-stack::-webkit-scrollbar {
display: none;
}
.toast-card {
flex-shrink: 0;
background: rgb(var(--v-theme-surface)) !important;
}
.toast-enter-from { opacity: 0; transform: translateX(100%); }
.toast-leave-to { opacity: 0; transform: translateX(100%); }
.toast-enter-active,
.toast-leave-active { transition: all 0.3s ease; }
</style>
<style scoped>
.layoutContainer {
display: flex;
+18 -2
View File
@@ -2,7 +2,7 @@
import MainLayout from "@/Layouts/MainLayout.vue";
import {Head, Link} from "@inertiajs/vue3";
import ProfileLayout from "@/Components/FlightsGoneBy/ProfileLayout.vue";
import {Achievement, Flight, User, UserAchievement} from "@/Types/types";
import {Flight, User} from "@/Types/types";
import BoardingPass from "@/Components/FlightsGoneBy/BoardingPasses/BoardingPass.vue";
import Panel from "@/Components/FlightsGoneBy/Panels/Panel.vue";
import AirportPanel from "@/Components/FlightsGoneBy/Panels/AirportPanel.vue";
@@ -11,6 +11,7 @@ import AircraftPanel from "@/Components/FlightsGoneBy/Panels/AircraftPanel.vue";
import RoutePanel from "@/Components/FlightsGoneBy/Panels/RoutePanel.vue";
import DetailRows from "@/Components/FlightsGoneBy/Panels/DetailRows.vue";
import ButtonLink from "@/Components/FlightsGoneBy/ButtonLink.vue";
import PanelLabel from "@/Components/FlightsGoneBy/Panels/PanelLabel.vue";
defineOptions({ layout: MainLayout })
@@ -21,6 +22,7 @@ const props = defineProps<{
followStatus: string
canEdit: boolean
canView: boolean
otherUsersOnFlight: string[]
}>()
</script>
@@ -42,7 +44,21 @@ const props = defineProps<{
<RoutePanel :flight="flight" />
<Panel label="Flight Details">
<BoardingPass :user="user" :showToolTips="false" style="width:100%;max-width:600px; margin:0 auto" :flight="flight" :canEdit="canEdit" />
<DetailRows/>
<DetailRows>
<PanelLabel>Other Users On This Flight</PanelLabel>
<div class="d-flex flex-wrap ga-2">
<v-chip
v-for="userName in otherUsersOnFlight"
:key="userName"
:href="route('profile.view', userName)"
color="primary"
variant="tonal"
link
>
{{ userName }}
</v-chip>
</div>
</DetailRows>
</Panel>
<AircraftPanel :flight="flight"/>
<AirportPanel :airport="flight.departure_airport" label="Departure" />
+1 -1
View File
@@ -28,7 +28,7 @@ const props = defineProps<{
flightCount: number
}>()
const hideFilters = ref(page.auth?.user?.resolved_settings?.hide_map_filters ?? false)
const hideFilters = ref(page.auth?.user?.resolved_settings?.hide_map_filters ?? true)
watch(hideFilters, (value) => {
updateSetting('hide_map_filters', value).catch(() => {})
+4
View File
@@ -100,6 +100,10 @@ export type SharedProps = import('@inertiajs/core').PageProps & {
permissions: string[];
apiToken: string | null;
},
flash: {
success?: string;
error?: string;
},
logo_api_url: string
achievement_notifications: Notification[]
unread_notification_count: number
+5 -4
View File
@@ -39,6 +39,10 @@ use Inertia\Inertia;
Route::put('/flights/{flight}', [FlightController::class, 'update'])->name('flights.update');
Route::delete('/flights/{flight}/{referrer?}', [FlightController::class, 'delete'])->name('flights.delete');
Route::get('/flights/lookup', [FlightController::class, 'lookup'])->name('flights.lookup');
Route::post('/flights/import', [FlightImportController::class, 'store'])->name('flights.import.store');
Route::get('/flights/{flight}/copy', [UserProfileController::class, 'copyFlight'])->name('flights.copy');
Route::patch('/settings/{key}', [SettingsController::class, 'updateSingle'])
->where('key', '[a-z_]+')
->name('settings.update-single');
@@ -47,10 +51,6 @@ use Inertia\Inertia;
Route::get('/import/fr24', [FlightImportController::class, 'showFr24Import'])->name('import.fr24');
Route::get('/reconcile', [FlightImportController::class, 'reconcile'])->name('reconcile');;
Route::get('/flights/lookup', [FlightController::class, 'lookup'])->name('flights.lookup');
Route::post('/flights/import', [FlightImportController::class, 'store'])->name('flights.import.store');
Route::get('/settings/{category?}', [UserController::class, 'settings'])->name('user.settings');
@@ -71,6 +71,7 @@ use Inertia\Inertia;
Route::post('/import/save', [FlightImportController::class, 'save'])->name('import.save');
//Search Routes
Route::get('/search/airlines', [SearchController::class, 'airlines'])->name('search.airlines');
Route::get('/search/aircraft', [SearchController::class, 'aircraft'])->name('search.aircraft');