feat: userProfile

This commit is contained in:
Michael 2025-07-16 11:36:10 +02:00
parent 4e8664ee31
commit 8f24453eff
28 changed files with 457 additions and 113 deletions

View file

@ -23,7 +23,6 @@ class LoginUser
try {
$user = User::where('login', $userInfo['username'])->first();
if ($user) {
$user['role'] = ($user->getRoleNames())->first();
$password = $user->getAuthPassword();
if (strlen($password) === 32 && ctype_xdigit($password)) {
if (hash_equals($password, md5($userInfo['password']))) {

View file

@ -29,7 +29,7 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
];
// Only add the 'type' validation rule if the user has the permission to change roles
if (auth()->user()->hasPermissionTo('user.changeRole')) {
if (auth()->user()->hasPermissionTo('users.changeRole')) {
$rules['type'] = ['required', 'string', 'max:6', Rule::in(['admin', 'editor', 'dj'])];
}
@ -44,7 +44,7 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
];
// Only update 'type' if the user has the permission
if (auth()->user()->hasPermissionTo('user.changeRole')) {
if (auth()->user()->hasPermissionTo('users.changeRole')) {
$data['type'] = $input['type'];
}

View file

@ -0,0 +1,12 @@
<?php
namespace App\Filters\FiltersType;
class GreaterEqualFilter
{
function __invoke($query, $whereToSearch, $textToSearch) {
return $query->where(function($query) use ($whereToSearch, $textToSearch) {
$query->where($whereToSearch, '>=', $textToSearch);
});
}
}

View file

@ -0,0 +1,12 @@
<?php
namespace App\Filters\FiltersType;
class SmallerEqualFiler
{
function __invoke($query, $whereToSearch, $textToSearch) {
return $query->where(function($query) use ($whereToSearch, $textToSearch) {
$query->where($whereToSearch, '<=', $textToSearch);
});
}
}

View file

@ -0,0 +1,42 @@
<?php
namespace App\Filters;
use App\Filters\FiltersType\AllFilter;
use App\Filters\FiltersType\GreaterEqualFilter;
use App\Filters\FiltersType\IsFilter;
use App\Filters\FiltersType\LikeFilter;
use App\Filters\FiltersType\SmallerEqualFiler;
use Illuminate\Http\Request;
class ScheduleFilters
{
protected $filters = [
'ends' => SmallerEqualFiler::class,
'starts' => GreaterEqualFilter::class,
];
public function apply($query, Request $filters)
{
foreach ($this->receivedFilters($filters) as $name => $value) {
switch ($name) {
case 'all':
$name = array_diff(array_keys($this->filters), ['all']);
$filterInstance = new $this->filters['all'];
break;
default:
$filterInstance = new $this->filters[$name];
break;
}
$query = $filterInstance($query, $name, $value);
}
return $query;
}
public function receivedFilters(Request $filters)
{
return $filters->only(array_keys($this->filters));
}
}

View file

@ -5,12 +5,15 @@ namespace App\Http\Controllers;
use App\Http\Requests\ScheduleRequest;
use App\Http\Resources\ScheduleResource;
use App\Models\Schedule;
use Illuminate\Http\Request;
class ScheduleController extends Controller
{
public function index()
public function index(Request $request)
{
return ScheduleResource::collection(Schedule::all());
$schedule = Schedule::searchFilter($request)->get();
return $schedule->toJson();
}
public function store(ScheduleRequest $request)

View file

@ -2,30 +2,25 @@
namespace App\Http\Controllers;
use App\Filters\Show\ShowFilters;
use App\Http\Controllers\Controller;
use App\Http\Requests\ShowRequest;
use App\Http\Resources\ShowResource;
use App\Actions\Fortify\UpdateUserProfileInformation;
use App\Models\Show\Show;
use App\Models\User;
use App\Traits\ScheduleTrait;
use App\Traits\Show\ShowDaysTrait;
use App\Traits\Show\ShowDjTrait;
use App\Traits\Show\ShowInstancesTrait;
use App\Traits\Show\ShowTrait;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Exception;
use Log;
# TODO Expose the show instance generation for user interaction and pypo queue
# When pypo requests the schedule up to a certain date, generate the shows up to that date
class UserController extends Controller
{
public function index(Request $request)
{
$queryParams = collect($request->except('withShow'));
$userFilter = (new User())->searchFilter($queryParams);
if($request->withShow) $userFilter = $userFilter->with('showDjs');
$userFilter = (new User())->searchFilter($queryParams);
if ($request->withShow) {
$userFilter = $userFilter->with('showDjs');
}
return response()->json($userFilter->get());
}
@ -41,24 +36,54 @@ class UserController extends Controller
$show = Show::firstOrCreate($showInfos);
$this->manageShowDays($show, $showDaysRules);
$this->manageShowDjs($showDjs, $show);
}catch(Exception $e){
} catch (Exception $e) {
return response()->json(['message' => $e->getMessage()], 500);
}
return response()->json(['message' => 'Show created successfully']);
}
public function show(ShowResource $show)
public function show(User $user)
{
return new ShowResource($show);
$allowedRoles = ['admin', 'editor'];
$authenticatedUser = auth()->user();
if ( ! $authenticatedUser && ! in_array($authenticatedUser->type, $allowedRoles)) {
return response()->json(['message' => 'Forbidden'], 403);
}
return response()->json($user);
}
public function update(ShowRequest $request, Show $show)
public function userProfile()
{
$show->update($request->validated());
return new ShowResource($show);
return response()->json(auth()->user());
}
public function update(Request $request, User $user, UpdateUserProfileInformation $updater)
{
$authenticatedUser = auth()->user();
if ($authenticatedUser->id !== $user->id && ! $authenticatedUser->hasPermissionTo('user.manageAll')) {
return response()->json(['message' => 'You do not have permission to edit other users.'], 403);
}
if ($authenticatedUser->id === $user->id && ! $authenticatedUser->hasPermissionTo('users.manageOwn')) {
return response()->json(['message' => 'You do not have permission to edit your own profile.'], 403);
}
try {
(new UpdateUserProfileInformation())->update($user, $request->all());
$user->load('preferences');
return response()->json($user);
} catch (\Throwable $e) {
Log::error($e->getMessage());
return response()->json(['message' => 'Failed to update user'], 500);
}
}
public function destroy(Request $request)
{
try {
@ -71,10 +96,4 @@ class UserController extends Controller
return response()->json(['message' => $responseMessage]);
}
public function testSchedule(int $showId)
{
$show = Show::find($showId);
$this->manageShowSchedule($show);
}
}

View file

@ -12,6 +12,6 @@ class Authenticate extends Middleware
*/
protected function redirectTo(Request $request): ?string
{
return $request->expectsJson() ? null : route('login');
return $request->expectsJson() ? null : '/login';
}
}

View file

@ -12,6 +12,18 @@ class LoginResponse implements LoginResponseContract
public function toResponse($request)
{
$user = $request->user();
$user->load(['roles', 'preferences' => function ($query) {
$query->where('keystr', 'user_timezone');
}]);
$timezonePreference = $user->preferences->first();
$user->timezone = $timezonePreference ? $timezonePreference->value : null;
unset($user->preferences);
$user->role = $user->roles->first() ? $user->roles->first()->name : null;
unset($user->roles);
return response()->json($user);
}
}

View file

@ -0,0 +1,20 @@
<?php
namespace App\Http\Responses;
use Illuminate\Http\JsonResponse;
use Laravel\Fortify\Contracts\LogoutResponse as LogoutResponseContract;
class LogoutResponse implements LogoutResponseContract
{
/**
* @param \Illuminate\Http\Request $request
* @return \Symfony\Component\HttpFoundation\Response
*/
public function toResponse($request)
{
return $request->wantsJson()
? new JsonResponse('', 204)
: redirect('/login');
}
}

View file

@ -2,6 +2,7 @@
namespace App\Models;
use App\Filters\ScheduleFilters;
use App\Models\ShowInstances\ShowInstances;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -49,4 +50,9 @@ class Schedule extends Model
{
return $this->belongsTo(ShowInstances::class, 'show_instance_id');
}
public function scopeSearchFilter($query, $request)
{
$filters = new ScheduleFilters();
return $filters->apply($query, $request);
}
}

View file

@ -4,6 +4,7 @@ namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use App\Filters\UserFilters;
use App\Helpers\Preferences;
use App\Models\Show\ShowHosts;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
@ -26,10 +27,16 @@ class User extends Authenticatable
protected $fillable = [
'login',
'email',
'first_name',
'last_name',
'cell_phone',
'pass',
'type'
'type',
'timezone',
];
protected $appends = ['timezone'];
/**
* The attributes that should be hidden for serialization.
*
@ -87,6 +94,24 @@ class User extends Authenticatable
parent::setAttribute($key, $value);
}
public function getTimezoneAttribute(): string
{
// Find the timezone preference or return a default value.
return $this->preferences()->where('keystr', 'user_timezone')->first()->valstr ?? Preferences::getDefaultTimeZone();
}
public function setTimezoneAttribute(?string $value): void
{
if ($value) {
$this->preferences()->updateOrCreate(
['keystr' => 'user_timezone'],
['valstr' => $value]
);
} else {
$this->preferences()->where('keystr', 'user_timezone')->delete();
}
}
/**
* Specify the password column for authentication.
*/
@ -113,4 +138,9 @@ class User extends Authenticatable
{
return $this->hasMany(ShowHosts::class, 'subjs_id');
}
public function preferences()
{
return $this->hasMany(Preference::class, 'subjid');
}
}

