diff --git a/app/api/routes/auth.py b/app/api/routes/auth.py index 9c479ee..b696b14 100644 --- a/app/api/routes/auth.py +++ b/app/api/routes/auth.py @@ -18,7 +18,7 @@ from app.services.auth import ( revoke_session, validate_csrf_token, ) -from app.services.config_page import build_config_sections +from app.services.config_page import build_config_sections, is_ticktick_oauth_ready logger = logging.getLogger(__name__) templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates")) @@ -225,6 +225,9 @@ def _render_config_page( "config_error": None, "config_saved": False, "config_sections": build_config_sections(auth_db_session, settings), + "ticktick_oauth_ready": is_ticktick_oauth_ready(settings), + "ticktick_oauth_notice": None, + "ticktick_oauth_error": None, }, status_code=status_code, ) diff --git a/app/api/routes/homeassistant.py b/app/api/routes/homeassistant.py index 396c527..7015a0b 100644 --- a/app/api/routes/homeassistant.py +++ b/app/api/routes/homeassistant.py @@ -6,7 +6,8 @@ from fastapi.responses import PlainTextResponse, Response from pydantic import ValidationError from sqlalchemy.orm import Session -from app.dependencies import get_db +from app.dependencies import get_db, get_ticktick_client +from app.integrations.ticktick import TickTickClient, TickTickConfigError, TickTickRequestError from app.schemas.homeassistant import HomeAssistantPublishEnvelope from app.services.homeassistant_inbound import ( UnsupportedHomeAssistantMessage, @@ -21,13 +22,15 @@ INTERNAL_SERVER_ERROR_MESSAGE = "internal server error" @router.post("/homeassistant/publish") async def publish_from_homeassistant( - request: Request, db: Session = Depends(get_db) + request: Request, + db: Session = Depends(get_db), + ticktick_client: TickTickClient = Depends(get_ticktick_client), ) -> Response: try: raw_payload = await request.body() data = json.loads(raw_payload) envelope = HomeAssistantPublishEnvelope.model_validate(data) - handle_homeassistant_message(db, envelope) + handle_homeassistant_message(db, envelope, ticktick_client) except json.JSONDecodeError as exc: logger.warning("Rejected Home Assistant publish request due to invalid JSON: %s", exc) return PlainTextResponse(BAD_REQUEST_MESSAGE, status_code=status.HTTP_400_BAD_REQUEST) @@ -42,6 +45,12 @@ async def publish_from_homeassistant( INTERNAL_SERVER_ERROR_MESSAGE, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, ) + except (TickTickConfigError, TickTickRequestError, RuntimeError) as exc: + logger.warning("Home Assistant publish request failed during TickTick handling: %s", exc) + return PlainTextResponse( + INTERNAL_SERVER_ERROR_MESSAGE, + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) except ValueError as exc: logger.warning("Rejected Home Assistant publish request due to invalid content: %s", exc) return PlainTextResponse(BAD_REQUEST_MESSAGE, status_code=status.HTTP_400_BAD_REQUEST) diff --git a/app/api/routes/pages.py b/app/api/routes/pages.py index 1ed940f..ec12211 100644 --- a/app/api/routes/pages.py +++ b/app/api/routes/pages.py @@ -8,7 +8,12 @@ from fastapi.templating import Jinja2Templates from app.config import Settings, get_settings from app.dependencies import get_app_settings, get_auth_db, get_current_auth_session from app.services.auth import AuthenticatedSession -from app.services.config_page import ConfigSaveError, build_config_sections, save_config_updates +from app.services.config_page import ( + ConfigSaveError, + build_config_sections, + is_ticktick_oauth_ready, + save_config_updates, +) from sqlalchemy.orm import Session templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates")) @@ -16,6 +21,18 @@ router = APIRouter(tags=["pages"]) logger = logging.getLogger(__name__) +def _ticktick_oauth_notice(status_value: str | None) -> tuple[str | None, str | None]: + if status_value == "success": + return "TickTick authorization completed successfully.", None + if status_value == "invalid-state": + return None, "TickTick authorization failed due to invalid OAuth state. Start the flow again." + if status_value == "invalid-callback": + return None, "TickTick authorization callback was missing required parameters." + if status_value == "failed": + return None, "TickTick authorization failed. Check server logs for the provider response and verify TickTick app credentials and redirect URI." + return None, None + + @router.get("/", response_class=HTMLResponse) def home( request: Request, @@ -46,6 +63,10 @@ def config_page( if current_auth is None: return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER) + ticktick_oauth_notice, ticktick_oauth_error = _ticktick_oauth_notice( + request.query_params.get("ticktick_oauth") + ) + context = { "app_name": settings.app_name, "app_env": settings.app_env, @@ -56,6 +77,9 @@ def config_page( "config_error": None, "config_saved": request.query_params.get("saved") == "1", "config_sections": build_config_sections(auth_db_session, settings), + "ticktick_oauth_ready": is_ticktick_oauth_ready(settings), + "ticktick_oauth_notice": ticktick_oauth_notice, + "ticktick_oauth_error": ticktick_oauth_error, } return templates.TemplateResponse(request, "config.html", context) @@ -84,6 +108,9 @@ async def config_submit( "config_error": "invalid config update request", "config_saved": False, "config_sections": build_config_sections(auth_db_session, settings), + "ticktick_oauth_ready": is_ticktick_oauth_ready(settings), + "ticktick_oauth_notice": None, + "ticktick_oauth_error": None, } return templates.TemplateResponse( request, @@ -96,7 +123,7 @@ async def config_submit( save_config_updates(auth_db_session, dict(form), settings) except ConfigSaveError: logger.warning("Rejected config update due to invalid submitted values") - refreshed_settings = build_runtime_settings(auth_db_session, get_settings()) + refreshed_settings = get_settings() context = { "app_name": refreshed_settings.app_name, "app_env": refreshed_settings.app_env, @@ -107,6 +134,9 @@ async def config_submit( "config_error": "invalid config submission", "config_saved": False, "config_sections": build_config_sections(auth_db_session, refreshed_settings), + "ticktick_oauth_ready": is_ticktick_oauth_ready(refreshed_settings), + "ticktick_oauth_notice": None, + "ticktick_oauth_error": None, } return templates.TemplateResponse( request, diff --git a/app/api/routes/ticktick.py b/app/api/routes/ticktick.py new file mode 100644 index 0000000..b728108 --- /dev/null +++ b/app/api/routes/ticktick.py @@ -0,0 +1,79 @@ +import logging + +from fastapi import APIRouter, Depends, Request, status +from fastapi.responses import PlainTextResponse, RedirectResponse, Response +from sqlalchemy.orm import Session + +from app.config import Settings +from app.dependencies import ( + get_app_settings, + get_auth_db, + get_current_auth_session, + get_ticktick_client, +) +from app.integrations.ticktick import TickTickAuthError, TickTickClient, TickTickConfigError, TickTickRequestError +from app.services.auth import AuthenticatedSession +from app.services.config_page import save_config_value + +router = APIRouter(tags=["ticktick"]) +logger = logging.getLogger(__name__) + + +@router.get("/ticktick/auth/start") +def start_ticktick_auth( + current_auth: AuthenticatedSession | None = Depends(get_current_auth_session), + ticktick_client: TickTickClient = Depends(get_ticktick_client), +) -> Response: + if current_auth is None: + return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER) + + try: + authorization_url = ticktick_client.build_authorization_url() + except TickTickConfigError as exc: + logger.warning("Rejected TickTick OAuth start due to incomplete configuration: %s", exc) + return PlainTextResponse("TickTick integration is not configured", status_code=400) + + return RedirectResponse(url=authorization_url, status_code=status.HTTP_303_SEE_OTHER) + + +@router.get("/ticktick/auth/code") +def handle_ticktick_auth_code( + request: Request, + auth_db_session: Session = Depends(get_auth_db), + settings: Settings = Depends(get_app_settings), + ticktick_client: TickTickClient = Depends(get_ticktick_client), +) -> Response: + code = request.query_params.get("code", "") + state = request.query_params.get("state", "") + + if not code or not state: + return RedirectResponse( + url="/config?ticktick_oauth=invalid-callback", + status_code=status.HTTP_303_SEE_OTHER, + ) + + try: + token = ticktick_client.exchange_authorization_code(code=code, state=state) + save_config_value( + auth_db_session, + env_name="TICKTICK_TOKEN", + value=token, + bootstrap_settings=settings, + ) + except TickTickAuthError as exc: + logger.warning("Rejected TickTick OAuth callback due to invalid state: %s", exc) + return RedirectResponse( + url="/config?ticktick_oauth=invalid-state", + status_code=status.HTTP_303_SEE_OTHER, + ) + except (TickTickConfigError, TickTickRequestError, ValueError) as exc: + logger.warning("TickTick OAuth callback failed: %s", exc) + return RedirectResponse( + url="/config?ticktick_oauth=failed", + status_code=status.HTTP_303_SEE_OTHER, + ) + + return RedirectResponse( + url="/config?ticktick_oauth=success", + status_code=status.HTTP_303_SEE_OTHER, + ) \ No newline at end of file diff --git a/app/dependencies.py b/app/dependencies.py index 8b567e5..ed4f3f0 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -7,6 +7,7 @@ from app.auth_db import get_auth_db_session from app.config import Settings, get_settings from app.db import get_db_session from app.integrations.homeassistant import HomeAssistantClient +from app.integrations.ticktick import TickTickClient from app.poo_db import get_poo_db_session from app.services.auth import AuthenticatedSession, get_authenticated_session from app.services.config_page import build_runtime_settings @@ -32,6 +33,10 @@ def get_homeassistant_client(settings: Settings = Depends(get_app_settings)) -> return HomeAssistantClient(settings) +def get_ticktick_client(settings: Settings = Depends(get_app_settings)) -> TickTickClient: + return TickTickClient(settings) + + def get_current_auth_session( request: Request, session: Session = Depends(get_auth_db), diff --git a/app/integrations/ticktick.py b/app/integrations/ticktick.py index 8dc15c3..2b9ae40 100644 --- a/app/integrations/ticktick.py +++ b/app/integrations/ticktick.py @@ -1,12 +1,301 @@ -from dataclasses import dataclass +from __future__ import annotations + +import json +import logging +import secrets +import base64 +from dataclasses import asdict, dataclass, field, fields +from typing import Any +from urllib import error, parse, request from app.config import Settings +logger = logging.getLogger(__name__) +TICKTICK_AUTH_URL = "https://ticktick.com/oauth/authorize" +TICKTICK_TOKEN_URL = "https://ticktick.com/oauth/token" +TICKTICK_OPEN_API_BASE_URL = "https://api.ticktick.com/open/v1" +TICKTICK_DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S%z" +AUTH_SCOPE = "tasks:read tasks:write" + + +class TickTickConfigError(RuntimeError): + """Raised when TickTick is missing required runtime configuration.""" + + +class TickTickAuthError(RuntimeError): + """Raised when TickTick OAuth state validation fails.""" + + +class TickTickRequestError(RuntimeError): + """Raised when a TickTick API request fails.""" + + +@dataclass(slots=True) +class TickTickProject: + id: str + name: str + color: str | None = None + sortOrder: int | None = None + closed: bool | None = None + groupId: str | None = None + viewMode: str | None = None + permission: str | None = None + kind: str | None = None + + +@dataclass(slots=True) +class TickTickTask: + projectId: str + title: str + id: str | None = None + isAllDay: bool | None = None + completedTime: str | None = None + content: str | None = None + desc: str | None = None + dueDate: str | None = None + items: list[Any] | None = None + priority: int | None = None + reminders: list[str] | None = None + repeatFlag: str | None = None + sortOrder: int | None = None + startDate: str | None = None + status: int | None = None + timeZone: str | None = None + + +@dataclass(slots=True) +class TickTickAuthStateStore: + pending_state: str | None = None + + def issue_state(self) -> str: + self.pending_state = secrets.token_hex(6) + return self.pending_state + + def matches_state(self, state: str) -> bool: + return bool(self.pending_state and state == self.pending_state) + + def consume_state(self, state: str) -> bool: + if not self.pending_state or state != self.pending_state: + return False + self.pending_state = None + return True + + def clear(self) -> None: + self.pending_state = None + + +default_auth_state_store = TickTickAuthStateStore() + + +def _coerce_dataclass_payload(model_type: type, payload: dict[str, Any]) -> Any: + allowed_field_names = {item.name for item in fields(model_type)} + filtered_payload = { + key: value for key, value in payload.items() if key in allowed_field_names + } + return model_type(**filtered_payload) + @dataclass(slots=True) class TickTickClient: settings: Settings + auth_state_store: TickTickAuthStateStore = field(default_factory=lambda: default_auth_state_store) + timeout_seconds: float = 10.0 def is_configured(self) -> bool: - return bool(self.settings.ticktick_client_id and self.settings.ticktick_client_secret) + return bool(self._client_id() and self._client_secret()) + + def has_token(self) -> bool: + return bool(self.settings.ticktick_token) + + def build_authorization_url(self) -> str: + self._require_auth_config() + state = self.auth_state_store.issue_state() + params = parse.urlencode( + { + "client_id": self._client_id(), + "response_type": "code", + "redirect_uri": self._redirect_uri(), + "state": state, + "scope": AUTH_SCOPE, + } + ) + return f"{TICKTICK_AUTH_URL}?{params}" + + def exchange_authorization_code(self, *, code: str, state: str) -> str: + self._require_auth_config() + if not code: + raise ValueError("code must not be empty") + if not state: + raise ValueError("state must not be empty") + if not self.auth_state_store.matches_state(state): + raise TickTickAuthError("Invalid state") + + body = parse.urlencode( + { + "code": code, + "grant_type": "authorization_code", + "scope": AUTH_SCOPE, + "redirect_uri": self._redirect_uri(), + } + ).encode("utf-8") + req = request.Request(TICKTICK_TOKEN_URL, data=body, method="POST") + req.add_header("Content-Type", "application/x-www-form-urlencoded") + req.add_header("Authorization", self._basic_auth_header()) + payload = self._send_json_request(req, operation="exchange_authorization_code") + self.auth_state_store.clear() + token = payload.get("access_token") + if not isinstance(token, str) or not token: + raise TickTickRequestError("TickTick token response did not include access_token") + return token + + def get_projects(self) -> list[TickTickProject]: + self._require_token() + payload = self._authorized_json_request( + method="GET", + path="/project/", + operation="get_projects", + ) + if not isinstance(payload, list): + raise TickTickRequestError("TickTick get_projects returned an unexpected payload") + return [_coerce_dataclass_payload(TickTickProject, project) for project in payload] + + def get_tasks(self, project_id: str) -> list[TickTickTask]: + self._require_token() + if not project_id: + raise ValueError("project_id must not be empty") + payload = self._authorized_json_request( + method="GET", + path=f"/project/{parse.quote(project_id, safe='')}/data", + operation="get_tasks", + accepted_status_codes={200, 404}, + ) + if payload is None: + return [] + if not isinstance(payload, dict): + raise TickTickRequestError("TickTick get_tasks returned an unexpected payload") + tasks = payload.get("tasks", []) + if not isinstance(tasks, list): + raise TickTickRequestError("TickTick get_tasks returned an invalid tasks payload") + return [_coerce_dataclass_payload(TickTickTask, task) for task in tasks] + + def has_duplicate_task(self, *, project_id: str, task_title: str) -> bool: + if not task_title: + raise ValueError("task_title must not be empty") + return any(task.title == task_title for task in self.get_tasks(project_id)) + + def create_task(self, task: TickTickTask) -> None: + self._require_token() + if not task.projectId: + raise ValueError("task.projectId must not be empty") + if not task.title: + raise ValueError("task.title must not be empty") + if self.has_duplicate_task(project_id=task.projectId, task_title=task.title): + return + + payload = {key: value for key, value in asdict(task).items() if value is not None} + self._authorized_json_request( + method="POST", + path="/task", + operation="create_task", + body=payload, + accepted_status_codes={200}, + ) + + def _authorized_json_request( + self, + *, + method: str, + path: str, + operation: str, + body: Any | None = None, + accepted_status_codes: set[int] | None = None, + ) -> Any: + url = f"{TICKTICK_OPEN_API_BASE_URL}{path}" + encoded_body = None if body is None else json.dumps(body).encode("utf-8") + req = request.Request(url, data=encoded_body, method=method) + req.add_header("Authorization", f"Bearer {self.settings.ticktick_token}") + if body is not None: + req.add_header("Content-Type", "application/json") + return self._send_json_request( + req, + operation=operation, + accepted_status_codes=accepted_status_codes, + ) + + def _send_json_request( + self, + req: request.Request, + *, + operation: str, + accepted_status_codes: set[int] | None = None, + ) -> Any: + accepted_codes = accepted_status_codes or {200} + try: + with request.urlopen(req, timeout=self.timeout_seconds) as response: + status_code = response.getcode() + if status_code not in accepted_codes: + raise TickTickRequestError( + f"TickTick {operation} returned unexpected status {status_code}" + ) + raw_body = response.read() + except error.HTTPError as exc: + if exc.code in accepted_codes: + raw_body = exc.read() + else: + logger.warning( + "TickTick %s failed with HTTP %s for %s", + operation, + exc.code, + req.full_url, + ) + raise TickTickRequestError( + f"TickTick {operation} failed with HTTP {exc.code}" + ) from exc + except error.URLError as exc: + logger.warning("TickTick %s failed for %s: %s", operation, req.full_url, exc) + raise TickTickRequestError( + f"TickTick {operation} failed to reach TickTick API" + ) from exc + + if not raw_body: + return None + try: + return json.loads(raw_body) + except json.JSONDecodeError as exc: + raise TickTickRequestError( + f"TickTick {operation} returned invalid JSON" + ) from exc + + def _basic_auth_header(self) -> str: + raw_credentials = f"{self._client_id()}:{self._client_secret()}" + token = base64.b64encode(raw_credentials.encode("utf-8")).decode("ascii") + return f"Basic {token}" + + def _client_id(self) -> str: + return self.settings.ticktick_client_id.strip() + + def _client_secret(self) -> str: + return self.settings.ticktick_client_secret.strip() + + def _redirect_uri(self) -> str: + return self.settings.ticktick_redirect_uri.strip() + + def _require_auth_config(self) -> None: + if not self.is_configured(): + raise TickTickConfigError( + "TickTick integration is not configured. Set TICKTICK_CLIENT_ID and " + "TICKTICK_CLIENT_SECRET." + ) + if not self._redirect_uri(): + raise TickTickConfigError( + "TickTick integration is missing TICKTICK_REDIRECT_URI." + ) + + def _require_token(self) -> None: + self._require_auth_config() + if self.has_token(): + return + raise TickTickConfigError( + "TickTick integration is missing TICKTICK_TOKEN. Complete the OAuth flow first." + ) diff --git a/app/main.py b/app/main.py index f0ce7b2..ea4e01d 100644 --- a/app/main.py +++ b/app/main.py @@ -12,6 +12,7 @@ import app.auth_db as auth_db from app.api.routes.homeassistant import router as homeassistant_router from app.api.routes.location import router as location_router from app.api.routes.poo import router as poo_router +from app.api.routes.ticktick import router as ticktick_router from app.config import get_settings from app.services.auth import AuthBootstrapError, initialize_auth_schema from app.services.config_page import seed_missing_config_from_bootstrap @@ -95,6 +96,7 @@ def create_app() -> FastAPI: app.include_router(homeassistant_router) app.include_router(location_router) app.include_router(poo_router) + app.include_router(ticktick_router) return app diff --git a/app/schemas/ticktick.py b/app/schemas/ticktick.py new file mode 100644 index 0000000..ccc3c96 --- /dev/null +++ b/app/schemas/ticktick.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel, ConfigDict, Field + + +class TickTickActionTaskRequest(BaseModel): + title: str | None = None + action: str + due_hour: int = Field(alias="due_hour") + + model_config = ConfigDict(extra="forbid", populate_by_name=True) \ No newline at end of file diff --git a/app/services/config_page.py b/app/services/config_page.py index 7cdbf28..43a5e19 100644 --- a/app/services/config_page.py +++ b/app/services/config_page.py @@ -173,6 +173,29 @@ def save_config_updates(session: Session, form_data: dict[str, str], bootstrap_s reset_auth_db_caches() +def save_config_value( + session: Session, + *, + env_name: str, + value: str, + bootstrap_settings: Settings, +) -> None: + current_values = _read_config_values(session) + current_values[env_name] = value + _validate_config_values(current_values, bootstrap_settings) + _persist_config_values(session, current_values) + get_settings.cache_clear() + reset_auth_db_caches() + + +def is_ticktick_oauth_ready(settings: Settings) -> bool: + return bool( + settings.ticktick_client_id + and settings.ticktick_client_secret + and settings.ticktick_redirect_uri + ) + + def _read_config_values(session: Session) -> dict[str, str]: rows = session.execute(select(AppConfigEntry).order_by(AppConfigEntry.key)).scalars().all() return {row.key: row.value for row in rows} diff --git a/app/services/homeassistant_inbound.py b/app/services/homeassistant_inbound.py index eead1f9..e75e414 100644 --- a/app/services/homeassistant_inbound.py +++ b/app/services/homeassistant_inbound.py @@ -1,10 +1,13 @@ from __future__ import annotations import json +from datetime import UTC, datetime, time, timedelta from sqlalchemy.orm import Session +from app.integrations.ticktick import TICKTICK_DATETIME_FORMAT, TickTickClient, TickTickTask from app.schemas.homeassistant import HomeAssistantPublishEnvelope from app.schemas.location import LocationRecordRequest +from app.schemas.ticktick import TickTickActionTaskRequest from app.services.location import record_location @@ -13,12 +16,18 @@ class UnsupportedHomeAssistantMessage(RuntimeError): def handle_homeassistant_message( - session: Session, envelope: HomeAssistantPublishEnvelope + session: Session, + envelope: HomeAssistantPublishEnvelope, + ticktick_client: TickTickClient | None = None, ) -> None: if envelope.target == "location_recorder": _handle_location_message(session, envelope) return + if envelope.target == "ticktick": + _handle_ticktick_message(envelope, ticktick_client) + return + raise UnsupportedHomeAssistantMessage( f"Unsupported Home Assistant target/action: {envelope.target}/{envelope.action}" ) @@ -33,3 +42,38 @@ def _handle_location_message(session: Session, envelope: HomeAssistantPublishEnv content = json.loads(envelope.content.replace("'", '"')) payload = LocationRecordRequest.model_validate(content) record_location(session, payload) + + +def _handle_ticktick_message( + envelope: HomeAssistantPublishEnvelope, + ticktick_client: TickTickClient | None, +) -> None: + if envelope.action != "create_action_task": + raise UnsupportedHomeAssistantMessage( + f"Unsupported Home Assistant target/action: {envelope.target}/{envelope.action}" + ) + if ticktick_client is None: + raise UnsupportedHomeAssistantMessage("TickTick client is unavailable") + + content = json.loads(envelope.content.replace("'", '"')) + payload = TickTickActionTaskRequest.model_validate(content) + project_id = ticktick_client.settings.home_assistant_action_task_project_id + if not project_id: + raise RuntimeError( + "TickTick action task integration is missing HOME_ASSISTANT_ACTION_TASK_PROJECT_ID" + ) + + ticktick_client.create_task( + TickTickTask( + projectId=project_id, + title=payload.action, + dueDate=build_action_task_due_date(datetime.now().astimezone(), payload.due_hour), + ) + ) + + +def build_action_task_due_date(now: datetime, due_hour: int) -> str: + local_now = now.astimezone() + due = local_now + timedelta(hours=due_hour) + next_midnight = datetime.combine(due.date(), time.min, tzinfo=local_now.tzinfo) + timedelta(days=1) + return next_midnight.astimezone(UTC).strftime(TICKTICK_DATETIME_FORMAT) diff --git a/app/static/styles.css b/app/static/styles.css index f981700..a3dd230 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -182,6 +182,53 @@ button:hover { color: var(--muted); } +.integration-action-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding-top: 8px; + border-top: 1px solid rgba(31, 41, 51, 0.08); +} + +.integration-action-title { + margin: 0 0 6px; + font-weight: 600; + color: var(--text); +} + +.integration-action-copy { + margin: 0; + color: var(--muted); + line-height: 1.5; +} + +.button-link { + display: inline-flex; + align-items: center; + justify-content: center; + width: fit-content; + min-width: 120px; + padding: 12px 18px; + border: none; + border-radius: 999px; + background: var(--accent); + color: white; + text-decoration: none; + cursor: pointer; +} + +.button-link:hover { + filter: brightness(1.04); +} + +.button-link.disabled { + background: rgba(91, 104, 117, 0.28); + color: rgba(31, 41, 51, 0.72); + cursor: not-allowed; + pointer-events: none; +} + @media (max-width: 640px) { .shell { margin: 24px auto; @@ -190,4 +237,9 @@ button:hover { .panel { padding: 24px; } + + .integration-action-row { + align-items: stretch; + flex-direction: column; + } } diff --git a/app/templates/config.html b/app/templates/config.html index f657c61..ad4fc2a 100644 --- a/app/templates/config.html +++ b/app/templates/config.html @@ -25,6 +25,14 @@