diff --git a/backend/internal/handlers/tenant_handler.go b/backend/internal/handlers/tenant_handler.go index 3351d36..1db14f7 100644 --- a/backend/internal/handlers/tenant_handler.go +++ b/backend/internal/handlers/tenant_handler.go @@ -196,6 +196,46 @@ func (h *TenantHandler) RemoveMember(w http.ResponseWriter, r *http.Request) { jsonResponse(w, map[string]string{"status": "removed"}, http.StatusOK) } +// UpdateSettings handles PUT /api/tenants/{id}/settings +func (h *TenantHandler) UpdateSettings(w http.ResponseWriter, r *http.Request) { + userID, ok := auth.UserFromContext(r.Context()) + if !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + tenantID, err := uuid.Parse(r.PathValue("id")) + if err != nil { + jsonError(w, "invalid tenant ID", http.StatusBadRequest) + return + } + + // Only owners and admins can update settings + role, err := h.svc.GetUserRole(r.Context(), userID, tenantID) + if err != nil { + jsonError(w, err.Error(), http.StatusInternalServerError) + return + } + if role != "owner" && role != "admin" { + jsonError(w, "only owners and admins can update settings", http.StatusForbidden) + return + } + + var settings json.RawMessage + if err := json.NewDecoder(r.Body).Decode(&settings); err != nil { + jsonError(w, "invalid request body", http.StatusBadRequest) + return + } + + tenant, err := h.svc.UpdateSettings(r.Context(), tenantID, settings) + if err != nil { + jsonError(w, err.Error(), http.StatusInternalServerError) + return + } + + jsonResponse(w, tenant, http.StatusOK) +} + // ListMembers handles GET /api/tenants/{id}/members func (h *TenantHandler) ListMembers(w http.ResponseWriter, r *http.Request) { userID, ok := auth.UserFromContext(r.Context()) diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index a1af7e5..83aa350 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -60,6 +60,7 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se api.HandleFunc("POST /api/tenants", tenantH.CreateTenant) api.HandleFunc("GET /api/tenants", tenantH.ListTenants) api.HandleFunc("GET /api/tenants/{id}", tenantH.GetTenant) + api.HandleFunc("PUT /api/tenants/{id}/settings", tenantH.UpdateSettings) api.HandleFunc("POST /api/tenants/{id}/invite", tenantH.InviteUser) api.HandleFunc("DELETE /api/tenants/{id}/members/{uid}", tenantH.RemoveMember) api.HandleFunc("GET /api/tenants/{id}/members", tenantH.ListMembers) diff --git a/backend/internal/services/tenant_service.go b/backend/internal/services/tenant_service.go index 5085831..7ed5614 100644 --- a/backend/internal/services/tenant_service.go +++ b/backend/internal/services/tenant_service.go @@ -3,6 +3,7 @@ package services import ( "context" "database/sql" + "encoding/json" "fmt" "github.com/google/uuid" @@ -173,6 +174,21 @@ func (s *TenantService) InviteByEmail(ctx context.Context, tenantID uuid.UUID, e return &ut, nil } +// UpdateSettings merges new settings into the tenant's existing settings JSONB. +func (s *TenantService) UpdateSettings(ctx context.Context, tenantID uuid.UUID, settings json.RawMessage) (*models.Tenant, error) { + var tenant models.Tenant + err := s.db.QueryRowxContext(ctx, + `UPDATE tenants SET settings = COALESCE(settings, '{}'::jsonb) || $1::jsonb, updated_at = NOW() + WHERE id = $2 + RETURNING id, name, slug, settings, created_at, updated_at`, + settings, tenantID, + ).StructScan(&tenant) + if err != nil { + return nil, fmt.Errorf("update settings: %w", err) + } + return &tenant, nil +} + // RemoveMember removes a user from a tenant. Cannot remove the last owner. func (s *TenantService) RemoveMember(ctx context.Context, tenantID, userID uuid.UUID) error { // Check if the user being removed is an owner diff --git a/frontend/src/app/(app)/einstellungen/page.tsx b/frontend/src/app/(app)/einstellungen/page.tsx new file mode 100644 index 0000000..a696ff4 --- /dev/null +++ b/frontend/src/app/(app)/einstellungen/page.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { Settings, Calendar, Users } from "lucide-react"; +import Link from "next/link"; +import { api } from "@/lib/api"; +import type { Tenant } from "@/lib/types"; +import { CalDAVSettings } from "@/components/settings/CalDAVSettings"; +import { SkeletonCard } from "@/components/ui/Skeleton"; +import { EmptyState } from "@/components/ui/EmptyState"; + +export default function EinstellungenPage() { + const tenantId = + typeof window !== "undefined" + ? localStorage.getItem("kanzlai_tenant_id") + : null; + + const { + data: tenant, + isLoading, + error, + refetch, + } = useQuery({ + queryKey: ["tenant-current", tenantId], + queryFn: () => api.get(`/api/tenants/${tenantId}`), + enabled: !!tenantId, + }); + + return ( +
+
+