View file

@ -15,6 +15,7 @@ use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Laravel\Fortify\Fortify;
use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract;
use Laravel\Fortify\Contracts\LogoutResponse;
class FortifyServiceProvider extends ServiceProvider
{
@ -32,11 +33,20 @@ class FortifyServiceProvider extends ServiceProvider
public function boot(): void
{
$this->app->singleton(LoginResponseContract::class, LoginResponse::class);
$this->app->singleton(LogoutResponse::class, LogoutResponse::class);
Fortify::createUsersUsing(CreateNewUser::class);
Fortify::updateUserProfileInformationUsing(UpdateUserProfileInformation::class);
Fortify::updateUserPasswordsUsing(UpdateUserPassword::class);
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
Fortify::authenticateUsing([LoginUser::class, 'login']);
$this->app->instance(LogoutResponse::class, new class implements LogoutResponse {
public function toResponse($request)
{
return redirect('/login');
}
});
RateLimiter::for('login', function (Request $request) {
$throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())) . '|' . $request->ip());

View file

@ -29,6 +29,6 @@ return [
'max_age' => 0,
'supports_credentials' => false,
'supports_credentials' => true,
];

View file

@ -10,7 +10,7 @@ import CalendarShowEvent from "@partials/show/CalendarShowEvent.vue"
// Store
const auth = useAuthStore();
const userRole = auth.userData.user.role;
const userRole = auth.userData.role;
// Data
const editMode = ref(false);

View file

@ -0,0 +1,91 @@
<script setup lang="ts">
import { useAuthStore } from '@/stores/auth.store.ts'; // Adjust the import path to your store
import { storeToRefs } from 'pinia';
import { ref } from 'vue';
const authStore = useAuthStore();
const { userData } = storeToRefs(authStore);
const emit = defineEmits([
'userProfilePage'
])
// Sample data for selection components
const roles = ['Admin', 'User', 'Editor'];
const timezones = [
'UTC',
'America/New_York',
'America/Chicago',
'America/Denver',
'America/Los_Angeles',
'Europe/London',
'Europe/Berlin',
'Asia/Tokyo',
];
const form = ref(null);
const saveUser = async () => {
await authStore.updateUser();
};
</script>
<template>
<v-form ref="form">
<v-container>
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model="userData.login"
label="Login"
required
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="userData.firstName"
label="First Name"
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="userData.lastName"
label="Last Name"
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-autocomplete
v-model="userData.timezone"
:items="timezones"
label="Timezone"
></v-autocomplete>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="userData.email"
label="email"
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="userData.cellPhone"
label="cellPhone"
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col>
<v-btn color="primary" @click="saveUser">
Salva
</v-btn>
</v-col>
</v-row>
</v-container>
</v-form>
</template>

View file

@ -76,7 +76,7 @@ function hiddenSmartBlockCriteria(){
// Hook and watch
onMounted(() => {
smartBlockStore.updateField({key: 'creator_id', value: auth.userData.user.id})
smartBlockStore.updateField({key: 'creator_id', value: auth.userData.id})
hiddenSmartBlockCriteria()
})

View file

@ -11,19 +11,17 @@ const color = ref("secondary");
const isOnAir = async (): void => {
const now = DateTime.now().setZone(import.meta.env.VITE_APP_TIMEZONE).toISO();
return await axios.get(`/api/v2/schedule`, {
auth: {
username: auth.userData.user.login,
password: auth.userData.password
},
return await axios.get(`/schedule`, {
params: {
ends_after: now,
starts_before: now,
ends: now,
start: now,
}
}).then((response: AxiosResponse) => {
if (typeof response.data === Array && response.data.length > 0) {
color.value = 'error';
}
}).catch((error) => {
console.error(error)
})
}

View file

@ -1,18 +1,28 @@
<script setup lang="ts">
import {useRouter} from "vue-router";
import {useAuthStore} from "@/stores/auth.store.ts";
import axios from "axios";
const router = useRouter();
const auth = useAuthStore();
const userInfo = auth.userData;
if (!userInfo) {
router.push('/login');
}
const userName = auth.userData.login;
const logout = () => {
auth.logout();
router.push('/login');
const emit = defineEmits([
'userProfilePage'
])
const userProfilePage = () => {
emit('userProfilePage')
}
const logout = async () => {
try {
await axios.get('/logout');
auth.resetUser();
router.push('/login');
} catch (error) {
console.error('Error logging out:', error);
}
};
</script>
@ -20,16 +30,17 @@ const logout = () => {
<v-sheet
:width="150"
>
<v-btn color="info">{{ userName }} {{ $t('header.userinfo.info') }}</v-btn>
<v-btn color="info" @click="userProfilePage">{{ userName }} {{ $t('header.userinfo.info') }}</v-btn>
<v-btn color="" @click="logout">{{ $t('header.userinfo.logout') }}</v-btn>
</v-sheet>
</template>
<style scoped>
div button {
width: 100%;
}
div button:not(:first-child) {
margin-top: 2px;
}
div button {
width: 100%;
}
div button:not(:first-child) {
margin-top: 2px;
}
</style>

View file

@ -9,6 +9,7 @@ export interface User {
lastName: string;
email?: boolean;
cellPhone?: boolean;
timezone?: string;
show?: Show[];
}

View file

@ -18,19 +18,18 @@ const router = createRouter({
history: createWebHistory(),
routes
});
export default router;
/**
* Redirect to login page if unauthenticated
*/
router.beforeEach(async (to) => {
// redirect to login page if not logged in and trying to access a restricted page
const publicPages = ['/login'];
const authRequired = !publicPages.includes(to.path);
const auth = useAuthStore();
if (authRequired && !auth.userData) {
auth.returnUrl = to.fullPath;
if (authRequired && !auth.userData.login) {
return '/login';
}
});

View file

@ -24,6 +24,7 @@ const tabs = {
spot: defineAsyncComponent(() => import('@components/content/Show.vue')),
'spot-playlist': defineAsyncComponent(() => import('@components/content/Playlist.vue')),
'spot-blocks': defineAsyncComponent(() => import('@components/content/SmartBlock.vue')),
userProfile: defineAsyncComponent(() => import('@components/content/UserProfile.vue')),
}
watch(currentPage, (newVal) => {

View file

@ -3,6 +3,14 @@ import OnAir from "@/components/header/OnAir.vue";
import Clock from "@/components/header/Clock.vue";
import Timer from "@/components/header/Timer.vue";
import UserInfo from "@/components/header/UserInfo.vue";
const emit = defineEmits([
'userProfilePage'
])
const userProfilePage = () => {
emit('userProfilePage')
}
</script>
<template>
@ -10,7 +18,7 @@ import UserInfo from "@/components/header/UserInfo.vue";
<OnAir />
<Clock />
<Timer />
<UserInfo />
<UserInfo @user-profile-page="userProfilePage" />
</header>
</template>

View file

@ -10,6 +10,19 @@ const page = reactive({
name: 'Dashboard',
})
let returnToPage = {id: '', name: ''}
const userProfilePage = () => {
if(page.id == 'userProfile') {
page.id = returnToPage.id
page.name = returnToPage.name
return
}
returnToPage = {...page}
page.id = 'userProfile'
page.name = 'userProfile'
}
const changePage = (currentPage) => {
page.id = currentPage.id;
page.name = currentPage.name;
@ -18,20 +31,20 @@ const changePage = (currentPage) => {
<template>
<div>
<Header />
<Header @user-profile-page="userProfilePage" />
<v-row :fluid="true">
<Sidebar
@show-page="changePage"
@show-page="changePage"
/>
<Content
:page="page"
:page="page"
/>
</v-row>
</div>
</template>
<style scoped>
.v-row {
margin: 0;
}
.v-row {
margin: 0;
}
</style>

View file

@ -1,57 +1,58 @@
<script setup lang="ts">
import {reactive} from 'vue';
import {onBeforeMount, reactive} from 'vue';
import axios from "axios";
import {useRouter} from "vue-router";
import { Settings } from "luxon";
import {Settings} from "luxon";
import {useAuthStore} from "@/stores/auth.store.ts";
axios.defaults.withCredentials = true
const data = reactive({
'username': null,
'password': null,
'loading': false,
'errors': {}
});
const auth = useAuthStore();
const router = useRouter();
const onSubmit = () => {
const onSubmit = async () => {
let loginSuccessful = false
if (!data.username || !data.password) return;
data.loading = true;
axios.post('/login', {
username: data.username,
password: data.password,
}).then(async (response) => {
if (response.status === 200) {
console.log(response);
const timezone = await setTimezone(response.data);
const auth = useAuthStore();
const userStore = {
user: response.data,
password: data.password,
timezone: timezone
await axios.get('/sanctum/csrf-cookie').then(() => {
axios.post('/login', {
username: data.username,
password: data.password,
}).then(async (response) => {
let timezone = await getTimezone();
if(response.data.timezone) {
timezone = response.data.timezone
} else {
response.data.timezone = timezone
}
auth.setUserData(userStore);
Settings.defaultZone = timezone;
router.push('/');
} else {
console.log(response)
}
}).catch((error) => {
console.log(error);
data.errors = error.response.data.errors
data.loading = false;
console.log(data)
});
auth.loadUser(response.data);
Settings.defaultZone = timezone;
try {
await router.push('/');
} catch (e) {
console.error(e)
}
}).catch((error) => {
data.errors = error.response.data.errors
data.loading = false;
});
})
}
const setTimezone = (user): Promise<string> => {
const getTimezone = (): Promise<string> => {
return axios.get("timezone").then((res) => {
return res.data;
}).catch(error => {
console.log("Error: "+error);
return null;
})
}
@ -59,6 +60,13 @@ const setTimezone = (user): Promise<string> => {
const required = (v) => {
return !!v || $t('login.errors.required');
}
onBeforeMount(() => {
// TODO Create a route taht checks if the user is already logged in (laravel session cookie), if it's logged in
// route.push /
// In the BE make something like route.get('userIsAuthenticated')->return auth()->user()
})
</script>
<template>

View file

@ -1,18 +1,63 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import {defineStore} from 'pinia'
import type {User} from "@models/User.ts";
import axios, {type AxiosResponse} from "axios";
import {camelToSnake} from "@/helpers/AxiosHelper.ts";
export const useAuthStore = defineStore('authStore', () => {
// A variable ref to store the user data
const userData = ref(null)
// A function acts as a setter to set the incoming user data
const setUserData = (newUserData: object) => {
userData.value = newUserData
export const baseUser = (): User => ({
login: '',
role: '',
firstName: '',
lastName: '',
timezone: 'UTC',
});
export const useAuthStore = defineStore('user', {
state: () => ({
userData: {} as User,
}),
actions: {
loadUser(userData: User) {
this.userData = {...userData};
},
/**
* Updates a specific field in the current user's state.
* @param {object} payload - The payload containing the key and value to update.
* @param {keyof (User)} payload.key - The key of the user property to update.
* @param {any} payload.value - The new value for the property.
*/
updateField(payload: { key: keyof (User); value: any }) {
this.userData[payload.key] = payload.value;
},
/**
* Resets the current user state to its base values.
*/
resetUser() {
this.userData = {...baseUser()};
},
/**
* Updates the current user on the server.
* @returns {Promise<any>} A promise that resolves with the response data from the server.
*/
async updateUser() {
if (!this.userData.id) {
console.error("Error: User ID is missing. Cannot update.");
return;
}
let userData = camelToSnake({...this.userData});
return await axios.put(`/user/${userData.id}`, userData)
.then((response: AxiosResponse) => {
return response.data;
}).catch((error: Error) => {
console.error("Error: " + error.message);
});
},
}
const logout = () => {
userData.value = null
}
return { userData, setUserData, logout }
})
});

View file

@ -67,6 +67,5 @@ export const useShowStore = defineStore('show', {
console.error("Error: " + error.message);
})
},
}
})

View file

@ -4,6 +4,7 @@ use App\Helpers\Preferences;
use App\Http\Controllers\FileController;
use App\Http\Controllers\PodcastController;
use App\Http\Controllers\PodcastEpisodeController;
use App\Http\Controllers\ScheduleController;
use App\Http\Controllers\Show\ShowController;
use App\Http\Controllers\Show\ShowDaysController;
use App\Http\Controllers\ShowInstance\ShowInstancesController;
@ -27,6 +28,7 @@ use Illuminate\Support\Facades\Route;
*/
Route::middleware('auth')->get('/user/profile', [UserController::class, 'userProfile']);
/**
* Create routes without create method
@ -47,6 +49,9 @@ Route::resources([
'except' => 'create'
]);
Route::middleware('auth:sanctum')->put('/user/{user}',[UserController::class,'update']);
Route::get('/schedule',[ScheduleController::class, 'index']);
/**
* Custom file route
*/