feat(10-01): serve the embedded admin SPA at backend.uri with cookie login

- backend.uri prefix (default /backend) mounts the admin API at {prefix}/api/v1
  and the embedded SPA shell at {prefix} with an api/ JSON 404 fallback
- cookie transport: an X-Requested-With login sets the HttpOnly summer_admin
  cookie and returns no token; the backend guard reads the cookie after Bearer
- CSRF wrapper refuses cookie-only POST/PUT/DELETE without X-Requested-With
- boardwalk package embeds boardwalk/dist, rewrites index.html once per prefix
  and sets cache and security headers
- framework admin OpenAPI pipeline (swag, swagger2openapi, openapi-typescript)
  with prefix-relative paths and typed envelopes for the tracer routes
- admin/ Vite SPA: login, plugin rail, section panel and read-only list
  through the openapi-fetch client typed by the generated schema
This commit is contained in:
Jakub Zych
2026-09-27 15:21:48 +02:00
parent 8c3e131111
commit 5f9353841b
79 changed files with 10745 additions and 147 deletions

View File

@@ -172,12 +172,39 @@ func (s *service) login(w http.ResponseWriter, r *http.Request) {
return
}
s.logAuth(r, "success", user.ID)
if isAjax(r) {
// Cookie transport (D-19): the SPA never sees the token.
s.writeSessionCookie(w, token)
WriteData(w, http.StatusOK, cookieLoginData(s.ttl), map[string]any{})
return
}
WriteData(w, http.StatusOK, map[string]string{
"access_token": token,
"token_type": "bearer",
}, map[string]any{})
}
// cookieLoginData is the login/refresh body under cookie transport: no token,
// only its type and the access lifetime in seconds.
func cookieLoginData(ttl time.Duration) AdminLoginData {
return AdminLoginData{TokenType: "cookie", ExpiresIn: int(ttl / time.Second)}
}
// writeSessionCookie sets the admin JWT cookie scoped to the admin prefix.
// Max-Age is the refresh window, because refresh accepts an expired access
// token until iat plus refresh_ttl.
func (s *service) writeSessionCookie(w http.ResponseWriter, token string) {
http.SetCookie(w, &http.Cookie{
Name: AdminCookieName,
Value: token,
Path: s.adminPrefix(),
MaxAge: int(s.refreshTTL / time.Second),
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
}
func (s *service) refresh(w http.ResponseWriter, r *http.Request) {
raw := bearerToken(r)
if raw == "" {
@@ -247,23 +274,19 @@ func (s *service) me(w http.ResponseWriter, r *http.Request) {
WriteData(w, http.StatusOK, profileOf(user), map[string]any{})
}
func profileOf(user BackendUser) map[string]any {
data := map[string]any{
"id": user.ID,
"login": user.Login,
"email": user.Email,
"first_name": user.FirstName,
"last_name": user.LastName,
"is_superuser": user.IsSuperuser,
func profileOf(user BackendUser) AdminProfile {
profile := AdminProfile{
ID: user.ID,
Login: user.Login,
Email: user.Email,
FirstName: user.FirstName,
LastName: user.LastName,
IsSuperuser: user.IsSuperuser,
}
if user.Role.ID != 0 {
data["role"] = map[string]any{
"id": user.Role.ID,
"code": user.Role.Code,
"name": user.Role.Name,
}
profile.Role = &AdminRoleSummary{ID: user.Role.ID, Code: user.Role.Code, Name: user.Role.Name}
}
return data
return profile
}
func bearerToken(r *http.Request) string {
@@ -402,15 +425,18 @@ func adminLoginWindow(app *backpack.App) (int, int) {
return maxAttempts, decayMinutes
}
func adminIssuer(app *backpack.App) string {
// adminIssuer is app.url plus the admin API login path. JWT verification does
// not check iss, so tokens minted under an earlier prefix stay valid until
// they expire.
func adminIssuer(app *backpack.App, prefix string) string {
base := ""
if app != nil && app.Config != nil {
base = strings.TrimRight(strings.TrimSpace(app.Config.String("app.url")), "/")
}
if base == "" {
return "/_admin/api/v1/auth/login"
if prefix == "" {
prefix = DefaultAdminPrefix
}
return base + "/_admin/api/v1/auth/login"
return base + prefix + adminAPIVersion + "/auth/login"
}
// dummyPasswordHash keeps a missing-user login on the bcrypt path.