6 Commits

Author SHA1 Message Date
H1K0 a371045b41 fix(frontend): keep auth tokens in sync across browser tabs
deploy / deploy (push) Successful in 1m4s
Refresh tokens rotate on every use and each refresh deletes the old
session server-side, so when one tab refreshed, other open tabs were
left holding a dead access token and a rotated-away refresh token —
their next request 401'd and bounced them to the login screen.

Sync the auth store across tabs via the storage event (propagating
logins, refreshes, and logouts), and make refresh race-resilient: if a
refresh fails but a newer token has meanwhile synced in from another
tab, adopt it and retry instead of ending a still-valid session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 00:13:13 +03:00
H1K0 da867406e2 fix(frontend): let a tag be created/edited without a colour
A native <input type="color"> always holds a value, so the form always
sent the input's default colour and a tag could never be colourless. Add
a "Color" checkbox gating the swatch: off by default on the new-tag form
(so tags are colourless unless opted in) and initialised from the tag on
the edit form, which can now clear a colour. Sends color: null when off.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 23:31:59 +03:00
H1K0 21c7aa31ea fix(backend): store an empty tag colour as NULL
The PATCH "clear colour" path sent an empty string, which violates the
hex CHECK constraint and never falls back to the category colour. Map ''
to NULL via NULLIF in the tag insert/update so a cleared or omitted
colour is stored as NULL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 23:31:59 +03:00
H1K0 4def59c86d feat(frontend): log a pool view when the pool page opens
Fire POST /pools/{id}/views fire-and-forget after the pool loads, the
same way the file viewer logs file views.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 23:20:46 +03:00
H1K0 d345839634 docs(project): document the pool view endpoint
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 23:20:46 +03:00
H1K0 7d0ea4e388 feat(backend): record pool views to activity.pool_views
Add POST /pools/{id}/views, mirroring the file-view endpoint: it
enforces view ACL and appends a row to activity.pool_views (viewed_at
defaults to statement_timestamp(), so each view is its own history row).
The table existed but nothing wrote to it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 23:20:46 +03:00
13 changed files with 232 additions and 13 deletions
+10
View File
@@ -266,6 +266,16 @@ WHERE p.id = $1`
return &p, nil return &p, nil
} }
// RecordView appends a row to activity.pool_views. viewed_at defaults to
// statement_timestamp(), so each call records a distinct view in the history.
func (r *PoolRepo) RecordView(ctx context.Context, poolID uuid.UUID, userID int16) error {
const query = `INSERT INTO activity.pool_views (pool_id, user_id) VALUES ($1, $2)`
if _, err := connOrTx(ctx, r.pool).Exec(ctx, query, poolID, userID); err != nil {
return fmt.Errorf("PoolRepo.RecordView: %w", err)
}
return nil
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Create // Create
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+2 -2
View File
@@ -272,7 +272,7 @@ func (r *TagRepo) Create(ctx context.Context, t *domain.Tag) (*domain.Tag, error
const query = ` const query = `
WITH ins AS ( WITH ins AS (
INSERT INTO data.tags (name, notes, color, category_id, metadata, creator_id, is_public) INSERT INTO data.tags (name, notes, color, category_id, metadata, creator_id, is_public)
VALUES ($1, $2, $3, $4, $5, $6, $7) VALUES ($1, $2, NULLIF($3, ''), $4, $5, $6, $7)
RETURNING * RETURNING *
) )
SELECT SELECT
@@ -321,7 +321,7 @@ WITH upd AS (
UPDATE data.tags SET UPDATE data.tags SET
name = $2, name = $2,
notes = $3, notes = $3,
color = $4, color = NULLIF($4, ''),
category_id = $5, category_id = $5,
metadata = COALESCE($6, metadata), metadata = COALESCE($6, metadata),
is_public = $7 is_public = $7
+19
View File
@@ -160,6 +160,25 @@ func (h *PoolHandler) Get(c *gin.Context) {
respondJSON(c, http.StatusOK, toPoolJSON(*p)) respondJSON(c, http.StatusOK, toPoolJSON(*p))
} }
// ---------------------------------------------------------------------------
// POST /pools/:pool_id/views
// ---------------------------------------------------------------------------
// RecordView logs that the current user viewed the pool (activity.pool_views).
func (h *PoolHandler) RecordView(c *gin.Context) {
id, ok := parsePoolID(c)
if !ok {
return
}
if err := h.poolSvc.RecordView(c.Request.Context(), id); err != nil {
respondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// PATCH /pools/:pool_id // PATCH /pools/:pool_id
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+1
View File
@@ -141,6 +141,7 @@ func NewRouter(
pools.GET("/:pool_id", poolHandler.Get) pools.GET("/:pool_id", poolHandler.Get)
pools.PATCH("/:pool_id", poolHandler.Update) pools.PATCH("/:pool_id", poolHandler.Update)
pools.DELETE("/:pool_id", poolHandler.Delete) pools.DELETE("/:pool_id", poolHandler.Delete)
pools.POST("/:pool_id/views", poolHandler.RecordView)
// Sub-routes registered before /:pool_id/files to avoid param conflicts. // Sub-routes registered before /:pool_id/files to avoid param conflicts.
pools.POST("/:pool_id/files/remove", poolHandler.RemoveFiles) pools.POST("/:pool_id/files/remove", poolHandler.RemoveFiles)
@@ -857,6 +857,75 @@ func TestTagSortByCategoryThenName(t *testing.T) {
assert.Equal(t, []string{"ant", "zebra", "mid", "solo"}, names) assert.Equal(t, []string{"ant", "zebra", "mid", "solo"}, names)
} }
// TestRecordPoolView verifies that viewing a pool is logged (POST .../views),
// is repeatable (view history, not a unique flag), and 404s for unknown pools.
func TestRecordPoolView(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
h := setupSuite(t)
ctx := context.Background()
adminToken := h.login("admin", "admin")
resp := h.doJSON("POST", "/pools", map[string]any{"name": "trip"}, adminToken)
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
var pool map[string]any
resp.decode(t, &pool)
poolID := pool["id"].(string)
resp = h.doJSON("POST", "/pools/"+poolID+"/views", nil, adminToken)
require.Equal(t, http.StatusNoContent, resp.StatusCode, resp.String())
// Viewing again logs another history row, not a conflict.
resp = h.doJSON("POST", "/pools/"+poolID+"/views", nil, adminToken)
require.Equal(t, http.StatusNoContent, resp.StatusCode, resp.String())
var n int
require.NoError(t, h.pool.QueryRow(ctx,
`SELECT count(*) FROM activity.pool_views WHERE pool_id = $1`, poolID).Scan(&n))
assert.Equal(t, 2, n, "each view should add a history row")
// Unknown pool id → 404.
resp = h.doJSON("POST", "/pools/00000000-0000-0000-0000-000000000000/views", nil, adminToken)
require.Equal(t, http.StatusNotFound, resp.StatusCode, resp.String())
}
// TestTagColorOptional verifies a tag can be created without a colour (stored as
// NULL rather than the colour input's default) and that an existing colour can
// be cleared back to none.
func TestTagColorOptional(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
h := setupSuite(t)
adminToken := h.login("admin", "admin")
// Created without a colour → color is null.
resp := h.doJSON("POST", "/tags", map[string]any{"name": "plain"}, adminToken)
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
var plain map[string]any
resp.decode(t, &plain)
assert.Nil(t, plain["color"], "tag created without a colour should have null color")
// Created with a colour → kept verbatim.
resp = h.doJSON("POST", "/tags", map[string]any{"name": "red", "color": "aabbcc"}, adminToken)
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
var red map[string]any
resp.decode(t, &red)
assert.Equal(t, "aabbcc", red["color"])
redID := red["id"].(string)
// Clearing the colour (color: null) must store NULL — an empty string would
// violate the hex CHECK constraint and fail the update.
resp = h.doJSON("PATCH", "/tags/"+redID, map[string]any{"color": nil}, adminToken)
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
var cleared map[string]any
resp.decode(t, &cleared)
assert.Nil(t, cleared["color"], "cleared colour should be null")
}
// TestBulkTagAutoRule verifies the bulk add path also applies then_tags. // TestBulkTagAutoRule verifies the bulk add path also applies then_tags.
func TestBulkTagAutoRule(t *testing.T) { func TestBulkTagAutoRule(t *testing.T) {
if testing.Short() { if testing.Short() {
+3
View File
@@ -129,6 +129,9 @@ type PoolRepo interface {
RemoveFiles(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID) error RemoveFiles(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID) error
// Reorder sets the full ordered sequence of file IDs in the pool. // Reorder sets the full ordered sequence of file IDs in the pool.
Reorder(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID) error Reorder(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID) error
// RecordView appends a view-history row (activity.pool_views) for the user.
RecordView(ctx context.Context, poolID uuid.UUID, userID int16) error
} }
// UserRepo is the persistence interface for users. // UserRepo is the persistence interface for users.
+10
View File
@@ -82,6 +82,16 @@ func (s *PoolService) authorizeView(ctx context.Context, poolID uuid.UUID) error
return nil return nil
} }
// RecordView appends a view-history entry for the current user, enforcing view
// ACL (you can only record a view of a pool you may see).
func (s *PoolService) RecordView(ctx context.Context, id uuid.UUID) error {
userID, _, _ := domain.UserFromContext(ctx)
if err := s.authorizeView(ctx, id); err != nil {
return err
}
return s.pools.RecordView(ctx, id, userID)
}
// authorizeEdit returns nil if the caller may edit the pool, else ErrForbidden // authorizeEdit returns nil if the caller may edit the pool, else ErrForbidden
// (or ErrNotFound if the pool does not exist). // (or ErrNotFound if the pool does not exist).
func (s *PoolService) authorizeEdit(ctx context.Context, poolID uuid.UUID) error { func (s *PoolService) authorizeEdit(ctx context.Context, poolID uuid.UUID) error {
+8 -3
View File
@@ -46,8 +46,8 @@ export class ApiError extends Error {
let refreshPromise: Promise<void> | null = null; let refreshPromise: Promise<void> | null = null;
async function refreshTokens(): Promise<void> { async function refreshTokens(): Promise<void> {
const { refreshToken } = get(authStore); const attempted = get(authStore).refreshToken;
if (!refreshToken) { if (!attempted) {
endSession(); endSession();
throw new ApiError(401, 'unauthorized', 'Session expired'); throw new ApiError(401, 'unauthorized', 'Session expired');
} }
@@ -55,10 +55,15 @@ async function refreshTokens(): Promise<void> {
const res = await fetch(`${BASE}/auth/refresh`, { const res = await fetch(`${BASE}/auth/refresh`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refreshToken }) body: JSON.stringify({ refresh_token: attempted })
}); });
if (!res.ok) { if (!res.ok) {
// Refresh tokens rotate, so another tab may have already refreshed and
// rotated ours out. If a newer token has since synced in from that tab (via
// the auth store's storage listener), adopt it and let the caller retry
// rather than ending a session that's actually still alive.
if (get(authStore).refreshToken !== attempted) return;
endSession(); endSession();
throw new ApiError(401, 'unauthorized', 'Session expired'); throw new ApiError(401, 'unauthorized', 'Session expired');
} }
+27 -2
View File
@@ -25,10 +25,35 @@ function loadStored(): AuthState {
export const authStore = writable<AuthState>(loadStored()); export const authStore = writable<AuthState>(loadStored());
// Persist on change. Compare first so a value that just arrived from another tab
// (applied by the storage listener below) isn't written straight back, which
// would risk a storage-event echo between tabs.
authStore.subscribe((state) => { authStore.subscribe((state) => {
if (typeof localStorage !== 'undefined') { if (typeof localStorage === 'undefined') return;
localStorage.setItem('auth', JSON.stringify(state)); const serialized = JSON.stringify(state);
if (localStorage.getItem('auth') !== serialized) {
localStorage.setItem('auth', serialized);
} }
}); });
// Keep tabs in sync. Refresh tokens rotate on every use (each refresh deletes the
// old session server-side), so when one tab logs in, refreshes, or logs out, the
// others must pick up the new tokens — or the cleared session — immediately.
// Otherwise a second tab would later refresh with a token that's already been
// rotated away and get bounced to the login screen.
if (typeof window !== 'undefined') {
window.addEventListener('storage', (e) => {
if (e.key !== 'auth') return;
let next: AuthState = initial;
if (e.newValue) {
try {
next = (JSON.parse(e.newValue) as AuthState) ?? initial;
} catch {
next = initial;
}
}
authStore.set(next);
});
}
export const isAuthenticated = derived(authStore, ($auth) => !!$auth.accessToken); export const isAuthenticated = derived(authStore, ($auth) => !!$auth.accessToken);
@@ -86,6 +86,9 @@
notes = p.notes ?? ''; notes = p.notes ?? '';
isPublic = p.is_public ?? false; isPublic = p.is_public ?? false;
loaded = true; loaded = true;
// Log the view (activity.pool_views). Fire-and-forget — never block or
// fail the page over view tracking.
void api.post(`/pools/${id}/views`).catch(() => {});
}) })
.catch((e) => { .catch((e) => {
loadError = e instanceof ApiError ? e.message : 'Failed to load pool'; loadError = e instanceof ApiError ? e.message : 'Failed to load pool';
+34 -3
View File
@@ -15,6 +15,9 @@
let name = $state(''); let name = $state('');
let notes = $state(''); let notes = $state('');
// A native <input type="color"> always holds a value, so a separate flag tracks
// whether the tag has a colour at all — letting it be cleared back to none.
let hasColor = $state(false);
let color = $state('#444455'); let color = $state('#444455');
let categoryId = $state(''); let categoryId = $state('');
let isPublic = $state(false); let isPublic = $state(false);
@@ -43,6 +46,7 @@
name = t.name ?? ''; name = t.name ?? '';
notes = t.notes ?? ''; notes = t.notes ?? '';
hasColor = !!t.color;
color = t.color ? `#${t.color}` : '#444455'; color = t.color ? `#${t.color}` : '#444455';
categoryId = t.category_id ?? ''; categoryId = t.category_id ?? '';
isPublic = t.is_public ?? false; isPublic = t.is_public ?? false;
@@ -61,7 +65,7 @@
await api.patch(`/tags/${tagId}`, { await api.patch(`/tags/${tagId}`, {
name: name.trim(), name: name.trim(),
notes: notes.trim() || null, notes: notes.trim() || null,
color: color.slice(1), color: hasColor ? color.slice(1) : null,
category_id: categoryId || null, category_id: categoryId || null,
is_public: isPublic is_public: isPublic
}); });
@@ -139,8 +143,18 @@
/> />
</div> </div>
<div class="field color-field"> <div class="field color-field">
<label class="label" for="color">Color</label> <label class="label color-label">
<input id="color" class="color-input" type="color" bind:value={color} /> <input type="checkbox" class="color-check" bind:checked={hasColor} />
Color
</label>
<input
id="color"
class="color-input"
type="color"
bind:value={color}
disabled={!hasColor}
aria-label="Tag color"
/>
</div> </div>
</div> </div>
@@ -339,6 +353,18 @@
border-color: var(--color-accent); border-color: var(--color-accent);
} }
.color-label {
display: flex;
align-items: center;
gap: 5px;
cursor: pointer;
}
.color-check {
cursor: pointer;
accent-color: var(--color-accent);
}
.color-input { .color-input {
width: 50px; width: 50px;
height: 36px; height: 36px;
@@ -349,6 +375,11 @@
cursor: pointer; cursor: pointer;
} }
.color-input:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.textarea { .textarea {
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
+34 -3
View File
@@ -6,6 +6,10 @@
let name = $state(''); let name = $state('');
let notes = $state(''); let notes = $state('');
// A native <input type="color"> always holds a value, so a separate flag tracks
// whether the tag should have a color at all. Off by default → no colour unless
// the user opts in (otherwise the tag falls back to its category / the default).
let hasColor = $state(false);
let color = $state('#444455'); let color = $state('#444455');
let categoryId = $state(''); let categoryId = $state('');
let isPublic = $state(false); let isPublic = $state(false);
@@ -27,7 +31,7 @@
await api.post('/tags', { await api.post('/tags', {
name: name.trim(), name: name.trim(),
notes: notes.trim() || null, notes: notes.trim() || null,
color: color.slice(1), // strip # color: hasColor ? color.slice(1) : null, // strip #; null = no colour
category_id: categoryId || null, category_id: categoryId || null,
is_public: isPublic is_public: isPublic
}); });
@@ -86,8 +90,18 @@
/> />
</div> </div>
<div class="field color-field"> <div class="field color-field">
<label class="label" for="color">Color</label> <label class="label color-label">
<input id="color" class="color-input" type="color" bind:value={color} /> <input type="checkbox" class="color-check" bind:checked={hasColor} />
Color
</label>
<input
id="color"
class="color-input"
type="color"
bind:value={color}
disabled={!hasColor}
aria-label="Tag color"
/>
</div> </div>
</div> </div>
@@ -235,6 +249,18 @@
border-color: var(--color-accent); border-color: var(--color-accent);
} }
.color-label {
display: flex;
align-items: center;
gap: 5px;
cursor: pointer;
}
.color-check {
cursor: pointer;
accent-color: var(--color-accent);
}
.color-input { .color-input {
width: 50px; width: 50px;
height: 36px; height: 36px;
@@ -245,6 +271,11 @@
cursor: pointer; cursor: pointer;
} }
.color-input:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.textarea { .textarea {
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
+12
View File
@@ -1151,6 +1151,18 @@ paths:
'204': '204':
description: Pool deleted description: Pool deleted
/pools/{pool_id}/views:
post:
tags: [Pools]
summary: Record that the current user viewed the pool
parameters:
- $ref: '#/components/parameters/pool_id'
responses:
'204':
description: View recorded
'404':
$ref: '#/components/responses/NotFound'
/pools/{pool_id}/files: /pools/{pool_id}/files:
get: get:
tags: [Pools, Files] tags: [Pools, Files]