Feat v0.6.9 cookie fix roadmap #44

Merged
xcaliber merged 3 commits from feat/v0.6.9-cookie-fix-roadmap into main 2026-04-01 09:41:59 +00:00
20 changed files with 866 additions and 42 deletions

View File

@@ -2,6 +2,62 @@
All notable changes to Armature are documented here.
## v0.6.9 — Session Lifetime Config
Admin-configurable session durations, "keep me logged in" opt-in, and
optional idle timeout. Completes the auth hardening started in v0.6.8.
### Added
- **Admin session settings**: `session.access_token_ttl` (default 15m,
clamp 5m60m) and `session.refresh_token_ttl` (default 7d, clamp
1h90d) stored in `global_settings`. New `LoadSessionConfig()` helper
reads and clamps values with `parseDurationString()` supporting `m`,
`h`, `d` suffixes.
- **"Keep me logged in" checkbox**: Login form opt-in. Checked = full
`refresh_token_ttl`. Unchecked = capped at 24h. Cookie `max-age`
tracks whichever lifetime was chosen. `keep_login` flag stored on
refresh token row.
- **Config-driven token generation**: `generateTokens()` reads TTLs from
`global_settings` instead of hardcoded `15*time.Minute` /
`7*24*time.Hour`. Response includes `expires_in` and
`refresh_expires_in` (seconds) so the client schedules refresh
correctly.
- **Idle timeout (optional)**: Admin-toggleable (default off). Server
checks `last_activity_at` on refresh-token row; rejects if gap exceeds
`session.idle_timeout`. Client SDK pings `POST /api/v1/auth/activity`
on click/keydown (debounced, max 1/min).
- **Admin Settings > Session section**: Dropdowns for access TTL, refresh
TTL, and idle timeout with toggle.
- **7 new tests**: Duration parsing, clamping (low/high), defaults,
invalid values, empty idle timeout.
### Changed
- `CreateRefreshToken` store method now accepts `keepLogin bool`
parameter; both Postgres and SQLite implementations updated.
- `GetRefreshTokenInfo` returns `RefreshTokenInfo` struct with
`UserID`, `KeepLogin`, `LastActivityAt` for idle-timeout decisions.
- SDK `auth.login()` accepts optional third `keepLogin` parameter;
cookie max-age derived from server `refresh_expires_in` response.
- SDK boots activity tracking after successful auth boot.
## v0.6.8 — Cookie Fix + UI Hardening Roadmap
### Fixed
- **Session cookie max-age bug**: `arm_token` cookie was set to 15 min
(matching access token) while refresh token lasted 7 days. Cookie now
matches refresh token lifetime so Go SSR middleware can serve page
shells while JS refreshes the access token client-side.
### Added
- **ROADMAP-UI.md**: Detailed UI hardening roadmap (v0.6.9v0.6.15)
covering session config, viewport foundation, CSS deduplication,
extension CSS isolation, responsive layout, visual polish, and
automated usability survey gate.
## v0.6.7 — Native mTLS
End-to-end mutual TLS without a reverse proxy. Targets systemd+podman

161
ROADMAP-UI.md Normal file
View File

