Ask the tunnel gateway where it actually listens
tests / pest (push) Successful in 7m15s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Successful in 5s Details

The console has been unreachable over the VPN, and the cause was the readiness
probe rather than the tunnel. The gateway binds to the WireGuard hub address
alone; the probe asked 127.0.0.1, where nothing has ever listened. It could only
fail, so VPN_READY stayed false, and on that basis the application withheld the
`DNS = 10.66.0.1` line from every client config it issued. A phone then built
the tunnel, asked its normal resolver for the console hostname, got the public
address and had its connection closed — indistinguishable from "this site does
not exist", which is exactly what it looked like.

Probing over TLS on the bare address would not have worked either: the site
matches on the console's hostname, so a request without SNI is offered no
certificate. The gateway now answers a plain-HTTP health port on the hub
address, which removes TLS, SNI and name resolution from the question and
answers only what is being asked — is this gateway listening, in this network
namespace, right now. Caddy refuses to start when the certificate is unreadable,
so a health port that answers still proves the whole file loaded.

Also here, all found while looking:

- Icons pushed their label onto a second line and rendered a size larger than
  asked for. Tailwind's preflight makes an svg display:block, and `.size-4` and
  `.size-5` have equal specificity, so stylesheet order decided — and it emits
  size-4 first. Every icon written as 16px was silently 20px. Recorded as R18.
- Four error pages printed `errors.404.hint` in place of a sentence: the lang
  files give those codes a null hint and Laravel returns the key for a null line.
- The Developer role had no label in either language, so the dropdown showed
  `admin_settings.role_developer`.
- The secrets area held one key, which is not worth a password gate. It now
  carries the credentials that actually stop the business when they expire —
  DNS, monitoring, SMTP — and the test button appears only where a checker
  exists, instead of reporting on Stripe whatever was being looked at.
- The update button said nothing about when a queued run would start or where a
  running one had got to; both are now shown, and a failure names its step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/portal-design tested-20260727-1258-3699c7c
nexxo 2026-07-27 14:43:23 +02:00
parent 2bb1d7cc3b
commit 3699c7cdb9
30 changed files with 891 additions and 72 deletions

53
CLAUDE.md Normal file
View File

@ -0,0 +1,53 @@
# CluPilot — verbindliche Regeln (Repo-Teil)
Die **Vollfassung der Regeln R1R17 liegt beim Nutzer**, nicht im Repo. Die
Kurzfassung steht in `docs/handoffs/2026-07-25-clupilot-state-handoff.md` §9.
Bei Konflikt: **STOP & fragen.**
Diese Datei hält die Regeln fest, die aus konkreten Fehlern im laufenden Betrieb
entstanden sind — sie sind nicht verhandelbar und werden per Test erzwungen.
---
## R18 — Icon-Größe und Zeilenumbruch
**Ein Icon steht neben seinem Text, nie darüber, und nie größer als bestellt.**
Verboten:
1. **Icon zwingt den Text auf eine zweite Zeile.** Ein Navigationseintrag, ein
Button, ein Tabellen-Action ist **einzeilig**. Zwei Zeilen sind nur erlaubt,
wenn der Text selbst bewusst zweizeilig gesetzt ist (Label + Unterzeile) —
dann steht das Icon links davon, nicht darüber.
2. **Icon größer als die Zeile, in der es sitzt.** Standard ist `size-5` (20px)
in der Navigation, `size-4` (16px) in Buttons, Tabellen und Fließtext.
Größer nur, wenn es als eigenständiges Element gemeint ist (Leerzustand,
Statusplakette).
### Warum das zweimal schiefging
- **Zeilenumbruch.** Tailwinds Preflight setzt `svg { display: block }`. Ein
Icon in einem *inline*-Elternteil schiebt den folgenden Text damit auf die
nächste Zeile. Genau so wurde aus dem Eintrag „Zugangsdaten" ein doppelt so
hoher Kasten mit Schloss oben und Wort darunter.
- **Größe.** `.size-4` und `.size-5` haben **dieselbe Spezifität**, also
entscheidet die Reihenfolge im Stylesheet — und Tailwind gibt `.size-4` *vor*
`.size-5` aus. Eine Komponente, die `size-5` bedingungslos mitmergt, überstimmt
damit **jedes** `class="size-4"` am Aufrufort. Alle so geschriebenen Icons
liefen still auf 20px.
### Wie es jetzt gebaut ist
- `resources/views/components/ui/icon.blade.php` setzt seine Standardgröße
**nur**, wenn der Aufrufort keine `size-`/`w-`/`h-`-Klasse mitgibt, und
rendert `inline-block shrink-0 align-middle` statt des Preflight-`block`.
- `resources/views/components/ui/nav-item.blade.php` legt Label und Icon in eine
eigene Flex-Zeile, damit ein Icon auch im falschen Slot daneben landet.
- Icons in `<x-ui.nav-item>` gehören in `<x-slot:icon>`, nicht in den
Default-Slot.
### Erzwungen durch
`tests/Feature/IconLayoutTest.php` — Größe des Aufruforts gewinnt, Icon bleibt
`inline-block`, Nav-Eintrag bleibt einzeilig aus beiden Slots, und kein
Blade-File im Repo darf ein Icon am Icon-Slot vorbeischmuggeln.

View File

