Compare commits

..
2 Commits
Author SHA1 Message Date
Nell aa486be6e5 init 2026-08-02 11:09:50 +02:00
Nell 659fd0f304 init 2026-08-02 10:34:18 +02:00
10 changed files with 327 additions and 9 deletions
@@ -0,0 +1,205 @@
<script lang="ts" setup>
import {computed, ref, watch} from 'vue'
import {useUserStore} from '@/stores/user'
import {useApi} from '@/composables/useApi'
import {usePermissions} from '@/composables/usePermissions'
import ChannelPermissionEditor from './ChannelPermissionEditor.vue'
import {toChannelPermissionMask, type ChannelPermissionMask, type ChannelPermissions} from '@/types/permissions'
interface Role { id: string; server_id: string; name: string; is_default: boolean }
interface Channel { id: string; name?: string | null }
type TargetType = 'role' | 'user'
const props = defineProps<{ modelValue: boolean; channel: Channel | null; serverId: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const users = useUserStore()
const api = useApi()
const permissionsApi = usePermissions()
const roles = ref<Role[]>([])
const configured = ref<ChannelPermissions>({users: [], roles: []})
const targetType = ref<TargetType>('role')
const targetId = ref<string | null>(null)
const addTargetId = ref<string | null>(null)
const mask = ref<ChannelPermissionMask>(toChannelPermissionMask(0))
const loading = ref(false)
const saving = ref(false)
const error = ref<string | null>(null)
const configuredEntries = computed(() => targetType.value === 'role' ? configured.value.roles : configured.value.users)
type PermissionEntry = ChannelPermissions['roles'][number] | ChannelPermissions['users'][number]
function entryTargetId(entry: PermissionEntry): string {
return 'role_id' in entry ? entry.role_id : entry.user_id
}
function entryName(entry: PermissionEntry): string {
const id = entryTargetId(entry)
return targetType.value === 'role'
? roles.value.find(role => role.id === id)?.name || id
: users.users.find(user => user.id === id)?.username || id
}
const availableTargets = computed(() => {
const used = new Set(configuredEntries.value.map(entryTargetId))
return targetType.value === 'role'
? roles.value.filter(role => !used.has(role.id))
: users.users.filter(user => !used.has(user.id))
})
const selectedName = computed(() => {
if (!targetId.value) return ''
if (targetType.value === 'role') return roles.value.find(role => role.id === targetId.value)?.name || targetId.value
return users.users.find(user => user.id === targetId.value)?.username || targetId.value
})
watch(() => props.modelValue, async open => {
if (open) await load()
else reset()
})
watch(targetType, () => { targetId.value = null; addTargetId.value = null; mask.value = toChannelPermissionMask(0); error.value = null })
watch(addTargetId, id => {
if (!id) return
targetId.value = id
mask.value = toChannelPermissionMask(0)
addTargetId.value = null
})
async function load() {
if (!props.channel) return
loading.value = true; error.value = null
try {
const [roleResponse] = await Promise.all([api.get('/groups'), users.fetchUsers(props.serverId)])
if (!roleResponse.ok) throw new Error('Impossible de charger les rôles')
roles.value = (await roleResponse.json() as Role[]).filter(role => role.server_id === props.serverId)
configured.value = await permissionsApi.getChannelPermissions(props.channel.id)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Erreur lors du chargement des permissions'
} finally { loading.value = false }
}
function selectExisting(id: string) {
targetId.value = id
const entry = configuredEntries.value.find(item => entryTargetId(item) === id)
mask.value = entry?.permissions ?? toChannelPermissionMask(0)
error.value = null
}
async function save(value: ChannelPermissionMask) {
if (!props.channel || !targetId.value) return
saving.value = true; error.value = null
try {
if (targetType.value === 'role') await permissionsApi.setChannelRolePermission(props.channel.id, targetId.value, value)
else await permissionsApi.setChannelUserPermission(props.channel.id, targetId.value, value)
configured.value = await permissionsApi.getChannelPermissions(props.channel.id)
mask.value = value
} catch (e) { error.value = e instanceof Error ? e.message : 'Erreur lors de la sauvegarde' }
finally { saving.value = false }
}
async function resetPermission() {
if (!props.channel || !targetId.value) return
saving.value = true; error.value = null
try {
if (targetType.value === 'role') await permissionsApi.removeChannelRolePermission(props.channel.id, targetId.value)
else await permissionsApi.removeChannelUserPermission(props.channel.id, targetId.value)
configured.value = await permissionsApi.getChannelPermissions(props.channel.id)
targetId.value = null
mask.value = toChannelPermissionMask(0)
} catch (e) {
const status = e instanceof Error ? (e as Error & {status?: number}).status : undefined
if (status !== 404) error.value = e instanceof Error ? e.message : 'Erreur lors de la réinitialisation'
} finally { saving.value = false }
}
function close() { emit('update:modelValue', false) }
function reset() { configured.value = {users: [], roles: []}; targetId.value = null; addTargetId.value = null; mask.value = toChannelPermissionMask(0); error.value = null }
</script>
<template>
<v-dialog :model-value="modelValue" max-width="760" @update:model-value="emit('update:modelValue', $event)">
<v-card>
<v-card-title>Permissions {{ channel?.name || 'Canal' }}</v-card-title>
<v-card-text>
<v-alert v-if="error" class="mb-4" type="error" density="compact">{{ error }}</v-alert>
<v-tabs v-model="targetType" class="mb-4">
<v-tab value="role">Rôles</v-tab>
<v-tab value="user">Membres</v-tab>
</v-tabs>
<v-row class="permission-layout" dense>
<v-col cols="12" md="4" class="permission-sidebar">
<div class="text-subtitle-2 mb-2">Cibles configurées</div>
<v-list v-if="!loading && configuredEntries.length" density="compact" lines="one" border class="permission-list">
<v-list-item
v-for="entry in configuredEntries"
:key="entry.id"
:active="targetId === entryTargetId(entry)"
@click="selectExisting(entryTargetId(entry))"
>
<v-list-item-title>{{ entryName(entry) }}</v-list-item-title>
<template #append><v-chip size="x-small" variant="tonal">{{ entry.permissions.toString(2).replace(/^0+/, '').length || 0 }}</v-chip></template>
</v-list-item>
</v-list>
<div v-else-if="!loading" class="text-medium-emphasis text-body-2 py-4">Aucune permission directe configurée.</div>
<v-skeleton-loader v-else type="list-item-two-line" />
<v-select
v-model="addTargetId"
:items="availableTargets"
:item-title="targetType === 'role' ? 'name' : 'username'"
item-value="id"
:label="targetType === 'role' ? 'Ajouter un rôle' : 'Ajouter un membre'"
:loading="loading"
clearable
class="mt-3"
hide-details
/>
</v-col>
<v-col cols="12" md="8">
<div v-if="targetId" class="permission-editor">
<v-chip class="mb-3" color="primary" size="small">{{ selectedName }}</v-chip>
<ChannelPermissionEditor
v-model="mask"
:loading="saving"
title="Permissions du canal"
@save="save"
/>
<v-btn class="mt-3" color="error" variant="text" :loading="saving" @click="resetPermission">Réinitialiser les permissions directes</v-btn>
</div>
<v-sheet v-else class="empty-editor d-flex align-center justify-center text-center text-medium-emphasis" rounded border>
Sélectionnez une cible à gauche ou ajoutez-en une pour modifier ses permissions.
</v-sheet>
</v-col>
</v-row>
</v-card-text>
<v-card-actions><v-spacer/><v-btn variant="text" @click="close">Fermer</v-btn></v-card-actions>
</v-card>
</v-dialog>
</template>
<style scoped>
.permission-layout {
min-height: 390px;
}
.permission-sidebar {
border-right: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
.permission-list {
max-height: 280px;
overflow-y: auto;
}
.empty-editor {
min-height: 360px;
padding: 2rem;
}
@media (max-width: 959px) {
.permission-sidebar {
border-right: 0;
border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
padding-bottom: 1rem;
}
.empty-editor {
min-height: 180px;
}
}
</style>
+14 -3
View File
@@ -1,6 +1,9 @@
import {useApi} from '@/composables/useApi'
import {
type ChannelRolePermission,
type ChannelPermissions,
type ChannelPermissionsDto,
channelPermissionsFromDto,
type ChannelRolePermissionDto,
channelRolePermissionFromDto,
type ChannelUserPermission,
@@ -22,10 +25,11 @@ export function usePermissions() {
async function parseResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
const error = await response.json().catch(() => null)
throw new Error(
const exception = new Error(
error?.message || 'Erreur lors de la gestion des permissions',
)
) as Error & { status?: number }
exception.status = response.status
throw exception
}
if (response.status === 204) {
@@ -129,6 +133,12 @@ export function usePermissions() {
// Permissions canal - utilisateur
// ---------------------------------------------------------------------------
async function getChannelPermissions(channelId: string): Promise<ChannelPermissions> {
const response = await api.get(`/channels/${channelId}/permissions`)
const dto = await parseResponse<ChannelPermissionsDto>(response)
return channelPermissionsFromDto(dto)
}
async function getChannelUserPermission(
channelId: string,
userId: string,
@@ -216,6 +226,7 @@ export function usePermissions() {
}
return {
getChannelPermissions,
getServerUserPermission,
setServerUserPermission,
removeServerUserPermission,
+19
View File
@@ -9,6 +9,8 @@ import CreateCategoryDialog from '@/components/category/CreateCategoryDialog.vue
import {type MenuItem, useContextMenu} from '@/composables/useContextMenu'
import {useUserStore} from "@/stores/user.ts";
import {useServerStore} from "@/stores/server.ts";
import ChannelPermissionsDialog from '@/components/permissions/ChannelPermissionsDialog.vue'
import {useAuthStore} from '@/stores/auth'
const props = defineProps<{
serverId: string
@@ -22,6 +24,9 @@ const userStore = useUserStore()
const serverStore = useServerStore()
const {currentTree} = storeToRefs(serverStore)
const {openContextMenu} = useContextMenu()
const authStore = useAuthStore()
const showPermissionsDialog = ref(false)
const selectedChannel = ref<any | null>(null)
const loadServerData = async (targetServerId: string) => {
@@ -111,6 +116,14 @@ async function deleteChannel(channelId: string) {
function onChannelContextMenu(event: MouseEvent, channel: any) {
const menuItems: MenuItem[] = [
...(authStore.isAdmin ? [{
label: 'Gérer les permissions',
icon: 'mdi-shield-key',
action: () => {
selectedChannel.value = channel
showPermissionsDialog.value = true
},
}] : []),
{
label: 'Marquer comme lu',
icon: 'mdi-check',
@@ -194,6 +207,12 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
@created="refreshServerTree"
/>
<ChannelPermissionsDialog
v-model="showPermissionsDialog"
:channel="selectedChannel"
:server-id="serverId"
/>
<v-main>
<router-view/>
</v-main>
+19
View File
@@ -52,6 +52,11 @@ export interface ChannelRolePermission {
permissions: ChannelPermissionMask
}
export interface ChannelPermissions {
users: ChannelUserPermission[]
roles: ChannelRolePermission[]
}
export interface ServerUserPermissionDto {
id: string
server_id: string
@@ -80,6 +85,11 @@ export interface ChannelRolePermissionDto {
permissions: number | string
}
export interface ChannelPermissionsDto {
users: ChannelUserPermissionDto[]
roles: ChannelRolePermissionDto[]
}
function asServerPermissionMask(value: bigint): ServerPermissionMask {
return value as ServerPermissionMask
}
@@ -443,3 +453,12 @@ export function channelRolePermissionFromDto(
permissions: toChannelPermissionMask(dto.permissions),
}
}
export function channelPermissionsFromDto(
dto: ChannelPermissionsDto,
): ChannelPermissions {
return {
users: dto.users.map(channelUserPermissionFromDto),
roles: dto.roles.map(channelRolePermissionFromDto),
}
}
+6
View File
@@ -70,3 +70,9 @@ pub struct ChannelRolePermissionResponse {
pub role_id: Uuid,
pub permissions: u64,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct ChannelPermissionsResponse {
pub users: Vec<ChannelUserPermissionResponse>,
pub roles: Vec<ChannelRolePermissionResponse>,
}
+20
View File
@@ -59,6 +59,16 @@ impl ChannelRepository {
.await?)
}
pub async fn list_user_permissions(
&self,
channel_id: Uuid,
) -> AnyResult<Vec<channel_user_permission::Model>> {
Ok(channel_user_permission::Entity::find()
.filter(channel_user_permission::Column::ChannelId.eq(channel_id))
.all(&self.context.db)
.await?)
}
pub async fn set_user_permission(
&self,
channel_id: Uuid,
@@ -109,6 +119,16 @@ impl ChannelRepository {
.await?)
}
pub async fn list_role_permissions(
&self,
channel_id: Uuid,
) -> AnyResult<Vec<channel_role_permission::Model>> {
Ok(channel_role_permission::Entity::find()
.filter(channel_role_permission::Column::ChannelId.eq(channel_id))
.all(&self.context.db)
.await?)
}
pub async fn set_role_permission(
&self,
channel_id: Uuid,
+21 -1
View File
@@ -2,7 +2,7 @@ use crate::core::state::AppState;
use crate::http::context::Superuser;
use crate::http::error::HTTPError;
use crate::domain::dto::channel::{
ChannelQueryParams, ChannelResponse, ChannelRolePermissionResponse,
ChannelQueryParams, ChannelResponse, ChannelPermissionsResponse, ChannelRolePermissionResponse,
ChannelUserPermissionResponse, CreateChannelRequest, SetChannelPermissionRequest,
UpdateChannelRequest,
};
@@ -69,6 +69,26 @@ pub async fn get_by_id(
Ok(Json(mapper::channel_model_to_channel_response(channel)))
}
/// Liste les permissions directes configurées pour un canal.
#[utoipa::path(
get,
path = "/channels/{channel_id}/permissions",
params(("channel_id" = Uuid, Path, description = "ID du canal")),
responses((status = 200, body = ChannelPermissionsResponse), (status = 404, description = "Canal non trouvé")),
tag = "Channel Permissions"
)]
pub async fn list_permissions(
State(state): State<AppState>,
Path(channel_id): Path<Uuid>,
) -> Result<Json<ChannelPermissionsResponse>, HTTPError> {
state.repositories.channel.get_by_id(channel_id).await?.ok_or(HTTPError::NotFound)?;
let (users, roles) = tokio::try_join!(
state.repositories.channel.list_user_permissions(channel_id),
state.repositories.channel.list_role_permissions(channel_id),
)?;
Ok(Json(mapper::channel_permissions_to_response(users, roles)))
}
/// Crée un nouveau channel
#[utoipa::path(
post,
+11 -1
View File
@@ -1,5 +1,5 @@
use crate::domain::dto::channel::{
ChannelQueryParams, ChannelResponse, ChannelRolePermissionResponse,
ChannelPermissionsResponse, ChannelQueryParams, ChannelResponse, ChannelRolePermissionResponse,
ChannelUserPermissionResponse, CreateChannelRequest, UpdateChannelRequest,
};
use crate::models::{channel, channel_role_permission, channel_user_permission};
@@ -71,6 +71,16 @@ pub fn channel_role_permission_to_response(
}
}
pub fn channel_permissions_to_response(
users: Vec<channel_user_permission::Model>,
roles: Vec<channel_role_permission::Model>,
) -> ChannelPermissionsResponse {
ChannelPermissionsResponse {
users: users.into_iter().map(channel_user_permission_to_response).collect(),
roles: roles.into_iter().map(channel_role_permission_to_response).collect(),
}
}
pub fn query_params_to_channel_filter(params: ChannelQueryParams) -> ChannelFilter {
ChannelFilter {
server_id: params.server_id,
+4
View File
@@ -11,6 +11,10 @@ pub fn router() -> Router<AppState> {
.put(handlers::update)
.delete(handlers::delete),
)
.route(
"/channels/{channel_id}/permissions",
get(handlers::list_permissions),
)
.route(
"/channels/{channel_id}/permissions/users/{user_id}",
get(handlers::get_user_permission)
+6 -2
View File
@@ -2,10 +2,11 @@ use crate::core::state::AppState;
use crate::http::context::Superuser;
use crate::http::error::HTTPError;
use crate::domain::dto::user::{CreateUserRequest, UpdateUserRequest, UserResponse};
use crate::domain::dto::user::UserQueryParams;
use crate::routes::user::mapper;
use axum::{
Json,
extract::{Path, State},
extract::{Path, Query, State},
http::StatusCode,
};
use uuid::Uuid;
@@ -28,8 +29,11 @@ use uuid::Uuid;
pub async fn get_all(
_admin: Superuser,
State(state): State<AppState>,
Query(filters): Query<UserQueryParams>,
) -> Result<Json<Vec<UserResponse>>, HTTPError> {
let users = state.repositories.user.get_all().await?;
let users = state.repositories.user.filter(crate::repositories::types::UserFilter {
server_id: filters.server_id,
}).await?;
Ok(Json(
users
.into_iter()