diff --git a/app/admin/clients/[id]/data/page.tsx b/app/admin/clients/[id]/data/page.tsx
new file mode 100644
index 0000000..6f884b3
--- /dev/null
+++ b/app/admin/clients/[id]/data/page.tsx
@@ -0,0 +1,10 @@
+import StammdatenAdminViewPage from "@/modules/admin/ui/views/stammdaten-admin-view"
+
+type Params = Promise<{ id: string }>;
+
+const DataAdminPage = async ({ params }: { params: Params }) => {
+ const { id } = await params
+ return
+}
+
+export default DataAdminPage
diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx
index 18b7698..9b6c74c 100644
--- a/app/admin/layout.tsx
+++ b/app/admin/layout.tsx
@@ -2,6 +2,7 @@ import { cookies } from "next/headers";
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { verifySession } from "@/lib/supabase/dal";
import { AppSidebar } from "@/modules/shared/ui/components/app-sidebar";
+import { TermsDialog } from "@/modules/shared/ui/components/terms-dialog";
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
@@ -9,6 +10,9 @@ export default async function AdminLayout({ children }: { children: React.ReactN
const cookieStore = await cookies()
const defaultOpen = cookieStore.get("sidebar_state")?.value !== "false"
+ // Check if User has accepted the Terms of Services before doing anything
+ {!session.isAdmin && !session.hasAcceptedTerms && }
+
return (
diff --git a/app/client/data/page.tsx b/app/client/data/page.tsx
new file mode 100644
index 0000000..7abde8a
--- /dev/null
+++ b/app/client/data/page.tsx
@@ -0,0 +1,7 @@
+import StammdatenClientViewPage from "@/modules/client/ui/views/stammdaten-client-view"
+
+const DataPage = () => {
+ return
+}
+
+export default DataPage
\ No newline at end of file
diff --git a/app/client/layout.tsx b/app/client/layout.tsx
index 41d181e..dc9b901 100644
--- a/app/client/layout.tsx
+++ b/app/client/layout.tsx
@@ -2,6 +2,7 @@ import { cookies } from "next/headers";
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { verifySession } from "@/lib/supabase/dal";
import { AppSidebar } from "@/modules/shared/ui/components/app-sidebar";
+import { TermsDialog } from "@/modules/shared/ui/components/terms-dialog";
export default async function ClientLayout({ children }: { children: React.ReactNode }) {
@@ -11,6 +12,7 @@ export default async function ClientLayout({ children }: { children: React.React
return (
+ {!session.isAdmin && !session.hasAcceptedTerms && }
diff --git a/lib/supabase/dal.ts b/lib/supabase/dal.ts
index fa10267..1f7c5c2 100644
--- a/lib/supabase/dal.ts
+++ b/lib/supabase/dal.ts
@@ -18,7 +18,7 @@ export const getProfile = cache(async () => {
const supabase = await createClient()
const { data } = await supabase
.from("profiles")
- .select("role, name, email")
+ .select("role, name, email, terms_accepted_at")
.eq("id", user.id)
.single()
@@ -41,5 +41,6 @@ export const verifySession = cache(async () => {
isLoggedIn: true,
isAdmin: profile.role === "admin",
isClient: profile.role === "client",
+ hasAcceptedTerms: profile.terms_accepted_at != null,
}
})
\ No newline at end of file
diff --git a/lib/supabase/migrations/20260928092016_create_db_schema.sql b/lib/supabase/migrations/20260928092016_create_db_schema.sql
new file mode 100644
index 0000000..352906f
--- /dev/null
+++ b/lib/supabase/migrations/20260928092016_create_db_schema.sql
@@ -0,0 +1,543 @@
+-- =====================================================================
+-- Client-Portal – Datenbankschema (Supabase / PostgreSQL 15+)
+-- Voraussetzung: public.profiles existiert bereits
+-- (id, email, name, role, created_at; id -> auth.users.id)
+-- Falls du bereits einen Trigger auf auth.users hast, VORHER droppen
+-- (siehe Abschnitt 3), sonst wird das Profil doppelt angelegt.
+-- =====================================================================
+
+-- ---------------------------------------------------------------------
+-- 0. Extensions & generische Helfer
+-- ---------------------------------------------------------------------
+-- Vault wird für verschlüsselte Passwort-Ablage in Abschnitt 5 gebraucht.
+create extension if not exists supabase_vault;
+
+create type public.payment_method as enum ('invoice', 'sepa_debit', 'card');
+create type public.invoice_status as enum ('pending', 'paid', 'cancelled'); -- "fällig" wird abgeleitet (siehe View)
+create type public.product_category as enum ('website', 'email', 'seo', 'other');
+create type public.product_status as enum ('online', 'maintenance', 'offline');
+create type public.task_status as enum ('in_progress', 'planned', 'done', 'cancelled');
+
+create or replace function public.set_updated_at()
+returns trigger language plpgsql set search_path = '' as $$
+begin
+ new.updated_at = now();
+ return new;
+end $$;
+
+-- ---------------------------------------------------------------------
+-- 1. Clients (Stammdaten)
+-- ---------------------------------------------------------------------
+create table public.clients (
+ id uuid primary key default gen_random_uuid(),
+
+ -- Firma
+ company_name text not null,
+ legal_form text not null, -- GmbH, UG, GbR, e.K., ...
+ industry text,
+ vat_id text, -- USt-IdNr
+ tax_number text, -- Steuernummer
+ commercial_register_number text, -- optional
+ logo_path text, -- Pfad im Storage-Bucket, keine öffentliche URL
+
+ -- Firmenadresse
+ street text not null,
+ postal_code text not null,
+ city text not null,
+ country char(2) not null default 'DE',
+
+ -- abweichende Rechnungsadresse (alles NULL = identisch mit Firmenadresse)
+ billing_street text,
+ billing_postal_code text,
+ billing_city text,
+ billing_country char(2),
+
+ -- Ansprechpartner
+ contact_name text not null,
+ contact_position text,
+ contact_email text not null,
+ contact_phone text,
+
+ created_by uuid default auth.uid(),
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now(),
+
+ constraint billing_address_all_or_nothing check (
+ (billing_street is null and billing_postal_code is null and billing_city is null and billing_country is null)
+ or
+ (billing_street is not null and billing_postal_code is not null and billing_city is not null and billing_country is not null)
+ )
+);
+
+-- Zahlungsdaten separat: sensibler, eigene Rechte später einfach möglich
+create table public.client_payment_details (
+ client_id uuid primary key references public.clients(id) on delete cascade,
+ payment_method public.payment_method not null default 'invoice',
+ account_holder text,
+ iban text,
+ bic text,
+ sepa_mandate_ref text,
+ sepa_mandate_date date,
+ card_customer_id text, -- NUR Token/Customer-ID des Zahlungsanbieters (z. B. Stripe), NIE Kartennummern
+ updated_at timestamptz not null default now(),
+
+ constraint iban_format check (iban is null or iban ~ '^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$'),
+ constraint sepa_needs_iban check (
+ payment_method <> 'sepa_debit' or (iban is not null and account_holder is not null)
+ )
+);
+
+-- ---------------------------------------------------------------------
+-- 2. profiles erweitern (Login-User -> Client)
+-- ---------------------------------------------------------------------
+alter table public.profiles
+ add column if not exists client_id uuid references public.clients(id) on delete set null,
+ add column if not exists updated_at timestamptz not null default now();
+
+-- Admins gehören zu keinem Client
+alter table public.profiles
+ add constraint profiles_admin_has_no_client
+ check ((role = 'admin' and client_id is null) or role = 'client');
+
+-- ---------------------------------------------------------------------
+-- 3. Auth-Helfer + Trigger
+-- ---------------------------------------------------------------------
+create or replace function public.is_admin()
+returns boolean language sql stable security definer set search_path = '' as $$
+ select exists (select 1 from public.profiles where id = auth.uid() and role = 'admin');
+$$;
+
+create or replace function public.current_client_id()
+returns uuid language sql stable security definer set search_path = '' as $$
+ select client_id from public.profiles where id = auth.uid();
+$$;
+
+-- Gehärteter Trigger: Rolle ist IMMER 'client'. Admins werden manuell per SQL gesetzt.
+-- client_id kommt aus raw_app_meta_data (nur per Service-Role/Admin-API setzbar),
+-- NICHT aus raw_user_meta_data (die kann ein User beim Signup selbst befüllen).
+create or replace function public.handle_new_user()
+returns trigger language plpgsql security definer set search_path = '' as $$
+begin
+ insert into public.profiles (id, email, name, role, client_id)
+ values (
+ new.id,
+ new.email,
+ coalesce(new.raw_user_meta_data->>'name', split_part(new.email, '@', 1)),
+ 'client',
+ nullif(new.raw_app_meta_data->>'client_id', '')::uuid
+ );
+ return new;
+end $$;
+
+-- drop trigger if exists on auth.users;
+drop trigger if exists on_auth_user_created on auth.users;
+create trigger on_auth_user_created
+ after insert on auth.users
+ for each row execute function public.handle_new_user();
+
+-- ---------------------------------------------------------------------
+-- 4. Rechnungen
+-- ---------------------------------------------------------------------
+-- Produkt-Vorlagen für Rechnungspositionen (nur Admin)
+create table public.product_templates (
+ id uuid primary key default gen_random_uuid(),
+ name text not null,
+ description text,
+ unit_price numeric(12,2) not null check (unit_price >= 0), -- netto
+ tax_rate numeric(5,2) not null default 19.00 check (tax_rate between 0 and 100),
+ is_active boolean not null default true,
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now()
+);
+
+-- Lückenloser Nummernkreis pro Jahr (nur über next_invoice_number() nutzbar)
+create table public.invoice_counters (
+ year int primary key,
+ last_number int not null default 0
+);
+alter table public.invoice_counters enable row level security; -- bewusst keine Policies
+
+create or replace function public.next_invoice_number()
+returns text language plpgsql security definer set search_path = '' as $$
+declare
+ y int := extract(year from current_date)::int;
+ n int;
+begin
+ if not public.is_admin() then
+ raise exception 'forbidden' using errcode = '42501';
+ end if;
+ insert into public.invoice_counters as c (year, last_number)
+ values (y, 1)
+ on conflict (year) do update set last_number = c.last_number + 1
+ returning c.last_number into n;
+ return format('RE-%s-%s', y, lpad(n::text, 4, '0'));
+end $$;
+
+create table public.invoices (
+ id uuid primary key default gen_random_uuid(),
+ client_id uuid not null references public.clients(id) on delete restrict,
+ invoice_number text not null unique default public.next_invoice_number(),
+ invoice_date date not null default current_date,
+ due_date date not null,
+ status public.invoice_status not null default 'pending',
+ paid_at date,
+
+ -- Snapshot der Kundendaten zum Rechnungszeitpunkt (werden per Trigger befüllt)
+ company_name text not null,
+ vat_id text,
+ billing_street text not null,
+ billing_postal_code text not null,
+ billing_city text not null,
+ billing_country char(2) not null,
+
+ notes text,
+ created_by uuid references public.profiles(id) on delete set null default auth.uid(),
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now(),
+
+ constraint due_after_invoice_date check (due_date >= invoice_date),
+ constraint paid_has_date check (status <> 'paid' or paid_at is not null)
+);
+
+create or replace function public.fill_invoice_snapshot()
+returns trigger language plpgsql set search_path = '' as $$
+declare c public.clients%rowtype;
+begin
+ select * into c from public.clients where id = new.client_id;
+ new.company_name := coalesce(new.company_name, c.company_name);
+ new.vat_id := coalesce(new.vat_id, c.vat_id);
+ new.billing_street := coalesce(new.billing_street, c.billing_street, c.street);
+ new.billing_postal_code := coalesce(new.billing_postal_code, c.billing_postal_code, c.postal_code);
+ new.billing_city := coalesce(new.billing_city, c.billing_city, c.city);
+ new.billing_country := coalesce(new.billing_country, c.billing_country, c.country);
+ return new;
+end $$;
+
+create trigger fill_invoice_snapshot
+ before insert on public.invoices
+ for each row execute function public.fill_invoice_snapshot();
+
+create table public.invoice_items (
+ id uuid primary key default gen_random_uuid(),
+ invoice_id uuid not null references public.invoices(id) on delete cascade,
+ position int not null,
+ product_template_id uuid references public.product_templates(id) on delete set null,
+ description text not null, -- Produktname (Snapshot aus Vorlage)
+ quantity numeric(10,2) not null default 1 check (quantity > 0),
+ unit_price numeric(12,2) not null, -- netto
+ tax_rate numeric(5,2) not null default 19.00 check (tax_rate between 0 and 100),
+ unique (invoice_id, position)
+);
+
+-- Summen + abgeleiteter Status "Fällig" (kein Cronjob nötig)
+create view public.invoices_overview with (security_invoker = true) as
+select
+ i.*,
+ coalesce(t.net_total, 0) as net_total,
+ coalesce(t.tax_total, 0) as tax_total,
+ coalesce(t.net_total, 0) + coalesce(t.tax_total, 0) as gross_total,
+ case when i.status = 'pending' and i.due_date < current_date
+ then 'overdue' else i.status::text end as display_status
+from public.invoices i
+left join lateral (
+ select sum(round(quantity * unit_price, 2)) as net_total,
+ sum(round(quantity * unit_price * tax_rate / 100, 2)) as tax_total
+ from public.invoice_items ii
+ where ii.invoice_id = i.id
+) t on true;
+
+-- ---------------------------------------------------------------------
+-- 5. Passwörter (Supabase Vault = verschlüsselt at rest)
+-- In der Tabelle liegt NIE das Passwort, nur die Vault-Referenz.
+-- ---------------------------------------------------------------------
+create table public.credentials (
+ id uuid primary key default gen_random_uuid(),
+ client_id uuid not null references public.clients(id) on delete cascade,
+ service text not null, -- Vercel, Hostinger, ...
+ username text not null, -- E-Mail / Username
+ vault_secret_id uuid not null, -- -> vault.secrets
+ created_by uuid references public.profiles(id) on delete set null default auth.uid(),
+ updated_by uuid references public.profiles(id) on delete set null,
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now()
+);
+
+create table public.credential_access_log (
+ id bigint generated always as identity primary key,
+ credential_id uuid not null, -- bewusst ohne FK: Log bleibt bei Löschung erhalten
+ user_id uuid,
+ action text not null default 'reveal',
+ created_at timestamptz not null default now()
+);
+
+create or replace function public.create_credential(
+ p_client_id uuid, p_service text, p_username text, p_password text
+) returns uuid language plpgsql security definer set search_path = '' as $$
+declare v_secret uuid; v_id uuid;
+begin
+ if not public.is_admin() then raise exception 'forbidden' using errcode = '42501'; end if;
+ v_secret := vault.create_secret(p_password, 'cred_' || gen_random_uuid()::text, p_service);
+ insert into public.credentials (client_id, service, username, vault_secret_id, updated_by)
+ values (p_client_id, p_service, p_username, v_secret, auth.uid())
+ returning id into v_id;
+ return v_id;
+end $$;
+
+-- p_password = NULL -> Passwort bleibt unverändert
+create or replace function public.update_credential(
+ p_id uuid, p_service text, p_username text, p_password text default null
+) returns void language plpgsql security definer set search_path = '' as $$
+declare v_secret uuid;
+begin
+ if not public.is_admin() then raise exception 'forbidden' using errcode = '42501'; end if;
+ update public.credentials
+ set service = p_service, username = p_username, updated_by = auth.uid()
+ where id = p_id
+ returning vault_secret_id into v_secret;
+ if not found then raise exception 'credential not found'; end if;
+ if p_password is not null and p_password <> '' then
+ perform vault.update_secret(v_secret, p_password);
+ end if;
+end $$;
+
+-- Klartext nur über diese Funktion, jeder Abruf wird protokolliert.
+-- Erlaubt: Admins + der zugehörige Client. (Client-Zugriff entfernen: "or v_client = ..." löschen)
+create or replace function public.reveal_credential(p_id uuid)
+returns text language plpgsql security definer set search_path = '' as $$
+declare v_client uuid; v_secret uuid; v_pw text;
+begin
+ select client_id, vault_secret_id into v_client, v_secret
+ from public.credentials where id = p_id;
+ if not found then raise exception 'credential not found'; end if;
+ if not (public.is_admin() or v_client = public.current_client_id()) then
+ raise exception 'forbidden' using errcode = '42501';
+ end if;
+ select decrypted_secret into v_pw from vault.decrypted_secrets where id = v_secret;
+ insert into public.credential_access_log (credential_id, user_id) values (p_id, auth.uid());
+ return v_pw;
+end $$;
+
+-- Vault-Eintrag aufräumen, wenn Credential (oder ganzer Client) gelöscht wird
+create or replace function public.delete_vault_secret()
+returns trigger language plpgsql security definer set search_path = '' as $$
+begin
+ delete from vault.secrets where id = old.vault_secret_id;
+ return old;
+end $$;
+
+create trigger credentials_delete_vault_secret
+ after delete on public.credentials
+ for each row execute function public.delete_vault_secret();
+
+revoke execute on function public.create_credential(uuid, text, text, text) from public, anon;
+revoke execute on function public.update_credential(uuid, text, text, text) from public, anon;
+revoke execute on function public.reveal_credential(uuid) from public, anon;
+revoke execute on function public.next_invoice_number() from public, anon;
+grant execute on function public.create_credential(uuid, text, text, text) to authenticated;
+grant execute on function public.update_credential(uuid, text, text, text) to authenticated;
+grant execute on function public.reveal_credential(uuid) to authenticated;
+grant execute on function public.next_invoice_number() to authenticated;
+
+-- ---------------------------------------------------------------------
+-- 6. Dokumente (Metadaten in DB, Dateien in Storage)
+-- ---------------------------------------------------------------------
+create table public.folders (
+ id uuid primary key default gen_random_uuid(),
+ client_id uuid not null references public.clients(id) on delete cascade,
+ parent_id uuid, -- NULL = oberste Ebene
+ name text not null,
+ created_by uuid references public.profiles(id) on delete set null default auth.uid(),
+ created_at timestamptz not null default now(),
+ unique (id, client_id),
+ -- Ordner nur löschbar, wenn leer (kein ON DELETE-Verhalten = NO ACTION)
+ foreign key (parent_id, client_id) references public.folders (id, client_id),
+ unique nulls not distinct (client_id, parent_id, name)
+);
+
+create table public.documents (
+ id uuid primary key default gen_random_uuid(),
+ client_id uuid not null references public.clients(id) on delete cascade,
+ folder_id uuid, -- NULL = außerhalb von Ordnern
+ name text not null, -- Originaldateiname
+ storage_path text not null unique, -- {client_id}/{document_id}/{dateiname}
+ mime_type text,
+ size_bytes bigint check (size_bytes >= 0),
+ uploaded_by uuid references public.profiles(id) on delete set null default auth.uid(),
+ created_at timestamptz not null default now(),
+ foreign key (folder_id, client_id) references public.folders (id, client_id)
+);
+
+insert into storage.buckets (id, name, public)
+values ('client-documents', 'client-documents', false)
+on conflict (id) do nothing;
+
+create policy "client_documents_select" on storage.objects for select to authenticated
+ using (bucket_id = 'client-documents'
+ and ((select public.is_admin())
+ or (storage.foldername(name))[1] = (select public.current_client_id())::text));
+
+create policy "client_documents_insert" on storage.objects for insert to authenticated
+ with check (bucket_id = 'client-documents'
+ and ((select public.is_admin())
+ or (storage.foldername(name))[1] = (select public.current_client_id())::text));
+
+create policy "client_documents_delete" on storage.objects for delete to authenticated
+ using (bucket_id = 'client-documents' and (select public.is_admin()));
+
+-- ---------------------------------------------------------------------
+-- 7. Info: Produkte, Vorgänge, interne Notizen
+-- ---------------------------------------------------------------------
+create table public.client_products (
+ id uuid primary key default gen_random_uuid(),
+ client_id uuid not null references public.clients(id) on delete cascade,
+ name text not null,
+ description text,
+ category public.product_category not null default 'website',
+ url text,
+ status public.product_status not null default 'online',
+ uptime_30d numeric(5,2) check (uptime_30d between 0 and 100), -- z. B. 99.98 (aus Monitoring-Tool)
+ uptime_updated_at timestamptz,
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now(),
+ unique (id, client_id)
+);
+
+create table public.product_tasks (
+ id uuid primary key default gen_random_uuid(),
+ client_id uuid not null references public.clients(id) on delete cascade,
+ product_id uuid not null,
+ name text not null,
+ description text,
+ status public.task_status not null default 'planned',
+ since date not null default current_date, -- "seit 3 Tagen" (in_progress) bzw. "ab 6. Okt." (planned)
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now(),
+ unique (id, client_id),
+ foreign key (product_id, client_id) references public.client_products (id, client_id) on delete cascade
+);
+
+-- Statuswechsel setzt "seit" auf heute, außer der Admin setzt das Datum im selben Update explizit
+create or replace function public.reset_task_since()
+returns trigger language plpgsql set search_path = '' as $$
+begin
+ if new.status is distinct from old.status and new.since is not distinct from old.since then
+ new.since := current_date;
+ end if;
+ return new;
+end $$;
+
+create trigger reset_task_since
+ before update on public.product_tasks
+ for each row execute function public.reset_task_since();
+
+-- Eigene Tabelle, damit Clients Notizen nie sehen (RLS arbeitet zeilen-, nicht spaltenweise)
+create table public.internal_notes (
+ id uuid primary key default gen_random_uuid(),
+ client_id uuid not null references public.clients(id) on delete cascade,
+ product_id uuid,
+ task_id uuid,
+ body text not null,
+ created_by uuid references public.profiles(id) on delete set null default auth.uid(),
+ created_at timestamptz not null default now(),
+ foreign key (product_id, client_id) references public.client_products (id, client_id) on delete cascade,
+ foreign key (task_id, client_id) references public.product_tasks (id, client_id) on delete cascade
+);
+
+-- ---------------------------------------------------------------------
+-- 8. Indizes & updated_at-Trigger
+-- ---------------------------------------------------------------------
+create index on public.profiles (client_id);
+create index on public.invoices (client_id, invoice_date desc);
+create index on public.invoice_items (invoice_id);
+create index on public.credentials (client_id);
+create index on public.folders (client_id, parent_id);
+create index on public.documents (client_id, folder_id);
+create index on public.client_products (client_id);
+create index on public.product_tasks (product_id);
+create index on public.product_tasks (client_id);
+create index on public.internal_notes (client_id);
+
+do $$
+declare t text;
+begin
+ foreach t in array array['profiles','clients','client_payment_details','product_templates',
+ 'invoices','credentials','client_products','product_tasks'] loop
+ execute format('create trigger set_updated_at before update on public.%I
+ for each row execute function public.set_updated_at()', t);
+ end loop;
+end $$;
+
+-- ---------------------------------------------------------------------
+-- 9. Row Level Security
+-- ---------------------------------------------------------------------
+-- Ohne Policy = kein Zugriff. Alles nur für eingeloggte User (nie anon).
+
+-- 9a) Admin darf alles auf diesen Tabellen
+do $$
+declare t text;
+begin
+ foreach t in array array['clients','client_payment_details','product_templates','invoice_items',
+ 'folders','documents','client_products','product_tasks','internal_notes'] loop
+ execute format('alter table public.%I enable row level security', t);
+ execute format('create policy admin_all on public.%I for all to authenticated
+ using ((select public.is_admin())) with check ((select public.is_admin()))', t);
+ end loop;
+end $$;
+
+-- 9b) profiles: lesen = eigenes Profil oder Admin; ändern = nur Admin (Rolle nicht selbst änderbar)
+alter table public.profiles enable row level security;
+create policy profiles_select on public.profiles for select to authenticated
+ using (id = (select auth.uid()) or (select public.is_admin()));
+create policy profiles_admin_update on public.profiles for update to authenticated
+ using ((select public.is_admin())) with check ((select public.is_admin()));
+
+-- 9c) Rechnungen: Admin darf lesen/anlegen/ändern, aber NICHT hart löschen (-> Status 'cancelled' = Storno)
+alter table public.invoices enable row level security;
+create policy admin_select on public.invoices for select to authenticated using ((select public.is_admin()));
+create policy admin_insert on public.invoices for insert to authenticated with check ((select public.is_admin()));
+create policy admin_update on public.invoices for update to authenticated
+ using ((select public.is_admin())) with check ((select public.is_admin()));
+
+-- 9d) Passwörter: Schreiben nur über die Funktionen (Abschnitt 5), Löschen nur Admin
+alter table public.credentials enable row level security;
+create policy credentials_select on public.credentials for select to authenticated
+ using ((select public.is_admin()) or client_id = (select public.current_client_id()));
+create policy credentials_admin_delete on public.credentials for delete to authenticated
+ using ((select public.is_admin()));
+
+alter table public.credential_access_log enable row level security;
+create policy access_log_admin_select on public.credential_access_log for select to authenticated
+ using ((select public.is_admin()));
+
+-- 9e) Clients sehen nur ihre eigenen Daten
+create policy client_select on public.clients for select to authenticated
+ using (id = (select public.current_client_id()));
+
+create policy client_select on public.client_payment_details for select to authenticated
+ using (client_id = (select public.current_client_id()));
+
+create policy client_select on public.invoices for select to authenticated
+ using (client_id = (select public.current_client_id()) and status <> 'cancelled');
+
+create policy client_select on public.invoice_items for select to authenticated
+ using (exists (select 1 from public.invoices i
+ where i.id = invoice_id
+ and i.client_id = (select public.current_client_id())
+ and i.status <> 'cancelled'));
+
+create policy client_select on public.folders for select to authenticated
+ using (client_id = (select public.current_client_id()));
+
+create policy client_select on public.documents for select to authenticated
+ using (client_id = (select public.current_client_id()));
+
+create policy client_insert on public.documents for insert to authenticated
+ with check (client_id = (select public.current_client_id()) and uploaded_by = (select auth.uid()));
+
+create policy client_select on public.client_products for select to authenticated
+ using (client_id = (select public.current_client_id()));
+
+create policy client_select on public.product_tasks for select to authenticated
+ using (client_id = (select public.current_client_id()));
+
+-- product_templates, internal_notes: für Clients bewusst keine Policy = unsichtbar
\ No newline at end of file
diff --git a/lib/supabase/migrations/20260928151300_add_terms_acceptance.sql b/lib/supabase/migrations/20260928151300_add_terms_acceptance.sql
new file mode 100644
index 0000000..0e71033
--- /dev/null
+++ b/lib/supabase/migrations/20260928151300_add_terms_acceptance.sql
@@ -0,0 +1,2 @@
+alter table public.profiles
+ add column if not exists terms_accepted_at timestamptz;
diff --git a/modules/admin/ui/components/clients-table.tsx b/modules/admin/ui/components/clients-table.tsx
index 42f56d4..27c57b5 100644
--- a/modules/admin/ui/components/clients-table.tsx
+++ b/modules/admin/ui/components/clients-table.tsx
@@ -52,7 +52,7 @@ export function ClientsTable({ clients }: { clients: Client[] }) {
return (