Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ All notable changes to this project are documented in this file. The format is b
## [Unreleased]

### Fixed
- **Behaviour change:** evict expired site-lock attempt buckets, paginate personal/admin API token lists
(default and maximum API page size 100, with `limit`/`offset`), preserve revoke-all
across pages, and recover concurrent duplicate share grants without a 500 (#640).
- Share UTC timestamp helpers across sharing, tenants, background tasks, API tokens
and users (#640).
- Users: publish deletion, disabling and new role assignments transactionally through
the durable outbox (inline cleanup without background tasks). Deletion purges tenant
memberships, shares to the user and direct permission grants; tenants left without
Expand Down Expand Up @@ -61,6 +66,8 @@ All notable changes to this project are documented in this file. The format is b
session (#615).

### Security
- Remove account-status and superuser fields from the users `UserUpdate` contract;
self-profile routes already used a narrower input schema (#640).
- Inertia: `Inertia.back()` and the exported validation handler now redirect only to a
same-site path taken from `Referer`, falling back to `/`; an off-site or malformed
`Referer` previously produced an open redirect (#635).
Expand Down
12 changes: 12 additions & 0 deletions framework/core/simple_module_core/datetimes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"""UTC timestamps and normalization of legacy naive database values."""

from datetime import UTC, datetime


def utcnow() -> datetime:
return datetime.now(UTC)


def aware(value: datetime) -> datetime:
"""Interpret a naive value as UTC; preserve an existing timezone."""
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
19 changes: 19 additions & 0 deletions framework/core/tests/test_core_datetimes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""Shared UTC helpers preserve aware values and interpret naive values as UTC."""

from datetime import UTC, datetime, timedelta, timezone

from simple_module_core.datetimes import aware, utcnow


def test_naive_is_utc_and_aware_keeps_its_offset():
naive = datetime(2026, 1, 1)
assert aware(naive) == datetime(2026, 1, 1, tzinfo=UTC)
offset = naive.replace(tzinfo=timezone(timedelta(hours=2)))
assert aware(offset) is offset


def test_utcnow_is_current_aware_utc():
before = datetime.now(UTC)
value = utcnow()
assert before <= value <= datetime.now(UTC)
assert value.tzinfo is UTC
17 changes: 12 additions & 5 deletions modules/api_tokens/api_tokens/endpoints/admin_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

import uuid

from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from simple_module_db import TenantIsolationError
from simple_module_hosting.csrf import RequiresCsrf
from simple_module_hosting.i18n_deps import TranslatorDep
Expand All @@ -31,18 +31,25 @@
)


async def tenant_token_views(service: ApiTokenService) -> list[TokenView]:
tokens = await service.list_tenant()
async def tenant_token_views(
service: ApiTokenService, *, limit: int = 100, offset: int = 0
) -> list[TokenView]:
tokens = await service.list_tenant(limit=limit, offset=offset)
emails = await service.owner_emails(tokens)
return [
service.to_view(t, emails.get(t.owner_user_id) if t.owner_user_id else None) for t in tokens
]


@router.get("/tokens", response_model=list[TokenView])
async def list_all(service: ApiTokenServiceDep, t: TranslatorDep) -> list[TokenView]:
async def list_all(
service: ApiTokenServiceDep,
t: TranslatorDep,
limit: int = Query(100, ge=1, le=100),
offset: int = Query(0, ge=0),
) -> list[TokenView]:
try:
return await tenant_token_views(service)
return await tenant_token_views(service, limit=limit, offset=offset)
except TenantIsolationError:
raise HTTPException(
status_code=409, detail=t.t("api_tokens.errors.tenant_required")
Expand Down
10 changes: 7 additions & 3 deletions modules/api_tokens/api_tokens/endpoints/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

import uuid

from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from simple_module_db import TenantIsolationError
from simple_module_hosting.csrf import RequiresCsrf
from simple_module_hosting.i18n_deps import TranslatorDep
Expand Down Expand Up @@ -45,9 +45,13 @@

@router.get("/", response_model=list[TokenView])
async def list_tokens(
user: SessionPrincipal, service: ApiTokenServiceDep, t: TranslatorDep
user: SessionPrincipal,
service: ApiTokenServiceDep,
t: TranslatorDep,
limit: int = Query(100, ge=1, le=100),
offset: int = Query(0, ge=0),
) -> list[TokenView]:
tokens = await service.list_for_owner(user_uuid(user, t))
tokens = await service.list_for_owner(user_uuid(user, t), limit=limit, offset=offset)
return [service.to_view(token) for token in tokens]


Expand Down
16 changes: 11 additions & 5 deletions modules/api_tokens/api_tokens/endpoints/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

from __future__ import annotations

from fastapi import APIRouter, Depends, Request
from fastapi import APIRouter, Depends, Query, Request
from simple_module_db import TenantIsolationError
from simple_module_hosting.csrf import get_csrf_token
from simple_module_hosting.i18n_deps import TranslatorDep
Expand Down Expand Up @@ -56,12 +56,15 @@ async def index(
service: ApiTokenServiceDep,
settings: SettingsDep,
t: TranslatorDep,
limit: int = Query(100, ge=1, le=100),
offset: int = Query(0, ge=0),
) -> InertiaResponse:
tokens = await service.list_for_owner(user_uuid(user, t))
tokens = await service.list_for_owner(user_uuid(user, t), limit=limit + 1, offset=offset)
return await inertia.render(
PAGE_INDEX,
{
"tokens": [service.to_view(tok).model_dump(mode="json") for tok in tokens],
"tokens": [service.to_view(tok).model_dump(mode="json") for tok in tokens[:limit]],
"pagination": {"limit": limit, "offset": offset, "has_more": len(tokens) > limit},
"scopes": await assignable_scopes(request),
**_form_props(request, settings.max_lifetime_days),
},
Expand All @@ -74,9 +77,11 @@ async def admin_index(
inertia: InertiaDep,
service: ApiTokenServiceDep,
settings: SettingsDep,
limit: int = Query(100, ge=1, le=100),
offset: int = Query(0, ge=0),
) -> InertiaResponse:
try:
views = await tenant_token_views(service)
views = await tenant_token_views(service, limit=limit + 1, offset=offset)
tenant_required = False
except TenantIsolationError:
# Strict tenancy with no active tenant: nothing to list, and nowhere
Expand All @@ -85,7 +90,8 @@ async def admin_index(
return await inertia.render(
PAGE_ADMIN,
{
"tokens": [v.model_dump(mode="json") for v in views],
"tokens": [v.model_dump(mode="json") for v in views[:limit]],
"pagination": {"limit": limit, "offset": offset, "has_more": len(views) > limit},
"scopes": await assignable_scopes(request),
"roles": assignable_roles(request),
"tenant_required": tenant_required,
Expand Down
2 changes: 2 additions & 0 deletions modules/api_tokens/api_tokens/pages/Admin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { api, csrfHeaders } from './api';
import { CreatedTokenDialog } from './components/CreatedTokenDialog';
import { CreateTokenDialog } from './components/CreateTokenDialog';
import { RevokeTokenDialog } from './components/RevokeTokenDialog';
import { TokenPagination } from './components/TokenPagination';
import { TokenTable } from './components/TokenTable';
import { useTokenActions } from './components/useTokenActions';
import type { CreatePayload, TokenFormProps } from './types';
Expand Down Expand Up @@ -72,6 +73,7 @@ function Admin(props: Props) {
onRevoke={actions.setPendingRevoke}
/>
)}
<TokenPagination {...props.pagination} />
</PageShell>

<CreateTokenDialog
Expand Down
8 changes: 7 additions & 1 deletion modules/api_tokens/api_tokens/pages/Index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { api, csrfHeaders } from './api';
import { CreatedTokenDialog } from './components/CreatedTokenDialog';
import { CreateTokenDialog } from './components/CreateTokenDialog';
import { RevokeTokenDialog } from './components/RevokeTokenDialog';
import { TokenPagination } from './components/TokenPagination';
import { TokenTable } from './components/TokenTable';
import { useTokenActions } from './components/useTokenActions';
import { type CreatePayload, type TokenFormProps, tokenStatus } from './types';
Expand All @@ -31,7 +32,11 @@ function Index(props: TokenFormProps) {
),
revokeAll: () => apiResult(api.POST('/api/api_tokens/tokens/revoke-all', { headers })),
});
const hasLive = props.tokens.some((tok) => tokenStatus(tok) === 'active');
// Unseen pages may still contain live tokens.
const hasLive =
props.pagination.has_more ||
props.pagination.offset > 0 ||
props.tokens.some((tok) => tokenStatus(tok) === 'active');

async function submit(payload: CreatePayload) {
const ok = await actions.create(payload);
Expand Down Expand Up @@ -64,6 +69,7 @@ function Index(props: TokenFormProps) {
>
<p className="text-sm text-muted-foreground">{t(keys.api_tokens.index.usage)}</p>
<TokenTable tokens={props.tokens} onRevoke={actions.setPendingRevoke} />
<TokenPagination {...props.pagination} />
</PageShell>

<CreateTokenDialog
Expand Down
28 changes: 26 additions & 2 deletions modules/api_tokens/api_tokens/pages/api.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,10 @@ export type $defs = Record<string, never>;
export interface operations {
api_tokens_list_tokens: {
parameters: {
query?: never;
query?: {
limit?: number;
offset?: number;
};
header?: never;
path?: never;
cookie?: never;
Expand All @@ -251,6 +254,15 @@ export interface operations {
"application/json": components["schemas"]["TokenView"][];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
api_tokens_create_token: {
Expand Down Expand Up @@ -339,7 +351,10 @@ export interface operations {
};
api_tokens_list_all: {
parameters: {
query?: never;
query?: {
limit?: number;
offset?: number;
};
header?: never;
path?: never;
cookie?: never;
Expand All @@ -355,6 +370,15 @@ export interface operations {
"application/json": components["schemas"]["TokenView"][];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
api_tokens_create_service_key: {
Expand Down
25 changes: 25 additions & 0 deletions modules/api_tokens/api_tokens/pages/components/TokenPagination.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { router } from '@inertiajs/react';
import { keys, useT } from '@simple-module-py/i18n';
import { Button } from '@simple-module-py/ui/components/ui/button';
import type { TokenFormProps } from '../types';

export function TokenPagination({ limit, offset, has_more }: TokenFormProps['pagination']) {
const { t } = useT();
function visit(nextOffset: number) {
router.get(window.location.pathname, { limit, offset: nextOffset }, { preserveState: true });
}
return (
<div className="flex justify-end gap-2">
<Button
variant="outline"
disabled={offset === 0}
onClick={() => visit(Math.max(0, offset - limit))}
>
{t(keys.ui.pagination.previous)}
</Button>
<Button variant="outline" disabled={!has_more} onClick={() => visit(offset + limit)}>
{t(keys.ui.pagination.next)}
</Button>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export interface TokenCalls {
revokeAll?: () => Promise<ApiResult<{ revoked: number }>>;
}

/** Create / revoke through the JSON API, then reload the page's `tokens` prop. */
/** Create / revoke through the JSON API, then reload the tokens and pagination. */
export function useTokenActions({ create: createCall, revoke, revokeAll }: TokenCalls) {
const { t } = useT();
const [created, setCreated] = useState<string | null>(null);
Expand All @@ -30,7 +30,7 @@ export function useTokenActions({ create: createCall, revoke, revokeAll }: Token
return false;
}
setCreated(result.data.token);
router.reload({ only: ['tokens'] });
router.reload({ only: ['tokens', 'pagination'] });
return true;
}

Expand All @@ -45,7 +45,7 @@ export function useTokenActions({ create: createCall, revoke, revokeAll }: Token
return;
}
toast.success(t(keys.api_tokens.toasts.revoked));
router.reload({ only: ['tokens'] });
router.reload({ only: ['tokens', 'pagination'] });
}

async function confirmRevokeAll() {
Expand All @@ -59,7 +59,7 @@ export function useTokenActions({ create: createCall, revoke, revokeAll }: Token
return;
}
toast.success(t(keys.api_tokens.toasts.revoked_all, { count: result.data.revoked }));
router.reload({ only: ['tokens'] });
router.reload({ only: ['tokens', 'pagination'] });
}

return {
Expand Down
1 change: 1 addition & 0 deletions modules/api_tokens/api_tokens/pages/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export interface ApiToken {
/** Props both pages receive for their create form. */
export interface TokenFormProps {
tokens: ApiToken[];
pagination: { limit: number; offset: number; has_more: boolean };
scopes: string[];
max_lifetime_days: number;
expiry_presets: number[];
Expand Down
Loading
Loading