feat: avatar, account menu, and single sign-on from Go Loose

Signing in now starts at the sign-in screen and one button that hands the
browser to Go Loose, which returns to the welcome page. Nothing redirects to the
identity provider on its own any more: that fired on every anonymous visit and
read as a redirect loop rather than as a choice to sign in. The password form
remains only where the API reports that Go Loose login is not configured for the
host, because otherwise the application is unreachable on a bare localhost.

A person's picture is carried through from the Go Loose identity and rendered in
the account menu and on the profile page, with their initials as the fallback.
Two things make that fallback necessary rather than decorative: Go Loose retires a
picture key when a picture is replaced, so a link cached in a browser can be dead,
and most people have not uploaded one. The URL is deliberately not persisted
locally, since a stored key would keep failing after the picture changed.

The account menu is a disclosure rather than a hover menu: a real button with
aria-expanded, Escape and outside clicks closing it, and focus returning to the
trigger. It is not an ARIA menu, because its actions are a link and a button and
menuitem roles would strip the link semantics from one of them for no gain.

The account URL of the Go Loose identity is resolved on each sign-in and is read
from the live identity provider every time, which is also what makes it correct
after a picture change.

Depends on the Go Loose revision that serves avatar_url through
/connect/userinfo, so the picture URL reaches this application at all.
This commit is contained in:
Daniel Noulet 2026-10-05 21:50:06 +02:00
parent 6be21597d5
commit 5449cd3d8d
52 changed files with 1927 additions and 434 deletions

View file