+ Einstellungen +

+ + + Team verwalten + +
+ + {/* Tenant Info */} + {isLoading ? ( + <> + + + + ) : error ? ( + refetch()} + className="rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-neutral-800" + > + Erneut versuchen + + } + /> + ) : tenant ? ( + <> + {/* Kanzlei Info */} +
+
+ +

+ Kanzlei +

+
+
+
+

Name

+

+ {tenant.name} +

+
+
+

Slug

+

+ {tenant.slug} +

+
+
+

Erstellt am

+

+ {new Date(tenant.created_at).toLocaleDateString("de-DE", { + day: "2-digit", + month: "2-digit", + year: "numeric", + })} +

+
+
+
+ + {/* CalDAV Settings */} +
+
+ +

+ CalDAV-Synchronisierung +

+
+
+ +
+
+ + ) : null} +
+ ); +} diff --git a/frontend/src/app/(app)/einstellungen/team/page.tsx b/frontend/src/app/(app)/einstellungen/team/page.tsx new file mode 100644 index 0000000..ad512e4 --- /dev/null +++ b/frontend/src/app/(app)/einstellungen/team/page.tsx @@ -0,0 +1,40 @@ +"use client"; + +import Link from "next/link"; +import { ArrowLeft, Users } from "lucide-react"; +import { TeamSettings } from "@/components/settings/TeamSettings"; + +export default function TeamPage() { + return ( +
+
+ + + +
+ +

+ Team verwalten +

+
+
+ +
+
+

+ Mitglieder +

+

+ Benutzer einladen und Rollen verwalten +

+
+
+ +
+
+
+ ); +} diff --git a/frontend/src/components/settings/CalDAVSettings.tsx b/frontend/src/components/settings/CalDAVSettings.tsx new file mode 100644 index 0000000..6935677 --- /dev/null +++ b/frontend/src/components/settings/CalDAVSettings.tsx @@ -0,0 +1,329 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { + RefreshCw, + CheckCircle2, + AlertCircle, + Clock, + ArrowUpDown, +} from "lucide-react"; +import { api } from "@/lib/api"; +import type { + Tenant, + CalDAVConfig, + CalDAVSyncResponse, +} from "@/lib/types"; + +const SYNC_INTERVALS = [ + { value: 5, label: "5 Minuten" }, + { value: 15, label: "15 Minuten" }, + { value: 30, label: "30 Minuten" }, + { value: 60, label: "1 Stunde" }, + { value: 120, label: "2 Stunden" }, + { value: 360, label: "6 Stunden" }, +]; + +const emptyConfig: CalDAVConfig = { + url: "", + username: "", + password: "", + calendar_path: "", + sync_enabled: false, + sync_interval_minutes: 15, +}; + +export function CalDAVSettings({ tenant }: { tenant: Tenant }) { + const queryClient = useQueryClient(); + const existing = (tenant.settings as Record)?.caldav as + | Partial + | undefined; + const [config, setConfig] = useState({ + ...emptyConfig, + ...existing, + }); + const [showPassword, setShowPassword] = useState(false); + + // Reset form when tenant changes + useEffect(() => { + const caldav = (tenant.settings as Record)?.caldav as + | Partial + | undefined; + setConfig({ ...emptyConfig, ...caldav }); + }, [tenant]); + + // Fetch sync status + const { data: syncStatus } = useQuery({ + queryKey: ["caldav-status"], + queryFn: () => api.get("/api/caldav/status"), + refetchInterval: 30_000, + }); + + // Save settings + const saveMutation = useMutation({ + mutationFn: (cfg: CalDAVConfig) => { + const tenantId = + typeof window !== "undefined" + ? localStorage.getItem("kanzlai_tenant_id") + : null; + return api.put(`/api/tenants/${tenantId}/settings`, { + caldav: cfg, + }); + }, + onSuccess: (updated) => { + queryClient.setQueryData(["tenant-current"], updated); + toast.success("CalDAV-Einstellungen gespeichert"); + }, + onError: () => { + toast.error("Fehler beim Speichern der CalDAV-Einstellungen"); + }, + }); + + // Trigger sync + const syncMutation = useMutation({ + mutationFn: () => api.post("/api/caldav/sync"), + onSuccess: (result) => { + queryClient.invalidateQueries({ queryKey: ["caldav-status"] }); + if (result.status === "ok") { + toast.success( + `Synchronisierung abgeschlossen: ${result.sync.items_pushed} gesendet, ${result.sync.items_pulled} empfangen` + ); + } else { + toast.error("Synchronisierung mit Fehlern abgeschlossen"); + } + }, + onError: () => { + toast.error("Fehler bei der Synchronisierung"); + }, + }); + + const handleSave = (e: React.FormEvent) => { + e.preventDefault(); + saveMutation.mutate(config); + }; + + const hasConfig = config.url && config.username && config.password; + + return ( +
+ {/* CalDAV Configuration Form */} +
+
+
+ + + setConfig((c) => ({ ...c, url: e.target.value })) + } + placeholder="https://dav.example.com/dav" + className="w-full rounded-md border border-neutral-200 px-3 py-1.5 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400" + /> +
+ +
+ + + setConfig((c) => ({ ...c, username: e.target.value })) + } + placeholder="user@example.com" + className="w-full rounded-md border border-neutral-200 px-3 py-1.5 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400" + /> +
+ +
+ +
+ + setConfig((c) => ({ ...c, password: e.target.value })) + } + placeholder="••••••••" + className="w-full rounded-md border border-neutral-200 px-3 py-1.5 pr-16 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400" + /> + +
+
+ +
+ + + setConfig((c) => ({ ...c, calendar_path: e.target.value })) + } + placeholder="/dav/calendars/user/default/" + className="w-full rounded-md border border-neutral-200 px-3 py-1.5 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400" + /> +

