Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ This file records what changes **in the product** – process and session state
## [Unreleased]

### Added
- Visual design for the pilot UI, implemented from the Claude Design prototype "RequestFlow A": warm
neutral palette with one blue accent, IBM Plex Sans/Mono (self-hosted via `@fontsource`, no
third-party requests), a header with company, name and role, a start page with open work, and a
two-column review page – fields, positions and documents on the left, the selected value with its
source and the decision in a sticky panel on the right. Status is always labelled (⚠ for values that
need attention); colours are design tokens (WCAG AA). Login and upload errors are announced as alerts;
after an upload the message links to the new request; invitation links can be copied.
- Observability: structured JSON logs (pino) with IDs and codes only, correlated by the request id from web
through worker to the AI service; `/api/health` also shows whether the AI service is reachable and how
many jobs are waiting.
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@
"dependencies": {
"@aws-sdk/client-s3": "3.1138.0",
"@better-auth/drizzle-adapter": "1.7.5",
"@fontsource/ibm-plex-mono": "5.3.0",
"@fontsource/ibm-plex-sans": "5.3.0",
"better-auth": "1.7.5",
"drizzle-orm": "0.45.3",
"next": "16.3.6",
Expand Down
16 changes: 16 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

61 changes: 61 additions & 0 deletions src/app/_components/app-header.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import Link from "next/link";
import { unstable_rethrow } from "next/navigation";
import { getRuntime, requestActor } from "@/app/_server/runtime";
import { getCompany, type Actor } from "@/features/identity";
import { logEvent } from "@/features/observability";
import { SignOutButton } from "./auth-form";
import { NavLink } from "./nav-link";

export const ROLE_LABEL = { admin: "Administration", clerk: "Sachbearbeitung" } as const;

async function signedIn(): Promise<{ actor: Actor; companyName: string | undefined } | null> {
try {
const actor = await requestActor();
if (!actor) return null;
const company = await getCompany(getRuntime().database.db, actor.companyId);
return { actor, companyName: company?.name };
} catch (error) {
unstable_rethrow(error); // Next.js control flow (dynamic rendering, redirects) is not a failure.
// The header must never take a page down (e.g. database unreachable): render no header.
logEvent("error", "app_header.failed", {}, { code: error instanceof Error ? error.name : "unknown" });
return null;
}
}

/** The brand mark + name – also used above the sign-in cards. */
export function Brand({ className }: { className?: string }) {
return (
<Link href="/" className={className ? `brand ${className}` : "brand"}>
<span className="brand-mark" aria-hidden="true" />
RequestFlow
</Link>
);
}

// App shell (#52, design prototype A): only for signed-in users – sign-in pages show the brand in the
// card instead. Navigation mirrors the server-side permissions; it hides links, it never grants access.
export async function AppHeader() {
const session = await signedIn();
if (!session) return null;
const { actor, companyName } = session;
return (
<header className="app-header">
<div className="app-header-inner">
<Brand />
<nav className="app-nav" aria-label="Hauptnavigation">
<NavLink href="/requests">Anfragen</NavLink>
{actor.role === "admin" && <NavLink href="/users" also={["/invite"]}>Benutzer</NavLink>}
</nav>
<div className="account">
<div className="account-who">
<div className="account-company">{companyName}</div>
<div className="account-person">
{actor.name ? `${actor.name} · ` : ""}Rolle: {ROLE_LABEL[actor.role]}
</div>
</div>
<SignOutButton />
</div>
</div>
</header>
);
}
41 changes: 24 additions & 17 deletions src/app/_components/auth-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ import { useState, type FormEvent } from "react";
export function AuthForm({ mode, invitationId }: { mode: "sign-in" | "sign-up"; invitationId?: string }) {
const router = useRouter();
const [message, setMessage] = useState<string | null>(null);
const [failed, setFailed] = useState(false);
const [busy, setBusy] = useState(false);

async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setBusy(true);
setMessage(null);
setFailed(false);
const form = new FormData(event.currentTarget);
const body = {
email: String(form.get("email") ?? ""),
Expand All @@ -30,12 +32,14 @@ export function AuthForm({ mode, invitationId }: { mode: "sign-in" | "sign-up";
if (response.ok) {
router.push("/");
router.refresh();
return;
}
else if (response.status === 429) setMessage("Zu viele Versuche – bitte später erneut versuchen.");
else setMessage("Anmeldung fehlgeschlagen. Bitte E-Mail und Passwort prüfen.");
setFailed(true);
setMessage(response.status === 429 ? "Zu viele Versuche – bitte später erneut versuchen." : "Anmeldung fehlgeschlagen. Bitte E-Mail und Passwort prüfen.");
return;
}
// Same answer for invited and uninvited addresses (no enumeration).
setFailed(!response.ok);
setMessage(
response.ok
? "Falls für diese Adresse eine Einladung vorliegt, ist das Konto jetzt angelegt. Bitte anmelden."
Expand All @@ -44,35 +48,38 @@ export function AuthForm({ mode, invitationId }: { mode: "sign-in" | "sign-up";
}

return (
<form onSubmit={submit} aria-busy={busy}>
<form onSubmit={submit} aria-busy={busy} className="auth-form">
{message && <div role={failed ? "alert" : "status"}>{message}</div>}
{mode === "sign-up" && (
<p>
<label htmlFor="name">Name</label>
<br />
<label className="field" htmlFor="name">
<span>Name</span>
<input id="name" name="name" required autoComplete="name" />
</p>
</label>
)}
<p>
<label htmlFor="email">E-Mail</label>
<br />
<label className="field" htmlFor="email">
<span>E-Mail</span>
<input id="email" name="email" type="email" required autoComplete="email" />
</p>
<p>
<label htmlFor="password">Passwort</label>
<br />
</label>
<label className="field" htmlFor="password">
<span>Passwort</span>
<input
id="password"
name="password"
type="password"
required
minLength={12}
autoComplete={mode === "sign-in" ? "current-password" : "new-password"}
aria-describedby={mode === "sign-up" ? "password-hint" : undefined}
/>
</p>
<button type="submit" disabled={busy}>
</label>
{mode === "sign-up" && (
<span id="password-hint" className="field-hint">
Mindestens 12 Zeichen
</span>
)}
<button type="submit" className="btn-primary btn-large" disabled={busy}>
{mode === "sign-in" ? "Anmelden" : "Konto anlegen"}
</button>
{message && <p role="status">{message}</p>}
</form>
);
}
Expand Down
21 changes: 21 additions & 0 deletions src/app/_components/copy-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"use client";

import { useState } from "react";

/** Copies a text (e.g. an invitation link) to the clipboard; the label confirms it. */
export function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
async function copy() {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
} catch {
setCopied(false); // Clipboard blocked (e.g. insecure context): the link stays selectable.
}
}
return (
<button type="button" onClick={copy} aria-live="polite">
{copied ? "Kopiert" : "Kopieren"}
</button>
);
}
16 changes: 16 additions & 0 deletions src/app/_components/nav-link.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
"use client";

import Link from "next/link";
import { usePathname } from "next/navigation";
import type { ReactNode } from "react";

/** Navigation link that marks the current section with `aria-current="page"` (styled, and announced). */
export function NavLink({ href, also = [], children }: { href: string; also?: string[]; children: ReactNode }) {
const pathname = usePathname();
const current = [href, ...also].some((path) => pathname === path || pathname.startsWith(`${path}/`));
return (
<Link href={href} aria-current={current ? "page" : undefined}>
{children}
</Link>
);
}
8 changes: 8 additions & 0 deletions src/app/_server/runtime.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { headers } from "next/headers";
import { cache } from "react";
import { loadConfig, type AppConfig } from "@/config/env";
import { createDatabase, type DatabaseHandle } from "@/db";
import { createJobQueue } from "@/db/job-queue-client";
Expand Down Expand Up @@ -49,3 +51,9 @@ export async function currentActor(headers: Headers): Promise<Actor | null> {
const { auth, database } = getRuntime();
return getActor(auth, database.db, headers);
}

/**
* The signed-in actor of the page being rendered, looked up once per request and shared by the app
* header (layout) and the page (#52). Server actions and route handlers keep calling `currentActor`.
*/
export const requestActor = cache(async (): Promise<Actor | null> => currentActor(await headers()));
Loading
Loading