+75
-4
@@ -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_EMAIL = 5
|
||||
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_VACATION: "Urlaub",
|
||||
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_version": settings.app_version,
|
||||
"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)
|
||||
return context
|
||||
@@ -463,6 +479,7 @@ def create_app(settings_override: Settings | None = None) -> FastAPI:
|
||||
"automatic_break_rules_enabled": user.automatic_break_rules_enabled,
|
||||
"default_break_minutes": user.default_break_minutes,
|
||||
"preferred_home_view": user.preferred_home_view,
|
||||
"theme_preference": user.theme_preference,
|
||||
"preferred_month_view_mode": user.preferred_month_view_mode,
|
||||
"entry_mode": user.entry_mode,
|
||||
"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": {
|
||||
"weekly_target_minutes": user.weekly_target_minutes,
|
||||
"preferred_home_view": user.preferred_home_view,
|
||||
"theme_preference": user.theme_preference,
|
||||
"preferred_month_view_mode": user.preferred_month_view_mode,
|
||||
"entry_mode": user.entry_mode,
|
||||
"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:
|
||||
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)
|
||||
return templates.TemplateResponse(
|
||||
"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)
|
||||
async def impressum_page(request: Request, db: Session = Depends(get_db)):
|
||||
return render_legal_page(
|
||||
@@ -2063,7 +2089,10 @@ def create_app(settings_override: Settings | None = None) -> FastAPI:
|
||||
|
||||
@app.get("/kontakt", response_class=HTMLResponse)
|
||||
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(
|
||||
"pages/contact.html",
|
||||
build_context(
|
||||
@@ -2096,10 +2125,13 @@ def create_app(settings_override: Settings | None = None) -> FastAPI:
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
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_email = email.strip().lower()
|
||||
normalized_email = user.email.strip().lower()
|
||||
normalized_subject = subject.strip()
|
||||
normalized_message = message.strip()
|
||||
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."
|
||||
elif msg == "email_verified":
|
||||
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":
|
||||
error_message = (
|
||||
"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(
|
||||
request: Request,
|
||||
preferred_home_view: str = Form(...),
|
||||
theme_preference: str = Form(default=""),
|
||||
preferred_month_view_mode: str = Form(...),
|
||||
entry_mode: str = Form(default=""),
|
||||
csrf_token: str = Form(...),
|
||||
@@ -4385,6 +4422,15 @@ def create_app(settings_override: Settings | None = None) -> FastAPI:
|
||||
error="Ungueltige Standardansicht.",
|
||||
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"}:
|
||||
return render_settings_form(
|
||||
request,
|
||||
@@ -4403,6 +4449,7 @@ def create_app(settings_override: Settings | None = None) -> FastAPI:
|
||||
)
|
||||
|
||||
user.preferred_home_view = preferred_home_view
|
||||
user.theme_preference = selected_theme_preference
|
||||
user.preferred_month_view_mode = preferred_month_view_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
|
||||
@@ -4529,6 +4576,7 @@ def create_app(settings_override: Settings | None = None) -> FastAPI:
|
||||
async def settings_update_weekly_target(
|
||||
request: Request,
|
||||
weekly_target_hours: float = Form(...),
|
||||
entry_mode: str = Form(default=""),
|
||||
automatic_break_rules_enabled: str | None = Form(default=None),
|
||||
default_break_minutes_value: str = Form(default="", alias="default_break_minutes"),
|
||||
csrf_token: str = Form(...),
|
||||
@@ -4549,6 +4597,15 @@ def create_app(settings_override: Settings | None = None) -> FastAPI:
|
||||
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"
|
||||
default_break_minutes = user.default_break_minutes if break_rules_enabled else 0
|
||||
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,
|
||||
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.entry_mode = new_entry_mode
|
||||
user.automatic_break_rules_enabled = break_rules_enabled
|
||||
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()
|
||||
|
||||
return RedirectResponse(url="/settings?msg=weekly_target_updated", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
@@ -20,6 +20,7 @@ class User(Base):
|
||||
role: Mapped[str] = mapped_column(String(32), default="user", 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)
|
||||
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)
|
||||
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)
|
||||
|
||||
@@ -133,6 +133,10 @@ def _normalize_settings(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
if preferred_home_view not in PREFERRED_HOME_VIEWS:
|
||||
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")
|
||||
if preferred_month_view_mode not in PREFERRED_MONTH_VIEWS:
|
||||
preferred_month_view_mode = "flat"
|
||||
@@ -170,6 +174,7 @@ def _normalize_settings(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"weekly_target_minutes": _parse_int(settings_data.get("weekly_target_minutes", 1500), label="Wochenstunden", minimum=1),
|
||||
"preferred_home_view": preferred_home_view,
|
||||
"theme_preference": theme_preference,
|
||||
"preferred_month_view_mode": preferred_month_view_mode,
|
||||
"entry_mode": entry_mode,
|
||||
"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:
|
||||
user.weekly_target_minutes = settings_data["weekly_target_minutes"]
|
||||
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.entry_mode = settings_data["entry_mode"]
|
||||
user.working_days_csv = serialize_working_days(settings_data["working_days"])
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import re
|
||||
|
||||
import markdown as markdown_lib
|
||||
import bleach
|
||||
|
||||
@@ -194,13 +197,36 @@ _ALLOWED_ATTRIBUTES = {
|
||||
'a': ['href', 'title', 'rel', 'target'],
|
||||
}
|
||||
_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:
|
||||
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(
|
||||
markdown_text or '',
|
||||
extensions=['extra', 'sane_lists'],
|
||||
@@ -213,6 +239,8 @@ def render_safe_markdown(markdown_text: str) -> str:
|
||||
protocols=_ALLOWED_PROTOCOLS,
|
||||
strip=True,
|
||||
)
|
||||
if obfuscate_emails:
|
||||
return obfuscate_emails_in_html(cleaned)
|
||||
return bleach.linkify(cleaned)
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ def run_startup_migrations(engine: Engine) -> None:
|
||||
statements: list[str] = []
|
||||
if "preferred_home_view" not in user_columns:
|
||||
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:
|
||||
statements.append("ALTER TABLE users ADD COLUMN preferred_month_view_mode VARCHAR(16) NOT NULL DEFAULT 'flat'")
|
||||
if "entry_mode" not in user_columns:
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
}
|
||||
|
||||
.app-logo {
|
||||
filter: var(--icon-filter);
|
||||
height: var(--logo-size);
|
||||
width: var(--logo-size);
|
||||
}
|
||||
@@ -194,6 +195,7 @@
|
||||
}
|
||||
|
||||
.dash-icon {
|
||||
filter: var(--icon-filter);
|
||||
height: var(--header-icon-size);
|
||||
width: var(--header-icon-size);
|
||||
}
|
||||
@@ -1383,6 +1385,39 @@ select:disabled {
|
||||
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 {
|
||||
gap: var(--stack-1);
|
||||
padding-inline-start: var(--space-2);
|
||||
|
||||
@@ -173,6 +173,8 @@
|
||||
.site-footer-inner {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
padding-bottom: var(--space-7);
|
||||
padding-bottom: calc(var(--space-7) + env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
--color-warning-bg: #6d5500;
|
||||
--color-chip-bg: #202124;
|
||||
--color-weekend: #22252b;
|
||||
--color-day-today: #1a3f4a;
|
||||
--color-day-today: #214331;
|
||||
--color-accent: #9e7700;
|
||||
--color-badge-bg: #f3f3f3;
|
||||
--color-badge-text: #222326;
|
||||
@@ -31,6 +31,10 @@
|
||||
--color-header-badge-bg: #1d1d1f;
|
||||
--color-header-badge-label: #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-size-xs: 0.75rem;
|
||||
@@ -99,3 +103,97 @@
|
||||
--bp-md: 51.25em;
|
||||
--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%);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ import { initForms } from './components/forms.js?v=20260322a';
|
||||
import { initModal } from './components/modal.js';
|
||||
import { initDashboard } from './components/dashboard.js';
|
||||
import { initSettingsSections } from './components/settings-sections.js';
|
||||
import { initTheme } from './components/theme.js';
|
||||
|
||||
function initApp() {
|
||||
initTheme();
|
||||
initCsrf();
|
||||
initFlash();
|
||||
initForms();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<html lang="de" data-theme="{{ theme_preference }}">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<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="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style"
|
||||
|
||||
@@ -25,8 +25,8 @@
|
||||
<input type="text" name="name" value="{{ contact_name }}" maxlength="120" />
|
||||
</label>
|
||||
<label>
|
||||
E-Mail-Adresse
|
||||
<input type="email" name="email" value="{{ contact_email }}" maxlength="254" required />
|
||||
Konto-E-Mail
|
||||
<input type="email" name="email" value="{{ contact_email }}" maxlength="254" readonly />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -68,6 +68,9 @@
|
||||
<p class="muted">
|
||||
Nachrichten werden intern als Ticket gespeichert. So gehen Rückmeldungen nicht verloren und können strukturiert bearbeitet werden.
|
||||
</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">
|
||||
Hinweise zu Anbieter und Datenschutz findest du ebenfalls unten im Footer über <a href="/impressum">Impressum</a> und <a href="/datenschutz">Datenschutz</a>.
|
||||
</p>
|
||||
|
||||
@@ -99,6 +99,15 @@
|
||||
<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">
|
||||
<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>
|
||||
Wochenstunden
|
||||
<input type="number"
|
||||
@@ -145,17 +154,16 @@
|
||||
</select>
|
||||
</label>
|
||||
<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>
|
||||
Theme
|
||||
<select name="theme_preference" required>
|
||||
<option value="auto"
|
||||
{% if user.theme_preference == 'auto' %}selected{% endif %}>Automatisch</option>
|
||||
<option value="dark"
|
||||
{% if user.theme_preference == 'dark' %}selected{% endif %}>Dark</option>
|
||||
<option value="light"
|
||||
{% if user.theme_preference == 'light' %}selected{% endif %}>Light</option>
|
||||
</select>
|
||||
</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>
|
||||
</form>
|
||||
{% endcall %}
|
||||
|
||||
@@ -8,7 +8,7 @@ from sqlalchemy.orm import Session
|
||||
from app.config import Settings
|
||||
from app.database import get_engine
|
||||
from app.main import create_app
|
||||
from app.models import SupportTicket
|
||||
from app.models import SupportTicket, User
|
||||
|
||||
|
||||
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):
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/login")
|
||||
@@ -54,6 +66,44 @@ def test_public_footer_and_legal_pages_render(app):
|
||||
assert privacy.status_code == 200
|
||||
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):
|
||||
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)
|
||||
|
||||
with TestClient(app) as client:
|
||||
_register_and_verify_user(client, "max@example.com")
|
||||
form = client.get("/kontakt")
|
||||
assert form.status_code == 200
|
||||
csrf = _csrf_from_html(form.text)
|
||||
@@ -76,7 +127,7 @@ def test_contact_form_creates_ticket(monkeypatch, app):
|
||||
"website": "",
|
||||
"category": "feature",
|
||||
"name": "Max Beispiel",
|
||||
"email": "max@example.com",
|
||||
"email": "ignored@example.com",
|
||||
"subject": "Bitte Monatsfilter erweitern",
|
||||
"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:
|
||||
tickets = db.execute(select(SupportTicket)).scalars().all()
|
||||
assert len(tickets) == 1
|
||||
assert tickets[0].email == "max@example.com"
|
||||
assert tickets[0].category == "feature"
|
||||
assert tickets[0].status == "open"
|
||||
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)
|
||||
|
||||
with TestClient(app) as client:
|
||||
_register_and_verify_user(client, "spam@example.com")
|
||||
form = client.get("/kontakt")
|
||||
csrf = _csrf_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",
|
||||
"category": "problem",
|
||||
"name": "",
|
||||
"email": "spam@example.com",
|
||||
"email": "ignored@example.com",
|
||||
"subject": "Spamversuch",
|
||||
"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.admin_notes == "Geschlossen im Test"
|
||||
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"
|
||||
|
||||
@@ -1013,6 +1013,7 @@ def test_register_onboarding_applies_optional_settings(app):
|
||||
assert payload["vacation_days_total"] == 22
|
||||
assert payload["vacation_show_in_header"] is True
|
||||
assert payload["preferred_home_view"] == "month"
|
||||
assert payload["theme_preference"] == "auto"
|
||||
assert payload["entry_mode"] == "auto_until_today"
|
||||
assert payload["overtime_start_date"] == "2026-02-02"
|
||||
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"]
|
||||
payload = export_backup.json()
|
||||
assert payload["backup_version"] == 2
|
||||
assert payload["settings"]["theme_preference"] == "auto"
|
||||
assert "user" not in payload
|
||||
assert payload["settings"]["weekly_target_minutes"] == 1500
|
||||
assert len(payload["time_entries"]) == 1
|
||||
@@ -1169,6 +1171,7 @@ def test_register_can_import_backup_during_signup(app):
|
||||
"/settings/preferences",
|
||||
data={
|
||||
"preferred_home_view": "month",
|
||||
"theme_preference": "light",
|
||||
"preferred_month_view_mode": "weeks",
|
||||
"entry_mode": "auto_until_today",
|
||||
"csrf_token": source_csrf,
|
||||
@@ -1237,6 +1240,7 @@ def test_register_can_import_backup_during_signup(app):
|
||||
assert me.status_code == 200
|
||||
payload = me.json()
|
||||
assert payload["preferred_home_view"] == "month"
|
||||
assert payload["theme_preference"] == "light"
|
||||
assert payload["entry_mode"] == "auto_until_today"
|
||||
assert payload["working_days"] == [0, 1, 2, 3]
|
||||
assert payload["count_vacation_as_worktime"] is True
|
||||
@@ -1333,6 +1337,7 @@ def test_settings_default_view_redirect(app):
|
||||
"/settings/preferences",
|
||||
data={
|
||||
"preferred_home_view": "month",
|
||||
"theme_preference": "light",
|
||||
"preferred_month_view_mode": "weeks",
|
||||
"csrf_token": csrf,
|
||||
},
|
||||
@@ -1348,6 +1353,10 @@ def test_settings_default_view_redirect(app):
|
||||
assert dashboard_redirect.status_code == 303
|
||||
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):
|
||||
with TestClient(app) as client:
|
||||
|
||||
Reference in New Issue
Block a user