+ Pfad zum Kalender auf dem CalDAV-Server +

+
+
+ + {/* Sync Settings */} +
+ + +
+ + +
+
+ +
+ + + {hasConfig && ( + + )} +
+
+ + {/* Sync Status */} + {syncStatus && syncStatus.last_sync_at !== null && ( + + )} +
+ ); +} + +function SyncStatusDisplay({ data }: { data: CalDAVSyncResponse }) { + const hasErrors = data.sync?.errors && data.sync.errors.length > 0; + const lastSync = data.sync?.last_sync_at + ? new Date(data.sync.last_sync_at) + : null; + + return ( +
+
+ {hasErrors ? ( + + ) : ( + + )} +
+

+ {hasErrors + ? "Letzte Synchronisierung mit Fehlern" + : "Letzte Synchronisierung erfolgreich"} +

+ +
+ {lastSync && ( + + + {lastSync.toLocaleDateString("de-DE", { + day: "2-digit", + month: "2-digit", + year: "numeric", + })}{" "} + {lastSync.toLocaleTimeString("de-DE", { + hour: "2-digit", + minute: "2-digit", + })} + + )} + + + {data.sync.items_pushed} gesendet, {data.sync.items_pulled}{" "} + empfangen + + {data.sync.sync_duration && ( + + Dauer: {data.sync.sync_duration} + + )} +
+ + {hasErrors && ( +
+ {data.sync.errors!.map((err, i) => ( +

+ {err} +

+ ))} +
+ )} +
+
+
+ ); +} diff --git a/frontend/src/components/settings/TeamSettings.tsx b/frontend/src/components/settings/TeamSettings.tsx new file mode 100644 index 0000000..e74987e --- /dev/null +++ b/frontend/src/components/settings/TeamSettings.tsx @@ -0,0 +1,167 @@ +"use client"; + +import { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { UserPlus, Trash2, Shield, Crown, User } from "lucide-react"; +import { api } from "@/lib/api"; +import type { UserTenant } from "@/lib/types"; +import { Skeleton } from "@/components/ui/Skeleton"; +import { EmptyState } from "@/components/ui/EmptyState"; + +const ROLE_LABELS: Record = { + owner: { label: "Eigentümer", icon: Crown }, + admin: { label: "Administrator", icon: Shield }, + member: { label: "Mitglied", icon: User }, +}; + +export function TeamSettings() { + const queryClient = useQueryClient(); + const tenantId = + typeof window !== "undefined" + ? localStorage.getItem("kanzlai_tenant_id") + : null; + + const [email, setEmail] = useState(""); + const [role, setRole] = useState("member"); + + const { + data: members, + isLoading, + error, + } = useQuery({ + queryKey: ["tenant-members", tenantId], + queryFn: () => + api.get(`/api/tenants/${tenantId}/members`), + enabled: !!tenantId, + }); + + const inviteMutation = useMutation({ + mutationFn: (data: { email: string; role: string }) => + api.post(`/api/tenants/${tenantId}/invite`, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["tenant-members"] }); + setEmail(""); + setRole("member"); + toast.success("Benutzer eingeladen"); + }, + onError: (err: { error?: string }) => { + toast.error(err.error || "Fehler beim Einladen"); + }, + }); + + const removeMutation = useMutation({ + mutationFn: (userId: string) => + api.delete(`/api/tenants/${tenantId}/members/${userId}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["tenant-members"] }); + toast.success("Mitglied entfernt"); + }, + onError: (err: { error?: string }) => { + toast.error(err.error || "Fehler beim Entfernen"); + }, + }); + + const handleInvite = (e: React.FormEvent) => { + e.preventDefault(); + if (!email.trim()) return; + inviteMutation.mutate({ email: email.trim(), role }); + }; + + if (isLoading) { + return ( +
+ + + +
+ ); + } + + if (error) { + return ( + + ); + } + + return ( +
+ {/* Invite Form */} +
+ setEmail(e.target.value)} + placeholder="name@example.com" + className="flex-1 rounded-md border border-neutral-200 px-3 py-1.5 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400" + /> + + +
+ + {/* Members List */} + {members && members.length > 0 ? ( +
+ {members.map((member, i) => { + const roleInfo = ROLE_LABELS[member.role] || ROLE_LABELS.member; + const RoleIcon = roleInfo.icon; + return ( +
+
+
+ +
+
+

+ {member.user_id.slice(0, 8)}... +

+

{roleInfo.label}

+
+
+ {member.role !== "owner" && ( + + )} +
+ ); + })} +
+ ) : ( + + )} +
+ ); +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index fdaff06..ef15a47 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -152,6 +152,30 @@ export interface CalculateResponse { deadlines: CalculatedDeadline[]; } +export interface CalDAVConfig { + url: string; + username: string; + password: string; + calendar_path: string; + sync_enabled: boolean; + sync_interval_minutes: number; +} + +export interface CalDAVSyncStatus { + tenant_id: string; + last_sync_at: string; + items_pushed: number; + items_pulled: number; + errors?: string[]; + sync_duration: string; +} + +export interface CalDAVSyncResponse { + status: string; + sync: CalDAVSyncStatus; + last_sync_at?: null; +} + export interface ApiError { error: string; status: number;