@ -2,7 +2,7 @@
This chart deploys Go Guess as two images, an API and a web front end, plus its
own PostgreSQL and RabbitMQ, and publishes one HTTPS host per tenant under
`https://<tenant>.guess.local`.
`https://<tenant>.guess.urpi.be`.
## 1. What the chart creates
@ -75,7 +75,7 @@ command that exports the CA certificate. With the local platform, prefer
`tenants` is the list of slugs and `domain` is the shared parent domain:
```yaml
domain: guess.local
domain: guess.urpi.be
tenants:
- nmbs
- ypto
@ -83,8 +83,8 @@ tenants:
The chart derives, without any extra configuration:
- `GO_LOOSE_APP_DOMAIN=guess.local`, so the API accepts `nmbs.guess.local` and
`ypto.guess.local` as tenant hosts.
- `GO_LOOSE_APP_DOMAIN=<domain>`, so the API accepts `nmbs.<domain>` and
`ypto.<domain>` as tenant hosts.
- One ingress rule per tenant, all pointing at `go-guess-web`.
- One certificate SAN per tenant, so a single secret covers every host.
- `FRONTEND_URL`, when `api.frontendURL` is empty, as
@ -107,7 +107,7 @@ sign-in is used.
|---|---|---|
| `nameOverride` | `""` | Overrides the chart name in resource names and labels |
| `fullnameOverride` | `""` | Replaces the generated resource name prefix |
| `domain` | `guess.local` | Parent domain of every tenant host |
| `domain` | `guess.urpi.be` | Parent domain of every tenant host. `guess-dev.urpi.be` in the development profile |
| `tenants` | `[nmbs, ypto]` | Tenant slugs served by the release |
| `imagePullSecrets` | `[]` | Pull secrets added to both pods |
@ -126,8 +126,8 @@ sign-in is used.
| `api.service.name` | `api` | Service DNS name, fixed by the web image |
| `api.service.type` | `ClusterIP` | API service type |
| `api.service.port` | `8080` | API service port |
| `api.goLoose.enabled` | `false` | Enables Go Loose browser sign-in |
| `api.goLoose.authDomain` | `auth.dev` | Tenant login host `https://<tenant>.<authDomain>` |
| `api.goLoose.enabled` | `true` | Enables Go Loose browser sign-in |
| `api.goLoose.authDomain` | `auth.urpi.be` | Tenant login host `https://<tenant>.<authDomain>`. `auth-dev.urpi.be` in the development profile |
| `api.goLoose.ca.existingSecret` | `""` | Secret holding the Go Loose CA bundle |
| `api.goLoose.ca.key` | `ca.crt` | Key projected from that secret |
| `api.goLoose.ca.mountPath` | `/certs/local-ca.crt` | `GO_LOOSE_CA_FILE` target |
@ -264,8 +264,8 @@ kubectl -n go-insane-production get ingress go-guess
kubectl -n go-insane-production get certificate
kubectl -n go-insane-production rollout status deployment/go-guess-api
kubectl -n go-insane-production rollout status deployment/go-guess-web
curl --resolve nmbs.guess.local:443:127.0.0.1 --cacert go-guess-ca.crt \
https://nmbs.guess.local/api/health
curl --resolve nmbs.guess.urpi.be:443:127.0.0.1 --cacert go-guess-ca.crt \
https://nmbs.guess.urpi.be/api/health
```
The test pod installed by `helm test` checks the API health endpoint and the web

View file

@ -53,7 +53,7 @@ app.kubernetes.io/instance: {{ .Release.Name }}
{{- default "api" .Values.api.service.name }}
{{- end }}
{{/* Host serving one tenant, for example nmbs.guess.local. */}}
{{/* Host serving one tenant, for example nmbs.guess.urpi.be. */}}
{{- define "go-guess.tenantHost" -}}
{{- printf "%s.%s" (index . 0) (index . 1) }}
{{- end }}
@ -81,7 +81,7 @@ app.kubernetes.io/instance: {{ .Release.Name }}
{{/* Cluster-scoped by default so one issuer signs every tenant release. */}}
{{- define "go-guess.certManagerIssuerRef" -}}
{{- if .Values.certManager.issuerRef }}
toYaml .Values.certManager.issuerRef
{{- toYaml .Values.certManager.issuerRef | nindent 4 }}
{{- else }}
name: selfsigned
kind: Issuer

View file

@ -0,0 +1,181 @@
# Development values for go-guess
nameOverride: ""
fullnameOverride: ""
# Public application domain. Every tenant is served from https://<tenant>.<domain>.
domain: guess-dev.urpi.be
# Tenant slugs. Each one gets its own ingress host and TLS SAN.
tenants:
- nmbs
- ypto
imagePullSecrets: []
api:
podSecurityContext:
runAsNonRoot: true
runAsUser: 100
runAsGroup: 100
fsGroup: 100
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 100
runAsGroup: 100
replicaCount: 1
image:
repository: batty1039.startdedicated.net:5000/go-guess-api
tag: "latest"
pullPolicy: Always
environment: development
frontendURL: ""
tokenTTL: 8h
maxUploadMB: 10
service:
name: api
type: ClusterIP
port: 8080
goLoose:
enabled: true
authDomain: auth-dev.urpi.be
ca:
existingSecret: ""
key: ca.crt
mountPath: /certs/local-ca.crt
tenants:
nmbs:
clientID: ""
clientSecret: ""
ypto:
clientID: ""
clientSecret: ""
resources:
requests:
cpu: 50m
memory: 96Mi
limits:
cpu: 300m
memory: 384Mi
podAnnotations: {}
podLabels: {}
strategy:
type: Recreate
web:
replicaCount: 1
image:
repository: batty1039.startdedicated.net:5000/go-guess-web
tag: "latest"
pullPolicy: Always
service:
type: ClusterIP
port: 80
nodePort: null
resources:
requests:
cpu: 25m
memory: 32Mi
limits:
cpu: 200m
memory: 96Mi
podAnnotations: {}
podLabels: {}
podSecurityContext: {}
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
add:
- CHOWN
- SETGID
- SETUID
- NET_BIND_SERVICE
readOnlyRootFilesystem: false
runAsNonRoot: false
runAsUser: 0
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
ingress:
enabled: true
className: nginx
annotations:
nginx.ingress.kubernetes.io/proxy-body-size: 10m
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-buffering: "off"
nginx.ingress.kubernetes.io/ssl-redirect: "true"
cert-manager.io/cluster-issuer: letsencrypt-staging
path: /
pathType: Prefix
tls:
enabled: true
existingSecret: ""
secretName: ""
certManager:
enabled: true
issuerRef:
name: letsencrypt-staging
kind: ClusterIssuer
duration: 8760h
renewBefore: 720h
caSecretName: ""
secrets:
jwtSecret: ""
databaseURL: ""
rabbitmqURL: ""
postgresqlPassword: ""
rabbitmqPassword: ""
postgresql:
enabled: true
image:
repository: postgres
tag: 18-alpine
pullPolicy: IfNotPresent
auth:
username: go_guess
database: go_guess
persistence:
enabled: true
size: 2Gi
storageClass: ""
resources:
requests:
cpu: 50m
memory: 96Mi
limits:
cpu: 300m
memory: 384Mi
rabbitmq:
enabled: true
image:
repository: rabbitmq
tag: 4-alpine
pullPolicy: IfNotPresent
auth:
username: go_guess
persistence:
enabled: true
size: 2Gi
storageClass: ""
resources:
requests:
cpu: 50m
memory: 96Mi
limits:
cpu: 300m
memory: 384Mi
nodeSelector: {}
tolerations: []
affinity: {}

View file

@ -1,7 +1,7 @@
# Development profile: demo fixtures, ephemeral database and broker storage, and
# `latest` image tags. TLS still uses the chart's self-signed certificate so
# https://<tenant>.guess.local behaves like the production profile.
domain: guess.local
# `latest` image tags. Browser sign-in goes through the development identity
# domain auth-dev.urpi.be, never the production auth.urpi.be.
domain: guess-dev.urpi.be
tenants:
- nmbs
@ -14,7 +14,7 @@ api:
pullPolicy: Always
goLoose:
enabled: true
authDomain: auth.dev
authDomain: auth-dev.urpi.be
resources:
requests:
cpu: 50m

View file

@ -0,0 +1,184 @@
# Production values for go-guess
nameOverride: ""
fullnameOverride: ""
# Public application domain. Every tenant is served from https://<tenant>.<domain>.
domain: guess.urpi.be
# Tenant slugs. Each one gets its own ingress host and TLS SAN.
tenants:
- nmbs
- ypto
imagePullSecrets: []
api:
podSecurityContext:
runAsNonRoot: true
runAsUser: 100
runAsGroup: 100
fsGroup: 100
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 100
runAsGroup: 100
replicaCount: 2
image:
repository: batty1039.startdedicated.net:5000/go-guess-api
tag: "latest"
pullPolicy: Always
environment: production
frontendURL: ""
tokenTTL: 8h
maxUploadMB: 10
service:
name: api
type: ClusterIP
port: 8080
goLoose:
enabled: true
authDomain: auth.urpi.be
ca:
existingSecret: ""
key: ca.crt
mountPath: /certs/local-ca.crt
tenants:
nmbs:
clientID: ""
clientSecret: ""
ypto:
clientID: ""
clientSecret: ""
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 1000m
memory: 1Gi
podAnnotations: {}
podLabels: {}
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
web:
replicaCount: 2
image:
repository: batty1039.startdedicated.net:5000/go-guess-web
tag: "latest"
pullPolicy: Always
service:
type: ClusterIP
port: 80
nodePort: null
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 500m
memory: 256Mi
podAnnotations: {}
podLabels: {}
podSecurityContext: {}
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
add:
- CHOWN
- SETGID
- SETUID
- NET_BIND_SERVICE
readOnlyRootFilesystem: false
runAsNonRoot: false
runAsUser: 0
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
ingress:
enabled: true
className: nginx
annotations:
nginx.ingress.kubernetes.io/proxy-body-size: 10m
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-buffering: "off"
nginx.ingress.kubernetes.io/ssl-redirect: "true"
cert-manager.io/cluster-issuer: letsencrypt-prod
path: /
pathType: Prefix
tls:
enabled: true
existingSecret: ""
secretName: ""
certManager:
enabled: true
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
duration: 8760h
renewBefore: 720h
caSecretName: ""
secrets:
jwtSecret: ""
databaseURL: ""
rabbitmqURL: ""
postgresqlPassword: ""
rabbitmqPassword: ""
postgresql:
enabled: true
image:
repository: postgres
tag: 18-alpine
pullPolicy: IfNotPresent
auth:
username: go_guess
database: go_guess
persistence:
enabled: true
size: 20Gi
storageClass: ""
resources:
requests:
cpu: 200m
memory: 512Mi
limits:
cpu: 1000m
memory: 2Gi
rabbitmq:
enabled: true
image:
repository: rabbitmq
tag: 4-alpine
pullPolicy: IfNotPresent
auth:
username: go_guess
persistence:
enabled: true
size: 10Gi
storageClass: ""
resources:
requests:
cpu: 200m
memory: 512Mi
limits:
cpu: 1000m
memory: 2Gi
nodeSelector: {}
tolerations: []
affinity: {}

View file

@ -1,6 +1,6 @@
# Production profile: production fixtures, persistent volumes, released image
# tags, and self-signed certificates served for https://<tenant>.guess.local.
domain: guess.local
# tags, and self-signed certificates served for https://<tenant>.guess.urpi.be.
domain: guess.urpi.be
tenants:
- nmbs

View file

@ -2,7 +2,7 @@ nameOverride: ""
fullnameOverride: ""
# Public application domain. Every tenant is served from https://<tenant>.<domain>.
domain: guess.local
domain: guess.urpi.be
# Tenant slugs. Each one gets its own ingress host and TLS SAN.
tenants:
@ -12,6 +12,17 @@ tenants:
imagePullSecrets: []
api:
podSecurityContext:
runAsNonRoot: true
runAsUser: 100
runAsGroup: 100
fsGroup: 100
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 100
runAsGroup: 100
replicaCount: 1
image:
repository: batty1039.startdedicated.net:5000/go-guess-api
@ -30,8 +41,8 @@ api:
type: ClusterIP
port: 8080
goLoose:
enabled: false
authDomain: auth.dev
enabled: true
authDomain: auth.urpi.be
ca:
# Mount an internal CA bundle and point GO_LOOSE_CA_FILE at it.
existingSecret: ""
@ -54,18 +65,6 @@ api:
memory: 512Mi
podAnnotations: {}
podLabels: {}
# The image runs as its own "app" user (uid 100), so no uid is forced here:
# runAsNonRoot then validates whatever the image declares.
podSecurityContext: {}
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
readOnlyRootFilesystem: true
runAsNonRoot: true
# The API container applies migrations and seeds before serving, so old pods must
# be gone before the new one starts.
strategy:
type: Recreate

View file

@ -55,8 +55,9 @@ curl --fail https://goguess.urpi.be/api/health
The `go-guess` service should show `80:31374/TCP`. The PostgreSQL and RabbitMQ
services should remain `ClusterIP` services without NodePorts.
Protected interviewer routes also call Go Loose from the application pod, using
`app.goLooseBaseURL` (default `https://%s.auth.dev`, where `%s` is the
request tenant). That address must be reachable from the cluster. The browser
forwards browser authentication and API requests without adding application
credentials. Nginx does not add or strip authorization headers.
Browser sign-in also calls Go Loose from the application pod, using
`GO_LOOSE_AUTH_DOMAIN` (`auth-dev.urpi.be` outside production, `auth.urpi.be` in
production) as `https://<tenant>.<GO_LOOSE_AUTH_DOMAIN>`. That address must be
reachable from the pod. The browser forwards browser authentication and API
requests without adding application credentials. Nginx does not add or strip
authorization headers.