refactor
This commit is contained in:
parent
d0ea6e9eec
commit
2967f1a5af
31 changed files with 469 additions and 165 deletions
|
|
@ -55,7 +55,7 @@ BANANAS_PUBLIC_BASE_URL=http://localhost:8080
|
||||||
|
|
||||||
# The development realm. A production deployment points this at its own provider;
|
# The development realm. A production deployment points this at its own provider;
|
||||||
# only the issuer, client credentials and group mapping are provider-specific.
|
# only the issuer, client credentials and group mapping are provider-specific.
|
||||||
BANANAS_OIDC_ISSUER=https://auth-dev.urpi.be/realms/urpi
|
BANANAS_OIDC_ISSUER=https://auth.lcl-urpi.be/realms/urpi
|
||||||
BANANAS_OIDC_CLIENT_ID=go-bananas
|
BANANAS_OIDC_CLIENT_ID=go-bananas
|
||||||
# BANANAS_OIDC_CLIENT_SECRET=read-from-a-secret
|
# BANANAS_OIDC_CLIENT_SECRET=read-from-a-secret
|
||||||
|
|
||||||
|
|
@ -163,4 +163,4 @@ BANANAS_ENABLE_PROFILER=false
|
||||||
# Passed by docker-bake and goreleaser rather than set by hand.
|
# Passed by docker-bake and goreleaser rather than set by hand.
|
||||||
# VERSION=dev
|
# VERSION=dev
|
||||||
# COMMIT=unknown
|
# COMMIT=unknown
|
||||||
# BUILD_DATE=unknown
|
# BUILD_DATE=unknown
|
||||||
|
|
|
||||||
66
Makefile
66
Makefile
|
|
@ -32,7 +32,7 @@ VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo
|
||||||
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||||
BUILD_DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
|
BUILD_DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||||
|
|
||||||
REGISTRY ?= nexus.urpi.be
|
REGISTRY ?= nexus.urpi.be/docker-hosted
|
||||||
IMAGE_NAME ?= $(REGISTRY)/$(BINARY)
|
IMAGE_NAME ?= $(REGISTRY)/$(BINARY)
|
||||||
IMAGE_TAG ?= $(VERSION)
|
IMAGE_TAG ?= $(VERSION)
|
||||||
PLATFORM ?= linux/amd64,linux/arm64
|
PLATFORM ?= linux/amd64,linux/arm64
|
||||||
|
|
@ -127,12 +127,13 @@ dev: ## Run the API with hot reload (Ctrl-C to stop)
|
||||||
air -c .air.toml
|
air -c .air.toml
|
||||||
|
|
||||||
.PHONY: dev-api
|
.PHONY: dev-api
|
||||||
dev-api: ## Run only the API server, no frontend
|
dev-api: ## Run the API (no SPA); Vite serves the UI, this serves the runtime SDK
|
||||||
@BANANAS_ENVIRONMENT=development \
|
@BANANAS_ENVIRONMENT=development \
|
||||||
BANANAS_AUTH_MODE=dev \
|
BANANAS_AUTH_MODE=dev \
|
||||||
BANANAS_HTTP_ADDR=:8080 \
|
BANANAS_HTTP_ADDR=:8080 \
|
||||||
BANANAS_DATABASE_PATH=./data/go-bananas.db \
|
BANANAS_DATABASE_PATH=./data/go-bananas.db \
|
||||||
BANANAS_STATIC_DIR= BANANAS_SDK_DIR= \
|
BANANAS_STATIC_DIR= \
|
||||||
|
BANANAS_SDK_DIR=$(SDK_DIR)/dist \
|
||||||
$(GO) run -ldflags "$(LDFLAGS)" $(CMD)
|
$(GO) run -ldflags "$(LDFLAGS)" $(CMD)
|
||||||
|
|
||||||
.PHONY: dev-web
|
.PHONY: dev-web
|
||||||
|
|
@ -140,7 +141,7 @@ dev-web: ## Run the Vite dev server for the admin UI, proxying the API
|
||||||
cd $(WEB_DIR) && $(NPM) run dev
|
cd $(WEB_DIR) && $(NPM) run dev
|
||||||
|
|
||||||
.PHONY: dev-all
|
.PHONY: dev-all
|
||||||
dev-all: ## Run the API and the frontend dev server together
|
dev-all: build-sdk ## Run the API and the frontend dev server together
|
||||||
@echo "API on http://localhost:8080, admin UI on http://localhost:5173"
|
@echo "API on http://localhost:8080, admin UI on http://localhost:5173"
|
||||||
@trap 'kill 0' EXIT; \
|
@trap 'kill 0' EXIT; \
|
||||||
$(MAKE) dev-api & \
|
$(MAKE) dev-api & \
|
||||||
|
|
@ -375,7 +376,7 @@ helm-template: ## Render the chart with the default values
|
||||||
helm-template-auth: ## Render the chart configured for OIDC
|
helm-template-auth: ## Render the chart configured for OIDC
|
||||||
$(HELM) template go-bananas deploy/helm/go-bananas \
|
$(HELM) template go-bananas deploy/helm/go-bananas \
|
||||||
--set identity.mode=oidc \
|
--set identity.mode=oidc \
|
||||||
--set identity.oidc.issuer=https://auth-dev.urpi.be/realms/urpi \
|
--set identity.oidc.issuer=https://auth.lcl-urpi.be/realms/urpi \
|
||||||
--set identity.oidc.clientId=go-bananas \
|
--set identity.oidc.clientId=go-bananas \
|
||||||
--set identity.oidc.generateSecret=false \
|
--set identity.oidc.generateSecret=false \
|
||||||
--set identity.oidc.existingSecret=go-bananas-oidc \
|
--set identity.oidc.existingSecret=go-bananas-oidc \
|
||||||
|
|
@ -392,6 +393,61 @@ helm-install: ## Install the chart into the current kube context ##@destructive
|
||||||
helm-uninstall: ## Remove the release ##@destructive
|
helm-uninstall: ## Remove the release ##@destructive
|
||||||
$(HELM) uninstall go-bananas --namespace go-bananas
|
$(HELM) uninstall go-bananas --namespace go-bananas
|
||||||
|
|
||||||
|
##@ Local (lcl)
|
||||||
|
|
||||||
|
# The lcl environment is the developer's local k3s cluster with a registry at
|
||||||
|
# localhost:5000 and a TLS edge serving *.intern-urpi.be. go-* apps deploy into the
|
||||||
|
# shared `go-insane-lcl` namespace. These targets use values-lcl.yaml.
|
||||||
|
#
|
||||||
|
# KUBECONFIG — the lcl kubeconfig. On this box the `local` kubecontext goes through a
|
||||||
|
# Rancher proxy that can 502; the direct k3s config is /etc/rancher/k3s/k3s.yaml.
|
||||||
|
# Set it to deploy without depending on the proxy: KUBECONFIG=/etc/rancher/k3s/k3s.yaml make deploy-lcl
|
||||||
|
# LCL_TAG — image tag. Defaults to a timestamp so a rebuild is always re-pulled;
|
||||||
|
# `IfNotPresent` would otherwise serve a stale `0.0.1` from the node cache.
|
||||||
|
|
||||||
|
LCL_REGISTRY ?= localhost:5000
|
||||||
|
LCL_REPO ?= go-bananas
|
||||||
|
LCL_TAG ?= $(shell date -u +%Y%m%d%H%M%S)
|
||||||
|
LCL_NAMESPACE ?= go-insane-lcl
|
||||||
|
LCL_VALUES = deploy/helm/go-bananas/values-lcl.yaml
|
||||||
|
|
||||||
|
.PHONY: image-lcl
|
||||||
|
image-lcl: ## Build and push the image to the local registry ##@destructive
|
||||||
|
$(DOCKER) buildx build \
|
||||||
|
--build-arg VERSION=$(VERSION) \
|
||||||
|
--build-arg COMMIT=$(COMMIT) \
|
||||||
|
--build-arg BUILD_DATE=$(BUILD_DATE) \
|
||||||
|
--tag $(LCL_REGISTRY)/$(LCL_REPO):$(LCL_TAG) \
|
||||||
|
--push \
|
||||||
|
.
|
||||||
|
|
||||||
|
.PHONY: helm-template-lcl
|
||||||
|
helm-template-lcl: ## Render the lcl Helm manifest
|
||||||
|
$(HELM) template go-bananas deploy/helm/go-bananas \
|
||||||
|
--namespace $(LCL_NAMESPACE) \
|
||||||
|
--values $(LCL_VALUES) \
|
||||||
|
--set-string image.tag=$(LCL_TAG)
|
||||||
|
|
||||||
|
.PHONY: helm-install-lcl
|
||||||
|
helm-install-lcl: ## Deploy go-bananas to the local (lcl) environment ##@destructive
|
||||||
|
$(HELM) upgrade --install go-bananas deploy/helm/go-bananas \
|
||||||
|
--namespace $(LCL_NAMESPACE) --create-namespace \
|
||||||
|
--values $(LCL_VALUES) \
|
||||||
|
--set-string image.tag=$(LCL_TAG) \
|
||||||
|
--wait --timeout 5m
|
||||||
|
|
||||||
|
.PHONY: helm-uninstall-lcl
|
||||||
|
helm-uninstall-lcl: ## Remove the lcl release ##@destructive
|
||||||
|
$(HELM) uninstall go-bananas --namespace $(LCL_NAMESPACE)
|
||||||
|
|
||||||
|
.PHONY: deploy-lcl
|
||||||
|
deploy-lcl: image-lcl helm-install-lcl ## Build, push, deploy to lcl, then verify ##@destructive
|
||||||
|
@echo "waiting for rollout ($(LCL_NAMESPACE)/go-bananas)..."
|
||||||
|
@kubectl -n $(LCL_NAMESPACE) rollout status deploy/go-bananas --timeout=120s
|
||||||
|
@echo "=== runtime manifest through the TLS edge ==="
|
||||||
|
@curl -sk https://bananas-lcl.intern-urpi.be/rt/v1/manifest.json -o /dev/null -w 'manifest.json -> %{http_code}\n' || true
|
||||||
|
@echo "deployed image: $(LCL_REGISTRY)/$(LCL_REPO):$(LCL_TAG)"
|
||||||
|
|
||||||
##@ Docs
|
##@ Docs
|
||||||
|
|
||||||
.PHONY: docs-serve
|
.PHONY: docs-serve
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ helm upgrade --install go-bananas oci://ghcr.io/urpi/charts/go-bananas \
|
||||||
--set identity.oidc.existingSecret=go-bananas-auth
|
--set identity.oidc.existingSecret=go-bananas-auth
|
||||||
```
|
```
|
||||||
|
|
||||||
The chart defaults to the shared development realm at `auth-dev.urpi.be`. See
|
The chart defaults to the shared development realm at `auth.lcl-urpi.be`. See
|
||||||
[docs/deployment.md](docs/deployment.md) for the full production checklist.
|
[docs/deployment.md](docs/deployment.md) for the full production checklist.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -462,8 +462,9 @@ Requires Go 1.27 and Node 22 or later. `make tools` installs `air`,
|
||||||
| [docs/operations.md](docs/operations.md) | Monitoring, backup, runbook |
|
| [docs/operations.md](docs/operations.md) | Monitoring, backup, runbook |
|
||||||
| [docs/security.md](docs/security.md) | Threat model and hardening |
|
| [docs/security.md](docs/security.md) | Threat model and hardening |
|
||||||
| [docs/styling-contract.md](docs/styling-contract.md) | The shared styling contract, and how an application adopts it |
|
| [docs/styling-contract.md](docs/styling-contract.md) | The shared styling contract, and how an application adopts it |
|
||||||
|
| [docs/dev-infrastructure.md](docs/dev-infrastructure.md) | **This machine's dev infrastructure: audit, port map, fixes, and the issue log to append to** |
|
||||||
| [docs/adr/](docs/adr/) | Architecture decision records |
|
| [docs/adr/](docs/adr/) | Architecture decision records |
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|
||||||
MIT.
|
MIT.
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
#
|
#
|
||||||
# The `oidc` profile adds a mock OpenID Connect provider, so the authentication
|
# The `oidc` profile adds a mock OpenID Connect provider, so the authentication
|
||||||
# flow can be exercised end to end without pointing at a real realm. The `oidc`
|
# flow can be exercised end to end without pointing at a real realm. The `oidc`
|
||||||
# profile uses the mock because auth-dev.urpi.be is a shared development realm and a
|
# profile uses the mock because auth.lcl-urpi.be is a shared development realm and a
|
||||||
# local stack should not depend on it being reachable.
|
# local stack should not depend on it being reachable.
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
|
|
|
||||||
|
|
@ -21,12 +21,8 @@
|
||||||
# reproducible rather than "whatever worked when it was deployed".
|
# reproducible rather than "whatever worked when it was deployed".
|
||||||
|
|
||||||
image:
|
image:
|
||||||
# The development cluster pulls from a private registry whose certificate is valid
|
registry: nexus.urpi.be
|
||||||
# for a different name, so the host and the port have to stay in one piece: putting
|
repository: docker-hosted/go-bananas
|
||||||
# the port in `repository` renders an image reference without it, and the kubelet
|
|
||||||
# then resolves the registry over https on port 443.
|
|
||||||
registry: batty1039.startdedicated.net:5000
|
|
||||||
repository: go-bananas
|
|
||||||
# The development registry is tagged `latest` rather than pinned to a version, to
|
# The development registry is tagged `latest` rather than pinned to a version, to
|
||||||
# match every other release in this namespace. Consequently the pull policy must be
|
# match every other release in this namespace. Consequently the pull policy must be
|
||||||
# Always: with IfNotPresent a node keeps serving the image it already has, and a
|
# Always: with IfNotPresent a node keeps serving the image it already has, and a
|
||||||
|
|
@ -39,7 +35,7 @@ config:
|
||||||
# refuses both of these outside a development environment, so this pair cannot be
|
# refuses both of these outside a development environment, so this pair cannot be
|
||||||
# promoted to production by accident.
|
# promoted to production by accident.
|
||||||
environment: development
|
environment: development
|
||||||
publicBaseUrl: https://bananas-dev.urpi.be
|
publicBaseUrl: https://bananas.dev-urpi.be
|
||||||
|
|
||||||
identity:
|
identity:
|
||||||
# dev, not oidc.
|
# dev, not oidc.
|
||||||
|
|
@ -59,11 +55,11 @@ identity:
|
||||||
# helm upgrade ... --set identity.mode=oidc \
|
# helm upgrade ... --set identity.mode=oidc \
|
||||||
# --set identity.oidc.generateSecret=false \
|
# --set identity.oidc.generateSecret=false \
|
||||||
# --set identity.oidc.existingSecret=go-bananas-auth \
|
# --set identity.oidc.existingSecret=go-bananas-auth \
|
||||||
# --set identity.oidc.issuer=https://auth-dev.urpi.be \
|
# --set identity.oidc.issuer=https://auth.dev-urpi.be \
|
||||||
# --set identity.oidc.clientId=go-bananas
|
# --set identity.oidc.clientId=go-bananas
|
||||||
mode: dev
|
mode: dev
|
||||||
oidc:
|
oidc:
|
||||||
issuer: https://auth-dev.urpi.be
|
issuer: https://auth.dev-urpi.be
|
||||||
clientId: go-bananas
|
clientId: go-bananas
|
||||||
existingSecret: go-bananas-auth
|
existingSecret: go-bananas-auth
|
||||||
generateSecret: false
|
generateSecret: false
|
||||||
|
|
@ -74,17 +70,17 @@ ingress:
|
||||||
cert-manager.io/cluster-issuer: letsencrypt-staging
|
cert-manager.io/cluster-issuer: letsencrypt-staging
|
||||||
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||||
hosts:
|
hosts:
|
||||||
- host: bananas-dev.urpi.be
|
- host: bananas.dev-urpi.be
|
||||||
paths:
|
paths:
|
||||||
- path: /
|
- path: /
|
||||||
pathType: Prefix
|
pathType: Prefix
|
||||||
tls:
|
tls:
|
||||||
- secretName: go-bananas-tls
|
- secretName: go-bananas-tls
|
||||||
hosts:
|
hosts:
|
||||||
- bananas-dev.urpi.be
|
- bananas.dev-urpi.be
|
||||||
|
|
||||||
deployment:
|
deployment:
|
||||||
# One replica. The volume is ReadWriteOnce, so a second replica cannot mount it and
|
# One replica. The volume is ReadWriteOnce, so a second replica cannot mount it and
|
||||||
# would sit in ImagePullBackOff-equivalent limbo forever. Scaling needs either this
|
# would sit in ImagePullBackOff-equivalent limbo forever. Scaling needs either this
|
||||||
# changed to 1 or a ReadWriteMany filesystem.
|
# changed to 1 or a ReadWriteMany filesystem.
|
||||||
replicaCount: 1
|
replicaCount: 1
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ Authentication is set to "{{ .Values.identity.mode }}". Every request is current
|
||||||
treated as an administrator. Before exposing this to a network, set:
|
treated as an administrator. Before exposing this to a network, set:
|
||||||
|
|
||||||
identity.mode=oidc
|
identity.mode=oidc
|
||||||
identity.oidc.issuer=https://auth-dev.urpi.be/realms/<realm>
|
identity.oidc.issuer=https://auth.dev-urpi.be/realms/<realm>
|
||||||
identity.oidc.clientId=go-bananas
|
identity.oidc.clientId=go-bananas
|
||||||
identity.oidc.existingSecret=<secret-name>
|
identity.oidc.existingSecret=<secret-name>
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
|
||||||
|
|
@ -167,7 +167,7 @@ CrashLoopBackOff to diagnose. Each check names the value to change.
|
||||||
|
|
||||||
{{- if eq $mode "oidc" -}}
|
{{- if eq $mode "oidc" -}}
|
||||||
{{- if not .Values.identity.oidc.issuer -}}
|
{{- if not .Values.identity.oidc.issuer -}}
|
||||||
{{- fail "identity.oidc.issuer is required when identity.mode is oidc. For the development realm this is https://auth-dev.urpi.be/realms/<realm>." -}}
|
{{- fail "identity.oidc.issuer is required when identity.mode is oidc. For the development realm this is https://auth.dev-urpi.be/realms/<realm>." -}}
|
||||||
{{- end -}}
|
{{- end -}}
|
||||||
{{- if not (regexMatch "^https?://" .Values.identity.oidc.issuer) -}}
|
{{- if not (regexMatch "^https?://" .Values.identity.oidc.issuer) -}}
|
||||||
{{- fail (printf "identity.oidc.issuer %q is not a URL. It must include the scheme." .Values.identity.oidc.issuer) -}}
|
{{- fail (printf "identity.oidc.issuer %q is not a URL. It must include the scheme." .Values.identity.oidc.issuer) -}}
|
||||||
|
|
@ -253,4 +253,4 @@ The data volume name, whether it is a PVC or an emptyDir.
|
||||||
{{- else -}}
|
{{- else -}}
|
||||||
{{- printf "%s-data" (include "go-bananas.fullname" .) -}}
|
{{- printf "%s-data" (include "go-bananas.fullname" .) -}}
|
||||||
{{- end -}}
|
{{- end -}}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
|
||||||
|
|
@ -52,9 +52,6 @@ spec:
|
||||||
{{- toYaml . | nindent 8 }}
|
{{- toYaml . | nindent 8 }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
spec:
|
spec:
|
||||||
{{- with .Values.image.pullPolicy }}
|
|
||||||
imagePullPolicy: {{ . }}
|
|
||||||
{{- end }}
|
|
||||||
serviceAccountName: {{ include "go-bananas.serviceAccountName" . }}
|
serviceAccountName: {{ include "go-bananas.serviceAccountName" . }}
|
||||||
automountServiceAccountToken: {{ .Values.serviceAccount.automountServiceAccountToken }}
|
automountServiceAccountToken: {{ .Values.serviceAccount.automountServiceAccountToken }}
|
||||||
{{- with .Values.serviceAccount.annotations }}
|
{{- with .Values.serviceAccount.annotations }}
|
||||||
|
|
@ -75,19 +72,20 @@ spec:
|
||||||
securityContext:
|
securityContext:
|
||||||
{{- toYaml .Values.podSecurityContext | nindent 8 }}
|
{{- toYaml .Values.podSecurityContext | nindent 8 }}
|
||||||
terminationGracePeriodSeconds: {{ .Values.deployment.terminationGracePeriodSeconds }}
|
terminationGracePeriodSeconds: {{ .Values.deployment.terminationGracePeriodSeconds }}
|
||||||
{{- if .Values.deployment.lifecycle.preStop.enabled }}
|
|
||||||
# A preStop sleep gives the Service endpoints controller time to remove this
|
|
||||||
# pod from the endpoint list before the process starts refusing connections.
|
|
||||||
# Without it, a rolling update can drop requests that were already routed.
|
|
||||||
lifecycle:
|
|
||||||
preStop:
|
|
||||||
exec:
|
|
||||||
command: ["/bin/sh", "-c", "sleep {{ .Values.deployment.lifecycle.preStop.sleepSeconds }}"]
|
|
||||||
{{- end }}
|
|
||||||
containers:
|
containers:
|
||||||
- name: go-bananas
|
- name: go-bananas
|
||||||
image: {{ include "go-bananas.image" . }}
|
image: {{ include "go-bananas.image" . }}
|
||||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||||
|
{{- if .Values.deployment.lifecycle.preStop.enabled }}
|
||||||
|
# A preStop sleep gives the Service endpoints controller time to remove this
|
||||||
|
# pod from the endpoint list before the process starts refusing connections.
|
||||||
|
# Without it, a rolling update can drop requests that were already routed.
|
||||||
|
# lifecycle is a container field, not a podSpec field.
|
||||||
|
lifecycle:
|
||||||
|
preStop:
|
||||||
|
exec:
|
||||||
|
command: ["/bin/sh", "-c", "sleep {{ .Values.deployment.lifecycle.preStop.sleepSeconds }}"]
|
||||||
|
{{- end }}
|
||||||
{{- with .Values.deployment.extraArgs }}
|
{{- with .Values.deployment.extraArgs }}
|
||||||
args:
|
args:
|
||||||
{{- toYaml . | nindent 12 }}
|
{{- toYaml . | nindent 12 }}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@
|
||||||
nameOverride: ""
|
nameOverride: ""
|
||||||
fullnameOverride: ""
|
fullnameOverride: ""
|
||||||
|
|
||||||
replicaCount: 1
|
deployment:
|
||||||
|
replicaCount: 1
|
||||||
|
|
||||||
image:
|
image:
|
||||||
repository: nexus.urpi.be/docker-hosted/go-bananas
|
repository: nexus.urpi.be/docker-hosted/go-bananas
|
||||||
|
|
@ -13,6 +14,16 @@ service:
|
||||||
type: ClusterIP
|
type: ClusterIP
|
||||||
port: 80
|
port: 80
|
||||||
|
|
||||||
|
# Spelled out here rather than inherited from values.yaml so the development
|
||||||
|
# issuer is stated where it is used.
|
||||||
|
identity:
|
||||||
|
mode: oidc
|
||||||
|
oidc:
|
||||||
|
issuer: https://auth.dev-urpi.be/realms/urpi
|
||||||
|
clientId: go-bananas
|
||||||
|
existingSecret: go-bananas-auth
|
||||||
|
generateSecret: false
|
||||||
|
|
||||||
ingress:
|
ingress:
|
||||||
enabled: true
|
enabled: true
|
||||||
className: nginx
|
className: nginx
|
||||||
|
|
@ -20,20 +31,14 @@ ingress:
|
||||||
cert-manager.io/cluster-issuer: letsencrypt-staging
|
cert-manager.io/cluster-issuer: letsencrypt-staging
|
||||||
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||||
hosts:
|
hosts:
|
||||||
- host: bananas-dev.intern-urpi.be
|
- host: bananas.dev-urpi.be
|
||||||
paths:
|
paths:
|
||||||
- path: /
|
- path: /
|
||||||
pathType: Prefix
|
pathType: Prefix
|
||||||
tls:
|
tls:
|
||||||
- secretName: go-bananas-tls
|
- secretName: go-bananas-tls
|
||||||
hosts:
|
hosts:
|
||||||
- bananas-dev.intern-urpi.be
|
- bananas.dev-urpi.be
|
||||||
|
|
||||||
certManager:
|
|
||||||
enabled: true
|
|
||||||
issuerRef:
|
|
||||||
name: letsencrypt-staging
|
|
||||||
kind: ClusterIssuer
|
|
||||||
|
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
# Local (LCL) values for go-bananas.
|
# Local (LCL) values for go-bananas.
|
||||||
# Served at https://bananas-lcl.intern-urpi.be by the docker TLS edge.
|
# Served at https://bananas.lcl-urpi.be by the docker TLS edge.
|
||||||
#
|
#
|
||||||
# NOTE: this chart validates values against values.schema.json, which allows
|
# NOTE: this chart validates values against values.schema.json, which allows
|
||||||
# only the keys defined there -- no certManager and no replicaCount here
|
# only the keys defined there -- no certManager and no replicaCount here
|
||||||
|
|
@ -18,12 +18,12 @@ service:
|
||||||
port: 80
|
port: 80
|
||||||
|
|
||||||
# Local environment: development unlocks the local authentication bypass, so
|
# Local environment: development unlocks the local authentication bypass, so
|
||||||
# the deployment works without the shared Keycloak realm at auth-dev.urpi.be
|
# the deployment works without the shared Keycloak realm at auth.dev-urpi.be
|
||||||
# (go-loose does not serve /realms/... endpoints, so the oidc default cannot
|
# (go-loose does not serve /realms/... endpoints, so the oidc default cannot
|
||||||
# be pointed at it either).
|
# be pointed at it either).
|
||||||
config:
|
config:
|
||||||
environment: development
|
environment: development
|
||||||
publicBaseUrl: "https://bananas-lcl.intern-urpi.be"
|
publicBaseUrl: "https://bananas.lcl-urpi.be"
|
||||||
|
|
||||||
identity:
|
identity:
|
||||||
mode: dev
|
mode: dev
|
||||||
|
|
@ -33,14 +33,14 @@ ingress:
|
||||||
className: traefik
|
className: traefik
|
||||||
annotations: {}
|
annotations: {}
|
||||||
hosts:
|
hosts:
|
||||||
- host: bananas-lcl.intern-urpi.be
|
- host: bananas.lcl-urpi.be
|
||||||
paths:
|
paths:
|
||||||
- path: /
|
- path: /
|
||||||
pathType: Prefix
|
pathType: Prefix
|
||||||
tls:
|
tls:
|
||||||
- secretName: local-dev-cert
|
- secretName: local-dev-cert
|
||||||
hosts:
|
hosts:
|
||||||
- bananas-lcl.intern-urpi.be
|
- bananas.lcl-urpi.be
|
||||||
|
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@
|
||||||
nameOverride: ""
|
nameOverride: ""
|
||||||
fullnameOverride: ""
|
fullnameOverride: ""
|
||||||
|
|
||||||
replicaCount: 1
|
deployment:
|
||||||
|
replicaCount: 1
|
||||||
|
|
||||||
image:
|
image:
|
||||||
repository: nexus.urpi.be/docker-hosted/go-bananas
|
repository: nexus.urpi.be/docker-hosted/go-bananas
|
||||||
|
|
@ -13,6 +14,17 @@ service:
|
||||||
type: ClusterIP
|
type: ClusterIP
|
||||||
port: 80
|
port: 80
|
||||||
|
|
||||||
|
# Spelled out here rather than inherited from values.yaml, whose default issuer
|
||||||
|
# points at the development realm: a missing key would otherwise make the
|
||||||
|
# testing release validate tokens against the development identity provider.
|
||||||
|
identity:
|
||||||
|
mode: oidc
|
||||||
|
oidc:
|
||||||
|
issuer: https://auth.tst-urpi.be/realms/urpi
|
||||||
|
clientId: go-bananas
|
||||||
|
existingSecret: go-bananas-auth
|
||||||
|
generateSecret: false
|
||||||
|
|
||||||
ingress:
|
ingress:
|
||||||
enabled: true
|
enabled: true
|
||||||
className: nginx
|
className: nginx
|
||||||
|
|
@ -20,20 +32,14 @@ ingress:
|
||||||
cert-manager.io/cluster-issuer: letsencrypt-staging
|
cert-manager.io/cluster-issuer: letsencrypt-staging
|
||||||
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||||
hosts:
|
hosts:
|
||||||
- host: bananas-tst.intern-urpi.be
|
- host: bananas.tst-urpi.be
|
||||||
paths:
|
paths:
|
||||||
- path: /
|
- path: /
|
||||||
pathType: Prefix
|
pathType: Prefix
|
||||||
tls:
|
tls:
|
||||||
- secretName: go-bananas-tls
|
- secretName: go-bananas-tls
|
||||||
hosts:
|
hosts:
|
||||||
- bananas-tst.intern-urpi.be
|
- bananas.tst-urpi.be
|
||||||
|
|
||||||
certManager:
|
|
||||||
enabled: true
|
|
||||||
issuerRef:
|
|
||||||
name: letsencrypt-staging
|
|
||||||
kind: ClusterIssuer
|
|
||||||
|
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
|
|
|
||||||
|
|
@ -197,7 +197,7 @@ config:
|
||||||
# replicaCount: 1, or a filesystem that permits multiple mounts.
|
# replicaCount: 1, or a filesystem that permits multiple mounts.
|
||||||
|
|
||||||
persistence:
|
persistence:
|
||||||
enabled: true
|
enabled: false
|
||||||
type: persistentVolumeClaim
|
type: persistentVolumeClaim
|
||||||
accessMode: ReadWriteOnce
|
accessMode: ReadWriteOnce
|
||||||
size: 2Gi
|
size: 2Gi
|
||||||
|
|
@ -288,7 +288,7 @@ identity:
|
||||||
# issuer: this string must match the `iss` claim in the ID token exactly,
|
# issuer: this string must match the `iss` claim in the ID token exactly,
|
||||||
# including a trailing slash if the provider emits one. A mismatch here is the
|
# including a trailing slash if the provider emits one. A mismatch here is the
|
||||||
# single most common cause of "invalid issuer" at the callback.
|
# single most common cause of "invalid issuer" at the callback.
|
||||||
issuer: "https://auth-dev.urpi.be/realms/urpi"
|
issuer: "https://auth.dev-urpi.be/realms/urpi"
|
||||||
|
|
||||||
# clientId is the client identifier registered with the provider.
|
# clientId is the client identifier registered with the provider.
|
||||||
clientId: "go-bananas"
|
clientId: "go-bananas"
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ Running go-bananas, from a laptop to a production cluster.
|
||||||
- [Configuration](#configuration)
|
- [Configuration](#configuration)
|
||||||
- [Authentication](#authentication)
|
- [Authentication](#authentication)
|
||||||
- [Helm](#helm)
|
- [Helm](#helm)
|
||||||
|
- [Local deployment (lcl)](#local-deployment-lcl)
|
||||||
- [The production checklist](#the-production-checklist)
|
- [The production checklist](#the-production-checklist)
|
||||||
- [Scaling](#scaling)
|
- [Scaling](#scaling)
|
||||||
- [Backup and restore](#backup-and-restore)
|
- [Backup and restore](#backup-and-restore)
|
||||||
|
|
@ -127,7 +128,7 @@ is made impossible to construct.
|
||||||
|
|
||||||
### The shared development realm
|
### The shared development realm
|
||||||
|
|
||||||
The chart defaults to `https://auth-dev.urpi.be/realms/urpi`, so a development cluster
|
The chart defaults to `https://auth.lcl-urpi.be/realms/urpi`, so a development cluster
|
||||||
works with no identity configuration.
|
works with no identity configuration.
|
||||||
|
|
||||||
The realm path is part of the issuer. The value must match the `iss` claim in the ID
|
The realm path is part of the issuer. The value must match the `iss` claim in the ID
|
||||||
|
|
@ -260,6 +261,77 @@ helm lint deploy/helm/go-bananas --strict
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Local deployment (lcl)
|
||||||
|
|
||||||
|
The `lcl` environment is the developer's own single-node **k3s** cluster used for
|
||||||
|
local integration testing. It is defined by `deploy/helm/go-bananas/values-lcl.yaml`:
|
||||||
|
|
||||||
|
- `config.environment: development` so the local auth bypass is allowed.
|
||||||
|
- `identity.mode: dev` so you can sign in without an OIDC provider.
|
||||||
|
- `image.registry: localhost:5000` so the cluster pulls from a local registry.
|
||||||
|
- Ingress host `bananas-lcl.intern-urpi.be`, terminated by the local TLS edge.
|
||||||
|
|
||||||
|
All the `go-*` applications share the **`go-insane-lcl`** namespace; go-bananas is a
|
||||||
|
Helm release named `go-bananas` inside it.
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
1. A running local k3s cluster and a registry at `localhost:5000`.
|
||||||
|
2. `bananas-lcl.intern-urpi.be` resolving to the node (`/etc/hosts`) behind the TLS edge.
|
||||||
|
3. `docker buildx`, `helm` and `kubectl`.
|
||||||
|
|
||||||
|
> **Kubeconfig caveat.** On this machine the default `local` context goes through a
|
||||||
|
> Rancher proxy (`https://rancher.urpi.be/k8s/clusters/local`) which can 502. When it
|
||||||
|
> does, talk to k3s directly instead: `export KUBECONFIG=/etc/rancher/k3s/k3s.yaml`.
|
||||||
|
> Everything below respects that variable.
|
||||||
|
|
||||||
|
### One-shot build + deploy + verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
KUBECONFIG=/etc/rancher/k3s/k3s.yaml ./hack/deploy-lcl.sh
|
||||||
|
# or, equivalently:
|
||||||
|
KUBECONFIG=/etc/rancher/k3s/k3s.yaml make deploy-lcl
|
||||||
|
```
|
||||||
|
|
||||||
|
This builds a fresh image (with the web admin UI and runtime SDK compiled in), pushes
|
||||||
|
it to the local registry, runs `helm upgrade --install`, waits for the rollout, and
|
||||||
|
curls the runtime manifest through the TLS edge. The image tag defaults to a build
|
||||||
|
timestamp so a rebuild is always re-pulled (`IfNotPresent` would otherwise serve a
|
||||||
|
stale cached tag). Override it with `LCL_TAG=...`.
|
||||||
|
|
||||||
|
### Step by step
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make install # Go and Node dependencies
|
||||||
|
make image-lcl # build + push to localhost:5000/go-bananas:<LCL_TAG>
|
||||||
|
make helm-template-lcl # sanity-check the rendered manifest
|
||||||
|
make helm-install-lcl # helm upgrade --install into go-insane-lcl
|
||||||
|
```
|
||||||
|
|
||||||
|
`LCL_TAG`, `LCL_REGISTRY` and `LCL_NAMESPACE` are overridable:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make deploy-lcl LCL_TAG=v1 LCL_REGISTRY=registry:5000 LCL_NAMESPACE=go-insane-lcl
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sign in and verify
|
||||||
|
|
||||||
|
The release uses `dev` authentication, so any user is a local administrator. Open
|
||||||
|
`https://bananas-lcl.intern-urpi.be/?dev-user=you@example.test`, then:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sk https://bananas-lcl.intern-urpi.be/readyz
|
||||||
|
curl -sk https://bananas-lcl.intern-urpi.be/rt/v1/manifest.json | jq '.themes[].slug'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Remove
|
||||||
|
|
||||||
|
```bash
|
||||||
|
KUBECONFIG=/etc/rancher/k3s/k3s.yaml make helm-uninstall-lcl
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## The production checklist
|
## The production checklist
|
||||||
|
|
||||||
- [ ] `config.environment: production`
|
- [ ] `config.environment: production`
|
||||||
|
|
@ -544,4 +616,4 @@ applications. A CDN or browser holding an old version explains it; the runtime
|
||||||
manifest's `baseUrl` will show whether a CDN is in the path.
|
manifest's `baseUrl` will show whether a CDN is in the path.
|
||||||
|
|
||||||
**The stylesheet is stale after a publish.** Expected within one `s-maxage` window
|
**The stylesheet is stale after a publish.** Expected within one `s-maxage` window
|
||||||
(300 s by default). To force it, lower `config.runtimeCacheControl` or purge the CDN.
|
(300 s by default). To force it, lower `config.runtimeCacheControl` or purge the CDN.
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ make tools # air, golangci-lint, sqlc
|
||||||
## Running
|
## Running
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make dev-all # API on :8080, admin UI on :5173 with a proxy
|
make dev-all # API on :8080, admin UI on :5173 with a proxy (builds the SDK first)
|
||||||
```
|
```
|
||||||
|
|
||||||
Open <http://localhost:5173>. The Vite dev server proxies `/api`, `/rt` and `/auth`
|
Open <http://localhost:5173>. The Vite dev server proxies `/api`, `/rt` and `/auth`
|
||||||
|
|
@ -34,11 +34,18 @@ to the Go process, so the session cookie works exactly as it will in production.
|
||||||
Same-origin matters: a cross-origin setup would not authenticate at all, and the
|
Same-origin matters: a cross-origin setup would not authenticate at all, and the
|
||||||
problem would look like a bug rather than a topology difference.
|
problem would look like a bug rather than a topology difference.
|
||||||
|
|
||||||
|
The admin UI is itself a consumer of the runtime: the header theme selector and the
|
||||||
|
light/dark toggle are the shipped controls, loaded from `/rt/v1/components.js`, which
|
||||||
|
the **API** serves from `sdk/dist` (Vite proxies `/rt` to it). `make dev-all` builds
|
||||||
|
the SDK up front for exactly this reason — an unbuilt `sdk/dist` means those controls
|
||||||
|
never define and the switcher silently falls back.
|
||||||
|
|
||||||
Other entry points:
|
Other entry points:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make dev-api # the API alone, with hot reload
|
make dev # the API with hot reload (air); serves web/dist and sdk/dist, so `make build` first
|
||||||
make dev-web # the admin UI alone
|
make dev-api # the API alone, no hot reload; serves the runtime SDK, Vite serves the UI
|
||||||
|
make dev-web # the admin UI alone (its /api and /rt proxy to :8080)
|
||||||
make run # build everything, run the real binary
|
make run # build everything, run the real binary
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// OIDCProvider implements Resolver against any OpenID Connect provider,
|
// OIDCProvider implements Resolver against any OpenID Connect provider,
|
||||||
// including the auth-dev.urpi.be realm used for local development.
|
// including the auth.lcl-urpi.be realm used for local development.
|
||||||
//
|
//
|
||||||
// Everything provider-specific comes from the issuer's discovery document, so
|
// Everything provider-specific comes from the issuer's discovery document, so
|
||||||
// the same code works against Keycloak, Authentik, Zitadel or Google without a
|
// the same code works against Keycloak, Authentik, Zitadel or Google without a
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@ type Config struct {
|
||||||
|
|
||||||
// AuthMode is oidc, dev, or none.
|
// AuthMode is oidc, dev, or none.
|
||||||
AuthMode string
|
AuthMode string
|
||||||
// OIDCIssuer is the issuer URL, e.g. https://auth-dev.urpi.be/realms/urpi.
|
// OIDCIssuer is the issuer URL, e.g. https://auth.lcl-urpi.be/realms/urpi.
|
||||||
OIDCIssuer string
|
OIDCIssuer string
|
||||||
// OIDCClientID is the client identifier registered with the provider.
|
// OIDCClientID is the client identifier registered with the provider.
|
||||||
OIDCClientID string
|
OIDCClientID string
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ func newUnauthenticatedRouter(t *testing.T) http.Handler {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
cfg := testConfig(t)
|
cfg := testConfig(t)
|
||||||
cfg.AuthMode = config.AuthModeOIDC
|
cfg.AuthMode = config.AuthModeOIDC
|
||||||
cfg.OIDCIssuer = "https://auth-dev.urpi.be/realms/urpi"
|
cfg.OIDCIssuer = "https://auth.lcl-urpi.be/realms/urpi"
|
||||||
cfg.OIDCClientID = "go-bananas"
|
cfg.OIDCClientID = "go-bananas"
|
||||||
cfg.OIDCClientSecret = "test-client-secret"
|
cfg.OIDCClientSecret = "test-client-secret"
|
||||||
cfg.SessionSecure = false
|
cfg.SessionSecure = false
|
||||||
|
|
@ -278,7 +278,7 @@ func TestRuntimeIsReadableCrossOrigin(t *testing.T) {
|
||||||
// not testing the policy — it is testing the same-origin path under a
|
// not testing the policy — it is testing the same-origin path under a
|
||||||
// misleading name.
|
// misleading name.
|
||||||
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, path, nil)
|
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, path, nil)
|
||||||
req.Header.Set("Origin", "https://tell-dev.urpi.be")
|
req.Header.Set("Origin", "https://tell.lcl-urpi.be")
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
router.ServeHTTP(rec, req)
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
|
@ -313,7 +313,7 @@ func TestAdminAPIIsNotOpenedByTheRuntimePolicy(t *testing.T) {
|
||||||
for _, path := range []string{"/api/v1/session", "/api/v1/themes"} {
|
for _, path := range []string{"/api/v1/session", "/api/v1/themes"} {
|
||||||
t.Run(path, func(t *testing.T) {
|
t.Run(path, func(t *testing.T) {
|
||||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, path, nil)
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, path, nil)
|
||||||
req.Header.Set("Origin", "https://tell-dev.urpi.be")
|
req.Header.Set("Origin", "https://tell.lcl-urpi.be")
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
router.ServeHTTP(rec, req)
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
|
@ -357,7 +357,7 @@ func TestAdminAPIAllowsAConfiguredOrigin(t *testing.T) {
|
||||||
func TestRuntimeAnswersPreflight(t *testing.T) {
|
func TestRuntimeAnswersPreflight(t *testing.T) {
|
||||||
req := httptest.NewRequestWithContext(
|
req := httptest.NewRequestWithContext(
|
||||||
context.Background(), http.MethodOptions, "/rt/v1/contract.json", nil)
|
context.Background(), http.MethodOptions, "/rt/v1/contract.json", nil)
|
||||||
req.Header.Set("Origin", "https://tell-dev.urpi.be")
|
req.Header.Set("Origin", "https://tell.lcl-urpi.be")
|
||||||
// A real preflight names the method it wants to use. Without this header the
|
// A real preflight names the method it wants to use. Without this header the
|
||||||
// request is not a preflight, and asserting 204 on it would be asserting on a
|
// request is not a preflight, and asserting 204 on it would be asserting on a
|
||||||
// case no browser ever sends — which is how this test passed against a 405.
|
// case no browser ever sends — which is how this test passed against a 405.
|
||||||
|
|
|
||||||
|
|
@ -207,8 +207,8 @@ func (s *Store) ListAudit(ctx context.Context, filter AuditFilter) ([]domain.Aud
|
||||||
args []any
|
args []any
|
||||||
)
|
)
|
||||||
if filter.Actor != "" {
|
if filter.Actor != "" {
|
||||||
where = append(where, `actor = ?`)
|
where = append(where, `actor_email LIKE ?`)
|
||||||
args = append(args, filter.Actor)
|
args = append(args, "%"+filter.Actor+"%")
|
||||||
}
|
}
|
||||||
if filter.Action != "" {
|
if filter.Action != "" {
|
||||||
where = append(where, `action = ?`)
|
where = append(where, `action = ?`)
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"src/**/*.js",
|
"src/**/*.js",
|
||||||
|
"src/**/*.d.ts",
|
||||||
"vite.config.js",
|
"vite.config.js",
|
||||||
"vitest.config.js"
|
"vitest.config.js"
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,11 @@ export default tseslint.config(
|
||||||
{
|
{
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
projectService: true,
|
// The app build project excludes test files (so the production image build
|
||||||
|
// never compiles the SDK-coupled integration test). ESLint still needs type
|
||||||
|
// info for every file it lints, so point it at BOTH projects explicitly
|
||||||
|
// rather than relying on projectService to discover the test project.
|
||||||
|
project: ['./tsconfig.app.json', './tsconfig.test.json'],
|
||||||
tsconfigRootDir: import.meta.dirname,
|
tsconfigRootDir: import.meta.dirname,
|
||||||
},
|
},
|
||||||
globals: { ...globals.browser, ...globals.es2022 },
|
globals: { ...globals.browser, ...globals.es2022 },
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,11 @@
|
||||||
"license": "SEE LICENSE IN ../LICENSE",
|
"license": "SEE LICENSE IN ../LICENSE",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc --build && vite build",
|
"build": "tsc -p tsconfig.app.json && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint": "eslint . --max-warnings 0",
|
"lint": "eslint . --max-warnings 0",
|
||||||
"lint:fix": "eslint . --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
"typecheck": "tsc --build --force",
|
"typecheck": "tsc -p tsconfig.app.json --noEmit && tsc -p tsconfig.test.json --noEmit",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:ui": "vitest --ui",
|
"test:ui": "vitest --ui",
|
||||||
|
|
@ -56,4 +56,4 @@
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22"
|
"node": ">=22"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,11 @@ export type AdminThemeValue = {
|
||||||
previewSlug: string | null
|
previewSlug: string | null
|
||||||
setMode: (mode: AdminThemeMode) => void
|
setMode: (mode: AdminThemeMode) => void
|
||||||
setPreviewSlug: (slug: string | null) => void
|
setPreviewSlug: (slug: string | null) => void
|
||||||
|
/**
|
||||||
|
* Sets mode and preview slug atomically. Used by the shipped controls so that
|
||||||
|
* picking a theme does not race with picking its default appearance.
|
||||||
|
*/
|
||||||
|
choose: (choice: { mode: AdminThemeMode; slug: string | null }) => void
|
||||||
/** The slugs available to preview, from the runtime manifest. */
|
/** The slugs available to preview, from the runtime manifest. */
|
||||||
available: string[]
|
available: string[]
|
||||||
/** The runtime base URL, for building preview URLs. */
|
/** The runtime base URL, for building preview URLs. */
|
||||||
|
|
|
||||||
|
|
@ -21,11 +21,27 @@ vi.mock('../lib/api', () => ({
|
||||||
resetPreferences: vi.fn(() => Promise.resolve({ selectedTheme: '', selectedAppearance: '' })),
|
resetPreferences: vi.fn(() => Promise.resolve({ selectedTheme: '', selectedAppearance: '' })),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// Mock the control loader so the origin it is asked to load from can be asserted
|
||||||
|
// directly, rather than depending on the loader's module-level singleton promise.
|
||||||
|
vi.mock('../lib/runtime-controls', () => ({
|
||||||
|
loadRuntimeControls: vi.fn(() => new Promise<void>(() => undefined)),
|
||||||
|
}))
|
||||||
|
|
||||||
const api = await import('../lib/api')
|
const api = await import('../lib/api')
|
||||||
|
const runtimeControls = await import('../lib/runtime-controls')
|
||||||
|
|
||||||
const THEME_ATTRIBUTE = 'data-bananas-theme'
|
const THEME_ATTRIBUTE = 'data-bananas-theme'
|
||||||
const APPEARANCE_ATTRIBUTE = 'data-bananas-appearance'
|
const APPEARANCE_ATTRIBUTE = 'data-bananas-appearance'
|
||||||
|
|
||||||
|
const FULL_ENTRY = {
|
||||||
|
slug: 'midnight',
|
||||||
|
name: 'Midnight',
|
||||||
|
defaultAppearance: 'dark',
|
||||||
|
version: 1,
|
||||||
|
css: '/rt/v1/theme.css?slug=midnight',
|
||||||
|
tokens: '/rt/v1/theme.json?slug=midnight',
|
||||||
|
} as const
|
||||||
|
|
||||||
/** A probe that renders the current choice and exposes the setters. */
|
/** A probe that renders the current choice and exposes the setters. */
|
||||||
function Probe() {
|
function Probe() {
|
||||||
const { mode, previewSlug, available, setMode, setPreviewSlug } = useAdminTheme()
|
const { mode, previewSlug, available, setMode, setPreviewSlug } = useAdminTheme()
|
||||||
|
|
@ -110,20 +126,26 @@ describe('with a stored preference', () => {
|
||||||
// knows that its own chrome is styled by the full variant of a theme.
|
// knows that its own chrome is styled by the full variant of a theme.
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(
|
expect(
|
||||||
document.querySelector('link[data-bananas-admin="bananas-admin-preview"]')?.getAttribute('href'),
|
document
|
||||||
|
.querySelector('link[data-bananas-admin="bananas-admin-preview"]')
|
||||||
|
?.getAttribute('href'),
|
||||||
).toBe('/rt/v1/theme.css?slug=midnight&variant=full'),
|
).toBe('/rt/v1/theme.css?slug=midnight&variant=full'),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
// The document attributes belong to the shipped control, so that there is one writer
|
// While the shipped controls are not ready — in this file loadRuntimeControls is
|
||||||
// for them rather than two that can disagree. This test pins that division of
|
// mocked and never resolves — the provider is the only writer, and the preview
|
||||||
// responsibility: if the provider starts writing them again, it will fail.
|
// stylesheet is scoped to exactly these attributes. If the provider did not apply
|
||||||
it('leaves the document attributes to the shipped controls', async () => {
|
// them, the fallback switcher would change state but repaint nothing, which is the
|
||||||
|
// "I can't select a theme / light-dark does nothing" bug. (The single-writer handoff,
|
||||||
|
// where the controls take over once loaded, is covered by the real-controls
|
||||||
|
// integration test.)
|
||||||
|
it('applies the document attributes itself while the controls are not ready', async () => {
|
||||||
renderProvider()
|
renderProvider()
|
||||||
await waitFor(() => expect(screen.getByTestId('mode')).toHaveTextContent('theme-dark'))
|
await waitFor(() => expect(screen.getByTestId('mode')).toHaveTextContent('theme-dark'))
|
||||||
await waitFor(() => expect(screen.getByTestId('slug')).toHaveTextContent('midnight'))
|
await waitFor(() => expect(screen.getByTestId('slug')).toHaveTextContent('midnight'))
|
||||||
expect(document.documentElement.hasAttribute(THEME_ATTRIBUTE)).toBe(false)
|
expect(document.documentElement.getAttribute(THEME_ATTRIBUTE)).toBe('midnight')
|
||||||
expect(document.documentElement.hasAttribute(APPEARANCE_ATTRIBUTE)).toBe(false)
|
expect(document.documentElement.getAttribute(APPEARANCE_ATTRIBUTE)).toBe('dark')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('writes the choice back to the API when the mode changes', async () => {
|
it('writes the choice back to the API when the mode changes', async () => {
|
||||||
|
|
@ -186,3 +208,41 @@ describe('when persisting fails', () => {
|
||||||
expect(screen.getByTestId('mode')).toHaveTextContent('theme-dark')
|
expect(screen.getByTestId('mode')).toHaveTextContent('theme-dark')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// TestTheAdminConsumesItsRuntimeSameOrigin: a manifest (or config) that advertises a
|
||||||
|
// DIFFERENT absolute origin than the page itself — a CDN in front, a public host that
|
||||||
|
// is not the one you opened, `localhost` vs `127.0.0.1` — must not make the admin
|
||||||
|
// fetch its own controls and preview stylesheet from that other origin. The page's CSP
|
||||||
|
// is `script-src 'self'`, so a cross-origin `<script type=module>` and the module's
|
||||||
|
// dynamic `import()` of its chunk are blocked, `controlsReady` never becomes true, and
|
||||||
|
// the switcher silently falls back with no theme selectable. The admin must therefore
|
||||||
|
// always load `/rt/v1` from its own origin.
|
||||||
|
describe('the runtime is consumed same-origin', () => {
|
||||||
|
it('ignores the absolute public base when loading controls and the preview', async () => {
|
||||||
|
vi.mocked(api.getManifest).mockResolvedValue({
|
||||||
|
apiVersion: '1',
|
||||||
|
// An origin that is deliberately not the page's own.
|
||||||
|
baseUrl: 'https://themes.example.com/rt/v1',
|
||||||
|
themes: [FULL_ENTRY],
|
||||||
|
})
|
||||||
|
vi.mocked(api.getSession).mockResolvedValue({
|
||||||
|
authenticated: true,
|
||||||
|
mode: 'dev',
|
||||||
|
preferences: { selectedTheme: 'midnight', selectedAppearance: 'dark' },
|
||||||
|
} as unknown as Awaited<ReturnType<typeof api.getSession>>)
|
||||||
|
|
||||||
|
renderProvider()
|
||||||
|
|
||||||
|
// The shipped controls are requested from the admin's own origin, not the CDN.
|
||||||
|
await waitFor(() => expect(runtimeControls.loadRuntimeControls).toHaveBeenCalledWith('/rt/v1'))
|
||||||
|
|
||||||
|
// The previewed palette is loaded from the admin's own origin too.
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
document
|
||||||
|
.querySelector('link[data-bananas-admin="bananas-admin-preview"]')
|
||||||
|
?.getAttribute('href'),
|
||||||
|
).toBe('/rt/v1/theme.css?slug=midnight&variant=full'),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,6 @@ import { loadRuntimeControls } from '../lib/runtime-controls'
|
||||||
/** Which visual mode the admin chrome is in. */
|
/** Which visual mode the admin chrome is in. */
|
||||||
export type AdminThemeMode = 'admin' | 'theme-light' | 'theme-dark'
|
export type AdminThemeMode = 'admin' | 'theme-light' | 'theme-dark'
|
||||||
|
|
||||||
|
|
||||||
/** The value used when a previewed theme has not been chosen yet. */
|
/** The value used when a previewed theme has not been chosen yet. */
|
||||||
const DEFAULT_PREVIEW = 'midnight'
|
const DEFAULT_PREVIEW = 'midnight'
|
||||||
|
|
||||||
|
|
@ -103,7 +102,30 @@ export function AdminThemeProvider({ children }: { children: ReactNode }) {
|
||||||
|
|
||||||
const available = useMemo(() => manifest?.themes.map((theme) => theme.slug) ?? [], [manifest])
|
const available = useMemo(() => manifest?.themes.map((theme) => theme.slug) ?? [], [manifest])
|
||||||
|
|
||||||
const runtimeBase = manifest?.baseUrl ?? '/rt/v1'
|
// The default appearance of each published theme, so that picking a theme in the
|
||||||
|
// fallback switcher previews it in the scheme its author chose — exactly what the
|
||||||
|
// shipped controls do. Without this, choosing a theme while in the admin chrome would
|
||||||
|
// set the slug but leave `mode: 'admin'`, so nothing repaints and the dropdown looks
|
||||||
|
// dead: the "I select a theme and nothing happens" symptom.
|
||||||
|
const defaultAppearanceOf = useMemo(() => {
|
||||||
|
const map = new Map<string, 'light' | 'dark'>()
|
||||||
|
for (const theme of manifest?.themes ?? []) {
|
||||||
|
map.set(theme.slug, theme.defaultAppearance === 'light' ? 'light' : 'dark')
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}, [manifest])
|
||||||
|
|
||||||
|
// The admin loads the shipped controls and the preview stylesheet from THIS
|
||||||
|
// origin, not from manifest.baseUrl / config.runtimeBase. Those are the absolute
|
||||||
|
// *public* origin (or the CDN) meant for consumers pasting a snippet; using them
|
||||||
|
// here would make the module script and its contract.json fetch cross-origin
|
||||||
|
// whenever the browser's origin differs from the configured public base — a
|
||||||
|
// localhost/127.0.0.1 mismatch, a Vite dev proxy, or a CDN in front — and the
|
||||||
|
// page's CSP (script-src 'self') would then block the controls from ever
|
||||||
|
// defining, which surfaces as a switcher that cannot pick a theme. apiBase()
|
||||||
|
// already derives the same-origin path from the optional base-path marker; do the
|
||||||
|
// same for /rt/v1 so a sub-path deployment resolves correctly too.
|
||||||
|
const runtimeBase = sameOriginRuntimeBase()
|
||||||
|
|
||||||
// Fetch the shipped controls from this service. A failure is not worth surfacing:
|
// Fetch the shipped controls from this service. A failure is not worth surfacing:
|
||||||
// the switcher keeps its previous control, and nothing else depends on this.
|
// the switcher keeps its previous control, and nothing else depends on this.
|
||||||
|
|
@ -140,26 +162,25 @@ export function AdminThemeProvider({ children }: { children: ReactNode }) {
|
||||||
writeStored(STORAGE_KEY, next.mode)
|
writeStored(STORAGE_KEY, next.mode)
|
||||||
if (next.slug) {
|
if (next.slug) {
|
||||||
writeStored(PREVIEW_KEY, next.slug)
|
writeStored(PREVIEW_KEY, next.slug)
|
||||||
persist(next.slug, next.mode === 'theme-dark' ? 'dark' : 'light')
|
|
||||||
} else {
|
} else {
|
||||||
window.localStorage?.removeItem(PREVIEW_KEY)
|
window.localStorage?.removeItem(PREVIEW_KEY)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The admin chrome is the absence of a theme, so returning to it forgets the
|
||||||
|
// preference rather than recording the slug that is still selected in the
|
||||||
|
// dropdown. Persisting the slug here would make the next browser open on a
|
||||||
|
// theme this person had just switched away from.
|
||||||
|
if (next.mode === 'admin') {
|
||||||
|
void resetPreferences().catch(() => undefined)
|
||||||
|
} else if (next.slug) {
|
||||||
|
persist(next.slug, next.mode === 'theme-dark' ? 'dark' : 'light')
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[persist],
|
[persist],
|
||||||
)
|
)
|
||||||
|
|
||||||
const setMode = useCallback(
|
const setMode = useCallback(
|
||||||
(next: AdminThemeMode) => {
|
(next: AdminThemeMode) => {
|
||||||
// The admin chrome is the absence of a theme, so returning to it forgets the
|
|
||||||
// preference rather than recording the slug that is still selected in the
|
|
||||||
// dropdown. Persisting the slug here would make the next browser open on a
|
|
||||||
// theme this person had just switched away from.
|
|
||||||
if (next === 'admin') {
|
|
||||||
setChoice({ mode: 'admin', slug: previewSlug })
|
|
||||||
writeStored(STORAGE_KEY, 'admin')
|
|
||||||
void resetPreferences().catch(() => undefined)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
choose({ mode: next, slug: previewSlug })
|
choose({ mode: next, slug: previewSlug })
|
||||||
},
|
},
|
||||||
[choose, previewSlug],
|
[choose, previewSlug],
|
||||||
|
|
@ -167,36 +188,48 @@ export function AdminThemeProvider({ children }: { children: ReactNode }) {
|
||||||
|
|
||||||
const setPreviewSlug = useCallback(
|
const setPreviewSlug = useCallback(
|
||||||
(slug: string | null) => {
|
(slug: string | null) => {
|
||||||
choose({ mode, slug })
|
// Choosing a concrete theme previews it; choosing none returns to the chrome.
|
||||||
|
// Used by the fallback switcher's dropdown, which has no separate appearance
|
||||||
|
// click, so it applies the theme's own default scheme the way the shipped
|
||||||
|
// controls do.
|
||||||
|
if (!slug) {
|
||||||
|
choose({ mode, slug: null })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const nextMode: AdminThemeMode =
|
||||||
|
defaultAppearanceOf.get(slug) === 'light' ? 'theme-light' : 'theme-dark'
|
||||||
|
choose({ mode: nextMode, slug })
|
||||||
},
|
},
|
||||||
[choose, mode],
|
[choose, mode, defaultAppearanceOf],
|
||||||
)
|
)
|
||||||
|
|
||||||
// The document attributes are no longer written from here. The shipped controls
|
// The document attributes are owned by the shipped controls once they are ready,
|
||||||
// write them, including the prefixed names this stylesheet reads, so there is one
|
// so there is one writer and the two can never disagree. BUT until the module has
|
||||||
// writer and no chance of the two disagreeing. The admin state drives the elements
|
// upgraded them — and in the fallback case where it never does — nothing else writes
|
||||||
// instead; see the shell for the binding.
|
// them. Because the preview stylesheet is scoped to exactly these attributes, leaving
|
||||||
//
|
// them unset means picking a theme or toggling light/dark changes state but repaints
|
||||||
// The `go-bananas-admin` sentinel is gone with it. It was read by nothing at all:
|
// nothing. So while the controls are not ready the provider must apply them itself;
|
||||||
// no rule in the stylesheet matched it and no component inspected it. "Admin
|
// once they are, it steps back and lets the controls own the attributes.
|
||||||
// chrome" is the state with no previewed palette applied, which is exactly what the
|
|
||||||
// appearance toggle's admin state produces.
|
|
||||||
|
|
||||||
// The injected stylesheet must follow the mode too, because a theme's light and
|
|
||||||
// dark schemes are two rules under the same scoped selector and the attribute
|
|
||||||
// decides which wins.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (controlsReady) return
|
||||||
const root = document.documentElement
|
const root = document.documentElement
|
||||||
if (mode === 'admin') {
|
if (mode === 'admin') {
|
||||||
root.removeAttribute('data-bananas-theme')
|
root.removeAttribute('data-bananas-theme')
|
||||||
root.removeAttribute('data-bananas-appearance')
|
root.removeAttribute('data-bananas-appearance')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const slug = previewSlug ?? DEFAULT_PREVIEW
|
const slug = previewSlug ?? DEFAULT_PREVIEW
|
||||||
root.setAttribute('data-bananas-theme', slug)
|
root.setAttribute('data-bananas-theme', slug)
|
||||||
root.setAttribute('data-bananas-appearance', mode === 'theme-dark' ? 'dark' : 'light')
|
root.setAttribute('data-bananas-appearance', mode === 'theme-dark' ? 'dark' : 'light')
|
||||||
|
}, [mode, previewSlug, controlsReady])
|
||||||
|
|
||||||
|
// The preview stylesheet is the one thing only this service knows it needs:
|
||||||
|
// the full variant of a published theme, scoped to the prefixed attributes the
|
||||||
|
// runtime emits.
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode === 'admin') return
|
||||||
|
|
||||||
|
const slug = previewSlug ?? DEFAULT_PREVIEW
|
||||||
const id = 'bananas-admin-preview'
|
const id = 'bananas-admin-preview'
|
||||||
let link = document.querySelector<HTMLLinkElement>(`link[data-bananas-admin="${id}"]`)
|
let link = document.querySelector<HTMLLinkElement>(`link[data-bananas-admin="${id}"]`)
|
||||||
if (!link) {
|
if (!link) {
|
||||||
|
|
@ -217,21 +250,36 @@ export function AdminThemeProvider({ children }: { children: ReactNode }) {
|
||||||
}, [mode])
|
}, [mode])
|
||||||
|
|
||||||
const value = useMemo<AdminThemeValue>(
|
const value = useMemo<AdminThemeValue>(
|
||||||
() => ({ mode, previewSlug, setMode, setPreviewSlug, available, runtimeBase, controlsReady }),
|
() => ({
|
||||||
[
|
|
||||||
mode,
|
mode,
|
||||||
previewSlug,
|
previewSlug,
|
||||||
setMode,
|
setMode,
|
||||||
setPreviewSlug,
|
setPreviewSlug,
|
||||||
|
choose,
|
||||||
available,
|
available,
|
||||||
runtimeBase,
|
runtimeBase,
|
||||||
controlsReady,
|
controlsReady,
|
||||||
],
|
}),
|
||||||
|
[mode, previewSlug, setMode, setPreviewSlug, choose, available, runtimeBase, controlsReady],
|
||||||
)
|
)
|
||||||
|
|
||||||
return <AdminThemeContext.Provider value={value}>{children}</AdminThemeContext.Provider>
|
return <AdminThemeContext.Provider value={value}>{children}</AdminThemeContext.Provider>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same-origin runtime base the admin consumes for its own controls and preview.
|
||||||
|
*
|
||||||
|
* It mirrors `apiBase()` in `lib/api.ts`: both resolve against the page's own origin
|
||||||
|
* and honour the optional `data-bananas-base-path` marker, so a sub-path deployment
|
||||||
|
* prefixes `/rt/v1` the same way it prefixes `/api/v1`. Nothing here may point at the
|
||||||
|
* absolute public/CDN origin — that is cross-origin under CSP `script-src 'self'`.
|
||||||
|
*/
|
||||||
|
function sameOriginRuntimeBase(): string {
|
||||||
|
const configured = document.documentElement.getAttribute('data-bananas-base-path') ?? ''
|
||||||
|
const prefix = configured.replace(/\/+$/, '')
|
||||||
|
return `${prefix}/rt/v1`
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads the persisted choice, tolerating storage being unavailable.
|
* Reads the persisted choice, tolerating storage being unavailable.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,11 @@ export function AppShell() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [navOpen, setNavOpen] = useState(false)
|
const [navOpen, setNavOpen] = useState(false)
|
||||||
|
|
||||||
const { data: session, isLoading, error } = useQuery({
|
const {
|
||||||
|
data: session,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
} = useQuery({
|
||||||
queryKey: ['session'],
|
queryKey: ['session'],
|
||||||
queryFn: ({ signal }) => getSession(signal),
|
queryFn: ({ signal }) => getSession(signal),
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
|
|
@ -117,7 +121,7 @@ export function AppShell() {
|
||||||
onClick={() => setNavOpen(false)}
|
onClick={() => setNavOpen(false)}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
cn(
|
cn(
|
||||||
'flex items-center gap-2.5 rounded-control px-2.5 py-2 text-sm transition-colors',
|
'rounded-control flex items-center gap-2.5 px-2.5 py-2 text-sm transition-colors',
|
||||||
isActive
|
isActive
|
||||||
? 'bg-brand-subtle text-brand font-medium'
|
? 'bg-brand-subtle text-brand font-medium'
|
||||||
: 'text-ink-muted hover:bg-raised hover:text-ink',
|
: 'text-ink-muted hover:bg-raised hover:text-ink',
|
||||||
|
|
@ -159,7 +163,7 @@ export function AppShell() {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Close navigation"
|
aria-label="Close navigation"
|
||||||
className="bg-black/60 fixed inset-0 z-30 lg:hidden"
|
className="fixed inset-0 z-30 bg-black/60 lg:hidden"
|
||||||
onClick={() => setNavOpen(false)}
|
onClick={() => setNavOpen(false)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -223,15 +227,19 @@ export function AppShell() {
|
||||||
/** The banana mark. Drawn inline so the shell needs no image asset. */
|
/** The banana mark. Drawn inline so the shell needs no image asset. */
|
||||||
function BananasMark() {
|
function BananasMark() {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg viewBox="0 0 24 24" className="text-brand size-5 shrink-0" fill="currentColor" aria-hidden>
|
||||||
viewBox="0 0 24 24"
|
<path
|
||||||
className="text-brand size-5 shrink-0"
|
d="M4 8c0-1.1.9-2 2-2h3c.6 0 1.1.2 1.5.6l7.2 6.4c.2.2.3.5.3.8V20a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8Z"
|
||||||
fill="currentColor"
|
opacity=".9"
|
||||||
aria-hidden
|
/>
|
||||||
>
|
<path
|
||||||
<path d="M4 8c0-1.1.9-2 2-2h3c.6 0 1.1.2 1.5.6l7.2 6.4c.2.2.3.5.3.8V20a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8Z" opacity=".9" />
|
d="M20 8c0-1.1-.9-2-2-2h-1.2c-.6 0-1.1.2-1.5.6L8.1 13c-.2.2-.3.5-.3.8V20a2 2 0 0 0 2 2H18a2 2 0 0 0 2-2V8Z"
|
||||||
<path d="M20 8c0-1.1-.9-2-2-2h-1.2c-.6 0-1.1.2-1.5.6L8.1 13c-.2.2-.3.5-.3.8V20a2 2 0 0 0 2 2H18a2 2 0 0 0 2-2V8Z" opacity=".55" />
|
opacity=".55"
|
||||||
<path d="M8 5c0-.6.4-1 1-1h1.5c.3 0 .6.1.8.4l.5.5c.2.2.2.5 0 .7-.2.2-.5.2-.7 0l-.4-.4H9.5a.5.5 0 0 1-.5-.5V5Z" opacity=".7" />
|
/>
|
||||||
|
<path
|
||||||
|
d="M8 5c0-.6.4-1 1-1h1.5c.3 0 .6.1.8.4l.5.5c.2.2.2.5 0 .7-.2.2-.5.2-.7 0l-.4-.4H9.5a.5.5 0 0 1-.5-.5V5Z"
|
||||||
|
opacity=".7"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -280,7 +288,7 @@ const routeLabels: Record<string, string> = {
|
||||||
function usePathSegments(): { label: string; to: string }[] {
|
function usePathSegments(): { label: string; to: string }[] {
|
||||||
// useLocation is imported lazily through the module scope; declaring the hook
|
// useLocation is imported lazily through the module scope; declaring the hook
|
||||||
// here keeps Breadcrumbs readable.
|
// here keeps Breadcrumbs readable.
|
||||||
|
|
||||||
return useLocationSegments()
|
return useLocationSegments()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -297,9 +305,18 @@ function usePathSegments(): { label: string; to: string }[] {
|
||||||
* moving between machines expects to see the same preview. And the element persists
|
* moving between machines expects to see the same preview. And the element persists
|
||||||
* locally under its own key; letting it win would silently outrank the account.
|
* locally under its own key; letting it win would silently outrank the account.
|
||||||
*/
|
*/
|
||||||
function ThemeSwitcher() {
|
/** Exported for integration tests. */
|
||||||
const { mode, previewSlug, setMode, setPreviewSlug, available, runtimeBase, controlsReady } =
|
export function ThemeSwitcher() {
|
||||||
useAdminThemeSafe()
|
const {
|
||||||
|
mode,
|
||||||
|
previewSlug,
|
||||||
|
setMode,
|
||||||
|
setPreviewSlug,
|
||||||
|
choose,
|
||||||
|
available,
|
||||||
|
runtimeBase,
|
||||||
|
controlsReady,
|
||||||
|
} = useAdminThemeSafe()
|
||||||
const selector = useRef<BananasElement>(null)
|
const selector = useRef<BananasElement>(null)
|
||||||
const toggle = useRef<BananasElement>(null)
|
const toggle = useRef<BananasElement>(null)
|
||||||
|
|
||||||
|
|
@ -314,16 +331,18 @@ function ThemeSwitcher() {
|
||||||
const slug = detail?.slug
|
const slug = detail?.slug
|
||||||
// An empty slug is the control's "no theme" entry, which in the admin means
|
// An empty slug is the control's "no theme" entry, which in the admin means
|
||||||
// the chrome, since that is what an unset preview already maps to.
|
// the chrome, since that is what an unset preview already maps to.
|
||||||
setPreviewSlug(slug || null)
|
|
||||||
// Also apply the theme's default appearance mode so selecting a theme
|
|
||||||
// immediately shows it rather than leaving the chrome in 'admin' mode.
|
|
||||||
if (slug && detail?.mode) {
|
if (slug && detail?.mode) {
|
||||||
setMode(detail.mode === 'dark' ? 'theme-dark' : 'theme-light')
|
choose({
|
||||||
|
slug,
|
||||||
|
mode: detail.mode === 'dark' ? 'theme-dark' : 'theme-light',
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
choose({ slug: null, mode: 'admin' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
el.addEventListener('bananas:theme', handler)
|
el.addEventListener('bananas:theme', handler)
|
||||||
return () => el.removeEventListener('bananas:theme', handler)
|
return () => el.removeEventListener('bananas:theme', handler)
|
||||||
}, [setPreviewSlug, setMode, controlsReady])
|
}, [choose, controlsReady])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = toggle.current
|
const el = toggle.current
|
||||||
|
|
@ -404,23 +423,21 @@ function LegacyThemeSwitcher({
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{mode !== 'admin' ? (
|
<select
|
||||||
<select
|
aria-label="Preview theme"
|
||||||
aria-label="Preview theme"
|
value={previewSlug ?? ''}
|
||||||
value={previewSlug ?? ''}
|
onChange={(event) => setPreviewSlug(event.target.value)}
|
||||||
onChange={(event) => setPreviewSlug(event.target.value)}
|
className="border-line bg-canvas text-ink-muted rounded-control h-8 max-w-[10rem] border px-2 text-xs"
|
||||||
className="border-line bg-canvas text-ink-muted h-8 max-w-[10rem] rounded-control border px-2 text-xs"
|
>
|
||||||
>
|
{available.length === 0 ? <option value="">no published themes</option> : null}
|
||||||
{available.length === 0 ? <option value="">no published themes</option> : null}
|
{available.map((slug) => (
|
||||||
{available.map((slug) => (
|
<option key={slug} value={slug}>
|
||||||
<option key={slug} value={slug}>
|
{slug}
|
||||||
{slug}
|
</option>
|
||||||
</option>
|
))}
|
||||||
))}
|
</select>
|
||||||
</select>
|
|
||||||
) : null}
|
|
||||||
<div
|
<div
|
||||||
className="border-line bg-canvas flex rounded-control border p-0.5"
|
className="border-line bg-canvas rounded-control flex border p-0.5"
|
||||||
role="group"
|
role="group"
|
||||||
aria-label="Interface theme"
|
aria-label="Interface theme"
|
||||||
>
|
>
|
||||||
|
|
@ -502,6 +519,7 @@ function useAdminThemeSafe() {
|
||||||
setMode: () => {},
|
setMode: () => {},
|
||||||
previewSlug: null,
|
previewSlug: null,
|
||||||
setPreviewSlug: () => {},
|
setPreviewSlug: () => {},
|
||||||
|
choose: () => {},
|
||||||
available: [] as string[],
|
available: [] as string[],
|
||||||
runtimeBase: '/rt/v1',
|
runtimeBase: '/rt/v1',
|
||||||
// Without a provider the module is never asked for, so the fallback control
|
// Without a provider the module is never asked for, so the fallback control
|
||||||
|
|
@ -523,4 +541,4 @@ function useLocationSegments(): { label: string; to: string }[] {
|
||||||
out.push({ label: routeLabels[part] ?? part, to: accumulated })
|
out.push({ label: routeLabels[part] ?? part, to: accumulated })
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
|
||||||
12
web/src/global.d.ts
vendored
12
web/src/global.d.ts
vendored
|
|
@ -1,3 +1,5 @@
|
||||||
|
/* eslint-disable @typescript-eslint/consistent-type-definitions */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Global type augmentations for bananas runtime controls.
|
* Global type augmentations for bananas runtime controls.
|
||||||
* This file must not have any imports/exports to be treated as a global script.
|
* This file must not have any imports/exports to be treated as a global script.
|
||||||
|
|
@ -6,13 +8,19 @@
|
||||||
declare global {
|
declare global {
|
||||||
namespace JSX {
|
namespace JSX {
|
||||||
interface IntrinsicElements {
|
interface IntrinsicElements {
|
||||||
'bananas-theme-selector': React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement> & {
|
'bananas-theme-selector': React.DetailedHTMLProps<
|
||||||
|
React.HTMLAttributes<HTMLElement>,
|
||||||
|
HTMLElement
|
||||||
|
> & {
|
||||||
base?: string
|
base?: string
|
||||||
label?: string
|
label?: string
|
||||||
compact?: boolean
|
compact?: boolean
|
||||||
'no-preview'?: boolean
|
'no-preview'?: boolean
|
||||||
}
|
}
|
||||||
'bananas-appearance-toggle': React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement> & {
|
'bananas-appearance-toggle': React.DetailedHTMLProps<
|
||||||
|
React.HTMLAttributes<HTMLElement>,
|
||||||
|
HTMLElement
|
||||||
|
> & {
|
||||||
label?: string
|
label?: string
|
||||||
'show-labels'?: boolean
|
'show-labels'?: boolean
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@ export default function DashboardRoute() {
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Counters. */}
|
{/* Counters. */}
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<Stat
|
<Stat
|
||||||
label="Themes"
|
label="Themes"
|
||||||
icon={<Palette className="size-4" aria-hidden />}
|
icon={<Palette className="size-4" aria-hidden />}
|
||||||
|
|
|
||||||
2
web/src/vite-env.d.ts
vendored
2
web/src/vite-env.d.ts
vendored
|
|
@ -1,3 +1,5 @@
|
||||||
|
/* eslint-disable @typescript-eslint/consistent-type-definitions */
|
||||||
|
|
||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -27,5 +27,11 @@
|
||||||
"types": ["vitest/globals", "@testing-library/jest-dom"],
|
"types": ["vitest/globals", "@testing-library/jest-dom"],
|
||||||
"paths": { "@/*": ["./src/*"] }
|
"paths": { "@/*": ["./src/*"] }
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
// The production build must not compile test files. One test imports the shipped
|
||||||
|
// SDK source across packages (`sdk/src/*.js`) to drive the real custom elements; that
|
||||||
|
// coupling belongs to the test project (tsconfig.test.json), not to the app image
|
||||||
|
// build, where the SDK may not even be on disk yet. `tsc -p tsconfig.app.json` is what
|
||||||
|
// the Dockerfile runs, so tests are simply out of this project's file set.
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/test"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,11 +36,11 @@ export default defineConfig({
|
||||||
port: 5173,
|
port: 5173,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': { target: 'http://localhost:8081', changeOrigin: true },
|
'/api': { target: 'http://localhost:8080', changeOrigin: true },
|
||||||
'/rt': { target: 'http://localhost:8081', changeOrigin: true },
|
'/rt': { target: 'http://localhost:8080', changeOrigin: true },
|
||||||
'/auth': { target: 'http://localhost:8081', changeOrigin: true },
|
'/auth': { target: 'http://localhost:8080', changeOrigin: true },
|
||||||
'/healthz': { target: 'http://localhost:8081', changeOrigin: true },
|
'/healthz': { target: 'http://localhost:8080', changeOrigin: true },
|
||||||
'/readyz': { target: 'http://localhost:8081', changeOrigin: true },
|
'/readyz': { target: 'http://localhost:8080', changeOrigin: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
import { defineConfig } from 'vitest/config'
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -7,6 +9,15 @@ import { defineConfig } from 'vitest/config'
|
||||||
* errors about the `test` key.
|
* errors about the `test` key.
|
||||||
*/
|
*/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
// The integration tests import the actual shipped SDK modules (sdk/src/*) to drive
|
||||||
|
// the real custom elements. Those live one directory up, outside the web package
|
||||||
|
// root, so Vite's default filesystem allow-list has to be widened to the repository
|
||||||
|
// root for the cross-package import to resolve.
|
||||||
|
server: {
|
||||||
|
fs: {
|
||||||
|
allow: [fileURLToPath(new URL('..', import.meta.url))],
|
||||||
|
},
|
||||||
|
},
|
||||||
test: {
|
test: {
|
||||||
// The admin interface is a browser application: components touch the DOM,
|
// The admin interface is a browser application: components touch the DOM,
|
||||||
// localStorage and matchMedia, so jsdom is required rather than merely useful.
|
// localStorage and matchMedia, so jsdom is required rather than merely useful.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue