chore: sync public repository
CI / checks (push) Has been cancelled

This commit is contained in:
maddin
2026-03-22 15:36:47 +00:00
parent 6fbd1bb3c2
commit 847f20c9d7
16 changed files with 402 additions and 23 deletions
+1 -1
View File
@@ -1 +1 @@
1.5.12 1.6.1
+75 -4
View File
@@ -145,6 +145,12 @@ SUPPORT_TICKET_RATE_LIMIT_WINDOW = timedelta(minutes=30)
SUPPORT_TICKET_RATE_LIMIT_MAX_PER_IP = 3 SUPPORT_TICKET_RATE_LIMIT_MAX_PER_IP = 3
SUPPORT_TICKET_RATE_LIMIT_MAX_PER_EMAIL = 5 SUPPORT_TICKET_RATE_LIMIT_MAX_PER_EMAIL = 5
SUPPORT_TICKET_MIN_FORM_SECONDS = 3 SUPPORT_TICKET_MIN_FORM_SECONDS = 3
THEME_PREFERENCE_AUTO = "auto"
THEME_PREFERENCE_DARK = "dark"
THEME_PREFERENCE_LIGHT = "light"
THEME_PREFERENCES = {THEME_PREFERENCE_AUTO, THEME_PREFERENCE_DARK, THEME_PREFERENCE_LIGHT}
THEME_COLOR_DARK = "#2c2d2f"
THEME_COLOR_LIGHT = "#f3f4f6"
DAY_STATUS_QUERY_LABELS = { DAY_STATUS_QUERY_LABELS = {
DAY_STATUS_QUERY_VACATION: "Urlaub", DAY_STATUS_QUERY_VACATION: "Urlaub",
SPECIAL_DAY_STATUS_HOLIDAY: "Feiertag", SPECIAL_DAY_STATUS_HOLIDAY: "Feiertag",
@@ -400,6 +406,16 @@ def create_app(settings_override: Settings | None = None) -> FastAPI:
"app_title": settings.resolved_app_title, "app_title": settings.resolved_app_title,
"app_version": settings.app_version, "app_version": settings.app_version,
"today_date": date.today(), "today_date": date.today(),
"theme_preference": (
user.theme_preference
if user and user.theme_preference in THEME_PREFERENCES
else THEME_PREFERENCE_AUTO
),
"theme_color": (
THEME_COLOR_LIGHT
if user and user.theme_preference == THEME_PREFERENCE_LIGHT
else THEME_COLOR_DARK
),
} }
context.update(extra) context.update(extra)
return context return context
@@ -463,6 +479,7 @@ def create_app(settings_override: Settings | None = None) -> FastAPI:
"automatic_break_rules_enabled": user.automatic_break_rules_enabled, "automatic_break_rules_enabled": user.automatic_break_rules_enabled,
"default_break_minutes": user.default_break_minutes, "default_break_minutes": user.default_break_minutes,
"preferred_home_view": user.preferred_home_view, "preferred_home_view": user.preferred_home_view,
"theme_preference": user.theme_preference,
"preferred_month_view_mode": user.preferred_month_view_mode, "preferred_month_view_mode": user.preferred_month_view_mode,
"entry_mode": user.entry_mode, "entry_mode": user.entry_mode,
"overtime_start_date": user.overtime_start_date.isoformat() if user.overtime_start_date else None, "overtime_start_date": user.overtime_start_date.isoformat() if user.overtime_start_date else None,
@@ -1897,6 +1914,7 @@ def create_app(settings_override: Settings | None = None) -> FastAPI:
"settings": { "settings": {
"weekly_target_minutes": user.weekly_target_minutes, "weekly_target_minutes": user.weekly_target_minutes,
"preferred_home_view": user.preferred_home_view, "preferred_home_view": user.preferred_home_view,
"theme_preference": user.theme_preference,
"preferred_month_view_mode": user.preferred_month_view_mode, "preferred_month_view_mode": user.preferred_month_view_mode,
"entry_mode": user.entry_mode, "entry_mode": user.entry_mode,
"working_days": sorted(get_user_working_days(user)), "working_days": sorted(get_user_working_days(user)),
@@ -2027,7 +2045,7 @@ def create_app(settings_override: Settings | None = None) -> FastAPI:
def render_legal_page(request: Request, *, db: Session, key: str, title: str, subtitle: str | None = None) -> HTMLResponse: def render_legal_page(request: Request, *, db: Session, key: str, title: str, subtitle: str | None = None) -> HTMLResponse:
markdown_text = get_site_content_markdown(db, key) markdown_text = get_site_content_markdown(db, key)
html_content = render_safe_markdown(markdown_text) html_content = render_safe_markdown(markdown_text, obfuscate_emails=True)
user = get_current_user(request, db) user = get_current_user(request, db)
return templates.TemplateResponse( return templates.TemplateResponse(
"pages/legal_page.html", "pages/legal_page.html",
@@ -2041,6 +2059,14 @@ def create_app(settings_override: Settings | None = None) -> FastAPI:
), ),
) )
def require_verified_ticket_user(request: Request, db: Session) -> User | RedirectResponse:
user = get_current_user(request, db)
if not user or not user.is_active:
return RedirectResponse(url="/login?msg=contact_requires_login", status_code=status.HTTP_303_SEE_OTHER)
if not user.email_verified:
return RedirectResponse(url="/verify-email/resend", status_code=status.HTTP_303_SEE_OTHER)
return user
@app.get("/impressum", response_class=HTMLResponse) @app.get("/impressum", response_class=HTMLResponse)
async def impressum_page(request: Request, db: Session = Depends(get_db)): async def impressum_page(request: Request, db: Session = Depends(get_db)):
return render_legal_page( return render_legal_page(
@@ -2063,7 +2089,10 @@ def create_app(settings_override: Settings | None = None) -> FastAPI:
@app.get("/kontakt", response_class=HTMLResponse) @app.get("/kontakt", response_class=HTMLResponse)
async def contact_form(request: Request, db: Session = Depends(get_db)): async def contact_form(request: Request, db: Session = Depends(get_db)):
user = get_current_user(request, db) required_user = require_verified_ticket_user(request, db)
if isinstance(required_user, RedirectResponse):
return required_user
user = required_user
return templates.TemplateResponse( return templates.TemplateResponse(
"pages/contact.html", "pages/contact.html",
build_context( build_context(
@@ -2096,10 +2125,13 @@ def create_app(settings_override: Settings | None = None) -> FastAPI:
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
verify_csrf(request, csrf_token) verify_csrf(request, csrf_token)
user = get_current_user(request, db) required_user = require_verified_ticket_user(request, db)
if isinstance(required_user, RedirectResponse):
return required_user
user = required_user
normalized_name = name.strip() normalized_name = name.strip()
normalized_email = email.strip().lower() normalized_email = user.email.strip().lower()
normalized_subject = subject.strip() normalized_subject = subject.strip()
normalized_message = message.strip() normalized_message = message.strip()
started_at_expected = request.session.get("contact_form_started_at") started_at_expected = request.session.get("contact_form_started_at")
@@ -2495,6 +2527,10 @@ def create_app(settings_override: Settings | None = None) -> FastAPI:
success_message = "Bitte bestätige zuerst deine E-Mail-Adresse über den Link in der E-Mail." success_message = "Bitte bestätige zuerst deine E-Mail-Adresse über den Link in der E-Mail."
elif msg == "email_verified": elif msg == "email_verified":
success_message = "E-Mail-Adresse bestätigt. Du kannst dich jetzt anmelden." success_message = "E-Mail-Adresse bestätigt. Du kannst dich jetzt anmelden."
elif msg == "contact_requires_login":
error_message = "Für Kontaktanfragen musst du zuerst eingeloggt sein."
elif msg == "contact_requires_verification":
error_message = "Bitte bestätige zuerst deine E-Mail-Adresse, bevor du das Ticketsystem nutzt."
elif msg == "email_verification_send_failed": elif msg == "email_verification_send_failed":
error_message = ( error_message = (
"Konto wurde erstellt, aber die Bestätigungs-E-Mail konnte nicht versendet werden. " "Konto wurde erstellt, aber die Bestätigungs-E-Mail konnte nicht versendet werden. "
@@ -4367,6 +4403,7 @@ def create_app(settings_override: Settings | None = None) -> FastAPI:
async def settings_update_preferences( async def settings_update_preferences(
request: Request, request: Request,
preferred_home_view: str = Form(...), preferred_home_view: str = Form(...),
theme_preference: str = Form(default=""),
preferred_month_view_mode: str = Form(...), preferred_month_view_mode: str = Form(...),
entry_mode: str = Form(default=""), entry_mode: str = Form(default=""),
csrf_token: str = Form(...), csrf_token: str = Form(...),
@@ -4385,6 +4422,15 @@ def create_app(settings_override: Settings | None = None) -> FastAPI:
error="Ungueltige Standardansicht.", error="Ungueltige Standardansicht.",
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
) )
selected_theme_preference = (theme_preference or user.theme_preference or THEME_PREFERENCE_AUTO).strip()
if selected_theme_preference not in THEME_PREFERENCES:
return render_settings_form(
request,
db=db,
user=user,
error="Ungueltiges Darstellungs-Theme.",
status_code=status.HTTP_400_BAD_REQUEST,
)
if preferred_month_view_mode not in {"flat", "weeks"}: if preferred_month_view_mode not in {"flat", "weeks"}:
return render_settings_form( return render_settings_form(
request, request,
@@ -4403,6 +4449,7 @@ def create_app(settings_override: Settings | None = None) -> FastAPI:
) )
user.preferred_home_view = preferred_home_view user.preferred_home_view = preferred_home_view
user.theme_preference = selected_theme_preference
user.preferred_month_view_mode = preferred_month_view_mode user.preferred_month_view_mode = preferred_month_view_mode
new_entry_mode = entry_mode or user.entry_mode new_entry_mode = entry_mode or user.entry_mode
switched_to_auto_until_today = user.entry_mode != ENTRY_MODE_AUTO_UNTIL_TODAY and new_entry_mode == ENTRY_MODE_AUTO_UNTIL_TODAY switched_to_auto_until_today = user.entry_mode != ENTRY_MODE_AUTO_UNTIL_TODAY and new_entry_mode == ENTRY_MODE_AUTO_UNTIL_TODAY
@@ -4529,6 +4576,7 @@ def create_app(settings_override: Settings | None = None) -> FastAPI:
async def settings_update_weekly_target( async def settings_update_weekly_target(
request: Request, request: Request,
weekly_target_hours: float = Form(...), weekly_target_hours: float = Form(...),
entry_mode: str = Form(default=""),
automatic_break_rules_enabled: str | None = Form(default=None), automatic_break_rules_enabled: str | None = Form(default=None),
default_break_minutes_value: str = Form(default="", alias="default_break_minutes"), default_break_minutes_value: str = Form(default="", alias="default_break_minutes"),
csrf_token: str = Form(...), csrf_token: str = Form(...),
@@ -4549,6 +4597,15 @@ def create_app(settings_override: Settings | None = None) -> FastAPI:
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
) )
if entry_mode and entry_mode not in {ENTRY_MODE_MANUAL, ENTRY_MODE_AUTO_UNTIL_TODAY}:
return render_settings_form(
request,
db=db,
user=user,
error="Ungueltiger Erfassungsmodus.",
status_code=status.HTTP_400_BAD_REQUEST,
)
break_rules_enabled = automatic_break_rules_enabled == "on" break_rules_enabled = automatic_break_rules_enabled == "on"
default_break_minutes = user.default_break_minutes if break_rules_enabled else 0 default_break_minutes = user.default_break_minutes if break_rules_enabled else 0
if default_break_minutes_value.strip(): if default_break_minutes_value.strip():
@@ -4580,9 +4637,23 @@ def create_app(settings_override: Settings | None = None) -> FastAPI:
new_target_minutes=new_target_minutes, new_target_minutes=new_target_minutes,
scope="all_weeks", scope="all_weeks",
) )
new_entry_mode = entry_mode or user.entry_mode
switched_to_auto_until_today = user.entry_mode != ENTRY_MODE_AUTO_UNTIL_TODAY and new_entry_mode == ENTRY_MODE_AUTO_UNTIL_TODAY
user.weekly_target_minutes = new_target_minutes user.weekly_target_minutes = new_target_minutes
user.entry_mode = new_entry_mode
user.automatic_break_rules_enabled = break_rules_enabled user.automatic_break_rules_enabled = break_rules_enabled
user.default_break_minutes = default_break_minutes user.default_break_minutes = default_break_minutes
delete_future_auto_entries(db=db, user_id=user.id, after_date=date.today())
if switched_to_auto_until_today:
ensure_user_has_default_target_rule(db, user)
autofill_entries_for_range(
db=db,
user=user,
range_start=user.created_at.date(),
range_end=date.today(),
)
db.commit() db.commit()
return RedirectResponse(url="/settings?msg=weekly_target_updated", status_code=status.HTTP_303_SEE_OTHER) return RedirectResponse(url="/settings?msg=weekly_target_updated", status_code=status.HTTP_303_SEE_OTHER)
+1
View File
@@ -20,6 +20,7 @@ class User(Base):
role: Mapped[str] = mapped_column(String(32), default="user", nullable=False) role: Mapped[str] = mapped_column(String(32), default="user", nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
preferred_home_view: Mapped[str] = mapped_column(String(16), default="week", nullable=False) preferred_home_view: Mapped[str] = mapped_column(String(16), default="week", nullable=False)
theme_preference: Mapped[str] = mapped_column(String(16), default="auto", nullable=False)
preferred_month_view_mode: Mapped[str] = mapped_column(String(16), default="flat", nullable=False) preferred_month_view_mode: Mapped[str] = mapped_column(String(16), default="flat", nullable=False)
entry_mode: Mapped[str] = mapped_column(String(16), default="manual", nullable=False) entry_mode: Mapped[str] = mapped_column(String(16), default="manual", nullable=False)
working_days_csv: Mapped[str] = mapped_column(String(32), default="0,1,2,3,4", nullable=False) working_days_csv: Mapped[str] = mapped_column(String(32), default="0,1,2,3,4", nullable=False)
+6
View File
@@ -133,6 +133,10 @@ def _normalize_settings(payload: dict[str, Any]) -> dict[str, Any]:
if preferred_home_view not in PREFERRED_HOME_VIEWS: if preferred_home_view not in PREFERRED_HOME_VIEWS:
preferred_home_view = "week" preferred_home_view = "week"
theme_preference = settings_data.get("theme_preference", "auto")
if theme_preference not in {"auto", "dark", "light"}:
theme_preference = "auto"
preferred_month_view_mode = settings_data.get("preferred_month_view_mode", "flat") preferred_month_view_mode = settings_data.get("preferred_month_view_mode", "flat")
if preferred_month_view_mode not in PREFERRED_MONTH_VIEWS: if preferred_month_view_mode not in PREFERRED_MONTH_VIEWS:
preferred_month_view_mode = "flat" preferred_month_view_mode = "flat"
@@ -170,6 +174,7 @@ def _normalize_settings(payload: dict[str, Any]) -> dict[str, Any]:
return { return {
"weekly_target_minutes": _parse_int(settings_data.get("weekly_target_minutes", 1500), label="Wochenstunden", minimum=1), "weekly_target_minutes": _parse_int(settings_data.get("weekly_target_minutes", 1500), label="Wochenstunden", minimum=1),
"preferred_home_view": preferred_home_view, "preferred_home_view": preferred_home_view,
"theme_preference": theme_preference,
"preferred_month_view_mode": preferred_month_view_mode, "preferred_month_view_mode": preferred_month_view_mode,
"entry_mode": entry_mode, "entry_mode": entry_mode,
"working_days": sorted(working_days), "working_days": sorted(working_days),
@@ -524,6 +529,7 @@ def parse_preview_payload(preview: ImportPreview) -> dict[str, Any]:
def _apply_settings_from_backup(*, user: User, settings_data: dict[str, Any]) -> None: def _apply_settings_from_backup(*, user: User, settings_data: dict[str, Any]) -> None:
user.weekly_target_minutes = settings_data["weekly_target_minutes"] user.weekly_target_minutes = settings_data["weekly_target_minutes"]
user.preferred_home_view = settings_data["preferred_home_view"] user.preferred_home_view = settings_data["preferred_home_view"]
user.theme_preference = settings_data["theme_preference"]
user.preferred_month_view_mode = settings_data["preferred_month_view_mode"] user.preferred_month_view_mode = settings_data["preferred_month_view_mode"]
user.entry_mode = settings_data["entry_mode"] user.entry_mode = settings_data["entry_mode"]
user.working_days_csv = serialize_working_days(settings_data["working_days"]) user.working_days_csv = serialize_working_days(settings_data["working_days"])
+29 -1
View File
@@ -1,5 +1,8 @@
from __future__ import annotations from __future__ import annotations
import html
import re
import markdown as markdown_lib import markdown as markdown_lib
import bleach import bleach
@@ -194,13 +197,36 @@ _ALLOWED_ATTRIBUTES = {
'a': ['href', 'title', 'rel', 'target'], 'a': ['href', 'title', 'rel', 'target'],
} }
_ALLOWED_PROTOCOLS = ['http', 'https', 'mailto'] _ALLOWED_PROTOCOLS = ['http', 'https', 'mailto']
_EMAIL_RE = re.compile(r'(?P<email>[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,})', re.IGNORECASE)
_MAILTO_LINK_RE = re.compile(
r'<a\b[^>]*href="mailto:(?P<href>[^"]+)"[^>]*>(?P<label>.*?)</a>',
re.IGNORECASE | re.DOTALL,
)
def default_site_content_markdown(key: str) -> str: def default_site_content_markdown(key: str) -> str:
return DEFAULT_SITE_CONTENT_MARKDOWN.get(key, '') return DEFAULT_SITE_CONTENT_MARKDOWN.get(key, '')
def render_safe_markdown(markdown_text: str) -> str: def obfuscate_email_address(email: str) -> str:
local_part, _, domain = (email or '').partition('@')
if not local_part or not domain:
return email
return f"{local_part} [at] {domain.replace('.', ' [dot] ')}"
def obfuscate_emails_in_html(html_text: str) -> str:
def replace_mailto_link(match: re.Match[str]) -> str:
href_email = html.unescape(match.group('href')).strip()
label = html.unescape(match.group('label')).strip()
email = href_email or label
return obfuscate_email_address(email)
html_text = _MAILTO_LINK_RE.sub(replace_mailto_link, html_text)
return _EMAIL_RE.sub(lambda match: obfuscate_email_address(match.group('email')), html_text)
def render_safe_markdown(markdown_text: str, *, obfuscate_emails: bool = False) -> str:
raw_html = markdown_lib.markdown( raw_html = markdown_lib.markdown(
markdown_text or '', markdown_text or '',
extensions=['extra', 'sane_lists'], extensions=['extra', 'sane_lists'],
@@ -213,6 +239,8 @@ def render_safe_markdown(markdown_text: str) -> str:
protocols=_ALLOWED_PROTOCOLS, protocols=_ALLOWED_PROTOCOLS,
strip=True, strip=True,
) )
if obfuscate_emails:
return obfuscate_emails_in_html(cleaned)
return bleach.linkify(cleaned) return bleach.linkify(cleaned)
+2
View File
@@ -17,6 +17,8 @@ def run_startup_migrations(engine: Engine) -> None:
statements: list[str] = [] statements: list[str] = []
if "preferred_home_view" not in user_columns: if "preferred_home_view" not in user_columns:
statements.append("ALTER TABLE users ADD COLUMN preferred_home_view VARCHAR(16) NOT NULL DEFAULT 'week'") statements.append("ALTER TABLE users ADD COLUMN preferred_home_view VARCHAR(16) NOT NULL DEFAULT 'week'")
if "theme_preference" not in user_columns:
statements.append("ALTER TABLE users ADD COLUMN theme_preference VARCHAR(16) NOT NULL DEFAULT 'auto'")
if "preferred_month_view_mode" not in user_columns: if "preferred_month_view_mode" not in user_columns:
statements.append("ALTER TABLE users ADD COLUMN preferred_month_view_mode VARCHAR(16) NOT NULL DEFAULT 'flat'") statements.append("ALTER TABLE users ADD COLUMN preferred_month_view_mode VARCHAR(16) NOT NULL DEFAULT 'flat'")
if "entry_mode" not in user_columns: if "entry_mode" not in user_columns:
+35
View File
@@ -12,6 +12,7 @@
} }
.app-logo { .app-logo {
filter: var(--icon-filter);
height: var(--logo-size); height: var(--logo-size);
width: var(--logo-size); width: var(--logo-size);
} }
@@ -194,6 +195,7 @@
} }
.dash-icon { .dash-icon {
filter: var(--icon-filter);
height: var(--header-icon-size); height: var(--header-icon-size);
width: var(--header-icon-size); width: var(--header-icon-size);
} }
@@ -1383,6 +1385,39 @@ select:disabled {
justify-self: end; justify-self: end;
} }
:is(html[data-theme="light"], html[data-theme="auto"][data-theme-resolved="light"]) .period-header,
:is(html[data-theme="light"], html[data-theme="auto"][data-theme-resolved="light"]) .kpi-bar__item,
:is(html[data-theme="light"], html[data-theme="auto"][data-theme-resolved="light"]) .day-row,
:is(html[data-theme="light"], html[data-theme="auto"][data-theme-resolved="light"]) .week-group-header {
border-color: var(--color-border);
}
:is(html[data-theme="light"], html[data-theme="auto"][data-theme-resolved="light"]) .period-header {
background: var(--color-surface-list-alt);
}
:is(html[data-theme="light"], html[data-theme="auto"][data-theme-resolved="light"]) .kpi-bar__item,
:is(html[data-theme="light"], html[data-theme="auto"][data-theme-resolved="light"]) .ui-chip,
:is(html[data-theme="light"], html[data-theme="auto"][data-theme-resolved="light"]) .week-group-header {
background: var(--color-surface);
}
:is(html[data-theme="light"], html[data-theme="auto"][data-theme-resolved="light"]) .ui-chip {
border-color: var(--color-border);
}
:is(html[data-theme="light"], html[data-theme="auto"][data-theme-resolved="light"]) .day-row {
background: var(--color-surface-list);
}
:is(html[data-theme="light"], html[data-theme="auto"][data-theme-resolved="light"]) .day-row--weekend {
background: var(--color-surface-list-alt);
}
:is(html[data-theme="light"], html[data-theme="auto"][data-theme-resolved="light"]) .day-row--today {
background: var(--color-surface-list-today);
}
.week-group-card-mobile .day-list { .week-group-card-mobile .day-list {
gap: var(--stack-1); gap: var(--stack-1);
padding-inline-start: var(--space-2); padding-inline-start: var(--space-2);
+2
View File
@@ -173,6 +173,8 @@
.site-footer-inner { .site-footer-inner {
align-items: flex-start; align-items: flex-start;
flex-direction: column; flex-direction: column;
padding-bottom: var(--space-7);
padding-bottom: calc(var(--space-7) + env(safe-area-inset-bottom));
} }
} }
+99 -1
View File
@@ -23,7 +23,7 @@
--color-warning-bg: #6d5500; --color-warning-bg: #6d5500;
--color-chip-bg: #202124; --color-chip-bg: #202124;
--color-weekend: #22252b; --color-weekend: #22252b;
--color-day-today: #1a3f4a; --color-day-today: #214331;
--color-accent: #9e7700; --color-accent: #9e7700;
--color-badge-bg: #f3f3f3; --color-badge-bg: #f3f3f3;
--color-badge-text: #222326; --color-badge-text: #222326;
@@ -31,6 +31,10 @@
--color-header-badge-bg: #1d1d1f; --color-header-badge-bg: #1d1d1f;
--color-header-badge-label: #f5f5f5; --color-header-badge-label: #f5f5f5;
--color-header-badge-text: #f5f5f5; --color-header-badge-text: #f5f5f5;
--color-surface-list: #2a2b2e;
--color-surface-list-alt: #24262a;
--color-surface-list-today: #214331;
--icon-filter: none;
--font-family-base: "Atkinson Hyperlegible", "Segoe UI", sans-serif; --font-family-base: "Atkinson Hyperlegible", "Segoe UI", sans-serif;
--font-size-xs: 0.75rem; --font-size-xs: 0.75rem;
@@ -99,3 +103,97 @@
--bp-md: 51.25em; --bp-md: 51.25em;
--bp-sm: 32.5em; --bp-sm: 32.5em;
} }
html {
color-scheme: dark;
}
html[data-theme="dark"] {
color-scheme: dark;
}
html[data-theme="light"] {
color-scheme: light;
--color-bg: #f3f4f6;
--color-surface: #ffffff;
--color-surface-2: #edf1f5;
--color-surface-3: #f7f8fa;
--color-surface-4: #e7ebf0;
--color-border: #d4dae2;
--color-border-soft: #e1e6ec;
--color-text: #17191d;
--color-text-muted: #5f6772;
--color-link: #17191d;
--color-primary: #dde3ea;
--color-primary-hover: #d1d8e1;
--color-button-primary: #4d8a55;
--color-button-primary-hover: #43784a;
--color-button-primary-border: #5c9a64;
--color-danger: #c95a5a;
--color-danger-strong: #d62828;
--color-success: #2f7d33;
--color-success-strong: #2cd600;
--color-success-bg: #e7f6e9;
--color-warning: #9f7200;
--color-warning-bg: #fff0c4;
--color-chip-bg: #e8ebf0;
--color-weekend: #ebeef2;
--color-day-today: #d7f1dc;
--color-accent: #9e7700;
--color-badge-bg: #2c2d2f;
--color-badge-text: #f5f5f5;
--color-workhours: #7f53d9;
--color-header-badge-bg: #e5e9ef;
--color-header-badge-label: #17191d;
--color-header-badge-text: #17191d;
--color-surface-list: #dde4ec;
--color-surface-list-alt: #cfd8e2;
--color-surface-list-today: #d1ebd6;
--icon-filter: brightness(0) saturate(100%) invert(8%) sepia(8%) saturate(834%) hue-rotate(182deg) brightness(92%) contrast(92%);
}
html[data-theme="auto"] {
color-scheme: dark;
}
@media (prefers-color-scheme: light) {
html[data-theme="auto"] {
color-scheme: light;
--color-bg: #f3f4f6;
--color-surface: #ffffff;
--color-surface-2: #edf1f5;
--color-surface-3: #f7f8fa;
--color-surface-4: #e7ebf0;
--color-border: #d4dae2;
--color-border-soft: #e1e6ec;
--color-text: #17191d;
--color-text-muted: #5f6772;
--color-link: #17191d;
--color-primary: #dde3ea;
--color-primary-hover: #d1d8e1;
--color-button-primary: #4d8a55;
--color-button-primary-hover: #43784a;
--color-button-primary-border: #5c9a64;
--color-danger: #c95a5a;
--color-danger-strong: #d62828;
--color-success: #2f7d33;
--color-success-strong: #2cd600;
--color-success-bg: #e7f6e9;
--color-warning: #9f7200;
--color-warning-bg: #fff0c4;
--color-chip-bg: #e8ebf0;
--color-weekend: #ebeef2;
--color-day-today: #d7f1dc;
--color-accent: #9e7700;
--color-badge-bg: #2c2d2f;
--color-badge-text: #f5f5f5;
--color-workhours: #7f53d9;
--color-header-badge-bg: #e5e9ef;
--color-header-badge-label: #17191d;
--color-header-badge-text: #17191d;
--color-surface-list: #dde4ec;
--color-surface-list-alt: #cfd8e2;
--color-surface-list-today: #d1ebd6;
--icon-filter: brightness(0) saturate(100%) invert(8%) sepia(8%) saturate(834%) hue-rotate(182deg) brightness(92%) contrast(92%);
}
}
+2
View File
@@ -4,8 +4,10 @@ import { initForms } from './components/forms.js?v=20260322a';
import { initModal } from './components/modal.js'; import { initModal } from './components/modal.js';
import { initDashboard } from './components/dashboard.js'; import { initDashboard } from './components/dashboard.js';
import { initSettingsSections } from './components/settings-sections.js'; import { initSettingsSections } from './components/settings-sections.js';
import { initTheme } from './components/theme.js';
function initApp() { function initApp() {
initTheme();
initCsrf(); initCsrf();
initFlash(); initFlash();
initForms(); initForms();
+42
View File
@@ -0,0 +1,42 @@
function resolveTheme(root) {
const preference = root.dataset.theme || 'auto';
if (preference === 'light' || preference === 'dark') {
return preference;
}
return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
}
function applyThemeState(root) {
const resolved = resolveTheme(root);
root.dataset.themeResolved = resolved;
const themeMeta = document.querySelector('meta[name="theme-color"]');
if (themeMeta) {
const background = getComputedStyle(root).getPropertyValue('--color-bg').trim();
if (background) {
themeMeta.setAttribute('content', background);
}
}
}
export function initTheme() {
const root = document.documentElement;
if (!root) {
return;
}
applyThemeState(root);
const media = window.matchMedia('(prefers-color-scheme: light)');
const handleChange = () => {
if ((root.dataset.theme || 'auto') === 'auto') {
applyThemeState(root);
}
};
if (typeof media.addEventListener === 'function') {
media.addEventListener('change', handleChange);
} else if (typeof media.addListener === 'function') {
media.addListener(handleChange);
}
}
+2 -2
View File
@@ -1,9 +1,9 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="de"> <html lang="de" data-theme="{{ theme_preference }}">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#2c2d2f" /> <meta name="theme-color" content="{{ theme_color }}" />
<meta name="mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" <meta name="apple-mobile-web-app-status-bar-style"
+5 -2
View File
@@ -25,8 +25,8 @@
<input type="text" name="name" value="{{ contact_name }}" maxlength="120" /> <input type="text" name="name" value="{{ contact_name }}" maxlength="120" />
</label> </label>
<label> <label>
E-Mail-Adresse Konto-E-Mail
<input type="email" name="email" value="{{ contact_email }}" maxlength="254" required /> <input type="email" name="email" value="{{ contact_email }}" maxlength="254" readonly />
</label> </label>
</div> </div>
@@ -68,6 +68,9 @@
<p class="muted"> <p class="muted">
Nachrichten werden intern als Ticket gespeichert. So gehen Rückmeldungen nicht verloren und können strukturiert bearbeitet werden. Nachrichten werden intern als Ticket gespeichert. So gehen Rückmeldungen nicht verloren und können strukturiert bearbeitet werden.
</p> </p>
<p class="muted">
Das Ticketsystem steht nur registrierten und bestätigten Nutzerkonten zur Verfügung. Die Rückmeldung wird mit der E-Mail-Adresse deines Kontos verknüpft.
</p>
<p class="muted"> <p class="muted">
Hinweise zu Anbieter und Datenschutz findest du ebenfalls unten im Footer über <a href="/impressum">Impressum</a> und <a href="/datenschutz">Datenschutz</a>. Hinweise zu Anbieter und Datenschutz findest du ebenfalls unten im Footer über <a href="/impressum">Impressum</a> und <a href="/datenschutz">Datenschutz</a>.
</p> </p>
+17 -9
View File
@@ -99,6 +99,15 @@
<p class="muted">Lege fest, wie viele Stunden du generell pro Woche arbeiten möchtest (Standard-Soll).</p> <p class="muted">Lege fest, wie viele Stunden du generell pro Woche arbeiten möchtest (Standard-Soll).</p>
<form method="post" action="/settings/weekly-target" class="stack" data-component="break-settings-form"> <form method="post" action="/settings/weekly-target" class="stack" data-component="break-settings-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" /> <input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<label>
Erfassungsmodus
<select name="entry_mode" required>
<option value="manual"
{% if user.entry_mode == 'manual' %}selected{% endif %}>Manuell (jeden Tag selbst erfassen)</option>
<option value="auto_until_today"
{% if user.entry_mode == 'auto_until_today' %}selected{% endif %}>Automatisch bis heute</option>
</select>
</label>
<label> <label>
Wochenstunden Wochenstunden
<input type="number" <input type="number"
@@ -145,17 +154,16 @@
</select> </select>
</label> </label>
<label> <label>
Erfassungsmodus Theme
<select name="entry_mode" required> <select name="theme_preference" required>
<option value="manual" <option value="auto"
{% if user.entry_mode == 'manual' %}selected{% endif %}>Manuell (jeden Tag selbst erfassen)</option> {% if user.theme_preference == 'auto' %}selected{% endif %}>Automatisch</option>
<option value="auto_until_today" <option value="dark"
{% if user.entry_mode == 'auto_until_today' %}selected{% endif %}>Automatisch bis heute</option> {% if user.theme_preference == 'dark' %}selected{% endif %}>Dark</option>
<option value="light"
{% if user.theme_preference == 'light' %}selected{% endif %}>Light</option>
</select> </select>
</label> </label>
<p class="muted">
Im automatischen Modus werden fehlende Einträge für deine Arbeitstage bis einschließlich heute automatisch angelegt. Abweichungen kannst du danach einzeln anpassen.
</p>
<button type="submit" class="button">Speichern</button> <button type="submit" class="button">Speichern</button>
</form> </form>
{% endcall %} {% endcall %}
+75 -3
View File
@@ -8,7 +8,7 @@ from sqlalchemy.orm import Session
from app.config import Settings from app.config import Settings
from app.database import get_engine from app.database import get_engine
from app.main import create_app from app.main import create_app
from app.models import SupportTicket from app.models import SupportTicket, User
def _csrf_from_html(html: str) -> str: def _csrf_from_html(html: str) -> str:
@@ -38,6 +38,18 @@ def _build_admin_app(db_url: str) -> object:
) )
def _register_and_verify_user(client: TestClient, email: str, password: str = "verystrongPass123") -> None:
register = client.post(
"/auth/register",
json={"email": email, "password": password},
)
assert register.status_code == 200
with Session(get_engine()) as db:
user = db.execute(select(User).where(User.email == email)).scalar_one()
user.email_verified = True
db.commit()
def test_public_footer_and_legal_pages_render(app): def test_public_footer_and_legal_pages_render(app):
with TestClient(app) as client: with TestClient(app) as client:
response = client.get("/login") response = client.get("/login")
@@ -54,6 +66,44 @@ def test_public_footer_and_legal_pages_render(app):
assert privacy.status_code == 200 assert privacy.status_code == 200
assert "Datenschutz" in privacy.text assert "Datenschutz" in privacy.text
contact = client.get("/kontakt", follow_redirects=False)
assert contact.status_code == 303
assert contact.headers["location"] == "/login?msg=contact_requires_login"
def test_legal_pages_obfuscate_email_addresses(tmp_path):
db_path = tmp_path / "legal-obfuscation.db"
app = _build_admin_app(f"sqlite:///{db_path}")
with TestClient(app) as client:
register = client.post(
"/auth/register",
json={"email": "admin@example.com", "password": "verystrongPass123"},
)
assert register.status_code == 200
csrf = register.json()["csrf_token"]
update_legal = client.post(
"/settings/admin/site-content",
data={
"csrf_token": csrf,
"impressum_markdown": "# Impressum\n\nE-Mail: [kontakt@example.com](mailto:kontakt@example.com)",
"privacy_markdown": "# Datenschutz\n\nE-Mail: kontakt@example.com",
},
follow_redirects=False,
)
assert update_legal.status_code == 303
impressum = client.get("/impressum")
assert impressum.status_code == 200
assert "kontakt [at] example [dot] com" in impressum.text
assert "mailto:" not in impressum.text
privacy = client.get("/datenschutz")
assert privacy.status_code == 200
assert "kontakt [at] example [dot] com" in privacy.text
assert "mailto:" not in privacy.text
def test_contact_form_creates_ticket(monkeypatch, app): def test_contact_form_creates_ticket(monkeypatch, app):
import app.main as main_module import app.main as main_module
@@ -62,6 +112,7 @@ def test_contact_form_creates_ticket(monkeypatch, app):
monkeypatch.setattr(main_module, "utc_now", lambda: base_time) monkeypatch.setattr(main_module, "utc_now", lambda: base_time)
with TestClient(app) as client: with TestClient(app) as client:
_register_and_verify_user(client, "max@example.com")
form = client.get("/kontakt") form = client.get("/kontakt")
assert form.status_code == 200 assert form.status_code == 200
csrf = _csrf_from_html(form.text) csrf = _csrf_from_html(form.text)
@@ -76,7 +127,7 @@ def test_contact_form_creates_ticket(monkeypatch, app):
"website": "", "website": "",
"category": "feature", "category": "feature",
"name": "Max Beispiel", "name": "Max Beispiel",
"email": "max@example.com", "email": "ignored@example.com",
"subject": "Bitte Monatsfilter erweitern", "subject": "Bitte Monatsfilter erweitern",
"message": "Ich wünsche mir eine bessere Filterung in der Monatsansicht.", "message": "Ich wünsche mir eine bessere Filterung in der Monatsansicht.",
}, },
@@ -88,6 +139,7 @@ def test_contact_form_creates_ticket(monkeypatch, app):
with Session(get_engine()) as db: with Session(get_engine()) as db:
tickets = db.execute(select(SupportTicket)).scalars().all() tickets = db.execute(select(SupportTicket)).scalars().all()
assert len(tickets) == 1 assert len(tickets) == 1
assert tickets[0].email == "max@example.com"
assert tickets[0].category == "feature" assert tickets[0].category == "feature"
assert tickets[0].status == "open" assert tickets[0].status == "open"
assert tickets[0].subject == "Bitte Monatsfilter erweitern" assert tickets[0].subject == "Bitte Monatsfilter erweitern"
@@ -100,6 +152,7 @@ def test_contact_form_honeypot_blocks_submission(monkeypatch, app):
monkeypatch.setattr(main_module, "utc_now", lambda: base_time) monkeypatch.setattr(main_module, "utc_now", lambda: base_time)
with TestClient(app) as client: with TestClient(app) as client:
_register_and_verify_user(client, "spam@example.com")
form = client.get("/kontakt") form = client.get("/kontakt")
csrf = _csrf_from_html(form.text) csrf = _csrf_from_html(form.text)
started_at = _started_at_from_html(form.text) started_at = _started_at_from_html(form.text)
@@ -113,7 +166,7 @@ def test_contact_form_honeypot_blocks_submission(monkeypatch, app):
"website": "spam", "website": "spam",
"category": "problem", "category": "problem",
"name": "", "name": "",
"email": "spam@example.com", "email": "ignored@example.com",
"subject": "Spamversuch", "subject": "Spamversuch",
"message": "Das sollte blockiert werden.", "message": "Das sollte blockiert werden.",
}, },
@@ -212,3 +265,22 @@ def test_admin_can_manage_legal_content_and_tickets(tmp_path, monkeypatch):
assert ticket.status == "closed" assert ticket.status == "closed"
assert ticket.admin_notes == "Geschlossen im Test" assert ticket.admin_notes == "Geschlossen im Test"
assert ticket.closed_at is not None assert ticket.closed_at is not None
def test_contact_requires_verified_user(tmp_path):
app = _build_admin_app(f"sqlite:///{tmp_path / 'contact-verified.db'}")
with TestClient(app) as client:
register = client.post(
"/auth/register",
json={"email": "user@example.com", "password": "verystrongPass123"},
)
assert register.status_code == 200
with Session(get_engine()) as db:
user = db.execute(select(User).where(User.email == "user@example.com")).scalar_one()
user.email_verified = False
db.commit()
contact = client.get("/kontakt", follow_redirects=False)
assert contact.status_code == 303
assert contact.headers["location"] == "/verify-email/resend"
+9
View File
@@ -1013,6 +1013,7 @@ def test_register_onboarding_applies_optional_settings(app):
assert payload["vacation_days_total"] == 22 assert payload["vacation_days_total"] == 22
assert payload["vacation_show_in_header"] is True assert payload["vacation_show_in_header"] is True
assert payload["preferred_home_view"] == "month" assert payload["preferred_home_view"] == "month"
assert payload["theme_preference"] == "auto"
assert payload["entry_mode"] == "auto_until_today" assert payload["entry_mode"] == "auto_until_today"
assert payload["overtime_start_date"] == "2026-02-02" assert payload["overtime_start_date"] == "2026-02-02"
assert payload["overtime_expiry_days"] == 90 assert payload["overtime_expiry_days"] == 90
@@ -1070,6 +1071,7 @@ def test_settings_export_all_supports_backup_and_existing_formats(app):
assert "application/json" in export_backup.headers["content-type"] assert "application/json" in export_backup.headers["content-type"]
payload = export_backup.json() payload = export_backup.json()
assert payload["backup_version"] == 2 assert payload["backup_version"] == 2
assert payload["settings"]["theme_preference"] == "auto"
assert "user" not in payload assert "user" not in payload
assert payload["settings"]["weekly_target_minutes"] == 1500 assert payload["settings"]["weekly_target_minutes"] == 1500
assert len(payload["time_entries"]) == 1 assert len(payload["time_entries"]) == 1
@@ -1169,6 +1171,7 @@ def test_register_can_import_backup_during_signup(app):
"/settings/preferences", "/settings/preferences",
data={ data={
"preferred_home_view": "month", "preferred_home_view": "month",
"theme_preference": "light",
"preferred_month_view_mode": "weeks", "preferred_month_view_mode": "weeks",
"entry_mode": "auto_until_today", "entry_mode": "auto_until_today",
"csrf_token": source_csrf, "csrf_token": source_csrf,
@@ -1237,6 +1240,7 @@ def test_register_can_import_backup_during_signup(app):
assert me.status_code == 200 assert me.status_code == 200
payload = me.json() payload = me.json()
assert payload["preferred_home_view"] == "month" assert payload["preferred_home_view"] == "month"
assert payload["theme_preference"] == "light"
assert payload["entry_mode"] == "auto_until_today" assert payload["entry_mode"] == "auto_until_today"
assert payload["working_days"] == [0, 1, 2, 3] assert payload["working_days"] == [0, 1, 2, 3]
assert payload["count_vacation_as_worktime"] is True assert payload["count_vacation_as_worktime"] is True
@@ -1333,6 +1337,7 @@ def test_settings_default_view_redirect(app):
"/settings/preferences", "/settings/preferences",
data={ data={
"preferred_home_view": "month", "preferred_home_view": "month",
"theme_preference": "light",
"preferred_month_view_mode": "weeks", "preferred_month_view_mode": "weeks",
"csrf_token": csrf, "csrf_token": csrf,
}, },
@@ -1348,6 +1353,10 @@ def test_settings_default_view_redirect(app):
assert dashboard_redirect.status_code == 303 assert dashboard_redirect.status_code == 303
assert dashboard_redirect.headers["location"].startswith("/month?view=weeks") assert dashboard_redirect.headers["location"].startswith("/month?view=weeks")
month_page = client.get("/month")
assert month_page.status_code == 200
assert 'data-theme="light"' in month_page.text
def test_main_navigation_uses_explicit_period_links(app): def test_main_navigation_uses_explicit_period_links(app):
with TestClient(app) as client: with TestClient(app) as client: