Compare commits

..

4 commits

Author SHA1 Message Date
1a865da656 🐛 use ?? operator where suited instead of || 2025-12-30 13:51:23 +00:00
6a7491b15b 🔧 model rework for "Credentials"
affects:
- environment vars/api.conf: "WEBDAV__USERNAME" -> "WEBDAV__AUTH__USERNAME" and "WEBDAV__PASSWORD" -> "WEBDAV__AUTH__PASSWORD"
- remote config.toml: "[admin] name" -> "[admin] username"
2025-12-30 13:50:35 +00:00
6bbfab2758 Preview in AdminView is now refreshable 2025-12-30 02:54:26 +00:00
811fec743e 🔧 improved secret handling
- BulmaSecret automatically returns to "hidden" after 2.5 seconds in "pending"
- ConfigView clears the credential values if not viewed
2025-12-30 02:31:53 +00:00
12 changed files with 108 additions and 68 deletions

View file

@ -4,15 +4,10 @@ from markdown import markdown
from pydantic import BaseModel, ConfigDict, field_validator from pydantic import BaseModel, ConfigDict, field_validator
from .dav.webdav import WebDAV from .dav.webdav import WebDAV
from .settings import SETTINGS from .settings import SETTINGS, Credentials
from .transformed_string import TransformedString from .transformed_string import TransformedString
class User(BaseModel):
name: str
password: str
class Site(BaseModel): class Site(BaseModel):
model_config = ConfigDict(validate_default=True) model_config = ConfigDict(validate_default=True)
@ -60,7 +55,7 @@ class Image(BaseModel):
class Config(BaseModel): class Config(BaseModel):
# Login-Daten für Admin-Modus # Login-Daten für Admin-Modus
admin: User admin: Credentials
# Lösungswort # Lösungswort
solution: TransformedString solution: TransformedString

View file

@ -16,8 +16,8 @@ class WebDAV:
_webdav_client = WebDAVclient( _webdav_client = WebDAVclient(
{ {
"webdav_hostname": SETTINGS.webdav.url, "webdav_hostname": SETTINGS.webdav.url,
"webdav_login": SETTINGS.webdav.username, "webdav_login": SETTINGS.webdav.auth.username,
"webdav_password": SETTINGS.webdav.password, "webdav_password": SETTINGS.webdav.auth.password,
} }
) )

View file

@ -6,6 +6,11 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
T = TypeVar("T") T = TypeVar("T")
class Credentials(BaseModel):
username: str = ""
password: str = ""
class DavSettings(BaseModel): class DavSettings(BaseModel):
""" """
Connection to a DAV server. Connection to a DAV server.
@ -16,8 +21,10 @@ class DavSettings(BaseModel):
path: str = "/remote.php/webdav" path: str = "/remote.php/webdav"
prefix: str = "/advent22" prefix: str = "/advent22"
username: str = "advent22_user" auth: Credentials = Credentials(
password: str = "password" username="advent22_user",
password="password",
)
cache_ttl: int = 60 * 10 cache_ttl: int = 60 * 10
config_filename: str = "config.toml" config_filename: str = "config.toml"

View file

@ -21,7 +21,7 @@ async def user_is_admin(
username_correct = secrets.compare_digest( username_correct = secrets.compare_digest(
credentials.username.lower(), credentials.username.lower(),
cfg.admin.name.lower(), cfg.admin.username.lower(),
) )
password_correct = secrets.compare_digest( password_correct = secrets.compare_digest(
credentials.password, credentials.password,

View file

@ -5,7 +5,11 @@ from pydantic import BaseModel
from advent22_api.core.helpers import EventDates from advent22_api.core.helpers import EventDates
from ..core.calendar_config import CalendarConfig, DoorsSaved, get_calendar_config from ..core.calendar_config import (
CalendarConfig,
DoorsSaved,
get_calendar_config,
)
from ..core.config import Config, Image, get_config from ..core.config import Config, Image, get_config
from ..core.depends import ( from ..core.depends import (
TTFont, TTFont,
@ -14,7 +18,7 @@ from ..core.depends import (
get_all_parts, get_all_parts,
get_all_ttfonts, get_all_ttfonts,
) )
from ..core.settings import SETTINGS, RedisSettings from ..core.settings import SETTINGS, Credentials, RedisSettings
from ._security import require_admin, user_is_admin from ._security import require_admin, user_is_admin
router = APIRouter(prefix="/admin", tags=["admin"]) router = APIRouter(prefix="/admin", tags=["admin"])
@ -170,24 +174,16 @@ async def put_doors(
await cal_cfg.change(cfg) await cal_cfg.change(cfg)
@router.get("/dav_credentials") @router.get("/credentials/{name}")
async def get_dav_credentials( async def get_credentials(
_: None = Depends(require_admin), name: str,
) -> tuple[str, str]:
"""
Zugangsdaten für WebDAV
"""
return SETTINGS.webdav.username, SETTINGS.webdav.password
@router.get("/ui_credentials")
async def get_ui_credentials(
_: None = Depends(require_admin), _: None = Depends(require_admin),
cfg: Config = Depends(get_config), cfg: Config = Depends(get_config),
) -> tuple[str, str]: ) -> Credentials:
"""
Zugangsdaten für Admin-UI
"""
return cfg.admin.name, cfg.admin.password if name == "dav":
return SETTINGS.webdav.auth
elif name == "ui":
return cfg.admin
else:
return Credentials()

View file

@ -2,15 +2,19 @@
<ConfigView /> <ConfigView />
<CalendarAssistant /> <CalendarAssistant />
<DoorMapEditor /> <DoorMapEditor />
<BulmaDrawer header="Vorschau"> <BulmaDrawer header="Vorschau" :opening="store.update" refreshable>
<UserView /> <UserView />
</BulmaDrawer> </BulmaDrawer>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { advent22Store } from "@/lib/store";
import UserView from "../UserView.vue"; import UserView from "../UserView.vue";
import BulmaDrawer from "../bulma/Drawer.vue"; import BulmaDrawer from "../bulma/Drawer.vue";
import CalendarAssistant from "./CalendarAssistant.vue"; import CalendarAssistant from "./CalendarAssistant.vue";
import ConfigView from "./ConfigView.vue"; import ConfigView from "./ConfigView.vue";
import DoorMapEditor from "./DoorMapEditor.vue"; import DoorMapEditor from "./DoorMapEditor.vue";
const store = advent22Store();
</script> </script>

View file

@ -139,12 +139,15 @@
<dt>Zugangsdaten</dt> <dt>Zugangsdaten</dt>
<dd class="is-family-monospace"> <dd class="is-family-monospace">
<BulmaSecret @load="load_dav_credentials"> <BulmaSecret
@show="load_credentials(creds.dav, 'admin/credentials/dav')"
@hide="clear_credentials(creds.dav)"
>
<span class="tag is-danger">user</span> <span class="tag is-danger">user</span>
{{ dav_credentials[0] }} {{ creds.dav.username }}
<br /> <br />
<span class="tag is-danger">pass</span> <span class="tag is-danger">pass</span>
{{ dav_credentials[1] }} {{ creds.dav.password }}
</BulmaSecret> </BulmaSecret>
</dd> </dd>
@ -167,12 +170,15 @@
<dt>UI-Admin</dt> <dt>UI-Admin</dt>
<dd class="is-family-monospace"> <dd class="is-family-monospace">
<BulmaSecret @load="load_ui_credentials"> <BulmaSecret
@show="load_credentials(creds.ui, 'admin/credentials/ui')"
@hide="clear_credentials(creds.ui)"
>
<span class="tag is-danger">user</span> <span class="tag is-danger">user</span>
{{ ui_credentials[0] }} {{ creds.ui.username }}
<br /> <br />
<span class="tag is-danger">pass</span> <span class="tag is-danger">pass</span>
{{ ui_credentials[1] }} {{ creds.ui.password }}
</BulmaSecret> </BulmaSecret>
</dd> </dd>
</dl> </dl>
@ -237,8 +243,16 @@ const admin_config_model = ref<AdminConfigModel>({
}); });
const doors = ref<DoorSaved[]>([]); const doors = ref<DoorSaved[]>([]);
const dav_credentials = ref<Credentials>(["", ""]); const creds = ref<Record<string, Credentials>>({
const ui_credentials = ref<Credentials>(["", ""]); dav: {
username: "",
password: "",
},
ui: {
username: "",
password: "",
},
});
function fmt_puzzle_date(name: keyof AdminConfigModel["puzzle"]): string { function fmt_puzzle_date(name: keyof AdminConfigModel["puzzle"]): string {
const iso_date = admin_config_model.value.puzzle[name]; const iso_date = admin_config_model.value.puzzle[name];
@ -257,22 +271,26 @@ async function on_open(): Promise<void> {
void store_update; // discard value void store_update; // discard value
admin_config_model.value = new_admin_config_model; admin_config_model.value = new_admin_config_model;
doors.value = new_doors; doors.value = new_doors;
clear_credentials(creds.value.dav);
clear_credentials(creds.value.ui);
} }
async function load_dav_credentials(): Promise<void> { async function load_credentials(
creds: Credentials,
endpoint: string,
): Promise<void> {
try { try {
dav_credentials.value = await API.request<Credentials>( const new_creds = await API.request<Credentials>(endpoint);
"admin/dav_credentials",
); creds.username = new_creds.username;
creds.password = new_creds.password;
} catch {} } catch {}
} }
async function load_ui_credentials(): Promise<void> { function clear_credentials(creds: Credentials): void {
try { creds.username = "";
ui_credentials.value = await API.request<Credentials>( creds.password = "";
"admin/ui_credentials",
);
} catch {}
} }
</script> </script>

View file

@ -4,8 +4,8 @@
<span v-else>***</span> <span v-else>***</span>
<BulmaButton <BulmaButton
:class="`is-small is-${record.color} ml-2`" :class="`is-small is-${record.color} ml-2`"
:icon="['fas', `${record.icon}`]" :icon="['fas', record.icon]"
:busy="state === 'clicked'" :busy="state === 'pending'"
@click="on_click" @click="on_click"
/> />
</template> </template>
@ -16,24 +16,37 @@ import { computed, ref } from "vue";
import BulmaButton from "./Button.vue"; import BulmaButton from "./Button.vue";
const emit = defineEmits<{ const emit = defineEmits<{
(event: "load"): void; (event: "show"): void;
(event: "hide"): void;
}>(); }>();
type State = "hidden" | "clicked" | "visible"; type State = "hidden" | "pending" | "visible";
const state = ref<State>("hidden"); const state = ref<State>("hidden");
const state_map: Record<State, { color: string; icon: string; next: State }> = { const state_map: Record<State, { color: string; icon: string; next: State }> = {
hidden: { color: "primary", icon: "eye-slash", next: "clicked" }, hidden: { color: "primary", icon: "eye-slash", next: "pending" },
clicked: { color: "warning", icon: "eye-slash", next: "visible" }, pending: { color: "warning", icon: "eye-slash", next: "visible" },
visible: { color: "danger", icon: "eye", next: "hidden" }, visible: { color: "danger", icon: "eye", next: "hidden" },
} as const; } as const;
const record = computed(() => state_map[state.value] ?? state_map.hidden); const record = computed(() => state_map[state.value] ?? state_map.hidden);
let pending_timeout: number | undefined;
function on_click(): void { function on_click(): void {
state.value = record.value.next; state.value = record.value.next;
if (state.value === "hidden") {
emit("hide");
}
if (state.value === "pending") {
pending_timeout = window.setTimeout(() => (state.value = "hidden"), 2500);
} else {
window.clearTimeout(pending_timeout);
}
if (state.value === "visible") { if (state.value === "visible") {
emit("load"); emit("show");
} }
} }
</script> </script>

View file

@ -44,12 +44,16 @@ export class API {
} }
public static get creds(): AxiosBasicCredentials { public static get creds(): AxiosBasicCredentials {
const auth_json = localStorage.getItem(this.creds_key); const stored_auth = JSON.parse(localStorage.getItem(this.creds_key) ?? "");
if (auth_json !== null) { if (
return JSON.parse(auth_json); stored_auth !== null &&
} else { Object.hasOwn(stored_auth, "username") &&
return { username: "", password: "" }; Object.hasOwn(stored_auth, "password")
) {
return stored_auth;
} }
return { username: "", password: "" };
} }
private static get_axios_config({ private static get_axios_config({

View file

@ -64,4 +64,7 @@ export interface ImageData {
data_url: string; data_url: string;
} }
export type Credentials = [username: string, password: string]; export interface Credentials {
username: string;
password: string;
}

View file

@ -67,7 +67,7 @@ export class Rectangle {
} }
public update(corner_1?: Vector2D, corner_2?: Vector2D): Rectangle { public update(corner_1?: Vector2D, corner_2?: Vector2D): Rectangle {
return new Rectangle(corner_1 || this.corner_1, corner_2 || this.corner_2); return new Rectangle(corner_1 ?? this.corner_1, corner_2 ?? this.corner_2);
} }
public move(vector: Vector2D): Rectangle { public move(vector: Vector2D): Rectangle {

View file

@ -57,7 +57,7 @@ export const advent22Store = defineStore({
const favicon = await API.request<ImageData>("user/favicon"); const favicon = await API.request<ImageData>("user/favicon");
const link: HTMLLinkElement = const link: HTMLLinkElement =
document.querySelector("link[rel*='icon']") || document.querySelector("link[rel*='icon']") ??
document.createElement("link"); document.createElement("link");
link.rel = "shortcut icon"; link.rel = "shortcut icon";
link.type = "image/x-icon"; link.type = "image/x-icon";
@ -111,12 +111,12 @@ export const advent22Store = defineStore({
}, },
async login(creds: Credentials): Promise<boolean> { async login(creds: Credentials): Promise<boolean> {
API.creds = { username: creds[0], password: creds[1] }; API.creds = creds;
return await this.update_is_admin(); return await this.update_is_admin();
}, },
logout() { logout() {
API.creds = { username: "", password: "" }; API.creds = null;
this.is_admin = false; this.is_admin = false;
}, },