@@ -0,0 +1,161 @@
# Armature — v0.6.x UI Hardening Roadmap
> **Goal**: Fix all viewport scaling, banner integration, and styling inconsistencies
> so Claude Code can run a meaningful automated usability survey against a
> stable, uniform UI.
---
## Problem Inventory
### P1 — Viewport & Scaling (blocks everything)
| # | Issue | Where | Impact |
|---|-------|-------|--------|
| 1 | **Dual layout systems**`base.html` template has its own banner→surface→footer column; `app-shell.js` Preact shell has a separate `sw-shell``sw-shell__body``sw-shell__surface` column. They never coordinate. | `server/pages/templates/base.html``src/js/sw/shell/app-shell.js` | Every surface inherits an ambiguous viewport ancestor. |
| 2 | **Transform-based scaling is broken**`transform: scale()` on `#surfaceInner` doesn't reflow layout. Breaks `getBoundingClientRect` (menu.js already carries a scale-correction hack at line 20), scroll containment, pointer events, and the inverse-width/height hack (`100/s%`) doesn't account for banners consuming viewport space. | `base.html:56-67`, `appearance.js:41-53`, `menu.js:20-33` | Menus, tooltips, dropdowns all mis-positioned at any non-100% scale. Click targets wrong. |
| 3 | **Banner height desync**`base.html` hardcodes `--banner-h: 28px` and renders banners in-flow. `app-shell.js` ShellBanner measures `offsetHeight` into `--banner-top-height` / `--banner-bottom-height` and uses `position: fixed` + body padding. Two independent banner systems. | `base.html:31,72-75,103-107``app-shell.js:25-42`, `sw-shell.css:22-37,63-69` | With banners enabled, content overflows by the banner height or gets double-inset depending on which code path is active. |
| 4 | **`100vh` on mobile** — `.sw-shell`, `.chat-app`, `.chat-loading`, `.login-shell` use `100vh` which doesn't subtract mobile browser chrome (address bar, toolbar). | `sw-shell.css:9`, `chat/css/main.css:15,25`, `sw-login.css:7` | Content overflows on iOS Safari, Android Chrome. |
| 5 | **Chat uses `100vh` not `100%`**`.chat-app { height: 100vh }` ignores parent container (which already excludes banners/footer). Notes correctly uses `height: 100%`. Every extension surface that copies chat's pattern will inherit the bug. | `packages/chat/css/main.css:15` | Chat surface overflows behind banners. |
### P2 — CSS Architecture
| # | Issue | Where | Impact |
|---|-------|-------|--------|
| 6 | **Class name collisions**`.settings-section` defined in both `modals.css:41-43` and `surfaces.css:75-83` with different padding, margin, and border-bottom rules. `.sw-dropdown` in `primitives.css:443` (styled `<select>`) vs `sw-primitives.css:262` (custom dropdown component). `.sw-tabs` in `primitives.css:459` vs `sw-primitives.css:234`. | Kernel CSS | Styles randomly win depending on load order. Bug reports differ by surface. |
| 7 | **Old/new primitive duplication** — Two button systems (`.btn-primary`/`.btn-small` in `primitives.css` vs `.sw-btn--primary`/`.sw-btn--sm` in `sw-primitives.css`). Two toast systems (`.toast` vs `.sw-toast`). Two dropdown, two tabs implementations. | `primitives.css``sw-primitives.css` | No single source of truth for any component. Extension authors can't know which to use. |
| 8 | **Extension CSS bleeds globally** — Extension `main.css` loaded via `<link>` at document level, no scoping. Any class name can override kernel or sibling extension styles. | `base.html:26-28`, `extension-surface.css` | Package CSS fights with kernel. Two packages with `.sidebar` both lose. |
| 9 | **No intermediate breakpoint** — Only `768px` mobile breakpoint. No tablet (7681024px). Secondary workspace pane hardcoded `480px`. Admin nav hardcoded `200px`. | `layout.css:263`, `surfaces.css:305` | Cramped layout on iPad/small laptops. |
### P3 — Visual Consistency
| # | Issue | Where | Impact |
|---|-------|-------|--------|
| 10 | **Mixed unit systems**`px`, `rem`, `em` used interchangeably with no rationale. Some font-sizes in `px` (primitives), others in `rem` (sw-primitives), others in `em` (user-picker). | All CSS files | Zoom/scale behaves differently per element. |
| 11 | **Google Fonts CDN dependency**`@import url(...)` in `variables.css:5` blocks rendering if CDN is slow; breaks entirely on air-gapped deployments. | `variables.css:5` | FOUT or blank page on slow networks. No offline support. |
| 12 | **No spacing scale** — Padding/margins are ad-hoc values (10px, 12px, 14px, 16px, 20px, 24px, 28px…). No design-token spacing scale. | All CSS | Inconsistent visual rhythm across surfaces. |
| 13 | **Hardcoded fallback colors in component CSS**`sw-shell.css`, `sw-primitives.css` have inline fallbacks like `var(--accent, #b38a4e)` — a gold color from a previous theme that doesn't match the current blue `#6c9fff`. | `sw-shell.css`, `sw-primitives.css` | Wrong colors flash briefly if variables load late. |
---
## Roadmap
### v0.6.9 — Session Lifetime Config ✅
Shipped. Admin-configurable TTLs, "keep me logged in" checkbox, idle timeout,
config-driven `generateTokens()`, 7 new tests. See CHANGELOG.md for details.
### v0.6.10 — Viewport Foundation
Establish a single, correct layout model. Every surface must render inside one
containment chain: `body → shell → surface`. No dual systems. No transform hacks.
| Step | Description |
|------|-------------|
| Unify shell layout | Remove the inline `<style>` block from `base.html` that creates a parallel column layout. Make `base.html` render a single `<div class="sw-shell">` wrapper. Banners, announcements, footer, and surface all live inside the Preact shell's flex column OR the template column — pick one, kill the other. **Recommendation**: keep the Go template column (it works without JS) and delete `sw-shell.css`'s parallel layout. The Preact `AppShell` becomes a component that renders *inside* `#surfaceInner`, not a viewport-level container. |
| Banner single source of truth | One banner rendering path. Template banners (`base.html:72-107`) set `--banner-top-height` and `--banner-bottom-height` via a `<script>` that measures after DOMContentLoaded. Remove `ShellBanner`'s JS measurement. Surfaces use `calc(100vh - var(--banner-top-height) - var(--banner-bottom-height))` or pure flex containment — not both. |
| Replace transform-scale with CSS `zoom` | CSS `zoom` is now supported in all evergreen browsers (including Firefox 126+, June 2024). `zoom` actually reflows layout — `getBoundingClientRect` returns correct values, scroll containers work, pointer events are accurate. Remove all `transform: scale()` code from `base.html`, `appearance.js`, `menu.js` scale-correction hacks. Apply `zoom` on `#surfaceInner`. The `sw.shell.getScale()` API becomes a no-op returning 1 (or reads `getComputedStyle().zoom`). |
| Fix `100vh``100dvh` | Replace all `height: 100vh` with `height: 100dvh` (fallback: `height: 100vh; height: 100dvh;`). Affects: `sw-shell.css`, `sw-login.css`, `chat/css/main.css`. |
| Chat `100vh``100%` | `.chat-app` and `.chat-loading` use `height: 100%` like Notes. They inherit their height from the extension mount container which is already correctly sized. |
| Validation | Manual test matrix: banner on/off × scale 80%/100%/150% × mobile/desktop × light/dark. Every surface (admin, settings, team-admin, docs, welcome, notes, chat). No overflow, no gaps, menus positioned correctly. |
### v0.6.11 — CSS Deduplication
Kill the old primitive system. One class per concept.
| Step | Description |
|------|-------------|
| Audit collision inventory | Script that finds all duplicate class selectors across kernel CSS files. Produce a machine-readable collision report (JSON). |
| Migrate `.settings-section` | Remove `modals.css:41-43` definition. The `surfaces.css` definition is authoritative. Modals that used the old styles get explicit overrides scoped to `.modal .settings-section`. |
| Retire `primitives.css` old components | `.btn-primary`, `.btn-small`, `.btn-danger`, `.btn-full` → migrate all usages to `.sw-btn--*`. `.toast-container` / `.toast` → migrate to `.sw-toast-container` / `.sw-toast`. Old `.popup-menu` → migrate to `.sw-menu`. Old `.sw-dropdown` (styled select in primitives.css) → rename to `.sw-native-select` or delete if unused. Old `.sw-tabs` / `.sw-tab-btn` in primitives.css → delete (sw-primitives.css version is authoritative). |
| Mark deprecated classes | Any remaining old classes get a `/* DEPRECATED v0.6.11 — use .sw-btn--* */` comment and a 1-version grace period for package authors. |
| Package CSS audit | Scan all `packages/*/css/main.css` for references to deprecated kernel classes. Fix in-tree packages. |
### v0.6.12 — Extension CSS Isolation
Prevent extension CSS from leaking into the kernel or sibling extensions.
| Step | Description |
|------|-------------|
| Scoping strategy | Two options: (A) `@scope (.extension-mount[data-ext="slug"])` — CSS `@scope` is supported in Chrome 118+, Firefox 128+, Safari 17.4+. (B) Prefix enforcement — package CSS linter rejects selectors that don't start with `.ext-{slug}` or `[data-ext="{slug}"]`. **Recommendation**: (B) prefix enforcement. `@scope` support is still patchy; prefix enforcement works everywhere and is trivially lintable. |
| Linter | `scripts/lint-package-css.sh` — shell script using `grep`/`awk`. Runs in CI. Accepts `.ext-{slug}` or `[data-ext="{slug}"]` prefixed selectors only. Exempts `:root`, `@keyframes`, `@font-face`, and `@media` wrappers. |
| Kernel CSS contract | Document which kernel classes are stable public API for extensions to reference (`.sw-btn--*`, `.sw-input`, `.sw-field`, `.sw-dialog`, `.sw-toast`, `.sw-menu`, `.sw-tabs`, CSS variables). Everything else is internal. Publish in `docs/EXTENSION-CSS.md`. |
| Migrate in-tree packages | Add `data-ext` attribute to extension mount container. Prefix all in-tree package CSS. Verify no visual regressions. |
### v0.6.13 — Responsive & Spacing
Fill the gaps between mobile and desktop.
| Step | Description |
|------|-------------|
| Tablet breakpoint | Add `@media (min-width: 769px) and (max-width: 1024px)` rules. Secondary workspace pane: `360px` (from `480px`). Admin nav collapses to icon-only at this breakpoint. Settings nav: `180px`. |
| Spacing scale | Define spacing tokens in `variables.css`: `--space-1: 4px` through `--space-8: 64px` (4px base, doubling). Migrate hardcoded padding/margin values to tokens. Priority: surfaces that users see on every session (sidebar, topbar, admin content areas). |
| Font-size scale | Define `--text-xs: 0.75rem` through `--text-xl: 1.25rem` (5 stops). Migrate all font-size declarations. Remove `em`-based sizes (user-picker) and bare `px` sizes. |
| Secondary pane responsive | `workspace-secondary.open` width uses `clamp(320px, 35vw, 560px)` instead of fixed `480px`. |
### v0.6.14 — Visual Polish
Systematic cleanup of stale values and rendering artifacts.
| Step | Description |
|------|-------------|
| Purge stale fallback colors | Remove all `var(--foo, #hexvalue)` fallbacks that reference old gold theme colors (`#b38a4e` and variants). If a CSS variable is undefined at this point, that's a bug — don't mask it with a wrong fallback. |
| Self-host fonts | Bundle DM Sans and JetBrains Mono woff2 files in `src/fonts/`. Replace Google Fonts `@import` with local `@font-face` declarations. Zero external font dependencies. |
| Light theme audit | Walk every surface in light mode. Fix any remaining hardcoded dark-mode colors, contrast issues, or invisible borders. Document any surface-specific light-mode overrides. |
| Consistent border-radius | Surfaces currently mix `6px`, `8px`, `10px`, `var(--radius)`, `var(--radius-lg)`. Audit and reduce to 3 values: `--radius-sm: 4px`, `--radius: 8px`, `--radius-lg: 12px`. |
### v0.6.15 — Usability Survey Gate
Make the UI machine-auditable so Claude Code can run an automated survey.
| Step | Description |
|------|-------------|
| UI inventory manifest | `scripts/generate-ui-inventory.sh` — walks kernel CSS + package CSS, extracts every rendered class, groups by surface, outputs `ui-inventory.json`. Fields: `{surface, component, classes, file, line, responsive_breakpoints, spacing_tokens_used, font_size_tokens_used}`. |
| Contrast checker | Script that parses `variables.css` light/dark token pairs and checks WCAG AA contrast ratios for all `text-*` on `bg-*` combinations. Outputs pass/fail report. |
| Component coverage matrix | Markdown table: each kernel primitive (button, input, dropdown, dialog, toast, menu, tabs, avatar, spinner, tooltip, drawer, banner) × each surface that uses it. Identifies surfaces still using deprecated old-style components. |
| Touch target audit | Script that finds all `<button>`, `[role=button]`, clickable elements across templates and JS, checks for `min-width/min-height ≥ 44px` on mobile breakpoint. |
| Claude Code survey prompt | `docs/USABILITY-SURVEY.md` — structured prompt template that Claude Code can execute against the codebase. Sections: viewport correctness, banner integration, responsive behavior, styling consistency, accessibility basics (contrast, touch targets, focus indicators), component uniformity. Each section has pass/fail criteria and file paths to inspect. |
| Survey dry-run | Run the survey. Fix anything it catches. Tag `v0.6.15` only after clean survey. |
---
## Sequencing Rationale
```
v0.6.9 Session Lifetime Config ✅ SHIPPED
v0.6.10 Viewport Foundation ← Everything depends on correct containment
v0.6.11 CSS Deduplication ← Can't audit styling until there's one source of truth
v0.6.12 Extension CSS Isolation ← Scoping requires the kernel CSS to be stable first
v0.6.13 Responsive & Spacing ← Spacing tokens need stable class names to attach to
v0.6.14 Visual Polish ← Final visual pass after structure is locked
v0.6.15 Usability Survey Gate ← Machine-audit the finished product
```
Each version is independently shippable and testable. No version depends on
anything after it.
---
## What "Usability Survey by Code" Means
The v0.6.15 deliverables give Claude Code (or any automated tool) three things:
1. **A structured inventory** (`ui-inventory.json`) of every component, on every
surface, with its CSS file and line number.
2. **Automated checks** (contrast, touch targets, token usage) that produce
machine-readable pass/fail reports.
3. **A survey prompt** (`USABILITY-SURVEY.md`) with explicit criteria and file
paths, so Claude Code can `cat` the relevant files, run the scripts, and
produce a scored report without human guidance.
The survey is not a substitute for real user testing — it's a structural
quality gate that catches the class of bugs (overflow, contrast, misalignment,
inconsistent styling) that have historically slipped through.

