Compare commits

...
7 Commits
7 changed files with 60 additions and 44 deletions
+19 -11
View File
@@ -36,14 +36,26 @@ const orgPermissions = computed(() => {
}) })
// Get users for a role as sorted array of { uuid, ...user } // Get users for a role as sorted array of { uuid, ...user }
function getNormalizedName(name) {
let cleaned = name.replace(/\([^)]*\)/g, '').trim();
if (cleaned.includes(',')) {
return cleaned.toLowerCase();
} else {
const parts = cleaned.split(/\s+/);
const last = parts.pop();
const first = parts.join(' ');
return `${last}, ${first}`.toLowerCase();
}
}
function roleUsers(roleUuid) { function roleUsers(roleUuid) {
return Object.entries(props.selectedOrg.users) return Object.entries(props.selectedOrg.users)
.filter(([_, u]) => u.role === roleUuid) .filter(([_, u]) => u.role === roleUuid)
.map(([uuid, u]) => ({ uuid, ...u })) .map(([uuid, u]) => ({ uuid, ...u }))
.sort((a, b) => { .sort((a, b) => {
const nameA = a.display_name.toLowerCase() const normA = getNormalizedName(a.display_name);
const nameB = b.display_name.toLowerCase() const normB = getNormalizedName(b.display_name);
return nameA.localeCompare(nameB) return normA.localeCompare(normB);
}) })
} }
@@ -59,10 +71,6 @@ function onUserChange(evt, targetRoleUuid) {
} }
} }
function permissionDisplayName(scope) {
return props.permissions.find(p => p.scope === scope)?.display_name || scope
}
function toggleRolePermission(role, pid, checked) { function toggleRolePermission(role, pid, checked) {
emit('toggleRolePermission', role, pid, checked) emit('toggleRolePermission', role, pid, checked)
} }
@@ -389,7 +397,7 @@ defineExpose({ focusFirstElement })
:title="u.uuid" :title="u.uuid"
> >
<span class="name">{{ u.display_name }}</span> <span class="name">{{ u.display_name }}</span>
<span class="meta">{{ u.last_seen ? new Date(u.last_seen).toLocaleDateString() : '—' }}</span> <span class="meta">{{ u.last_seen ? new Date(u.last_seen).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : '—' }}</span>
</li> </li>
</template> </template>
</draggable> </draggable>
@@ -409,7 +417,7 @@ defineExpose({ focusFirstElement })
.perm-matrix-grid .role-head { display: flex; align-items: flex-end; justify-content: center; } .perm-matrix-grid .role-head { display: flex; align-items: flex-end; justify-content: center; }
.perm-matrix-grid .role-head span { writing-mode: vertical-rl; transform: rotate(180deg); font-size: 0.65rem; } .perm-matrix-grid .role-head span { writing-mode: vertical-rl; transform: rotate(180deg); font-size: 0.65rem; }
.perm-matrix-grid .add-role-head { cursor: pointer; } .perm-matrix-grid .add-role-head { cursor: pointer; }
.roles-grid { display: flex; flex-wrap: wrap; gap: var(--space-lg); margin-top: var(--space-lg); justify-content: flex-start; align-items: stretch; } .roles-grid { display: flex; flex-wrap: wrap; gap: 0; margin-top: var(--space-lg); justify-content: flex-start; align-items: stretch; }
.role-column { flex: 0 0 240px; border-radius: var(--radius-md); padding: var(--space-md); display: flex; flex-direction: column; } .role-column { flex: 0 0 240px; border-radius: var(--radius-md); padding: var(--space-md); display: flex; flex-direction: column; }
.role-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-md); } .role-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-md); }
.role-name { display: flex; align-items: center; gap: var(--space-xs); font-size: 1.1rem; color: var(--color-heading); } .role-name { display: flex; align-items: center; gap: var(--space-xs); font-size: 1.1rem; color: var(--color-heading); }
@@ -418,9 +426,9 @@ defineExpose({ focusFirstElement })
.plus-btn:hover { background: rgba(37, 99, 235, 0.18); } .plus-btn:hover { background: rgba(37, 99, 235, 0.18); }
.user-list-wrapper { position: relative; flex: 1; display: flex; flex-direction: column; min-height: 5.5rem; } .user-list-wrapper { position: relative; flex: 1; display: flex; flex-direction: column; min-height: 5.5rem; }
.user-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-xs); flex: 1; } .user-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-xs); flex: 1; }
.user-chip { background: var(--color-accent-strong); color: white; border: none; border-radius: var(--radius-md); padding: 0.45rem 0.6rem; display: flex; justify-content: space-between; gap: var(--space-sm); cursor: grab; } .user-chip { background: var(--color-accent-strong); color: var(--color-accent-contrast); border: none; border-radius: var(--radius-md); padding: 0.45rem 0.6rem; display: flex; justify-content: space-between; gap: var(--space-sm); cursor: grab; }
.user-chip:focus { outline: 2px solid var(--color-accent); outline-offset: 1px; } .user-chip:focus { outline: 2px solid var(--color-accent); outline-offset: 1px; }
.user-chip .meta { font-size: 0.7rem; color: rgba(255, 255, 255, 0.8); } .user-chip .meta { font-size: 0.7rem; }
.user-chip.sortable-ghost { opacity: 0.5; } .user-chip.sortable-ghost { opacity: 0.5; }
.user-chip.sortable-chosen { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); } .user-chip.sortable-chosen { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); }
.empty-role { position: absolute; inset: 0; border: 1px dashed var(--color-border-strong); border-radius: var(--radius-md); display: flex; align-items: center; justify-content: center; pointer-events: none; } .empty-role { position: absolute; inset: 0; border: 1px dashed var(--color-border-strong); border-radius: var(--radius-md); display: flex; align-items: center; justify-content: center; pointer-events: none; }
-7
View File
@@ -15,14 +15,11 @@ const props = defineProps({
const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay', 'createOidcClient', 'openOidcClient', 'deleteOidcClient', 'openServerConfig', 'navigateOut']) const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay', 'createOidcClient', 'openOidcClient', 'deleteOidcClient', 'openServerConfig', 'navigateOut'])
// Template refs for navigation // Template refs for navigation
const orgSection = ref(null)
const orgActionsRef = ref(null) const orgActionsRef = ref(null)
const orgTableRef = ref(null) const orgTableRef = ref(null)
const permMatrixRef = ref(null) const permMatrixRef = ref(null)
const permActionsRef = ref(null) const permActionsRef = ref(null)
const permTableRef = ref(null) const permTableRef = ref(null)
const oidcActionsRef = ref(null)
const oidcTableRef = ref(null)
const sortedOrgs = computed(() => [...props.orgs].sort((a,b)=> { const sortedOrgs = computed(() => [...props.orgs].sort((a,b)=> {
const nameCompare = a.org.display_name.localeCompare(b.org.display_name) const nameCompare = a.org.display_name.localeCompare(b.org.display_name)
@@ -62,10 +59,6 @@ const sortedPermissions = computed(() => [...props.permissions].sort((a,b)=> a.s
const isMasterAdmin = computed(() => props.info?.ctx.permissions.includes('auth:admin')) const isMasterAdmin = computed(() => props.info?.ctx.permissions.includes('auth:admin'))
const isOrgAdmin = computed(() => props.info?.ctx.permissions.includes('auth:org:admin')) const isOrgAdmin = computed(() => props.info?.ctx.permissions.includes('auth:org:admin'))
function permissionDisplayName(scope) {
return props.permissions.find(p => p.scope === scope)?.display_name || scope
}
function getRoleNames(org) { function getRoleNames(org) {
// org.roles is dict[UUID, Role] // org.roles is dict[UUID, Role]
return Object.values(org.roles) return Object.values(org.roles)
+3 -3
View File
@@ -9,8 +9,8 @@
--font-sans: "Inter", "Inter var", "Segoe UI", system-ui, -apple-system, "Helvetica Neue", sans-serif; --font-sans: "Inter", "Inter var", "Segoe UI", system-ui, -apple-system, "Helvetica Neue", sans-serif;
--font-mono: "DM Mono", "JetBrains Mono", "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", monospace; --font-mono: "DM Mono", "JetBrains Mono", "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", monospace;
--color-canvas: white; --color-canvas: white;
--color-surface: white; --color-surface: #def;
--color-surface-subtle: white; --color-surface-subtle: #bcf;
--color-surface-hover: oklab(0.97 -0.01 -0.02); --color-surface-hover: oklab(0.97 -0.01 -0.02);
--color-dialog: oklab(0.96 -0.01 -0.03); --color-dialog: oklab(0.96 -0.01 -0.03);
--color-border: oklab(0.82 -0.02 -0.06); --color-border: oklab(0.82 -0.02 -0.06);
@@ -21,7 +21,7 @@
--color-link: oklab(0.5 -0.06 -0.17); --color-link: oklab(0.5 -0.06 -0.17);
--color-link-hover: oklab(0.45 -0.06 -0.19); --color-link-hover: oklab(0.45 -0.06 -0.19);
--color-accent: oklab(0.55 -0.06 -0.19); --color-accent: oklab(0.55 -0.06 -0.19);
--color-accent-strong: oklab(0.45 -0.06 -0.19); --color-accent-strong: #46f;
--color-accent-contrast: white; --color-accent-contrast: white;
--color-secondary: oklab(0.55 -0.02 -0.05); --color-secondary: oklab(0.55 -0.02 -0.05);
--color-secondary-strong: oklab(0.45 -0.02 -0.05); --color-secondary-strong: oklab(0.45 -0.02 -0.05);
+6
View File
@@ -1,11 +1,13 @@
import argparse import argparse
import logging import logging
import os import os
import sys
import msgspec import msgspec
from fastapi_vue import server from fastapi_vue import server
from fastapi_vue.hostutil import parse_endpoints from fastapi_vue.hostutil import parse_endpoints
from paskia._version import __version__
from paskia.db.jsonl import load_readonly from paskia.db.jsonl import load_readonly
from paskia.util import startupbox from paskia.util import startupbox
from paskia.util.hostutil import ( from paskia.util.hostutil import (
@@ -74,7 +76,11 @@ def main():
# Load stored config (read-only, no writes, no global state) # Load stored config (read-only, no writes, no global state)
db_path = os.environ.get("PASKIA_DB", f"{args.rp_id}.paskiadb") db_path = os.environ.get("PASKIA_DB", f"{args.rp_id}.paskiadb")
try:
config = load_readonly(db_path, rp_id=args.rp_id).config config = load_readonly(db_path, rp_id=args.rp_id).config
except SystemExit as e:
print(f"🛑 Paskia {__version__} could not load")
sys.exit(str(e))
# Override stored config with CLI args, or clear with empty string # Override stored config with CLI args, or clear with empty string
if args.rp_name is not None: if args.rp_name is not None:
+21 -20
View File
@@ -34,23 +34,21 @@ _logger = logging.getLogger(__name__)
class ReplayResult(msgspec.Struct, frozen=False): class ReplayResult(msgspec.Struct, frozen=False):
"""Return value of _replay_from_data""" """Return value of _replay_from_data"""
state: dict state: dict = {}
v: int = 0 v: int = 0
ts: datetime | None = None ts: datetime | None = None
snapts: datetime | None = None snapts: datetime | None = None
changes: int = 0 changes: int = 0
class DatabaseError(Exception): class DatabaseError(ValueError):
"""Exception raised for database loading errors.""" """Exception raised for database loading errors."""
pass
def _replay_from_data(data: bytes, db_path: str) -> ReplayResult: def _replay_from_data(data: bytes, db_path: str) -> ReplayResult:
"""Replay database state from file data, using the last snapshot if available.""" """Replay database state from file data, using the last snapshot if available."""
resolved_path = str(Path(db_path).resolve()) resolved_path = str(Path(db_path).resolve())
result = ReplayResult(state={}) result = ReplayResult()
# Find and apply the last snapshot # Find and apply the last snapshot
snap, start_offset = SnapshotState.load(data) snap, start_offset = SnapshotState.load(data)
@@ -61,14 +59,16 @@ def _replay_from_data(data: bytes, db_path: str) -> ReplayResult:
# Replay change records after the snapshot # Replay change records after the snapshot
lines = data[start_offset:].split(b"\n") lines = data[start_offset:].split(b"\n")
for line_num, raw in enumerate(lines, start=1): # 1-based line numbering for raw in lines:
line = raw.strip() line = raw.strip()
if not line: if not line:
continue continue
try: try:
change = msgspec.json.decode(line, type=ChangeRecord) change = msgspec.json.decode(line, type=ChangeRecord)
except msgspec.DecodeError as e: except msgspec.DecodeError as e:
raise DatabaseError(f"{resolved_path}:{line_num}: {e}") raise DatabaseError(
f"{resolved_path}: {e}\n{line.decode(errors='replace')}"
)
result.state = jsondiff.patch(result.state, change.diff, marshal=True) result.state = jsondiff.patch(result.state, change.diff, marshal=True)
result.v = change.v result.v = change.v
result.ts = change.ts result.ts = change.ts
@@ -88,19 +88,10 @@ def load_readonly(db_path: str, *, rp_id: str = "localhost") -> DB:
return DB(config=Config(rp_id=rp_id)) return DB(config=Config(rp_id=rp_id))
try: try:
with open(path, "rb") as f: content = path.read_bytes()
content = f.read()
r = _replay_from_data(content, str(path.resolve())) r = _replay_from_data(content, str(path.resolve()))
data_dict = r.state data_dict = r.state
version = r.v version = r.v
except OSError as e:
_logger.exception("Failed to load database")
raise SystemExit(f"{e}")
except (ValueError, msgspec.DecodeError, DatabaseError) as e:
raise SystemExit(f"{e}")
except Exception as e:
_logger.exception("Unexpected error loading database")
raise SystemExit(f"{e}")
if not data_dict: if not data_dict:
return DB(config=Config(rp_id=rp_id)) return DB(config=Config(rp_id=rp_id))
@@ -109,13 +100,23 @@ def load_readonly(db_path: str, *, rp_id: str = "localhost") -> DB:
apply_migrations_readonly(data_dict, version, MigrationCtx(rp_id=rp_id)) apply_migrations_readonly(data_dict, version, MigrationCtx(rp_id=rp_id))
# Decode to msgspec struct # Decode to msgspec struct
db = msgspec.json.decode(msgspec.json.encode(data_dict), type=DB) try:
return db return msgspec.json.decode(msgspec.json.encode(data_dict), type=DB)
except msgspec.ValidationError as e:
raise DatabaseError(f"{path.resolve()}: {e}") from None
except OSError as e:
_logger.exception("Failed to load database")
raise SystemExit(f"{e}")
except (ValueError, msgspec.DecodeError) as e:
raise SystemExit(f"{e}")
except Exception as e:
_logger.exception("Unexpected error loading database")
raise SystemExit(f"{e}")
class ChangeRecord(msgspec.Struct, omit_defaults=True, kw_only=True): class ChangeRecord(msgspec.Struct, omit_defaults=True, kw_only=True):
ts: datetime = msgspec.field(default_factory=lambda: datetime.now(UTC)) ts: datetime = msgspec.field(default_factory=lambda: datetime.now(UTC))
a: str # action - describes the operation (e.g., "migrate", "login", "create_user") a: str = "" # action (e.g., "migrate", "login", "create_user")
v: int = 0 # schema version after this change v: int = 0 # schema version after this change
u: str | None = None # user UUID who performed the action (None for system) u: str | None = None # user UUID who performed the action (None for system)
diff: dict diff: dict
+7
View File
@@ -45,6 +45,13 @@ def migrate_v4(d: dict, ctx: MigrationCtx) -> None:
d["oidc"] = {"clients": {}, "key": base64.standard_b64encode(secret_key()).decode()} d["oidc"] = {"clients": {}, "key": base64.standard_b64encode(secret_key()).decode()}
def migrate_v5(d: dict, ctx: MigrationCtx) -> None:
"""Convert config.listen from str to list[str] if needed."""
listen = d["config"].get("listen")
if listen and isinstance(listen, str):
d["config"]["listen"] = [listen]
migrations = sorted( migrations = sorted(
[f for n, f in globals().items() if n.startswith("migrate_v")], [f for n, f in globals().items() if n.startswith("migrate_v")],
key=lambda f: int(f.__name__.removeprefix("migrate_v")), key=lambda f: int(f.__name__.removeprefix("migrate_v")),
+2 -1
View File
@@ -14,6 +14,7 @@ from paskia.db import start_background, stop_background
from paskia.db.background import flush from paskia.db.background import flush
from paskia.db.logging import configure_db_logging from paskia.db.logging import configure_db_logging
from paskia.fastapi import admin, api, auth_host, oid, ws from paskia.fastapi import admin, api, auth_host, oid, ws
from paskia.fastapi.admin.adminapp import adminapp
# Import frontend instance # Import frontend instance
from paskia.fastapi.front import frontend from paskia.fastapi.front import frontend
@@ -162,7 +163,7 @@ async def admin_root_redirect():
@app.get("/admin/", include_in_schema=False) @app.get("/admin/", include_in_schema=False)
@app.get("/auth/admin/", include_in_schema=False) @app.get("/auth/admin/", include_in_schema=False)
async def admin_root(request: Request, auth=AUTH_COOKIE): async def admin_root(request: Request, auth=AUTH_COOKIE):
return await admin.adminapp(request, auth) # Delegated to admin app return await adminapp(request, auth) # Delegated to admin app
@app.get("/auth/examples/", include_in_schema=False) @app.get("/auth/examples/", include_in_schema=False)