AnyDesk-ID gemmes på kontaktpersonens hardware. Findes maskinen ikke, oprettes en PC under firmaet. Koblingen bruges også på tidligere uregistrerede sessioner. Sag og noter gælder kun denne session.
@@ -718,8 +719,8 @@ async function loadHardware() {
async function loadCustomers() {
try {
- const data = await api('/api/v1/customers/?limit=500');
- customerList = data.customers || data || [];
+ const data = await api('/api/v1/customers/?limit=500&is_active=true');
+ customerList = (data.customers || data || []).filter(customer => customer.is_active === true);
} catch(e) { /* silent */ }
}
@@ -1036,8 +1037,8 @@ async function loadContactsForCustomer(customerId, selectedId) {
sel.innerHTML = '';
try {
const url = customerId
- ? `/api/v1/contacts/?customer_id=${customerId}&limit=300`
- : '/api/v1/contacts/?limit=300';
+ ? `/api/v1/contacts/?customer_id=${customerId}&limit=100&is_active=true`
+ : '/api/v1/contacts/?limit=100&is_active=true';
const data = await api(url);
currentContactList = data.contacts || data || [];
filterContactOptions();
@@ -1138,13 +1139,17 @@ async function saveRegModal() {
const params = new URLSearchParams();
if (hw) params.append('hardware_asset_id', hw);
if (cust) params.append('customer_id', cust);
+ if (cust) params.append('remember_machine_link', 'true');
if (contact) params.append('contact_id', contact);
if (sag) params.append('sag_id', sag);
if (notes) params.append('notes', notes);
try {
- await api(`/api/v1/anydesk/sessions/${id}/link?${params}`, { method: 'PATCH' });
- toast('✅ Session registreret', 'success');
+ const result = await api(`/api/v1/anydesk/sessions/${id}/link?${params}`, { method: 'PATCH' });
+ toast(result.remembered
+ ? `✅ AnyDesk-ID gemt på hardware · ${result.linked_sessions || 0} andre sessioner opdateret`
+ : '✅ Session registreret', 'success');
+ if (result.remembered) await loadHardware();
document.getElementById('drawer-backdrop').classList.remove('open');
document.getElementById('session-drawer').classList.remove('open');
await loadSessions();
diff --git a/app/contacts/frontend/contact_detail.html b/app/contacts/frontend/contact_detail.html
index cc2ab5c..f892e20 100644
--- a/app/contacts/frontend/contact_detail.html
+++ b/app/contacts/frontend/contact_detail.html
@@ -878,6 +878,24 @@
+
+
+
+
+
Tilknyt sag
+
+
+
+
+
+
Kun sager fra sessionens firma. Klik på en sag for at tilknytte den.
`;
+ }
+}
+
+async function linkRemoteSessionCase(caseId) {
+ if (remoteCaseSaving) return;
+ remoteCaseSaving = true;
+ const sessionId = remoteCaseSessionId;
+ const errorBox = document.getElementById('remoteCaseError');
+ errorBox.classList.add('d-none');
+ document.querySelectorAll('[data-remote-case]').forEach(button => button.disabled = true);
+ try {
+ const response = await fetch(`/api/v1/anydesk/sessions/${sessionId}/link?sag_id=${caseId}&require_same_customer=true`, { method: 'PATCH' });
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(typeof error.detail === 'string' ? error.detail : 'Kunne ikke tilknytte sagen');
+ }
+ bootstrap.Modal.getOrCreateInstance(document.getElementById('remoteCaseModal')).hide();
+ await loadRemoteSessions();
+ } catch (error) {
+ errorBox.textContent = error.message;
+ errorBox.classList.remove('d-none');
+ } finally {
+ remoteCaseSaving = false;
+ document.querySelectorAll('[data-remote-case]').forEach(button => button.disabled = false);
+ }
}
async function startRemoteSession() {
diff --git a/app/core/case_visibility.py b/app/core/case_visibility.py
new file mode 100644
index 0000000..2e064c6
--- /dev/null
+++ b/app/core/case_visibility.py
@@ -0,0 +1,199 @@
+"""Shared case confidentiality policy for HTML, APIs, feeds and work queues."""
+from contextvars import ContextVar
+import json
+import re
+
+from fastapi import HTTPException
+from starlette.responses import JSONResponse
+from psycopg2.errors import DeadlockDetected,SerializationFailure
+from app.core.database import execute_query, execute_query_single
+
+_scope = ContextVar('case_visibility_scope', default=None)
+_DROP = object()
+
+
+def hidden_case_ids(user):
+ if user.get('is_superadmin'):
+ return set()
+ current = _scope.get()
+ if current is not None and current[0] == user.get('id'):
+ return current[1]
+ if 'permissions' in user:
+ readable=bool(set(user.get('permissions') or []) & {'cases.view','tickets.view'})
+ else:
+ row=execute_query_single('''SELECT EXISTS(SELECT 1 FROM users WHERE user_id=%s AND is_superadmin)
+ OR EXISTS(SELECT 1 FROM user_groups ug JOIN group_permissions gp ON gp.group_id=ug.group_id
+ JOIN permissions p ON p.id=gp.permission_id WHERE ug.user_id=%s AND p.code IN ('cases.view','tickets.view')) AS allowed''',
+ (user.get('id'),user.get('id')))
+ readable=bool(row and row['allowed'])
+ if not readable:
+ return {r['id'] for r in execute_query('SELECT id FROM sag_sager') or []}
+ exists = execute_query_single("SELECT to_regclass('sag_case_visibility') IS NOT NULL AS present")
+ if not exists or not exists['present']:
+ return set()
+ return {r['sag_id'] for r in execute_query('''SELECT v.sag_id FROM sag_case_visibility v
+ WHERE v.restricted AND NOT EXISTS(SELECT 1 FROM sag_case_visibility_users a WHERE a.sag_id=v.sag_id AND a.user_id=%s)
+ AND NOT EXISTS(SELECT 1 FROM sag_case_visibility_groups a JOIN user_groups ug ON ug.group_id=a.group_id
+ WHERE a.sag_id=v.sag_id AND ug.user_id=%s)''',(user.get('id'),user.get('id'))) or []}
+
+
+def require_case(user,sag_id):
+ if sag_id in hidden_case_ids(user):
+ # 404 avoids confirming whether a confidential case exists.
+ raise HTTPException(404,'Sagen findes ikke')
+
+
+def notification_recipients(sag_id,user_ids):
+ exists=execute_query_single("SELECT to_regclass('sag_case_visibility') IS NOT NULL AS present")
+ if not exists or not exists['present']:
+ return user_ids,False
+ users=execute_query('''SELECT u.user_id FROM users u WHERE u.user_id=ANY(%s) AND u.is_active AND
+ (u.is_superadmin OR EXISTS(SELECT 1 FROM user_groups ug JOIN group_permissions gp ON gp.group_id=ug.group_id
+ JOIN permissions p ON p.id=gp.permission_id WHERE ug.user_id=u.user_id AND p.code IN ('cases.view','tickets.view')))''',(user_ids,)) or []
+ user_ids=[r['user_id'] for r in users]
+ private=execute_query_single('SELECT restricted FROM sag_case_visibility WHERE sag_id=%s',(sag_id,))
+ if not private or not private['restricted']:
+ return user_ids,False
+ allowed=execute_query('''SELECT u.user_id FROM users u WHERE u.user_id=ANY(%s) AND u.is_active AND
+ (u.is_superadmin OR EXISTS(SELECT 1 FROM sag_case_visibility_users a WHERE a.sag_id=%s AND a.user_id=u.user_id)
+ OR EXISTS(SELECT 1 FROM sag_case_visibility_groups a JOIN user_groups ug ON ug.group_id=a.group_id
+ WHERE a.sag_id=%s AND ug.user_id=u.user_id))''',(user_ids,sag_id,sag_id)) or []
+ return [r['user_id'] for r in allowed],True
+
+
+def linked_ids(value,case_list=False):
+ result = set()
+ if not isinstance(value,dict):
+ return result
+ for key in ('sag_id','hub_case_id','kilde_sag_id','målsag_id','parent_sag_id'):
+ if isinstance(value.get(key),int):
+ result.add(value[key])
+ if value.get('reference_type')=='case' and isinstance(value.get('reference_id'),int):
+ result.add(value['reference_id'])
+ if 'titel' in value and isinstance(value.get('case_id'),int):
+ result.add(value['case_id'])
+ elif (case_list or 'titel' in value or value.get('entity_type')=='case') and isinstance(value.get('id'),int):
+ result.add(value['id'])
+ if value.get('type')=='relation' and isinstance(value.get('case_id'),int):
+ result.add(value['case_id'])
+ for key in ('url','action','href','download_url','link','case_url','action_url','context_path'):
+ if isinstance(value.get(key),str):
+ result.update(int(s) for s in re.findall(r'/sag/(\d+)(?:/|\?|$)',value[key]))
+ if isinstance(value.get('extendedProps'),dict):
+ result.update(linked_ids(value['extendedProps']))
+ for key in ('case','linked_case','parent_case'):
+ if isinstance(value.get(key),dict):
+ result.update(linked_ids(value[key]))
+ return result
+
+
+def filter_data(value,hidden,context=''):
+ """Drop confidential records; calendar reservations retain only busy intervals."""
+ if not hidden:
+ return value
+ if isinstance(value,list):
+ result=[]
+ for item in value:
+ filtered=filter_data(item,hidden,context)
+ if filtered is not _DROP:
+ result.append(filtered)
+ return result
+ if not isinstance(value,dict):
+ return value
+ case_list='cases' in context or context in {'/api/v1/sag','/api/v1/planner/cases','urgent','unassigned','today_tasks'}
+ if linked_ids(value,case_list) & hidden:
+ start=value.get('starts_at') or value.get('start')
+ end=value.get('ends_at') or value.get('end')
+ if start and end:
+ safe={k:value[k] for k in ('starts_at','ends_at','start','end','user_id') if k in value}
+ safe.update(title='Optaget',case_title='Optaget',customer_name='',private=True,locked=True,editable=False,conflict_status='none')
+ safe['id']='busy-'+str(value.get('id','reservation'))
+ return safe
+ return _DROP
+ result={}
+ for key,item in value.items():
+ filtered=filter_data(item,hidden,key if key not in {'items','list'} else context)
+ if filtered is not _DROP:
+ result[key]=filtered
+ return result
+
+
+def requested_case_ids(value):
+ result=set()
+ if isinstance(value,list):
+ for item in value:
+ result.update(requested_case_ids(item))
+ elif isinstance(value,dict):
+ if value.get('type')=='relation':
+ raw=value.get('case_id') or value.get('caseId') or value.get('target_id')
+ try:
+ result.add(int(str(raw).lstrip('#')))
+ except (ValueError,TypeError):
+ pass
+ for key,item in value.items():
+ if key in {'sag_id','hub_case_id','kilde_sag_id','målsag_id'} and isinstance(item,int):
+ result.add(item)
+ elif key=='sag_ids' and isinstance(item,list):
+ result.update(i for i in item if isinstance(i,int))
+ elif isinstance(item,(dict,list)):
+ result.update(requested_case_ids(item))
+ return result
+
+
+def scrub_absence_time(value,user_id,can_see_reason):
+ if isinstance(value,list):
+ return [scrub_absence_time(item,user_id,can_see_reason) for item in value]
+ if not isinstance(value,dict):
+ return value
+ result={k:scrub_absence_time(v,user_id,can_see_reason) for k,v in value.items()}
+ owner=value.get('medarbejder_id',value.get('user_id'))
+ if value.get('planner_absence_id') and owner!=user_id and not can_see_reason:
+ for key in ('planner_activity_code','planner_absence_id'):
+ if key in result:
+ result[key]=None
+ if 'description' in result:
+ result['description']='Fravær'
+ return result
+
+
+async def protect_request(request,call_next,user):
+ hidden=hidden_case_ids(user)
+ token=_scope.set((user.get('id'),hidden))
+ try:
+ request.state.case_superadmin=bool(user.get('is_superadmin'))
+ refs={int(s) for s in re.findall(r'/(?:sag|planner/cases)/(\d+)(?:/|$)',request.url.path)}
+ for key in ('sag_id','kilde_sag_id','målsag_id'):
+ raw=request.query_params.get(key)
+ if raw and raw.isdigit():
+ refs.add(int(raw))
+ if hidden and request.method in {'POST','PUT','PATCH','DELETE'} and 'application/json' in request.headers.get('content-type',''):
+ try:
+ refs.update(requested_case_ids(await request.json()))
+ except (ValueError,UnicodeError):
+ pass # Normal endpoint validation handles malformed JSON.
+ if refs & hidden:
+ return JSONResponse({'detail':'Sagen findes ikke'},status_code=404)
+ try:
+ response=await call_next(request)
+ except (DeadlockDetected,SerializationFailure):
+ if request.url.path.startswith('/api/v1/planner'):
+ return JSONResponse({'detail':'Planlægningen blev ændret samtidig. Genindlæs og prøv igen.'},status_code=409)
+ raise
+ check_absence=request.url.path.startswith('/api/v1/timetracking')
+ if (hidden or check_absence) and request.url.path.startswith('/api/') and 'application/json' in response.headers.get('content-type',''):
+ body=b''.join([chunk async for chunk in response.body_iterator])
+ data=json.loads(body)
+ filtered=filter_data(data,hidden,request.url.path.rstrip('/'))
+ if check_absence and b'planner_absence_id' in body:
+ allowed=bool(user.get('is_superadmin') or set(user.get('permissions') or []) & {'planner.admin','planner.absence_reason'})
+ if not allowed and 'permissions' not in user:
+ row=execute_query_single('''SELECT EXISTS(SELECT 1 FROM user_groups ug JOIN group_permissions gp ON gp.group_id=ug.group_id
+ JOIN permissions p ON p.id=gp.permission_id WHERE ug.user_id=%s AND p.code IN ('planner.admin','planner.absence_reason')) AS allowed''',(user.get('id'),))
+ allowed=bool(row and row['allowed'])
+ filtered=scrub_absence_time(filtered,user.get('id'),allowed)
+ result=JSONResponse(None if filtered is _DROP else filtered,status_code=response.status_code,background=response.background)
+ result.raw_headers += [(k,v) for k,v in response.raw_headers if k.lower() not in {b'content-length',b'content-type'}]
+ return result
+ return response
+ finally:
+ _scope.reset(token)
diff --git a/app/dashboard/backend/router.py b/app/dashboard/backend/router.py
index 3fdb173..dde6633 100644
--- a/app/dashboard/backend/router.py
+++ b/app/dashboard/backend/router.py
@@ -1,4 +1,4 @@
-from fastapi import APIRouter, HTTPException
+from fastapi import APIRouter, HTTPException, Request
from app.core.database import execute_query, execute_query_single
from typing import Dict, Any, List
import logging
@@ -171,7 +171,7 @@ async def global_search(q: str):
@router.get("/search/sag", response_model=List[Dict[str, Any]])
-async def search_sag(q: str):
+async def search_sag(q: str, request: Request):
"""
Search for cases (sager) with customer information
"""
@@ -181,6 +181,9 @@ async def search_sag(q: str):
search_term = f"%{q}%"
try:
+ from app.core.case_visibility import hidden_case_ids
+ hidden=hidden_case_ids({'id':getattr(request.state,'user_id',None),
+ 'is_superadmin':getattr(request.state,'case_superadmin',False)})
# Search cases with customer names
sager = execute_query("""
SELECT
@@ -233,9 +236,10 @@ async def search_sag(q: str):
AND b.word ILIKE %s
)
)
+ AND NOT(s.id=ANY(%s))
ORDER BY s.created_at DESC
LIMIT 20
- """, (search_term, search_term, search_term, search_term, search_term, search_term, search_term))
+ """, (search_term, search_term, search_term, search_term, search_term, search_term, search_term,list(hidden)))
return sager or []
except Exception as e:
diff --git a/app/economy/frontend/also_cloud.html b/app/economy/frontend/also_cloud.html
index 20fcfd2..7ecf829 100644
--- a/app/economy/frontend/also_cloud.html
+++ b/app/economy/frontend/also_cloud.html
@@ -198,19 +198,19 @@