feat(communication): add room visibility and project chat controls
This commit is contained in:
8
backend/db/migrations/0078_chat_visibility.sql
Normal file
8
backend/db/migrations/0078_chat_visibility.sql
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
ALTER TABLE "projects" ADD COLUMN IF NOT EXISTS "chat_enabled" boolean NOT NULL DEFAULT true;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "communication_hidden_rooms" (
|
||||||
|
"tenant_id" bigint NOT NULL REFERENCES "tenants"("id") ON DELETE CASCADE,
|
||||||
|
"user_id" uuid NOT NULL REFERENCES "auth_users"("id") ON DELETE CASCADE,
|
||||||
|
"room_key" text NOT NULL,
|
||||||
|
PRIMARY KEY ("tenant_id", "user_id", "room_key")
|
||||||
|
);
|
||||||
@@ -526,6 +526,13 @@
|
|||||||
"when": 1790899200000,
|
"when": 1790899200000,
|
||||||
"tag": "0077_scanner_inbox_preparation",
|
"tag": "0077_scanner_inbox_preparation",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 75,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1790985600000,
|
||||||
|
"tag": "0078_chat_visibility",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
10
backend/db/schema/communication_hidden_rooms.ts
Normal file
10
backend/db/schema/communication_hidden_rooms.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { bigint, pgTable, primaryKey, text, uuid } from "drizzle-orm/pg-core"
|
||||||
|
import { authUsers } from "./auth_users"
|
||||||
|
import { tenants } from "./tenants"
|
||||||
|
|
||||||
|
// Room keys also cover collaboration bindings and not-yet-provisioned chats.
|
||||||
|
export const communicationHiddenRooms = pgTable("communication_hidden_rooms", {
|
||||||
|
tenantId: bigint("tenant_id", { mode: "number" }).notNull().references(() => tenants.id, { onDelete: "cascade" }),
|
||||||
|
userId: uuid("user_id").notNull().references(() => authUsers.id, { onDelete: "cascade" }),
|
||||||
|
roomKey: text("room_key").notNull(),
|
||||||
|
}, table => ({ pk: primaryKey({ columns: [table.tenantId, table.userId, table.roomKey] }) }))
|
||||||
@@ -19,6 +19,7 @@ export * from "./communication_rooms"
|
|||||||
export * from "./communication_room_members"
|
export * from "./communication_room_members"
|
||||||
export * from "./communication_messages"
|
export * from "./communication_messages"
|
||||||
export * from "./communication_room_reads"
|
export * from "./communication_room_reads"
|
||||||
|
export * from "./communication_hidden_rooms"
|
||||||
export * from "./contacts"
|
export * from "./contacts"
|
||||||
export * from "./contracts"
|
export * from "./contracts"
|
||||||
export * from "./contracttypes"
|
export * from "./contracttypes"
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ export const projects = pgTable("projects", {
|
|||||||
|
|
||||||
archived: boolean("archived").notNull().default(false),
|
archived: boolean("archived").notNull().default(false),
|
||||||
|
|
||||||
|
chatEnabled: boolean("chat_enabled").notNull().default(true),
|
||||||
|
|
||||||
customerRef: text("customerRef"),
|
customerRef: text("customerRef"),
|
||||||
|
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
||||||
|
|||||||
@@ -15,9 +15,18 @@ export function managesProjects(req: any): boolean {
|
|||||||
return Boolean(req.user?.is_admin || req.hasPermission?.("organisation.projects.write"))
|
return Boolean(req.user?.is_admin || req.hasPermission?.("organisation.projects.write"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function projectChatSettingError(req: any, body: Record<string, unknown>, previous = true) {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(body, "chatEnabled")) return null
|
||||||
|
if (typeof body.chatEnabled !== "boolean") return { statusCode: 400, error: "Projektchat aktiviert muss ein Wahrheitswert sein" }
|
||||||
|
if (body.chatEnabled !== previous && !managesProjects(req)) {
|
||||||
|
return { statusCode: 403, error: "Keine Berechtigung zum Ändern des Projektchats" }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
export async function projectChatAccess(db: any, req: any, projectId?: number) {
|
export async function projectChatAccess(db: any, req: any, projectId?: number) {
|
||||||
const tenantId = Number(req.user?.tenant_id)
|
const tenantId = Number(req.user?.tenant_id)
|
||||||
const conditions = [eq(projects.tenant, tenantId), eq(projects.archived, false)]
|
const conditions = [eq(projects.tenant, tenantId), eq(projects.archived, false), eq(projects.chatEnabled, true)]
|
||||||
if (projectId !== undefined) conditions.push(eq(projects.id, projectId))
|
if (projectId !== undefined) conditions.push(eq(projects.id, projectId))
|
||||||
const rows = await db.select().from(projects).where(and(...conditions))
|
const rows = await db.select().from(projects).where(and(...conditions))
|
||||||
if (managesProjects(req)) return rows
|
if (managesProjects(req)) return rows
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
communicationRoomMembers,
|
communicationRoomMembers,
|
||||||
communicationRoomReads,
|
communicationRoomReads,
|
||||||
communicationRooms,
|
communicationRooms,
|
||||||
|
communicationHiddenRooms,
|
||||||
notificationsItems,
|
notificationsItems,
|
||||||
projects,
|
projects,
|
||||||
} from "../../db/schema"
|
} from "../../db/schema"
|
||||||
@@ -244,6 +245,28 @@ export default async function communicationRoutes(server: FastifyInstance) {
|
|||||||
|
|
||||||
server.get("/communication/chat/status", async () => ({ ready: true, provider: "fedeo" }))
|
server.get("/communication/chat/status", async () => ({ ready: true, provider: "fedeo" }))
|
||||||
|
|
||||||
|
server.get("/communication/chat/room-preferences", async (req: any) => {
|
||||||
|
const tenantId = await requireTenant(req)
|
||||||
|
const hidden = await server.db.select({ key: communicationHiddenRooms.roomKey }).from(communicationHiddenRooms)
|
||||||
|
.where(and(eq(communicationHiddenRooms.tenantId, tenantId), eq(communicationHiddenRooms.userId, req.user.user_id)))
|
||||||
|
return { hiddenRoomKeys: hidden.map(room => room.key) }
|
||||||
|
})
|
||||||
|
|
||||||
|
server.put("/communication/chat/room-preferences", async (req: any, reply) => {
|
||||||
|
const tenantId = await requireTenant(req)
|
||||||
|
const { roomKey, hidden } = req.body || {}
|
||||||
|
if (typeof hidden !== "boolean" || typeof roomKey !== "string" || roomKey.length > 200
|
||||||
|
|| !/^(?:[a-z0-9._=-]+|collaboration:[a-f0-9-]{36})$/.test(roomKey)) {
|
||||||
|
return reply.code(400).send({ error: "Ungültige Raumeinstellung" })
|
||||||
|
}
|
||||||
|
const value = { tenantId, userId: req.user.user_id, roomKey }
|
||||||
|
if (hidden) await server.db.insert(communicationHiddenRooms).values(value).onConflictDoNothing()
|
||||||
|
else await server.db.delete(communicationHiddenRooms).where(and(
|
||||||
|
eq(communicationHiddenRooms.tenantId, tenantId), eq(communicationHiddenRooms.userId, req.user.user_id),
|
||||||
|
eq(communicationHiddenRooms.roomKey, roomKey)))
|
||||||
|
return { roomKey, hidden }
|
||||||
|
})
|
||||||
|
|
||||||
server.get("/communication/chat/users", async (req: any) => {
|
server.get("/communication/chat/users", async (req: any) => {
|
||||||
const tenantId = await requireTenant(req)
|
const tenantId = await requireTenant(req)
|
||||||
return { users: (await tenantUsers(tenantId)).map((user) => ({
|
return { users: (await tenantUsers(tenantId)).map((user) => ({
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { validateCostCentreParent } from "../../modules/cost-centre-hierarchy.service"
|
import { validateCostCentreParent } from "../../modules/cost-centre-hierarchy.service"
|
||||||
|
import { projectChatSettingError } from "../../modules/communication-access"
|
||||||
import { FastifyInstance } from "fastify"
|
import { FastifyInstance } from "fastify"
|
||||||
import {
|
import {
|
||||||
eq,
|
eq,
|
||||||
@@ -942,6 +943,10 @@ 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 (resource === "projects") {
|
||||||
|
const error = projectChatSettingError(req, createData)
|
||||||
|
if (error) return reply.code(error.statusCode).send({ error: error.error })
|
||||||
|
}
|
||||||
|
|
||||||
if (resource === "members") {
|
if (resource === "members") {
|
||||||
createData = normalizeMemberPayload(createData)
|
createData = normalizeMemberPayload(createData)
|
||||||
@@ -1095,6 +1100,11 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
return reply.code(404).send({ error: "Resource not found" })
|
return reply.code(404).send({ error: "Resource not found" })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (resource === "projects") {
|
||||||
|
const error = projectChatSettingError(req, body, oldRecord.chatEnabled)
|
||||||
|
if (error) return reply.code(error.statusCode).send({ error: error.error })
|
||||||
|
}
|
||||||
|
|
||||||
let data: Record<string, any> = { ...body }
|
let data: Record<string, any> = { ...body }
|
||||||
delete data.updatedAt
|
delete data.updatedAt
|
||||||
delete data.updated_at
|
delete data.updated_at
|
||||||
|
|||||||
110
backend/tests/chatVisibility.test.ts
Normal file
110
backend/tests/chatVisibility.test.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import test from "node:test"
|
||||||
|
import { randomUUID } from "node:crypto"
|
||||||
|
import { readFile } from "node:fs/promises"
|
||||||
|
import Fastify from "fastify"
|
||||||
|
import communicationRoutes from "../src/routes/communication"
|
||||||
|
import resourceRoutes from "../src/routes/resources/main"
|
||||||
|
import { projectChatSettingError } from "../src/modules/communication-access"
|
||||||
|
import { collaborationDatabase, testDatabaseUrl } from "./helpers/collaboration-db"
|
||||||
|
|
||||||
|
test("changing the project chat switch requires project management permission and a boolean", () => {
|
||||||
|
const member = { user: {}, hasPermission: () => false }
|
||||||
|
const manager = { user: {}, hasPermission: (permission: string) => permission === "organisation.projects.write" }
|
||||||
|
assert.equal(projectChatSettingError(member, {}), null)
|
||||||
|
assert.equal(projectChatSettingError(member, { chatEnabled: true }), null)
|
||||||
|
assert.equal(projectChatSettingError(member, { chatEnabled: false }, false), null)
|
||||||
|
assert.equal(projectChatSettingError(member, { chatEnabled: false })?.statusCode, 403)
|
||||||
|
assert.equal(projectChatSettingError(member, { chatEnabled: true }, false)?.statusCode, 403)
|
||||||
|
assert.equal(projectChatSettingError(manager, { chatEnabled: false }), null)
|
||||||
|
assert.equal(projectChatSettingError({ user: { is_admin: true } }, { chatEnabled: true }, false), null)
|
||||||
|
for (const invalid of [null, "false", 0, {}]) assert.equal(projectChatSettingError(manager, { chatEnabled: invalid })?.statusCode, 400)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("migration keeps existing and newly created project chats enabled", { skip: !testDatabaseUrl }, async () => {
|
||||||
|
const fixture = await collaborationDatabase()
|
||||||
|
try {
|
||||||
|
await fixture.pool.query("ALTER TABLE projects DROP COLUMN chat_enabled")
|
||||||
|
await fixture.pool.query("INSERT INTO projects (id,tenant,name) VALUES (1,1,'Existing')")
|
||||||
|
await fixture.pool.query(await readFile(new URL("../db/migrations/0078_chat_visibility.sql", import.meta.url), "utf8"))
|
||||||
|
await fixture.pool.query("INSERT INTO projects (id,tenant,name) VALUES (2,1,'New')")
|
||||||
|
assert.deepEqual((await fixture.pool.query("SELECT chat_enabled FROM projects ORDER BY id")).rows, [{ chat_enabled: true }, { chat_enabled: true }])
|
||||||
|
} finally { await fixture.close() }
|
||||||
|
})
|
||||||
|
|
||||||
|
test("hidden rooms persist per user and tenant without changing room memberships", { skip: !testDatabaseUrl }, async () => {
|
||||||
|
const fixture = await collaborationDatabase(["0078_chat_visibility.sql"]), app = Fastify()
|
||||||
|
const user = randomUUID(), other = randomUUID(), collaborationKey = `collaboration:${randomUUID()}`
|
||||||
|
try {
|
||||||
|
await fixture.pool.query("INSERT INTO tenants(id,name) VALUES (1,'A'),(2,'B')")
|
||||||
|
await fixture.pool.query("INSERT INTO auth_users(id,email) VALUES ($1,'a@example.test'),($2,'b@example.test')", [user, other])
|
||||||
|
await fixture.pool.query("INSERT INTO auth_tenant_users(tenant_id,user_id) VALUES (1,$1),(2,$1),(1,$2)", [user, other])
|
||||||
|
app.decorate("db", fixture.db)
|
||||||
|
app.addHook("preHandler", async (req: any) => {
|
||||||
|
req.user = { tenant_id: Number(req.headers["x-tenant"] || 1), user_id: req.headers["x-user"] || user }
|
||||||
|
req.hasPermission = () => false
|
||||||
|
})
|
||||||
|
await app.register(communicationRoutes)
|
||||||
|
const url = "/communication/chat/room-preferences"
|
||||||
|
for (const roomKey of ["allgemein", "project_1", collaborationKey, collaborationKey]) {
|
||||||
|
const result = await app.inject({ method: "PUT", url, payload: { roomKey, hidden: true, userId: other, tenantId: 2 } })
|
||||||
|
assert.equal(result.statusCode, 200, result.body)
|
||||||
|
}
|
||||||
|
assert.deepEqual((await app.inject(url)).json().hiddenRoomKeys.sort(), ["allgemein", "project_1", collaborationKey].sort())
|
||||||
|
assert.deepEqual((await app.inject({ url, headers: { "x-user": other } })).json().hiddenRoomKeys, [])
|
||||||
|
assert.deepEqual((await app.inject({ url, headers: { "x-tenant": "2" } })).json().hiddenRoomKeys, [])
|
||||||
|
assert.equal((await fixture.pool.query("SELECT count(*) FROM communication_room_members")).rows[0].count, "0")
|
||||||
|
await app.inject({ method: "PUT", url, payload: { roomKey: collaborationKey, hidden: false } })
|
||||||
|
assert.deepEqual((await app.inject(url)).json().hiddenRoomKeys.sort(), ["allgemein", "project_1"])
|
||||||
|
for (const payload of [{ roomKey: "allgemein", hidden: "false" }, { roomKey: "../secret", hidden: true }, { roomKey: "", hidden: true }]) {
|
||||||
|
assert.equal((await app.inject({ method: "PUT", url, payload })).statusCode, 400)
|
||||||
|
}
|
||||||
|
await fixture.pool.query("DELETE FROM auth_tenant_users WHERE tenant_id=1 AND user_id=$1", [user])
|
||||||
|
assert.equal((await app.inject(url)).statusCode, 403)
|
||||||
|
assert.equal((await app.inject({ method: "PUT", url, payload: { roomKey: "allgemein", hidden: false } })).statusCode, 403)
|
||||||
|
} finally { await app.close(); await fixture.close() }
|
||||||
|
})
|
||||||
|
|
||||||
|
test("disabling in the project form blocks list, provision, reads and writes until re-enabled", { skip: !testDatabaseUrl }, async () => {
|
||||||
|
const fixture = await collaborationDatabase(), app = Fastify()
|
||||||
|
const user = randomUUID(), profile = randomUUID()
|
||||||
|
try {
|
||||||
|
await fixture.pool.query("INSERT INTO tenants(id,name) VALUES (1,'A'),(2,'B')")
|
||||||
|
await fixture.pool.query("INSERT INTO auth_users(id,email) VALUES ($1,'a@example.test')", [user])
|
||||||
|
await fixture.pool.query("INSERT INTO auth_tenant_users(tenant_id,user_id) VALUES (1,$1)", [user])
|
||||||
|
await fixture.pool.query("INSERT INTO auth_profiles(id,tenant_id,user_id) VALUES ($1,1,$2)", [profile, user])
|
||||||
|
await fixture.pool.query("INSERT INTO projects(id,tenant,name,profiles) VALUES (1,1,'Project',ARRAY[$1]::uuid[]),(2,2,'Other tenant','{}')", [profile])
|
||||||
|
app.decorate("db", fixture.db)
|
||||||
|
app.addHook("preHandler", async (req: any) => {
|
||||||
|
req.user = { tenant_id: 1, user_id: user, is_admin: req.headers["x-admin"] === "true" }
|
||||||
|
req.hasPermission = (permission: string) => permission === "organisation.projects.write" && req.headers["x-manager"] === "true"
|
||||||
|
})
|
||||||
|
await app.register(communicationRoutes)
|
||||||
|
await app.register(resourceRoutes)
|
||||||
|
const update = (chatEnabled: unknown, headers = { "x-manager": "true" }) => app.inject({ method: "PUT", url: "/resource/projects/1", headers, payload: { chatEnabled } })
|
||||||
|
const provision = await app.inject({ method: "POST", url: "/communication/chat/project-rooms/1/provision" })
|
||||||
|
assert.equal(provision.statusCode, 200, provision.body)
|
||||||
|
const roomId = provision.json().id
|
||||||
|
await fixture.pool.query("INSERT INTO communication_messages(tenant_id,room_id,author_user_id,body) VALUES (1,$1,$2,'Preserved history')", [roomId, user])
|
||||||
|
assert.equal((await update(false, { "x-manager": "false" })).statusCode, 403)
|
||||||
|
assert.equal((await update("false")).statusCode, 400)
|
||||||
|
assert.equal((await app.inject({ method: "PUT", url: "/resource/projects/2", headers: { "x-manager": "true" }, payload: { chatEnabled: false } })).statusCode, 404)
|
||||||
|
assert.equal((await update(false)).statusCode, 200)
|
||||||
|
assert.deepEqual((await app.inject("/communication/chat/project-rooms")).json().rooms, [])
|
||||||
|
assert.equal((await app.inject({ method: "POST", url: "/communication/chat/project-rooms/1/provision" })).statusCode, 404)
|
||||||
|
for (const suffix of ["messages", "sync", "members"]) {
|
||||||
|
assert.equal((await app.inject(`/communication/chat/rooms/project_1/${suffix}`)).statusCode, 404)
|
||||||
|
assert.equal((await app.inject({ url: `/communication/chat/rooms/project_1/${suffix}`, headers: { "x-admin": "true" } })).statusCode, 404)
|
||||||
|
}
|
||||||
|
for (const suffix of ["messages", "read"]) assert.equal((await app.inject({ method: "POST", url: `/communication/chat/rooms/project_1/${suffix}`, payload: { text: "Rejected", messageId: 1 } })).statusCode, 404)
|
||||||
|
assert.deepEqual((await app.inject("/communication/chat/unread")).json().rooms, {})
|
||||||
|
assert.equal((await update(true, { "x-manager": "false" })).statusCode, 403)
|
||||||
|
assert.equal((await update(true)).statusCode, 200)
|
||||||
|
const history = await app.inject("/communication/chat/rooms/project_1/messages")
|
||||||
|
assert.equal(history.statusCode, 200, history.body)
|
||||||
|
assert.deepEqual(history.json().messages.map(message => message.body), ["Preserved history"])
|
||||||
|
assert.equal((await app.inject("/communication/chat/project-rooms")).json().rooms.length, 1)
|
||||||
|
const create = await app.inject({ method: "POST", url: "/resource/projects", payload: { id: 3, name: "Disabled", projectNumber: "P-3", chatEnabled: false } })
|
||||||
|
assert.equal(create.statusCode, 403)
|
||||||
|
} finally { await app.close(); await fixture.close() }
|
||||||
|
})
|
||||||
@@ -25,7 +25,7 @@ export async function collaborationDatabase(migrations: string[] = []) {
|
|||||||
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") suffix += " DEFAULT now()"
|
if (column.columnType === "PgTimestamp") suffix += " DEFAULT now()"
|
||||||
if (column.getSQLType() === "boolean") suffix += " DEFAULT false"
|
if (column.getSQLType() === "boolean") suffix += ` DEFAULT ${column.default === true ? "true" : "false"}`
|
||||||
return `"${column.name}" ${column.getSQLType()}${suffix}`
|
return `"${column.name}" ${column.getSQLType()}${suffix}`
|
||||||
})
|
})
|
||||||
await pool.query(`CREATE TABLE "${getTableName(table)}" (${columns.join(",")})`)
|
await pool.query(`CREATE TABLE "${getTableName(table)}" (${columns.join(",")})`)
|
||||||
|
|||||||
@@ -287,6 +287,8 @@ Unter **Kommunikation → Zusammenarbeit** können interne Teilnehmer ihre eigen
|
|||||||
|
|
||||||
Unter **Kommunikation → Chat** erscheinen diese Unterräume ebenfalls in der Raumgruppe **Zusammenarbeit**, einschließlich instanzübergreifender Räume und Ungelesen-Zähler. Einladungen lassen sich dort direkt annehmen oder ablehnen; nach der Annahme öffnet sich der Raum. Nachrichten, Teilnehmer und ausdrücklich freigegebene Projektinformationen werden direkt im normalen Chat angezeigt. **Details und Freigaben** öffnet die bestehende Verwaltungsansicht für den ausgewählten Raum; **Im Chat öffnen** führt zurück. Sichtbar sind ausschließlich Räume mit eigener Teilnahme, keine lediglich verwaltbaren Räume. Interne Chaträume und Unterräume behalten ihre getrennten Berechtigungsprüfungen.
|
Unter **Kommunikation → Chat** erscheinen diese Unterräume ebenfalls in der Raumgruppe **Zusammenarbeit**, einschließlich instanzübergreifender Räume und Ungelesen-Zähler. Einladungen lassen sich dort direkt annehmen oder ablehnen; nach der Annahme öffnet sich der Raum. Nachrichten, Teilnehmer und ausdrücklich freigegebene Projektinformationen werden direkt im normalen Chat angezeigt. **Details und Freigaben** öffnet die bestehende Verwaltungsansicht für den ausgewählten Raum; **Im Chat öffnen** führt zurück. Sichtbar sind ausschließlich Räume mit eigener Teilnahme, keine lediglich verwaltbaren Räume. Interne Chaträume und Unterräume behalten ihre getrennten Berechtigungsprüfungen.
|
||||||
|
|
||||||
|
Die Chatansicht nutzt die volle verfügbare Fläche. Über das Augensymbol lassen sich beliebige Räume persönlich ausblenden und unter **Ausgeblendete Räume** wieder einblenden. Diese Auswahl wird pro Benutzer und Mandant gespeichert und bleibt auf anderen Geräten erhalten; Teilnahme, Nachrichten und Benachrichtigungseinstellungen werden dadurch nicht geändert. Unter **Projekt → Bearbeiten → Projektchat aktiviert** können Projektverantwortliche den internen Projektchat für alle Teilnehmer deaktivieren. Er verschwindet aus der Chatliste und ist auch über direkte Aufrufe nicht mehr les- oder beschreibbar. Beim erneuten Aktivieren bleibt der bisherige Verlauf erhalten. Bestehende und neue Projekte starten mit aktiviertem Projektchat. Separat freigegebene Unterräume bleiben unabhängig davon verwaltbar.
|
||||||
|
|
||||||
Einladungen unterstützen eigene Mitarbeiter, persönliche FEDEO-Kontaktadressen und Gäste ohne FEDEO-Konto. Eine Kontaktadresse bezeichnet immer genau einen Benutzer in einem Heimatmandanten. Sie lässt sich in der Zusammenarbeit kopieren. Kontakte anderer Mandanten derselben Instanz benötigen keinen Server-Server-Transport. Fremde Instanzen müssen auf beiden Seiten im jeweiligen Mandanten unter **Partnerinstanzen** freigegeben sein. Beim Empfänger erscheint ein Angebot, das ausdrücklich angenommen oder abgelehnt wird.
|
Einladungen unterstützen eigene Mitarbeiter, persönliche FEDEO-Kontaktadressen und Gäste ohne FEDEO-Konto. Eine Kontaktadresse bezeichnet immer genau einen Benutzer in einem Heimatmandanten. Sie lässt sich in der Zusammenarbeit kopieren. Kontakte anderer Mandanten derselben Instanz benötigen keinen Server-Server-Transport. Fremde Instanzen müssen auf beiden Seiten im jeweiligen Mandanten unter **Partnerinstanzen** freigegeben sein. Beim Empfänger erscheint ein Angebot, das ausdrücklich angenommen oder abgelehnt wird.
|
||||||
|
|
||||||
Gäste erhalten ausschließlich einen Projekt-Unterraum. Der Link öffnet `/guest`; ein achtstelliger, zehn Minuten gültiger Einmalcode wird an die eingeladene Adresse geschickt. Nach fünf falschen Versuchen ist der Code gesperrt. Die eigene Gastsitzung gilt zwölf Stunden und wird nicht als reguläres FEDEO-Token akzeptiert. Für spätere Anmeldungen bietet die Gastoberfläche eine als Lesezeichen speicherbare Adresse mit öffentlicher Workspace-ID an. Ohne gewähltes Ablaufdatum gilt die Teilnahme eines Gasts 30 Tage. Der Einladungslink allein erlaubt keinen Inhaltszugriff.
|
Gäste erhalten ausschließlich einen Projekt-Unterraum. Der Link öffnet `/guest`; ein achtstelliger, zehn Minuten gültiger Einmalcode wird an die eingeladene Adresse geschickt. Nach fünf falschen Versuchen ist der Code gesperrt. Die eigene Gastsitzung gilt zwölf Stunden und wird nicht als reguläres FEDEO-Token akzeptiert. Für spätere Anmeldungen bietet die Gastoberfläche eine als Lesezeichen speicherbare Adresse mit öffentlicher Workspace-ID an. Ohne gewähltes Ablaufdatum gilt die Teilnahme eines Gasts 30 Tage. Der Einladungslink allein erlaubt keinen Inhaltszugriff.
|
||||||
@@ -299,7 +301,7 @@ Bei einem vorübergehend nicht erreichbaren Eigentümer sind bereits geladene Na
|
|||||||
|
|
||||||
### Einrichtung
|
### Einrichtung
|
||||||
|
|
||||||
1. Vor dem Backendstart die additiven Migrationen `0075_project_collaboration.sql` und `0076_collaboration_federation.sql` über den bestehenden Migrationslauf einspielen. Der Selfhost-Container führt standardmäßig `npm run migrate` aus. Bestehende interne Chats bleiben getrennt; kein vorhandener Chat wird automatisch freigegeben.
|
1. Vor dem Backendstart die additiven Migrationen `0075_project_collaboration.sql`, `0076_collaboration_federation.sql` und `0078_chat_visibility.sql` über den bestehenden Migrationslauf einspielen. Der Selfhost-Container führt standardmäßig `npm run migrate` aus. Bestehende interne Chats bleiben getrennt; kein vorhandener Chat wird automatisch freigegeben.
|
||||||
2. Die vorhandene `API_BASE_URL` wird als Föderationsadresse verwendet, einschließlich des Reverse-Proxy-Präfixes, etwa `https://fedeo.example/backend`. Gastlinks werden aus deren Ursprung `https://fedeo.example` abgeleitet. Zusätzliche URL-Variablen sind im Standardbetrieb nicht nötig. Für Gastcodes und optional versendete Einladungs-E-Mails muss der bestehende SMTP-Versand funktionieren. Den vorhandenen `ENCRYPTION_KEY` beibehalten; er muss ein stabiler 64-stelliger Hex-Schlüssel sein.
|
2. Die vorhandene `API_BASE_URL` wird als Föderationsadresse verwendet, einschließlich des Reverse-Proxy-Präfixes, etwa `https://fedeo.example/backend`. Gastlinks werden aus deren Ursprung `https://fedeo.example` abgeleitet. Zusätzliche URL-Variablen sind im Standardbetrieb nicht nötig. Für Gastcodes und optional versendete Einladungs-E-Mails muss der bestehende SMTP-Versand funktionieren. Den vorhandenen `ENCRYPTION_KEY` beibehalten; er muss ein stabiler 64-stelliger Hex-Schlüssel sein.
|
||||||
3. Der Standard ist jetzt `FEDEO_FEDERATION_ENABLED=auto`, auch wenn die Variable nicht gesetzt ist. Mit gültiger HTTPS-API-Adresse auf Port 443 und gültigem Verschlüsselungsschlüssel wird Föderation automatisch bereitgestellt. Lokale HTTP-Entwicklungsumgebungen oder unvollständige Konfigurationen bleiben ohne Föderation startfähig; die Partnerverwaltung nennt den fehlenden Konfigurationswert. `false` schaltet Föderation ausdrücklich ab. `true` erzwingt die Aktivierung und meldet eine ungültige Konfiguration bereits beim Start. Nur bei abweichender Infrastruktur `FEDEO_FEDERATION_URL` oder `FEDEO_PUBLIC_URL` setzen; beide überschreiben die automatisch abgeleiteten Werte. Ein separates Föderationsgateway verändert die Gast-Webadresse nicht automatisch.
|
3. Der Standard ist jetzt `FEDEO_FEDERATION_ENABLED=auto`, auch wenn die Variable nicht gesetzt ist. Mit gültiger HTTPS-API-Adresse auf Port 443 und gültigem Verschlüsselungsschlüssel wird Föderation automatisch bereitgestellt. Lokale HTTP-Entwicklungsumgebungen oder unvollständige Konfigurationen bleiben ohne Föderation startfähig; die Partnerverwaltung nennt den fehlenden Konfigurationswert. `false` schaltet Föderation ausdrücklich ab. `true` erzwingt die Aktivierung und meldet eine ungültige Konfiguration bereits beim Start. Nur bei abweichender Infrastruktur `FEDEO_FEDERATION_URL` oder `FEDEO_PUBLIC_URL` setzen; beide überschreiben die automatisch abgeleiteten Werte. Ein separates Föderationsgateway verändert die Gast-Webadresse nicht automatisch.
|
||||||
4. Der Proxy muss `GET <API-Adresse>/.well-known/fedeo-federation` und `POST <API-Adresse>/federation/v1/rpc` durchreichen und darf JSON-Body oder Signaturheader nicht verändern. Externe Ziele benötigen öffentlich auflösbare HTTPS-Adressen auf Port 443. Private/reservierte IPv4- und IPv6-Ziele sowie Weiterleitungen werden abgewiesen; die geprüfte DNS-Adresse wird für die TLS-Verbindung festgehalten.
|
4. Der Proxy muss `GET <API-Adresse>/.well-known/fedeo-federation` und `POST <API-Adresse>/federation/v1/rpc` durchreichen und darf JSON-Body oder Signaturheader nicht verändern. Externe Ziele benötigen öffentlich auflösbare HTTPS-Adressen auf Port 443. Private/reservierte IPv4- und IPv6-Ziele sowie Weiterleitungen werden abgewiesen; die geprüfte DNS-Adresse wird für die TLS-Verbindung festgehalten.
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ const requestedRoom = () => typeof route.query.room === 'string' ? route.query.r
|
|||||||
const activeRoomKey = ref(requestedRoom())
|
const activeRoomKey = ref(requestedRoom())
|
||||||
const loading = ref(true), roomLoading = ref(false), sending = ref(false), creating = ref(false)
|
const loading = ref(true), roomLoading = ref(false), sending = ref(false), creating = ref(false)
|
||||||
const showRoomForm = ref(false), messagesViewport = ref(null)
|
const showRoomForm = ref(false), messagesViewport = ref(null)
|
||||||
|
const hiddenRoomKeys = ref([]), showHiddenRooms = ref(false), preferenceBusy = ref(''), preferenceError = ref('')
|
||||||
|
const preferencesReady = ref(false)
|
||||||
const roomForm = reactive({ name: '', key: '', topic: '' })
|
const roomForm = reactive({ name: '', key: '', topic: '' })
|
||||||
const session = computed(() => `${auth.user?.id || auth.user?.user_id || ''}:${auth.activeTenant || ''}`)
|
const session = computed(() => `${auth.user?.id || auth.user?.user_id || ''}:${auth.activeTenant || ''}`)
|
||||||
const hasSession = computed(() => !!auth.user && !!auth.activeTenant)
|
const hasSession = computed(() => !!auth.user && !!auth.activeTenant)
|
||||||
@@ -38,10 +40,12 @@ const decoratedRooms = computed(() => [
|
|||||||
...collaborationRooms.value.map(room => ({ ...room, key: collaborationKey(room.id), source: 'collaboration', exists: true,
|
...collaborationRooms.value.map(room => ({ ...room, key: collaborationKey(room.id), source: 'collaboration', exists: true,
|
||||||
group: 'Zusammenarbeit', icon: 'i-heroicons-user-group', unread: room.unread || 0 }))
|
group: 'Zusammenarbeit', icon: 'i-heroicons-user-group', unread: room.unread || 0 }))
|
||||||
])
|
])
|
||||||
|
const visibleRooms = computed(() => decoratedRooms.value.filter(room => !hiddenRoomKeys.value.includes(room.key)))
|
||||||
|
const hiddenRooms = computed(() => decoratedRooms.value.filter(room => hiddenRoomKeys.value.includes(room.key)))
|
||||||
const groupedRooms = computed(() => ['Räume', 'Projekte', 'Direktnachrichten', 'Zusammenarbeit']
|
const groupedRooms = computed(() => ['Räume', 'Projekte', 'Direktnachrichten', 'Zusammenarbeit']
|
||||||
.map(label => ({ label, rooms: decoratedRooms.value.filter(room => room.group === label) }))
|
.map(label => ({ label, rooms: visibleRooms.value.filter(room => room.group === label) }))
|
||||||
.filter(group => group.rooms.length))
|
.filter(group => group.rooms.length))
|
||||||
const activeRoom = computed(() => decoratedRooms.value.find(room => room.key === activeRoomKey.value) || null)
|
const activeRoom = computed(() => visibleRooms.value.find(room => room.key === activeRoomKey.value) || null)
|
||||||
const isCollaboration = computed(() => activeRoom.value?.source === 'collaboration')
|
const isCollaboration = computed(() => activeRoom.value?.source === 'collaboration')
|
||||||
const isInternal = computed(() => activeRoom.value?.source === 'internal')
|
const isInternal = computed(() => activeRoom.value?.source === 'internal')
|
||||||
|
|
||||||
@@ -96,6 +100,40 @@ async function refreshLists() {
|
|||||||
await Promise.all([loadRoomLists(scope), loadUnread(scope), loadCollaboration(scope)])
|
await Promise.all([loadRoomLists(scope), loadUnread(scope), loadCollaboration(scope)])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadRoomPreferences(scope = scopeVersion) {
|
||||||
|
if (!hasSession.value || !currentScope(scope)) return
|
||||||
|
try {
|
||||||
|
const result = await $api('/api/communication/chat/room-preferences')
|
||||||
|
if (!currentScope(scope)) return
|
||||||
|
hiddenRoomKeys.value = result.hiddenRoomKeys || []
|
||||||
|
preferencesReady.value = true; preferenceError.value = ''
|
||||||
|
} catch (error) {
|
||||||
|
if (currentScope(scope)) preferenceError.value = errorMessage(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function setRoomHidden(room, hidden) {
|
||||||
|
if (preferenceBusy.value || !preferencesReady.value) return
|
||||||
|
const scope = scopeVersion
|
||||||
|
preferenceBusy.value = room.key
|
||||||
|
try {
|
||||||
|
await $api('/api/communication/chat/room-preferences', { method: 'PUT', body: { roomKey: room.key, hidden } })
|
||||||
|
if (!currentScope(scope)) return
|
||||||
|
hiddenRoomKeys.value = hidden
|
||||||
|
? [...new Set([...hiddenRoomKeys.value, room.key])]
|
||||||
|
: hiddenRoomKeys.value.filter(key => key !== room.key)
|
||||||
|
if (hidden && activeRoomKey.value === room.key) {
|
||||||
|
++roomVersion; activeRoomKey.value = ''; clearMessages()
|
||||||
|
const next = visibleRooms.value.find(candidate => candidate.exists)
|
||||||
|
if (next) await selectRoom(next)
|
||||||
|
else await router.replace({ query: { ...route.query, room: undefined } })
|
||||||
|
} else if (!hidden) {
|
||||||
|
const restored = visibleRooms.value.find(candidate => candidate.key === room.key)
|
||||||
|
if (restored) await selectRoom(restored)
|
||||||
|
}
|
||||||
|
} catch (error) { if (currentScope(scope)) toast.add({ title: errorMessage(error), color: 'error' }) }
|
||||||
|
finally { if (currentScope(scope)) preferenceBusy.value = '' }
|
||||||
|
}
|
||||||
|
|
||||||
async function markRead(request) {
|
async function markRead(request) {
|
||||||
if (!currentSelection(request) || !isInternal.value || !activeRoom.value?.exists) return
|
if (!currentSelection(request) || !isInternal.value || !activeRoom.value?.exists) return
|
||||||
const messageId = Number(messages.value.at(-1)?.id || 0)
|
const messageId = Number(messages.value.at(-1)?.id || 0)
|
||||||
@@ -119,6 +157,7 @@ async function loadActiveRoom(request = selection()) {
|
|||||||
} finally { if (currentSelection(request)) roomLoading.value = false }
|
} finally { if (currentSelection(request)) roomLoading.value = false }
|
||||||
}
|
}
|
||||||
async function selectRoom(room) {
|
async function selectRoom(room) {
|
||||||
|
if (hiddenRoomKeys.value.includes(room.key)) return
|
||||||
++roomVersion; activeRoomKey.value = room.key; clearMessages()
|
++roomVersion; activeRoomKey.value = room.key; clearMessages()
|
||||||
let request = selection()
|
let request = selection()
|
||||||
try {
|
try {
|
||||||
@@ -199,18 +238,22 @@ async function initialize() {
|
|||||||
++roomVersion; clearMessages()
|
++roomVersion; clearMessages()
|
||||||
baseRooms.value = []; projectRooms.value = []; directRooms.value = []; collaborationRooms.value = []; invitations.value = []
|
baseRooms.value = []; projectRooms.value = []; directRooms.value = []; collaborationRooms.value = []; invitations.value = []
|
||||||
unreadRooms.value = {}; invitationBusy.value = ''; collaborationError.value = ''; listError.value = ''; creating.value = false
|
unreadRooms.value = {}; invitationBusy.value = ''; collaborationError.value = ''; listError.value = ''; creating.value = false
|
||||||
|
hiddenRoomKeys.value = []; showHiddenRooms.value = false; preferenceBusy.value = ''; preferenceError.value = ''; preferencesReady.value = false
|
||||||
showRoomForm.value = false; Object.assign(roomForm, { name: '', key: '', topic: '' })
|
showRoomForm.value = false; Object.assign(roomForm, { name: '', key: '', topic: '' })
|
||||||
activeRoomKey.value = requestedRoom(); loading.value = true
|
activeRoomKey.value = requestedRoom(); loading.value = true
|
||||||
await refreshLists()
|
await Promise.all([refreshLists(), loadRoomPreferences(scope)])
|
||||||
if (!currentScope(scope)) return
|
if (!currentScope(scope)) return
|
||||||
loading.value = false
|
loading.value = false
|
||||||
const room = activeRoom.value || (activeRoomKey.value === 'allgemein' ? decoratedRooms.value[0] : null)
|
const room = activeRoom.value || (activeRoomKey.value === 'allgemein' ? visibleRooms.value.find(room => room.exists) : null)
|
||||||
if (room) await selectRoom(room)
|
if (room) await selectRoom(room)
|
||||||
}
|
}
|
||||||
watch(session, () => { if (mounted) void initialize() }, { flush: 'sync' })
|
watch(session, () => { if (mounted) void initialize() }, { flush: 'sync' })
|
||||||
|
watch(() => activeRoom.value?.key, (key, previous) => {
|
||||||
|
if (!key && previous && !loading.value) { ++roomVersion; clearMessages() }
|
||||||
|
})
|
||||||
watch(() => route.query.room, () => {
|
watch(() => route.query.room, () => {
|
||||||
if (!mounted || loading.value || requestedRoom() === activeRoomKey.value) return
|
if (!mounted || loading.value || requestedRoom() === activeRoomKey.value) return
|
||||||
const room = decoratedRooms.value.find(room => room.key === requestedRoom())
|
const room = visibleRooms.value.find(room => room.key === requestedRoom())
|
||||||
if (room) void selectRoom(room)
|
if (room) void selectRoom(room)
|
||||||
else { ++roomVersion; activeRoomKey.value = requestedRoom(); clearMessages() }
|
else { ++roomVersion; activeRoomKey.value = requestedRoom(); clearMessages() }
|
||||||
})
|
})
|
||||||
@@ -223,8 +266,8 @@ onBeforeUnmount(() => { disposed = true; ++scopeVersion; ++roomVersion; clearInt
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex min-h-0 flex-1 bg-gray-50 p-4 sm:p-6">
|
<div class="flex h-full min-h-0 min-w-0 w-full flex-1 overflow-hidden bg-white">
|
||||||
<div class="mx-auto grid h-[calc(100vh-8rem)] w-full max-w-7xl grid-cols-1 grid-rows-[minmax(8rem,30%)_minmax(0,1fr)] overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm lg:grid-rows-1"
|
<div class="grid h-full min-h-0 w-full flex-1 grid-cols-1 grid-rows-[minmax(8rem,30%)_minmax(0,1fr)] overflow-hidden lg:grid-rows-1"
|
||||||
:class="isCollaboration ? 'lg:grid-cols-[19rem_minmax(0,1fr)]' : 'lg:grid-cols-[19rem_minmax(0,1fr)_15rem]'">
|
:class="isCollaboration ? 'lg:grid-cols-[19rem_minmax(0,1fr)]' : 'lg:grid-cols-[19rem_minmax(0,1fr)_15rem]'">
|
||||||
<aside class="min-h-0 overflow-y-auto border-r border-gray-200 p-4">
|
<aside class="min-h-0 overflow-y-auto border-r border-gray-200 p-4">
|
||||||
<div class="mb-4 flex items-center justify-between">
|
<div class="mb-4 flex items-center justify-between">
|
||||||
@@ -248,6 +291,7 @@ onBeforeUnmount(() => { disposed = true; ++scopeVersion; ++roomVersion; clearInt
|
|||||||
<UButton to="/communication/collaboration" icon="i-heroicons-user-group" color="neutral" variant="outline" size="xs" class="mb-4 w-full justify-center">Unterräume und Freigaben</UButton>
|
<UButton to="/communication/collaboration" icon="i-heroicons-user-group" color="neutral" variant="outline" size="xs" class="mb-4 w-full justify-center">Unterräume und Freigaben</UButton>
|
||||||
<p v-if="listError" role="alert" class="mb-3 text-xs text-red-700">{{ listError }} <button class="underline" @click="loadRoomLists()">Erneut laden</button></p>
|
<p v-if="listError" role="alert" class="mb-3 text-xs text-red-700">{{ listError }} <button class="underline" @click="loadRoomLists()">Erneut laden</button></p>
|
||||||
<p v-if="collaborationError" role="alert" class="mb-3 text-xs text-red-700">Zusammenarbeit: {{ collaborationError }} <button class="underline" @click="loadCollaboration()">Erneut laden</button></p>
|
<p v-if="collaborationError" role="alert" class="mb-3 text-xs text-red-700">Zusammenarbeit: {{ collaborationError }} <button class="underline" @click="loadCollaboration()">Erneut laden</button></p>
|
||||||
|
<p v-if="preferenceError" role="alert" class="mb-3 text-xs text-red-700">Raumeinstellungen: {{ preferenceError }} <button class="underline" @click="loadRoomPreferences()">Erneut laden</button></p>
|
||||||
<section v-if="invitations.length" class="mb-5 space-y-2" aria-label="Einladungen">
|
<section v-if="invitations.length" class="mb-5 space-y-2" aria-label="Einladungen">
|
||||||
<h2 class="px-2 text-xs font-semibold uppercase tracking-wide text-gray-400">Einladungen</h2>
|
<h2 class="px-2 text-xs font-semibold uppercase tracking-wide text-gray-400">Einladungen</h2>
|
||||||
<div v-for="invitation in invitations" :key="invitation.id" class="rounded-lg border border-primary-200 bg-primary-50 p-3">
|
<div v-for="invitation in invitations" :key="invitation.id" class="rounded-lg border border-primary-200 bg-primary-50 p-3">
|
||||||
@@ -265,26 +309,43 @@ onBeforeUnmount(() => { disposed = true; ++scopeVersion; ++roomVersion; clearInt
|
|||||||
<div v-else class="space-y-5">
|
<div v-else class="space-y-5">
|
||||||
<section v-for="group in groupedRooms" :key="group.label">
|
<section v-for="group in groupedRooms" :key="group.label">
|
||||||
<h2 class="mb-1 px-2 text-xs font-semibold uppercase tracking-wide text-gray-400">{{ group.label }}</h2>
|
<h2 class="mb-1 px-2 text-xs font-semibold uppercase tracking-wide text-gray-400">{{ group.label }}</h2>
|
||||||
<button
|
<div
|
||||||
v-for="room in group.rooms"
|
v-for="room in group.rooms"
|
||||||
:key="room.key"
|
:key="room.key"
|
||||||
type="button"
|
class="mb-1 flex w-full items-center rounded-lg transition hover:bg-gray-100"
|
||||||
class="mb-1 flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left transition hover:bg-gray-100"
|
|
||||||
:class="room.key === activeRoomKey ? 'bg-primary-50 text-primary-700' : 'text-gray-700'"
|
:class="room.key === activeRoomKey ? 'bg-primary-50 text-primary-700' : 'text-gray-700'"
|
||||||
@click="selectRoom(room)"
|
|
||||||
>
|
>
|
||||||
<UIcon :name="room.icon" class="size-5 shrink-0" />
|
<button
|
||||||
<span class="min-w-0 flex-1">
|
type="button"
|
||||||
<span class="block truncate text-sm font-medium">{{ room.name }}</span>
|
class="flex min-w-0 flex-1 items-center gap-3 px-2 py-2 text-left"
|
||||||
<span class="block truncate text-xs text-gray-400">{{ room.source === 'collaboration' ? room.ownerName || 'Projekt-Unterraum' : room.topic }}{{ room.remote ? ' · Extern' : '' }}{{ room.status === 'archived' ? ' · Archiviert' : '' }}</span>
|
@click="selectRoom(room)"
|
||||||
</span>
|
>
|
||||||
<UBadge v-if="room.unread" color="primary" size="xs">{{ room.unread }}</UBadge>
|
<UIcon :name="room.icon" class="size-5 shrink-0" />
|
||||||
|
<span class="min-w-0 flex-1">
|
||||||
|
<span class="block truncate text-sm font-medium">{{ room.name }}</span>
|
||||||
|
<span class="block truncate text-xs text-gray-400">{{ room.source === 'collaboration' ? room.ownerName || 'Projekt-Unterraum' : room.topic }}{{ room.remote ? ' · Extern' : '' }}{{ room.status === 'archived' ? ' · Archiviert' : '' }}</span>
|
||||||
|
</span>
|
||||||
|
<UBadge v-if="room.unread" color="primary" size="xs">{{ room.unread }}</UBadge>
|
||||||
|
</button>
|
||||||
|
<UButton icon="i-heroicons-eye-slash" color="neutral" variant="ghost" size="xs" class="mr-1 shrink-0" :aria-label="`${room.name} ausblenden`" title="Für mich ausblenden" :disabled="!preferencesReady || !!preferenceBusy" @click="setRoomHidden(room, true)" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<p v-if="!visibleRooms.length" class="px-2 text-sm text-gray-500">Keine sichtbaren Räume. Du kannst ausgeblendete Räume unten wieder einblenden.</p>
|
||||||
|
<section v-if="hiddenRooms.length" aria-label="Ausgeblendete Räume">
|
||||||
|
<button type="button" class="flex w-full items-center justify-between px-2 py-2 text-left text-xs font-semibold text-gray-500" :aria-expanded="showHiddenRooms" @click="showHiddenRooms = !showHiddenRooms">
|
||||||
|
<span>Ausgeblendete Räume ({{ hiddenRooms.length }})</span><UIcon :name="showHiddenRooms ? 'i-heroicons-chevron-up' : 'i-heroicons-chevron-down'" />
|
||||||
</button>
|
</button>
|
||||||
|
<div v-if="showHiddenRooms" class="space-y-1">
|
||||||
|
<div v-for="room in hiddenRooms" :key="room.key" class="flex items-center gap-2 px-2 py-1">
|
||||||
|
<span class="min-w-0 flex-1 truncate text-sm text-gray-500">{{ room.name }}</span>
|
||||||
|
<UButton icon="i-heroicons-eye" variant="ghost" color="neutral" size="xs" :aria-label="`${room.name} einblenden`" :disabled="!!preferenceBusy" @click="setRoomHidden(room, false)">Einblenden</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<CollaborationRoom v-if="isCollaboration" :key="`${session}:${activeRoom.id}`" :room-id="activeRoom.id" :api="$api" embedded @changed="loadCollaboration()" @unauthorized="loadCollaboration()">
|
<CollaborationRoom v-if="!loading && isCollaboration" :key="`${session}:${activeRoom.id}`" :room-id="activeRoom.id" :api="$api" embedded @changed="loadCollaboration()" @unauthorized="loadCollaboration()">
|
||||||
<template #actions><UButton :to="{ path: '/communication/collaboration', query: { room: activeRoom.id } }" color="neutral" variant="outline" size="xs">Details und Freigaben</UButton></template>
|
<template #actions><UButton :to="{ path: '/communication/collaboration', query: { room: activeRoom.id } }" color="neutral" variant="outline" size="xs">Details und Freigaben</UButton></template>
|
||||||
</CollaborationRoom>
|
</CollaborationRoom>
|
||||||
<main v-else class="flex min-h-0 flex-col">
|
<main v-else class="flex min-h-0 flex-col">
|
||||||
|
|||||||
@@ -1717,6 +1717,16 @@ export const useDataStore = defineStore('data', () => {
|
|||||||
key: "customerRef",
|
key: "customerRef",
|
||||||
label: "Referenz des Kunden",
|
label: "Referenz des Kunden",
|
||||||
inputType: "text"
|
inputType: "text"
|
||||||
|
},{
|
||||||
|
key: "chatEnabled",
|
||||||
|
label: "Projektchat aktiviert",
|
||||||
|
inputType: "bool",
|
||||||
|
component: active,
|
||||||
|
defaultValue: true,
|
||||||
|
disabledFunction: () => {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
return !auth.user?.is_admin && !auth.hasPermission('organisation.projects.write')
|
||||||
|
}
|
||||||
},{
|
},{
|
||||||
key: "notes",
|
key: "notes",
|
||||||
label: "Notizen",
|
label: "Notizen",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ function fixture(override = () => undefined) {
|
|||||||
const result = override(url, options)
|
const result = override(url, options)
|
||||||
if (result !== undefined) return result
|
if (result !== undefined) return result
|
||||||
if (url === '/api/collaboration') return { rooms: [sharedRoom], invitations: [], managedRooms: [{ id: 'admin-only' }] }
|
if (url === '/api/collaboration') return { rooms: [sharedRoom], invitations: [], managedRooms: [{ id: 'admin-only' }] }
|
||||||
|
if (url === '/api/communication/chat/room-preferences') return { hiddenRoomKeys: [] }
|
||||||
if (url === '/api/communication/chat/rooms') return { rooms: [nativeRoom] }
|
if (url === '/api/communication/chat/rooms') return { rooms: [nativeRoom] }
|
||||||
if (url.endsWith('/project-rooms') || url.endsWith('/direct-rooms')) return { rooms: [] }
|
if (url.endsWith('/project-rooms') || url.endsWith('/direct-rooms')) return { rooms: [] }
|
||||||
if (url.endsWith('/unread')) return { rooms: { allgemein: { count: 2 } } }
|
if (url.endsWith('/unread')) return { rooms: { allgemein: { count: 2 } } }
|
||||||
@@ -237,3 +238,92 @@ test('unmount cancels both polling timers and ignores pending lists', async () =
|
|||||||
assert.equal(ui.run('collaborationRooms.value.length'), 0)
|
assert.equal(ui.run('collaborationRooms.value.length'), 0)
|
||||||
assert.equal(ui.run('messages.value.length'), 0)
|
assert.equal(ui.run('messages.value.length'), 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('hiding the active room persists the preference, switches away and allows restoring it', async t => {
|
||||||
|
const ui = setup()
|
||||||
|
t.after(ui.close)
|
||||||
|
ui.mount()
|
||||||
|
await settle()
|
||||||
|
await ui.run('setRoomHidden(activeRoom.value, true)')
|
||||||
|
assert.equal(ui.run('hiddenRooms.value[0].key'), 'allgemein')
|
||||||
|
assert.equal(ui.run('visibleRooms.value.some(room => room.key === "allgemein")'), false)
|
||||||
|
assert.equal(ui.run('isCollaboration.value'), true)
|
||||||
|
assert.equal(ui.run('messages.value.length'), 0)
|
||||||
|
const save = ui.calls.find(call => call.url.endsWith('/room-preferences') && call.options?.method === 'PUT')
|
||||||
|
assert.equal(save.options.body.roomKey, 'allgemein')
|
||||||
|
assert.equal(save.options.body.hidden, true)
|
||||||
|
await ui.run('setRoomHidden(hiddenRooms.value[0], false)')
|
||||||
|
assert.equal(ui.run('hiddenRooms.value.length'), 0)
|
||||||
|
assert.equal(ui.run('activeRoomKey.value'), 'allgemein')
|
||||||
|
assert.equal(ui.run('messages.value[0].body'), 'Interne Nachricht')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('saved hidden rooms stay hidden on reload, including federated room deep links', async t => {
|
||||||
|
const ui = setup(fixture(url => url.endsWith('/room-preferences') ? { hiddenRoomKeys: ['collaboration:shared-room', 'allgemein'] } : undefined), { room: 'collaboration:shared-room' })
|
||||||
|
t.after(ui.close)
|
||||||
|
ui.mount()
|
||||||
|
await settle()
|
||||||
|
assert.equal(ui.run('visibleRooms.value.length'), 0)
|
||||||
|
assert.equal(ui.run('activeRoom.value'), null)
|
||||||
|
assert.equal(ui.calls.some(({ url }) => /\/rooms\/.+\/(messages|read)/.test(url)), false)
|
||||||
|
await ui.run('selectRoom(decoratedRooms.value[0])')
|
||||||
|
assert.equal(ui.run('activeRoom.value'), null)
|
||||||
|
await ui.run('setRoomHidden(hiddenRooms.value.find(r => r.source === "collaboration"), false)')
|
||||||
|
assert.equal(ui.run('isCollaboration.value'), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('preference save failure leaves the active conversation and list unchanged', async t => {
|
||||||
|
const ui = setup(fixture((url, options) => {
|
||||||
|
if (url.endsWith('/room-preferences') && options?.method === 'PUT') throw { data: { message: 'Speichern fehlgeschlagen' } }
|
||||||
|
}))
|
||||||
|
t.after(ui.close)
|
||||||
|
ui.mount()
|
||||||
|
await settle()
|
||||||
|
await ui.run('setRoomHidden(activeRoom.value, true)')
|
||||||
|
assert.equal(ui.run('hiddenRooms.value.length'), 0)
|
||||||
|
assert.equal(ui.run('activeRoomKey.value'), 'allgemein')
|
||||||
|
assert.equal(ui.run('messages.value[0].body'), 'Interne Nachricht')
|
||||||
|
assert.equal(ui.toasts.at(-1).title, 'Speichern fehlgeschlagen')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('pending hide operations cannot change a new tenant, and hiding the last room clears its content', async t => {
|
||||||
|
const pending = deferred()
|
||||||
|
const ui = setup(fixture((url, options) => {
|
||||||
|
if (url.endsWith('/room-preferences') && options?.method === 'PUT') return pending.promise
|
||||||
|
}))
|
||||||
|
t.after(ui.close)
|
||||||
|
ui.mount()
|
||||||
|
await settle()
|
||||||
|
const hiding = ui.run('setRoomHidden(activeRoom.value, true)')
|
||||||
|
ui.auth.activeTenant = '2'
|
||||||
|
await settle()
|
||||||
|
pending.resolve({ hidden: true, roomKey: 'allgemein' })
|
||||||
|
await hiding
|
||||||
|
assert.equal(ui.run('hiddenRoomKeys.value.length'), 0)
|
||||||
|
assert.equal(ui.run('activeRoomKey.value'), 'allgemein')
|
||||||
|
const single = setup(fixture(url => url === '/api/collaboration' ? { rooms: [], invitations: [] } : undefined))
|
||||||
|
t.after(single.close)
|
||||||
|
single.mount()
|
||||||
|
await settle()
|
||||||
|
await single.run('setRoomHidden(activeRoom.value, true)')
|
||||||
|
await settle()
|
||||||
|
assert.equal(single.run('activeRoom.value'), null)
|
||||||
|
assert.equal(single.run('messages.value.length'), 0)
|
||||||
|
assert.equal(single.run('members.value.length'), 0)
|
||||||
|
assert.equal(single.run('visibleRooms.value.length'), 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('removing an active project from the permitted room list clears its messages and members', async t => {
|
||||||
|
let disabled = false
|
||||||
|
const ui = setup(fixture(url => url.endsWith('/project-rooms') ? { rooms: disabled ? [] : [{ key: 'project_1', exists: true, type: 'project' }] } : undefined), { room: 'project_1' })
|
||||||
|
t.after(ui.close)
|
||||||
|
ui.mount()
|
||||||
|
await settle()
|
||||||
|
assert.equal(ui.run('messages.value.length'), 1)
|
||||||
|
disabled = true
|
||||||
|
await ui.run('loadRoomLists()')
|
||||||
|
await settle()
|
||||||
|
assert.equal(ui.run('activeRoom.value'), null)
|
||||||
|
assert.equal(ui.run('messages.value.length'), 0)
|
||||||
|
assert.equal(ui.run('members.value.length'), 0)
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user