feat: Oberflaechenbausteine, Anwendungsrahmen und Konventionen
Basisbausteine im shadcn/ui-Stil, aber mit drei bewussten Abweichungen: - Bedienelemente sind 44px hoch statt 36px. Ich bediene die App oft am Handy im Heim oder auf dem Amt; kleiner ist dort nicht treffsicher. - Auswahlfelder benutzen das native <select>. Android und iOS oeffnen dafuer ihre eigene Liste - schneller als jeder Nachbau, und mit Screenreader und Tastatur funktioniert es ohne Zutun. - Eingabefelder haben mindestens 16px Schrift, sonst zoomt iOS-Safari beim Hineintippen das Formular heran. <Feld> verdrahtet Beschriftung, Erklaerung und Fehler ueber aria-describedby. Der Erklaerungstext ist kein Beiwerk: die Anwendung benutzt Begriffe aus dem Betreuungsrecht, die niemand nachschlagen soll. <Rechtshinweis> ist der stehende Vermerk, dass hier keine Rechtsberatung stattfindet. Er steht an jeder Stelle mit Paragraphenbezug. Der Anwendungsrahmen fuehrt drei Bedingungen zusammen, ohne die niemand an Fachdaten kommt: angemeldet, zweiter Faktor eingerichtet, mindestens eine betreute Person zugeordnet. Die Navigation zeigt Zaehler fuer ueberfaellige Fristen und neuen Posteingang - die Frage "wo muss ich hin?" wird schon im Menue beantwortet. Das Farbschema wird per Inline-Skript mit CSP-Nonce gesetzt, bevor die Seite gezeichnet wird - sonst blitzt beim Laden das helle Thema auf. docs/KONVENTIONEN.md haelt fest, was in allen weiteren Etappen gilt: Verzeichnisaufbau, Cent-Betraege, Umgang mit verschluesselten Feldern, Zugriffspruefung, Audit-Pflicht, Ampelfarben, Commit-Trailer. Etappe: 1 Phase: Fundament
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import { AppHuelle, SchnellerfassungKnopf } from '@/components/app-huelle'
|
||||
import { BenutzerMenue } from '@/components/benutzer-menue'
|
||||
import { BetreuteAuswahl } from '@/components/betreute-auswahl'
|
||||
import { NAV_STRUKTUR, type NavEintrag } from '@/components/navigation'
|
||||
import { aktiveBetreute, erfordereBenutzer, erfordereZweitenFaktor, meineBetreuten } from '@/lib/auth/sitzung'
|
||||
import { darf } from '@/lib/auth/rollen'
|
||||
import { ROLLEN_BESCHRIFTUNG } from '@/lib/auth/rollen'
|
||||
import { db, nichtGeloescht } from '@/lib/db'
|
||||
|
||||
/**
|
||||
* Rahmen aller Fachseiten.
|
||||
*
|
||||
* Hier laufen die drei Bedingungen zusammen, ohne die niemand an Daten
|
||||
* kommt: angemeldet, zweiter Faktor eingerichtet, mindestens eine betreute
|
||||
* Person zugeordnet.
|
||||
*/
|
||||
export default async function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
const benutzer = await erfordereBenutzer()
|
||||
await erfordereZweitenFaktor(benutzer)
|
||||
|
||||
const betreute = await meineBetreuten(benutzer)
|
||||
if (betreute.length === 0) {
|
||||
// Erststart: es gibt noch keine Stammdaten.
|
||||
redirect('/ersteinrichtung')
|
||||
}
|
||||
|
||||
const aktive = (await aktiveBetreute(benutzer))!
|
||||
|
||||
// Zugang der aktiven Person bestimmt, welche Menuepunkte sichtbar sind.
|
||||
const eintrag = await db.betreuungsZugang.findUnique({
|
||||
where: { userId_betreuteId: { userId: benutzer.id, betreuteId: aktive.id } },
|
||||
select: { rolle: true, freigegebeneModule: true },
|
||||
})
|
||||
const zugang = eintrag
|
||||
? { rolle: eintrag.rolle, freigegebeneModule: eintrag.freigegebeneModule }
|
||||
: benutzer.rolle === 'ADMIN'
|
||||
? { rolle: 'ADMIN' as const, freigegebeneModule: [] }
|
||||
: null
|
||||
|
||||
// Zaehler fuer die Navigation: was ist ueberfaellig, was liegt neu im
|
||||
// Posteingang? Beides beantwortet "wo muss ich hin?" schon im Menue.
|
||||
const heute = new Date()
|
||||
heute.setHours(0, 0, 0, 0)
|
||||
|
||||
const [ueberfaellig, neuImPosteingang] = await Promise.all([
|
||||
db.frist.count({
|
||||
where: { betreuteId: aktive.id, status: 'OFFEN', datum: { lt: heute }, ...nichtGeloescht },
|
||||
}),
|
||||
db.dokument.count({
|
||||
where: { betreuteId: aktive.id, posteingangStatus: 'NEU', ...nichtGeloescht },
|
||||
}),
|
||||
])
|
||||
|
||||
const navigation: NavEintrag[] = NAV_STRUKTUR.filter((e) => darf(zugang, e.modul, 'lesen')).map(
|
||||
(e) => ({
|
||||
...e,
|
||||
zaehler:
|
||||
e.href === '/fristen' ? ueberfaellig : e.href === '/dokumente' ? neuImPosteingang : undefined,
|
||||
dringend: e.href === '/fristen',
|
||||
}),
|
||||
)
|
||||
|
||||
return (
|
||||
<AppHuelle
|
||||
navigation={navigation}
|
||||
kopfzeile={
|
||||
<BetreuteAuswahl
|
||||
aktiveId={aktive.id}
|
||||
betreute={betreute.map((b) => ({
|
||||
id: b.id,
|
||||
vorname: b.vorname,
|
||||
nachname: b.nachname,
|
||||
geburtsdatum: b.geburtsdatum ? b.geburtsdatum.toISOString() : null,
|
||||
}))}
|
||||
/>
|
||||
}
|
||||
benutzerbereich={
|
||||
<BenutzerMenue
|
||||
name={benutzer.name}
|
||||
email={benutzer.email}
|
||||
rolle={ROLLEN_BESCHRIFTUNG[benutzer.rolle]}
|
||||
/>
|
||||
}
|
||||
schnellerfassung={
|
||||
<SchnellerfassungKnopf
|
||||
eintraege={[
|
||||
{ titel: 'Beleg fotografieren', href: '/dokumente/neu?quelle=kamera' },
|
||||
{ titel: 'Notiz / Kontakt festhalten', href: '/kontakte/protokoll/neu' },
|
||||
{ titel: 'Frist anlegen', href: '/fristen/neu' },
|
||||
{ titel: 'Buchung erfassen', href: '/finanzen/buchungen/neu' },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</AppHuelle>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
'use client'
|
||||
|
||||
import { useActionState } from 'react'
|
||||
import { useFormStatus } from 'react-dom'
|
||||
import { AlertCircle, Loader2, LogIn } from 'lucide-react'
|
||||
import { anmelden, type AnmeldeZustand } from '@/server/actions/anmelden'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Feld } from '@/components/feld'
|
||||
|
||||
function Absendeknopf() {
|
||||
const { pending } = useFormStatus()
|
||||
return (
|
||||
<Button type="submit" className="w-full" size="lg" disabled={pending}>
|
||||
{pending ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" aria-hidden="true" />
|
||||
Wird geprüft …
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LogIn aria-hidden="true" />
|
||||
Anmelden
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export function AnmeldeFormular({ weiter }: { weiter: string }) {
|
||||
const [zustand, aktion] = useActionState<AnmeldeZustand, FormData>(anmelden, {})
|
||||
|
||||
return (
|
||||
<form action={aktion} className="space-y-4" noValidate>
|
||||
<input type="hidden" name="weiter" value={weiter} />
|
||||
|
||||
{zustand.fehler && (
|
||||
<Alert variant="fehler">
|
||||
<AlertCircle aria-hidden="true" />
|
||||
<AlertDescription>{zustand.fehler}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Feld id="email" beschriftung="E-Mail-Adresse" fehler={zustand.felder?.email} pflicht>
|
||||
<Input
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="username"
|
||||
inputMode="email"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
defaultValue={zustand.email}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</Feld>
|
||||
|
||||
<Feld id="passwort" beschriftung="Passwort" fehler={zustand.felder?.passwort} pflicht>
|
||||
<Input name="passwort" type="password" autoComplete="current-password" required />
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="code"
|
||||
beschriftung="Code aus der Authenticator-App"
|
||||
erklaerung="Sechs Ziffern. Alternativ ein Wiederherstellungscode im Format XXXX-XXXX-XXXX."
|
||||
pflicht
|
||||
>
|
||||
<Input
|
||||
name="code"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
// Kein maxLength: Wiederherstellungscodes sind laenger.
|
||||
placeholder="123456"
|
||||
className="font-mono tracking-widest"
|
||||
required
|
||||
/>
|
||||
</Feld>
|
||||
|
||||
<Absendeknopf />
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { ShieldCheck } from 'lucide-react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { aktuellerBenutzer } from '@/lib/auth/sitzung'
|
||||
import { db } from '@/lib/db'
|
||||
import { AnmeldeFormular } from './formular'
|
||||
|
||||
export const metadata: Metadata = { title: 'Anmelden' }
|
||||
|
||||
/** Fachdaten dürfen nie aus einem Cache kommen. */
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function AnmeldenSeite({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ weiter?: string }>
|
||||
}) {
|
||||
// Wer schon angemeldet ist, soll nicht am Anmeldeformular hängen bleiben.
|
||||
if (await aktuellerBenutzer()) redirect('/heute')
|
||||
|
||||
// Gibt es überhaupt schon einen Zugang? Beim allerersten Start nicht.
|
||||
const anzahlBenutzer = await db.user.count({ where: { geloeschtAm: null } })
|
||||
if (anzahlBenutzer === 0) redirect('/ersteinrichtung')
|
||||
|
||||
const { weiter = '/heute' } = await searchParams
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Anmelden</CardTitle>
|
||||
<CardDescription>
|
||||
E-Mail, Passwort und der Code aus deiner Authenticator-App — alle drei zusammen.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AnmeldeFormular weiter={weiter} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex items-start gap-2.5 rounded-md border bg-muted/50 p-3 text-sm text-muted-foreground">
|
||||
<ShieldCheck className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
|
||||
<p>
|
||||
Hier liegen Gesundheits- und Vermögensdaten einer betreuten Person. Der zweite Faktor ist
|
||||
deshalb Pflicht und lässt sich nicht abschalten.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
/**
|
||||
* Rahmen fuer Anmeldung, Ersteinrichtung und Zweitfaktor.
|
||||
* Bewusst schmal und ruhig - hier gibt es genau eine Aufgabe.
|
||||
*/
|
||||
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-dvh flex-col">
|
||||
<main className="flex flex-1 items-center justify-center px-4 py-10">
|
||||
<div className="w-full max-w-md space-y-6">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="grid size-10 place-items-center rounded-lg bg-primary font-semibold text-primary-foreground"
|
||||
>
|
||||
BC
|
||||
</span>
|
||||
<span className="text-xl font-semibold">Betreuungs-Cockpit</span>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer className="border-t px-4 py-4">
|
||||
<div className="mx-auto flex max-w-md flex-wrap justify-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<Link href="/datenschutz" className="hover:underline">
|
||||
Datenschutz
|
||||
</Link>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>Daten bleiben in der EU</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>Keine Rechtsberatung</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { Metadata, Viewport } from 'next'
|
||||
import { headers } from 'next/headers'
|
||||
import './globals.css'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: 'Betreuungs-Cockpit',
|
||||
template: '%s · Betreuungs-Cockpit',
|
||||
},
|
||||
description:
|
||||
'Arbeits- und Dokumentationswerkzeug für die ehrenamtliche rechtliche Betreuung: ' +
|
||||
'Fristen, Vorgänge, Belege, Vermögen, Sozialhilfe.',
|
||||
// Kein Indexieren - hier stehen Gesundheits- und Finanzdaten.
|
||||
robots: { index: false, follow: false, nocache: true },
|
||||
applicationName: 'Betreuungs-Cockpit',
|
||||
formatDetection: { telephone: false },
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
// Zoomen bleibt erlaubt. Wer schlecht sieht, muss heranzoomen können.
|
||||
maximumScale: 5,
|
||||
themeColor: [
|
||||
{ media: '(prefers-color-scheme: light)', color: '#ffffff' },
|
||||
{ media: '(prefers-color-scheme: dark)', color: '#0b1220' },
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt das Farbschema, BEVOR die Seite gezeichnet wird.
|
||||
*
|
||||
* Ohne dieses Skript blitzt beim Laden kurz das helle Thema auf, auch wenn
|
||||
* dunkel eingestellt ist. Es laeuft synchron im <head> und traegt die
|
||||
* Nonce der Content-Security-Policy - Inline-Skripte ohne Nonce werden
|
||||
* vom Browser blockiert.
|
||||
*/
|
||||
const THEMA_SKRIPT = `
|
||||
(function () {
|
||||
try {
|
||||
var gespeichert = localStorage.getItem('bc-thema');
|
||||
var dunkel =
|
||||
gespeichert === 'dunkel' ||
|
||||
((!gespeichert || gespeichert === 'system') &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
document.documentElement.classList.toggle('dark', dunkel);
|
||||
document.documentElement.style.colorScheme = dunkel ? 'dark' : 'light';
|
||||
} catch (e) {
|
||||
/* localStorage kann gesperrt sein - dann bleibt es beim hellen Thema. */
|
||||
}
|
||||
})();
|
||||
`
|
||||
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const nonce = (await headers()).get('x-nonce') ?? undefined
|
||||
|
||||
return (
|
||||
<html lang="de" suppressHydrationWarning>
|
||||
<head>
|
||||
<script nonce={nonce} dangerouslySetInnerHTML={{ __html: THEMA_SKRIPT }} />
|
||||
</head>
|
||||
<body className="min-h-dvh bg-background text-foreground">{children}</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user