feat: Add password reset functionality with request and reset forms

feat: Implement price list import feature with preview and apply options

feat: Create price rules management page with CRUD operations

feat: Develop quotes management page with itemized quotes and status tracking

feat: Introduce organization registration page for new users

feat: Build suppliers management page with detailed supplier information

feat: Create users management page for inviting and managing roles

chore: Add TypeScript configuration for improved type checking

chore: Set up Vite configuration for development server and API proxy

chore: Add Vite environment type definitions for better TypeScript support
This commit is contained in:
Torsten Schulz (local)
2026-06-02 15:28:38 +02:00
commit 0e539710c0
95 changed files with 31882 additions and 0 deletions

6903
backend/src/api.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,54 @@
use base64::{engine::general_purpose::STANDARD, Engine};
use companytool_shared_protocol::crypto::SessionKey;
use serde::{de::DeserializeOwned, Serialize};
use tracing::warn;
#[derive(Clone)]
pub struct DataCrypto {
key: SessionKey,
}
pub struct EncryptedField {
pub ciphertext: Vec<u8>,
pub nonce: Vec<u8>,
pub key_id: String,
}
impl DataCrypto {
pub fn from_env() -> Self {
let key_id = std::env::var("COMPANYTOOL_DATA_KEY_ID")
.unwrap_or_else(|_| "dev-data-key-v1".to_string());
let key_base64 = std::env::var("COMPANYTOOL_DATA_KEY_BASE64").unwrap_or_else(|_| {
warn!("COMPANYTOOL_DATA_KEY_BASE64 fehlt; verwende nur für Entwicklung geeigneten festen Schlüssel");
STANDARD.encode([7_u8; 32])
});
let key = SessionKey::from_base64(key_id, &key_base64).expect("valid data encryption key");
Self { key }
}
pub fn encrypt<T: Serialize>(&self, value: &T) -> anyhow::Result<EncryptedField> {
let envelope = self.key.encrypt(value)?;
Ok(EncryptedField {
ciphertext: STANDARD.decode(envelope.ciphertext)?,
nonce: STANDARD.decode(envelope.nonce)?,
key_id: envelope.key_id,
})
}
pub fn decrypt<T: DeserializeOwned>(
&self,
ciphertext: &[u8],
nonce: &[u8],
key_id: &str,
) -> anyhow::Result<T> {
let envelope = companytool_shared_protocol::EncryptedEnvelope {
enc: "aes-256-gcm-v1".to_string(),
key_id: key_id.to_string(),
nonce: STANDARD.encode(nonce),
ciphertext: STANDARD.encode(ciphertext),
};
Ok(self.key.decrypt(&envelope)?)
}
}

472
backend/src/main.rs Normal file
View File

