6 Commits
8 changed files with 274 additions and 27 deletions

No files matched your search

+2 -2
View File
@@ -1,4 +1,4 @@
# TeamVis — Self-Hosting-Bundle (v0.56.2) # TeamVis — Self-Hosting-Bundle (v0.60.0)
Dieses Bundle enthält alles zum **Betreiben** von TeamVis auf eigener Dieses Bundle enthält alles zum **Betreiben** von TeamVis auf eigener
Infrastruktur — **keinen** App-Quellcode. Die App selbst kommt als fertiges Infrastruktur — **keinen** App-Quellcode. Die App selbst kommt als fertiges
@@ -36,4 +36,4 @@ Vollständige Schritt-für-Schritt-Anleitung (von der nackten VM bis live):
| `supabase/migrations/` | Datenbank-Schema (DDL) | | `supabase/migrations/` | Datenbank-Schema (DDL) |
| `docs/` | Anleitungen | | `docs/` | Anleitungen |
Stand: TeamVis 0.56.2. Stand: TeamVis 0.60.0.
+4
View File
@@ -35,3 +35,7 @@ TEAMVIS_SMTP_FROM_NAME=TeamVis
# Optional: zusätzliche next/image-Hosts (i.d.R. leer lassen). # Optional: zusätzliche next/image-Hosts (i.d.R. leer lassen).
SUPABASE_IMAGE_HOSTS= SUPABASE_IMAGE_HOSTS=
# Update-Hinweis im Admin (ab 0.57): ruft höchstens alle 6 Stunden eine
# öffentliche Datei bei git.zfx.services ab. Abschalten mit:
# TEAMVIS_UPDATE_CHECK=off
+56 -24
View File
@@ -67,32 +67,48 @@ POSTGRES_DB="${POSTGRES_DB:-postgres}"
PGPW="$(grep -E '^POSTGRES_PASSWORD=' "$INSTANCE/.env" 2>/dev/null | cut -d= -f2- || true)" PGPW="$(grep -E '^POSTGRES_PASSWORD=' "$INSTANCE/.env" 2>/dev/null | cut -d= -f2- || true)"
dbsql() { ( cd "$INSTANCE" && docker compose exec -T -e PGPASSWORD="$PGPW" db psql -v ON_ERROR_STOP=1 -U supabase_admin -d "$POSTGRES_DB" "$@" ); } dbsql() { ( cd "$INSTANCE" && docker compose exec -T -e PGPASSWORD="$PGPW" db psql -v ON_ERROR_STOP=1 -U supabase_admin -d "$POSTGRES_DB" "$@" ); }
# ── 1. Pre-Update-Backup (nur Modus 2 — lokale DB) ──────────────────── if [ -n "${TEAMVIS_UPDATE_REEXEC:-}" ]; then
if [ "$MODE2" = "1" ]; then # Zweiter Durchlauf nach dem Selbst-Neustart (siehe Ende von Schritt 2):
head "1) Datenbank-Backup" # Sicherung und git pull sind schon erledigt.
ts="$(date +%Y%m%d-%H%M%S)" OLD="$TEAMVIS_UPDATE_OLD"
dump="$INSTANCE/backup-db-$ts.sql" NEW="$(git rev-parse HEAD)"
# Der Dump enthält alle Personendaten — nur für den Eigentümer lesbar ok "Neue Fassung von update.sh übernommen — weiter ab Schritt 3."
# anlegen (vorher landete er mit 644 im Instanz-Verzeichnis). else
install -m 600 /dev/null "$dump" # ── 1. Pre-Update-Backup (nur Modus 2 — lokale DB) ────────────────────
if ( cd "$INSTANCE" && docker compose exec -T -e PGPASSWORD="$PGPW" db pg_dump -U supabase_admin "$POSTGRES_DB" ) > "$dump" 2>/dev/null; then if [ "$MODE2" = "1" ]; then
ok "Backup: $dump ($(wc -l < "$dump") Zeilen)" head "1) Datenbank-Backup"
ts="$(date +%Y%m%d-%H%M%S)"
dump="$INSTANCE/backup-db-$ts.sql"
# Der Dump enthält alle Personendaten — nur für den Eigentümer lesbar
# anlegen (vorher landete er mit 644 im Instanz-Verzeichnis).
install -m 600 /dev/null "$dump"
if ( cd "$INSTANCE" && docker compose exec -T -e PGPASSWORD="$PGPW" db pg_dump -U supabase_admin "$POSTGRES_DB" ) > "$dump" 2>/dev/null; then
ok "Backup: $dump ($(wc -l < "$dump") Zeilen)"
else
warn "Backup fehlgeschlagen — trotzdem fortfahren? (Strg-C zum Abbrechen)"; read -r _ || true
fi
else else
warn "Backup fehlgeschlagen — trotzdem fortfahren? (Strg-C zum Abbrechen)"; read -r _ || true warn "Externe Supabase (Modus 1): Backup macht dein Supabase-Anbieter. Kein lokaler Dump."
fi fi
else
warn "Externe Supabase (Modus 1): Backup macht dein Supabase-Anbieter. Kein lokaler Dump."
fi
# ── 2. git pull → neue Migrationen/Skripte ──────────────────────────── # ── 2. git pull → neue Migrationen/Skripte ────────────────────────────
head "2) Bundle aktualisieren (git pull)" head "2) Bundle aktualisieren (git pull)"
OLD="$(git rev-parse HEAD)" OLD="$(git rev-parse HEAD)"
git pull --ff-only origin main || die "git pull fehlgeschlagen (abweichende Branches?). Notfalls: git fetch && git reset --hard origin/main" git pull --ff-only origin main || die "git pull fehlgeschlagen (abweichende Branches?). Notfalls: git fetch && git reset --hard origin/main"
NEW="$(git rev-parse HEAD)" NEW="$(git rev-parse HEAD)"
if [ "$OLD" = "$NEW" ]; then if [ "$OLD" = "$NEW" ]; then
ok "Schon aktuell (keine neuen Commits)." ok "Schon aktuell (keine neuen Commits)."
else else
ok "Aktualisiert: ${OLD:0:8} → ${NEW:0:8}" ok "Aktualisiert: ${OLD:0:8} → ${NEW:0:8}"
fi
# Hat der Pull update.sh selbst geändert, läuft ab hier die NEUE Fassung
# weiter — sonst griffen Korrekturen am Skript erst beim nächsten Update
# (Okt. 2026: Instanzen mussten update.sh deshalb mehrfach starten).
if [ "$OLD" != "$NEW" ] && ! git diff --quiet "$OLD" "$NEW" -- deploy/selfhost/update.sh; then
say "update.sh wurde mit aktualisiert — die neue Fassung übernimmt."
exec env TEAMVIS_UPDATE_REEXEC=1 TEAMVIS_UPDATE_OLD="$OLD" bash "$BUNDLE/deploy/selfhost/update.sh" "$INSTANCE"
fi
fi fi
# ── 3. Fehlende Migrationen einspielen ──────────────────────────────── # ── 3. Fehlende Migrationen einspielen ────────────────────────────────
@@ -149,10 +165,26 @@ fi
# ── 4. Neues Image ziehen + Container neu starten ───────────────────── # ── 4. Neues Image ziehen + Container neu starten ─────────────────────
head "4) App aktualisieren" head "4) App aktualisieren"
# Image-Version: Steht in der .env eine feste, ältere Version (frühere
# Installer, oder bewusst gepinnt), zöge `docker compose pull` immer wieder
# dasselbe alte Image — die App bliebe trotz neuer Migrationen stehen (RZHAS,
# 10.10.2026). Daher auf die Version des Bundles heben. `latest` bleibt
# `latest`, eine bewusst neuere Version bleibt unangetastet.
# (Kein `| head` in diesem Skript: head() ist oben als Überschriften-Funktion
# definiert und überdeckt den Befehl — daher grep -m1.)
BUNDLE_VERSION="$(grep -m1 -o '"version"[[:space:]]*:[[:space:]]*"[^"]*"' "$BUNDLE/package.json" | sed 's/.*"\([^"]*\)"$/\1/')"
CUR_VERSION="$(grep -E '^TEAMVIS_VERSION=' "$INSTANCE/.env" 2>/dev/null | cut -d= -f2- || true)"
if [ -n "$CUR_VERSION" ] && [ -n "$BUNDLE_VERSION" ] && [ "$CUR_VERSION" != "latest" ] && [ "$CUR_VERSION" != "$BUNDLE_VERSION" ] \
&& [ "$(printf '%s\n%s\n' "$CUR_VERSION" "$BUNDLE_VERSION" | sort -V | tail -1)" = "$BUNDLE_VERSION" ]; then
sed -i "s/^TEAMVIS_VERSION=.*/TEAMVIS_VERSION=$BUNDLE_VERSION/" "$INSTANCE/.env"
ok "Image-Version in .env: $CUR_VERSION → $BUNDLE_VERSION"
fi
grep -q 'teamvis:\${TEAMVIS_VERSION' "$INSTANCE/docker-compose.yml" \
|| warn "Das App-Image steht fest in docker-compose.yml (ohne \${TEAMVIS_VERSION}) — bitte dort von Hand auf ${BUNDLE_VERSION:-die neue Version} setzen."
( cd "$INSTANCE" && docker compose pull && docker compose up -d ) ( cd "$INSTANCE" && docker compose pull && docker compose up -d )
ok "Container neu gestartet." ok "Container neu gestartet."
head "Fertig 🎉" head "Fertig 🎉"
SITE_URL="$(grep -E '^SITE_URL=|^NEXT_PUBLIC_SITE_URL=' "$INSTANCE/.env" 2>/dev/null | head -1 | cut -d= -f2-)" SITE_URL="$(grep -m1 -E '^SITE_URL=|^NEXT_PUBLIC_SITE_URL=' "$INSTANCE/.env" 2>/dev/null | cut -d= -f2- || true)"
[ -n "$SITE_URL" ] && say " $SITE_URL" [ -n "$SITE_URL" ] && say " $SITE_URL"
say " Bei Problemen Logs prüfen: ( cd $INSTANCE && docker compose logs -f app )" say " Bei Problemen Logs prüfen: ( cd $INSTANCE && docker compose logs -f app )"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "teamvis-selfhost", "name": "teamvis-selfhost",
"version": "0.56.2", "version": "0.60.0",
"private": true, "private": true,
"description": "Self-Hosting-Bundle für TeamVis (Installer + Migrationen, ohne App-Quellcode).", "description": "Self-Hosting-Bundle für TeamVis (Installer + Migrationen, ohne App-Quellcode).",
"type": "module", "type": "module",
+6
View File
@@ -0,0 +1,6 @@
{
"version": "0.60.0",
"released": "2026-10-29",
"security": true,
"summary": "Konto löschen aus der App (Apple 5.1.1(v)) und Rechte-Härtung M22. SQL-first: Migrationen 0073 und 0074 vor dem Image-Update einspielen."
}
@@ -0,0 +1,39 @@
-- KI-Anbieter mit OpenAI-kompatibler Schnittstelle und eigener Adresse
-- ====================================================================
-- Ergänzt die drei festen Anbieter aus 0038 um einen vierten, dessen
-- Adresse die Verwaltung selbst einträgt — z. B. ein Hoster mit Sitz und
-- Verarbeitung in der EU oder ein selbst betriebenes Modell. Nur additiv:
-- neue Spalten und eine erweiterte Prüfregel.
alter table public.site_settings
add column if not exists ai_compatible_name text,
add column if not exists ai_compatible_base_url text,
add column if not exists ai_compatible_key text,
add column if not exists ai_compatible_model text;
-- Die Prüfregel aus 0038 hat einen automatisch vergebenen Namen; sie wird
-- über ihre Definition gefunden und mit 'compatible' neu angelegt.
do $$
declare c record;
begin
for c in
select conname
from pg_constraint
where conrelid = 'public.site_settings'::regclass
and contype = 'c'
and pg_get_constraintdef(oid) like '%ai_active_provider%'
loop
execute format('alter table public.site_settings drop constraint %I', c.conname);
end loop;
end $$;
alter table public.site_settings
add constraint site_settings_ai_active_provider_check
check (ai_active_provider in ('anthropic', 'openai', 'openrouter', 'compatible'));
-- Nur verschlüsselte Verbindungen: Über die Schnittstelle gehen
-- personenbezogene Daten.
alter table public.site_settings drop constraint if exists site_settings_ai_compatible_base_url_https;
alter table public.site_settings
add constraint site_settings_ai_compatible_base_url_https
check (ai_compatible_base_url is null or ai_compatible_base_url ~ '^https://');
@@ -0,0 +1,114 @@
-- ====================================================================
-- 0073_anon_rechte_minimal — anon nur noch dort, wo die Karte liest
-- ====================================================================
-- Sicherheitsplan M22 (24.10.2026). Supabase vergibt über seine
-- Default-Privileges jeder neuen Tabelle volle Rechte an anon (inkl.
-- TRUNCATE, das RLS nicht erfasst). RLS hat das bisher abgefangen; jetzt
-- werden die Rechte selbst abgebaut. Öffentlich gelesen werden nur
-- employees, persons und site_settings — dort gelten weiter die
-- Spalten-Grants aus 0014/0045 ff. (ohne Geheimnis-Spalten).
--
-- Bewusst KEIN "revoke ... on all tables in schema public" und KEIN
-- "alter default privileges": Auf gemeinsam genutzten Datenbanken (etwa
-- supabase.stwhas.de) liegen fremde Tabellen derselben Eigentümerrolle im
-- Schema public; deren Rechte dürfen sich nicht ändern. Deshalb eine
-- ausdrückliche Liste der TeamVis-Tabellen. Neue Tabellen nehmen anon die
-- Rechte in ihrer eigenen Migration (siehe 0060, 0072 und CLAUDE.md).
--
-- Ebenfalls entfernt: die Policies admin_write und public_read auf employees (ALL für
-- authenticated, true). Sie stammt aus keiner TeamVis-Migration — TeamVis
-- nutzt keine Supabase-Auth —, lag aber auf einer Bestandsinstanz und hätte
-- jedem Inhaber eines authenticated-JWT Schreibzugriff gegeben.
do $$
declare t text;
begin
foreach t in array array[
'admin_invites',
'admin_users',
'appointment_requests',
'bookings',
'calendar_connections',
'card_leads',
'card_view_log',
'change_log',
'compliance_role_bindings',
'deputies',
'external_parties',
'job_description_blocks',
'job_description_versions',
'job_descriptions',
'locations',
'magic_link_tokens',
'org_units',
'organigram_versions',
'portal_api_tokens',
'position_assignments',
'positions',
'push_preferences',
'push_subscriptions',
'reception_kiosks',
'reception_visit_audit',
'service_relations',
'teamvis_schema_migrations',
'trusted_access_log',
'visitor_preregistrations',
'wallet_pass_registrations',
'webhook_deliveries',
'webhook_subscriptions'
] loop
if to_regclass('public.' || t) is not null then
execute format('revoke all on table public.%I from anon', t);
end if;
end loop;
end $$;
drop policy if exists admin_write on public.employees;
-- authenticated: dieselben engen Rechte wie anon. TeamVis nutzt keine
-- Supabase-Auth; auf gemeinsam genutzten Datenbanken kann es aber Inhaber
-- eines authenticated-JWT geben. Übrige Tabellen: keine Rechte; die drei
-- Kartentabellen: genau die Lesespalten, die anon hat.
do $$
declare t text; cols text;
begin
foreach t in array array[
'admin_invites', 'admin_users', 'appointment_requests', 'bookings',
'calendar_connections', 'card_leads', 'card_view_log', 'change_log',
'compliance_role_bindings', 'deputies', 'external_parties',
'job_description_blocks', 'job_description_versions', 'job_descriptions',
'locations', 'magic_link_tokens', 'org_units', 'organigram_versions',
'portal_api_tokens', 'position_assignments', 'positions',
'push_preferences', 'push_subscriptions', 'reception_kiosks',
'reception_visit_audit', 'service_relations', 'teamvis_schema_migrations',
'trusted_access_log', 'visitor_preregistrations',
'wallet_pass_registrations', 'webhook_deliveries', 'webhook_subscriptions'
] loop
if to_regclass('public.' || t) is not null then
execute format('revoke all on table public.%I from authenticated', t);
end if;
end loop;
foreach t in array array['employees', 'persons', 'site_settings'] loop
if to_regclass('public.' || t) is null then continue; end if;
select string_agg(quote_ident(column_name), ', ' order by column_name) into cols
from information_schema.column_privileges
where table_schema = 'public' and table_name = t
and grantee = 'anon' and privilege_type = 'SELECT';
execute format('revoke all on table public.%I from authenticated', t);
if cols is not null then
execute format('grant select (%s) on public.%I to authenticated', cols, t);
end if;
end loop;
end $$;
-- Ebenfalls nicht aus TeamVis: Policy public_read (Rolle public, aktive
-- Karten). Für anon gilt public_read_active_employees (0001/0014).
drop policy if exists public_read on public.employees;
do $$
begin
if to_regclass('public.teamvis_schema_migrations') is not null then
alter table public.teamvis_schema_migrations enable row level security;
revoke all on table public.teamvis_schema_migrations from anon;
end if;
end $$;
@@ -0,0 +1,52 @@
-- Konto löschen aus der App (Apple Guideline 5.1.1(v))
-- ====================================================================
-- Bisher schickte die App nur einen Antrag an die Verwaltung. Apple
-- verlangt, dass sich das Konto in der App selbst löschen lässt. Ablauf
-- ab jetzt:
--
-- 1. sofort: App-Zugang weg (Tokens, Login-Codes, Push), Karte
-- deaktiviert und öffentlich nicht mehr erreichbar — auch nicht als
-- „nicht mehr im Unternehmen"-Seite
-- 2. nach `account_deletion_days` (Default 30): Datensatz samt Foto
-- endgültig gelöscht, die Person bekommt eine Bestätigung
--
-- Die Frist dazwischen erlaubt der Verwaltung, versehentliche Anträge
-- zurückzunehmen und Kontakte/Termine der Karte zu übernehmen (sie hängen
-- per ON DELETE CASCADE an der Karte).
alter table public.employees
add column if not exists deletion_requested_at timestamptz;
comment on column public.employees.deletion_requested_at is
'Zeitpunkt, zu dem die Person ihr Konto in der App gelöscht hat. Gesetzt → Karte offline, endgültige Löschung nach site_settings.account_deletion_days.';
create index if not exists employees_deletion_requested_at_idx
on public.employees (deletion_requested_at)
where deletion_requested_at is not null;
alter table public.site_settings
add column if not exists account_deletion_days integer not null default 30;
comment on column public.site_settings.account_deletion_days is
'Tage zwischen Kontolöschung in der App und endgültiger Löschung des Kartendatensatzes.';
-- Mindestens ein Tag (sonst gibt es kein Zurücknehmen), höchstens ein
-- Jahr (die App nennt die Frist der Person; darüber ist sie für einen
-- Datensatz ohne weiteren Zweck nicht mehr begründbar).
alter table public.site_settings
drop constraint if exists site_settings_account_deletion_days_check;
alter table public.site_settings
add constraint site_settings_account_deletion_days_check
check (account_deletion_days between 1 and 365);
-- Beide Spalten sind nur für den Server bestimmt. Spaltenrechte hat
-- 0073 für anon/authenticated explizit vergeben — neue Spalten sind
-- davon nicht erfasst; das revoke hält es auch auf Instanzen fest, auf
-- denen noch ein Tabellenrecht liegt.
do $$
begin
revoke select (deletion_requested_at) on public.employees from anon, authenticated;
revoke select (account_deletion_days) on public.site_settings from anon, authenticated;
exception when others then
raise notice 'Spaltenrechte nicht angepasst (%).', sqlerrm;
end $$;