This commit is contained in:
2026-06-28 18:12:00 +02:00
parent 5152ec0f7e
commit 7a593fc204
27 changed files with 413 additions and 100 deletions
Generated
+35
View File
@@ -583,6 +583,28 @@ dependencies = [
"tracing",
]
[[package]]
name = "axum-extra"
version = "0.12.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be44683b41ccb9ab2d23a5230015c9c3c55be97a25e4428366de8873103f7970"
dependencies = [
"axum",
"axum-core",
"bytes",
"cookie",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "base64"
version = "0.22.1"
@@ -935,6 +957,17 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "cookie"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
dependencies = [
"percent-encoding",
"time",
"version_check",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
@@ -2346,6 +2379,7 @@ dependencies = [
"argon2",
"async-trait",
"axum",
"axum-extra",
"bitflags",
"chrono",
"config",
@@ -2360,6 +2394,7 @@ dependencies = [
"serde",
"serde_json",
"thiserror",
"time",
"tokio",
"toml",
"tower",
+2
View File
@@ -13,6 +13,7 @@ members = [".", "migration", "event_bus"]
[dependencies]
tokio = { version = "1.52.3", features = ["full"] }
axum = { version = "0.8", features = ["ws"] }
axum-extra = { version = "0.12.6", features = ["cookie"] }
config = "0.15.24"
sea-orm = { version = "2.0.0-rc.41", features = ["sqlx-sqlite", "sqlx-postgres", "sqlx-mysql", "runtime-tokio", "with-chrono", "with-uuid", "with-json", "schema-sync"] }
migration = { path = "migration" }
@@ -39,3 +40,4 @@ async-trait = "0.1.89"
anyhow = "1.0.102"
futures-util = "0.3"
form_urlencoded = "1.2.2"
time = "0.3.47"
+2 -6
View File
@@ -1,11 +1,7 @@
<template>
<v-app>
<v-main>
<router-view />
</v-main>
</v-app>
<router-view/>
</template>
<script lang="ts" setup>
//
//
</script>
+5 -4
View File
@@ -4,7 +4,8 @@ import {useAuthStore} from "@/stores/auth";
export function useApi() {
const appStore = useAppStore()
const authStore = useAuthStore()
const baseUrl = `${appStore.baseurl}/api`
// const baseUrl = `${appStore.baseurl}/api`
const baseUrl = '/api'
const request = async (endpoint: string, options: RequestInit = {}) => {
const headers = new Headers(options.headers)
@@ -13,9 +14,9 @@ export function useApi() {
headers.set('Content-Type', 'application/json')
}
if (authStore.token) {
headers.set('Authorization', `Bearer ${authStore.token}`)
}
// if (authStore.token) {
// headers.set('Authorization', `Bearer ${authStore.token}`)
// }
const config = {
...options,
+32
View File
@@ -0,0 +1,32 @@
<script lang="ts" setup>
</script>
<template>
<v-app class="admin-layout">
<v-app-bar color="error" dark>
<v-app-bar-title>Console d'Administration</v-app-bar-title>
<v-spacer></v-spacer>
<v-btn prepend-icon="mdi-arrow-left" to="/">Retour à l'App</v-btn>
</v-app-bar>
<v-navigation-drawer permanent>
<v-list>
<v-list-item subtitle="Contrôle" title="Admin Panel"></v-list-item>
<v-divider></v-divider>
<v-list-item prepend-icon="mdi-view-dashboard" title="Dashboard" to="/admin"></v-list-item>
</v-list>
</v-navigation-drawer>
<v-main>
<v-container fluid>
<!-- Les pages d'administration s'injecteront ici -->
<router-view/>
</v-container>
</v-main>
</v-app>
</template>
<style scoped>
</style>
+65
View File
@@ -0,0 +1,65 @@
<script lang="ts" setup>
</script>
<template>
<v-app>
<!--Top bar-->
<v-system-bar>
<v-spacer></v-spacer>
<v-icon>mdi-square</v-icon>
<v-icon>mdi-circle</v-icon>
<v-icon>mdi-triangle</v-icon>
</v-system-bar>
<v-navigation-drawer
color="grey-lighten-3"
rail
>
<v-avatar
class="d-block text-center mx-auto mt-4"
color="grey-darken-1"
size="36"
></v-avatar>
<v-divider class="mx-3 my-5"></v-divider>
<v-avatar
v-for="n in 6"
:key="n"
class="d-block text-center mx-auto mb-9"
color="grey-lighten-1"
size="28"
></v-avatar>
</v-navigation-drawer>
<v-navigation-drawer width="244">
<v-sheet
color="grey-lighten-5"
height="128"
width="100%"
></v-sheet>
<v-list>
<v-list-item
v-for="n in 5"
:key="n"
:title="`Item ${ n }`"
link
></v-list-item>
</v-list>
</v-navigation-drawer>
<v-main>
<!-- Les pages de l'application s'injecteront ici -->
<router-view/>
</v-main>
</v-app>
</template>
<style scoped>
</style>
+16
View File
@@ -0,0 +1,16 @@
<script lang="ts" setup>
</script>
<template>
<v-app class="auth-layout">
<v-main>
<!-- On injecte directement les pages qui gèrent déjà leur propre centrage et v-card -->
<router-view/>
</v-main>
</v-app>
</template>
<style scoped>
</style>
@@ -137,7 +137,7 @@ async function handleRegister() {
</v-btn>
</v-card-actions>
<div class="text-center mt-2">
<router-link to="/login">Déjà un compte ? Connectez-vous</router-link>
<router-link to="/auth/login">Déjà un compte ? Connectez-vous</router-link>
</div>
</v-form>
</v-card-text>
@@ -93,7 +93,7 @@ async function handleLogin() {
</v-btn>
</v-card-actions>
<div class="text-center mt-4">
<router-link to="/join">Pas encore de compte ? Rejoindre</router-link>
<router-link to="/auth/join">Pas encore de compte ? Rejoindre</router-link>
</div>
</v-form>
</v-card-text>
+37 -10
View File
@@ -6,31 +6,47 @@
// Composables
import {createRouter, createWebHistory} from 'vue-router'
import Index from '@/pages/index.vue'
import {useAuthStore} from '@/stores/auth'
import AuthLayout from "@/layouts/AuthLayout.vue";
import AppLayout from "@/layouts/AppLayout.vue";
import AdminLayout from "@/layouts/AdminLayout.vue";
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
// ----------------- GROUPE AUTHENTIFICATION -----------------
{
path: '/login',
path: '/auth',
component: AuthLayout,
children: [
{
path: 'login',
name: 'login',
component: () => import('@/pages/login.vue'),
component: () => import('@/pages/auth/login.vue'),
},
{
path: '/join',
path: 'join',
name: 'join',
component: () => import('@/pages/join.vue'),
component: () => import('@/pages/auth/join.vue'),
},
// Vous pouvez ajouter ici 'reset-password', etc.
],
},
// ----------------- GROUPE APPLICATION GÉNÉRALE -----------------
{
path: '/',
component: Index,
component: AppLayout,
children: [
{
path: '',
name: 'home',
component: () => import('@/pages/index.vue'),
},
{
// Cette regex capture soit un UUID, soit le mot "default"
path: '/server/:id(default|[0-9a-fA-F-]{36})',
path: 'server/:id(default|[0-9a-fA-F-]{36})',
name: 'server-dashboard',
component: () => import('@/pages/server/index.vue'),
props: true,
@@ -43,11 +59,22 @@ const router = createRouter({
},
],
},
],
},
// ----------------- GROUPE ADMINISTRATION -----------------
{
path: '/admin',
component: AdminLayout,
meta: {requiresAdmin: true},
children: [
{
path: '',
name: 'admin-dashboard',
component: () => import('@/pages/admin/dashboard.vue'),
meta: {requiresAdmin: true}
},
// Ajoutez d'autres pages admin ici
],
},
],
})
@@ -64,7 +91,7 @@ router.beforeEach(async (to, from, next) => {
if (authRequired && !authStore.isAuthenticated) {
// Non connecté -> Login
next('/login')
next('/auth/login')
} else if (to.name === 'login' && authStore.isAuthenticated) {
// Déjà connecté -> Accueil
next('/')
+4 -1
View File
@@ -34,8 +34,11 @@ export const useAppStore = defineStore('app', {
// On s'abonne à l'événement de connexion de la gateway
window.addEventListener('gateway:connected', loadAppData)
// On connecte la gateway (qui déclenchera 'gateway:connected')
await gatewayStore.connect()
// Initialisation de l'authentification (qui lancera la connexion à la gateway)
await authStore.initialize()
// await authStore.initialize()
// Sécurité : si la gateway s'est déjà connectée entre-temps
if (gatewayStore.status === 'connected') {
+67 -25
View File
@@ -1,6 +1,5 @@
import {defineStore} from 'pinia'
import {useApi} from "@/composables/useApi";
import {useGatewayStore} from "@/stores/gateway.ts";
export interface User {
id: string
@@ -13,57 +12,100 @@ export interface User {
export const useAuthStore = defineStore('auth', {
state: () => ({
token: localStorage.getItem('token') || null as string | null,
// Plus de token stocké dans le localStorage !
user: null as User | null,
isInitialized: false,
// Nous ne gardons ce token en mémoire que pour la connexion au WebSocket
// gatewayToken: null as string | null,
}),
getters: {
isAuthenticated: (state) => !!state.token && !!state.user,
isAuthenticated: (state) => !!state.user,
isAdmin: (state) => state.user?.is_superuser || false,
currentUser: (state) => state.user,
},
actions: {
async initialize() {
if (!this.token) {
this.isInitialized = true
return
}
const api = useApi()
try {
const response = await api.get('/auth/me')
if (response.ok) {
const data = await response.json()
this.user = data.user
// Initialisation du gateway
const gatewayStore = useGatewayStore()
await gatewayStore.connect()
} else {
this.logout()
}
} catch (e) {
console.error("Auth initialization failed", e)
this.logout()
// this.logout()
} finally {
this.isInitialized = true
}
},
setToken(token: string) {
this.token = token
localStorage.setItem('token', token)
// On déclenche la récupération des infos utilisateur immédiatement
return this.initialize()
},
logout() {
this.token = null
const api = useApi()
this.user = null
localStorage.removeItem('token')
// On ne redirige pas ici pour laisser le router ou le composant décider
api.post('/auth/logout')
// Note : si vous implémentez une route /auth/logout côté Axum,
// elle devra nettoyer le cookie en renvoyant un Set-Cookie expiré.
}
}
})
////// Version with token + local storage
// export const useAuthStore = defineStore('auth', {
// state: () => ({
// token: localStorage.getItem('token') || null as string | null,
// user: null as User | null,
// isInitialized: false,
// }),
//
// getters: {
// isAuthenticated: (state) => !!state.token && !!state.user,
// isAdmin: (state) => state.user?.is_superuser || false,
// currentUser: (state) => state.user,
// },
//
// actions: {
// async initialize() {
// if (!this.token) {
// this.isInitialized = true
// return
// }
//
// const api = useApi()
// try {
// const response = await api.get('/auth/me')
// if (response.ok) {
// const data = await response.json()
// this.user = data.user
//
// // Initialisation du gateway
// const gatewayStore = useGatewayStore()
// await gatewayStore.connect()
// } else {
// this.logout()
// }
// } catch (e) {
// console.error("Auth initialization failed", e)
// this.logout()
// } finally {
// this.isInitialized = true
// }
// },
//
// setToken(token: string) {
// this.token = token
// localStorage.setItem('token', token)
// // On déclenche la récupération des infos utilisateur immédiatement
// return this.initialize()
// },
//
// logout() {
// this.token = null
// this.user = null
// localStorage.removeItem('token')
// // On ne redirige pas ici pour laisser le router ou le composant décider
// }
// }
// })
+1 -1
View File
@@ -8,7 +8,7 @@ export const useCategoryStore = defineStore("category", {
actions: {
async fetchCategories() {
let api = useApi();
let response = await api.get("/api/categories");
let response = await api.get("/categories");
this.categories = await response.json();
}
}
+1 -1
View File
@@ -11,7 +11,7 @@ export const useChannelStore = defineStore('channel', {
actions: {
async fetchChannels() {
let api = useApi();
let response = await api.get("/api/channels");
let response = await api.get("/channels");
this.channels = await response.json();
}
}
+10 -10
View File
@@ -1,6 +1,5 @@
import {defineStore} from 'pinia';
import {useAppStore} from "@/stores/app.ts";
import {useAuthStore} from "@/stores/auth.ts";
type GatewayStatus = 'disconnected' | 'connecting' | 'connected' | 'error'
@@ -18,20 +17,21 @@ export const useGatewayStore = defineStore('gateway', {
}
const appStore = useAppStore()
const authStore = useAuthStore()
// const authStore = useAuthStore()
this.status = 'connecting'
const token = authStore.token
if (!token) {
this.status = 'error'
return
}
// version token
// const token = authStore.token
// if (!token) {
// this.status = 'error'
// return
// }
const apiUri = appStore.baseurl ? new URL(appStore.baseurl) : new URL(window.location.href)
const wsProtocol = apiUri.protocol === 'https:' ? 'wss:' : 'ws:'
const wsUrl = `${wsProtocol}//${apiUri.host}/ws/gateway?token=${encodeURIComponent(token)}`
// version token
// const wsUrl = `${wsProtocol}//${apiUri.host}/ws/gateway?token=${encodeURIComponent(token)}`
const wsUrl = `${wsProtocol}//${apiUri.host}/ws/gateway`
const socket = new WebSocket(wsUrl)
socket.onopen = () => {
+1 -1
View File
@@ -8,7 +8,7 @@ export const useServerStore = defineStore("server", {
actions: {
async fetchServers() {
let api = useApi();
const response = await api.get("/api/servers");
const response = await api.get("/servers");
this.servers = await response.json();
}
}
+11 -5
View File
@@ -1,14 +1,14 @@
import { fileURLToPath, URL } from 'node:url'
import {fileURLToPath, URL} from 'node:url'
import Vue from '@vitejs/plugin-vue'
import Fonts from 'unplugin-fonts/vite'
import { defineConfig } from 'vite'
import Vuetify, { transformAssetUrls } from 'vite-plugin-vuetify'
import {defineConfig} from 'vite'
import Vuetify, {transformAssetUrls} from 'vite-plugin-vuetify'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
Vue({
template: { transformAssetUrls },
template: {transformAssetUrls},
}),
// https://github.com/vuetifyjs/vuetify-loader/tree/master/packages/vite-plugin#readme
Vuetify({
@@ -29,7 +29,7 @@ export default defineConfig({
},
}),
],
define: { 'process.env': {} },
define: {'process.env': {}},
resolve: {
alias: {
'@': fileURLToPath(new URL('src', import.meta.url)),
@@ -46,5 +46,11 @@ export default defineConfig({
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
})
@@ -125,6 +125,16 @@ impl MigrationTrait for Migration {
.integer()
.not_null(),
)
.col(
ColumnDef::new(Alias::new("default_channel_permissions"))
.big_integer()
.null(),
)
.col(
ColumnDef::new(Alias::new("default_voice_permissions"))
.big_integer()
.null(),
)
.col(ColumnDef::new(Alias::new("name")).string().null())
.col(
ColumnDef::new(Alias::new("created_at"))
-2
View File
@@ -36,13 +36,11 @@ pub fn create_jwt(
}
pub fn verify_jwt(token: &str, secret: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
println!("Verifying token: {}", token);
let validation = Validation::default();
let token_data = decode::<Claims>(
token,
&DecodingKey::from_secret(secret.as_ref()),
&validation,
)?;
println!("Token data: {:?}", token_data);
Ok(token_data.claims)
}
+6
View File
@@ -5,6 +5,7 @@ use axum::{
middleware::Next,
response::{IntoResponse, Response},
};
use axum_extra::extract::CookieJar;
use std::sync::Arc;
use std::time::Instant;
use tracing::{info, Instrument};
@@ -103,6 +104,11 @@ pub async fn auth_middleware(
None
}
})
.or_else(|| {
// Grâce à CookieJar, on extrait proprement le cookie "token" ou "jwt"
let jar = CookieJar::from_headers(req.headers());
jar.get("token").map(|cookie| cookie.value().to_string())
})
.or_else(|| {
req.uri().query().and_then(|q| {
form_urlencoded::parse(q.as_bytes())
+2 -1
View File
@@ -32,7 +32,8 @@ pub struct Model {
pub name: Option<String>,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
pub default_permissions: Option<u64>,
pub default_channel_permissions: Option<i64>,
pub default_voice_permissions: Option<i64>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
+68 -3
View File
@@ -6,11 +6,13 @@ use crate::http::error::HTTPError;
use crate::routes::user::mapper::user_model_to_user_response;
use axum::extract::State;
use axum::Json;
use axum_extra::extract::cookie::{Cookie, SameSite};
use axum_extra::extract::CookieJar;
use sea_orm::ActiveModelBehavior;
#[utoipa::path(
get,
path = "/auth/login",
post,
path = "/auth/bearer-login",
request_body = LoginRequest,
responses(
(status = 200, description = "Login successful", body = LoginResponse),
@@ -18,7 +20,7 @@ use sea_orm::ActiveModelBehavior;
),
tag = "Auth"
)]
pub async fn login_user_pw(
pub async fn login_bearer(
State(state): State<AppState>,
Json(payload): Json<LoginRequest>,
) -> Result<Json<LoginResponse>, HTTPError> {
@@ -41,6 +43,69 @@ pub async fn login_user_pw(
Ok(Json(LoginResponse { token }))
}
#[utoipa::path(
post,
path = "/auth/login",
request_body = LoginRequest,
responses(
(status = 200, description = "Login successful with cookie set", body = LoginResponse),
(status = 401, description = "Unauthorized")
),
tag = "Auth"
)]
pub async fn login_cookie(
State(state): State<AppState>,
jar: CookieJar,
Json(payload): Json<LoginRequest>,
) -> Result<(CookieJar, Json<LoginResponse>), HTTPError> {
let user = state
.repositories
.user
.check_password(&payload.username, &payload.password)
.await
.map_err(|_| HTTPError::Unauthorized)?;
let token = create_jwt(
user.id,
&user.username,
user.is_superuser,
&state.config.jwt.secret,
state.config.jwt.duration,
)
.map_err(|_| HTTPError::InternalServerError("Failed to create JWT token".to_string()))?;
// Création du cookie sécurisé contenant le token JWT
let cookie = Cookie::build(("token", token.clone()))
.path("/")
.http_only(true)
.same_site(SameSite::Lax)
.secure(false) // Mettez à true si vous forcez le HTTPS en production
.build();
let updated_jar = jar.add(cookie);
Ok((updated_jar, Json(LoginResponse { token })))
}
#[utoipa::path(
post,
path = "/auth/logout",
responses(
(status = 200, description = "Logout successful")
),
tag = "Auth"
)]
pub async fn logout_cookie(jar: CookieJar) -> Result<CookieJar, HTTPError> {
// On crée un cookie expiré en lui donnant une durée négative de 1 seconde (ou Duration::ZERO)
let cookie = Cookie::build(("token", ""))
.path("/")
.max_age(time::Duration::seconds(-1))
.build();
let updated_jar = jar.add(cookie);
Ok(updated_jar)
}
#[utoipa::path(
post,
path = "/auth/me",
+2 -1
View File
@@ -5,6 +5,7 @@ use axum::Router;
pub fn router() -> OxRouter {
Router::new()
.route("/auth/login", post(handlers::login_user_pw))
.route("/auth/login", post(handlers::login_cookie))
.route("/auth/bearer-login", post(handlers::login_bearer))
.route("/auth/me", get(handlers::me))
}
+6 -3
View File
@@ -13,7 +13,8 @@ pub struct CreateChannelRequest {
pub channel_type: ChannelType,
#[schema(example = "général")]
pub name: Option<String>,
pub default_permissions: Option<u64>,
pub default_channel_permissions: Option<u64>,
pub default_voice_permissions: Option<u64>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
@@ -23,7 +24,8 @@ pub struct UpdateChannelRequest {
pub position: i32,
pub channel_type: ChannelType,
pub name: Option<String>,
pub default_permissions: Option<u64>,
pub default_channel_permissions: Option<u64>,
pub default_voice_permissions: Option<u64>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
@@ -36,5 +38,6 @@ pub struct ChannelResponse {
pub name: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub default_permissions: Option<u64>,
pub default_channel_permissions: Option<u64>,
pub default_voice_permissions: Option<u64>,
}
+6 -3
View File
@@ -13,7 +13,8 @@ pub fn channel_model_to_channel_response(model: channel::Model) -> ChannelRespon
name: model.name,
created_at: model.created_at,
updated_at: model.updated_at,
default_permissions: model.default_permissions,
default_channel_permissions: model.default_channel_permissions.map(|p| p as u64),
default_voice_permissions: model.default_voice_permissions.map(|p| p as u64),
}
}
@@ -25,7 +26,8 @@ pub fn create_request_to_am(req: CreateChannelRequest) -> channel::ActiveModel {
position: Set(req.position),
channel_type: Set(req.channel_type),
name: Set(req.name),
default_permissions: Set(req.default_permissions),
default_channel_permissions: Set(req.default_channel_permissions.map(|p| p as i64)),
default_voice_permissions: Set(req.default_voice_permissions.map(|p| p as i64)),
..Default::default()
}
}
@@ -38,7 +40,8 @@ pub fn update_request_to_am(id: Uuid, req: UpdateChannelRequest) -> channel::Act
position: Set(req.position),
channel_type: Set(req.channel_type),
name: Set(req.name),
default_permissions: Set(req.default_permissions),
default_channel_permissions: Set(req.default_channel_permissions.map(|p| p as i64)),
default_voice_permissions: Set(req.default_voice_permissions.map(|p| p as i64)),
..Default::default()
}
}
+1 -2
View File
@@ -4,7 +4,7 @@ use crate::models::user::Model as User;
use crate::routes::gateway::GatewayClient;
use axum::{
extract::{
ws::{Message, WebSocket, WebSocketUpgrade}, Query,
ws::{Message, WebSocket, WebSocketUpgrade},
State,
},
response::IntoResponse,
@@ -19,7 +19,6 @@ pub struct WsQuery {
}
pub async fn ws_handler(
Query(query): Query<WsQuery>,
ws: WebSocketUpgrade,
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
+3 -1
View File
@@ -6,7 +6,9 @@ use utoipa::{Modify, OpenApi};
#[derive(OpenApi)]
#[openapi(
paths(
auth::handlers::login_user_pw,
auth::handlers::login_bearer,
auth::handlers::login_cookie,
auth::handlers::logout_cookie,
auth::handlers::me,
user::handlers::get_all,
user::handlers::get_by_id,