@ -94,8 +94,15 @@ class Secrets extends Component
{
$this->guard();
// The checker named by THIS entry, not a fixed one. When the area held a
// single Stripe key a hard-coded StripeCheck was the same thing; with
// four entries it would have reported on Stripe while the operator was
// looking at the DNS token.
$checker = SecretVault::REGISTRY[$key]['check'] ?? null;
abort_if($checker === null, 404);
$candidate = trim((string) ($this->entered[self::field($key)] ?? '')) ?: null;
$this->check = app(StripeCheck::class)->run($candidate);
$this->check = app($checker)->run($candidate);
}
/** The dotless form key for a registry key. */
@ -123,7 +130,10 @@ class Secrets extends Component
->map(fn (array $meta, string $key) => [
'key' => $key,
'field' => self::field($key),
'label' => $meta['label'],
'label' => __($meta['label']),
// Only where a checker exists. A test button that cannot
// actually test is a promise the page does not keep.
'testable' => isset($meta['check']),
'source' => $vault->source($key),
// Only ever an outline, and only once unlocked.
'outline' => $unlocked ? $vault->outline($key) : null,

View File

@ -49,6 +49,19 @@ final class UpdateChannel
/** Tail of the last run, for the operator to read without shell access. */
private const LOG = 'deploy/update-last-run.log';
/**
* The step deploy/update.sh is on, written by update.sh itself.
*
* Read from here rather than from the status file, and that is the whole
* point: the agent writes the status once, then blocks for the length of the
* run. Anything taken from the status file is therefore frozen at "the run
* started" and the step would never advance while it mattered. update.sh
* rewrites this file at every step, so it is the only live source.
*
* Two tab-separated fields: the step key, and when it started.
*/
private const PHASE = 'deploy/update-phase';
/**
* A request older than this is treated as abandoned.
*
@ -106,7 +119,21 @@ final class UpdateChannel
'requested_at' => $request !== null ? $this->timestamp($request['requested_at'] ?? null) : null,
'requested_by' => $request['requested_by'] ?? null,
// When the agent will next look. A queued update sits until the
// timer fires, and "in a few minutes" is exactly the answer that
// leaves an operator refreshing the page wondering whether the
// button did anything at all.
'next_check_at' => $agentAlive ? $this->nextCheckAt($checkedAt, $status) : null,
// The step the deployment script is on — a key, translated here.
// Read live from the phase file; older agents write none, so its
// absence is normal rather than a fault.
'phase' => $this->phase($this->currentPhase($status)),
'started_at' => $this->timestamp($status['started_at'] ?? null),
'last_state' => $lastRun['state'] ?? null,
'last_phase' => $this->phase($lastRun['phase'] ?? null),
'last_started_at' => $this->timestamp($lastRun['started_at'] ?? null),
'last_finished_at' => $this->timestamp($lastRun['finished_at'] ?? null),
// The run's own failure first; a check-level problem (the
// repository being unreachable) only when the last run was fine.
@ -114,6 +141,116 @@ final class UpdateChannel
];
}
/**
* The deployment step, as something a German interface can print.
*
* The script writes a key precisely so this stays translatable. An unknown
* key a newer script against older translations yields null rather than
* a raw identifier in the middle of a sentence.
*/
private function phase(mixed $key): ?string
{
if (! is_string($key) || $key === '') {
return null;
}
$line = __('admin_settings.update_phase.'.$key);
return is_string($line) && $line !== 'admin_settings.update_phase.'.$key ? $line : null;
}
/**
* The step key update.sh last announced, or null.
*
* Its own tiny file rather than a field in the status document: update.sh
* runs as the service account on the host and rewrites this at every step,
* while the agent that owns the status document is blocked waiting for it.
*
* Only while a run is genuinely in flight, and only if the step is newer
* than that run. A failed run deliberately LEAVES its file behind so the
* failing step can be named; without these two checks, queueing the next
* update would show it as already at "Migrating the database" a step
* nothing has started and hide the one thing the operator needs, which is
* when it will actually begin.
*
* @param array<string, mixed> $status
*/
private function currentPhase(array $status): ?string
{
if (($status['state'] ?? null) !== 'running') {
return null;
}
$path = storage_path('app/'.self::PHASE);
if (! is_file($path)) {
return null;
}
// Deliberately forgiving: this is a progress hint, and a half-written
// line during the moment update.sh replaces it must not throw on a page
// an operator is watching a deployment from.
$parts = explode("\t", (string) @file_get_contents($path), 2);
$key = trim($parts[0] ?? '');
if ($key === '') {
return null;
}
// Left over from a manual run of update.sh on the shell: newer than the
// last agent status, older than this run.
$startedAt = $this->timestamp($status['started_at'] ?? null);
$phaseAt = $this->timestamp(trim($parts[1] ?? ''));
if ($startedAt !== null && $phaseAt !== null && $phaseAt->lt($startedAt)) {
return null;
}
return $key;
}
/**
* The latest moment a queued update can still be picked up.
*
* Normally one interval after the agent's last check. But a systemd timer
* that ran late leaves a last check already older than its interval, and
* `checked_at + interval` is then a time in the PAST the page would
* promise a start that has demonstrably not happened. In that case the only
* honest upper bound is one interval from now.
*
* @param array<string, mixed> $status
*/
private function nextCheckAt(?Carbon $checkedAt, array $status): ?Carbon
{
if ($checkedAt === null) {
return null;
}
$interval = $this->checkInterval($status);
$due = $checkedAt->copy()->addMinutes($interval);
return $due->isFuture() ? $due : Carbon::now()->addMinutes($interval);
}
/**
* How often the agent looks, in minutes.
*
* Reported by the agent rather than assumed here: the interval lives in the
* systemd timer, and a server whose timer was tuned would otherwise be
* given a next-check time that quietly never arrives.
*
* @param array<string, mixed> $status
*/
private function checkInterval(array $status): int
{
$minutes = (int) ($status['check_interval_minutes'] ?? 0);
// Clamped, not trusted: this value ends up in a promise to the operator,
// and a corrupt file must not produce "next check in 1970" or a time so
// far out that the page looks broken.
return $minutes >= 1 && $minutes <= 60 ? $minutes : 5;
}
/**
* Has the agent checked in recently enough to be considered alive?
*

View File

@ -36,8 +36,41 @@ use RuntimeException;
final class SecretVault
{
/** @var array<string, array{config: string, label: string}> */
/**
* What the owner may change here curated, never "everything in config".
*
* The list is the whole point of the area. With one entry it was a page
* that existed to hold a single Stripe key, which is not worth a password
* gate; these four are the credentials that actually stop the business when
* they expire, and none of them can otherwise be rotated without a shell on
* the server.
*
* `check` is optional and names a class that can verify the value before it
* is stored. Where there is none, no test button is offered a button that
* silently checks something else is worse than no button.
*
* Deliberately NOT here: STRIPE_WEBHOOK_SECRET. It is read on every
* incoming payment event, and a database problem would turn signature
* verification into a silent failure. It stays in the server file.
*/
public const REGISTRY = [
'stripe.secret' => ['config' => 'services.stripe.secret', 'label' => 'Stripe Secret Key'],
'stripe.secret' => [
'config' => 'services.stripe.secret',
'label' => 'secrets.item.stripe_secret',
'check' => \App\Services\Stripe\StripeCheck::class,
],
'dns.token' => [
'config' => 'provisioning.dns.token',
'label' => 'secrets.item.dns_token',
],
'monitoring.token' => [
'config' => 'services.monitoring.token',
'label' => 'secrets.item.monitoring_token',
],
'mail.password' => [
'config' => 'mail.mailers.smtp.password',
'label' => 'secrets.item.mail_password',
],
];
public function has(string $key): bool

View File

@ -27,6 +27,9 @@ STATE_DIR="$ROOT/storage/app/deploy"
REQUEST="$STATE_DIR/update-request.json"
STATUS="$STATE_DIR/update-status.json"
RUNLOG="$STATE_DIR/update-last-run.log"
# The step update.sh is on, written by update.sh itself. Read back here so a run
# that failed reports WHERE it failed instead of only that it did.
PHASE_FILE="$STATE_DIR/update-phase"
# The outcome of the last RUN, kept apart from the periodic check. Written into
# one file, the next idle tick five minutes later would overwrite a failure with
# "idle" — and an operator would usually never see that the update failed.
@ -44,6 +47,12 @@ ALLOWFILE="${CONSOLE_ALLOW_FILE:-/etc/caddy/clupilot-console-allow.conf}"
# stopped fires whenever the agent next starts — which could be days later.
REQUEST_EXPIRES_MINUTES=30
# How often systemd starts this agent — see OnUnitActiveSec in install-agent.sh.
# Reported so the console can say WHEN a queued update will start rather than
# "in a few minutes", which is the whole reason an operator sits there wondering
# whether anything is happening at all.
CHECK_INTERVAL_MINUTES=5
mkdir -p "$STATE_DIR"
# One agent at a time. Two overlapping runs of update.sh fight over the
@ -118,6 +127,14 @@ json_escape() {
printf '%s' "${1-}" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g' | tr -d '\n\r'
}
# The step update.sh last announced, if any. Two tab-separated fields: key, time.
#
# Not copied into the status document below: this agent BLOCKS for the whole
# run, so anything it wrote there would be frozen at the first step. The panel
# reads the phase file itself, which update.sh keeps current. Here it is only
# needed once, to record which step a failed run died at.
read_phase() { cut -f1 "$PHASE_FILE" 2>/dev/null | tr -d '\r\n' || true; }
write_status() {
local state="$1" error="${2-}"
cat > "$STATUS.tmp" <<EOF
@ -125,6 +142,8 @@ write_status() {
"state": "$(json_escape "$state")",
"mode": "$(json_escape "$MODE")",
"checked_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"check_interval_minutes": ${CHECK_INTERVAL_MINUTES},
"started_at": "$(json_escape "${STARTED_AT:-}")",
"finished_at": "$(json_escape "${FINISHED_AT:-}")",
"local_commit": "$(json_escape "${LOCAL_COMMIT:-}")",
"remote_commit": "$(json_escape "${REMOTE_COMMIT:-}")",
@ -146,7 +165,9 @@ write_run() {
cat > "$LASTRUN.tmp" <<EOF
{
"state": "$(json_escape "$state")",
"started_at": "$(json_escape "${STARTED_AT:-}")",
"finished_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"phase": "$(json_escape "$(read_phase)")",
"commit": "$(json_escape "$(git rev-parse HEAD 2>/dev/null || echo '')")",
"error": "$(json_escape "$error")",
"exit_code": ${EXIT_CODE:-null}
@ -225,6 +246,12 @@ fi
# again on the next tick and update in a loop.
rm -f "$REQUEST"
# The previous run's last step is not this run's first one. Left in place, the
# console would show a stale phase for the seconds before update.sh writes its
# own — and on a run that dies before writing any, for the whole run.
rm -f "$PHASE_FILE"
STARTED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
write_status running "$FETCH_ERROR"
set +e
@ -247,9 +274,15 @@ EXIT_CODE="$RESULT"
if [[ $RESULT -eq 0 ]]; then
BEHIND=0
TARGET_RELEASE=''
# Dropped before anything records it: a run that finished has no step it is
# on, and the last one it passed would read as "still at Rebuilding assets".
rm -f "$PHASE_FILE"
write_run succeeded ''
write_status idle ''
else
# The phase file stays: it names the step that failed, and the console shows
# it next to the log so the operator does not have to read the log to find
# out where it stopped.
write_run failed 'update_failed'
write_status idle "$FETCH_ERROR"
fi

View File

@ -31,10 +31,27 @@ if [[ $EUID -eq 0 ]]; then
exit 1
fi
STATE_FILE="storage/app/deployed-commit"
# Which step is running, for the console to show. Written as a KEY, not as the
# sentence below it: the console is translated and this script is not, so an
# English line here would surface untranslated in the interface. A run that dies
# leaves the key of the step it died at, which is the one thing the operator
# needs and the log alone makes them hunt for.
PHASE_FILE="storage/app/deploy/update-phase"
log() { printf '\n\033[1;34m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m !\033[0m %s\n' "$*"; }
# A step, announced to the terminal and recorded for the console.
phase() {
local key="$1"; shift
mkdir -p "$(dirname "$PHASE_FILE")"
# Not atomic on purpose: this is a hint for a progress line, and a torn read
# costs a single poll. Writing a temp file per step would be more moving
# parts than the thing is worth.
printf '%s\t%s\n' "$key" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$PHASE_FILE" 2>/dev/null || true
log "$*"
}
in_app() { docker compose exec -T app "$@"; }
down=0
@ -62,7 +79,7 @@ if [[ "$mode" == "release" ]]; then
[[ -n "$RELEASE" ]] || { echo "Pinned to a release, but no tag recorded. Pass RELEASE=vX.Y.Z." >&2; exit 1; }
fi
log "Fetching release $RELEASE"
phase fetch "Fetching release $RELEASE"
git fetch --quiet --tags --force origin
git rev-parse -q --verify "refs/tags/${RELEASE}^{commit}" >/dev/null \
|| { echo "No such release tag: ${RELEASE}" >&2; exit 1; }
@ -70,7 +87,7 @@ if [[ "$mode" == "release" ]]; then
target="$(git rev-parse "refs/tags/${RELEASE}^{commit}")"
source_ref="refs/tags/${RELEASE}"
else
log "Fetching $BRANCH"
phase fetch "Fetching $BRANCH"
git fetch --quiet origin "$BRANCH"
target="$(git rev-parse "origin/$BRANCH")"
source_ref="$BRANCH"
@ -120,10 +137,31 @@ reconcile_vpn_readiness() {
# inside it listens in a network namespace that was torn down underneath it
# — nothing errors, the address simply refuses connections, and a
# container-state check reports everything fine.
#
# Asked at the address the gateway actually serves. It binds to the hub
# address alone (see docker/caddy/vpn.Caddyfile), so nothing has ever
# listened on 127.0.0.1 — the previous probe could only ever fail, and it
# did: VPN_READY stayed false on a perfectly healthy tunnel, and because of
# that the application withheld the resolver from every client config it
# issued. The console was then unreachable over the VPN for weeks, with the
# deployment printing a warning that read like a gateway fault.
#
# Over plain HTTP on the health port, not HTTPS: the TLS site matches on the
# console's hostname, so a request to the bare address presents no SNI, gets
# no certificate and fails the handshake — which looks exactly like the
# outage this is meant to detect. Caddy refuses to start at all if the
# certificate is unreadable, so a health port that answers still proves the
# whole gateway loaded.
# The same variable compose hands the gateway, so the two cannot drift.
local hub port health
hub="$(sed -n 's/^CLUPILOT_WG_HUB_ADDRESS=//p' .env | tail -1)"
hub="${hub:-10.66.0.1}"
port="$(sed -n 's/^VPN_HEALTH_PORT=//p' .env | tail -1)"
health="http://${hub}:${port:-8081}/healthz"
for _ in $(seq 1 10); do
if docker compose exec -T vpn-gateway sh -c \
"wget -q --spider --no-check-certificate https://127.0.0.1/ 2>/dev/null \
|| wget -q -O /dev/null --no-check-certificate https://127.0.0.1/ 2>/dev/null" >/dev/null 2>&1; then
"wget -q --spider '$health' 2>/dev/null || wget -q -O /dev/null '$health' 2>/dev/null" >/dev/null 2>&1; then
vpn_ready=true
break
fi
@ -263,7 +301,7 @@ else
warn "Code is current but the last update did not finish — repeating the steps."
fi
log "Enabling maintenance mode"
phase maintenance_on "Enabling maintenance mode"
in_app php artisan down --retry=60 >/dev/null || warn "Could not enable maintenance mode (continuing)"
down=1
@ -271,17 +309,17 @@ if [[ "$mode" == "release" ]]; then
# Detached on purpose: a release is a fixed point, not a line to follow.
# `git merge --ff-only` would be meaningless here, and on a detached HEAD it
# is how a pinned server silently rejoins main.
log "Checking out $source_ref"
phase checkout "Checking out $source_ref"
git checkout --quiet --detach "$target"
else
log "Checking out $BRANCH"
phase checkout "Checking out $BRANCH"
git merge --quiet --ff-only "origin/$BRANCH"
fi
after="$(git rev-parse HEAD)"
# The image is only rebuilt when its definition changed — minutes versus seconds.
if ! git diff --quiet "$base" "$after" -- docker/ 2>/dev/null; then
log "Rebuilding the image"
phase image "Rebuilding the image"
docker compose build --quiet app
# Recreate now, not at the end: everything below runs INSIDE this container,
# and an update that changes the PHP runtime would otherwise install and
@ -297,24 +335,24 @@ fi
# image contains: rebuilding the image does NOT update them. Install explicitly
# whenever a lockfile moved, or the migration below runs against stale packages.
if ! git diff --quiet "$base" "$after" -- composer.json composer.lock 2>/dev/null || [[ ! -d vendor ]]; then
log "Installing PHP dependencies"
phase composer "Installing PHP dependencies"
in_app composer install --no-interaction --no-dev --prefer-dist --no-progress --optimize-autoloader
fi
if ! git diff --quiet "$base" "$after" -- package.json package-lock.json 2>/dev/null || [[ ! -d node_modules ]]; then
log "Installing JS dependencies"
phase npm "Installing JS dependencies"
in_app npm ci --no-fund --no-audit
fi
log "Applying migrations"
phase migrate "Applying migrations"
in_app php artisan migrate --force
log "Rebuilding assets"
phase assets "Rebuilding assets"
in_app npm run build
# Before the restarts, not after: a service that starts while the old cache is
# still on disk loads it and keeps those values for the life of its process.
log "Rebuilding caches"
phase caches "Rebuilding caches"
# optimize:clear first, then optimize: a half-warm cache from before the update
# is what produces a page styled with assets that no longer exist.
in_app php artisan optimize:clear >/dev/null
@ -354,7 +392,7 @@ if [[ $proxy_running -eq 1 ]]; then
done
fi
log "Restarting services"
phase restart "Restarting services"
docker compose up -d
# Workers hold their PHP classes for the life of the process; without this they
# keep running the code from before the update.
@ -378,7 +416,7 @@ fi
reconcile_vpn_readiness
log "Leaving maintenance mode"
phase maintenance_off "Leaving maintenance mode"
in_app php artisan up >/dev/null
down=0

View File

@ -132,6 +132,10 @@ services:
# it here while the application reads a configurable one would produce a
# config pointing at an address nothing listens on.
VPN_HUB_ADDRESS: ${CLUPILOT_WG_HUB_ADDRESS:-10.66.0.1}
# Plain-HTTP health port on the hub address, for the deployment to ask
# whether this gateway is really listening in the tunnel's namespace.
# Never published; 10.66.0.1 exists only inside the tunnel.
VPN_HEALTH_PORT: ${VPN_HEALTH_PORT:-8081}
VPN_CERT_PATH: ${VPN_CERT_PATH:-}
VPN_KEY_PATH: ${VPN_KEY_PATH:-}
depends_on:

View File

@ -28,3 +28,21 @@ https://{$VPN_INTERNAL_HOST}:443 {
header_up Host {host}
}
}
# A health port the deployment can ask, on the hub address and nowhere else.
#
# The site above matches on the console's HOSTNAME. A probe to the bare address
# sends no SNI, is offered no certificate and fails the handshake — which is
# indistinguishable from the gateway being down, and that is precisely the
# mistake that left VPN_READY false on a healthy tunnel. Plain HTTP on a
# separate port removes TLS, SNI and name resolution from the question and
# answers only what is being asked: is this gateway listening, in this network
# namespace, right now.
#
# Not a hole: 10.66.0.1 exists only inside the tunnel, and this port is never
# published. Caddy also refuses to start when the certificate above cannot be
# read, so a health port that answers proves the whole file loaded.
http://{$VPN_HUB_ADDRESS}:{$VPN_HEALTH_PORT:8081} {
respond /healthz 204
respond 404
}

View File

@ -109,7 +109,7 @@ Services: `app` (php-fpm+nginx+**vite** via supervisor — EIN Container), `reve
## 9. Regeln (Kurzfassung — verbindlich)
R1/R2 full-page **klassenbasierte Livewire** als Routen (kein Volt, kein Page-Controller) · R3 nur `@theme`/Token-Utilities, kein Hex · R4 keine Inline-Styles außer Progress-`width` · R5 destruktiv → wire-elements/modal · R6 Ordner-Map · R7 responsive 375/768/1280, Touch ≥44px · R8 alles im Container · R9 UI-Copy DE, **keine Emoji** (Status via Farbe/Dots/Pills) · R10 Design-System wiederverwenden · R11 URLs per **UUID** (nicht Integer-PK) · R12 **Browser-verifiziert HTTP 200 + 0 Konsolenfehler** · R13 Routen-Pfade/Namen **englisch** · R14 Fonts **self-hosted** · R15 **Codex-Review clean** vor „fertig" · R16 alles lokalisiert **DE+EN** (identische Keys) · R17 Blade-`@php`-Block-Regeln.
R1/R2 full-page **klassenbasierte Livewire** als Routen (kein Volt, kein Page-Controller) · R3 nur `@theme`/Token-Utilities, kein Hex · R4 keine Inline-Styles außer Progress-`width` · R5 destruktiv → wire-elements/modal · R6 Ordner-Map · R7 responsive 375/768/1280, Touch ≥44px · R8 alles im Container · R9 UI-Copy DE, **keine Emoji** (Status via Farbe/Dots/Pills) · R10 Design-System wiederverwenden · R11 URLs per **UUID** (nicht Integer-PK) · R12 **Browser-verifiziert HTTP 200 + 0 Konsolenfehler** · R13 Routen-Pfade/Namen **englisch** · R14 Fonts **self-hosted** · R15 **Codex-Review clean** vor „fertig" · R16 alles lokalisiert **DE+EN** (identische Keys) · R17 Blade-`@php`-Block-Regeln · **R18 Icon-Größe und Zeilenumbruch** (siehe `CLAUDE.md`).
> Volltext `rules.md`/`CLAUDE.md` hat der Nutzer (nicht im Repo). Bei Konflikt: STOP & fragen.
## 10. Session-Memory

View File

@ -43,6 +43,7 @@ return [
'role_support' => 'Support',
'role_billing' => 'Abrechnung',
'role_read-only' => 'Nur Lesen',
'role_developer' => 'Entwickler',
'role_updated' => 'Rolle aktualisiert.',
'staff_revoked' => 'Zugriff entzogen.',
@ -104,6 +105,31 @@ return [
'running' => 'läuft',
],
// Angefordert, aber der Dienst prüft im Takt — ohne Uhrzeit sitzt man davor
// und weiß nicht, ob der Knopf überhaupt etwas getan hat.
'update_queued' => 'Angefordert. Der Update-Dienst startet sie spätestens um :time.',
'update_step' => 'Schritt: :step',
'update_since' => 'Läuft seit :when',
'update_offline_hint' => 'Während der Aktualisierung ist die Seite kurz im Wartungsmodus — diese Anzeige bleibt so lange stehen und meldet sich danach von selbst zurück.',
'update_took' => 'Dauer: :duration',
'update_failed_at' => 'Abgebrochen beim Schritt: :step',
// Die Schritte, die deploy/update.sh meldet. Das Skript schreibt Schlüssel,
// damit hier übersetzt und nicht englisch durchgereicht wird.
'update_phase' => [
'fetch' => 'Neue Version holen',
'maintenance_on' => 'Wartungsmodus einschalten',
'checkout' => 'Code übernehmen',
'image' => 'Container-Image neu bauen',
'composer' => 'PHP-Pakete installieren',
'npm' => 'JS-Pakete installieren',
'migrate' => 'Datenbank migrieren',
'assets' => 'Assets neu bauen',
'caches' => 'Caches neu aufbauen',
'restart' => 'Dienste neu starten',
'maintenance_off' => 'Wartungsmodus beenden',
],
'password_title' => 'Passwort ändern',
'password_sub' => 'Gilt sofort. Andere Sitzungen bleiben angemeldet.',
'password_current' => 'Aktuelles Passwort',

View File

@ -1,6 +1,14 @@
<?php
return [
// Die Namen der verwalteten Zugangsdaten.
'item' => [
'stripe_secret' => 'Stripe Secret Key',
'dns_token' => 'Hetzner-DNS-API-Token',
'monitoring_token' => 'Uptime-Kuma-API-Token',
'mail_password' => 'SMTP-Passwort (E-Mail-Versand)',
],
'title' => 'Zugangsdaten',
'subtitle' => 'Schlüssel für angebundene Dienste — hier änderbar, ohne Zugriff auf den Server.',
'no_key' => 'SECRETS_KEY ist auf diesem Server nicht gesetzt. Ohne eigenen Schlüssel werden hier keine Zugangsdaten gespeichert — bewusst, denn APP_KEY wird routinemäßig gewechselt.',

View File

@ -43,6 +43,7 @@ return [
'role_support' => 'Support',
'role_billing' => 'Billing',
'role_read-only' => 'Read-only',
'role_developer' => 'Developer',
'role_updated' => 'Role updated.',
'staff_revoked' => 'Access revoked.',
@ -104,6 +105,31 @@ return [
'running' => 'running',
],
// Requested, but the service checks on a timer — without a time an operator
// sits in front of it not knowing whether the button did anything.
'update_queued' => 'Requested. The update service starts it by :time at the latest.',
'update_step' => 'Step: :step',
'update_since' => 'Running since :when',
'update_offline_hint' => 'During the update the site is briefly in maintenance mode — this view stays put and comes back on its own afterwards.',
'update_took' => 'Took :duration',
'update_failed_at' => 'Stopped at step: :step',
// The steps deploy/update.sh reports. The script writes keys so they are
// translated here rather than passed through in English.
'update_phase' => [
'fetch' => 'Fetching the new version',
'maintenance_on' => 'Enabling maintenance mode',
'checkout' => 'Applying the code',
'image' => 'Rebuilding the container image',
'composer' => 'Installing PHP packages',
'npm' => 'Installing JS packages',
'migrate' => 'Migrating the database',
'assets' => 'Rebuilding assets',
'caches' => 'Rebuilding caches',
'restart' => 'Restarting services',
'maintenance_off' => 'Leaving maintenance mode',
],
'password_title' => 'Change password',
'password_sub' => 'Takes effect immediately. Other sessions stay signed in.',
'password_current' => 'Current password',

View File

@ -1,6 +1,14 @@
<?php
return [
// Names of the managed credentials.
'item' => [
'stripe_secret' => 'Stripe secret key',
'dns_token' => 'Hetzner DNS API token',
'monitoring_token' => 'Uptime Kuma API token',
'mail_password' => 'SMTP password (outgoing mail)',
],
'title' => 'Credentials',
'subtitle' => 'Keys for connected services — changeable here, without server access.',
'no_key' => 'SECRETS_KEY is not set on this server. Without a key of its own nothing is stored here — deliberately, because APP_KEY is rotated as routine maintenance.',

View File

@ -39,11 +39,27 @@
'settings' => '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
];
$body = $icons[$name] ?? '';
// The default size applies only when the caller has not chosen one.
//
// Merging `size-5` unconditionally does NOT lose to a caller's `size-4`:
// both utilities have the same specificity, so stylesheet order decides,
// and Tailwind emits `.size-4` before `.size-5`. Every icon written as
// size-4 was therefore rendering at 20px — visibly too large, and against
// whatever the call site asked for.
$sized = preg_match('/(^|\s)(size|[hw])-\S/', (string) $attributes->get('class', '')) === 1;
@endphp
{{--
inline-block, not the block that Tailwind's preflight gives an svg: a block
icon inside an inline parent pushes the text that follows it onto a second
line. In a flex or grid parent this changes nothing (items are blockified
there), so it is a safety net that costs nothing. shrink-0 for the matching
reason: an icon must never be squashed by a long label beside it.
--}}
<svg
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round"
aria-hidden="true"
{{ $attributes->merge(['class' => 'size-5']) }}
{{ $attributes->merge(['class' => 'inline-block shrink-0 align-middle'.($sized ? '' : ' size-5')]) }}
>{!! $body !!}</svg>

View File

@ -4,7 +4,16 @@
'disabled' => false,
])
@php
// items-center, not baseline: the icon is a block-level svg (Tailwind's
// preflight makes it one) and must sit BESIDE the label, never above it.
$base = 'flex items-center gap-3 rounded border-l-2 px-3 py-2 text-sm font-medium min-h-11';
// The label is a flex row of its own so that an icon handed to the default
// slot instead of the icon slot still lands next to the text. Passing it in
// the default slot put a display:block svg into an inline span and pushed
// the label onto a second line — the sidebar entry then stood twice as tall
// as every other one. Layout must not depend on which slot was used.
$label = 'flex min-w-0 items-center gap-3';
$state = $active
? 'bg-accent-subtle text-accent-text border-accent'
: 'text-muted border-transparent hover:bg-surface-hover hover:text-body';
@ -13,11 +22,11 @@
{{-- Section not built yet: render a non-interactive item, never a dead link. --}}
<span aria-disabled="true" {{ $attributes->merge(['class' => $base.' border-transparent text-faint cursor-not-allowed']) }}>
@isset($icon)<span class="shrink-0">{{ $icon }}</span>@endisset
<span>{{ $slot }}</span>
<span class="{{ $label }}">{{ $slot }}</span>
</span>
@else
<a href="{{ $href }}" @if ($active) aria-current="page" @endif {{ $attributes->merge(['class' => $base.' '.$state]) }}>
@isset($icon)<span class="shrink-0">{{ $icon }}</span>@endisset
<span>{{ $slot }}</span>
<span class="{{ $label }}">{{ $slot }}</span>
</a>
@endif

View File

@ -1,6 +1 @@
@include('errors.layout', [
'code' => 401,
'title' => __('errors.401.title'),
'body' => __('errors.401.body'),
'hint' => __('errors.401.hint'),
])
@include('errors.layout', ['code' => 401])

View File

@ -1,6 +1 @@
@include('errors.layout', [
'code' => 403,
'title' => __('errors.403.title'),
'body' => __('errors.403.body'),
'hint' => __('errors.403.hint'),
])
@include('errors.layout', ['code' => 403])

View File

@ -1,6 +1 @@
@include('errors.layout', [
'code' => 404,
'title' => __('errors.404.title'),
'body' => __('errors.404.body'),
'hint' => __('errors.404.hint'),
])
@include('errors.layout', ['code' => 404])

View File

@ -1,6 +1 @@
@include('errors.layout', [
'code' => 405,
'title' => __('errors.405.title'),
'body' => __('errors.405.body'),
'hint' => __('errors.405.hint'),
])
@include('errors.layout', ['code' => 405])

View File

@ -1,6 +1 @@
@include('errors.layout', [
'code' => 419,
'title' => __('errors.419.title'),
'body' => __('errors.419.body'),
'hint' => __('errors.419.hint'),
])
@include('errors.layout', ['code' => 419])

View File

@ -1,6 +1 @@
@include('errors.layout', [
'code' => 429,
'title' => __('errors.429.title'),
'body' => __('errors.429.body'),
'hint' => __('errors.429.hint'),
])
@include('errors.layout', ['code' => 429])

View File

@ -1,6 +1 @@
@include('errors.layout', [
'code' => 500,
'title' => __('errors.500.title'),
'body' => __('errors.500.body'),
'hint' => __('errors.500.hint'),
])
@include('errors.layout', ['code' => 500])

View File

@ -1,4 +1,22 @@
@props(['code', 'title', 'body', 'hint' => null])
@props(['code'])
@php
/**
* The text is looked up from the code rather than passed in.
*
* Four of these codes have no hint, and the lang files say so with `null`.
* Laravel's translator returns the KEY when a line resolves to null, so
* `__('errors.404.hint')` printed "errors.404.hint" on the page which is
* what it did, live, on 403/404/405/429. Lang::has() is false for a null
* line, so it is the check that actually distinguishes the two cases.
*/
$line = fn (string $part) => \Illuminate\Support\Facades\Lang::has("errors.{$code}.{$part}")
? __("errors.{$code}.{$part}")
: null;
$title = $line('title') ?? (string) $code;
$body = $line('body');
$hint = $line('hint');
@endphp
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
@ -76,7 +94,9 @@
<div class="head"><span>{{ __('errors.heading') }}</span><b>{{ $code }}</b></div>
<div class="body">
<h1>{{ $title }}</h1>
<p>{{ $body }}</p>
@if ($body)
<p>{{ $body }}</p>
@endif
@if ($hint)
<p class="hint">{{ $hint }}</p>
@endif

View File

@ -52,7 +52,8 @@
{{-- Only where the capability is held: "can open the console"
must not mean "can read the payment key". --}}
<x-ui.nav-item :href="route('admin.secrets')" :active="\App\Support\AdminArea::routeIs('admin.secrets')">
<x-ui.icon name="lock" class="size-4" />{{ __('admin.nav.secrets') }}
<x-slot:icon><x-ui.icon name="lock" /></x-slot:icon>
{{ __('admin.nav.secrets') }}
</x-ui.nav-item>
@endcan
<x-ui.nav-item :href="route('admin.settings')" :active="\App\Support\AdminArea::routeIs('admin.settings')">

View File

@ -72,11 +72,14 @@
<div class="flex flex-wrap gap-2">
{{-- Test first: a key that is saved and wrong fails later,
somewhere else, usually in front of a customer. --}}
<x-ui.button variant="secondary" wire:click="test('{{ $entry['key'] }}')"
wire:loading.attr="disabled" wire:target="test('{{ $entry['key'] }}')">
{{ __('secrets.test') }}
</x-ui.button>
somewhere else, usually in front of a customer. Only
where this entry actually has a checker. --}}
@if ($entry['testable'])
<x-ui.button variant="secondary" wire:click="test('{{ $entry['key'] }}')"
wire:loading.attr="disabled" wire:target="test('{{ $entry['key'] }}')">
{{ __('secrets.test') }}
</x-ui.button>
@endif
<x-ui.button variant="primary" wire:click="save('{{ $entry['key'] }}')"
wire:confirm="{{ __('secrets.save_confirm') }}"

View File

@ -81,12 +81,39 @@
<x-ui.alert variant="danger" class="mt-4">{{ $update['last_error'] }}</x-ui.alert>
@endif
{{-- Where it is. "Läuft gerade" alone is what sends an operator to
the shell: the service checks on a timer, so a queued update
does nothing at all for up to five minutes, and while it runs
the site is in maintenance mode and this page is unreachable.
Both of those look identical to "wedged" without a time. --}}
@if ($update['running'])
<div class="mt-4 space-y-1 rounded-lg border border-line bg-surface-2 px-4 py-3 text-sm">
@if ($update['phase'])
<p class="font-medium text-body">{{ __('admin_settings.update_step', ['step' => $update['phase']]) }}</p>
@if ($update['started_at'])
<p class="text-xs text-muted">{{ __('admin_settings.update_since', ['when' => $update['started_at']->diffForHumans()]) }}</p>
@endif
@elseif ($update['next_check_at'])
<p class="font-medium text-body">{{ __('admin_settings.update_queued', ['time' => $update['next_check_at']->timezone(config('app.timezone'))->format('H:i')]) }}</p>
@endif
<p class="text-xs text-muted">{{ __('admin_settings.update_offline_hint') }}</p>
</div>
@endif
@if ($update['last_state'] !== null && ! $update['running'])
<p class="mt-3 text-xs text-muted">
{{ __('admin_settings.update_last_run', [
'state' => __('admin_settings.update_state.'.$update['last_state']),
'when' => $update['last_finished_at']?->diffForHumans() ?? '—',
]) }}
@if ($update['last_started_at'] && $update['last_finished_at'])
· {{ __('admin_settings.update_took', ['duration' => $update['last_started_at']->diffForHumans($update['last_finished_at'], true)]) }}
@endif
{{-- Only on a failure: on a run that worked the last step is
"left maintenance mode", which says nothing. --}}
@if ($update['last_state'] === 'failed' && $update['last_phase'])
· {{ __('admin_settings.update_failed_at', ['step' => $update['last_phase']]) }}
@endif
</p>
@endif

View File

@ -120,12 +120,183 @@ it('survives a status file that is corrupt', function () {
->assertOk();
});
it('says when a queued update will actually start', function () {
// The agent checks on a timer, so pressing the button visibly does nothing
// for minutes. Without a time that is indistinguishable from a broken
// button, which is precisely how it was read.
// Frozen: the assertion is about the arithmetic, and a clock that moves
// between writing the file and reading it turns 5 into 4.998.
Carbon::setTestNow(Carbon::parse('2026-07-27 12:34:00'));
writeStatus([
'state' => 'idle',
'checked_at' => now()->toIso8601String(),
'check_interval_minutes' => 5,
'behind' => 1,
]);
app(UpdateChannel::class)->request('owner@example.com');
$state = app(UpdateChannel::class)->state();
expect($state['running'])->toBeTrue()
->and($state['next_check_at']?->toIso8601String())->toBe(now()->addMinutes(5)->toIso8601String());
Livewire::actingAs(operator('Owner'))
->test(AdminSettings::class)
->assertSee(__('admin_settings.update_queued', [
'time' => $state['next_check_at']->timezone(config('app.timezone'))->format('H:i'),
]));
});
it('names the step the deployment is on, in the operators language', function () {
// The step comes from the file update.sh rewrites as it goes, NOT from the
// status document: the agent writes that once and then blocks for the whole
// run, so a phase taken from there is frozen at the first step forever.
writeStatus([
'state' => 'running',
'checked_at' => now()->toIso8601String(),
'started_at' => now()->subMinutes(2)->toIso8601String(),
'behind' => 1,
]);
writePhase('migrate');
Livewire::actingAs(operator('Owner'))
->test(AdminSettings::class)
->assertSee(__('admin_settings.update_step', [
'step' => __('admin_settings.update_phase.migrate'),
]));
});
it('follows the step file as the deployment moves on', function () {
writeStatus(['state' => 'running', 'checked_at' => now()->toIso8601String(), 'behind' => 1]);
writePhase('composer');
expect(app(UpdateChannel::class)->state()['phase'])->toBe(__('admin_settings.update_phase.composer'));
writePhase('restart');
expect(app(UpdateChannel::class)->state()['phase'])->toBe(__('admin_settings.update_phase.restart'));
});
it('does not print a raw phase key it has no translation for', function () {
// A newer deployment script against older translations. Better to say
// nothing than to put `update_phase.something` in front of an operator.
writeStatus([
'state' => 'running',
'checked_at' => now()->toIso8601String(),
'behind' => 1,
]);
writePhase('something_new');
expect(app(UpdateChannel::class)->state()['phase'])->toBeNull();
});
it('does not present the last failures step as this updates progress', function () {
// A failed run leaves its phase file in place on purpose — that is how the
// failing step gets named. Queueing the next update must not then show it
// as already mid-migration, hiding the only thing that is actually known:
// when it will start.
Carbon::setTestNow(Carbon::parse('2026-07-27 12:34:00'));
writeStatus([
'state' => 'idle',
'checked_at' => now()->toIso8601String(),
'check_interval_minutes' => 5,
'behind' => 1,
]);
writePhase('migrate');
app(UpdateChannel::class)->request('owner@example.com');
$state = app(UpdateChannel::class)->state();
expect($state['running'])->toBeTrue()
->and($state['phase'])->toBeNull();
Livewire::actingAs(operator('Owner'))
->test(AdminSettings::class)
->assertSee(__('admin_settings.update_queued', [
'time' => $state['next_check_at']->timezone(config('app.timezone'))->format('H:i'),
]))
->assertDontSee(__('admin_settings.update_phase.migrate'));
});
it('ignores a step written before the run that is going on now', function () {
// A manual `bash deploy/update.sh` on the shell leaves a phase file the
// agent never cleared.
Carbon::setTestNow(Carbon::parse('2026-07-27 12:34:00'));
File::ensureDirectoryExists(storage_path('app/deploy'));
File::put(
storage_path('app/deploy/update-phase'),
"assets\t".now()->subHour()->toIso8601String()."\n",
);
writeStatus([
'state' => 'running',
'checked_at' => now()->toIso8601String(),
'started_at' => now()->subMinute()->toIso8601String(),
'behind' => 1,
]);
expect(app(UpdateChannel::class)->state()['phase'])->toBeNull();
});
it('never promises a start time that has already gone by', function () {
// A systemd timer that ran late leaves the last check older than its own
// interval. checked_at + interval is then in the past, and the page would
// promise a start that demonstrably did not happen.
Carbon::setTestNow(Carbon::parse('2026-07-27 12:34:00'));
writeStatus([
'state' => 'idle',
'checked_at' => now()->subMinutes(12)->toIso8601String(),
'check_interval_minutes' => 5,
'behind' => 1,
]);
app(UpdateChannel::class)->request('owner@example.com');
$next = app(UpdateChannel::class)->state()['next_check_at'];
expect($next)->not->toBeNull()
->and($next->isFuture())->toBeTrue()
// One interval from now is the only honest upper bound once the timer
// is already overdue.
->and($next->toIso8601String())->toBe(now()->addMinutes(5)->toIso8601String());
});
it('names the step a failed update stopped at', function () {
writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 1]);
writeLastRun([
'state' => 'failed',
'started_at' => now()->subMinutes(3)->toIso8601String(),
'finished_at' => now()->toIso8601String(),
'phase' => 'migrate',
'error' => 'update_failed',
'exit_code' => 1,
]);
Livewire::actingAs(operator('Owner'))
->test(AdminSettings::class)
->assertSee(__('admin_settings.update_failed_at', [
'step' => __('admin_settings.update_phase.migrate'),
]));
});
function writeStatus(array $status): void
{
File::ensureDirectoryExists(storage_path('app/deploy'));
File::put(storage_path('app/deploy/update-status.json'), json_encode($status));
}
/** What deploy/update.sh writes as it moves from step to step. */
function writePhase(string $key): void
{
File::ensureDirectoryExists(storage_path('app/deploy'));
File::put(storage_path('app/deploy/update-phase'), $key."\t".now()->toIso8601String()."\n");
}
function writeLastRun(array $run): void
{
File::ensureDirectoryExists(storage_path('app/deploy'));

View File

@ -0,0 +1,52 @@
<?php
use Illuminate\Support\Facades\View;
/**
* The error pages, in both languages.
*
* These are the pages nobody looks at until something has already gone wrong,
* which is exactly why they rot. Four of them shipped printing the raw
* translation key `errors.404.hint` in place of a sentence: the lang file gives
* those codes a `null` hint, and Laravel's translator returns the KEY when a
* line is null, so the page rendered the identifier.
*/
$codes = [401, 403, 404, 405, 419, 429, 500];
it('renders every error page without leaking a translation key', function (int $code) {
foreach (['de', 'en'] as $locale) {
app()->setLocale($locale);
$html = View::make("errors.{$code}")->render();
expect($html)
->toContain((string) $code)
->not->toContain("errors.{$code}.")
// The generic catch: any unresolved key of ours has this shape.
->not->toMatch('/errors\.\d{3}\./');
}
})->with($codes);
it('shows a hint where there is one and nothing where there is not', function () {
app()->setLocale('de');
// 419 has a hint in the lang file; 404 deliberately does not. Asserted on
// the element, not on the word: "hint" is also the CSS class that styles it.
expect(View::make('errors.419')->render())
->toContain(__('errors.419.hint'))
->toContain('<p class="hint">');
$html = View::make('errors.404')->render();
expect($html)->toContain(__('errors.404.title'))
->and($html)->not->toContain('<p class="hint">');
});
it('serves a real 404 through the stack', function () {
// The view rendering above cannot catch a broken error handler.
$this->get('/definitely-not-a-route')
->assertNotFound()
->assertSee(__('errors.404.title'))
->assertDontSee('errors.404.');
});

View File

@ -0,0 +1,103 @@
<?php
use Illuminate\Support\Facades\Blade;
/**
* An icon sits BESIDE its label, at the size the call site asked for.
*
* Both halves of that sentence were broken at once in the console sidebar: the
* "Zugangsdaten" entry rendered the lock above the word instead of next to it,
* and at 20px instead of the 16px it was written as. The two causes are
* independent, so they are tested independently.
*/
it('renders an icon at the size the call site asked for', function () {
// Not a style preference: `.size-4` and `.size-5` have the same
// specificity, so the one Tailwind emits LATER wins — and it emits size-4
// first. A component that always merges its own size therefore overrides
// every caller that picked a smaller one.
$html = Blade::render('<x-ui.icon name="lock" class="size-4" />');
expect($html)->toContain('size-4')->not->toContain('size-5');
});
it('falls back to its own size when the call site names none', function () {
expect(Blade::render('<x-ui.icon name="lock" />'))->toContain('size-5');
});
it('keeps an icon on the line it was written on', function () {
// Tailwind's preflight makes every svg display:block. Inside an inline
// parent that pushes the following text onto a second line, which is how a
// one-line navigation entry became two lines tall.
expect(Blade::render('<x-ui.icon name="lock" />'))
->toContain('inline-block')
->toContain('shrink-0');
});
it('keeps a navigation entry on one line whichever slot the icon uses', function () {
// Blade::render leaves a slot's output buffer open when a component is
// rendered outside a request, which PHPUnit reports as a risky test. The
// markup it returns is complete; only the buffer needs tidying.
$render = function (string $template): string {
$level = ob_get_level();
try {
return Blade::render($template);
} finally {
while (ob_get_level() > $level) {
ob_end_clean();
}
}
};
$viaSlot = $render(
'<x-ui.nav-item href="/x"><x-slot:icon><x-ui.icon name="lock" /></x-slot:icon>Zugangsdaten</x-ui.nav-item>'
);
// The mistake that caused the report: the icon handed to the DEFAULT slot
// rather than the icon slot. The layout must not depend on which was used.
$viaDefaultSlot = $render(
'<x-ui.nav-item href="/x"><x-ui.icon name="lock" />Zugangsdaten</x-ui.nav-item>'
);
foreach ([$viaSlot, $viaDefaultSlot] as $html) {
expect($html)
->toContain('items-center')
->toContain('Zugangsdaten')
// The label wrapper is a flex row of its own, so a stray icon lands
// next to the text rather than above it.
->toContain('flex min-w-0 items-center gap-3');
}
});
it('puts navigation icons in the icon slot everywhere', function () {
// Belt to the component's braces. The component now survives an icon in the
// wrong slot, but the wrong slot also skips the shrink-0 wrapper and reads
// as an accident to the next person editing the file.
$offenders = [];
$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator(resource_path('views'), FilesystemIterator::SKIP_DOTS)
);
foreach ($files as $file) {
if (! str_ends_with($file->getFilename(), '.blade.php')) {
continue;
}
$source = (string) file_get_contents($file->getPathname());
// Each nav-item element, from its opening tag to its closing one.
preg_match_all('/<x-ui\.nav-item\b.*?<\/x-ui\.nav-item>/s', $source, $matches);
foreach ($matches[0] as $element) {
$withoutIconSlot = preg_replace('/<x-slot:icon>.*?<\/x-slot:icon>/s', '', $element);
if (str_contains((string) $withoutIconSlot, '<x-ui.icon')) {
$offenders[] = str_replace(resource_path('views').'/', '', $file->getPathname());
}
}
}
expect($offenders)->toBe([]);
});

View File

@ -0,0 +1,58 @@
<?php
/**
* German and English carry the same keys.
*
* R16. A key added to one language and forgotten in the other does not fail
* anywhere Laravel prints the key itself so it ships, and then surfaces as
* `admin_settings.update_phase.migrate` in the middle of a sentence on whichever
* language nobody was testing in.
*/
/** @return array<int, string> every key in a translation array, dotted. */
function flattenKeys(array $lines, string $prefix = ''): array
{
$keys = [];
foreach ($lines as $key => $value) {
$path = $prefix === '' ? (string) $key : $prefix.'.'.$key;
// Only nested arrays recurse. A list of strings — a bullet list in the
// interface — is one key whose shape is the translator's business.
if (is_array($value) && ! array_is_list($value)) {
$keys = [...$keys, ...flattenKeys($value, $path)];
continue;
}
$keys[] = $path;
}
sort($keys);
return $keys;
}
it('has the same keys in German and English', function () {
$files = array_map(
fn (string $path) => basename($path),
glob(lang_path('de/*.php')) ?: [],
);
expect($files)->not->toBeEmpty();
foreach ($files as $file) {
$de = require lang_path('de/'.$file);
$enPath = lang_path('en/'.$file);
expect(file_exists($enPath))->toBeTrue("lang/en/{$file} is missing");
$en = require $enPath;
$missingInEn = array_diff(flattenKeys($de), flattenKeys($en));
$missingInDe = array_diff(flattenKeys($en), flattenKeys($de));
expect($missingInEn)->toBe([], "missing in lang/en/{$file}: ".implode(', ', $missingInEn));
expect($missingInDe)->toBe([], "missing in lang/de/{$file}: ".implode(', ', $missingInDe));
}
});