View File

@@ -1 +1 @@
0.6.7
0.6.9

View File

@@ -24,13 +24,17 @@ func (p *BuiltinProvider) SupportsRegistration() bool { return true }
func (p *BuiltinProvider) Authenticate(c *gin.Context, stores store.Stores) (*Result, error) {
var req struct {
Login string `json:"login" binding:"required"`
Password string `json:"password" binding:"required"`
Login string `json:"login" binding:"required"`
Password string `json:"password" binding:"required"`
KeepLogin bool `json:"keep_login"`
}
if err := c.ShouldBindJSON(&req); err != nil {
return nil, err
}
// Stash keep_login in context for generateTokens
c.Set("keep_login", req.KeepLogin)
user, err := stores.Users.GetByLogin(c.Request.Context(), req.Login)
if err != nil {
return nil, ErrInvalidCreds

View File

@@ -56,9 +56,11 @@ CREATE TABLE IF NOT EXISTS refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
revoked_at TIMESTAMPTZ
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
revoked_at TIMESTAMPTZ,
keep_login BOOLEAN DEFAULT FALSE,
last_activity_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);

View File

@@ -41,9 +41,11 @@ CREATE TABLE IF NOT EXISTS refresh_tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
revoked_at TEXT
expires_at TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
revoked_at TEXT,
keep_login INTEGER DEFAULT 0,
last_activity_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);

View File

