Files
decision/frontend/app/components/WorkspaceSelector.vue
Yvv 79e468b40f 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>
2026-04-23 15:17:14 +02:00

253 lines
5.7 KiB
Vue

<script setup lang="ts">
const orgsStore = useOrganizationsStore()
const isOpen = ref(false)
const containerRef = ref<HTMLElement | null>(null)
const active = computed(() => orgsStore.active)
const organizations = computed(() => orgsStore.organizations)
function selectOrg(slug: string) {
orgsStore.setActive(slug)
isOpen.value = false
}
// Close on outside click
onMounted(() => {
document.addEventListener('click', (e) => {
if (containerRef.value && !containerRef.value.contains(e.target as Node)) {
isOpen.value = false
}
})
})
</script>
<template>
<div ref="containerRef" class="ws">
<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>
<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 && organizations.length" class="ws__dropdown">
<div class="ws__dropdown-header">
Espace de travail
</div>
<div class="ws__items">
<button
v-for="org in organizations"
:key="org.id"
class="ws__item"
:class="{ 'ws__item--active': org.slug === orgsStore.activeSlug }"
@click="selectOrg(org.slug)"
>
<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">{{ org.name }}</span>
<span class="ws__item-role">{{ org.is_transparent ? 'Public' : 'Membres' }}</span>
</div>
<UIcon v-if="org.slug === orgsStore.activeSlug" name="i-lucide-check" class="ws__item-check" />
</button>
</div>
<div class="ws__dropdown-footer">
<button class="ws__new-btn" disabled>
<UIcon name="i-lucide-plus" />
Nouveau collectif
</button>
</div>
</div>
</Transition>
</div>
</template>
<style scoped>
.ws {
position: relative;
}
.ws__trigger {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.625rem;
background: var(--mood-accent-soft);
border-radius: 10px;
cursor: pointer;
transition: all 0.12s ease;
min-height: 2rem;
max-width: 11rem;
}
.ws__trigger:hover:not(:disabled) {
background: color-mix(in srgb, var(--mood-accent-soft) 80%, var(--mood-accent) 20%);
}
.ws__trigger--open {
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;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
font-size: 0.75rem;
}
.ws__icon--muted {
background: var(--mood-accent-soft);
color: var(--mood-text-muted);
}
.ws__name {
font-size: 0.8125rem;
font-weight: 700;
color: var(--mood-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.ws__caret {
font-size: 0.75rem;
color: var(--mood-text-muted);
flex-shrink: 0;
}
/* Dropdown */
.ws__dropdown {
position: absolute;
top: calc(100% + 0.375rem);
left: 0;
min-width: 13rem;
background: var(--mood-surface);
border-radius: 14px;
box-shadow: 0 8px 32px var(--mood-shadow);
z-index: 100;
overflow: hidden;
}
.ws__dropdown-header {
padding: 0.625rem 0.875rem 0.375rem;
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--mood-text-muted);
}
.ws__items {
padding: 0.25rem 0.5rem;
display: flex;
flex-direction: column;
gap: 2px;
}
.ws__item {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.625rem 0.625rem;
border-radius: 10px;
cursor: pointer;
transition: background 0.1s ease;
text-align: left;
width: 100%;
}
.ws__item:hover { background: var(--mood-accent-soft); }
.ws__item--active { background: var(--mood-accent-soft); }
.ws__item-icon {
width: 1.75rem;
height: 1.75rem;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
font-size: 0.875rem;
}
.ws__item-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.ws__item-name {
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-text);
}
.ws__item-role {
font-size: 0.6875rem;
color: var(--mood-text-muted);
}
.ws__item-check {
color: var(--mood-accent);
font-size: 0.875rem;
flex-shrink: 0;
}
.ws__dropdown-footer {
padding: 0.5rem;
border-top: 1px solid var(--mood-accent-soft);
}
.ws__new-btn {
display: flex;
align-items: center;
gap: 0.375rem;
width: 100%;
padding: 0.5rem 0.625rem;
border-radius: 10px;
font-size: 0.8125rem;
font-weight: 600;
color: var(--mood-text-muted);
background: none;
cursor: not-allowed;
opacity: 0.5;
}
/* Transition */
.dropdown-enter-active, .dropdown-leave-active {
transition: all 0.15s ease;
transform-origin: top left;
}
.dropdown-enter-from, .dropdown-leave-to {
opacity: 0;
transform: scale(0.95) translateY(-4px);
}
</style>