Multi-tenancy : espaces de travail + fix auth reload (rate limiter OPTIONS)

- Modèles Organization + OrgMember, migration Alembic (SQLite compatible)
- organization_id nullable sur Document, Decision, Mandate, VotingProtocol
- Service, schéma, router /organizations + dependency get_active_org_id
- Seed : Duniter G1 + Axiom Team ; tout le contenu seed attaché à Duniter G1
- Backend : list/create filtrés par header X-Organization
- Frontend : store organizations, WorkspaceSelector réel, useApi injecte l'org
- Fix critique : rate_limiter exclut les requêtes OPTIONS (CORS preflight)
  → résout le bug "Failed to fetch /auth/me" au reload (429 sur preflight)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-04-23 15:17:14 +02:00
parent 224e5b0f5e
commit 79e468b40f
31 changed files with 1296 additions and 159 deletions

View File

@@ -1,52 +1,18 @@
<script setup lang="ts">
/**
* WorkspaceSelector — Sélecteur de collectif / espace de travail.
* Compartimentage multi-collectifs, multi-sites.
* UI-only pour l'instant, prêt pour le backend (collective_id sur toutes les entités).
*/
const orgsStore = useOrganizationsStore()
interface Workspace {
id: string
name: string
slug: string
icon: string
role?: string
color?: string
}
// Mock data — sera remplacé par le store collectifs
const workspaces: Workspace[] = [
{
id: 'g1-main',
name: 'Duniter G1',
slug: 'duniter-g1',
icon: 'i-lucide-coins',
role: 'Membre',
color: 'accent',
},
{
id: 'axiom',
name: 'Axiom Team',
slug: 'axiom-team',
icon: 'i-lucide-layers',
role: 'Admin',
color: 'secondary',
},
]
const activeId = ref('g1-main')
const isOpen = ref(false)
const containerRef = ref<HTMLElement | null>(null)
const active = computed(() => workspaces.find(w => w.id === activeId.value) ?? workspaces[0])
const active = computed(() => orgsStore.active)
const organizations = computed(() => orgsStore.organizations)
function selectWorkspace(id: string) {
activeId.value = id
function selectOrg(slug: string) {
orgsStore.setActive(slug)
isOpen.value = false
// TODO: store.setActiveCollective(id) + refetch all data
}
// Close on outside click
const containerRef = ref<HTMLElement | null>(null)
onMounted(() => {
document.addEventListener('click', (e) => {
if (containerRef.value && !containerRef.value.contains(e.target as Node)) {
@@ -58,35 +24,46 @@ onMounted(() => {
<template>
<div ref="containerRef" class="ws">
<button class="ws__trigger" :class="{ 'ws__trigger--open': isOpen }" @click="isOpen = !isOpen">
<div class="ws__icon" :class="`ws__icon--${active.color}`">
<UIcon :name="active.icon" />
<button
class="ws__trigger"
:class="{ 'ws__trigger--open': isOpen }"
:disabled="orgsStore.loading || !active"
@click="isOpen = !isOpen"
>
<div v-if="orgsStore.loading" class="ws__icon ws__icon--muted">
<UIcon name="i-lucide-loader-2" class="animate-spin" />
</div>
<span class="ws__name">{{ active.name }}</span>
<div v-else-if="active" class="ws__icon" :style="{ background: active.color ? active.color + '22' : undefined, color: active.color || undefined }">
<UIcon :name="active.icon || 'i-lucide-building'" />
</div>
<span class="ws__name">{{ active?.name ?? '…' }}</span>
<UIcon name="i-lucide-chevrons-up-down" class="ws__caret" />
</button>
<Transition name="dropdown">
<div v-if="isOpen" class="ws__dropdown">
<div v-if="isOpen && organizations.length" class="ws__dropdown">
<div class="ws__dropdown-header">
Espace de travail
</div>
<div class="ws__items">
<button
v-for="ws in workspaces"
:key="ws.id"
v-for="org in organizations"
:key="org.id"
class="ws__item"
:class="{ 'ws__item--active': ws.id === activeId }"
@click="selectWorkspace(ws.id)"
:class="{ 'ws__item--active': org.slug === orgsStore.activeSlug }"
@click="selectOrg(org.slug)"
>
<div class="ws__item-icon" :class="`ws__icon--${ws.color}`">
<UIcon :name="ws.icon" />
<div
class="ws__item-icon"
:style="{ background: org.color ? org.color + '22' : undefined, color: org.color || undefined }"
>
<UIcon :name="org.icon || 'i-lucide-building'" />
</div>
<div class="ws__item-info">
<span class="ws__item-name">{{ ws.name }}</span>
<span v-if="ws.role" class="ws__item-role">{{ ws.role }}</span>
<span class="ws__item-name">{{ org.name }}</span>
<span class="ws__item-role">{{ org.is_transparent ? 'Public' : 'Membres' }}</span>
</div>
<UIcon v-if="ws.id === activeId" name="i-lucide-check" class="ws__item-check" />
<UIcon v-if="org.slug === orgsStore.activeSlug" name="i-lucide-check" class="ws__item-check" />
</button>
</div>
<div class="ws__dropdown-footer">
@@ -118,7 +95,7 @@ onMounted(() => {
max-width: 11rem;
}
.ws__trigger:hover {
.ws__trigger:hover:not(:disabled) {
background: color-mix(in srgb, var(--mood-accent-soft) 80%, var(--mood-accent) 20%);
}
@@ -126,6 +103,11 @@ onMounted(() => {
background: color-mix(in srgb, var(--mood-accent-soft) 60%, var(--mood-accent) 40%);
}
.ws__trigger:disabled {
opacity: 0.5;
cursor: default;
}
.ws__icon {
width: 1.375rem;
height: 1.375rem;
@@ -137,10 +119,9 @@ onMounted(() => {
font-size: 0.75rem;
}
.ws__icon--accent { background: var(--mood-accent); color: var(--mood-accent-text); }
.ws__icon--secondary {
background: color-mix(in srgb, var(--mood-secondary, var(--mood-accent)) 20%, transparent);
color: var(--mood-secondary, var(--mood-accent));
.ws__icon--muted {
background: var(--mood-accent-soft);
color: var(--mood-text-muted);
}
.ws__name {
@@ -202,7 +183,6 @@ onMounted(() => {
}
.ws__item:hover { background: var(--mood-accent-soft); }
.ws__item--active { background: var(--mood-accent-soft); }
.ws__item-icon {