@@ -72,7 +72,7 @@ func (h *AuthHandler) Register(c *gin.Context) {
return
}
tokens, err := h.generateTokens(result.User)
tokens, err := h.generateTokens(result.User, false)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
return
@@ -94,7 +94,13 @@ func (h *AuthHandler) Login(c *gin.Context) {
h.stores.Users.UpdateLastLogin(c.Request.Context(), result.User.ID)
tokens, err := h.generateTokens(result.User)
// Check for keep_login flag from request body.
// The provider already consumed the body, so we read from gin context
// if the provider stashed it there; otherwise default to false.
keepLogin, _ := c.Get("keep_login")
keep, _ := keepLogin.(bool)
tokens, err := h.generateTokens(result.User, keep)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
return
@@ -113,22 +119,32 @@ func (h *AuthHandler) Refresh(c *gin.Context) {
}
tokenHash := hashToken(req.RefreshToken)
userID, err := h.stores.Users.GetRefreshToken(c.Request.Context(), tokenHash)
info, err := h.stores.Users.GetRefreshTokenInfo(c.Request.Context(), tokenHash)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid refresh token"})
return
}
// Idle timeout check
sc := LoadSessionConfig(c.Request.Context(), h.stores.GlobalConfig)
if sc.IdleTimeout > 0 && info.LastActivityAt != nil {
if time.Since(*info.LastActivityAt) > sc.IdleTimeout {
h.stores.Users.RevokeRefreshToken(c.Request.Context(), tokenHash)
c.JSON(http.StatusUnauthorized, gin.H{"error": "session expired due to inactivity"})
return
}
}
// Revoke the used token (rotate)
h.stores.Users.RevokeRefreshToken(c.Request.Context(), tokenHash)
user, err := h.stores.Users.GetByID(c.Request.Context(), userID)
user, err := h.stores.Users.GetByID(c.Request.Context(), info.UserID)
if err != nil || !user.IsActive {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found or inactive"})
return
}
tokens, err := h.generateTokens(user)
tokens, err := h.generateTokens(user, info.KeepLogin)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
return
@@ -159,6 +175,19 @@ func (h *AuthHandler) Logout(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "logged out"})
}
// Activity updates last_activity_at on the caller's active refresh tokens.
// Called by the client SDK on user interaction (debounced, max 1/min).
// POST /api/v1/auth/activity (requires auth middleware)
func (h *AuthHandler) Activity(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
return
}
h.stores.Users.UpdateRefreshTokenActivity(c.Request.Context(), userID.(string))
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// ── OIDC Flow ──────────────────────────────
// OIDCLogin initiates the authorization code flow by redirecting to the IdP.
@@ -271,7 +300,7 @@ func (h *AuthHandler) OIDCCallback(c *gin.Context) {
}
// Issue internal JWT
tokens, err := h.generateTokens(result.User)
tokens, err := h.generateTokens(result.User, false)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
return
@@ -286,8 +315,14 @@ func (h *AuthHandler) OIDCCallback(c *gin.Context) {
refreshToken, _ := tokens["refresh_token"].(string)
userJSON, _ := json.Marshal(tokens["user"])
// Set page-auth cookie too (for SSR middleware)
c.SetCookie("arm_token", accessToken, 900, "/", "", false, false)
// Set page-auth cookie too (for SSR middleware).
// MaxAge matches refresh token lifetime so the cookie
// survives until JS can proactively refresh the access token.
refreshExpiresIn, _ := tokens["refresh_expires_in"].(int)
if refreshExpiresIn <= 0 {
refreshExpiresIn = 604800
}
c.SetCookie("arm_token", accessToken, refreshExpiresIn, "/", "", false, false)
// Base64-encode the token payload for the fragment
payload := fmt.Sprintf(`{"access_token":"%s","refresh_token":"%s","user":%s}`,
@@ -298,13 +333,15 @@ func (h *AuthHandler) OIDCCallback(c *gin.Context) {
c.Redirect(http.StatusFound, dest)
}
func (h *AuthHandler) generateTokens(user *models.User) (gin.H, error) {
// Access token (15 min)
func (h *AuthHandler) generateTokens(user *models.User, keepLogin bool) (gin.H, error) {
sc := LoadSessionConfig(context.Background(), h.stores.GlobalConfig)
// Access token
accessClaims := Claims{
UserID: user.ID,
Email: user.Email,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(sc.AccessTokenTTL)),
IssuedAt: jwt.NewNumericDate(time.Now()),
ID: uuid.New().String(),
},
@@ -315,20 +352,26 @@ func (h *AuthHandler) generateTokens(user *models.User) (gin.H, error) {
return nil, err
}
// Refresh token (7 days)
// Refresh token — keepLogin=false caps at 24h (session-length)
refreshTTL := sc.RefreshTokenTTL
if !keepLogin && refreshTTL > 24*time.Hour {
refreshTTL = 24 * time.Hour
}
refreshRaw := uuid.New().String()
refreshHash := hashToken(refreshRaw)
expiresAt := time.Now().Add(7 * 24 * time.Hour)
expiresAt := time.Now().Add(refreshTTL)
if err := h.stores.Users.CreateRefreshToken(context.Background(), user.ID, refreshHash, expiresAt); err != nil {
if err := h.stores.Users.CreateRefreshToken(context.Background(), user.ID, refreshHash, expiresAt, keepLogin); err != nil {
log.Printf("warn: failed to store refresh token: %v", err)
}
return gin.H{
"access_token": accessString,
"refresh_token": refreshRaw,
"token_type": "Bearer",
"expires_in": 900,
"access_token": accessString,
"refresh_token": refreshRaw,
"token_type": "Bearer",
"expires_in": int(sc.AccessTokenTTL.Seconds()),
"refresh_expires_in": int(refreshTTL.Seconds()),
"user": gin.H{
"id": user.ID,
"username": user.Username,

View File

@@ -0,0 +1,103 @@
package handlers
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"armature/store"
)
// SessionConfig holds parsed, clamped session TTL settings.
type SessionConfig struct {
AccessTokenTTL time.Duration
RefreshTokenTTL time.Duration
IdleTimeout time.Duration // 0 = disabled
}
// Defaults & clamp bounds
const (
defaultAccessTTL = 15 * time.Minute
defaultRefreshTTL = 7 * 24 * time.Hour // 7d
defaultIdleTimeout = 0 // disabled
minAccessTTL = 5 * time.Minute
maxAccessTTL = 60 * time.Minute
minRefreshTTL = 1 * time.Hour
maxRefreshTTL = 90 * 24 * time.Hour // 90d
minIdleTTL = 5 * time.Minute
maxIdleTTL = 24 * time.Hour
)
// LoadSessionConfig reads session settings from the global_settings table.
// Returns defaults if the key is missing or values are unparseable.
func LoadSessionConfig(ctx context.Context, gc store.GlobalConfigStore) SessionConfig {
sc := SessionConfig{
AccessTokenTTL: defaultAccessTTL,
RefreshTokenTTL: defaultRefreshTTL,
IdleTimeout: defaultIdleTimeout,
}
raw, err := gc.Get(ctx, "session")
if err != nil || raw == nil {
return sc
}
if v, ok := raw["access_token_ttl"].(string); ok && v != "" {
if d, err := parseDurationString(v); err == nil {
sc.AccessTokenTTL = clamp(d, minAccessTTL, maxAccessTTL)
}
}
if v, ok := raw["refresh_token_ttl"].(string); ok && v != "" {
if d, err := parseDurationString(v); err == nil {
sc.RefreshTokenTTL = clamp(d, minRefreshTTL, maxRefreshTTL)
}
}
if v, ok := raw["idle_timeout"].(string); ok && v != "" {
if d, err := parseDurationString(v); err == nil {
sc.IdleTimeout = clamp(d, minIdleTTL, maxIdleTTL)
}
}
return sc
}
// parseDurationString parses strings like "15m", "2h", "7d", "30d".
// Supports suffixes: s (seconds), m (minutes), h (hours), d (days).
func parseDurationString(s string) (time.Duration, error) {
s = strings.TrimSpace(s)
if s == "" {
return 0, fmt.Errorf("empty duration")
}
// Try Go's standard parser first (handles "15m", "2h30m", etc.)
if d, err := time.ParseDuration(s); err == nil {
return d, nil
}
// Handle "Xd" suffix (days)
if strings.HasSuffix(s, "d") {
numStr := strings.TrimSuffix(s, "d")
n, err := strconv.ParseFloat(numStr, 64)
if err != nil {
return 0, fmt.Errorf("invalid duration: %s", s)
}
return time.Duration(n * float64(24*time.Hour)), nil
}
return 0, fmt.Errorf("invalid duration: %s", s)
}
func clamp(d, min, max time.Duration) time.Duration {
if d < min {
return min
}
if d > max {
return max
}
return d
}

View File

@@ -0,0 +1,188 @@
package handlers
import (
"context"
"testing"
"time"
"armature/models"
"armature/store"
)
// mockGlobalConfig implements store.GlobalConfigStore for tests.
type mockGlobalConfig struct {
data map[string]models.JSONMap
}
func (m *mockGlobalConfig) Get(_ context.Context, key string) (models.JSONMap, error) {
if v, ok := m.data[key]; ok {
return v, nil
}
return nil, nil
}
func (m *mockGlobalConfig) Set(_ context.Context, _ string, _ models.JSONMap, _ string) error {
return nil
}
func (m *mockGlobalConfig) GetAll(_ context.Context) (map[string]models.JSONMap, error) {
return m.data, nil
}
func (m *mockGlobalConfig) SaveOIDCState(_ context.Context, _, _, _ string) error { return nil }
func (m *mockGlobalConfig) ConsumeOIDCState(_ context.Context, _ string) (string, string, error) {
return "", "", nil
}
func (m *mockGlobalConfig) CleanupOIDCState(_ context.Context) error { return nil }
func (m *mockGlobalConfig) GetString(_ context.Context, _ string) (string, error) { return "", nil }
func newMockGC(session models.JSONMap) store.GlobalConfigStore {
return &mockGlobalConfig{data: map[string]models.JSONMap{"session": session}}
}
// ── parseDurationString tests ──────────────
func TestParseDurationString(t *testing.T) {
tests := []struct {
input string
want time.Duration
err bool
}{
{"15m", 15 * time.Minute, false},
{"2h", 2 * time.Hour, false},
{"7d", 7 * 24 * time.Hour, false},
{"90d", 90 * 24 * time.Hour, false},
{"30m", 30 * time.Minute, false},
{"1h30m", 90 * time.Minute, false},
{"", 0, true},
{"abc", 0, true},
{"d", 0, true},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got, err := parseDurationString(tt.input)
if tt.err {
if err == nil {
t.Errorf("expected error for %q, got %v", tt.input, got)
}
return
}
if err != nil {
t.Fatalf("unexpected error for %q: %v", tt.input, err)
}
if got != tt.want {
t.Errorf("parseDurationString(%q) = %v, want %v", tt.input, got, tt.want)
}
})
}
}
// ── LoadSessionConfig tests ────────────────
func TestSessionConfigDefaults(t *testing.T) {
gc := &mockGlobalConfig{data: map[string]models.JSONMap{}}
sc := LoadSessionConfig(context.Background(), gc)
if sc.AccessTokenTTL != 15*time.Minute {
t.Errorf("AccessTokenTTL = %v, want 15m", sc.AccessTokenTTL)
}
if sc.RefreshTokenTTL != 7*24*time.Hour {
t.Errorf("RefreshTokenTTL = %v, want 7d", sc.RefreshTokenTTL)
}
if sc.IdleTimeout != 0 {
t.Errorf("IdleTimeout = %v, want 0", sc.IdleTimeout)
}
}
func TestSessionConfigCustomValues(t *testing.T) {
gc := newMockGC(models.JSONMap{
"access_token_ttl": "30m",
"refresh_token_ttl": "30d",
"idle_timeout": "1h",
})
sc := LoadSessionConfig(context.Background(), gc)
if sc.AccessTokenTTL != 30*time.Minute {
t.Errorf("AccessTokenTTL = %v, want 30m", sc.AccessTokenTTL)
}
if sc.RefreshTokenTTL != 30*24*time.Hour {
t.Errorf("RefreshTokenTTL = %v, want 30d", sc.RefreshTokenTTL)
}
if sc.IdleTimeout != 1*time.Hour {
t.Errorf("IdleTimeout = %v, want 1h", sc.IdleTimeout)
}
}
func TestSessionConfigClampLow(t *testing.T) {
gc := newMockGC(models.JSONMap{
"access_token_ttl": "1m", // below 5m min
"refresh_token_ttl": "30m", // below 1h min
"idle_timeout": "1m", // below 5m min
})
sc := LoadSessionConfig(context.Background(), gc)
if sc.AccessTokenTTL != 5*time.Minute {
t.Errorf("AccessTokenTTL clamped = %v, want 5m", sc.AccessTokenTTL)
}
if sc.RefreshTokenTTL != 1*time.Hour {
t.Errorf("RefreshTokenTTL clamped = %v, want 1h", sc.RefreshTokenTTL)
}
if sc.IdleTimeout != 5*time.Minute {
t.Errorf("IdleTimeout clamped = %v, want 5m", sc.IdleTimeout)
}
}
func TestSessionConfigClampHigh(t *testing.T) {
gc := newMockGC(models.JSONMap{
"access_token_ttl": "2h", // above 60m max
"refresh_token_ttl": "365d", // above 90d max
"idle_timeout": "48h", // above 24h max
})
sc := LoadSessionConfig(context.Background(), gc)
if sc.AccessTokenTTL != 60*time.Minute {
t.Errorf("AccessTokenTTL clamped = %v, want 60m", sc.AccessTokenTTL)
}
if sc.RefreshTokenTTL != 90*24*time.Hour {
t.Errorf("RefreshTokenTTL clamped = %v, want 90d", sc.RefreshTokenTTL)
}
if sc.IdleTimeout != 24*time.Hour {
t.Errorf("IdleTimeout clamped = %v, want 24h", sc.IdleTimeout)
}
}
func TestSessionConfigEmptyIdleTimeout(t *testing.T) {
gc := newMockGC(models.JSONMap{
"access_token_ttl": "15m",
"refresh_token_ttl": "7d",
"idle_timeout": "",
})
sc := LoadSessionConfig(context.Background(), gc)
if sc.IdleTimeout != 0 {
t.Errorf("IdleTimeout = %v, want 0 (disabled)", sc.IdleTimeout)
}
}
func TestSessionConfigInvalidValues(t *testing.T) {
gc := newMockGC(models.JSONMap{
"access_token_ttl": "not-a-duration",
"refresh_token_ttl": "xyz",
"idle_timeout": "---",
})
sc := LoadSessionConfig(context.Background(), gc)
// Should fall back to defaults
if sc.AccessTokenTTL != 15*time.Minute {
t.Errorf("AccessTokenTTL = %v, want 15m (default)", sc.AccessTokenTTL)
}
if sc.RefreshTokenTTL != 7*24*time.Hour {
t.Errorf("RefreshTokenTTL = %v, want 7d (default)", sc.RefreshTokenTTL)
}
if sc.IdleTimeout != 0 {
t.Errorf("IdleTimeout = %v, want 0 (default)", sc.IdleTimeout)
}
}

View File

@@ -444,6 +444,13 @@ func main() {
wfScanner.Start()
defer wfScanner.Stop()
// ── Activity ping (authenticated, rate-limited) ──
// Client SDK calls this on user interaction (debounced, max 1/min)
// to update last_activity_at for idle-timeout tracking.
activityGroup := api.Group("/auth")
activityGroup.Use(middleware.Auth(cfg, stores.Users, userCache))
activityGroup.POST("/activity", authH.Activity)
// ── Protected routes ────────────────────
protected := api.Group("")
protected.Use(middleware.Auth(cfg, stores.Users, userCache))

View File

@@ -142,9 +142,30 @@ func parseAndValidateJWT(tokenString string, jwtSecret string) (*Claims, bool) {
return claims, true
}
// parseJWTIgnoringExpiry parses a JWT and validates the signature but
// tolerates an expired token. Used by page-auth middleware so the Go
// template can render the page shell even when the access token has
// lapsed — the Preact SDK will refresh the token client-side.
// Returns (claims, signatureValid). A tampered or unsigned token
// returns (nil, false).
func parseJWTIgnoringExpiry(tokenString string, jwtSecret string) (*Claims, bool) {
claims := &Claims{}
_, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrSignatureInvalid
}
return []byte(jwtSecret), nil
}, jwt.WithoutClaimsValidation())
if err != nil {
return nil, false
}
return claims, true
}
// UserIDFromCookie extracts the user ID from the arm_token cookie without
// requiring authentication. Returns "" if no valid token is found.
// Used by unauthenticated routes that want optional user context.
// Tolerates expired tokens (signature must be valid) so that user preferences
// still apply even when the access token has lapsed.
func UserIDFromCookie(c *gin.Context, jwtSecret string) string {
cookie, err := c.Cookie("arm_token")
if err != nil || cookie == "" {
@@ -152,7 +173,11 @@ func UserIDFromCookie(c *gin.Context, jwtSecret string) string {
}
claims, ok := parseAndValidateJWT(cookie, jwtSecret)
if !ok {
return ""
// Accept expired-but-signed token for optional user context
claims, ok = parseJWTIgnoringExpiry(cookie, jwtSecret)
if !ok {
return ""
}
}
return claims.UserID
}

View File

@@ -0,0 +1,82 @@
package middleware
import (
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
)
const testSecret = "test-jwt-secret-key"
func makeToken(t *testing.T, userID, email string, expiresAt time.Time) string {
t.Helper()
claims := Claims{
UserID: userID,
Email: email,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(expiresAt),
IssuedAt: jwt.NewNumericDate(time.Now().Add(-1 * time.Hour)),
ID: "test-jti",
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
s, err := token.SignedString([]byte(testSecret))
if err != nil {
t.Fatal(err)
}
return s
}
func TestParseAndValidateJWT_Valid(t *testing.T) {
tok := makeToken(t, "u1", "a@b.com", time.Now().Add(15*time.Minute))
claims, ok := parseAndValidateJWT(tok, testSecret)
if !ok {
t.Fatal("expected valid token to parse")
}
if claims.UserID != "u1" {
t.Errorf("UserID = %q, want u1", claims.UserID)
}
}
func TestParseAndValidateJWT_Expired(t *testing.T) {
tok := makeToken(t, "u1", "a@b.com", time.Now().Add(-5*time.Minute))
_, ok := parseAndValidateJWT(tok, testSecret)
if ok {
t.Fatal("expected expired token to be rejected by strict parser")
}
}
func TestParseAndValidateJWT_WrongSecret(t *testing.T) {
tok := makeToken(t, "u1", "a@b.com", time.Now().Add(15*time.Minute))
_, ok := parseAndValidateJWT(tok, "wrong-secret")
if ok {
t.Fatal("expected wrong-secret token to be rejected")
}
}
func TestParseJWTIgnoringExpiry_Expired(t *testing.T) {
tok := makeToken(t, "u1", "a@b.com", time.Now().Add(-5*time.Minute))
claims, ok := parseJWTIgnoringExpiry(tok, testSecret)
if !ok {
t.Fatal("expected expired token to be accepted by lenient parser")
}
if claims.UserID != "u1" {
t.Errorf("UserID = %q, want u1", claims.UserID)
}
}
func TestParseJWTIgnoringExpiry_WrongSecret(t *testing.T) {
tok := makeToken(t, "u1", "a@b.com", time.Now().Add(-5*time.Minute))
_, ok := parseJWTIgnoringExpiry(tok, "wrong-secret")
if ok {
t.Fatal("expected tampered token to be rejected even with lenient parser")
}
}
func TestParseJWTIgnoringExpiry_GarbageToken(t *testing.T) {
_, ok := parseJWTIgnoringExpiry("not.a.jwt", testSecret)
if ok {
t.Fatal("expected garbage token to be rejected")
}
}

View File

@@ -50,10 +50,17 @@ func AuthOrRedirect(cfg *config.Config, users store.UserStore, cache *UserStatus
return
}
// Try strict validation first (token not expired).
claims, ok := parseAndValidateJWT(tokenString, cfg.JWTSecret)
if !ok {
redirectToLogin(c, loginPath)
return
// Fallback: accept an expired-but-signed token so the page
// shell can render and the Preact SDK can refresh client-side.
// A tampered/unsigned token still gets rejected.
claims, ok = parseJWTIgnoringExpiry(tokenString, cfg.JWTSecret)
if !ok {
redirectToLogin(c, loginPath)
return
}
}
if claims.UserID == "" {

View File

@@ -13,6 +13,13 @@ import (
// ErrSystemGroup is returned when attempting to delete a system-sourced group.
var ErrSystemGroup = errors.New("system groups cannot be deleted")
// RefreshTokenInfo holds extended metadata for a refresh token row.
type RefreshTokenInfo struct {
UserID string
KeepLogin bool
LastActivityAt *time.Time
}
// =========================================
// STORES — Data Access Layer
// =========================================
@@ -79,11 +86,13 @@ type UserStore interface {
ListActiveUserIDs(ctx context.Context) ([]string, error)
// Refresh tokens
CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error
CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time, keepLogin bool) error
GetRefreshToken(ctx context.Context, tokenHash string) (userID string, err error)
GetRefreshTokenInfo(ctx context.Context, tokenHash string) (*RefreshTokenInfo, error)
RevokeRefreshToken(ctx context.Context, tokenHash string) error
RevokeAllRefreshTokens(ctx context.Context, userID string) error
CleanExpiredTokens(ctx context.Context) error
UpdateRefreshTokenActivity(ctx context.Context, userID string) error
// ── CS1 additions ──

View File

@@ -142,8 +142,10 @@ func (s *UserStore) ListActiveUserIDs(ctx context.Context) ([]string, error) {
// ── Refresh Tokens ──────────────────────────
func (s *UserStore) CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error {
_, err := DB.ExecContext(ctx, `INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)`, userID, tokenHash, expiresAt)
func (s *UserStore) CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time, keepLogin bool) error {
_, err := DB.ExecContext(ctx,
`INSERT INTO refresh_tokens (user_id, token_hash, expires_at, keep_login, last_activity_at) VALUES ($1, $2, $3, $4, NOW())`,
userID, tokenHash, expiresAt, keepLogin)
return err
}
func (s *UserStore) GetRefreshToken(ctx context.Context, tokenHash string) (string, error) {
@@ -151,6 +153,17 @@ func (s *UserStore) GetRefreshToken(ctx context.Context, tokenHash string) (stri
err := DB.QueryRowContext(ctx, `SELECT user_id FROM refresh_tokens WHERE token_hash = $1 AND revoked_at IS NULL AND expires_at > NOW()`, tokenHash).Scan(&userID)
return userID, err
}
func (s *UserStore) GetRefreshTokenInfo(ctx context.Context, tokenHash string) (*store.RefreshTokenInfo, error) {
var info store.RefreshTokenInfo
err := DB.QueryRowContext(ctx,
`SELECT user_id, COALESCE(keep_login, false), last_activity_at
FROM refresh_tokens WHERE token_hash = $1 AND revoked_at IS NULL AND expires_at > NOW()`,
tokenHash).Scan(&info.UserID, &info.KeepLogin, &info.LastActivityAt)
if err != nil {
return nil, err
}
return &info, nil
}
func (s *UserStore) RevokeRefreshToken(ctx context.Context, tokenHash string) error {
_, err := DB.ExecContext(ctx, "UPDATE refresh_tokens SET revoked_at = NOW() WHERE token_hash = $1", tokenHash)
return err
@@ -163,6 +176,12 @@ func (s *UserStore) CleanExpiredTokens(ctx context.Context) error {
_, err := DB.ExecContext(ctx, "DELETE FROM refresh_tokens WHERE expires_at < NOW() - INTERVAL '30 days'")
return err
}
func (s *UserStore) UpdateRefreshTokenActivity(ctx context.Context, userID string) error {
_, err := DB.ExecContext(ctx,
"UPDATE refresh_tokens SET last_activity_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL",
userID)
return err
}
// ── Internal ────────────────────────────────

View File

@@ -148,9 +148,10 @@ func (s *UserStore) ListActiveUserIDs(ctx context.Context) ([]string, error) {
// ── Refresh Tokens ──────────────────────────
func (s *UserStore) CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error {
_, err := DB.ExecContext(ctx, `INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at) VALUES (?, ?, ?, ?)`,
store.NewID(), userID, tokenHash, expiresAt.Format(timeFmt))
func (s *UserStore) CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time, keepLogin bool) error {
_, err := DB.ExecContext(ctx,
`INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at, keep_login, last_activity_at) VALUES (?, ?, ?, ?, ?, datetime('now'))`,
store.NewID(), userID, tokenHash, expiresAt.Format(timeFmt), keepLogin)
return err
}
func (s *UserStore) GetRefreshToken(ctx context.Context, tokenHash string) (string, error) {
@@ -158,6 +159,23 @@ func (s *UserStore) GetRefreshToken(ctx context.Context, tokenHash string) (stri
err := DB.QueryRowContext(ctx, `SELECT user_id FROM refresh_tokens WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > datetime('now')`, tokenHash).Scan(&userID)
return userID, err
}
func (s *UserStore) GetRefreshTokenInfo(ctx context.Context, tokenHash string) (*store.RefreshTokenInfo, error) {
var info store.RefreshTokenInfo
var lastAct *string
err := DB.QueryRowContext(ctx,
`SELECT user_id, COALESCE(keep_login, 0), last_activity_at
FROM refresh_tokens WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > datetime('now')`,
tokenHash).Scan(&info.UserID, &info.KeepLogin, &lastAct)
if err != nil {
return nil, err
}
if lastAct != nil {
if t, err := time.Parse(timeFmt, *lastAct); err == nil {
info.LastActivityAt = &t
}
}
return &info, nil
}
func (s *UserStore) RevokeRefreshToken(ctx context.Context, tokenHash string) error {
_, err := DB.ExecContext(ctx, "UPDATE refresh_tokens SET revoked_at = datetime('now') WHERE token_hash = ?", tokenHash)
return err
@@ -170,6 +188,12 @@ func (s *UserStore) CleanExpiredTokens(ctx context.Context) error {
_, err := DB.ExecContext(ctx, "DELETE FROM refresh_tokens WHERE expires_at < datetime('now', '-30 days')")
return err
}
func (s *UserStore) UpdateRefreshTokenActivity(ctx context.Context, userID string) error {
_, err := DB.ExecContext(ctx,
"UPDATE refresh_tokens SET last_activity_at = datetime('now') WHERE user_id = ? AND revoked_at IS NULL",
userID)
return err
}
// ── Internal ────────────────────────────────

View File

@@ -99,6 +99,13 @@
border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-dim);
}
.login-keep-login {
display: flex; align-items: center; gap: 6px; margin-top: 4px;
font-size: 0.82rem; color: var(--text-secondary, #999); cursor: pointer;
user-select: none;
}
.login-keep-login input[type="checkbox"] { margin: 0; cursor: pointer; }
.login-auth-error {
color: var(--danger); font-size: 0.78rem; margin: 0 0 0.5rem;
}

View File

@@ -36,15 +36,18 @@ export function createAuth() {
// ── Token persistence ───────────────────────
let _cookieMaxAge = 604800; // default 7 days, updated from server response
function _saveTokens() {
localStorage.setItem(_storageKey, JSON.stringify({
accessToken: _accessToken,
refreshToken: _refreshToken,
user: _user,
cookieMaxAge: _cookieMaxAge,
}));
// Cookie sync for Go template page auth
if (_accessToken) {
document.cookie = `arm_token=${_accessToken}; path=/; max-age=900; SameSite=Strict`;
document.cookie = `arm_token=${_accessToken}; path=/; max-age=${_cookieMaxAge}; SameSite=Strict`;
} else {
document.cookie = 'arm_token=; path=/; max-age=0';
}
@@ -57,6 +60,7 @@ export function createAuth() {
_accessToken = saved.accessToken || null;
_refreshToken = saved.refreshToken || null;
_user = saved.user || null;
if (saved.cookieMaxAge) _cookieMaxAge = saved.cookieMaxAge;
}
} catch (_) { /* corrupt storage */ }
}
@@ -81,6 +85,7 @@ export function createAuth() {
_accessToken = data.access_token;
_refreshToken = data.refresh_token;
_user = data.user;
if (data.refresh_expires_in) _cookieMaxAge = data.refresh_expires_in;
_saveTokens();
_scheduleRefresh(data.expires_in || 900);
}
@@ -149,8 +154,8 @@ export function createAuth() {
/**
* Login with credentials. Stores tokens, fetches permissions, emits event.
*/
async login(login, password) {
const data = await _restClient.post('/api/v1/auth/login', { login, password });
async login(login, password, keepLogin = false) {
const data = await _restClient.post('/api/v1/auth/login', { login, password, keep_login: keepLogin });
_setAuth(data);
await _fetchPermissions();
_emit('auth.login', { user: auth.user }, { localOnly: true });
@@ -245,9 +250,30 @@ export function createAuth() {
if (_accessToken) {
_emit('auth.boot', { user: auth.user }, { localOnly: true });
auth._startActivityTracking();
}
},
// ── Activity Ping (idle-timeout support) ──
/**
* Start activity tracking. Sends POST /api/v1/auth/activity
* on user interaction, debounced to max once per minute.
*/
_startActivityTracking() {
let lastPing = 0;
const INTERVAL = 60_000; // 1 minute
const ping = () => {
if (!_accessToken) return;
const now = Date.now();
if (now - lastPing < INTERVAL) return;
lastPing = now;
_restClient.post('/api/v1/auth/activity', {}).catch(() => {});
};
document.addEventListener('click', ping, { passive: true });
document.addEventListener('keydown', ping, { passive: true });
},
// ── Internal (called by rest-client / index.js) ──
_getToken() { return _accessToken; },

View File

@@ -37,6 +37,10 @@ export default function SettingsSection() {
footer_enabled: !!cfg_.footer?.enabled,
footer_text: cfg_.footer?.text || '',
package_registry_url: cfg_.package_registry?.url || '',
session_access_ttl: cfg_.session?.access_token_ttl || '15m',
session_refresh_ttl: cfg_.session?.refresh_token_ttl || '7d',
session_idle_enabled: !!cfg_.session?.idle_timeout,
session_idle_timeout: cfg_.session?.idle_timeout || '2h',
});
setVault(v);
} catch (e) { sw.toast(e.message, 'error'); }
@@ -82,6 +86,13 @@ export default function SettingsSection() {
// Package Registry
await sw.api.admin.settings.update('package_registry', { value: { url: cfg.package_registry_url } });
// Session
await sw.api.admin.settings.update('session', { value: {
access_token_ttl: cfg.session_access_ttl,
refresh_token_ttl: cfg.session_refresh_ttl,
idle_timeout: cfg.session_idle_enabled ? cfg.session_idle_timeout : '',
}});
sw.toast('Settings saved', 'success');
} catch (e) { sw.toast(e.message, 'error'); }
finally { setSaving(false); }
@@ -163,6 +174,48 @@ export default function SettingsSection() {
`}
</div>
<div class="settings-section"><h3>Session</h3>
<div class="form-row">
<div class="form-group"><label>Access token TTL</label>
<select value=${cfg.session_access_ttl} onChange=${e => set('session_access_ttl', e.target.value)}>
<option value="5m">5 minutes</option>
<option value="10m">10 minutes</option>
<option value="15m">15 minutes (default)</option>
<option value="30m">30 minutes</option>
<option value="60m">60 minutes</option>
</select>
</div>
<div class="form-group"><label>Refresh token TTL</label>
<select value=${cfg.session_refresh_ttl} onChange=${e => set('session_refresh_ttl', e.target.value)}>
<option value="1h">1 hour</option>
<option value="12h">12 hours</option>
<option value="24h">24 hours</option>
<option value="7d">7 days (default)</option>
<option value="30d">30 days</option>
<option value="90d">90 days</option>
</select>
</div>
</div>
<span class="text-muted" style="font-size:12px;">Access token controls JWT lifetime. Refresh token controls how long sessions persist. "Keep me logged in" unchecked caps refresh at 24h.</span>
<div style="margin-top:8px;">
<label class="toggle-label"><input type="checkbox" checked=${cfg.session_idle_enabled} onChange=${e => set('session_idle_enabled', e.target.checked)} /><span class="toggle-track"></span><span>Enable idle timeout</span></label>
${cfg.session_idle_enabled && html`
<div class="form-group" style="margin-top:8px;"><label>Idle timeout</label>
<select value=${cfg.session_idle_timeout} onChange=${e => set('session_idle_timeout', e.target.value)}>
<option value="15m">15 minutes</option>
<option value="30m">30 minutes</option>
<option value="1h">1 hour</option>
<option value="2h">2 hours (default)</option>
<option value="4h">4 hours</option>
<option value="8h">8 hours</option>
<option value="24h">24 hours</option>
</select>
</div>
<span class="text-muted" style="font-size:12px;">Rejects token refresh if no user activity within the timeout period.</span>
`}
</div>
</div>
<div class="settings-section"><h3>Encryption Vault</h3>
${vault
? html`<div class="text-muted" style="font-size:12px;">Key set: ${vault.encryption_key_set ? 'Yes' : 'No'} \u2022 User vaults: ${vault.user_vaults_count ?? '?'}</div>`

View File

@@ -10,6 +10,7 @@ const { useState, useRef, useCallback } = hooks;
export function LoginForm({ onSuccess }) {
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [keepLogin, setKeepLogin] = useState(false);
const userRef = useRef(null);
const passRef = useRef(null);
@@ -25,7 +26,7 @@ export function LoginForm({ onSuccess }) {
setError('');
setLoading(true);
try {
await sw.auth.login(username, password);
await sw.auth.login(username, password, keepLogin);
if (onSuccess) {
onSuccess();
} else {
@@ -35,7 +36,7 @@ export function LoginForm({ onSuccess }) {
setError(e.message || 'Login failed');
setLoading(false);
}
}, [onSuccess]);
}, [onSuccess, keepLogin]);
const onUserKey = useCallback((e) => {
if (e.key === 'Enter') passRef.current?.focus();
@@ -59,6 +60,11 @@ export function LoginForm({ onSuccess }) {
placeholder="Enter password" autocomplete="current-password"
onKeyDown=${onPassKey} />
</div>
<label class="login-keep-login">
<input type="checkbox" checked=${keepLogin}
onChange=${e => setKeepLogin(e.target.checked)} />
<span>Keep me logged in</span>
</label>
${error && html`<p class="login-auth-error">${error}</p>`}
<button class="login-btn" disabled=${loading} onClick=${doLogin}>
${loading ? 'Logging in\u2026' : 'Log In'}