Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c452f325a | ||
|
|
e9b6bc7a3d | ||
|
|
f5545b48f0 | ||
|
|
c1b2bcf76c | ||
|
|
1806bcab5c | ||
|
|
be177cbafc | ||
|
|
f5ccc204be |
@@ -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; }
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
+7
-1
@@ -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")
|
||||||
config = load_readonly(db_path, rp_id=args.rp_id).config
|
try:
|
||||||
|
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:
|
||||||
|
|||||||
+22
-21
@@ -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,34 +88,35 @@ 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
|
||||||
|
|
||||||
|
if not data_dict:
|
||||||
|
return DB(config=Config(rp_id=rp_id))
|
||||||
|
|
||||||
|
# Apply migrations in-memory (no persistence)
|
||||||
|
apply_migrations_readonly(data_dict, version, MigrationCtx(rp_id=rp_id))
|
||||||
|
|
||||||
|
# Decode to msgspec struct
|
||||||
|
try:
|
||||||
|
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:
|
except OSError as e:
|
||||||
_logger.exception("Failed to load database")
|
_logger.exception("Failed to load database")
|
||||||
raise SystemExit(f"{e}")
|
raise SystemExit(f"{e}")
|
||||||
except (ValueError, msgspec.DecodeError, DatabaseError) as e:
|
except (ValueError, msgspec.DecodeError) as e:
|
||||||
raise SystemExit(f"{e}")
|
raise SystemExit(f"{e}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.exception("Unexpected error loading database")
|
_logger.exception("Unexpected error loading database")
|
||||||
raise SystemExit(f"{e}")
|
raise SystemExit(f"{e}")
|
||||||
|
|
||||||
if not data_dict:
|
|
||||||
return DB(config=Config(rp_id=rp_id))
|
|
||||||
|
|
||||||
# Apply migrations in-memory (no persistence)
|
|
||||||
apply_migrations_readonly(data_dict, version, MigrationCtx(rp_id=rp_id))
|
|
||||||
|
|
||||||
# Decode to msgspec struct
|
|
||||||
db = msgspec.json.decode(msgspec.json.encode(data_dict), type=DB)
|
|
||||||
return db
|
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -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")),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user