@@ -0,0 +1,472 @@
mod api;
mod crypto_at_rest;
mod models;
use std::{env, net::SocketAddr};
use anyhow::Context;
use axum::{
extract::{
ws::{Message, WebSocket, WebSocketUpgrade},
State,
},
response::IntoResponse,
routing::{get, patch, post},
Json, Router,
};
use chrono::Utc;
use companytool_shared_protocol::{
crypto::SessionKey, ClientMessage, HelloAckMessage, ProtocolErrorMessage, RecordSummary,
ServerMessage, WireMessage, PROTOCOL_VERSION,
};
use crypto_at_rest::DataCrypto;
use futures_util::{SinkExt, StreamExt};
use sqlx::{PgPool, Row};
use tokio::sync::broadcast;
use tower_http::{cors::CorsLayer, trace::TraceLayer};
use tracing::{info, warn};
use uuid::Uuid;
#[derive(Clone)]
struct AppState {
db: Option<PgPool>,
crypto: DataCrypto,
events: broadcast::Sender<ServerMessage>,
}
impl AppState {
fn db(&self) -> Result<&PgPool, api::ApiError> {
self.db
.as_ref()
.ok_or_else(|| api::ApiError::bad_request("Datenbank ist im Testmodus nicht aktiv"))
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
dotenvy::dotenv().ok();
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
let communication_test_mode = env::var("COMMUNICATION_TEST_MODE").as_deref() == Ok("1");
let bind: SocketAddr = env::var("BACKEND_BIND")
.unwrap_or_else(|_| "127.0.0.1:8080".to_string())
.parse()
.context("BACKEND_BIND ist keine gültige Adresse")?;
let db = if communication_test_mode {
warn!("COMMUNICATION_TEST_MODE aktiv: backend startet ohne datenbank");
None
} else {
let database_url =
env::var("DATABASE_URL").context("DATABASE_URL fehlt, siehe .env.example")?;
let db = PgPool::connect(&database_url).await?;
migrate(&db).await?;
Some(db)
};
let (events, _) = broadcast::channel(256);
let state = AppState {
db,
crypto: DataCrypto::from_env(),
events,
};
let app = Router::new()
.route("/health", get(health))
.route("/ws", get(ws_handler))
.route(
"/api/v1/dev/bootstrap-local",
post(api::dev_bootstrap_local),
)
.route(
"/api/v1/registration/organization",
post(api::register_organization),
)
.route(
"/api/v1/admin/organization-registrations",
get(api::list_organization_registrations),
)
.route(
"/api/v1/admin/organization-registrations/:id",
get(api::get_organization_registration),
)
.route(
"/api/v1/admin/organization-registrations/:id/approve",
post(api::approve_organization_registration),
)
.route(
"/api/v1/admin/organization-registrations/:id/reject",
post(api::reject_organization_registration),
)
.route(
"/api/v1/admin/organization-registrations/:id/resend-initial-email",
post(api::resend_initial_email),
)
.route(
"/api/v1/admin/organization-registrations/:id/retry-provisioning",
post(api::retry_provisioning),
)
.route("/api/v1/auth/login", post(api::login))
.route(
"/api/v1/auth/change-initial-password",
post(api::change_initial_password),
)
.route(
"/api/v1/auth/request-password-reset",
post(api::request_password_reset),
)
.route("/api/v1/auth/reset-password", post(api::reset_password))
.route(
"/api/v1/auth/accept-invitation",
post(api::accept_invitation),
)
.route("/api/v1/auth/organizations", get(api::auth_organizations))
.route(
"/api/v1/auth/select-organization",
post(api::select_organization),
)
.route(
"/api/v1/organizations/current/setup",
get(api::get_organization_setup).put(api::put_organization_setup),
)
.route(
"/api/v1/users/me/settings/navigation",
get(api::get_user_navigation_settings).put(api::put_user_navigation_settings),
)
.route("/api/v1/number-ranges", get(api::list_number_ranges))
.route(
"/api/v1/number-ranges/:code/next",
axum::routing::post(api::generate_next_number),
)
.route(
"/api/v1/number-ranges/:code",
axum::routing::put(api::update_number_range),
)
.route(
"/api/v1/customers",
get(api::list_customers).post(api::create_customer),
)
.route(
"/api/v1/customers/:customer_id",
axum::routing::put(api::update_customer).delete(api::delete_customer),
)
.route(
"/api/v1/suppliers",
get(api::list_suppliers).post(api::create_supplier),
)
.route(
"/api/v1/suppliers/:supplier_id",
axum::routing::put(api::update_supplier).delete(api::delete_supplier),
)
.route("/api/v1/items", get(api::list_items).post(api::create_item))
.route(
"/api/v1/items/:item_id/prices",
get(api::list_item_price_history),
)
.route(
"/api/v1/items/:item_id",
axum::routing::put(api::update_item).delete(api::delete_item),
)
.route(
"/api/v1/cash-discount-terms",
get(api::list_cash_discount_terms).post(api::create_cash_discount_term),
)
.route(
"/api/v1/cash-discount-terms/:term_id",
axum::routing::put(api::update_cash_discount_term)
.delete(api::delete_cash_discount_term),
)
.route(
"/api/v1/quotes",
get(api::list_quotes).post(api::create_quote),
)
.route(
"/api/v1/quotes/:quote_id",
axum::routing::put(api::update_quote).delete(api::delete_quote),
)
.route(
"/api/v1/quotes/:quote_id/convert-to-invoice",
post(api::convert_quote_to_outgoing_invoice),
)
.route(
"/api/v1/outgoing-invoices",
get(api::list_outgoing_invoices).post(api::create_outgoing_invoice),
)
.route(
"/api/v1/outgoing-invoices/:invoice_id",
axum::routing::put(api::update_outgoing_invoice).delete(api::delete_outgoing_invoice),
)
.route(
"/api/v1/outgoing-invoices/:invoice_id/finalize",
post(api::finalize_outgoing_invoice),
)
.route(
"/api/v1/incoming-invoices",
get(api::list_incoming_invoices).post(api::create_incoming_invoice),
)
.route(
"/api/v1/incoming-invoices/:invoice_id",
axum::routing::put(api::update_incoming_invoice).delete(api::delete_incoming_invoice),
)
.route(
"/api/v1/imports/price-list/preview",
post(api::preview_price_list_import),
)
.route(
"/api/v1/imports/price-list/apply",
post(api::apply_price_list_import),
)
.route(
"/api/v1/api-connectors",
get(api::list_api_connectors).post(api::create_api_connector),
)
.route(
"/api/v1/api-connectors/:connector_id",
axum::routing::put(api::update_api_connector).delete(api::delete_api_connector),
)
.route(
"/api/v1/api-connectors/:connector_id/sync",
post(api::sync_api_connector),
)
.route(
"/api/v1/price-rules",
get(api::list_price_rules).post(api::create_price_rule),
)
.route(
"/api/v1/price-rules/:rule_id",
axum::routing::put(api::update_price_rule).delete(api::delete_price_rule),
)
.route(
"/api/v1/activities",
get(api::list_activities).post(api::create_activity),
)
.route(
"/api/v1/activities/:activity_id",
axum::routing::put(api::update_activity).delete(api::delete_activity),
)
.route(
"/api/v1/communications",
get(api::list_communications).post(api::create_communication),
)
.route(
"/api/v1/communications/:communication_id",
axum::routing::put(api::update_communication).delete(api::delete_communication),
)
.route(
"/api/v1/documents",
get(api::list_documents).post(api::upload_document),
)
.route(
"/api/v1/documents/:document_id/download",
get(api::download_document),
)
.route(
"/api/v1/documents/:document_id/audit-log",
get(api::list_document_audit_log),
)
.route(
"/api/v1/documents/:document_id",
axum::routing::delete(api::delete_document),
)
.route(
"/api/v1/organizations/current/invitations",
post(api::invite_user),
)
.route("/api/v1/organizations/current/users", get(api::list_users))
.route(
"/api/v1/organizations/current/users/:user_id/roles",
patch(api::update_user_roles),
)
.route(
"/api/v1/organizations/current/users/:user_id/disable",
post(api::disable_user),
)
.with_state(state)
.layer(CorsLayer::permissive())
.layer(TraceLayer::new_for_http());
let listener = tokio::net::TcpListener::bind(bind).await?;
info!("backend lauscht auf http://{bind}");
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await?;
Ok(())
}
async fn health() -> Json<serde_json::Value> {
Json(serde_json::json!({ "status": "ok" }))
}
async fn ws_handler(ws: WebSocketUpgrade, State(state): State<AppState>) -> impl IntoResponse {
ws.on_upgrade(move |socket| handle_socket(socket, state))
}
async fn handle_socket(socket: WebSocket, state: AppState) {
let (mut sender, mut receiver) = socket.split();
let mut events = state.events.subscribe();
let mut session_key: Option<SessionKey> = None;
loop {
tokio::select! {
Some(Ok(message)) = receiver.next() => {
if let Message::Text(text) = message {
if let Some(new_session_key) = handle_client_wire_message(&text, &state, &mut sender, session_key.as_ref()).await {
if let Ok(snapshot) = load_snapshot(state.db.as_ref()).await {
if send_encrypted_server_message(
&mut sender,
&new_session_key,
ServerMessage::Snapshot { records: snapshot },
).await.is_err() {
break;
}
}
session_key = Some(new_session_key);
}
}
}
Ok(event) = events.recv() => {
if let Some(active_session_key) = session_key.as_ref() {
if let Err(error) = send_encrypted_server_message(&mut sender, active_session_key, event).await {
warn!(%error, "server message konnte nicht gesendet werden");
break;
}
}
}
else => break,
}
}
}
async fn handle_client_wire_message(
text: &str,
state: &AppState,
sender: &mut futures_util::stream::SplitSink<WebSocket, Message>,
session_key: Option<&SessionKey>,
) -> Option<SessionKey> {
match serde_json::from_str::<WireMessage>(text) {
Ok(WireMessage::Hello(hello)) => {
if hello.protocol_version != PROTOCOL_VERSION {
let _ = send_wire_error(sender, "nicht unterstützte Protokollversion").await;
return None;
}
match SessionKey::from_base64(hello.key_id.clone(), &hello.session_key) {
Ok(new_session_key) => {
let ack = WireMessage::HelloAck(HelloAckMessage {
protocol_version: PROTOCOL_VERSION,
key_id: hello.key_id,
});
if send_wire_message(sender, ack).await.is_err() {
return None;
}
Some(new_session_key)
}
Err(error) => {
let _ =
send_wire_error(sender, &format!("ungültiger Session-Key: {error}")).await;
None
}
}
}
Ok(WireMessage::Encrypted(envelope)) => {
let Some(active_session_key) = session_key else {
let _ = send_wire_error(sender, "verschlüsselte Nachricht vor hello").await;
return None;
};
match active_session_key.decrypt::<ClientMessage>(&envelope) {
Ok(message) => handle_client_message(message, state).await,
Err(error) => {
let _ = send_wire_error(
sender,
&format!("Nachricht konnte nicht entschlüsselt werden: {error}"),
)
.await;
}
}
None
}
Ok(WireMessage::HelloAck(_) | WireMessage::Error(_)) => None,
Err(error) => {
let _ = send_wire_error(sender, &format!("ungültige Wire-Nachricht: {error}")).await;
None
}
}
}
async fn handle_client_message(message: ClientMessage, state: &AppState) {
match message {
ClientMessage::Ping => {
let _ = state.events.send(ServerMessage::Pong);
}
ClientMessage::Subscribe { topic } => {
info!(%topic, "client hat topic abonniert");
}
}
}
async fn send_encrypted_server_message(
sender: &mut futures_util::stream::SplitSink<WebSocket, Message>,
session_key: &SessionKey,
message: ServerMessage,
) -> anyhow::Result<()> {
let envelope = session_key.encrypt(&message)?;
send_wire_message(sender, WireMessage::Encrypted(envelope)).await
}
async fn send_wire_error(
sender: &mut futures_util::stream::SplitSink<WebSocket, Message>,
message: &str,
) -> anyhow::Result<()> {
send_wire_message(
sender,
WireMessage::Error(ProtocolErrorMessage {
message: message.to_string(),
}),
)
.await
}
async fn send_wire_message(
sender: &mut futures_util::stream::SplitSink<WebSocket, Message>,
message: WireMessage,
) -> anyhow::Result<()> {
let text = serde_json::to_string(&message)?;
sender.send(Message::Text(text)).await?;
Ok(())
}
async fn migrate(db: &PgPool) -> anyhow::Result<()> {
sqlx::migrate!("./migrations").run(db).await?;
api::sync_all_company_schemas(db).await?;
Ok(())
}
async fn load_snapshot(db: Option<&PgPool>) -> anyhow::Result<Vec<RecordSummary>> {
let Some(db) = db else {
return Ok(vec![RecordSummary {
id: Uuid::nil(),
title: "Kommunikationstest-Datensatz".to_string(),
updated_at: Utc::now(),
}]);
};
let rows = sqlx::query("select id, title, updated_at from records order by updated_at desc")
.fetch_all(db)
.await?;
Ok(rows
.into_iter()
.map(|row| RecordSummary {
id: row.get("id"),
title: row.get("title"),
updated_at: row.get("updated_at"),
})
.collect())
}
async fn shutdown_signal() {
let _ = tokio::signal::ctrl_c().await;
}

210
backend/src/models.rs Normal file
View File

@@ -0,0 +1,210 @@
#![allow(dead_code)]
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
#[sqlx(type_name = "text", rename_all = "snake_case")]
#[serde(rename_all = "snake_case")]
pub enum OrganizationStatus {
PendingApproval,
Approved,
Active,
Rejected,
Suspended,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
#[sqlx(type_name = "text", rename_all = "snake_case")]
#[serde(rename_all = "snake_case")]
pub enum UserOrganizationStatus {
PendingInvitation,
Active,
Disabled,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
#[sqlx(type_name = "text", rename_all = "snake_case")]
#[serde(rename_all = "snake_case")]
pub enum RegistrationStatus {
PendingApproval,
Approved,
Active,
Rejected,
Suspended,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
#[sqlx(type_name = "text", rename_all = "snake_case")]
#[serde(rename_all = "snake_case")]
pub enum InvitationStatus {
Pending,
Accepted,
Expired,
Revoked,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
#[sqlx(type_name = "text", rename_all = "snake_case")]
#[serde(rename_all = "snake_case")]
pub enum EmailOutboxStatus {
Pending,
Sending,
Sent,
Failed,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct User {
pub id: Uuid,
pub email: String,
pub display_name_ciphertext: Option<Vec<u8>>,
pub display_name_nonce: Option<Vec<u8>>,
pub display_name_key_id: Option<String>,
pub password_hash: Option<String>,
pub is_active: bool,
pub must_change_password: bool,
pub initial_password_expires_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub last_login_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Organization {
pub id: Uuid,
pub display_name_ciphertext: Option<Vec<u8>>,
pub display_name_nonce: Option<Vec<u8>>,
pub display_name_key_id: Option<String>,
pub schema_name: Option<String>,
pub status: OrganizationStatus,
pub registration_email: String,
pub setup_completed_at: Option<DateTime<Utc>>,
pub approved_by_user_id: Option<Uuid>,
pub approved_at: Option<DateTime<Utc>>,
pub rejected_by_user_id: Option<Uuid>,
pub rejected_at: Option<DateTime<Utc>>,
pub rejection_reason: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct UserOrganization {
pub user_id: Uuid,
pub organization_id: Uuid,
pub status: UserOrganizationStatus,
pub invited_by_user_id: Option<Uuid>,
pub invited_at: Option<DateTime<Utc>>,
pub accepted_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct AuthIdentity {
pub id: Uuid,
pub user_id: Uuid,
pub provider: String,
pub provider_subject: String,
pub email_at_provider: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct RefreshToken {
pub id: Uuid,
pub user_id: Uuid,
pub organization_id: Option<Uuid>,
pub token_hash: String,
pub expires_at: DateTime<Utc>,
pub revoked_at: Option<DateTime<Utc>>,
pub revoked_reason: Option<String>,
pub user_agent: Option<String>,
pub created_ip: Option<String>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct SocketToken {
pub id: Uuid,
pub user_id: Uuid,
pub organization_id: Uuid,
pub token_hash: String,
pub expires_at: DateTime<Utc>,
pub used_at: Option<DateTime<Utc>>,
pub revoked_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct SessionKeyRecord {
pub id: Uuid,
pub user_id: Uuid,
pub organization_id: Uuid,
pub key_id: String,
pub wrapped_key: Option<Vec<u8>>,
pub algorithm: String,
pub created_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
pub revoked_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct IdempotencyKey {
pub id: Uuid,
pub user_id: Uuid,
pub organization_id: Option<Uuid>,
pub key: String,
pub request_hash: String,
pub response_status: Option<i32>,
pub response_body_json: Option<serde_json::Value>,
pub expires_at: DateTime<Utc>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct OrganizationRegistrationRequest {
pub id: Uuid,
pub organization_name_ciphertext: Vec<u8>,
pub organization_name_nonce: Vec<u8>,
pub organization_name_key_id: String,
pub email: String,
pub status: RegistrationStatus,
pub organization_id: Option<Uuid>,
pub requested_at: DateTime<Utc>,
pub decided_by_user_id: Option<Uuid>,
pub decided_at: Option<DateTime<Utc>>,
pub decision_note: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct UserInvitation {
pub id: Uuid,
pub organization_id: Uuid,
pub email: String,
pub invited_by_user_id: Uuid,
pub status: InvitationStatus,
pub expires_at: DateTime<Utc>,
pub accepted_at: Option<DateTime<Utc>>,
pub created_user_id: Option<Uuid>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct EmailOutboxItem {
pub id: Uuid,
pub recipient_email: String,
pub template: String,
pub payload_ciphertext: Vec<u8>,
pub payload_nonce: Vec<u8>,
pub payload_key_id: String,
pub status: EmailOutboxStatus,
pub attempt_count: i32,
pub last_error: Option<String>,
pub send_after: DateTime<Utc>,
pub sent_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
}