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

@@ -26,6 +26,8 @@ from app.database import Base
from app.models import ( # noqa: F401
DuniterIdentity,
Session,
Organization,
OrgMember,
Document,
DocumentItem,
ItemVersion,

View File

@@ -0,0 +1,41 @@
"""add organizations
Revision ID: 70914b334cfb
Revises:
Create Date: 2026-04-23 12:27:56.220214+00:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '70914b334cfb'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# SQLite does not support ADD CONSTRAINT via ALTER TABLE — FK constraints
# are declared in models only; integrity is enforced at app layer.
op.add_column('decisions', sa.Column('organization_id', sa.Uuid(), nullable=True))
op.create_index(op.f('ix_decisions_organization_id'), 'decisions', ['organization_id'], unique=False)
op.add_column('documents', sa.Column('organization_id', sa.Uuid(), nullable=True))
op.create_index(op.f('ix_documents_organization_id'), 'documents', ['organization_id'], unique=False)
op.add_column('mandates', sa.Column('organization_id', sa.Uuid(), nullable=True))
op.create_index(op.f('ix_mandates_organization_id'), 'mandates', ['organization_id'], unique=False)
op.add_column('voting_protocols', sa.Column('organization_id', sa.Uuid(), nullable=True))
op.create_index(op.f('ix_voting_protocols_organization_id'), 'voting_protocols', ['organization_id'], unique=False)
def downgrade() -> None:
op.drop_index(op.f('ix_voting_protocols_organization_id'), table_name='voting_protocols')
op.drop_column('voting_protocols', 'organization_id')
op.drop_index(op.f('ix_mandates_organization_id'), table_name='mandates')
op.drop_column('mandates', 'organization_id')
op.drop_index(op.f('ix_documents_organization_id'), table_name='documents')
op.drop_column('documents', 'organization_id')
op.drop_index(op.f('ix_decisions_organization_id'), table_name='decisions')
op.drop_column('decisions', 'organization_id')

View File

View File

@@ -0,0 +1,26 @@
"""FastAPI dependency: resolve X-Organization header → org UUID."""
from __future__ import annotations
import uuid
from fastapi import Depends, Header
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.services.org_service import get_organization_by_slug
async def get_active_org_id(
x_organization: str | None = Header(default=None, alias="X-Organization"),
db: AsyncSession = Depends(get_db),
) -> uuid.UUID | None:
"""Return the UUID of the org named in the X-Organization header, or None.
None means no org filter — used for backward compat and internal tooling.
An unknown slug is silently treated as None (don't break the client).
"""
if not x_organization:
return None
org = await get_organization_by_slug(db, x_organization)
return org.id if org else None

View File

@@ -13,6 +13,7 @@ from app.middleware.rate_limiter import RateLimiterMiddleware
from app.middleware.security_headers import SecurityHeadersMiddleware
from app.routers import auth, documents, decisions, votes, mandates, protocols, sanctuary, websocket
from app.routers import public
from app.routers import organizations
# ── Structured logging setup ───────────────────────────────────────────────
@@ -117,6 +118,7 @@ app.include_router(protocols.router, prefix="/api/v1/protocols", tags=["protocol
app.include_router(sanctuary.router, prefix="/api/v1/sanctuary", tags=["sanctuary"])
app.include_router(websocket.router, prefix="/api/v1/ws", tags=["websocket"])
app.include_router(public.router, prefix="/api/v1/public", tags=["public"])
app.include_router(organizations.router, prefix="/api/v1/organizations", tags=["organizations"])
# ── Health check ─────────────────────────────────────────────────────────

View File

@@ -64,14 +64,6 @@ class RateLimiterMiddleware(BaseHTTPMiddleware):
self._last_cleanup: float = time.time()
self._lock = asyncio.Lock()
def _get_limit_for_path(self, path: str) -> int:
"""Return the rate limit applicable to the given request path."""
if "/auth" in path:
return self.rate_limit_auth
if "/vote" in path:
return self.rate_limit_vote
return self.rate_limit_default
def _get_client_ip(self, request: Request) -> str:
"""Extract the client IP from the request, respecting X-Forwarded-For."""
forwarded = request.headers.get("x-forwarded-for")
@@ -101,6 +93,22 @@ class RateLimiterMiddleware(BaseHTTPMiddleware):
if ips_to_delete:
logger.debug("Nettoyage rate limiter: %d IPs supprimees", len(ips_to_delete))
def _get_limit_for_request(self, request: Request) -> int:
"""Return the rate limit applicable to the given request.
CORS preflight (OPTIONS) requests are never rate-limited — blocking them
breaks authenticated cross-origin requests in browsers.
Strict auth limit applies only to POST (login flows), not to GET /auth/me.
"""
if request.method == "OPTIONS":
return 10_000 # effectively unlimited for preflights
path = request.url.path
if request.method == "POST" and "/auth" in path:
return self.rate_limit_auth
if "/vote" in path:
return self.rate_limit_vote
return self.rate_limit_default
async def dispatch(self, request: Request, call_next) -> Response:
"""Check rate limit and either allow the request or return 429."""
# Skip rate limiting for WebSocket upgrades
@@ -111,8 +119,7 @@ class RateLimiterMiddleware(BaseHTTPMiddleware):
await self._cleanup_old_entries()
client_ip = self._get_client_ip(request)
path = request.url.path
limit = self._get_limit_for_path(path)
limit = self._get_limit_for_request(request)
now = time.time()
window_start = now - 60
@@ -133,7 +140,7 @@ class RateLimiterMiddleware(BaseHTTPMiddleware):
logger.warning(
"Rate limit depasse pour %s sur %s (%d/%d)",
client_ip, path, request_count, limit,
client_ip, request.url.path, request_count, limit,
)
return JSONResponse(

View File

@@ -1,4 +1,5 @@
from app.models.user import DuniterIdentity, Session
from app.models.organization import Organization, OrgMember
from app.models.document import Document, DocumentItem, ItemVersion
from app.models.decision import Decision, DecisionStep
from app.models.vote import VoteSession, Vote
@@ -9,6 +10,7 @@ from app.models.cache import BlockchainCache
__all__ = [
"DuniterIdentity", "Session",
"Organization", "OrgMember",
"Document", "DocumentItem", "ItemVersion",
"Decision", "DecisionStep",
"VoteSession", "Vote",

View File

@@ -16,6 +16,7 @@ class Decision(Base):
context: Mapped[str | None] = mapped_column(Text)
decision_type: Mapped[str] = mapped_column(String(64), nullable=False) # runtime_upgrade, document_change, mandate_vote, custom
status: Mapped[str] = mapped_column(String(32), default="draft") # draft, qualification, review, voting, executed, closed
organization_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("organizations.id"), nullable=True, index=True)
voting_protocol_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("voting_protocols.id"))
created_by_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("duniter_identities.id"))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

View File

@@ -17,6 +17,7 @@ class Document(Base):
version: Mapped[str] = mapped_column(String(32), default="0.1.0")
status: Mapped[str] = mapped_column(String(32), default="draft") # draft, active, archived
description: Mapped[str | None] = mapped_column(Text)
organization_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("organizations.id"), nullable=True, index=True)
ipfs_cid: Mapped[str | None] = mapped_column(String(128))
chain_anchor: Mapped[str | None] = mapped_column(String(128))
genesis_json: Mapped[str | None] = mapped_column(Text) # JSON: source files, repos, forum URLs, formula trigger

View File

@@ -15,6 +15,7 @@ class Mandate(Base):
description: Mapped[str | None] = mapped_column(Text)
mandate_type: Mapped[str] = mapped_column(String(64), nullable=False) # techcomm, smith, custom
status: Mapped[str] = mapped_column(String(32), default="draft") # draft, candidacy, voting, active, reporting, completed, revoked
organization_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("organizations.id"), nullable=True, index=True)
mandatee_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("duniter_identities.id"))
decision_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("decisions.id"))
starts_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))

View File

@@ -0,0 +1,42 @@
import uuid
from datetime import datetime
from sqlalchemy import String, Text, Boolean, DateTime, ForeignKey, Uuid, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Organization(Base):
__tablename__ = "organizations"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(128), nullable=False)
slug: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True)
# commune, enterprise, association, collective, basin, intercommunality, community
org_type: Mapped[str] = mapped_column(String(64), default="community")
# True = all authenticated users see & interact with content (Duniter G1, Axiom Team)
# False = membership required
is_transparent: Mapped[bool] = mapped_column(Boolean, default=False)
color: Mapped[str | None] = mapped_column(String(32)) # CSS color or mood token
icon: Mapped[str | None] = mapped_column(String(64)) # lucide icon name
description: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
members: Mapped[list["OrgMember"]] = relationship(
back_populates="organization", cascade="all, delete-orphan"
)
class OrgMember(Base):
__tablename__ = "org_members"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
org_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("organizations.id"), nullable=False)
identity_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("duniter_identities.id"), nullable=False
)
role: Mapped[str] = mapped_column(String(32), default="member") # admin, member, observer
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
organization: Mapped["Organization"] = relationship(back_populates="members")

View File

@@ -44,6 +44,7 @@ class VotingProtocol(Base):
description: Mapped[str | None] = mapped_column(Text)
vote_type: Mapped[str] = mapped_column(String(32), nullable=False) # binary, nuanced
formula_config_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("formula_configs.id"), nullable=False)
organization_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("organizations.id"), nullable=True, index=True)
mode_params: Mapped[str | None] = mapped_column(String(64)) # e.g. "D30M50B.1G.2T.1"
is_meta_governed: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

View File

@@ -132,9 +132,9 @@ async def verify_challenge(
detail="Challenge invalide",
)
# 4. Verify signature (bypass for demo profiles in DEMO_MODE)
# 4. Verify signature (bypass for demo profiles in dev/demo mode)
_demo_addresses = {p["address"] for p in DEV_PROFILES}
is_demo_bypass = settings.DEMO_MODE and payload.address in _demo_addresses
is_demo_bypass = (settings.DEMO_MODE or settings.ENVIRONMENT == "development") and payload.address in _demo_addresses
if not is_demo_bypass:
# polkadot.js / Cesium2 signRaw(type='bytes') wraps: <Bytes>{challenge}</Bytes>

View File

@@ -21,6 +21,7 @@ from app.schemas.decision import (
DecisionUpdate,
)
from app.schemas.vote import VoteSessionOut
from app.dependencies.org import get_active_org_id
from app.services.auth_service import get_current_identity
from app.services.decision_service import advance_decision, create_vote_session_for_step
@@ -49,6 +50,7 @@ async def _get_decision(db: AsyncSession, decision_id: uuid.UUID) -> Decision:
@router.get("/", response_model=list[DecisionOut])
async def list_decisions(
db: AsyncSession = Depends(get_db),
org_id: uuid.UUID | None = Depends(get_active_org_id),
decision_type: str | None = Query(default=None, description="Filtrer par type de decision"),
status_filter: str | None = Query(default=None, alias="status", description="Filtrer par statut"),
skip: int = Query(default=0, ge=0),
@@ -57,6 +59,8 @@ async def list_decisions(
"""List all decisions with optional filters."""
stmt = select(Decision).options(selectinload(Decision.steps))
if org_id is not None:
stmt = stmt.where(Decision.organization_id == org_id)
if decision_type is not None:
stmt = stmt.where(Decision.decision_type == decision_type)
if status_filter is not None:
@@ -74,11 +78,13 @@ async def create_decision(
payload: DecisionCreate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> DecisionOut:
"""Create a new decision process."""
decision = Decision(
**payload.model_dump(),
created_by_id=identity.id,
organization_id=org_id,
)
db.add(decision)
await db.commit()

View File

@@ -25,6 +25,7 @@ from app.schemas.document import (
ItemVersionCreate,
ItemVersionOut,
)
from app.dependencies.org import get_active_org_id
from app.services import document_service
from app.services.auth_service import get_current_identity
@@ -65,6 +66,7 @@ async def _get_item(db: AsyncSession, document_id: uuid.UUID, item_id: uuid.UUID
@router.get("/", response_model=list[DocumentOut])
async def list_documents(
db: AsyncSession = Depends(get_db),
org_id: uuid.UUID | None = Depends(get_active_org_id),
doc_type: str | None = Query(default=None, description="Filtrer par type de document"),
status_filter: str | None = Query(default=None, alias="status", description="Filtrer par statut"),
skip: int = Query(default=0, ge=0),
@@ -73,6 +75,8 @@ async def list_documents(
"""List all reference documents, with optional filters."""
stmt = select(Document)
if org_id is not None:
stmt = stmt.where(Document.organization_id == org_id)
if doc_type is not None:
stmt = stmt.where(Document.doc_type == doc_type)
if status_filter is not None:
@@ -101,6 +105,7 @@ async def create_document(
payload: DocumentCreate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> DocumentOut:
"""Create a new reference document."""
# Check slug uniqueness
@@ -111,7 +116,7 @@ async def create_document(
detail="Un document avec ce slug existe deja",
)
doc = Document(**payload.model_dump())
doc = Document(**payload.model_dump(), organization_id=org_id)
db.add(doc)
await db.commit()
await db.refresh(doc)

View File

@@ -22,6 +22,7 @@ from app.schemas.mandate import (
MandateUpdate,
)
from app.schemas.vote import VoteSessionOut
from app.dependencies.org import get_active_org_id
from app.services.auth_service import get_current_identity
from app.services.mandate_service import (
advance_mandate,
@@ -55,6 +56,7 @@ async def _get_mandate(db: AsyncSession, mandate_id: uuid.UUID) -> Mandate:
@router.get("/", response_model=list[MandateOut])
async def list_mandates(
db: AsyncSession = Depends(get_db),
org_id: uuid.UUID | None = Depends(get_active_org_id),
mandate_type: str | None = Query(default=None, description="Filtrer par type de mandat"),
status_filter: str | None = Query(default=None, alias="status", description="Filtrer par statut"),
skip: int = Query(default=0, ge=0),
@@ -63,6 +65,8 @@ async def list_mandates(
"""List all mandates with optional filters."""
stmt = select(Mandate).options(selectinload(Mandate.steps))
if org_id is not None:
stmt = stmt.where(Mandate.organization_id == org_id)
if mandate_type is not None:
stmt = stmt.where(Mandate.mandate_type == mandate_type)
if status_filter is not None:
@@ -80,9 +84,10 @@ async def create_mandate(
payload: MandateCreate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> MandateOut:
"""Create a new mandate."""
mandate = Mandate(**payload.model_dump())
mandate = Mandate(**payload.model_dump(), organization_id=org_id)
db.add(mandate)
await db.commit()
await db.refresh(mandate)

View File

@@ -0,0 +1,96 @@
"""Organizations router: list, create, membership."""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.user import DuniterIdentity
from app.schemas.organization import OrgMemberOut, OrganizationCreate, OrganizationOut
from app.services.auth_service import get_current_identity
from app.services.org_service import (
add_member,
create_organization,
get_organization,
get_organization_by_slug,
is_member,
list_members,
list_organizations,
)
router = APIRouter()
@router.get("/", response_model=list[OrganizationOut])
async def get_organizations(db: AsyncSession = Depends(get_db)) -> list[OrganizationOut]:
"""List all organizations (public — transparent ones need no auth)."""
orgs = await list_organizations(db)
return [OrganizationOut.model_validate(o) for o in orgs]
@router.post("/", response_model=OrganizationOut, status_code=status.HTTP_201_CREATED)
async def post_organization(
payload: OrganizationCreate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> OrganizationOut:
"""Create a new organization (authenticated users only)."""
existing = await get_organization_by_slug(db, payload.slug)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Slug '{payload.slug}' déjà utilisé",
)
org = await create_organization(db, **payload.model_dump())
# Creator becomes admin
await add_member(db, org.id, identity.id, role="admin")
return OrganizationOut.model_validate(org)
@router.get("/{org_id}", response_model=OrganizationOut)
async def get_organization_detail(
org_id: uuid.UUID, db: AsyncSession = Depends(get_db)
) -> OrganizationOut:
org = await get_organization(db, org_id)
if not org:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Organisation introuvable")
return OrganizationOut.model_validate(org)
@router.get("/{org_id}/members", response_model=list[OrgMemberOut])
async def get_members(
org_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> list[OrgMemberOut]:
org = await get_organization(db, org_id)
if not org:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Organisation introuvable")
if not org.is_transparent and not await is_member(db, org_id, identity.id):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Accès refusé")
members = await list_members(db, org_id)
return [OrgMemberOut.model_validate(m) for m in members]
@router.post("/{org_id}/join", response_model=OrgMemberOut, status_code=status.HTTP_201_CREATED)
async def join_organization(
org_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> OrgMemberOut:
"""Join a transparent organization."""
org = await get_organization(db, org_id)
if not org:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Organisation introuvable")
if not org.is_transparent:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Rejoindre cette organisation nécessite une invitation",
)
if await is_member(db, org_id, identity.id):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Déjà membre")
member = await add_member(db, org_id, identity.id)
return OrgMemberOut.model_validate(member)

View File

@@ -25,6 +25,7 @@ from app.schemas.protocol import (
VotingProtocolOut,
VotingProtocolUpdate,
)
from app.dependencies.org import get_active_org_id
from app.services.auth_service import get_current_identity
router = APIRouter()
@@ -63,6 +64,7 @@ async def _get_formula(db: AsyncSession, formula_id: uuid.UUID) -> FormulaConfig
@router.get("/", response_model=list[VotingProtocolOut])
async def list_protocols(
db: AsyncSession = Depends(get_db),
org_id: uuid.UUID | None = Depends(get_active_org_id),
vote_type: str | None = Query(default=None, description="Filtrer par type de vote (binary, nuanced)"),
skip: int = Query(default=0, ge=0),
limit: int = Query(default=50, ge=1, le=200),
@@ -70,6 +72,8 @@ async def list_protocols(
"""List all voting protocols with their formula configurations."""
stmt = select(VotingProtocol).options(selectinload(VotingProtocol.formula_config))
if org_id is not None:
stmt = stmt.where(VotingProtocol.organization_id == org_id)
if vote_type is not None:
stmt = stmt.where(VotingProtocol.vote_type == vote_type)
@@ -85,6 +89,7 @@ async def create_protocol(
payload: VotingProtocolCreate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> VotingProtocolOut:
"""Create a new voting protocol.
@@ -100,7 +105,7 @@ async def create_protocol(
detail="Configuration de formule introuvable",
)
protocol = VotingProtocol(**payload.model_dump())
protocol = VotingProtocol(**payload.model_dump(), organization_id=org_id)
db.add(protocol)
await db.commit()
await db.refresh(protocol)

View File

@@ -0,0 +1,42 @@
"""Pydantic v2 schemas for organizations."""
from __future__ import annotations
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class OrganizationOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
slug: str
org_type: str
is_transparent: bool
color: str | None
icon: str | None
description: str | None
created_at: datetime
class OrganizationCreate(BaseModel):
name: str
slug: str
org_type: str = "community"
is_transparent: bool = False
color: str | None = None
icon: str | None = None
description: str | None = None
class OrgMemberOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
org_id: uuid.UUID
identity_id: uuid.UUID
role: str
created_at: datetime

View File

@@ -0,0 +1,60 @@
"""Organization service: CRUD + membership helpers."""
from __future__ import annotations
import uuid
from typing import Sequence
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.organization import OrgMember, Organization
async def list_organizations(db: AsyncSession) -> Sequence[Organization]:
result = await db.execute(select(Organization).order_by(Organization.name))
return result.scalars().all()
async def get_organization(db: AsyncSession, org_id: uuid.UUID) -> Organization | None:
return await db.get(Organization, org_id)
async def get_organization_by_slug(db: AsyncSession, slug: str) -> Organization | None:
result = await db.execute(select(Organization).where(Organization.slug == slug))
return result.scalar_one_or_none()
async def create_organization(db: AsyncSession, **kwargs) -> Organization:
org = Organization(**kwargs)
db.add(org)
await db.commit()
await db.refresh(org)
return org
async def is_member(db: AsyncSession, org_id: uuid.UUID, identity_id: uuid.UUID) -> bool:
result = await db.execute(
select(OrgMember).where(
OrgMember.org_id == org_id,
OrgMember.identity_id == identity_id,
)
)
return result.scalar_one_or_none() is not None
async def add_member(
db: AsyncSession, org_id: uuid.UUID, identity_id: uuid.UUID, role: str = "member"
) -> OrgMember:
member = OrgMember(org_id=org_id, identity_id=identity_id, role=role)
db.add(member)
await db.commit()
await db.refresh(member)
return member
async def list_members(db: AsyncSession, org_id: uuid.UUID) -> Sequence[OrgMember]:
result = await db.execute(
select(OrgMember).where(OrgMember.org_id == org_id).order_by(OrgMember.created_at)
)
return result.scalars().all()

View File

@@ -32,6 +32,7 @@ from app.models.protocol import FormulaConfig, VotingProtocol
from app.models.document import Document, DocumentItem
from app.models.decision import Decision, DecisionStep
from app.models.mandate import Mandate, MandateStep
from app.models.organization import Organization
from app.models.user import DuniterIdentity
from app.models.vote import VoteSession, Vote
@@ -161,6 +162,7 @@ async def seed_formula_configs(session: AsyncSession) -> dict[str, FormulaConfig
async def seed_voting_protocols(
session: AsyncSession,
formulas: dict[str, FormulaConfig],
org_id: uuid.UUID | None = None,
) -> dict[str, VotingProtocol]:
protocols: dict[str, dict] = {
"Vote WoT standard": {
@@ -206,6 +208,7 @@ async def seed_voting_protocols(
instance, created = await get_or_create(
session, VotingProtocol, "name", name, **params,
)
instance.organization_id = org_id
status = "created" if created else "exists"
print(f" VotingProtocol '{name}': {status}")
result[name] = instance
@@ -829,6 +832,7 @@ ENGAGEMENT_CERTIFICATION_ITEMS: list[dict] = [
async def seed_document_engagement_certification(
session: AsyncSession,
protocols: dict[str, VotingProtocol],
org_id: uuid.UUID | None = None,
) -> Document:
genesis = json.dumps(GENESIS_CERTIFICATION, ensure_ascii=False, indent=2)
@@ -850,6 +854,7 @@ async def seed_document_engagement_certification(
),
genesis_json=genesis,
)
doc.organization_id = org_id
print(f" Document 'Acte d'engagement Certification': {'created' if created else 'exists'}")
if created:
@@ -1893,6 +1898,7 @@ ENGAGEMENT_FORGERON_ITEMS: list[dict] = [
async def seed_document_engagement_forgeron(
session: AsyncSession,
protocols: dict[str, VotingProtocol],
org_id: uuid.UUID | None = None,
) -> Document:
genesis = json.dumps(GENESIS_FORGERON, ensure_ascii=False, indent=2)
@@ -1916,6 +1922,7 @@ async def seed_document_engagement_forgeron(
),
genesis_json=genesis,
)
doc.organization_id = org_id
print(f" Document 'Acte d'engagement forgeron': {'created' if created else 'exists'}")
if created:
@@ -1988,7 +1995,7 @@ RUNTIME_UPGRADE_STEPS: list[dict] = [
]
async def seed_decision_runtime_upgrade(session: AsyncSession) -> Decision:
async def seed_decision_runtime_upgrade(session: AsyncSession, org_id: uuid.UUID | None = None) -> Decision:
decision, created = await get_or_create(
session,
Decision,
@@ -2009,6 +2016,7 @@ async def seed_decision_runtime_upgrade(session: AsyncSession) -> Decision:
decision_type="runtime_upgrade",
status="draft",
)
decision.organization_id = org_id
print(f" Decision 'Runtime Upgrade': {'created' if created else 'exists'}")
if created:
@@ -2148,7 +2156,7 @@ async def seed_votes_on_items(
# Seed: Additional decisions (demo content)
# ---------------------------------------------------------------------------
async def seed_decision_licence_evolution(session: AsyncSession) -> Decision:
async def seed_decision_licence_evolution(session: AsyncSession, org_id: uuid.UUID | None = None) -> Decision:
"""Seed a community decision: evolution of the G1 monetary license."""
decision, created = await get_or_create(
session,
@@ -2170,6 +2178,7 @@ async def seed_decision_licence_evolution(session: AsyncSession) -> Decision:
decision_type="community",
status="draft",
)
decision.organization_id = org_id
print(f" Decision 'Évolution Licence G1 v0.4.0': {'created' if created else 'exists'}")
if created:
@@ -2225,7 +2234,7 @@ async def seed_decision_licence_evolution(session: AsyncSession) -> Decision:
# Seed: Mandates (Comité Technique + Admin Forgerons)
# ---------------------------------------------------------------------------
async def seed_mandates(session: AsyncSession, voters: list[DuniterIdentity]) -> None:
async def seed_mandates(session: AsyncSession, voters: list[DuniterIdentity], org_id: uuid.UUID | None = None) -> None:
"""Seed example mandates: TechComm and Smith Admin."""
now = datetime.now(timezone.utc)
@@ -2397,6 +2406,7 @@ async def seed_mandates(session: AsyncSession, voters: list[DuniterIdentity]) ->
m_data["title"],
**{k: v for k, v in m_data.items() if k != "title"},
)
mandate.organization_id = org_id
status_str = "created" if created else "exists"
print(f" Mandate '{mandate.title[:50]}': {status_str}")
@@ -2408,6 +2418,43 @@ async def seed_mandates(session: AsyncSession, voters: list[DuniterIdentity]) ->
print(f" -> {len(steps_data)} steps created")
# ---------------------------------------------------------------------------
# Seed: Organizations
# ---------------------------------------------------------------------------
async def seed_organizations(session: AsyncSession) -> dict[str, Organization]:
"""Create the two base transparent organizations (idempotent)."""
orgs_data = [
{
"slug": "duniter-g1",
"name": "Duniter G1",
"org_type": "community",
"is_transparent": True,
"color": "#22c55e",
"icon": "i-lucide-globe",
"description": "Communauté Duniter — monnaie libre G1. Accessible à tous les membres authentifiés.",
},
{
"slug": "axiom-team",
"name": "Axiom Team",
"org_type": "collective",
"is_transparent": True,
"color": "#3b82f6",
"icon": "i-lucide-users",
"description": "Équipe Axiom — développement et gouvernance des outils communs.",
},
]
orgs: dict[str, Organization] = {}
for data in orgs_data:
org, created = await get_or_create(session, Organization, "slug", data["slug"], **{k: v for k, v in data.items() if k != "slug"})
status_str = "created" if created else "exists"
print(f" Organisation '{org.name}': {status_str}")
orgs[org.slug] = org
return orgs
# ---------------------------------------------------------------------------
# Main seed runner
# ---------------------------------------------------------------------------
@@ -2423,23 +2470,27 @@ async def run_seed():
async with async_session() as session:
async with session.begin():
print("\n[0/10] Organizations...")
orgs = await seed_organizations(session)
duniter_g1_id = orgs["duniter-g1"].id
print("\n[1/10] Formula Configs...")
formulas = await seed_formula_configs(session)
print("\n[2/10] Voting Protocols...")
protocols = await seed_voting_protocols(session, formulas)
protocols = await seed_voting_protocols(session, formulas, org_id=duniter_g1_id)
print("\n[3/10] Document: Acte d'engagement Certification...")
await seed_document_engagement_certification(session, protocols)
await seed_document_engagement_certification(session, protocols, org_id=duniter_g1_id)
print("\n[4/10] Document: Acte d'engagement forgeron v2.0.0...")
doc_forgeron = await seed_document_engagement_forgeron(session, protocols)
doc_forgeron = await seed_document_engagement_forgeron(session, protocols, org_id=duniter_g1_id)
print("\n[5/10] Decision: Runtime Upgrade...")
await seed_decision_runtime_upgrade(session)
await seed_decision_runtime_upgrade(session, org_id=duniter_g1_id)
print("\n[6/10] Decision: Évolution Licence G1 v0.4.0...")
await seed_decision_licence_evolution(session)
await seed_decision_licence_evolution(session, org_id=duniter_g1_id)
print("\n[7/10] Simulated voters...")
voters = await seed_voters(session)
@@ -2453,7 +2504,7 @@ async def run_seed():
)
print("\n[9/10] Mandates...")
await seed_mandates(session, voters)
await seed_mandates(session, voters, org_id=duniter_g1_id)
print("\n[10/10] Done.")

View File

@@ -1,24 +1,20 @@
<script setup lang="ts">
const auth = useAuthStore()
const orgsStore = useOrganizationsStore()
const route = useRoute()
const { initMood } = useMood()
const navigationItems = [
{
label: 'Boîte à outils',
icon: 'i-lucide-wrench',
to: '/tools',
label: 'Décisions',
icon: 'i-lucide-scale',
to: '/decisions',
},
{
label: 'Documents',
icon: 'i-lucide-book-open',
to: '/documents',
},
{
label: 'Decisions',
icon: 'i-lucide-scale',
to: '/decisions',
},
{
label: 'Mandats',
icon: 'i-lucide-user-check',
@@ -29,6 +25,11 @@ const navigationItems = [
icon: 'i-lucide-settings',
to: '/protocols',
},
{
label: 'Outils',
icon: 'i-lucide-wrench',
to: '/tools',
},
{
label: 'Sanctuaire',
icon: 'i-lucide-archive',
@@ -63,12 +64,18 @@ onMounted(async () => {
if (auth.token) {
try {
await auth.fetchMe()
} catch {
auth.logout()
} catch (err: any) {
// Déconnexion seulement sur session réellement invalide (401/403)
// Erreur réseau ou backend temporairement indisponible → conserver la session
if (err?.status === 401 || err?.status === 403) {
auth.logout()
}
}
}
ws.connect()
setupWsNotifications(ws)
// Load organizations in parallel — non-blocking, no auth required
orgsStore.fetchOrganizations()
})
onUnmounted(() => {

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 {

View File

@@ -73,6 +73,7 @@ function isRetryable(status: number): boolean {
export function useApi() {
const config = useRuntimeConfig()
const auth = useAuthStore()
const orgsStore = useOrganizationsStore()
/**
* Perform a typed fetch against the backend API.
@@ -94,6 +95,9 @@ export function useApi() {
if (auth.token) {
headers.Authorization = `Bearer ${auth.token}`
}
if (orgsStore.activeSlug) {
headers['X-Organization'] = orgsStore.activeSlug
}
const maxAttempts = noRetry ? 1 : MAX_RETRIES
let lastError: any = null

View File

@@ -2,6 +2,14 @@
import type { DecisionCreate } from '~/stores/decisions'
const decisions = useDecisionsStore()
const protocols = useProtocolsStore()
type Nature = 'individual' | 'collective' | 'onchain'
const step = ref<1 | 2>(1)
const nature = ref<Nature | null>(null)
const submitting = ref(false)
const openMandate = ref(false)
const formData = ref<DecisionCreate>({
title: '',
@@ -11,62 +19,528 @@ const formData = ref<DecisionCreate>({
voting_protocol_id: null,
})
const submitting = ref(false)
interface NatureOption {
key: Nature
icon: string
title: string
desc: string
color: string
}
const choiceOptions: NatureOption[] = [
{
key: 'individual',
icon: 'i-lucide-user',
title: 'Décision individuelle',
desc: 'Je dois décider seul·e, après avoir consulté et consigné des avis',
color: 'var(--mood-secondary, var(--mood-accent))',
},
{
key: 'collective',
icon: 'i-lucide-users',
title: 'Décision collective',
desc: 'Le collectif tranche ensemble — vote WoT, nuancé ou par protocole',
color: 'var(--mood-accent)',
},
{
key: 'onchain',
icon: 'i-lucide-cpu',
title: 'Exception on-chain',
desc: 'Décision inscrite sur la blockchain Duniter (Runtime Upgrade, etc.)',
color: 'var(--mood-success)',
},
]
const selectedOption = computed(() => choiceOptions.find(o => o.key === nature.value))
onMounted(() => protocols.fetchProtocols())
function selectNature(n: Nature) {
nature.value = n
formData.value.decision_type = n === 'onchain' ? 'runtime_upgrade' : 'other'
step.value = 2
}
function goBack() {
step.value = 1
nature.value = null
formData.value = { title: '', description: '', context: '', decision_type: 'other', voting_protocol_id: null }
openMandate.value = false
}
async function onSubmit() {
if (!formData.value.title.trim()) return
submitting.value = true
try {
const decision = await decisions.create(formData.value)
const contextPrefix = nature.value === 'individual' ? '[Consultation individuelle]\n' : ''
const decision = await decisions.create({
...formData.value,
context: contextPrefix + (formData.value.context ?? ''),
})
if (decision) {
navigateTo(`/decisions/${decision.id}`)
if (openMandate.value && nature.value === 'individual') {
navigateTo('/mandates')
}
else {
navigateTo(`/decisions/${decision.id}`)
}
}
} catch {
// Error is handled by the store
} finally {
}
catch {
// handled by store
}
finally {
submitting.value = false
}
}
</script>
<template>
<div class="space-y-6">
<!-- Back link -->
<div>
<UButton
to="/decisions"
variant="ghost"
color="neutral"
icon="i-lucide-arrow-left"
label="Retour aux decisions"
size="sm"
/>
<div class="decide">
<!-- Nav -->
<div class="decide__nav">
<button v-if="step === 2" class="decide__back-btn" @click="goBack">
<UIcon name="i-lucide-arrow-left" class="text-sm" />
<span>Retour</span>
</button>
<NuxtLink v-else to="/decisions" class="decide__back-btn">
<UIcon name="i-lucide-arrow-left" class="text-sm" />
<span>Décisions</span>
</NuxtLink>
</div>
<!-- Header -->
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
Nouvelle decision
</h1>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
Cadrage d'un nouveau processus de decision collectif
</p>
</div>
<Transition name="slide-fade" mode="out-in">
<!-- Step 1 : Qualifier -->
<div v-if="step === 1" key="step1" class="decide__step">
<div class="decide__header">
<h1 class="decide__title">Quelle décision prendre ?</h1>
<p class="decide__subtitle">Qualifiez d'abord la nature de votre décision — le parcours s'adapte</p>
</div>
<!-- Error -->
<UCard v-if="decisions.error">
<div class="flex items-center gap-3 text-red-500">
<UIcon name="i-lucide-alert-circle" class="text-xl" />
<p>{{ decisions.error }}</p>
<div class="decide__choices">
<button
v-for="option in choiceOptions"
:key="option.key"
class="decide__choice"
:style="{ '--choice-color': option.color }"
@click="selectNature(option.key)"
>
<div class="decide__choice-icon">
<UIcon :name="option.icon" class="text-xl" />
</div>
<div class="decide__choice-body">
<span class="decide__choice-title">{{ option.title }}</span>
<span class="decide__choice-desc">{{ option.desc }}</span>
</div>
<UIcon name="i-lucide-chevron-right" class="decide__choice-arrow" />
</button>
</div>
<p class="decide__footer-note">
<UIcon name="i-lucide-info" class="text-xs" />
Les décisions on-chain sont des exceptions elles concernent uniquement les opérations blockchain Duniter.
</p>
</div>
</UCard>
<!-- Form -->
<UCard>
<DecisionCadrage
v-model="formData"
:submitting="submitting"
@submit="onSubmit"
/>
</UCard>
<!-- Step 2 : Form -->
<div v-else-if="step === 2 && selectedOption" key="step2" class="decide__step">
<div class="decide__nature-badge" :style="{ '--badge-color': selectedOption.color }">
<UIcon :name="selectedOption.icon" class="text-sm" />
<span>{{ selectedOption.title }}</span>
</div>
<div class="decide__header">
<h1 class="decide__title">
{{ nature === 'individual' ? 'Consultation d\'avis' : nature === 'onchain' ? 'Décision on-chain' : 'Décision collective' }}
</h1>
<p class="decide__subtitle">
<template v-if="nature === 'individual'">
Les avis seront consignés. Vous décidez, informé·e des consultations.
</template>
<template v-else-if="nature === 'onchain'">
Exception réservée aux opérations blockchain. Voir le protocole
<NuxtLink to="/protocols" class="decide__link">Runtime Upgrade</NuxtLink>.
</template>
<template v-else>
Le collectif vote selon le protocole choisi. Seuil adaptatif par la WoT.
</template>
</p>
</div>
<div v-if="decisions.error" class="decide__error">
<UIcon name="i-lucide-alert-circle" />
<span>{{ decisions.error }}</span>
</div>
<div class="decide__fields">
<div class="decide__field">
<label class="decide__label">
{{ nature === 'individual' ? 'Question à consulter' : 'Titre de la décision' }}
<span class="decide__required">*</span>
</label>
<input
v-model="formData.title"
type="text"
class="decide__input"
:placeholder="nature === 'individual' ? 'Ex : Faut-il migrer vers PostgreSQL ?' : 'Ex : Adoption de la v2.0 du protocole…'"
autofocus
/>
</div>
<div class="decide__field">
<label class="decide__label">Description</label>
<textarea
v-model="formData.description"
class="decide__textarea"
rows="3"
:placeholder="nature === 'individual' ? 'Décrivez la question, les options envisagées…' : 'Décrivez l\'objet, les enjeux, les alternatives…'"
/>
</div>
<div class="decide__field">
<label class="decide__label">Contexte et références</label>
<textarea
v-model="formData.context"
class="decide__textarea"
rows="2"
placeholder="Liens forum, documents de référence, historique…"
/>
</div>
<!-- Collective: protocol -->
<div v-if="nature === 'collective'" class="decide__field">
<label class="decide__label">Protocole de vote</label>
<ProtocolPicker
:model-value="formData.voting_protocol_id ?? null"
@update:model-value="formData.voting_protocol_id = $event"
/>
<p class="decide__hint">Optionnel peut être défini à chaque étape</p>
</div>
<!-- Individual: mandate option -->
<label v-if="nature === 'individual'" class="decide__mandate-toggle">
<input v-model="openMandate" type="checkbox" class="decide__mandate-check" />
<div class="decide__mandate-body">
<span class="decide__mandate-label">Ouvrir un mandat en parallèle</span>
<span class="decide__mandate-hint">Si la consultation mène à déléguer une mission</span>
</div>
</label>
</div>
<div class="decide__actions">
<button class="decide__cancel" @click="goBack">
Annuler
</button>
<button
class="decide__submit"
:disabled="!formData.title.trim() || submitting"
:style="{ '--submit-color': selectedOption.color }"
@click="onSubmit"
>
<UIcon v-if="submitting" name="i-lucide-loader-2" class="animate-spin" />
<UIcon v-else name="i-lucide-plus" />
<span>{{ nature === 'individual' ? 'Lancer la consultation' : 'Créer la décision' }}</span>
</button>
</div>
</div>
</Transition>
</div>
</template>
<style scoped>
.decide {
display: flex;
flex-direction: column;
gap: 1.5rem;
max-width: 42rem;
margin: 0 auto;
}
/* Nav */
.decide__nav { margin-bottom: -0.5rem; }
.decide__back-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-text-muted);
background: none;
border-radius: 10px;
cursor: pointer;
text-decoration: none;
transition: color 0.12s, background 0.12s;
}
.decide__back-btn:hover {
color: var(--mood-text);
background: var(--mood-accent-soft);
}
/* Step container */
.decide__step { display: flex; flex-direction: column; gap: 1.5rem; }
/* Header */
.decide__header { display: flex; flex-direction: column; gap: 0.25rem; }
.decide__title {
font-size: 1.5rem;
font-weight: 800;
color: var(--mood-text);
letter-spacing: -0.02em;
margin: 0;
}
@media (min-width: 640px) {
.decide__title { font-size: 1.875rem; }
}
.decide__subtitle {
font-size: 0.9375rem;
color: var(--mood-text-muted);
font-weight: 500;
margin: 0;
line-height: 1.5;
}
/* Nature badge */
.decide__nature-badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.875rem;
font-size: 0.8125rem;
font-weight: 700;
color: var(--badge-color, var(--mood-accent));
background: color-mix(in srgb, var(--badge-color, var(--mood-accent)) 12%, transparent);
border-radius: 20px;
width: fit-content;
}
/* Choices (Step 1) */
.decide__choices { display: flex; flex-direction: column; gap: 0.75rem; }
.decide__choice {
display: flex;
align-items: center;
gap: 1rem;
padding: 1.25rem;
background: var(--mood-surface);
border-radius: 16px;
cursor: pointer;
text-align: left;
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.decide__choice:hover {
transform: translateY(-3px);
box-shadow: 0 8px 24px var(--mood-shadow);
}
.decide__choice:active { transform: translateY(0); }
.decide__choice-icon {
width: 3rem;
height: 3rem;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 14px;
background: color-mix(in srgb, var(--choice-color) 12%, transparent);
color: var(--choice-color);
}
.decide__choice-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.decide__choice-title {
font-size: 1.0625rem;
font-weight: 800;
color: var(--mood-text);
}
.decide__choice-desc {
font-size: 0.8125rem;
color: var(--mood-text-muted);
line-height: 1.4;
}
.decide__choice-arrow {
flex-shrink: 0;
color: var(--mood-text-muted);
opacity: 0.3;
transition: all 0.12s;
}
.decide__choice:hover .decide__choice-arrow {
opacity: 1;
color: var(--choice-color);
transform: translateX(3px);
}
.decide__footer-note {
display: flex;
align-items: flex-start;
gap: 0.375rem;
font-size: 0.75rem;
color: var(--mood-text-muted);
line-height: 1.5;
margin: 0;
}
/* Form (Step 2) */
.decide__error {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.875rem 1rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-error, #c42b2b);
background: color-mix(in srgb, #c42b2b 8%, transparent);
border-radius: 12px;
}
.decide__fields { display: flex; flex-direction: column; gap: 1rem; }
.decide__field { display: flex; flex-direction: column; gap: 0.375rem; }
.decide__label {
font-size: 0.8125rem;
font-weight: 700;
color: var(--mood-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.decide__required { color: var(--mood-error, #c42b2b); }
.decide__input,
.decide__textarea {
width: 100%;
padding: 0.75rem 1rem;
font-size: 0.9375rem;
color: var(--mood-text);
background: var(--mood-surface);
border-radius: 12px;
outline: none;
font-family: inherit;
resize: vertical;
transition: box-shadow 0.15s ease;
}
.decide__input:focus,
.decide__textarea:focus {
box-shadow: 0 0 0 3px var(--mood-accent-soft);
}
.decide__hint {
font-size: 0.75rem;
color: var(--mood-text-muted);
margin: 0;
}
.decide__link {
color: var(--mood-accent);
text-decoration: underline;
text-underline-offset: 2px;
}
/* Mandate toggle */
.decide__mandate-toggle {
display: flex;
align-items: flex-start;
gap: 0.875rem;
padding: 1rem;
background: var(--mood-surface);
border-radius: 14px;
cursor: pointer;
}
.decide__mandate-check {
margin-top: 0.125rem;
width: 1rem;
height: 1rem;
flex-shrink: 0;
accent-color: var(--mood-accent);
cursor: pointer;
}
.decide__mandate-body { display: flex; flex-direction: column; gap: 0.125rem; }
.decide__mandate-label {
font-size: 0.9375rem;
font-weight: 700;
color: var(--mood-text);
}
.decide__mandate-hint {
font-size: 0.8125rem;
color: var(--mood-text-muted);
}
/* Actions */
.decide__actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.75rem;
padding-top: 0.25rem;
}
.decide__cancel {
padding: 0.625rem 1.25rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-text-muted);
background: none;
border-radius: 12px;
cursor: pointer;
transition: color 0.12s, background 0.12s;
}
.decide__cancel:hover {
color: var(--mood-text);
background: var(--mood-accent-soft);
}
.decide__submit {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.75rem;
font-size: 0.9375rem;
font-weight: 700;
color: var(--mood-accent-text);
background: var(--submit-color, var(--mood-accent));
border-radius: 20px;
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease;
min-height: 2.75rem;
}
.decide__submit:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 16px var(--mood-shadow);
}
.decide__submit:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Transition */
.slide-fade-enter-active,
.slide-fade-leave-active {
transition: all 0.2s ease;
}
.slide-fade-enter-from {
opacity: 0;
transform: translateX(12px);
}
.slide-fade-leave-to {
opacity: 0;
transform: translateX(-12px);
}
</style>

View File

@@ -116,6 +116,22 @@ function formatDate(dateStr: string): string {
</p>
</div>
<!-- Décider CTA -->
<NuxtLink to="/decisions/new" class="dash__decide">
<div class="dash__decide-left">
<div class="dash__decide-icon">
<UIcon name="i-lucide-scale" class="text-xl" />
</div>
<div class="dash__decide-text">
<span class="dash__decide-label">Prendre une décision</span>
<span class="dash__decide-sub">Individuelle · collective · déléguée le parcours s'adapte</span>
</div>
</div>
<div class="dash__decide-arrow">
<UIcon name="i-lucide-arrow-right" class="text-base" />
</div>
</NuxtLink>
<!-- Entry cards -->
<div class="dash__entries">
<template v-if="loading">
@@ -177,7 +193,7 @@ function formatDate(dateStr: string): string {
<span class="dash__toolbox-card-tag">Vote WoT</span>
<span class="dash__toolbox-card-tag">Inertie</span>
<span class="dash__toolbox-card-tag">Smith</span>
<span class="dash__toolbox-card-tag">Nuancé</span>
<span class="dash__toolbox-card-tag">Élection</span>
</div>
</div>
<UIcon name="i-lucide-arrow-right" class="dash__toolbox-card-arrow" />
@@ -292,6 +308,87 @@ function formatDate(dateStr: string): string {
}
}
/* --- Décider CTA --- */
.dash__decide {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1.25rem;
background: var(--mood-accent);
border-radius: 16px;
text-decoration: none;
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.dash__decide:hover {
transform: translateY(-2px);
box-shadow: 0 8px 28px var(--mood-shadow);
}
.dash__decide:active { transform: translateY(0); }
.dash__decide-left {
display: flex;
align-items: center;
gap: 0.875rem;
min-width: 0;
}
.dash__decide-icon {
width: 2.75rem;
height: 2.75rem;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 14px;
background: rgba(255,255,255,0.18);
color: var(--mood-accent-text);
}
.dash__decide-text {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.dash__decide-label {
font-size: 1rem;
font-weight: 800;
color: var(--mood-accent-text);
letter-spacing: -0.01em;
}
@media (min-width: 640px) {
.dash__decide-label { font-size: 1.125rem; }
}
.dash__decide-sub {
font-size: 0.75rem;
color: rgba(255,255,255,0.75);
font-weight: 500;
}
@media (min-width: 640px) {
.dash__decide-sub { font-size: 0.8125rem; }
}
.dash__decide-arrow {
flex-shrink: 0;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(255,255,255,0.18);
color: var(--mood-accent-text);
transition: transform 0.12s;
}
.dash__decide:hover .dash__decide-arrow {
transform: translateX(3px);
}
/* --- Entry cards --- */
.dash__entries {
display: grid;

View File

@@ -49,7 +49,8 @@ async function loginAsProfile(p: DevProfile) {
try {
step.value = 'signing'
await auth.login(p.address)
// Dev mode: bypass extension — backend accepte toute signature pour les profils dev
await auth.login(p.address, () => Promise.resolve('0x' + 'a'.repeat(128)))
step.value = 'success'
setTimeout(() => router.push('/'), 800)
} catch (err: any) {

View File

@@ -136,24 +136,6 @@ interface OperationalProtocol {
}
const operationalProtocols: OperationalProtocol[] = [
{
slug: 'election-sociocratique',
name: 'Élection sociocratique',
description: 'Processus d\'élection d\'un rôle par consentement : clarification du rôle, nominations silencieuses, argumentaire, levée d\'objections. Garantit légitimité et clarté.',
category: 'gouvernance',
icon: 'i-lucide-users',
instancesLabel: 'Tout renouvellement de rôle',
linkedRefs: [
{ label: 'Mandats', icon: 'i-lucide-user-check', to: '/mandates', kind: 'decision' },
],
steps: [
{ label: 'Clarifier le rôle', actor: 'Cercle', icon: 'i-lucide-clipboard-list', type: 'checklist' },
{ label: 'Nominations silencieuses', actor: 'Tous les membres', icon: 'i-lucide-pencil', type: 'checklist' },
{ label: 'Recueil & argumentaire', actor: 'Facilitateur', icon: 'i-lucide-list-checks', type: 'checklist' },
{ label: 'Objections & consentement', actor: 'Cercle', icon: 'i-lucide-shield-check', type: 'certification' },
{ label: 'Proclamation', actor: 'Facilitateur', icon: 'i-lucide-star', type: 'on_chain' },
],
},
{
slug: 'embarquement-forgeron',
name: 'Embarquement Forgeron',

View File

@@ -20,6 +20,8 @@ interface ToolSection {
tools: Tool[]
}
const expandSocio = ref(false)
const sections: ToolSection[] = [
{
key: 'documents',
@@ -149,6 +151,29 @@ const sections: ToolSection[] = [
</div>
</div>
</div>
<!-- Election sociocratique modalité d'élection, accessible depuis mandats -->
<div v-if="section.key === 'mandats'" class="socio-expand">
<button class="socio-expand__trigger" @click="expandSocio = !expandSocio">
<div class="socio-expand__icon">
<UIcon name="i-lucide-users" class="text-sm" />
</div>
<div class="socio-expand__info">
<span class="socio-expand__title">Élection sociocratique</span>
<span class="socio-expand__meta">6 étapes · clarification · consentement collectif</span>
</div>
<span class="socio-expand__tag">Modalité d'élection</span>
<UIcon
:name="expandSocio ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
class="socio-expand__toggle"
/>
</button>
<Transition name="socio-expand">
<div v-if="expandSocio" class="socio-expand__content">
<SocioElection />
</div>
</Transition>
</div>
</div>
</div>
</div>
@@ -329,4 +354,99 @@ const sections: ToolSection[] = [
opacity: 1;
color: var(--section-color);
}
/* --- Élection sociocratique expandable --- */
.socio-expand {
margin-top: 0.5rem;
border-radius: 16px;
overflow: hidden;
background: var(--mood-surface);
}
.socio-expand__trigger {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 1rem 1.125rem;
cursor: pointer;
background: none;
text-align: left;
transition: background 0.12s;
}
.socio-expand__trigger:hover { background: var(--mood-accent-soft); }
.socio-expand__icon {
width: 2rem;
height: 2rem;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 10px;
background: color-mix(in srgb, var(--mood-success) 12%, transparent);
color: var(--mood-success);
}
.socio-expand__info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.0625rem;
}
.socio-expand__title {
font-size: 0.9375rem;
font-weight: 700;
color: var(--mood-text);
}
.socio-expand__meta {
font-size: 0.75rem;
color: var(--mood-text-muted);
}
.socio-expand__tag {
font-size: 0.625rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 3px 8px;
border-radius: 20px;
background: color-mix(in srgb, var(--mood-success) 12%, transparent);
color: var(--mood-success);
white-space: nowrap;
flex-shrink: 0;
}
@media (max-width: 480px) {
.socio-expand__tag { display: none; }
}
.socio-expand__toggle {
flex-shrink: 0;
color: var(--mood-text-muted);
font-size: 0.875rem;
}
.socio-expand__content {
padding: 0 1rem 1rem;
}
.socio-expand-enter-active,
.socio-expand-leave-active {
transition: all 0.25s ease;
overflow: hidden;
}
.socio-expand-enter-from,
.socio-expand-leave-to {
max-height: 0;
opacity: 0;
}
.socio-expand-enter-to,
.socio-expand-leave-from {
max-height: 2000px;
opacity: 1;
}
</style>

View File

@@ -149,10 +149,15 @@ export const useAuthStore = defineStore('auth', {
const identity = await $api<DuniterIdentity>('/auth/me')
this.identity = identity
} catch (err: any) {
this.error = err?.data?.detail || err?.message || 'Session invalide'
this.token = null
this.identity = null
this._clearToken()
const status = (err as any)?.status ?? 0
this.error = err?.message || 'Session invalide'
// N'effacer le token que sur 401/403 (session réellement invalide)
// Les erreurs réseau ou 5xx sont transitoires — conserver la session
if (status === 401 || status === 403) {
this.token = null
this.identity = null
this._clearToken()
}
throw err
} finally {
this.loading = false

View File

@@ -0,0 +1,71 @@
export interface Organization {
id: string
name: string
slug: string
org_type: string
is_transparent: boolean
color: string | null
icon: string | null
description: string | null
created_at: string
}
interface OrgState {
organizations: Organization[]
activeSlug: string | null
loading: boolean
error: string | null
}
export const useOrganizationsStore = defineStore('organizations', {
state: (): OrgState => ({
organizations: [],
activeSlug: null,
loading: false,
error: null,
}),
getters: {
active: (state): Organization | null =>
state.organizations.find(o => o.slug === state.activeSlug) ?? state.organizations[0] ?? null,
hasOrganizations: (state): boolean => state.organizations.length > 0,
},
actions: {
async fetchOrganizations() {
this.loading = true
this.error = null
try {
const { $api } = useApi()
const orgs = await $api<Organization[]>('/organizations/')
// Duniter G1 first, then alphabetical
this.organizations = orgs.sort((a, b) => {
if (a.slug === 'duniter-g1') return -1
if (b.slug === 'duniter-g1') return 1
return a.name.localeCompare(b.name)
})
// Restore persisted active slug, or default to first org
const stored = import.meta.client ? localStorage.getItem('libredecision_org') : null
if (stored && this.organizations.some(o => o.slug === stored)) {
this.activeSlug = stored
} else if (this.organizations.length > 0) {
this.activeSlug = this.organizations[0].slug
}
} catch (err: any) {
this.error = err?.message || 'Erreur lors du chargement des organisations'
} finally {
this.loading = false
}
},
setActive(slug: string) {
if (this.organizations.some(o => o.slug === slug)) {
this.activeSlug = slug
if (import.meta.client) {
localStorage.setItem('libredecision_org', slug)
}
}
},
},
})