27 Commits

Author SHA1 Message Date
a8c6c2625c Merge pull request 'dev' (#242) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 1m6s
Build and Release FEDEO / build-and-release (push) Successful in 6m31s
Reviewed-on: #242
2026-10-13 16:18:53 +00:00
a2a1151ccb Merge pull request 'dev' (#241) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 1m5s
Build and Release FEDEO / build-and-release (push) Successful in 6m29s
Reviewed-on: #241
2026-10-07 17:11:45 +00:00
daaed01d02 Merge pull request 'dev' (#240) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 59s
Build and Release FEDEO / build-and-release (push) Successful in 6m16s
Reviewed-on: #240
2026-10-02 16:30:02 +00:00
e7d6c482ad Merge pull request 'dev' (#239) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 58s
Build and Release FEDEO / build-and-release (push) Successful in 6m13s
Reviewed-on: #239
2026-10-02 15:17:16 +00:00
ed305f656c Merge pull request 'dev' (#238) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 57s
Build and Release FEDEO / build-and-release (push) Successful in 6m14s
Reviewed-on: #238
2026-10-02 14:18:01 +00:00
30e932a874 Merge pull request 'dev' (#237) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 58s
Build and Release FEDEO / build-and-release (push) Successful in 6m16s
Reviewed-on: #237
2026-10-02 12:51:46 +00:00
0a74e07288 Merge pull request 'fix(scanner): accept Epson empty write probes' (#234) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 58s
Build and Release FEDEO / build-and-release (push) Successful in 6m8s
Reviewed-on: #234
2026-10-01 15:36:56 +00:00
4c3de8c942 Merge pull request 'dev' (#233) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 58s
Build and Release FEDEO / build-and-release (push) Successful in 6m11s
Reviewed-on: #233
2026-09-30 17:23:15 +00:00
49d44d9701 Merge pull request 'dev' (#232) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 1m1s
Build and Release FEDEO / build-and-release (push) Successful in 6m18s
Reviewed-on: #232
2026-09-30 15:43:45 +00:00
96a7acff33 Merge pull request 'feat(selfhost): show sanitized update logs in admin UI' (#231) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 47s
Build and Release FEDEO / build-and-release (push) Successful in 6m7s
Reviewed-on: #231
2026-09-25 17:22:30 +00:00
65588686c7 Merge pull request 'fix(documents): normalize units when selecting and loading rows' (#230) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 48s
Build and Release FEDEO / build-and-release (push) Successful in 6m3s
Reviewed-on: #230
2026-09-25 09:54:08 +00:00
fcf9cc3fc9 Merge pull request 'dev' (#229) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 49s
Build and Release FEDEO / build-and-release (push) Successful in 6m6s
Reviewed-on: #229
2026-09-25 09:24:39 +00:00
20091143d2 Merge pull request 'dev' (#228) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 45s
Build and Release FEDEO / build-and-release (push) Successful in 6m5s
Reviewed-on: #228
2026-09-23 17:20:58 +00:00
f708d0a2b2 Merge pull request 'dev' (#227) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 44s
Build and Release FEDEO / build-and-release (push) Successful in 6m7s
Reviewed-on: #227
2026-09-23 16:15:44 +00:00
7e9f4409f6 Merge pull request 'dev' (#226) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 33s
Build and Release FEDEO / build-and-release (push) Successful in 6m10s
Reviewed-on: #226
2026-09-23 14:57:40 +00:00
2e63af1aa2 Merge pull request 'fix(release): stream large migration checksums from git' (#225) from flfeders/codex/release-migration-checksums into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 25s
Build and Release FEDEO / build-and-release (push) Successful in 5m59s
Reviewed-on: #225
2026-09-23 14:09:57 +00:00
5ebd2d508c Merge pull request 'fix(release): exclude legacy main history from commit checks' (#224) from dev into main
Some checks failed
Build and Release FEDEO / verify (push) Successful in 23s
Build and Release FEDEO / build-and-release (push) Failing after 5m57s
Reviewed-on: #224
2026-09-23 13:03:36 +00:00
9128fbb165 Merge pull request 'fix(ci): build dev images without repository write access' (#221) from dev into main
Some checks failed
Build and Release FEDEO / verify (push) Failing after 26s
Build and Release FEDEO / build-and-release (push) Has been skipped
Reviewed-on: #221
2026-09-20 16:25:38 +00:00
8473221c13 Merge pull request '3.0.0' (#220) from dev into main
Some checks failed
Build and Release FEDEO / verify (push) Failing after 22s
Build and Release FEDEO / build-and-release (push) Has been skipped
Reviewed-on: #220
2026-09-20 16:10:46 +00:00
e7554fa2cc .gitea/ISSUE_TEMPLATE/feature_request.md aktualisiert
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 16s
Build and Push Docker Images / build-frontend (push) Successful in 16s
2026-01-22 17:42:28 +00:00
7c1fabf58a .gitea/ISSUE_TEMPLATE/bug_report.md aktualisiert
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 17s
Build and Push Docker Images / build-frontend (push) Successful in 16s
2026-01-22 17:40:58 +00:00
1203b6cbd1 .gitea/ISSUE_TEMPLATE/bug_report.md aktualisiert
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 16s
Build and Push Docker Images / build-frontend (push) Successful in 16s
2026-01-15 11:39:03 +00:00
525f2906fb .gitea/ISSUE_TEMPLATE/feature_request.md aktualisiert
Some checks failed
Build and Push Docker Images / build-frontend (push) Has been cancelled
Build and Push Docker Images / build-backend (push) Has been cancelled
2026-01-15 11:38:50 +00:00
b105382abf .gitea/ISSUE_TEMPLATE/bug_report.md aktualisiert
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 16s
Build and Push Docker Images / build-frontend (push) Successful in 15s
2026-01-15 11:38:00 +00:00
b1cdec7d17 Merge pull request 'Added feature request template' (#62) from dev into main
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 17s
Build and Push Docker Images / build-frontend (push) Successful in 16s
Reviewed-on: #62
2026-01-15 11:31:57 +00:00
f1d512b2e5 Merge pull request 'dev' (#61) from dev into main
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 17s
Build and Push Docker Images / build-frontend (push) Successful in 16s
Reviewed-on: #61
2026-01-15 11:29:15 +00:00
db21b43120 Merge pull request 'dev' (#40) from dev into main
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 18s
Build and Push Docker Images / build-frontend (push) Successful in 16s
Reviewed-on: #40
2026-01-08 22:21:06 +00:00
47 changed files with 116 additions and 1336 deletions

View File

@@ -2,37 +2,18 @@
name: 🐛 Bug Report name: 🐛 Bug Report
about: Erstelle einen Bericht, um uns zu helfen, das Projekt zu verbessern. about: Erstelle einen Bericht, um uns zu helfen, das Projekt zu verbessern.
title: '[BUG] ' title: '[BUG] '
labels: bug labels: Problem
assignees: '' assignees: ''
--- ---
**Beschreibung** **Beschreibung**
Eine klare und prägnante Beschreibung des Fehlers.
**Reproduktion** **Reproduktion**
Schritte, um den Fehler zu reproduzieren:
Entweder:
1. Gehe zu '...'
2. Klicke auf '...'
3. Scrolle runter zu '...'
4. Siehe Fehler
Oder Link zur Seite
**Erwartetes Verhalten**
Eine klare Beschreibung dessen, was du erwartet hast.
**Screenshots** **Screenshots**
Falls zutreffend, füge hier Screenshots oder Gifs hinzu, um das Problem zu verdeutlichen.
**Achtung: Achte bitte auf Datenschutz deiner Daten sowie der Daten deiner Kunden. Sollten ein Screenshot nur mit Daten möglich sein, schwärze diese bitte vor dem Upload.** **Achtung: Achte bitte auf Datenschutz deiner Daten sowie der Daten deiner Kunden. Sollten ein Screenshot nur mit Daten möglich sein, schwärze diese bitte vor dem Upload.**
**Umgebung:**
- Betriebssystem: [z.B. Windows, macOS, Linux]
- Browser / Version (falls relevant): [z.B. Chrome 120]
- Projekt-Version: [z.B. v1.0.2]
**Zusätzlicher Kontext**
Füge hier alle anderen Informationen zum Problem hinzu.

View File

@@ -2,19 +2,16 @@
name: ✨ Feature Request name: ✨ Feature Request
about: Schlage eine Idee für dieses Projekt vor. about: Schlage eine Idee für dieses Projekt vor.
title: '[FEATURE] ' title: '[FEATURE] '
labels: enhancement labels: Funktionswunsch
assignees: '' assignees: ''
--- ---
**Ist dein Feature-Wunsch mit einem Problem verbunden?** **Ist dein Feature-Wunsch mit einem Problem verbunden?**
Eine klare Beschreibung des Problems (z.B. "Ich bin immer genervt, wenn...").
**Lösungsvorschlag** **Lösungsvorschlag**
Eine klare Beschreibung dessen, was du dir wünschst und wie es funktionieren soll.
**Alternativen** **Alternativen**
Hast du über alternative Lösungen oder Workarounds nachgedacht?
**Zusätzlicher Kontext**
Hier ist Platz für weitere Informationen, Skizzen oder Beispiele von anderen Tools.

View File

@@ -1,3 +0,0 @@
ALTER TABLE "auth_users" ADD COLUMN "archived_at" timestamp with time zone;
--> statement-breakpoint
ALTER TABLE "auth_profiles" ADD COLUMN "archived_at" timestamp with time zone;

View File

@@ -554,13 +554,6 @@
"when": 1791244800000, "when": 1791244800000,
"tag": "0081_chat_media_reactions", "tag": "0081_chat_media_reactions",
"breakpoints": true "breakpoints": true
},
{
"idx": 79,
"version": "7",
"when": 1792022400000,
"tag": "0082_archive_users",
"breakpoints": true
} }
] ]
} }

View File

@@ -82,7 +82,6 @@ export const authProfiles = pgTable("auth_profiles", {
address_city: text("address_city"), address_city: text("address_city"),
active: boolean("active").notNull().default(true), active: boolean("active").notNull().default(true),
archived_at: timestamp("archived_at", { withTimezone: true }),
}) })
export type AuthProfile = typeof authProfiles.$inferSelect export type AuthProfile = typeof authProfiles.$inferSelect

View File

@@ -13,7 +13,6 @@ export const authUsers = pgTable("auth_users", {
multiTenant: boolean("multi_tenant").notNull().default(true), multiTenant: boolean("multi_tenant").notNull().default(true),
must_change_password: boolean("must_change_password").notNull().default(false), must_change_password: boolean("must_change_password").notNull().default(false),
is_admin: boolean("is_admin").notNull().default(false), is_admin: boolean("is_admin").notNull().default(false),
archived_at: timestamp("archived_at", { withTimezone: true }),
updatedAt: timestamp("updated_at", { withTimezone: true }), updatedAt: timestamp("updated_at", { withTimezone: true }),

View File

@@ -1,7 +1,7 @@
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
import fp from "fastify-plugin"; import fp from "fastify-plugin";
import { secrets } from "../utils/secrets"; import { secrets } from "../utils/secrets";
import { and, eq, isNull } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { authUsers, m2mApiKeys } from "../../db/schema"; import { authUsers, m2mApiKeys } from "../../db/schema";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
@@ -44,8 +44,7 @@ export default fp(async (server: FastifyInstance, opts: { allowedPrefix?: string
.innerJoin(authUsers, eq(authUsers.id, m2mApiKeys.userId)) .innerJoin(authUsers, eq(authUsers.id, m2mApiKeys.userId))
.where(and( .where(and(
eq(m2mApiKeys.keyHash, keyHash), eq(m2mApiKeys.keyHash, keyHash),
eq(m2mApiKeys.active, true), eq(m2mApiKeys.active, true)
isNull(authUsers.archived_at)
)) ))
.limit(1) .limit(1)
@@ -71,7 +70,7 @@ export default fp(async (server: FastifyInstance, opts: { allowedPrefix?: string
const users = await server.db const users = await server.db
.select({ email: authUsers.email }) .select({ email: authUsers.email })
.from(authUsers) .from(authUsers)
.where(and(eq(authUsers.id, userId), isNull(authUsers.archived_at))) .where(eq(authUsers.id, userId))
.limit(1) .limit(1)
if (!users[0]) { if (!users[0]) {

View File

@@ -12,7 +12,7 @@ import {
tenants, tenants,
} from "../../db/schema" } from "../../db/schema"
import { eq, and, inArray, isNull } from "drizzle-orm" import { eq, and, inArray } from "drizzle-orm"
export default fp(async (server: FastifyInstance) => { export default fp(async (server: FastifyInstance) => {
const hashApiKey = (apiKey: string) => const hashApiKey = (apiKey: string) =>
@@ -56,8 +56,7 @@ export default fp(async (server: FastifyInstance) => {
.innerJoin(authUsers, eq(authUsers.id, m2mApiKeys.userId)) .innerJoin(authUsers, eq(authUsers.id, m2mApiKeys.userId))
.where(and( .where(and(
eq(m2mApiKeys.keyHash, keyHash), eq(m2mApiKeys.keyHash, keyHash),
eq(m2mApiKeys.active, true), eq(m2mApiKeys.active, true)
isNull(authUsers.archived_at)
)) ))
.limit(1) .limit(1)
@@ -142,16 +141,22 @@ export default fp(async (server: FastifyInstance) => {
return reply.code(401).send({ error: "Invalid token" }) return reply.code(401).send({ error: "Invalid token" })
} }
// Check current account state even when a JWT carries cached admin claims. // Payload an Request hängen
req.user = payload
if (typeof payload.is_admin === "boolean") {
req.user.is_admin = payload.is_admin
} else {
const [currentUser] = await server.db const [currentUser] = await server.db
.select({ is_admin: authUsers.is_admin }) .select({
is_admin: authUsers.is_admin,
})
.from(authUsers) .from(authUsers)
.where(and(eq(authUsers.id, payload.user_id), isNull(authUsers.archived_at))) .where(eq(authUsers.id, payload.user_id))
.limit(1) .limit(1)
if (!currentUser) {
return reply.code(401).send({ error: "Invalid or expired token" }) req.user.is_admin = Boolean(currentUser?.is_admin)
} }
req.user = { ...payload, is_admin: Boolean(currentUser.is_admin) }
// Multi-Tenant Modus ohne ausgewählten Tenant → keine Rollenprüfung // Multi-Tenant Modus ohne ausgewählten Tenant → keine Rollenprüfung
if (!req.user.tenant_id) { if (!req.user.tenant_id) {

View File

@@ -33,12 +33,9 @@ import { requireSystemAdmin } from "../utils/requireSystemAdmin";
import { buildSystemStatus } from "../modules/system-status.service"; import { buildSystemStatus } from "../modules/system-status.service";
import { s3 } from "../utils/s3"; import { s3 } from "../utils/s3";
import { secrets } from "../utils/secrets"; import { secrets } from "../utils/secrets";
import adminUserArchiveRoutes from "./adminUserArchive";
import { getPortalLoginUrl } from "../utils/portalUrl"; import { getPortalLoginUrl } from "../utils/portalUrl";
export default async function adminRoutes(server: FastifyInstance) { export default async function adminRoutes(server: FastifyInstance) {
await adminUserArchiveRoutes(server);
await server.register(multipart, { await server.register(multipart, {
limits: { fileSize: 1024 * 1024 * 1024 } limits: { fileSize: 1024 * 1024 * 1024 }
}); });
@@ -605,7 +602,6 @@ export default async function adminRoutes(server: FastifyInstance) {
const currentUser = await requireAdmin(req, reply); const currentUser = await requireAdmin(req, reply);
if (!currentUser) return; if (!currentUser) return;
const includeArchived = (req.query as { includeArchived?: string }).includeArchived === "true";
const [tenantRows, userRows, profileRows, membershipRows, roleRows, roleAssignmentRows] = await Promise.all([ const [tenantRows, userRows, profileRows, membershipRows, roleRows, roleAssignmentRows] = await Promise.all([
server.db server.db
.select({ .select({
@@ -625,9 +621,8 @@ export default async function adminRoutes(server: FastifyInstance) {
multiTenant: authUsers.multiTenant, multiTenant: authUsers.multiTenant,
must_change_password: authUsers.must_change_password, must_change_password: authUsers.must_change_password,
is_admin: authUsers.is_admin, is_admin: authUsers.is_admin,
archived_at: authUsers.archived_at,
}) })
.from(authUsers).where(includeArchived ? undefined : isNull(authUsers.archived_at)), .from(authUsers),
server.db server.db
.select({ .select({
id: authProfiles.id, id: authProfiles.id,
@@ -638,7 +633,6 @@ export default async function adminRoutes(server: FastifyInstance) {
full_name: authProfiles.full_name, full_name: authProfiles.full_name,
email: authProfiles.email, email: authProfiles.email,
active: authProfiles.active, active: authProfiles.active,
archived_at: authProfiles.archived_at,
}) })
.from(authProfiles), .from(authProfiles),
server.db server.db
@@ -687,17 +681,16 @@ export default async function adminRoutes(server: FastifyInstance) {
}; };
}); });
const activeUserIds = new Set(userRows.filter((user) => !user.archived_at).map((user) => user.id));
const tenantsWithCounts = tenantRows.map((tenant) => ({ const tenantsWithCounts = tenantRows.map((tenant) => ({
...tenant, ...tenant,
user_count: uniqueMembershipRows.filter((membership) => membership.tenant_id === tenant.id && activeUserIds.has(membership.user_id)).length, user_count: uniqueMembershipRows.filter((membership) => membership.tenant_id === tenant.id).length,
})); }));
return { return {
users, users,
tenants: tenantsWithCounts, tenants: tenantsWithCounts,
roles: roleRows, roles: roleRows,
unassignedProfiles: profileRows.filter((profile) => !profile.user_id && !profile.archived_at && profile.active), unassignedProfiles: profileRows.filter((profile) => !profile.user_id),
memberships: uniqueMembershipRows, memberships: uniqueMembershipRows,
roleAssignments: roleAssignmentRows, roleAssignments: roleAssignmentRows,
}; };
@@ -816,7 +809,7 @@ export default async function adminRoutes(server: FastifyInstance) {
email: authProfiles.email, email: authProfiles.email,
}) })
.from(authProfiles) .from(authProfiles)
.where(and(eq(authProfiles.id, profileId), isNull(authProfiles.archived_at))) .where(eq(authProfiles.id, profileId))
.limit(1); .limit(1);
if (!profile) { if (!profile) {
@@ -958,16 +951,11 @@ export default async function adminRoutes(server: FastifyInstance) {
id: authUsers.id, id: authUsers.id,
email: authUsers.email, email: authUsers.email,
is_admin: authUsers.is_admin, is_admin: authUsers.is_admin,
archived_at: authUsers.archived_at,
}) })
.from(authUsers) .from(authUsers)
.where(eq(authUsers.email, email)) .where(eq(authUsers.email, email))
.limit(1); .limit(1);
if (existingUser?.archived_at) {
return reply.code(409).send({ error: "Dieses Benutzerkonto ist dauerhaft archiviert." });
}
const derivedName = deriveNameFromEmail(email); const derivedName = deriveNameFromEmail(email);
const firstName = customerRecord.firstname?.trim() || derivedName.first_name; const firstName = customerRecord.firstname?.trim() || derivedName.first_name;
const lastName = customerRecord.lastname?.trim() || derivedName.last_name; const lastName = customerRecord.lastname?.trim() || derivedName.last_name;
@@ -1199,12 +1187,6 @@ export default async function adminRoutes(server: FastifyInstance) {
if (!currentUser) return; if (!currentUser) return;
const { user_id } = req.params as { user_id: string }; const { user_id } = req.params as { user_id: string };
const [account] = await server.db.select({ archived_at: authUsers.archived_at })
.from(authUsers).where(eq(authUsers.id, user_id)).limit(1);
if (!account) return reply.code(404).send({ error: "User not found" });
if (account.archived_at) {
return reply.code(409).send({ error: "Archivierte Benutzer können nicht mehr bearbeitet werden." });
}
const body = req.body as { const body = req.body as {
email?: string; email?: string;
multiTenant?: boolean; multiTenant?: boolean;
@@ -1254,12 +1236,6 @@ export default async function adminRoutes(server: FastifyInstance) {
if (!currentUser) return; if (!currentUser) return;
const { user_id } = req.params as { user_id: string }; const { user_id } = req.params as { user_id: string };
const [account] = await server.db.select({ archived_at: authUsers.archived_at })
.from(authUsers).where(eq(authUsers.id, user_id)).limit(1);
if (!account) return reply.code(404).send({ error: "User not found" });
if (account.archived_at) {
return reply.code(409).send({ error: "Archivierte Benutzer können nicht mehr bearbeitet werden." });
}
const body = (req.body || {}) as { password?: string }; const body = (req.body || {}) as { password?: string };
const requestedPassword = body.password?.trim(); const requestedPassword = body.password?.trim();
@@ -1660,12 +1636,6 @@ export default async function adminRoutes(server: FastifyInstance) {
if (!currentUser) return; if (!currentUser) return;
const { user_id } = req.params as { user_id: string }; const { user_id } = req.params as { user_id: string };
const [account] = await server.db.select({ archived_at: authUsers.archived_at })
.from(authUsers).where(eq(authUsers.id, user_id)).limit(1);
if (!account) return reply.code(404).send({ error: "User not found" });
if (account.archived_at) {
return reply.code(409).send({ error: "Archivierte Benutzer können nicht mehr bearbeitet werden." });
}
const body = req.body as { const body = req.body as {
tenant_ids?: number[]; tenant_ids?: number[];
role_assignments?: { tenant_id: number; role_id: string }[]; role_assignments?: { tenant_id: number; role_id: string }[];
@@ -1742,9 +1712,7 @@ export default async function adminRoutes(server: FastifyInstance) {
.where( .where(
and( and(
inArray(authProfiles.tenant_id, tenantIds), inArray(authProfiles.tenant_id, tenantIds),
isNull(authProfiles.user_id), isNull(authProfiles.user_id)
isNull(authProfiles.archived_at),
eq(authProfiles.active, true)
) )
) )
: []; : [];
@@ -1867,10 +1835,6 @@ export default async function adminRoutes(server: FastifyInstance) {
}); });
} }
const [targetUser] = await server.db.select({ id: authUsers.id })
.from(authUsers).where(and(eq(authUsers.id, body.user_id), isNull(authUsers.archived_at))).limit(1);
if (!targetUser) return reply.code(404).send({ error: "Aktiver Benutzer nicht gefunden." });
const mode = body.mode ?? "multi"; const mode = body.mode ?? "multi";
// ---------------------------- // ----------------------------

View File

@@ -1,51 +0,0 @@
import type { FastifyInstance } from "fastify"
import { eq } from "drizzle-orm"
import { authProfiles, authRefreshTokens, authUsers, m2mApiKeys } from "../../db/schema"
import { requireSystemAdmin } from "../utils/requireSystemAdmin"
export default async function adminUserArchiveRoutes(server: FastifyInstance) {
server.post("/admin/users/:user_id/archive", {
schema: {
params: {
type: "object",
required: ["user_id"],
properties: { user_id: { type: "string", format: "uuid" } },
},
},
}, async (req, reply) => {
const currentUser = await requireSystemAdmin(server, req, reply)
if (!currentUser) return
const { user_id } = req.params as { user_id: string }
if (user_id === currentUser.id) {
return reply.code(409).send({ error: "Das eigene Benutzerkonto kann nicht archiviert werden." })
}
const user = await server.db.transaction(async (tx) => {
// Serialize repeated archive requests and keep the original archive date.
const [target] = await tx.select({ id: authUsers.id, archived_at: authUsers.archived_at })
.from(authUsers).where(eq(authUsers.id, user_id)).limit(1).for("update")
if (!target) return null
const archivedAt = target.archived_at || new Date()
const [archivedUser] = await tx.update(authUsers)
.set({ archived_at: archivedAt, updatedAt: archivedAt })
.where(eq(authUsers.id, user_id))
.returning({ id: authUsers.id, archived_at: authUsers.archived_at })
// Keep links intact so existing documents and time records retain their authors.
await tx.update(authProfiles)
.set({ active: false, archived_at: archivedAt, token_id: null, calendar_subscription_token: null })
.where(eq(authProfiles.user_id, user_id))
await tx.update(authRefreshTokens).set({ revokedAt: archivedAt })
.where(eq(authRefreshTokens.userId, user_id))
await tx.update(m2mApiKeys).set({ active: false, updatedAt: archivedAt })
.where(eq(m2mApiKeys.userId, user_id))
return archivedUser
})
if (!user) return reply.code(404).send({ error: "Benutzer nicht gefunden." })
return { user }
})
}

View File

@@ -6,7 +6,7 @@ import { sendMail } from "../../utils/mailer";
import { authUsers } from "../../../db/schema"; import { authUsers } from "../../../db/schema";
import { authTenantUsers } from "../../../db/schema"; import { authTenantUsers } from "../../../db/schema";
import { tenants } from "../../../db/schema"; import { tenants } from "../../../db/schema";
import { and, eq, isNull } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { resolveLoginTenant } from "../../utils/loginTenant"; import { resolveLoginTenant } from "../../utils/loginTenant";
import { import {
createAccessToken, createAccessToken,
@@ -112,7 +112,7 @@ export default async function authRoutes(server: FastifyInstance) {
const [found] = await server.db const [found] = await server.db
.select() .select()
.from(authUsers) .from(authUsers)
.where(and(eq(authUsers.email, body.email.toLowerCase()), isNull(authUsers.archived_at))) .where(eq(authUsers.email, body.email.toLowerCase()))
.limit(1); .limit(1);
if (!found) { if (!found) {
@@ -216,7 +216,7 @@ export default async function authRoutes(server: FastifyInstance) {
mustChangePassword: authUsers.must_change_password, mustChangePassword: authUsers.must_change_password,
}) })
.from(authUsers) .from(authUsers)
.where(and(eq(authUsers.email, email.toLowerCase()), isNull(authUsers.archived_at))) .where(eq(authUsers.email, email.toLowerCase()))
.limit(1); .limit(1);
if (!user) { if (!user) {

View File

@@ -58,7 +58,6 @@ export default async function meRoutes(server: FastifyInstance) {
features: tenants.features, features: tenants.features,
extraModules: tenants.extraModules, extraModules: tenants.extraModules,
businessInfo: tenants.businessInfo, businessInfo: tenants.businessInfo,
portalConfig: tenants.portalConfig,
numberRanges: tenants.numberRanges, numberRanges: tenants.numberRanges,
accountChart: tenants.accountChart, accountChart: tenants.accountChart,
taxEvaluationPeriod: tenants.taxEvaluationPeriod, taxEvaluationPeriod: tenants.taxEvaluationPeriod,

View File

@@ -1,5 +1,5 @@
import { FastifyInstance } from "fastify" import { FastifyInstance } from "fastify"
import { eq, and, isNull } from "drizzle-orm" import { eq, and } from "drizzle-orm"
import { import {
authUsers, authUsers,
@@ -83,8 +83,7 @@ export default async function userRoutes(server: FastifyInstance) {
.where( .where(
and( and(
eq(authProfiles.user_id, id), eq(authProfiles.user_id, id),
eq(authProfiles.tenant_id, req.user.tenant_id), eq(authProfiles.tenant_id, req.user.tenant_id)
isNull(authProfiles.archived_at)
) )
) )
@@ -100,7 +99,6 @@ export default async function userRoutes(server: FastifyInstance) {
} }
const updateData: any = { ...data } const updateData: any = { ...data }
for (const key of ["archived_at", "user_id", "tenant_id", "id"]) delete updateData[key]
// bekannte Date-Felder prüfen // bekannte Date-Felder prüfen
if (data.entry_date !== undefined) if (data.entry_date !== undefined)
@@ -118,10 +116,9 @@ export default async function userRoutes(server: FastifyInstance) {
const [updatedProfile] = await server.db const [updatedProfile] = await server.db
.update(authProfiles) .update(authProfiles)
.set(updateData) .set(updateData)
.where(and(eq(authProfiles.id, profile.id), isNull(authProfiles.archived_at))) .where(eq(authProfiles.id, profile.id))
.returning() .returning()
if (!updatedProfile) return reply.code(404).send({ error: "Profile not found in tenant" })
return { profile: updatedProfile } return { profile: updatedProfile }
} catch (err: any) { } catch (err: any) {

View File

@@ -1,6 +1,6 @@
import { createHash } from "node:crypto" import { createHash } from "node:crypto"
import { FastifyInstance } from "fastify" import { FastifyInstance } from "fastify"
import { and, asc, desc, eq, gt, inArray, isNull, ne, sql } from "drizzle-orm" import { and, asc, desc, eq, gt, inArray, ne, sql } from "drizzle-orm"
import { import {
authProfiles, authProfiles,
authTenantUsers, authTenantUsers,
@@ -80,7 +80,7 @@ export default async function communicationRoutes(server: FastifyInstance) {
eq(authProfiles.user_id, authTenantUsers.user_id), eq(authProfiles.user_id, authTenantUsers.user_id),
eq(authProfiles.tenant_id, tenantId) eq(authProfiles.tenant_id, tenantId)
)) ))
.where(and(eq(authTenantUsers.tenant_id, tenantId), isNull(authUsers.archived_at))) .where(eq(authTenantUsers.tenant_id, tenantId))
const addMembers = async (roomId: string, userIds: string[]) => { const addMembers = async (roomId: string, userIds: string[]) => {
const uniqueUserIds = Array.from(new Set(userIds.filter(Boolean))) const uniqueUserIds = Array.from(new Set(userIds.filter(Boolean)))
@@ -122,8 +122,7 @@ export default async function communicationRoutes(server: FastifyInstance) {
.where(eq(communicationRooms.id, room.id)).returning() .where(eq(communicationRooms.id, room.id)).returning()
} }
const users = await tx.select({ userId: authTenantUsers.user_id }).from(authTenantUsers) const users = await tx.select({ userId: authTenantUsers.user_id }).from(authTenantUsers)
.innerJoin(authUsers, eq(authUsers.id, authTenantUsers.user_id)) .where(eq(authTenantUsers.tenant_id, tenantId))
.where(and(eq(authTenantUsers.tenant_id, tenantId), isNull(authUsers.archived_at)))
if (users.length) await tx.insert(communicationRoomMembers) if (users.length) await tx.insert(communicationRoomMembers)
.values([...new Set(users.map(user => user.userId))].map(userId => ({ roomId: room.id, userId }))) .values([...new Set(users.map(user => user.userId))].map(userId => ({ roomId: room.id, userId })))
.onConflictDoNothing() .onConflictDoNothing()
@@ -373,7 +372,7 @@ export default async function communicationRoutes(server: FastifyInstance) {
const profileIds = (project.profiles || []) as string[] const profileIds = (project.profiles || []) as string[]
const memberProfiles = profileIds.length const memberProfiles = profileIds.length
? await server.db.select({ userId: authProfiles.user_id }).from(authProfiles) ? await server.db.select({ userId: authProfiles.user_id }).from(authProfiles)
.where(and(eq(authProfiles.tenant_id, tenantId), inArray(authProfiles.id, profileIds), eq(authProfiles.active, true))) .where(and(eq(authProfiles.tenant_id, tenantId), inArray(authProfiles.id, profileIds)))
: [] : []
const projectMemberIds = memberProfiles.map((profile) => profile.userId).filter(Boolean) as string[] const projectMemberIds = memberProfiles.map((profile) => profile.userId).filter(Boolean) as string[]
await addMembers(room.id, [req.user.user_id, ...projectMemberIds]) await addMembers(room.id, [req.user.user_id, ...projectMemberIds])

View File

@@ -44,8 +44,7 @@ export default async function devicesRFIDRoutes(server: FastifyInstance) {
.where( .where(
and( and(
eq(authProfiles.tenant_id, device.tenant), eq(authProfiles.tenant_id, device.tenant),
eq(authProfiles.token_id, rfid_id), eq(authProfiles.token_id, rfid_id)
eq(authProfiles.active, true)
) )
) )
.limit(1) .limit(1)

View File

@@ -7,7 +7,7 @@ import {
tenants tenants
} from "../../../db/schema" } from "../../../db/schema"
import {and, eq, inArray, isNull} from "drizzle-orm" import {and, eq, inArray} from "drizzle-orm"
import { enrichProfilesWithBranches } from "../../utils/profileBranches" import { enrichProfilesWithBranches } from "../../utils/profileBranches"
import { enrichProfilesWithTeams } from "../../utils/profileTeams" import { enrichProfilesWithTeams } from "../../utils/profileTeams"
@@ -52,7 +52,7 @@ export default async function tenantRoutesInternal(server: FastifyInstance) {
const users = await server.db const users = await server.db
.select() .select()
.from(authUsers) .from(authUsers)
.where(and(inArray(authUsers.id, userIds), isNull(authUsers.archived_at))) .where(inArray(authUsers.id, userIds))
// 3) auth_profiles pro Tenant laden // 3) auth_profiles pro Tenant laden
const profileRows = await server.db const profileRows = await server.db
@@ -61,9 +61,7 @@ export default async function tenantRoutesInternal(server: FastifyInstance) {
.where( .where(
and( and(
eq(authProfiles.tenant_id, tenantId), eq(authProfiles.tenant_id, tenantId),
inArray(authProfiles.user_id, userIds), inArray(authProfiles.user_id, userIds)
eq(authProfiles.active, true),
isNull(authProfiles.archived_at)
)) ))
const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows) const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows)
const profiles = await enrichProfilesWithTeams(server, profilesWithBranches) const profiles = await enrichProfilesWithTeams(server, profilesWithBranches)
@@ -100,7 +98,7 @@ export default async function tenantRoutesInternal(server: FastifyInstance) {
const profileRows = await server.db const profileRows = await server.db
.select() .select()
.from(authProfiles) .from(authProfiles)
.where(and(eq(authProfiles.tenant_id, tenantId), eq(authProfiles.active, true), isNull(authProfiles.archived_at))) .where(eq(authProfiles.tenant_id, tenantId))
const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows) const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows)
return await enrichProfilesWithTeams(server, profilesWithBranches) return await enrichProfilesWithTeams(server, profilesWithBranches)

View File

@@ -1,5 +1,5 @@
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
import { eq, and, isNull } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import { import {
authProfiles, authProfiles,
@@ -57,7 +57,7 @@ export default async function authProfilesRoutes(server: FastifyInstance) {
const forbidden = [ const forbidden = [
"id", "user_id", "tenant_id", "created_at", "updated_at", "id", "user_id", "tenant_id", "created_at", "updated_at",
"updatedAt", "updatedBy", "old_profile_id", "full_name", "updatedAt", "updatedBy", "old_profile_id", "full_name",
"branch", "archived_at", "calendar_subscription_token", "branch", "calendar_subscription_token",
"calendar_subscription_path", "calendar_subscription_url" "calendar_subscription_path", "calendar_subscription_url"
] ]
forbidden.forEach(f => delete cleaned[f]) forbidden.forEach(f => delete cleaned[f])
@@ -135,8 +135,7 @@ export default async function authProfilesRoutes(server: FastifyInstance) {
.where( .where(
and( and(
eq(authProfiles.id, id), eq(authProfiles.id, id),
eq(authProfiles.tenant_id, tenantId), eq(authProfiles.tenant_id, tenantId)
isNull(authProfiles.archived_at)
) )
) )
.returning() .returning()

View File

@@ -9,14 +9,12 @@ import {
and, and,
count, count,
inArray, inArray,
isNull,
or, or,
sql, sql,
} from "drizzle-orm" } from "drizzle-orm"
import { import {
authProfiles, authProfiles,
authUsers,
createddocuments, createddocuments,
customers, customers,
entitybankaccounts, entitybankaccounts,
@@ -140,10 +138,7 @@ function buildFieldUpdateHistoryText(resource: string, label: string, oldValue:
return `${resourceLabel}: ${label} geändert von "${formatDiffValue(oldValue)}" zu "${formatDiffValue(newValue)}"` return `${resourceLabel}: ${label} geändert von "${formatDiffValue(oldValue)}" zu "${formatDiffValue(newValue)}"`
} }
function applyResourceWhereFilters(resource: string, table: any, whereCond: any, list = false) { function applyResourceWhereFilters(resource: string, table: any, whereCond: any) {
if (list && table === authProfiles) {
return and(whereCond, eq(authProfiles.active, true), isNull(authProfiles.archived_at))
}
if (resource === "members") { if (resource === "members") {
return and(whereCond, eq(table.type, "Mitglied")) return and(whereCond, eq(table.type, "Mitglied"))
} }
@@ -575,7 +570,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
const tenantColumn = getTenantColumn(resource, table) const tenantColumn = getTenantColumn(resource, table)
let whereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined let whereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined
whereCond = applyResourceWhereFilters(resource, table, whereCond, true) whereCond = applyResourceWhereFilters(resource, table, whereCond)
whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId) whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId)
let q = server.db.select().from(table).$dynamic() let q = server.db.select().from(table).$dynamic()
@@ -693,7 +688,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
const tenantColumn = getTenantColumn(resource, table); const tenantColumn = getTenantColumn(resource, table);
let whereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined; let whereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined;
whereCond = applyResourceWhereFilters(resource, table, whereCond, true) whereCond = applyResourceWhereFilters(resource, table, whereCond)
whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId) whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId)
const searchCols: any[] = (config.searchColumns || []).map(c => table[c]); const searchCols: any[] = (config.searchColumns || []).map(c => table[c]);
const debugSearchColumnNames: string[] = [...(config.searchColumns || [])]; const debugSearchColumnNames: string[] = [...(config.searchColumns || [])];
@@ -794,7 +789,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
}); });
} }
let distinctWhereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined let distinctWhereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined
distinctWhereCond = applyResourceWhereFilters(resource, table, distinctWhereCond, true) distinctWhereCond = applyResourceWhereFilters(resource, table, distinctWhereCond)
distinctWhereCond = applyPortalScope(resource, table, distinctWhereCond, portalCustomerId) distinctWhereCond = applyPortalScope(resource, table, distinctWhereCond, portalCustomerId)
if (search) { if (search) {
@@ -883,7 +878,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
} }
const table = resourceConfig[resource].table const table = resourceConfig[resource].table
let whereCond: any = and(eq(table.id, id), eq(getTenantColumn(resource, table), tenantId)) let whereCond: any = and(eq(table.id, id), eq(table.tenant, tenantId))
whereCond = applyResourceWhereFilters(resource, table, whereCond) whereCond = applyResourceWhereFilters(resource, table, whereCond)
whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId) whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId)
@@ -948,15 +943,6 @@ export default async function resourceRoutes(server: FastifyInstance) {
const table = config.table; const table = config.table;
let createData: Record<string, any> = { ...body, tenant: req.user.tenant_id, archived: false }; let createData: Record<string, any> = { ...body, tenant: req.user.tenant_id, archived: false };
if (table === authProfiles) {
delete createData.archived_at;
createData.tenant_id = req.user.tenant_id;
if (createData.user_id) {
const [owner] = await server.db.select({ id: authUsers.id }).from(authUsers)
.where(and(eq(authUsers.id, createData.user_id), isNull(authUsers.archived_at))).limit(1);
if (!owner) return reply.code(409).send({ error: "Aktiver Benutzer nicht gefunden." });
}
}
if (resource === "projects") { if (resource === "projects") {
const error = projectChatSettingError(req, createData) const error = projectChatSettingError(req, createData)
if (error) return reply.code(error.statusCode).send({ error: error.error }) if (error) return reply.code(error.statusCode).send({ error: error.error })
@@ -1107,7 +1093,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
const [oldRecord] = await server.db const [oldRecord] = await server.db
.select() .select()
.from(table) .from(table)
.where(applyPortalScope(resource, table, and(eq(table.id, id), eq(getTenantColumn(resource, table), tenantId)), portalCustomerId)) .where(applyPortalScope(resource, table, and(eq(table.id, id), eq(table.tenant, tenantId)), portalCustomerId))
.limit(1) .limit(1)
if (!oldRecord) { if (!oldRecord) {
@@ -1119,13 +1105,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
if (error) return reply.code(error.statusCode).send({ error: error.error }) if (error) return reply.code(error.statusCode).send({ error: error.error })
} }
if (table === authProfiles && oldRecord.archived_at) {
return reply.code(409).send({ error: "Archivierte Profile können nicht mehr bearbeitet werden." })
}
let data: Record<string, any> = { ...body } let data: Record<string, any> = { ...body }
if (table === authProfiles) {
for (const key of ["archived_at", "user_id", "tenant_id", "id"]) delete data[key]
}
delete data.updatedAt delete data.updatedAt
delete data.updated_at delete data.updated_at
delete data.updatedBy delete data.updatedBy
@@ -1214,8 +1194,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
} }
}) })
let updateWhereCond: any = and(eq(table.id, id), eq(getTenantColumn(resource, table), tenantId)) let updateWhereCond: any = and(eq(table.id, id), eq(table.tenant, tenantId))
if (table === authProfiles) updateWhereCond = and(updateWhereCond, isNull(authProfiles.archived_at))
updateWhereCond = applyResourceWhereFilters(resource, table, updateWhereCond) updateWhereCond = applyResourceWhereFilters(resource, table, updateWhereCond)
const [updated] = await server.db.update(table).set(data).where(updateWhereCond).returning() const [updated] = await server.db.update(table).set(data).where(updateWhereCond).returning()
@@ -1224,7 +1203,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
.update(table) .update(table)
.set({ defaultMandate: false }) .set({ defaultMandate: false })
.where(and( .where(and(
eq(getTenantColumn(resource, table), tenantId), eq(table.tenant, tenantId),
eq(table.customer, updated.customer), eq(table.customer, updated.customer),
sql`${table.id} <> ${updated.id}` sql`${table.id} <> ${updated.id}`
)) ))

View File

@@ -11,7 +11,7 @@ import {
m2mApiKeys m2mApiKeys
} from "../../db/schema" } from "../../db/schema"
import {and, desc, eq, inArray, isNull} from "drizzle-orm" import {and, desc, eq, inArray} from "drizzle-orm"
import { enrichProfilesWithBranches } from "../utils/profileBranches" import { enrichProfilesWithBranches } from "../utils/profileBranches"
import { enrichProfilesWithTeams } from "../utils/profileTeams" import { enrichProfilesWithTeams } from "../utils/profileTeams"
import { rotateRefreshToken } from "../utils/authTokens" import { rotateRefreshToken } from "../utils/authTokens"
@@ -152,7 +152,7 @@ export default async function tenantRoutes(server: FastifyInstance) {
const users = await server.db const users = await server.db
.select() .select()
.from(authUsers) .from(authUsers)
.where(and(inArray(authUsers.id, userIds), isNull(authUsers.archived_at))) .where(inArray(authUsers.id, userIds))
// 3) auth_profiles pro Tenant laden // 3) auth_profiles pro Tenant laden
const profileRows = await server.db const profileRows = await server.db
@@ -161,9 +161,7 @@ export default async function tenantRoutes(server: FastifyInstance) {
.where( .where(
and( and(
eq(authProfiles.tenant_id, tenantId), eq(authProfiles.tenant_id, tenantId),
inArray(authProfiles.user_id, userIds), inArray(authProfiles.user_id, userIds)
eq(authProfiles.active, true),
isNull(authProfiles.archived_at)
)) ))
const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows) const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows)
const profiles = await enrichProfilesWithTeams(server, profilesWithBranches) const profiles = await enrichProfilesWithTeams(server, profilesWithBranches)
@@ -196,14 +194,10 @@ export default async function tenantRoutes(server: FastifyInstance) {
const tenantId = req.user?.tenant_id const tenantId = req.user?.tenant_id
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" }) if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
const includeArchived = (req.query as { includeArchived?: string }).includeArchived === "true"
const profileRows = await server.db const profileRows = await server.db
.select() .select()
.from(authProfiles) .from(authProfiles)
.where(and( .where(eq(authProfiles.tenant_id, tenantId))
eq(authProfiles.tenant_id, tenantId),
...(includeArchived ? [] : [eq(authProfiles.active, true), isNull(authProfiles.archived_at)])
))
const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows) const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows)
const data = await enrichProfilesWithTeams(server, profilesWithBranches) const data = await enrichProfilesWithTeams(server, profilesWithBranches)
@@ -277,16 +271,6 @@ export default async function tenantRoutes(server: FastifyInstance) {
if (!data) return reply.code(400).send({ error: "data required" }) if (!data) return reply.code(400).send({ error: "data required" })
if (Object.prototype.hasOwnProperty.call(data, "portalConfig")) {
const config = data.portalConfig
if (!config || typeof config !== "object" || Array.isArray(config)
|| typeof config.primaryColor !== "string" || !/^#[0-9a-f]{6}$/i.test(config.primaryColor.trim())) {
return reply.code(400).send({ error: "Die Portal-Akzentfarbe muss ein Hex-Farbwert wie #69c350 sein." })
}
data.portalConfig = { ...config, primaryColor: config.primaryColor.trim().toLowerCase() }
delete data.portalConfig.primayColor
}
const updated = await server.db const updated = await server.db
.update(tenants) .update(tenants)
.set(data) .set(data)

View File

@@ -70,7 +70,7 @@ export async function rotateRefreshToken(
const [user] = await server.db const [user] = await server.db
.select({ id: authUsers.id, email: authUsers.email }) .select({ id: authUsers.id, email: authUsers.email })
.from(authUsers) .from(authUsers)
.where(and(eq(authUsers.id, session.userId), isNull(authUsers.archived_at))) .where(eq(authUsers.id, session.userId))
.limit(1) .limit(1)
if (!user) return null if (!user) return null

View File

@@ -1,5 +1,5 @@
import crypto from "crypto" import crypto from "crypto"
import { and, asc, eq, isNull, sql } from "drizzle-orm" import { and, asc, eq, sql } from "drizzle-orm"
import { FastifyInstance } from "fastify" import { FastifyInstance } from "fastify"
import { authProfiles, events } from "../../db/schema" import { authProfiles, events } from "../../db/schema"
@@ -105,8 +105,7 @@ export async function generateProfileCalendarSubscriptionToken(server: FastifyIn
.where( .where(
and( and(
eq(authProfiles.id, profileId), eq(authProfiles.id, profileId),
eq(authProfiles.tenant_id, tenantId), eq(authProfiles.tenant_id, tenantId)
isNull(authProfiles.archived_at)
) )
) )
.returning() .returning()

View File

@@ -1,5 +1,5 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { and, eq, isNull } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { authUsers } from "../../db/schema/auth_users"; import { authUsers } from "../../db/schema/auth_users";
export async function requireSystemAdmin(server: FastifyInstance, req: FastifyRequest, reply: FastifyReply) { export async function requireSystemAdmin(server: FastifyInstance, req: FastifyRequest, reply: FastifyReply) {
@@ -9,7 +9,7 @@ export async function requireSystemAdmin(server: FastifyInstance, req: FastifyRe
} }
// Read the current global role; tenant roles and stale JWT claims do not grant access. // Read the current global role; tenant roles and stale JWT claims do not grant access.
const [user] = await server.db.select({ id: authUsers.id, email: authUsers.email, is_admin: authUsers.is_admin }) const [user] = await server.db.select({ id: authUsers.id, email: authUsers.email, is_admin: authUsers.is_admin })
.from(authUsers).where(and(eq(authUsers.id, req.user.user_id), isNull(authUsers.archived_at))).limit(1); .from(authUsers).where(eq(authUsers.id, req.user.user_id)).limit(1);
if (!user?.is_admin) { if (!user?.is_admin) {
reply.code(403).send({ error: "Admin access required" }); reply.code(403).send({ error: "Admin access required" });
return null; return null;

View File

@@ -24,7 +24,7 @@ export async function collaborationDatabase(migrations: string[] = []) {
const columns = Object.values(getTableColumns(table)).map((column: any) => { const columns = Object.values(getTableColumns(table)).map((column: any) => {
let suffix = column.primary ? " PRIMARY KEY" : "" let suffix = column.primary ? " PRIMARY KEY" : ""
if (column.name === "id" && column.getSQLType() === "uuid") suffix += " DEFAULT gen_random_uuid()" if (column.name === "id" && column.getSQLType() === "uuid") suffix += " DEFAULT gen_random_uuid()"
if (column.columnType === "PgTimestamp" && column.hasDefault) suffix += " DEFAULT now()" if (column.columnType === "PgTimestamp") suffix += " DEFAULT now()"
if (column.getSQLType() === "boolean") suffix += ` DEFAULT ${column.default === true ? "true" : "false"}` if (column.getSQLType() === "boolean") suffix += ` DEFAULT ${column.default === true ? "true" : "false"}`
return `"${column.name}" ${column.getSQLType()}${suffix}` return `"${column.name}" ${column.getSQLType()}${suffix}`
}) })

View File

@@ -151,7 +151,6 @@ test("HTTP MCP accepts uploads above 1 MiB and streams authorized PDFs without e
return { rows: [[id, 7, id, true, null, "test@example.invalid", admin]] } return { rows: [[id, 7, id, true, null, "test@example.invalid", admin]] }
} }
if (sql.startsWith('update "m2m_api_keys"')) return { rows: [] } if (sql.startsWith('update "m2m_api_keys"')) return { rows: [] }
if (sql.includes('from "auth_users"')) return { rows: [[admin]] }
if (sql.includes('from "tenants"')) return { rows: [[null, null]] } if (sql.includes('from "tenants"')) return { rows: [[null, null]] }
if (sql.includes('from "auth_user_roles"')) return { rows: [[id]] } if (sql.includes('from "auth_user_roles"')) return { rows: [[id]] }
if (sql.includes('from "auth_role_permissions"')) return { rows: [] } if (sql.includes('from "auth_role_permissions"')) return { rows: [] }

View File

@@ -1,233 +0,0 @@
import assert from "node:assert/strict"
import test from "node:test"
import Fastify from "fastify"
import cookie from "@fastify/cookie"
import bcrypt from "bcrypt"
import jwt from "jsonwebtoken"
import { drizzle } from "drizzle-orm/pg-proxy"
import { Pool } from "pg"
import { createHash } from "node:crypto"
import authRoutes from "../src/routes/auth/auth"
import userRoutes from "../src/routes/auth/user"
import authPlugin from "../src/plugins/auth"
import m2mAuthPlugin from "../src/plugins/auth.m2m"
import tenantRoutes from "../src/routes/tenant"
import internalTenantRoutes from "../src/routes/internal/tenant"
import profileRoutes from "../src/routes/profiles"
import resourceRoutes from "../src/routes/resources/main"
import { secrets } from "../src/utils/secrets"
const adminId = "00000000-0000-4000-8000-000000000001"
const userId = "00000000-0000-4000-8000-000000000002"
const profileId = "00000000-0000-4000-8000-000000000003"
const otherProfileId = "00000000-0000-4000-8000-000000000004"
const passwordHash = bcrypt.hashSync("archive-test-password", 4)
type Row = Record<string, any>
// Execute the generated SQL against a small fixture, including its archive filters.
// This keeps the HTTP/authentication tests independent of a configured production DB.
function matches(where: string, params: any[], row: Row) {
for (const match of where.matchAll(/(?:"\w+"\.)?"(\w+)" = \$(\d+)/g)) {
if (String(row[match[1]]) !== String(params[Number(match[2]) - 1])) return false
}
for (const match of where.matchAll(/(?:"\w+"\.)?"(\w+)" is null/g)) {
if (row[match[1]] != null) return false
}
for (const match of where.matchAll(/(?:"\w+"\.)?"(\w+)" in \(([^)]+)\)/g)) {
const values = [...match[2].matchAll(/\$(\d+)/g)].map(value => params[Number(value[1]) - 1])
if (!values.includes(row[match[1]])) return false
}
return true
}
function selected(columns: string, row: Row) {
return columns.split(", ").map(column => {
const name = [...column.matchAll(/"([^"]+)"/g)].at(-1)?.[1]
return row[name!] ?? null
})
}
async function fixture(t: any) {
const data: Record<string, Row[]> = {
auth_users: [
{ id: adminId, email: "admin@example.test", is_admin: true, archived_at: null },
{ id: userId, email: "former@example.test", password_hash: passwordHash, is_admin: false, archived_at: null },
],
auth_profiles: [
{ id: profileId, user_id: userId, tenant_id: 7, active: true, archived_at: null, first_name: "Former", last_name: "User", full_name: "Former User", token_id: "rfid", calendar_subscription_token: "calendar" },
{ id: otherProfileId, user_id: userId, tenant_id: 8, active: false, archived_at: null },
{ id: adminId, user_id: adminId, tenant_id: 7, active: true, archived_at: null },
],
auth_tenant_users: [{ tenant_id: 7, user_id: userId }, { tenant_id: 8, user_id: userId }, { tenant_id: 7, user_id: adminId }],
auth_refresh_tokens: [{ id: userId, user_id: userId, tenant_id: 7, token_hash: "old", revoked_at: null }],
m2m_api_keys: [{ id: userId, user_id: userId, tenant_id: 7, active: true, key_hash: createHash("sha256").update("fedeo_mcp_archive_test").digest("hex") }],
tenants: [{ id: 7, name: "First" }, { id: 8, name: "Second" }],
}
const queries: string[] = []
let inTransaction = false
const db = drizzle(async (sql, params) => {
queries.push(sql)
if (sql.startsWith("select ")) {
const table = sql.match(/from "([^"]+)"/)![1]
const source = table === "m2m_api_keys"
? data[table].map(row => ({ ...row, ...data.auth_users.find(user => user.id === row.user_id) }))
: data[table] || []
const rows = source.filter(row => matches(sql.split(" where ")[1] || "", params, row))
if (sql.startsWith("select count(")) return { rows: [[rows.length]] }
return { rows: rows.map(row => selected(sql.slice(7, sql.indexOf(" from ")), row)) }
}
if (sql.startsWith("update ")) {
const table = sql.match(/update "([^"]+)"/)![1]
const rows = (data[table] || []).filter(row => matches(sql.split(" where ")[1] || "", params, row))
if (sql.includes('"archived_at" =')) assert.ok(inTransaction, "Archiving must be atomic")
const assignments = sql.slice(sql.indexOf(" set "), sql.indexOf(" where "))
for (const row of rows) {
for (const match of assignments.matchAll(/"(\w+)" = \$(\d+)/g)) row[match[1]] = params[Number(match[2]) - 1]
}
return { rows: sql.includes(" returning ") ? rows.map(row => selected(sql.split(" returning ")[1], row)) : [] }
}
throw new Error(`Unexpected mutation: ${sql}`)
})
;(db as any).transaction = async (fn: any) => {
inTransaction = true
try { return await fn(db) } finally { inTransaction = false }
}
const app = Fastify()
app.decorate("db", db as any)
app.addHook("preHandler", async (req: any) => {
req.user = { user_id: req.headers["x-test-user"] || adminId, tenant_id: 7 }
req.queryConfig = { pagination: { limit: 100, offset: 0 }, filters: {}, sort: [] }
})
// adminRoutes imports the export utility, whose legacy DB module probes on import.
const queryMock = t.mock.method(Pool.prototype, "query", async () => ({ rows: [{ now: "test" }] }))
const { default: adminRoutes } = await import("../src/routes/admin")
queryMock.mock.restore()
await app.register(adminRoutes)
await app.register(tenantRoutes)
await app.register(profileRoutes)
await app.register(userRoutes)
await app.register(resourceRoutes)
await app.register(internalTenantRoutes, { prefix: "/internal" })
t.after(() => app.close())
const archive = (id = userId, caller = adminId) => app.inject({ method: "POST", url: `/admin/users/${id}/archive`, headers: { "x-test-user": caller } })
return { app, db, data, queries, archive }
}
test("archiving preserves history, archives every tenant profile and revokes credentials atomically", async t => {
const f = await fixture(t)
const memberships = structuredClone(f.data.auth_tenant_users)
const response = await f.archive()
assert.equal(response.statusCode, 200, response.body)
const archivedAt = response.json().user.archived_at
assert.ok(archivedAt)
for (const profile of f.data.auth_profiles.filter(profile => profile.user_id === userId)) {
assert.equal(profile.active, false)
assert.equal(profile.archived_at, archivedAt)
assert.equal(profile.token_id, null)
assert.equal(profile.calendar_subscription_token, null)
}
assert.equal(f.data.auth_profiles[2].active, true)
assert.equal(f.data.auth_users[0].archived_at, null)
assert.equal(f.data.auth_refresh_tokens[0].revoked_at, archivedAt)
assert.equal(f.data.m2m_api_keys[0].active, false)
assert.deepEqual(f.data.auth_tenant_users, memberships)
assert.equal((await f.archive()).json().user.archived_at, archivedAt)
assert.ok(f.queries.some(sql => sql.endsWith("for update")))
})
test("only an active system admin can archive another existing user", async t => {
const f = await fixture(t)
assert.equal((await f.archive(adminId, userId)).statusCode, 403)
assert.equal((await f.archive(adminId)).statusCode, 409)
assert.equal((await f.archive(profileId)).statusCode, 404)
assert.equal((await f.archive("invalid-id")).statusCode, 400)
f.data.auth_users[0].archived_at = new Date().toISOString()
assert.equal((await f.archive()).statusCode, 403)
assert.equal(f.data.auth_users[1].archived_at, null)
})
test("archived users disappear from active administration and tenant lists but retain a readable archive", async t => {
const f = await fixture(t)
await f.archive()
const overview = (await f.app.inject("/admin/overview")).json()
assert.deepEqual(overview.users.map((row: Row) => row.id), [adminId])
assert.equal(overview.tenants.find((row: Row) => row.id === 7).user_count, 1)
const archived = (await f.app.inject("/admin/overview?includeArchived=true")).json().users.find((row: Row) => row.id === userId)
assert.ok(archived.archived_at)
assert.equal(archived.profiles.length, 2)
for (const url of ["/tenant/users", "/internal/tenant/users"]) {
const response = await f.app.inject(url)
assert.equal(response.statusCode, 200, response.body)
assert.deepEqual(response.json().users.map((row: Row) => row.id), [adminId])
}
for (const url of ["/tenant/profiles", "/internal/tenant/7/profiles", "/resource/profiles", "/resource/authProfile", "/resource/profiles/paginated"]) {
const response = await f.app.inject(url)
assert.equal(response.statusCode, 200, response.body)
const body = response.json()
assert.deepEqual((body.data || body).map((row: Row) => row.id), [adminId], url)
}
const historic = (await f.app.inject("/tenant/profiles?includeArchived=true")).json().data
assert.equal(historic.find((row: Row) => row.id === profileId).full_name, "Former User")
const detail = await f.app.inject(`/resource/profiles/${profileId}`)
assert.equal(detail.statusCode, 200, detail.body)
assert.equal(detail.json().full_name, "Former User")
})
test("archived accounts and profiles cannot be edited, reassigned or reactivated", async t => {
const f = await fixture(t)
await f.archive()
for (const [method, url, payload, status] of [
["PUT", `/admin/users/${userId}`, { archived_at: null, is_admin: true }, 409],
["PUT", `/admin/users/${userId}/access`, { tenant_ids: [9] }, 409],
["POST", `/admin/users/${userId}/reset-password`, {}, 409],
["POST", "/admin/add-user-to-tenant", { user_id: userId, tenant_id: 9 }, 404],
["PUT", `/user/${userId}/profile`, { data: { active: true, archived_at: null, user_id: adminId } }, 404],
["PUT", `/profiles/${profileId}`, { active: true, archived_at: null }, 404],
["PUT", `/resource/profiles/${profileId}`, { active: true, archived_at: null, user_id: adminId }, 409],
["POST", `/profiles/${profileId}/calendar-subscription-token`, {}, 404],
["POST", "/resource/profiles", { user_id: userId, first_name: "New", last_name: "Profile" }, 409],
] as const) {
const response = await f.app.inject({ method, url, payload })
assert.equal(response.statusCode, status, `${url}: ${response.body}`)
}
assert.equal(f.data.auth_profiles[0].active, false)
assert.equal(f.data.auth_profiles[0].user_id, userId)
})
test("archiving blocks password login, refresh, existing JWTs and legacy API keys", async t => {
const f = await fixture(t)
const oldSecret = secrets.JWT_SECRET, oldM2mKey = secrets.M2M_API_KEY
secrets.JWT_SECRET = "archive-test-secret"
secrets.M2M_API_KEY = "archive-test-legacy-key"
t.after(() => { secrets.JWT_SECRET = oldSecret; secrets.M2M_API_KEY = oldM2mKey })
const app = Fastify()
app.decorate("db", f.db as any)
await app.register(cookie)
await app.register(authRoutes)
await app.register(async protectedApp => {
await protectedApp.register(authPlugin)
protectedApp.get("/check", async () => ({ success: true }))
protectedApp.get("/mcp", async () => ({ success: true }))
}, { prefix: "/api" })
await app.register(async internal => {
await internal.register(m2mAuthPlugin)
internal.get("/check", async () => ({ success: true }))
}, { prefix: "/internal" })
t.after(() => app.close())
const token = jwt.sign({ user_id: userId, tenant_id: null, is_admin: true }, secrets.JWT_SECRET)
const headers = { authorization: `Bearer ${token}` }
assert.equal((await app.inject({ url: "/api/check", headers })).statusCode, 200)
await f.archive()
assert.equal((await app.inject({ url: "/api/check", headers })).statusCode, 401)
assert.equal((await app.inject({ method: "POST", url: "/auth/login", payload: { email: "former@example.test", password: "archive-test-password" } })).statusCode, 401)
assert.equal((await app.inject({ method: "POST", url: "/auth/password/reset", payload: { email: "former@example.test" } })).statusCode, 404)
// Even a session issued concurrently with archiving must fail the current-user check.
f.data.auth_refresh_tokens[0].revoked_at = null
const { hashRefreshToken } = await import("../src/utils/authTokens")
f.data.auth_refresh_tokens[0].token_hash = hashRefreshToken("old-refresh-token")
assert.equal((await app.inject({ method: "POST", url: "/auth/refresh", payload: { refreshToken: "old-refresh-token" } })).statusCode, 401)
f.data.m2m_api_keys[0].active = true
assert.equal((await app.inject({ url: "/api/mcp", headers: { authorization: "Bearer fedeo_mcp_archive_test" } })).statusCode, 401)
assert.equal((await app.inject({ url: "/internal/check", headers: { "x-api-key": "fedeo_mcp_archive_test" } })).statusCode, 401)
assert.equal((await app.inject({ url: "/internal/check", headers: { "x-api-key": secrets.M2M_API_KEY, "x-user-id": userId, "x-tenant-id": "7" } })).statusCode, 401)
})

View File

@@ -1,6 +1,6 @@
# Persönliche Benachrichtigungen # Persönliche Benachrichtigungen
Unter Einstellungen → Benachrichtigungen (`/settings/notifications`) im Web und Mehr → Einstellungen in der mobilen App kann jeder Benutzer E-Mail und Push für sein Profil im aktiven Tenant einstellen. Der Web-Menüeintrag ist für alle angemeldeten Mitarbeiter unabhängig von Administrationsrechten sichtbar. Für ein anderes Profil wird zuerst der Tenant gewechselt. Die E-Mail-Adresse stammt aktuell vom Benutzerkonto, nicht vom Mitarbeiterprofil. Unter Einstellungen → Benachrichtigungen im Web und Mehr → Einstellungen in der mobilen App kann jeder Benutzer E-Mail und Push für sein Profil im aktiven Tenant einstellen. Für ein anderes Profil wird zuerst der Tenant gewechselt. Die E-Mail-Adresse stammt aktuell vom Benutzerkonto, nicht vom Mitarbeiterprofil.
## Ereignisse ## Ereignisse

View File

@@ -1,57 +0,0 @@
<script setup>
const props = defineProps({
modelValue: {
type: String,
default: null
},
disabled: {
type: Boolean,
default: false
},
label: {
type: String,
default: 'Farbe'
}
})
const emit = defineEmits(['update:modelValue'])
const pickerValue = computed(() => {
const value = props.modelValue?.trim() || ''
if (/^#[\da-f]{6}$/i.test(value)) return value
if (/^#[\da-f]{3}$/i.test(value)) {
return `#${value.slice(1).split('').map(character => character.repeat(2)).join('')}`
}
return '#2563eb'
})
</script>
<template>
<InputGroup class="w-full">
<input
type="color"
:value="pickerValue"
:disabled="disabled"
:aria-label="`${label} auswählen`"
class="h-9 w-14 shrink-0 cursor-pointer rounded border border-default bg-transparent p-1 disabled:cursor-not-allowed disabled:opacity-50"
@input="emit('update:modelValue', $event.target.value)"
>
<UInput
:model-value="modelValue"
:disabled="disabled"
:aria-label="`${label} als Farbwert`"
class="min-w-0 flex-1"
placeholder="Standardfarbe"
@update:model-value="emit('update:modelValue', $event || null)"
/>
<UButton
type="button"
:disabled="disabled || !modelValue"
:aria-label="`${label} zurücksetzen`"
variant="outline"
color="neutral"
icon="i-heroicons-x-mark"
@click="emit('update:modelValue', null)"
/>
</InputGroup>
</template>

View File

@@ -385,8 +385,6 @@ const getEntityModalCreateQuery = (datapoint) => {
const getPostSaveRoute = () => { const getPostSaveRoute = () => {
if (type !== "events") return null if (type !== "events") return null
if (route.query.returnTo === "events") return "/events"
if (route.query.returnTo === "plantafel") { if (route.query.returnTo === "plantafel") {
return { return {
path: "/organisation/plantafel", path: "/organisation/plantafel",
@@ -477,21 +475,25 @@ const updateItem = async () => {
<template> <template>
<UDashboardNavbar <UDashboardNavbar
v-if="!props.inModal" v-if="!props.inModal"
:title="item.id ? `${dataType.labelSingle} bearbeiten` : `${dataType.labelSingle} erstellen`"
:ui="{center: 'flex items-stretch gap-1.5 min-w-0'}" :ui="{center: 'flex items-stretch gap-1.5 min-w-0'}"
> >
<template #toggle> <template #toggle>
<div v-if="platform === 'mobile'"></div> <div v-if="platform === 'mobile'"></div>
</template> </template>
<template #leading> <template #left>
<UButton <UButton
icon="i-heroicons-chevron-left" icon="i-heroicons-chevron-left"
variant="outline" variant="outline"
aria-label="Zurück"
@click="router.back()" @click="router.back()"
> >
</UButton> </UButton>
</template> </template>
<template #center>
<h1
v-if="item"
:class="['text-xl','font-medium', 'text-center']"
>{{ item.id ? `${dataType.labelSingle} bearbeiten` : `${dataType.labelSingle} erstellen` }}</h1>
</template>
<template #right> <template #right>
<ArchiveButton <ArchiveButton
color="error" color="error"
@@ -515,7 +517,7 @@ const updateItem = async () => {
Erstellen Erstellen
</UButton> </UButton>
<UButton <UButton
@click="router.push(getPostSaveRoute() || (item.id ? `/standardEntity/${type}/show/${item.id}` : `/standardEntity/${type}`))" @click="router.push(item.id ? `/standardEntity/${type}/show/${item.id}` : `/standardEntity/${type}`)"
color="red" color="red"
class="ml-1" class="ml-1"
> >
@@ -525,9 +527,14 @@ const updateItem = async () => {
</UDashboardNavbar> </UDashboardNavbar>
<UDashboardNavbar <UDashboardNavbar
v-else v-else
:title="props.floatingWindow ? '' : (item.id ? `${dataType.labelSingle} bearbeiten` : `${dataType.labelSingle} erstellen`)"
:ui="{center: 'flex items-stretch gap-1.5 min-w-0'}" :ui="{center: 'flex items-stretch gap-1.5 min-w-0'}"
> >
<template #center>
<h1
v-if="item && !props.floatingWindow"
:class="['text-xl', 'font-medium']"
>{{ item.id ? `${dataType.labelSingle} bearbeiten` : `${dataType.labelSingle} erstellen` }}</h1>
</template>
<template #right> <template #right>
<UButton <UButton
v-if="item.id" v-if="item.id"
@@ -579,14 +586,7 @@ const updateItem = async () => {
:item="item" :item="item"
/> />
</template> </template>
<ColorInput <InputGroup class="w-full" v-if="datapoint.key.includes('.')">
v-if="datapoint.inputType === 'color'"
:model-value="getDatapointValue(datapoint)"
:label="datapoint.label"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
@update:model-value="(value) => setDatapointValue(datapoint, value)"
/>
<InputGroup class="w-full" v-else-if="datapoint.key.includes('.')">
<UInput <UInput
:color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'error') : 'white'" :color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'error') : 'white'"
class="flex-auto" class="flex-auto"
@@ -833,14 +833,7 @@ const updateItem = async () => {
:item="item" :item="item"
/> />
</template> </template>
<ColorInput <InputGroup class="w-full" v-if="datapoint.key.includes('.')">
v-if="datapoint.inputType === 'color'"
:model-value="getDatapointValue(datapoint)"
:label="datapoint.label"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
@update:model-value="(value) => setDatapointValue(datapoint, value)"
/>
<InputGroup class="w-full" v-else-if="datapoint.key.includes('.')">
<UInput <UInput
:color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'error') : 'white'" :color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'error') : 'white'"
class="flex-auto" class="flex-auto"

View File

@@ -124,16 +124,6 @@ const invoiceDeliveryNotes = () => {
router.push(`/createDocument/edit?type=invoices&loadMode=deliveryNotes&linkedDocuments=[${props.item.createddocuments.filter(i => deliveryNoteLikeDocumentTypes.includes(i.type)).map(i => i.id)}]`) router.push(`/createDocument/edit?type=invoices&loadMode=deliveryNotes&linkedDocuments=[${props.item.createddocuments.filter(i => deliveryNoteLikeDocumentTypes.includes(i.type)).map(i => i.id)}]`)
} }
const createAdvanceInvoice = () => {
if (props.topLevelType !== 'projects' || !props.item.id) return
const query = new URLSearchParams(props.queryStringData)
query.set('type', 'advanceInvoices')
query.set('loadMode', 'advanceInvoice')
query.set('project', props.item.id)
router.push(`/createDocument/edit?${query.toString()}`)
}
const showFinalInvoiceConfig = ref(false) const showFinalInvoiceConfig = ref(false)
const referenceDocument = ref(null) const referenceDocument = ref(null)
const advanceInvoicesToAdd = ref([]) const advanceInvoicesToAdd = ref([])
@@ -179,13 +169,6 @@ const selectItem = (item) => {
> >
Dokument aus Vorlage Dokument aus Vorlage
</UButton> </UButton>
<UButton
v-if="props.topLevelType === 'projects'"
icon="i-heroicons-plus"
@click="createAdvanceInvoice"
>
Abschlagsrechnung
</UButton>
<UButton <UButton
@click="showFinalInvoiceConfig = true" @click="showFinalInvoiceConfig = true"
v-if="props.topLevelType === 'projects'" v-if="props.topLevelType === 'projects'"

View File

@@ -38,12 +38,6 @@ const isRouteActive = (to) => {
const links = computed(() => { const links = computed(() => {
const organisationChildren = [ const organisationChildren = [
has("events") && featureEnabled("calendar") ? {
label: "Termine",
to: "/events",
icon: "i-heroicons-calendar-days",
active: route.path.startsWith("/standardEntity/events")
} : null,
has("tasks") && featureEnabled("tasks") ? { has("tasks") && featureEnabled("tasks") ? {
label: "Aufgaben", label: "Aufgaben",
to: "/tasks", to: "/tasks",
@@ -324,11 +318,6 @@ const links = computed(() => {
] ]
const settingsChildren = [ const settingsChildren = [
{
label: "Benachrichtigungen",
to: "/settings/notifications",
icon: "i-heroicons-bell",
},
featureEnabled("settingsNumberRanges") ? { featureEnabled("settingsNumberRanges") ? {
label: "Nummernkreise", label: "Nummernkreise",
to: "/settings/numberRanges", to: "/settings/numberRanges",

View File

@@ -18,9 +18,9 @@ const props = defineProps({
const emit = defineEmits(["updateNeeded","returnData"]) const emit = defineEmits(["updateNeeded","returnData"])
const documentTypeToUse = ref(props.type === 'advanceInvoices' ? null : props.type) const documentTypeToUse = ref(props.type)
const documentTypeItems = computed(() => { const documentTypeItems = computed(() => {
return Object.keys(dataStore.documentTypesForCreation).filter(key => key !== 'advanceInvoices').map((key) => ({ return Object.keys(dataStore.documentTypesForCreation).map((key) => ({
...dataStore.documentTypesForCreation[key], ...dataStore.documentTypesForCreation[key],
key key
})) }))
@@ -83,8 +83,6 @@ const mappings = ref({
}) })
const startImport = () => { const startImport = () => {
if (!selectedDocumentType.value) return
router.push(`/createDocument/edit/?createddocument=${props.id}&type=${documentTypeToUse.value}&optionsToImport=${encodeURIComponent(JSON.stringify(optionsToImport.value))}`) router.push(`/createDocument/edit/?createddocument=${props.id}&type=${documentTypeToUse.value}&optionsToImport=${encodeURIComponent(JSON.stringify(optionsToImport.value))}`)
modal.close() modal.close()
} }
@@ -149,7 +147,7 @@ const startImport = () => {
<UButton color="neutral" variant="ghost" @click="modal.close()"> <UButton color="neutral" variant="ghost" @click="modal.close()">
Abbrechen Abbrechen
</UButton> </UButton>
<UButton :disabled="!selectedDocumentType" @click="startImport"> <UButton @click="startImport">
Kopieren Kopieren
</UButton> </UButton>
</div> </div>

View File

@@ -10,11 +10,10 @@ const props = defineProps({
}) })
const documentTypes = computed(() => Object.entries(dataStore.documentTypesForCreation || {}) const documentTypes = computed(() => Object.entries(dataStore.documentTypesForCreation || {})
.filter(([key]) => !['serialInvoices', 'advanceInvoices'].includes(key)) .filter(([key]) => key !== 'serialInvoices')
.map(([key, value]) => ({ key, ...value }))) .map(([key, value]) => ({ key, ...value })))
const visibleTemplates = computed(() => templates.value.filter(template => { const visibleTemplates = computed(() => templates.value.filter(template => {
return template.documentType !== 'advanceInvoices' return !selectedType.value || template.documentType === selectedType.value
&& (!selectedType.value || template.documentType === selectedType.value)
})) }))
const loadTemplates = async () => { const loadTemplates = async () => {
@@ -26,8 +25,6 @@ const loadTemplates = async () => {
} }
const selectTemplate = (template) => { const selectTemplate = (template) => {
if (template.documentType === 'advanceInvoices') return
const query = new URLSearchParams(props.queryStringData) const query = new URLSearchParams(props.queryStringData)
query.set('templateId', template.id) query.set('templateId', template.id)
query.set('type', template.documentType) query.set('type', template.documentType)

View File

@@ -9,12 +9,10 @@ const props = defineProps({
}) })
const documentTypes = computed(() => Object.entries(dataStore.documentTypesForCreation || {}) const documentTypes = computed(() => Object.entries(dataStore.documentTypesForCreation || {})
.filter(([key]) => !['serialInvoices', 'advanceInvoices'].includes(key)) .filter(([key]) => key !== 'serialInvoices')
.map(([key, value]) => ({ key, ...value }))) .map(([key, value]) => ({ key, ...value })))
const createDocument = (type) => { const createDocument = (type) => {
if (!documentTypes.value.some(documentType => documentType.key === type)) return
const query = new URLSearchParams(props.queryStringData) const query = new URLSearchParams(props.queryStringData)
query.set('type', type) query.set('type', type)
if (props.projectId) query.set('project', props.projectId) if (props.projectId) query.set('project', props.projectId)

View File

@@ -23,13 +23,11 @@ export type AdminUserProfile = {
last_name: string last_name: string
email?: string | null email?: string | null
active: boolean active: boolean
archived_at: string | null
} }
export type AdminUser = { export type AdminUser = {
id: string id: string
email: string email: string
archived_at: string | null
display_name: string display_name: string
multiTenant: boolean multiTenant: boolean
must_change_password: boolean must_change_password: boolean
@@ -163,8 +161,8 @@ export type CreateInstanceAgentResult = {
export const useAdmin = () => { export const useAdmin = () => {
const { $api } = useNuxtApp() const { $api } = useNuxtApp()
const getOverview = async (includeArchived = false): Promise<AdminOverview> => { const getOverview = async (): Promise<AdminOverview> => {
const response = await $api("/api/admin/overview", { query: { includeArchived } }) as any const response = await $api("/api/admin/overview") as any
return { return {
users: response?.users || [], users: response?.users || [],
@@ -195,10 +193,6 @@ export const useAdmin = () => {
}) })
} }
const archiveUser = async (id: string) => {
return await $api(`/api/admin/users/${id}/archive`, { method: "POST" })
}
const resetUserPassword = async (id: string, password?: string) => { const resetUserPassword = async (id: string, password?: string) => {
return await $api(`/api/admin/users/${id}/reset-password`, { return await $api(`/api/admin/users/${id}/reset-password`, {
method: "POST", method: "POST",
@@ -300,7 +294,6 @@ export const useAdmin = () => {
createUserForProfile, createUserForProfile,
updateUser, updateUser,
resetUserPassword, resetUserPassword,
archiveUser,
updateUserAccess, updateUserAccess,
createTenant, createTenant,
invitePortalUser, invitePortalUser,

View File

@@ -11,10 +11,6 @@ const userId = route.params.id as string
const loading = ref(true) const loading = ref(true)
const saving = ref(false) const saving = ref(false)
const resettingPassword = ref(false) const resettingPassword = ref(false)
const archiving = ref(false)
const archiveModalOpen = ref(false)
const profileStore = useProfileStore()
const isOwnUser = computed(() => userId === (auth.user?.id || auth.user?.user_id))
const resetPasswordModalOpen = ref(false) const resetPasswordModalOpen = ref(false)
const resetPasswordInput = ref("") const resetPasswordInput = ref("")
const resetPasswordResult = ref("") const resetPasswordResult = ref("")
@@ -106,7 +102,7 @@ const fetchUser = async () => {
loading.value = true loading.value = true
try { try {
const overview = await admin.getOverview(true) const overview = await admin.getOverview()
roles.value = overview.roles roles.value = overview.roles
tenants.value = overview.tenants tenants.value = overview.tenants
unassignedProfiles.value = overview.unassignedProfiles unassignedProfiles.value = overview.unassignedProfiles
@@ -140,7 +136,7 @@ const fetchUser = async () => {
} }
const saveUser = async () => { const saveUser = async () => {
if (!userForm.value || userForm.value.archived_at || saving.value || archiving.value) return if (!userForm.value || saving.value) return
saving.value = true saving.value = true
normalizeUserAssignments() normalizeUserAssignments()
@@ -176,24 +172,8 @@ const saveUser = async () => {
} }
} }
const archiveUser = async () => {
if (!userForm.value || userForm.value.archived_at || archiving.value || isOwnUser.value) return
archiving.value = true
try {
await admin.archiveUser(userForm.value.id)
archiveModalOpen.value = false
await fetchUser()
if (auth.activeTenant) await profileStore.fetchData()
toast.add({ title: "Benutzer dauerhaft archiviert", description: "Alle zugehörigen Profile wurden archiviert und ausgeblendet.", color: "success" })
} catch (err: any) {
toast.add({ title: "Benutzer konnte nicht archiviert werden", description: err?.data?.error || err?.message || "Unbekannter Fehler", color: "error" })
} finally {
archiving.value = false
}
}
const resetPassword = async () => { const resetPassword = async () => {
if (!userForm.value || userForm.value.archived_at || resettingPassword.value || archiving.value) return if (!userForm.value || resettingPassword.value) return
const password = resetPasswordInput.value.trim() const password = resetPasswordInput.value.trim()
if (password && password.length < 8) { if (password && password.length < 8) {
@@ -268,40 +248,21 @@ onMounted(async () => {
</UButton> </UButton>
</template> </template>
<template #right> <template #right>
<UButton
v-if="userForm && !userForm.archived_at && !isOwnUser"
color="error"
variant="soft"
icon="i-heroicons-archive-box"
:disabled="saving || resettingPassword || archiving"
@click="archiveModalOpen = true"
>
Dauerhaft archivieren
</UButton>
<UButton <UButton
color="warning" color="warning"
variant="soft" variant="soft"
icon="i-heroicons-key" icon="i-heroicons-key"
:disabled="loading || !userForm || !!userForm.archived_at || archiving || saving"
@click="openResetPasswordModal" @click="openResetPasswordModal"
> >
Passwort zurücksetzen Passwort zurücksetzen
</UButton> </UButton>
<UButton color="primary" :loading="saving" :disabled="loading || !userForm || !!userForm.archived_at || archiving || resettingPassword" @click="saveUser"> <UButton color="primary" :loading="saving" @click="saveUser">
Speichern Speichern
</UButton> </UButton>
</template> </template>
</UDashboardNavbar> </UDashboardNavbar>
<UDashboardPanelContent> <UDashboardPanelContent>
<UAlert
v-if="userForm?.archived_at"
title="Benutzer dauerhaft archiviert"
:description="`Archiviert am ${new Date(userForm.archived_at).toLocaleString('de-DE')}. Die Anmeldung ist gesperrt. Alle zugehörigen Profile sind archiviert und ausgeblendet.`"
color="warning"
variant="soft"
class="mb-4"
/>
<UCard v-if="!loading && userForm"> <UCard v-if="!loading && userForm">
<div class="flex items-start justify-between gap-4 mb-6"> <div class="flex items-start justify-between gap-4 mb-6">
<div> <div>
@@ -312,7 +273,7 @@ onMounted(async () => {
<USeparator label="Benutzer" /> <USeparator label="Benutzer" />
<UForm :disabled="!!userForm.archived_at || archiving" :state="userForm" class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-4"> <UForm :state="userForm" class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-4">
<UFormField label="E-Mail"> <UFormField label="E-Mail">
<UInput v-model="userForm.email" /> <UInput v-model="userForm.email" />
</UFormField> </UFormField>
@@ -380,7 +341,6 @@ onMounted(async () => {
<UFormField label="Rolle"> <UFormField label="Rolle">
<USelectMenu <USelectMenu
:disabled="!!userForm.archived_at || archiving"
:model-value="getRoleForTenant(tenantId)" :model-value="getRoleForTenant(tenantId)"
:items="getRoleOptionsForTenant(tenantId)" :items="getRoleOptionsForTenant(tenantId)"
value-key="value" value-key="value"
@@ -392,7 +352,6 @@ onMounted(async () => {
<UFormField label="Freies Profil"> <UFormField label="Freies Profil">
<USelectMenu <USelectMenu
:disabled="!!userForm.archived_at || archiving"
:model-value="getProfileAssignmentForTenant(tenantId)" :model-value="getProfileAssignmentForTenant(tenantId)"
:items="[ :items="[
{ label: 'Neues Profil erzeugen', value: null }, { label: 'Neues Profil erzeugen', value: null },
@@ -431,7 +390,7 @@ onMounted(async () => {
variant="subtle" variant="subtle"
color="gray" color="gray"
> >
{{ profile.full_name || `${profile.first_name} ${profile.last_name}` }} · Tenant {{ profile.tenant_id }}{{ profile.archived_at ? " · Archiviert" : "" }} {{ profile.full_name || `${profile.first_name} ${profile.last_name}` }} · Tenant {{ profile.tenant_id }}
</UBadge> </UBadge>
</div> </div>
</UCard> </UCard>
@@ -439,27 +398,6 @@ onMounted(async () => {
<USkeleton v-if="loading" class="h-80" /> <USkeleton v-if="loading" class="h-80" />
</UDashboardPanelContent> </UDashboardPanelContent>
<UModal v-model:open="archiveModalOpen" :dismissible="!archiving">
<template #content>
<UCard>
<template #header>
<div class="text-lg font-semibold">Benutzer dauerhaft archivieren</div>
</template>
<div class="space-y-3 text-sm">
<p><strong>{{ userForm?.display_name }}</strong> ({{ userForm?.email }}) wird dauerhaft archiviert.</p>
<p>Die Anmeldung wird gesperrt, bestehende Sitzungen und API-Schlüssel werden ungültig. Alle {{ userForm?.profiles.length || 0 }} zugehörigen Profile werden in sämtlichen Tenants archiviert und aus Listen und Auswahllisten ausgeblendet.</p>
<p>Bestehende Dokumente, Zeiterfassungen und Zuordnungen bleiben erhalten. Die Archivierung kann nicht rückgängig gemacht werden.</p>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<UButton variant="soft" color="neutral" :disabled="archiving" @click="archiveModalOpen = false">Abbrechen</UButton>
<UButton color="error" icon="i-heroicons-archive-box" :loading="archiving" @click="archiveUser">Dauerhaft archivieren</UButton>
</div>
</template>
</UCard>
</template>
</UModal>
<UModal v-model:open="resetPasswordModalOpen"> <UModal v-model:open="resetPasswordModalOpen">
<template #content> <template #content>
<UCard> <UCard>

View File

@@ -12,7 +12,6 @@ const createUserModalOpen = ref(false)
const createdUserPassword = ref("") const createdUserPassword = ref("")
const users = ref<AdminUser[]>([]) const users = ref<AdminUser[]>([])
const searchString = ref("") const searchString = ref("")
const showArchived = ref(false)
const createUserForm = ref({ const createUserForm = ref({
email: "", email: "",
@@ -28,15 +27,13 @@ const templateColumns = [
{ key: "email", label: "E-Mail" }, { key: "email", label: "E-Mail" },
{ key: "tenant_count", label: "Tenants" }, { key: "tenant_count", label: "Tenants" },
{ key: "is_admin", label: "Admin" }, { key: "is_admin", label: "Admin" },
{ key: "status", label: "Status" },
] ]
const filteredRows = computed(() => { const filteredRows = computed(() => {
const search = searchString.value.trim().toLowerCase() const search = searchString.value.trim().toLowerCase()
const rows = users.value.filter((user) => showArchived.value || !user.archived_at).map((user) => ({ const rows = users.value.map((user) => ({
...user, ...user,
tenant_count: user.tenant_ids.length, tenant_count: user.tenant_ids.length,
status: user.archived_at ? "Archiviert" : "Aktiv",
is_admin: user.is_admin ? "Ja" : "Nein", is_admin: user.is_admin ? "Ja" : "Nein",
})) }))
@@ -53,7 +50,7 @@ const fetchUsers = async () => {
loading.value = true loading.value = true
try { try {
const overview = await admin.getOverview(true) const overview = await admin.getOverview()
users.value = overview.users users.value = overview.users
} catch (err: any) { } catch (err: any) {
console.error("[administration/users/index]", err) console.error("[administration/users/index]", err)
@@ -122,7 +119,6 @@ onMounted(async () => {
<template> <template>
<UDashboardNavbar title="Administration: Benutzer" :badge="filteredRows.length"> <UDashboardNavbar title="Administration: Benutzer" :badge="filteredRows.length">
<template #right> <template #right>
<UCheckbox v-model="showArchived" label="Archivierte anzeigen" />
<UInput <UInput
v-model="searchString" v-model="searchString"
icon="i-heroicons-magnifying-glass" icon="i-heroicons-magnifying-glass"

View File

@@ -104,36 +104,10 @@ const bankaccounts = ref([])
const texttemplates = ref([]) const texttemplates = ref([])
const units = ref([]) const units = ref([])
const tenantUsers = ref([]) const tenantUsers = ref([])
const originalDocumentType = ref(null) const documentTypeItems = computed(() => Object.keys(dataStore.documentTypesForCreation).map((key) => ({
const canUseAdvanceInvoice = computed(() => originalDocumentType.value === 'advanceInvoices' || (
route.query.type === 'advanceInvoices'
&& route.query.loadMode === 'advanceInvoice'
&& Number.isSafeInteger(Number(route.query.project))
&& Number(route.query.project) > 0
&& !route.params.id
&& !route.query.createddocument
&& !route.query.optionsToImport
&& !route.query.linkedDocuments
&& !route.query.templateId
&& !isTemplateMode.value
))
const documentTypeItems = computed(() => Object.keys(dataStore.documentTypesForCreation)
.filter(key => key !== 'advanceInvoices' || canUseAdvanceInvoice.value)
.map((key) => ({
label: dataStore.documentTypesForCreation[key].labelSingle, label: dataStore.documentTypesForCreation[key].labelSingle,
type: key type: key
}))) })))
const validateAdvanceInvoiceCreation = () => {
if (itemInfo.value.type !== 'advanceInvoices' || isTemplateMode.value) return true
if (canUseAdvanceInvoice.value && (route.params.id || normalizeEntityId(itemInfo.value.project))) return true
toast.add({
title: 'Abschlagsrechnung nur im Projekt erstellen',
description: 'Bitte den Button „Abschlagsrechnung“ innerhalb eines Projekts verwenden.',
color: 'error'
})
return false
}
const taxTypeItems = [ const taxTypeItems = [
{ key: 'Standard', label: 'Standard' }, { key: 'Standard', label: 'Standard' },
{ key: '13b UStG', label: '13b UStG' }, { key: '13b UStG', label: '13b UStG' },
@@ -417,7 +391,6 @@ const setupPage = async () => {
if (route.params.id) { if (route.params.id) {
console.log(route.params) console.log(route.params)
itemInfo.value = await useEntities("createddocuments").selectSingle(route.params.id,'',false) itemInfo.value = await useEntities("createddocuments").selectSingle(route.params.id,'',false)
originalDocumentType.value = itemInfo.value.type
itemInfo.value.rows = normalizeCreatedDocumentRows(itemInfo.value.rows) itemInfo.value.rows = normalizeCreatedDocumentRows(itemInfo.value.rows)
itemInfo.value.taxType = normalizeTaxTypeValue(itemInfo.value.taxType) itemInfo.value.taxType = normalizeTaxTypeValue(itemInfo.value.taxType)
await setContactPersonData() await setContactPersonData()
@@ -726,10 +699,6 @@ const setupPage = async () => {
itemInfo.value.rows = normalizeCreatedDocumentRows(itemInfo.value.rows) itemInfo.value.rows = normalizeCreatedDocumentRows(itemInfo.value.rows)
if (!validateAdvanceInvoiceCreation()) {
await router.replace('/createDocument')
return
}
loaded.value = true loaded.value = true
} }
@@ -1848,7 +1817,6 @@ const saveDocumentTemplate = async () => {
} }
const saveDocument = async (state, resetup = false) => { const saveDocument = async (state, resetup = false) => {
if (!validateAdvanceInvoiceCreation()) return
itemInfo.value.state = state itemInfo.value.state = state
normalizeCustomSurchargePercentage() normalizeCustomSurchargePercentage()

View File

@@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import dayjs from "dayjs" import dayjs from "dayjs"
import { getPortalColor, portalThemeStyle } from "~/utils/portalTheme"
definePageMeta({ definePageMeta({
layout: "notLoggedIn" layout: "notLoggedIn"
@@ -8,7 +7,6 @@ definePageMeta({
const auth = useAuthStore() const auth = useAuthStore()
const toast = useToast() const toast = useToast()
const portalTheme = computed(() => portalThemeStyle(getPortalColor(auth.activeTenantData?.portalConfig)))
const customer = ref<any | null>(null) const customer = ref<any | null>(null)
const contracts = ref<any[]>([]) const contracts = ref<any[]>([])
@@ -339,7 +337,7 @@ onMounted(async () => {
</script> </script>
<template> <template>
<div :style="portalTheme" class="min-h-screen bg-gradient-to-b from-neutral-50 via-white to-neutral-100"> <div class="min-h-screen bg-gradient-to-b from-neutral-50 via-white to-neutral-100">
<UContainer class="py-8 lg:py-12"> <UContainer class="py-8 lg:py-12">
<div class="flex flex-col gap-6"> <div class="flex flex-col gap-6">
<div class="rounded-3xl border border-neutral-200 bg-white/90 p-6 shadow-sm lg:p-8"> <div class="rounded-3xl border border-neutral-200 bg-white/90 p-6 shadow-sm lg:p-8">
@@ -394,7 +392,7 @@ onMounted(async () => {
</div> </div>
<UCard v-else :ui="{ body: 'p-4 sm:p-6' }"> <UCard v-else :ui="{ body: 'p-4 sm:p-6' }">
<UTabs v-model="activeTab" :items="portalTabs" :ui="{ trigger: 'data-[state=active]:text-[var(--portal-accent-contrast)]' }" class="portal-tabs"> <UTabs v-model="activeTab" :items="portalTabs" class="portal-tabs">
<template #customer> <template #customer>
<div class="pt-4"> <div class="pt-4">
<div class="mb-4 flex items-center justify-between gap-4"> <div class="mb-4 flex items-center justify-between gap-4">
@@ -472,7 +470,7 @@ onMounted(async () => {
</div> </div>
<div class="mt-6 flex justify-end"> <div class="mt-6 flex justify-end">
<UButton icon="i-heroicons-check" class="text-[var(--portal-accent-contrast)]" :loading="saving" @click="saveCustomerData"> <UButton icon="i-heroicons-check" :loading="saving" @click="saveCustomerData">
Kundendaten speichern Kundendaten speichern
</UButton> </UButton>
</div> </div>
@@ -772,7 +770,7 @@ onMounted(async () => {
</div> </div>
</UContainer> </UContainer>
<UModal v-model:open="contractChangeModalOpen" :content="{ style: portalTheme }"> <UModal v-model:open="contractChangeModalOpen">
<template #content> <template #content>
<div class="space-y-5 p-6"> <div class="space-y-5 p-6">
<div> <div>
@@ -789,7 +787,6 @@ onMounted(async () => {
<USelectMenu <USelectMenu
v-model="contractChangeForm.contracttype" v-model="contractChangeForm.contracttype"
:items="selectedContractAllowedContracttypes" :items="selectedContractAllowedContracttypes"
:content="{ style: portalTheme }"
value-key="id" value-key="id"
label-key="name" label-key="name"
class="w-full" class="w-full"
@@ -816,7 +813,6 @@ onMounted(async () => {
</UButton> </UButton>
<UButton <UButton
color="primary" color="primary"
class="text-[var(--portal-accent-contrast)]"
:loading="submittingContractRequest" :loading="submittingContractRequest"
:disabled="!contractChangeForm.contracttype" :disabled="!contractChangeForm.contracttype"
@click="submitContractChangeRequest" @click="submitContractChangeRequest"
@@ -828,7 +824,7 @@ onMounted(async () => {
</template> </template>
</UModal> </UModal>
<UModal v-model:open="cancellationModalOpen" :content="{ style: portalTheme }"> <UModal v-model:open="cancellationModalOpen">
<template #content> <template #content>
<div class="space-y-5 p-6"> <div class="space-y-5 p-6">
<div> <div>

View File

@@ -1,206 +0,0 @@
<script setup>
import dayjs from 'dayjs'
definePageMeta({ alias: ['/standardEntity/events'] })
useHead({ title: 'Termine · FEDEO' })
const auth = useAuthStore()
const { has } = usePermission()
const eventEntities = useEntities('events')
const canView = computed(() => has('events') && auth.activeTenantData?.features?.calendar !== false)
const canCreate = computed(() => canView.value && has('events-create'))
const events = ref([])
const total = ref(0)
const loading = ref(false)
const error = ref('')
const search = ref('')
const status = ref('all')
const page = ref(1)
const pageSize = 25
const sortDirection = ref('desc')
const createRoute = { path: '/standardEntity/events/create', query: { returnTo: 'events' } }
const statusOptions = [
{ label: 'Alle Status', value: 'all' },
{ label: 'Entwurf', value: 'Entwurf' },
{ label: 'Final', value: 'Final' }
]
const sortOptions = [
{ label: 'Beginn: neueste zuerst', value: 'desc' },
{ label: 'Beginn: älteste zuerst', value: 'asc' }
]
const columns = [
{ accessorKey: 'name', header: 'Termin' },
{ accessorKey: 'startDate', header: 'Zeitraum' },
{ accessorKey: 'project', header: 'Projekt' },
{ accessorKey: 'state', header: 'Status' },
{ id: 'actions', header: '' }
]
let requestVersion = 0
let searchTimer
const editRoute = (event) => ({
path: `/standardEntity/events/edit/${event.id}`,
query: { returnTo: 'events' }
})
const eventTitle = (event) => event.name || event.project?.name || 'Termin ohne Titel'
const projectLabel = (event) => {
const project = event.project
if (!project) return 'Ohne Projekt'
if (typeof project !== 'object') return `Projekt ${project}`
return [project.projectNumber, project.name].filter(Boolean).join(' · ') || 'Ohne Projekt'
}
const formatDate = (value) => value && dayjs(value).isValid() ? dayjs(value).format('DD.MM.YYYY HH:mm') : 'Nicht angegeben'
const eventColor = (event) => event.color
|| (event.quick ? auth.activeTenantData?.calendarConfig?.quickEntry?.color : null)
|| auth.activeTenantData?.calendarConfig?.eventTypes?.find((type) => type.label === event.eventtype)?.color
|| '#2563eb'
async function loadEvents() {
const version = ++requestVersion
if (!canView.value) {
events.value = []
total.value = 0
loading.value = false
return
}
loading.value = true
error.value = ''
try {
const result = await eventEntities.selectPaginated({
select: '*, project(id,name,projectNumber)',
filters: { archived: false, ...(status.value === 'all' ? {} : { state: status.value }) },
search: search.value.trim(),
sort: [{ field: 'startDate', direction: sortDirection.value }],
page: page.value,
limit: pageSize
})
if (version !== requestVersion) return
events.value = result.data
total.value = result.meta?.total || 0
} catch {
if (version !== requestVersion) return
events.value = []
total.value = 0
error.value = 'Die Termine konnten nicht geladen werden. Bitte versuche es erneut.'
} finally {
if (version === requestVersion) loading.value = false
}
}
function reloadFromFirstPage() {
clearTimeout(searchTimer)
if (page.value !== 1) page.value = 1
else loadEvents()
}
function resetFilters() {
search.value = ''
status.value = 'all'
}
watch(search, () => {
++requestVersion
loading.value = true
clearTimeout(searchTimer)
searchTimer = setTimeout(reloadFromFirstPage, 250)
})
watch([status, sortDirection, canView, () => auth.activeTenant], reloadFromFirstPage)
watch(page, loadEvents)
onBeforeUnmount(() => {
clearTimeout(searchTimer)
++requestVersion
})
loadEvents()
</script>
<template>
<UDashboardNavbar title="Termine" :badge="total">
<template #right>
<UButton v-if="canCreate" :to="createRoute" icon="i-heroicons-plus" label="Neuer Termin" />
</template>
</UDashboardNavbar>
<UDashboardPanelContent class="space-y-4">
<UAlert v-if="!canView" color="neutral" variant="subtle" title="Termine sind für deinen Zugang nicht verfügbar." />
<template v-else>
<div class="flex flex-col gap-3 lg:flex-row lg:items-center">
<UInput
v-model="search"
icon="i-heroicons-magnifying-glass"
placeholder="Termin, Projekt oder Projektnummer suchen …"
aria-label="Termine durchsuchen"
class="w-full lg:flex-1"
/>
<div class="flex flex-wrap gap-2">
<USelect v-model="status" :items="statusOptions" aria-label="Status filtern" class="min-w-36 flex-1 lg:flex-none" />
<USelect v-model="sortDirection" :items="sortOptions" aria-label="Termine sortieren" class="min-w-48 flex-1 lg:flex-none" />
<UButton color="neutral" variant="outline" icon="i-heroicons-arrow-path" aria-label="Termine aktualisieren" :loading="loading" @click="loadEvents" />
</div>
</div>
<UAlert v-if="error" color="error" variant="subtle" title="Laden fehlgeschlagen" :description="error">
<template #actions>
<UButton color="error" variant="outline" label="Erneut versuchen" @click="loadEvents" />
</template>
</UAlert>
<div v-else-if="loading" class="space-y-3 py-4" role="status" aria-label="Termine werden geladen">
<USkeleton v-for="index in 5" :key="index" class="h-20 w-full rounded-lg" />
</div>
<div v-else-if="!events.length" class="rounded-xl border border-dashed border-default px-4 py-14 text-center">
<UIcon name="i-heroicons-calendar-days" class="mx-auto mb-3 size-10 text-muted" />
<h2 class="text-lg font-semibold">{{ search || status !== 'all' ? 'Keine passenden Termine' : 'Noch keine Termine' }}</h2>
<p class="mt-1 text-sm text-muted">{{ search || status !== 'all' ? 'Passe deine Suche oder den Statusfilter an.' : 'Erstelle deinen ersten Termin direkt über diese Übersicht.' }}</p>
<UButton v-if="search || status !== 'all'" class="mt-4" color="neutral" variant="outline" label="Filter zurücksetzen" @click="resetFilters" />
<UButton v-else-if="canCreate" class="mt-4" :to="createRoute" icon="i-heroicons-plus" label="Neuer Termin" />
</div>
<template v-else>
<UTable :data="events" :columns="columns" class="hidden rounded-xl border border-default md:block" :ui="{ td: 'align-top' }">
<template #name-cell="{ row }">
<div class="flex items-start gap-3">
<span class="mt-1 size-3 shrink-0 rounded-full" :style="{ backgroundColor: eventColor(row.original) }" aria-hidden="true" />
<div class="min-w-0">
<NuxtLink :to="editRoute(row.original)" class="block max-w-sm whitespace-normal break-words font-medium text-highlighted hover:text-primary">{{ eventTitle(row.original) }}</NuxtLink>
<p v-if="row.original.repeatInterval && row.original.repeatInterval !== 'Keine Wiederholung'" class="mt-1 text-xs text-muted">{{ row.original.repeatInterval }}</p>
<p v-if="row.original.quick" class="mt-1 text-xs text-muted">Quick-Eintrag</p>
</div>
</div>
</template>
<template #startDate-cell="{ row }">
<div>{{ formatDate(row.original.startDate) }}</div>
<div v-if="row.original.endDate" class="mt-1 text-xs text-muted">bis {{ formatDate(row.original.endDate) }}</div>
</template>
<template #project-cell="{ row }">
<span class="block max-w-xs whitespace-normal break-words">{{ projectLabel(row.original) }}</span>
</template>
<template #state-cell="{ row }">
<UBadge :color="row.original.state === 'Entwurf' ? 'warning' : 'success'" variant="subtle">{{ row.original.state || 'Final' }}</UBadge>
</template>
<template #actions-cell="{ row }">
<UButton :to="editRoute(row.original)" color="neutral" variant="ghost" icon="i-heroicons-pencil-square" :aria-label="`${eventTitle(row.original)} bearbeiten`" />
</template>
</UTable>
<div class="space-y-3 md:hidden">
<NuxtLink v-for="event in events" :key="event.id" :to="editRoute(event)" class="block rounded-xl border border-default p-4 hover:border-primary">
<div class="flex items-start gap-3">
<span class="mt-1 size-3 shrink-0 rounded-full" :style="{ backgroundColor: eventColor(event) }" aria-hidden="true" />
<h2 class="min-w-0 flex-1 break-words font-semibold text-highlighted">{{ eventTitle(event) }}</h2>
<UBadge :color="event.state === 'Entwurf' ? 'warning' : 'success'" variant="subtle" class="shrink-0">{{ event.state || 'Final' }}</UBadge>
</div>
<p class="mt-3 text-sm">{{ formatDate(event.startDate) }}</p>
<p v-if="event.endDate" class="text-sm text-muted">bis {{ formatDate(event.endDate) }}</p>
<p class="mt-2 break-words text-sm text-muted">{{ projectLabel(event) }}</p>
<p v-if="event.repeatInterval && event.repeatInterval !== 'Keine Wiederholung'" class="mt-2 text-xs text-muted">{{ event.repeatInterval }}</p>
<p v-if="event.quick" class="mt-1 text-xs text-muted">Quick-Eintrag</p>
</NuxtLink>
</div>
<div class="flex flex-wrap items-center justify-between gap-3 border-t border-default pt-4">
<p class="text-sm text-muted">{{ total }} {{ total === 1 ? 'Termin' : 'Termine' }}</p>
<UPagination v-model:page="page" :total="total" :items-per-page="pageSize" :sibling-count="0" />
</div>
</template>
</template>
</UDashboardPanelContent>
</template>

View File

@@ -1,9 +0,0 @@
<template>
<UDashboardNavbar title="Benachrichtigungen" />
<div class="min-h-0 flex-1 overflow-y-auto p-4 sm:p-6">
<UCard class="mx-auto w-full max-w-4xl">
<NotificationPreferences />
</UCard>
</div>
</template>

View File

@@ -3,7 +3,6 @@ import {
TAX_EVALUATION_PERIOD_OPTIONS, TAX_EVALUATION_PERIOD_OPTIONS,
normalizeTaxEvaluationPeriod normalizeTaxEvaluationPeriod
} from "~/composables/useTaxEvaluation" } from "~/composables/useTaxEvaluation"
import { DEFAULT_PORTAL_COLOR, getPortalColor, normalizePortalColor, portalThemeStyle } from "~/utils/portalTheme"
const auth = useAuthStore() const auth = useAuthStore()
const toast = useToast() const toast = useToast()
@@ -261,16 +260,6 @@ const setupPage = async () => {
const features = ref({ ...defaultFeatures, ...(auth.activeTenantData?.features || {}) }) const features = ref({ ...defaultFeatures, ...(auth.activeTenantData?.features || {}) })
const businessInfo = ref(normalizeBusinessInfo(auth.activeTenantData?.businessInfo)) const businessInfo = ref(normalizeBusinessInfo(auth.activeTenantData?.businessInfo))
const portalColor = ref(getPortalColor(auth.activeTenantData?.portalConfig))
const portalColorSaving = ref(false)
const validPortalColor = computed(() => normalizePortalColor(portalColor.value))
const portalPreviewStyle = computed(() => portalThemeStyle(portalColor.value))
watch(() => auth.activeTenant, () => {
portalColor.value = getPortalColor(auth.activeTenantData?.portalConfig)
})
watch(() => auth.activeTenantData?.portalConfig, (config) => {
portalColor.value = getPortalColor(config)
})
const accountChart = ref(auth.activeTenantData.accountChart || "skr03") const accountChart = ref(auth.activeTenantData.accountChart || "skr03")
const taxEvaluationPeriod = ref(normalizeTaxEvaluationPeriod(auth.activeTenantData.taxEvaluationPeriod)) const taxEvaluationPeriod = ref(normalizeTaxEvaluationPeriod(auth.activeTenantData.taxEvaluationPeriod))
const accountChartOptions = [ const accountChartOptions = [
@@ -299,18 +288,6 @@ const saveFeatures = async () => {
await updateTenant({features: features.value}) await updateTenant({features: features.value})
} }
const savePortalColor = async () => {
if (!validPortalColor.value || portalColorSaving.value) return
portalColorSaving.value = true
try {
const { primayColor, ...portalConfig } = auth.activeTenantData?.portalConfig || {}
await updateTenant({ portalConfig: { ...portalConfig, primaryColor: validPortalColor.value } })
toast.add({ title: "Akzentfarbe für das Kundenportal gespeichert", color: "success" })
} finally {
portalColorSaving.value = false
}
}
const formatMcpTokenDate = (value) => { const formatMcpTokenDate = (value) => {
if (!value) return "Nie" if (!value) return "Nie"
@@ -637,8 +614,6 @@ onMounted(() => {
label: 'Dokubox' label: 'Dokubox'
},{ },{
label: 'Rechnung & Kontakt' label: 'Rechnung & Kontakt'
},{
label: 'Kundenportal'
},{ },{
label: 'Plantafel' label: 'Plantafel'
},{ },{
@@ -780,43 +755,6 @@ onMounted(() => {
</UCard> </UCard>
</div> </div>
<div v-else-if="item.label === 'Kundenportal'">
<UCard class="mt-5">
<div class="max-w-xl space-y-6">
<div>
<h2 class="text-lg font-semibold">Akzentfarbe im Kundenportal</h2>
<p class="mt-1 text-sm text-muted">Wähle die Farbe für Schaltflächen, Reiter und Hervorhebungen im Kundenportal deiner Firma.</p>
</div>
<UFormField
label="Akzentfarbe"
help="Hex-Farbwert mit sechs Zeichen, zum Beispiel #69c350."
:error="!validPortalColor ? 'Bitte einen gültigen Hex-Farbwert eingeben.' : undefined"
>
<div class="flex items-center gap-3">
<input
type="color"
:value="validPortalColor || DEFAULT_PORTAL_COLOR"
aria-label="Akzentfarbe auswählen"
class="h-10 w-14 shrink-0 cursor-pointer rounded border border-default bg-default p-1"
@input="portalColor = $event.target.value"
>
<UInput v-model="portalColor" aria-label="Akzentfarbe als Hex-Wert" placeholder="#69c350" class="w-40" maxlength="7" />
</div>
</UFormField>
<div :style="portalPreviewStyle" class="rounded-xl border border-default p-5" aria-label="Vorschau der Portal-Akzentfarbe">
<p class="mb-3 text-sm font-medium text-primary-600">Vorschau Kundenportal</p>
<div class="flex flex-wrap items-center gap-3">
<UButton class="text-[var(--portal-accent-contrast)]" tabindex="-1">Kundendaten speichern</UButton>
<UBadge color="primary" variant="subtle">Rechnung</UBadge>
</div>
</div>
<div class="flex flex-wrap gap-3">
<UButton :loading="portalColorSaving" :disabled="!validPortalColor" @click="savePortalColor">Akzentfarbe speichern</UButton>
<UButton color="neutral" variant="outline" :disabled="portalColorSaving" @click="portalColor = DEFAULT_PORTAL_COLOR">FEDEO-Grün verwenden</UButton>
</div>
</div>
</UCard>
</div>
<div v-else-if="item.label === 'Plantafel'"> <div v-else-if="item.label === 'Plantafel'">
<UCard class="mt-5"> <UCard class="mt-5">
<UForm class="w-1/2"> <UForm class="w-1/2">

View File

@@ -3231,7 +3231,7 @@ export const useDataStore = defineStore('data', () => {
{ {
key: "color", key: "color",
label: "Farbe", label: "Farbe",
inputType: "color", inputType: "text",
sortable: true sortable: true
}, },
{ {
@@ -3270,7 +3270,7 @@ export const useDataStore = defineStore('data', () => {
inputType: "select", inputType: "select",
selectDataType: "projects", selectDataType: "projects",
selectOptionAttribute: "name", selectOptionAttribute: "name",
selectSearchAttributes: ['name', 'projectNumber'], selectSearchAttributes: ['name'],
},{ },{
key: "vehicles", key: "vehicles",
label: "Fahrzeuge", label: "Fahrzeuge",

View File

@@ -19,7 +19,6 @@ export const useProfileStore = defineStore("profile", () => {
}) })
const profiles = ref([]) const profiles = ref([])
const allProfiles = ref([])
const ownProfiles = ref([]) const ownProfiles = ref([])
const activeProfile = ref(null) const activeProfile = ref(null)
const tenants = ref([]) const tenants = ref([])
@@ -61,8 +60,8 @@ export const useProfileStore = defineStore("profile", () => {
} }
async function fetchData() { async function fetchData() {
await fetchProfiles()
await fetchOwnProfiles() await fetchOwnProfiles()
await fetchProfiles()
await fetchTenants() await fetchTenants()
await fetchOwnTenant() await fetchOwnTenant()
loaded.value = true loaded.value = true
@@ -72,7 +71,6 @@ export const useProfileStore = defineStore("profile", () => {
loaded.value = false loaded.value = false
ownTenant.value = {} ownTenant.value = {}
profiles.value = [] profiles.value = []
allProfiles.value = []
ownProfiles.value = [] ownProfiles.value = []
tenants.value = [] tenants.value = []
} }
@@ -94,13 +92,10 @@ export const useProfileStore = defineStore("profile", () => {
async function fetchProfiles() { async function fetchProfiles() {
try { try {
const res = await useNuxtApp().$api("/api/tenant/profiles", { query: { includeArchived: true } }) const res = await useNuxtApp().$api("/api/tenant/profiles")
// Retain historical names while keeping archived profiles out of selections. profiles.value = res?.data || []
allProfiles.value = res?.data || []
profiles.value = allProfiles.value.filter(profile => profile.active && !profile.archived_at)
} catch (e) { } catch (e) {
profiles.value = activeProfile.value ? [activeProfile.value] : [] profiles.value = activeProfile.value ? [activeProfile.value] : []
allProfiles.value = profiles.value
} }
} }
@@ -117,8 +112,7 @@ export const useProfileStore = defineStore("profile", () => {
}) })
const getProfileById = computed(() => (itemId) => { const getProfileById = computed(() => (itemId) => {
const profile = allProfiles.value.find((item) => item.id === itemId || item.user_id === itemId) return profiles.value.find((item) => item.id === itemId || item.user_id === itemId)
return profile ? { ...profile, fullName: profile.full_name || profile.fullName } : { fullName: "Unbekanntes Profil" }
}) })
return { return {

View File

@@ -1,131 +0,0 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
const { parse, compileScript } = require('@vue/compiler-sfc')
const documentTypesForCreation = Object.fromEntries([
'invoices', 'advanceInvoices', 'serialInvoices', 'quotes', 'costEstimates',
'deliveryNotes', 'packingSlips', 'confirmationOrders', 'cancellationInvoices',
].map(key => [key, { labelSingle: key }]))
function fixture(filename, names, globals = {}) {
const { descriptor } = parse(fs.readFileSync(`${__dirname}/../${filename}`, 'utf8'))
compileScript(descriptor, { id: filename, inlineTemplate: true })
const script = ts.createSourceFile(filename, descriptor.scriptSetup.content, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS)
const source = script.statements.filter(statement => ts.isVariableStatement(statement)
&& statement.declarationList.declarations.some(declaration => names.includes(declaration.name.getText(script))))
.map(statement => statement.getText(script)).join('\n')
const navigations = []
const notices = []
const context = vm.createContext({
ref: value => ({ value }), computed: getter => ({ get value() { return getter() } }),
dataStore: { documentTypesForCreation }, URLSearchParams,
router: { push: url => navigations.push(url), replace: url => navigations.push(url) },
modal: { close() {} }, toast: { add: notice => notices.push(notice) },
...globals,
})
vm.runInContext(source, context)
return { context, navigations, notices, run: code => vm.runInContext(code, context) }
}
test('copying any document excludes advance invoices and requires an allowed target', () => {
for (const type of Object.keys(documentTypesForCreation)) {
const { run, navigations } = fixture('components/copyCreatedDocumentModal.vue', [
'documentTypeToUse', 'documentTypeItems', 'selectedDocumentType', 'optionsToImport', 'startImport',
], { props: { id: '12', type } })
assert.equal(run("documentTypeItems.value.some(item => item.key === 'advanceInvoices')"), false)
assert.equal(run('documentTypeToUse.value'), type === 'advanceInvoices' ? null : type)
run("documentTypeToUse.value = 'advanceInvoices'; startImport()")
assert.equal(navigations.length, 0)
run("documentTypeToUse.value = 'invoices'; startImport()")
const query = new URL(navigations[0], 'http://localhost').searchParams
assert.equal(query.get('type'), 'invoices')
assert.equal(query.get('createddocument'), '12')
}
})
test('+ Dokument excludes advance invoices even when opened inside a project', () => {
for (const queryStringData of ['', 'project=7&customer=8']) {
const { run, navigations } = fixture('components/createDocumentModal.vue', ['documentTypes', 'createDocument'], {
props: { queryStringData },
})
assert.equal(run("documentTypes.value.some(item => item.key === 'advanceInvoices')"), false)
run("createDocument('advanceInvoices')")
assert.equal(navigations.length, 0)
run("createDocument('quotes')")
assert.equal(new URL(navigations[0], 'http://localhost').searchParams.get('type'), 'quotes')
}
})
test('advance invoice templates cannot create outgoing documents', () => {
const { run, navigations } = fixture('components/createDocumentFromTemplateModal.vue', [
'templates', 'selectedType', 'documentTypes', 'visibleTemplates', 'selectTemplate',
], { props: { queryStringData: 'project=7' } })
run("templates.value = [{ id: 1, documentType: 'advanceInvoices' }, { id: 2, documentType: 'quotes' }]")
assert.equal(run("documentTypes.value.some(item => item.key === 'advanceInvoices')"), false)
assert.equal(run('visibleTemplates.value.length'), 1)
assert.equal(run('visibleTemplates.value[0].id'), 2)
run('selectTemplate(templates.value[0])')
assert.equal(navigations.length, 0)
run('selectTemplate(templates.value[1])')
assert.equal(new URL(navigations[0], 'http://localhost').searchParams.get('templateId'), '2')
})
function editorFixture(query = {}, id) {
return fixture('pages/createDocument/edit/[[id]].vue', [
'originalDocumentType', 'canUseAdvanceInvoice', 'documentTypeItems', 'normalizeEntityId',
'validateAdvanceInvoiceCreation', 'saveDocument',
], {
route: { params: { id }, query }, isTemplateMode: { value: false },
itemInfo: { value: { type: 'advanceInvoices', project: 7 } },
})
}
test('only the project action opens a new advance invoice with its project and customer', () => {
const { run, navigations, context } = fixture('components/EntityShowSubCreatedDocuments.vue', ['createAdvanceInvoice'], {
props: { topLevelType: 'projects', item: { id: 7 }, queryStringData: 'customer=8&project=99' },
})
run('createAdvanceInvoice()')
const query = Object.fromEntries(new URL(navigations[0], 'http://localhost').searchParams)
assert.equal(query.project, '7')
assert.equal(query.customer, '8')
const editor = editorFixture(query)
assert.equal(editor.run('validateAdvanceInvoiceCreation()'), true)
assert.equal(editor.run("documentTypeItems.value.some(item => item.type === 'advanceInvoices')"), true)
context.props.topLevelType = 'customers'
run('createAdvanceInvoice()')
assert.equal(navigations.length, 1)
})
test('editor rejects alternate creation routes and saving a switched document type', async () => {
const projectQuery = { type: 'advanceInvoices', loadMode: 'advanceInvoice', project: '7' }
for (const query of [
{}, { type: 'advanceInvoices' }, { type: 'advanceInvoices', project: '7' },
{ ...projectQuery, project: '' }, { ...projectQuery, project: 'invalid' },
{ ...projectQuery, createddocument: '12' }, { ...projectQuery, optionsToImport: '{}' },
{ ...projectQuery, linkedDocuments: '[12]' }, { ...projectQuery, templateId: '5' },
]) {
const { run, notices, navigations } = editorFixture(query)
assert.equal(run("documentTypeItems.value.some(item => item.type === 'advanceInvoices')"), false)
assert.equal(run('validateAdvanceInvoiceCreation()'), false)
await run("saveDocument('Entwurf')")
assert.equal(notices.length, 2)
assert.equal(navigations.length, 0)
}
})
test('new advance invoices require a project; existing advance invoices remain editable', () => {
const fresh = editorFixture({ type: 'advanceInvoices', loadMode: 'advanceInvoice', project: '7' })
fresh.run('itemInfo.value.project = null')
assert.equal(fresh.run('validateAdvanceInvoiceCreation()'), false)
const existing = editorFixture({}, '12')
existing.run("originalDocumentType.value = 'advanceInvoices'; itemInfo.value.project = null")
assert.equal(existing.run('validateAdvanceInvoiceCreation()'), true)
assert.equal(existing.run("documentTypeItems.value.some(item => item.type === 'advanceInvoices')"), true)
existing.run("originalDocumentType.value = 'invoices'")
assert.equal(existing.run('validateAdvanceInvoiceCreation()'), false)
existing.run("itemInfo.value.type = 'invoices'")
assert.equal(existing.run('validateAdvanceInvoiceCreation()'), true)
})

View File

@@ -8,7 +8,7 @@ const { parse } = require('@vue/compiler-sfc')
const { descriptor } = parse(fs.readFileSync(`${__dirname}/../pages/createDocument/edit/[[id]].vue`, 'utf8')) const { descriptor } = parse(fs.readFileSync(`${__dirname}/../pages/createDocument/edit/[[id]].vue`, 'utf8'))
const script = ts.createSourceFile('document-editor.js', descriptor.scriptSetup.content, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS) const script = ts.createSourceFile('document-editor.js', descriptor.scriptSetup.content, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS)
const handlers = new Set(['normalizeEntityId', 'normalizeUnitId', 'normalizeCreatedDocumentRow', 'normalizeCreatedDocumentRows', 'normalizeTaxTypeValue', 'originalDocumentType', 'canUseAdvanceInvoice', 'validateAdvanceInvoiceCreation', 'setupPage', 'setRowData']) const handlers = new Set(['normalizeEntityId', 'normalizeUnitId', 'normalizeCreatedDocumentRow', 'normalizeCreatedDocumentRows', 'normalizeTaxTypeValue', 'setupPage', 'setRowData'])
const source = script.statements const source = script.statements
.filter(statement => ts.isVariableStatement(statement) && statement.declarationList.declarations.some(declaration => handlers.has(declaration.name.getText(script)))) .filter(statement => ts.isVariableStatement(statement) && statement.declarationList.declarations.some(declaration => handlers.has(declaration.name.getText(script))))
.map(statement => statement.getText(script)).join('\n') .map(statement => statement.getText(script)).join('\n')
@@ -18,8 +18,6 @@ function fixture(unit) {
const product = { id: 20, name: 'Artikel', unit, selling_price: 10, tax_percentage: 19 } const product = { id: 20, name: 'Artikel', unit, selling_price: 10, tax_percentage: 19 }
const service = { id: 30, name: 'Leistung', unit, sellingPrice: 20, sellingPriceComposed: {}, taxPercentage: 19 } const service = { id: 30, name: 'Leistung', unit, sellingPrice: 20, sellingPriceComposed: {}, taxPercentage: 19 }
const context = vm.createContext({ const context = vm.createContext({
ref, computed: getter => ({ get value() { return getter() } }),
isTemplateMode: ref(false),
dayjs, uuidv4: () => 'row-id', console: { log() {} }, dayjs, uuidv4: () => 'row-id', console: { log() {} },
products: ref([product]), services: ref([service]), products: ref([product]), services: ref([service]),
productcategories: ref([]), servicecategories: ref([]), productcategories: ref([]), servicecategories: ref([]),

View File

@@ -1,166 +0,0 @@
// Run against a built frontend with PLAYWRIGHT_MODULE pointing to an installed Playwright package.
// Requests use isolated fixtures; no real account or application data is changed.
import assert from 'node:assert/strict'
import { readFile, writeFile } from 'node:fs/promises'
const { chromium } = await import(process.env.PLAYWRIGHT_MODULE || 'playwright')
const base = process.env.EVENTS_FRONTEND_URL || 'http://127.0.0.1:3157'
const browser = await chromium.launch({ headless: true })
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } })
page.setDefaultTimeout(10000)
const errors = []
const writes = []
const listRequests = []
const iconCollections = Object.fromEntries(await Promise.all(['heroicons', 'lucide'].map(async prefix => [
prefix, JSON.parse(await readFile(new URL(`../node_modules/@iconify/json/json/${prefix}.json`, import.meta.url), 'utf8'))
])))
let failList = false
let allowCreate = true
const project = { id: 42, name: 'Umbau Hafenstraße', projectNumber: 'P-2026-042' }
const profile = { id: '11111111-1111-4111-8111-111111111111', full_name: 'Anna Beispiel', pinned_on_navigation: [], temp_config: {}, role: { rights: [] } }
const tenant = { id: 1, name: 'Testbetrieb', extraModules: [], features: {}, locked: 'none' }
let events = Array.from({ length: 26 }, (_, index) => ({
id: index + 1, name: index ? `Termin ${index + 1}` : 'Baubesprechung Hafen',
startDate: `2026-10-${String(index + 1).padStart(2, '0')}T08:00:00.000Z`,
endDate: index ? null : '2026-10-01T09:30:00.000Z',
project: index ? null : project, state: index ? 'Final' : 'Entwurf',
color: '#2563eb', quick: false, archived: false, profiles: null,
repeatInterval: index ? 'Keine Wiederholung' : 'Wöchentlich'
}))
page.on('pageerror', error => errors.push(error.message))
await page.addInitScript(() => localStorage.setItem('token', 'fixture-only'))
await page.route('**/*', async route => {
const url = new URL(route.request().url())
if (url.hostname === 'api.iconify.design') {
const collection = iconCollections[url.pathname.slice(1).replace('.json', '')]
if (collection) return route.fulfill({ contentType: 'application/json', body: JSON.stringify(collection) })
}
if (url.origin !== new URL(base).origin) return route.abort()
if (url.pathname.startsWith('/api/_nuxt_icon/')) return route.continue()
if (!url.pathname.startsWith('/api/') && !url.pathname.startsWith('/auth/')) return route.continue()
let body = []
if (url.pathname === '/api/me') body = {
user: { id: profile.id, email: 'anna@example.test', is_admin: false }, profile, tenants: [tenant], activeTenant: 1,
permissions: ['events', 'projects', ...(allowCreate ? ['events-create'] : [])]
}
else if (url.pathname === '/api/tenant') body = tenant
else if (url.pathname === '/api/tenant/profiles') body = { data: [profile] }
else if (url.pathname === '/api/resource/projects') body = [project]
else if (url.pathname === '/api/resource/events/paginated') {
if (failList) return route.fulfill({ status: 503, contentType: 'application/json', body: '{"error":"Fixture unavailable"}' })
const search = (url.searchParams.get('search') || '').toLowerCase()
const state = url.searchParams.get('filter[state]')
const limit = Number(url.searchParams.get('limit'))
const currentPage = Number(url.searchParams.get('page'))
listRequests.push(Object.fromEntries(url.searchParams))
const matches = events.filter(event => !event.archived && (!state || state === event.state)
&& [event.name, event.project?.name, event.project?.projectNumber].some(value => String(value || '').toLowerCase().includes(search)))
matches.sort((a, b) => url.searchParams.get('sort')?.endsWith(':asc')
? a.startDate.localeCompare(b.startDate) : b.startDate.localeCompare(a.startDate))
body = { data: matches.slice((currentPage - 1) * limit, currentPage * limit), queryConfig: { total: matches.length } }
}
else if (url.pathname === '/api/resource/events' && route.request().method() === 'POST') {
const payload = route.request().postDataJSON()
writes.push(payload)
body = { id: 100, state: 'Final', repeatInterval: 'Keine Wiederholung', ...payload, archived: false }
events.push({ ...body, project: payload.project === project.id ? project : null })
}
else if (/^\/api\/resource\/events\/\d+(\/true)?$/.test(url.pathname)) {
const event = events.find(item => item.id === Number(url.pathname.split('/')[4]))
body = { ...event, project: event?.project?.id || null }
if (route.request().method() === 'PUT') {
Object.assign(event, route.request().postDataJSON())
writes.push(route.request().postDataJSON())
body = event
}
}
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) })
})
const searchInput = () => page.getByRole('textbox', { name: 'Termine durchsuchen' })
const waitForList = () => page.getByRole('table').waitFor()
try {
await page.goto(`${base}/events`, { waitUntil: 'networkidle' })
await waitForList()
assert.equal(await page.getByRole('columnheader').count(), 5)
assert.equal(await page.locator('tbody tr').count(), 25)
await page.getByRole('button', { name: 'Next Page', exact: true }).click()
await page.getByRole('link', { name: 'Baubesprechung Hafen', exact: true }).first().waitFor()
assert.equal(listRequests.at(-1).page, '2')
await searchInput().fill('P-2026-042')
await page.getByText('P-2026-042 · Umbau Hafenstraße', { exact: true }).first().waitFor()
await waitForList()
assert.equal(listRequests.at(-1).page, '1')
assert.equal(await page.locator('tbody tr').count(), 1)
await page.screenshot({ path: '/tmp/fedeo-events-desktop.png', fullPage: true })
await page.getByRole('combobox', { name: 'Status filtern' }).click()
await page.getByRole('option', { name: 'Final', exact: true }).click()
await page.getByRole('heading', { name: 'Keine passenden Termine' }).waitFor()
await page.getByRole('button', { name: 'Filter zurücksetzen' }).click()
await waitForList()
await page.getByRole('link', { name: 'Neuer Termin', exact: true }).click()
await page.getByRole('heading', { name: 'Termin erstellen' }).waitFor()
await page.getByLabel('Name', { exact: true }).fill('Neu aus Terminübersicht')
for (let index = 0; index < 2; index++) {
await page.getByRole('button', { name: 'Datum auswählen', exact: true }).first().click()
await page.getByRole('button', { name: 'Heute', exact: true }).click()
await page.keyboard.press('Escape')
}
await page.getByLabel('Farbe auswählen', { exact: true }).fill('#f43f5e')
const projectInputId = await page.locator('label').filter({ hasText: /^Projekt$/ }).getAttribute('for')
await page.locator(`[id="${projectInputId}"]`).click()
await page.getByPlaceholder('Suche...', { exact: true }).fill('2026-042')
await page.getByRole('option', { name: project.name, exact: true }).click()
await page.getByRole('button', { name: 'Erstellen', exact: true }).click()
await page.waitForURL(`${base}/events`)
await searchInput().fill('Neu aus Terminübersicht')
await page.getByRole('link', { name: 'Neu aus Terminübersicht', exact: true }).first().waitFor()
assert.equal(writes[0].project, 42)
assert.equal(writes[0].color, '#f43f5e')
assert.ok(writes[0].startDate && writes[0].endDate)
await page.getByRole('link', { name: 'Neu aus Terminübersicht', exact: true }).first().click()
await page.getByRole('heading', { name: 'Termin bearbeiten' }).waitFor()
await page.getByLabel('Name', { exact: true }).fill('Bearbeiteter Termin')
await page.getByRole('button', { name: 'Speichern', exact: true }).click()
await page.waitForURL(`${base}/events`)
await waitForList()
await page.setViewportSize({ width: 390, height: 844 })
await searchInput().fill('P-2026-042')
await page.getByRole('heading', { name: 'Baubesprechung Hafen' }).waitFor()
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false)
await page.getByRole('link', { name: 'Neuer Termin', exact: true }).waitFor()
await page.screenshot({ path: '/tmp/fedeo-events-mobile.png', fullPage: true })
await page.setViewportSize({ width: 1440, height: 1000 })
await page.goto(`${base}/standardEntity/events`, { waitUntil: 'networkidle' })
await searchInput().waitFor()
failList = true
await page.getByRole('button', { name: 'Termine aktualisieren' }).click()
await page.getByText('Laden fehlgeschlagen', { exact: true }).waitFor()
failList = false
await page.getByRole('button', { name: 'Erneut versuchen' }).click()
await waitForList()
events = []
await page.getByRole('button', { name: 'Termine aktualisieren' }).click()
await page.getByRole('heading', { name: 'Noch keine Termine' }).waitFor()
assert.equal(await page.getByRole('link', { name: 'Neuer Termin', exact: true }).count(), 2)
allowCreate = false
await page.reload({ waitUntil: 'networkidle' })
await page.getByRole('heading', { name: 'Noch keine Termine' }).waitFor()
assert.equal(await page.getByRole('link', { name: 'Neuer Termin', exact: true }).count(), 0)
assert.deepEqual(errors, [], 'Browser runtime errors')
console.log('Events browser checks passed: pagination, project-number search, status filter, create and edit with return to list, mobile layout, legacy URL, errors, empty state and create permission.')
} catch (error) {
await writeFile('/tmp/fedeo-events-error.html', await page.content())
await page.screenshot({ path: '/tmp/fedeo-events-error.png', fullPage: true })
throw error
} finally {
await browser.close()
}

View File

@@ -1,32 +0,0 @@
export const DEFAULT_PORTAL_COLOR = "#69c350"
export function normalizePortalColor(value: unknown): string | null {
if (typeof value !== "string") return null
const color = value.trim().toLowerCase()
return /^#[0-9a-f]{6}$/.test(color) ? color : null
}
export function getPortalColor(config: any): string {
// Older tenant records contain the misspelled key from the original schema.
return normalizePortalColor(config?.primaryColor)
|| normalizePortalColor(config?.primayColor)
|| DEFAULT_PORTAL_COLOR
}
export function portalThemeStyle(value: unknown): Record<string, string> {
const color = normalizePortalColor(value) || DEFAULT_PORTAL_COLOR
const rgb = [1, 3, 5].map(offset => parseInt(color.slice(offset, offset + 2), 16) / 255)
const linear = rgb.map(channel => channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4)
const luminance = linear[0] * 0.2126 + linear[1] * 0.7152 + linear[2] * 0.0722
const style: Record<string, string> = {
"--ui-primary": color,
"--portal-accent-contrast": luminance > 0.179 ? "#000000" : "#ffffff",
}
const shades = { 50: 5, 100: 10, 200: 25, 300: 45, 400: 70, 500: 100, 600: 80, 700: 65, 800: 50, 900: 35, 950: 20 }
for (const [shade, percent] of Object.entries(shades)) {
style[`--ui-color-primary-${shade}`] = Number(shade) === 500
? color
: `color-mix(in srgb, ${color} ${percent}%, ${Number(shade) < 500 ? "white" : "black"})`
}
return style
}