diff --git a/app.py b/app.py index 888df9e6..281a418a 100644 --- a/app.py +++ b/app.py @@ -47,6 +47,7 @@ from routes.oauth_routes import router as oauth_router from routes.redirect_routes import router as redirect_router from routes.static_routes import router as static_router +from shared.app_registry import load_app_registry from shared.logging import get_logger from shared.templates import configure_template_globals, templates @@ -124,6 +125,9 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: await ensure_indexes(app.state.db) + # App registry for the consent/apps system + app.state.app_registry = load_app_registry() + # Warn if session secret is missing when auth is enabled if settings.jwt and not settings.secret_key: log.warning( diff --git a/config.py b/config.py index 3557c17e..5cb8538a 100644 --- a/config.py +++ b/config.py @@ -125,10 +125,6 @@ class AppSettings(BaseSettings): cors_origins: list[str] = ["*"] # deprecated — kept for backward compat cors_private_origins: list[str] = [] - # Device auth flow - allowed redirect URIs for third-party clients - # (e.g., mobile apps, desktop apps). Browser extensions don't need this. - device_auth_redirect_uris: list[str] = [] - # Request body size limit (bytes); 1 MB default max_content_length: int = 1_048_576 diff --git a/config/apps.yaml b/config/apps.yaml new file mode 100644 index 00000000..275ccb35 --- /dev/null +++ b/config/apps.yaml @@ -0,0 +1,124 @@ +# App registry for the spoo.me ecosystem. +# +# Loaded at application startup and cached in app.state.app_registry. +# All apps listed here appear on the dashboard Apps page. +# Only 'live' + 'device_auth' apps can initiate the consent flow. + +apps: + spoo-snap: + name: Spoo Snap + icon: spoo-snap.svg + description: Official browser extension for spoo.me + verified: true + status: live + type: device_auth + redirect_uris: [] + links: + chrome: https://chrome.google.com/webstore/detail/spoo-snap + firefox: https://addons.mozilla.org/en-US/firefox/addon/spoo-snap + permissions: + - Access your spoo.me account + - Create and manage short URLs + - View your analytics + + spoo-desktop: + name: Spoo Desktop + icon: spoo-desktop.svg + description: Official Windows desktop app + verified: true + status: live + type: device_auth + redirect_uris: + - http://localhost:9274/callback + links: + windows: https://apps.microsoft.com/detail/9mtwpjxlb0gr + permissions: + - Access your spoo.me account + - Create and manage short URLs + + spoo-discord: + name: Discord Bot + icon: discord-bot.svg + description: Shorten links directly in Discord + verified: true + status: live + type: device_auth + redirect_uris: + - https://discord-bot.spoo.me/callback + links: + invite: https://spoo.me/discord-bot + permissions: + - Access your spoo.me account + - Create short URLs on your behalf + + spoo-mobile: + name: Spoo Mobile + icon: spoo-mobile.svg + description: Shorten links on the go + verified: true + status: coming_soon + type: device_auth + + spoo-telegram: + name: Telegram Bot + icon: telegram-bot.svg + description: Shorten links in Telegram chats + verified: true + status: coming_soon + type: device_auth + + spoo-slack: + name: Slack Bot + icon: slack-bot.svg + description: Shorten links in Slack with /shorten + verified: true + status: coming_soon + type: device_auth + + spoo-raycast: + name: Raycast Extension + icon: raycast.svg + description: Shorten links from Raycast + verified: true + status: coming_soon + type: device_auth + + spoo-vscode: + name: VS Code Extension + icon: vscode.svg + description: Shorten links from your editor + verified: true + status: coming_soon + type: device_auth + + spoo-mcp: + name: MCP Server + icon: mcp.svg + description: AI tool server for Claude, Cursor, and others + verified: true + status: coming_soon + type: device_auth + + spoo-zapier: + name: Zapier + icon: zapier.svg + description: Shorten links in no-code workflows + verified: true + status: coming_soon + type: device_auth + + spoo-n8n: + name: n8n + icon: n8n.svg + description: Self-hosted automation for link shortening + verified: true + status: coming_soon + type: device_auth + + spoo-cli: + name: Spoo CLI + icon: spoo-cli.svg + description: Shorten links from your terminal + verified: true + status: coming_soon + type: device_auth diff --git a/dependencies/__init__.py b/dependencies/__init__.py index 8e776cd3..1ae21264 100644 --- a/dependencies/__init__.py +++ b/dependencies/__init__.py @@ -36,6 +36,7 @@ ) from dependencies.services import ( get_api_key_service, + get_app_grant_repo, get_auth_service, get_click_service, get_contact_service, @@ -61,6 +62,7 @@ "check_api_key_scope", # services "get_api_key_service", + "get_app_grant_repo", "get_auth_service", "get_click_service", "get_contact_service", diff --git a/dependencies/services.py b/dependencies/services.py index e4da7c97..cf67e590 100644 --- a/dependencies/services.py +++ b/dependencies/services.py @@ -22,6 +22,7 @@ from infrastructure.geoip import GeoIPService from infrastructure.webhook.discord import DiscordWebhookProvider from repositories.api_key_repository import ApiKeyRepository +from repositories.app_grant_repository import AppGrantRepository from repositories.blocked_url_repository import BlockedUrlRepository from repositories.click_repository import ClickRepository from repositories.legacy.emoji_url_repository import EmojiUrlRepository @@ -41,6 +42,11 @@ from services.url_service import UrlService +async def get_app_grant_repo(db=Depends(get_db)) -> AppGrantRepository: + """Return an AppGrantRepository for the current request.""" + return AppGrantRepository(db["app-grants"]) + + async def get_url_service( db=Depends(get_db), url_cache: UrlCache = Depends(get_url_cache), diff --git a/repositories/app_grant_repository.py b/repositories/app_grant_repository.py new file mode 100644 index 00000000..50eab75a --- /dev/null +++ b/repositories/app_grant_repository.py @@ -0,0 +1,141 @@ +""" +Repository for the `app-grants` MongoDB collection. + +Tracks user consent grants for registered apps (device auth flow). +Supports soft-delete via revoked_at for analytics and reconnect flows. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from bson import ObjectId +from pymongo.asynchronous.collection import AsyncCollection +from pymongo.errors import PyMongoError + +from schemas.models.app_grant import AppGrantDoc +from shared.logging import get_logger + +log = get_logger(__name__) + + +class AppGrantRepository: + def __init__(self, collection: AsyncCollection) -> None: + self._col = collection + + async def find_active_grant( + self, user_id: ObjectId, app_id: str + ) -> AppGrantDoc | None: + """Find an active (non-revoked) grant for a user and app.""" + try: + doc = await self._col.find_one( + {"user_id": user_id, "app_id": app_id, "revoked_at": None} + ) + return AppGrantDoc.from_mongo(doc) + except PyMongoError as exc: + log.error( + "app_grant_find_active_failed", + user_id=str(user_id), + app_id=app_id, + error=str(exc), + error_type=type(exc).__name__, + ) + raise + + async def find_active_for_user(self, user_id: ObjectId) -> list[AppGrantDoc]: + """Find all active grants for a user.""" + try: + cursor = self._col.find({"user_id": user_id, "revoked_at": None}) + return [AppGrantDoc.from_mongo(doc) async for doc in cursor] + except PyMongoError as exc: + log.error( + "app_grant_find_active_for_user_failed", + user_id=str(user_id), + error=str(exc), + error_type=type(exc).__name__, + ) + raise + + async def find_all_for_user(self, user_id: ObjectId) -> list[AppGrantDoc]: + """Find all grants for a user, including revoked.""" + try: + cursor = self._col.find({"user_id": user_id}) + return [AppGrantDoc.from_mongo(doc) async for doc in cursor] + except PyMongoError as exc: + log.error( + "app_grant_find_all_for_user_failed", + user_id=str(user_id), + error=str(exc), + error_type=type(exc).__name__, + ) + raise + + async def create_or_reactivate(self, user_id: ObjectId, app_id: str) -> AppGrantDoc: + """Create a new grant or reactivate a revoked one. + + Uses upsert: if a document exists for (user_id, app_id), clears + revoked_at and updates granted_at. Otherwise inserts a new document. + """ + now = datetime.now(timezone.utc) + try: + doc = await self._col.find_one_and_update( + {"user_id": user_id, "app_id": app_id}, + { + "$set": { + "granted_at": now, + "revoked_at": None, + }, + "$setOnInsert": { + "user_id": user_id, + "app_id": app_id, + "last_used_at": None, + }, + }, + upsert=True, + return_document=True, + ) + return AppGrantDoc.from_mongo(doc) # type: ignore[return-value] + except PyMongoError as exc: + log.error( + "app_grant_create_or_reactivate_failed", + user_id=str(user_id), + app_id=app_id, + error=str(exc), + error_type=type(exc).__name__, + ) + raise + + async def revoke(self, user_id: ObjectId, app_id: str) -> bool: + """Soft-delete a grant by setting revoked_at. Returns True if a grant was revoked.""" + try: + result = await self._col.update_one( + {"user_id": user_id, "app_id": app_id, "revoked_at": None}, + {"$set": {"revoked_at": datetime.now(timezone.utc)}}, + ) + return result.modified_count > 0 + except PyMongoError as exc: + log.error( + "app_grant_revoke_failed", + user_id=str(user_id), + app_id=app_id, + error=str(exc), + error_type=type(exc).__name__, + ) + raise + + async def touch_last_used(self, user_id: ObjectId, app_id: str) -> None: + """Update last_used_at on an active grant.""" + try: + await self._col.update_one( + {"user_id": user_id, "app_id": app_id, "revoked_at": None}, + {"$set": {"last_used_at": datetime.now(timezone.utc)}}, + ) + except PyMongoError as exc: + log.error( + "app_grant_touch_last_used_failed", + user_id=str(user_id), + app_id=app_id, + error=str(exc), + error_type=type(exc).__name__, + ) + raise diff --git a/repositories/indexes.py b/repositories/indexes.py index 9fd39dd5..d07064d3 100644 --- a/repositories/indexes.py +++ b/repositories/indexes.py @@ -87,4 +87,10 @@ async def ensure_indexes(db: AsyncDatabase) -> None: name="ix_latest_unused_by_user", ) + # ── app-grants ───────────────────────────────────────────────────── + app_grants_col = db["app-grants"] + await app_grants_col.create_index([("user_id", 1), ("app_id", 1)], unique=True) + await app_grants_col.create_index([("user_id", 1), ("revoked_at", 1)]) + await app_grants_col.create_index([("app_id", 1), ("revoked_at", 1)]) + log.info("mongodb_indexes_ensured") diff --git a/repositories/token_repository.py b/repositories/token_repository.py index a8f61451..bfed7935 100644 --- a/repositories/token_repository.py +++ b/repositories/token_repository.py @@ -1,7 +1,7 @@ """ Repository for the `verification-tokens` MongoDB collection. -Tokens are used for email verification, password resets, and OTP flows. +Tokens are used for email verification, password resets, OTP flows, and device auth codes. All methods are async. Returns VerificationTokenDoc models where applicable. Errors are logged and re-raised — the service layer decides recovery. """ @@ -153,16 +153,21 @@ async def increment_attempts(self, token_id: ObjectId) -> bool: raise async def delete_by_user( - self, user_id: ObjectId, token_type: str | None = None + self, + user_id: ObjectId, + token_type: str | None = None, + app_id: str | None = None, ) -> int: """ - Delete all tokens for a user, optionally filtered by token type. + Delete all tokens for a user, optionally filtered by token type and app_id. Returns the number of documents deleted. """ query: dict = {"user_id": user_id} if token_type is not None: query["token_type"] = token_type + if app_id is not None: + query["app_id"] = app_id try: result = await self._col.delete_many(query) return result.deleted_count diff --git a/routes/auth_routes.py b/routes/auth_routes.py index 90595d99..399cd3c7 100644 --- a/routes/auth_routes.py +++ b/routes/auth_routes.py @@ -15,19 +15,36 @@ POST /auth/verify-email POST /auth/request-password-reset POST /auth/reset-password +GET /auth/device/login → device auth initiation + consent +POST /auth/device/consent → consent form submission +GET /auth/device/callback → code delivery page for extensions +POST /auth/device/token → exchange code for JWT tokens +POST /auth/device/refresh → refresh app tokens (body-based) +POST /auth/device/revoke → revoke app access """ from __future__ import annotations -from fastapi import APIRouter, Depends, Request, Response +import secrets +from urllib.parse import quote, urlencode + +from fastapi import APIRouter, Depends, Form, Request, Response from fastapi.responses import JSONResponse, RedirectResponse -from dependencies import AuthUser, OptionalUser, get_auth_service +from dependencies import ( + AuthUser, + JwtUser, + OptionalUser, + get_app_grant_repo, + get_auth_service, +) from errors import AuthenticationError from middleware.openapi import AUTH_RESPONSES, ERROR_RESPONSES, PUBLIC_SECURITY from middleware.rate_limiter import Limits, limiter +from repositories.app_grant_repository import AppGrantRepository from routes.cookie_helpers import clear_auth_cookies, set_auth_cookies from schemas.dto.requests.auth import ( + DeviceRefreshRequest, DeviceTokenRequest, LoginRequest, RegisterRequest, @@ -37,6 +54,7 @@ VerifyEmailRequest, ) from schemas.dto.responses.auth import ( + DeviceRefreshResponse, DeviceTokenResponse, LoginResponse, LogoutResponse, @@ -48,7 +66,9 @@ VerifyEmailResponse, ) from schemas.dto.responses.common import MessageResponse +from schemas.models.app import AppEntry from services.auth_service import OTP_EXPIRY_SECONDS, AuthService +from shared.generators import generate_secure_token from shared.ip_utils import get_client_ip from shared.logging import get_logger from shared.templates import templates @@ -58,13 +78,6 @@ router = APIRouter(tags=["Authentication"]) -def _validate_redirect_uri(uri: str, allowed: list[str]) -> str | None: - """Return the URI if it's in the allowlist, None otherwise.""" - if not uri or not allowed: - return None - return uri if uri in allowed else None - - # ── Redirect shortcuts ──────────────────────────────────────────────────────── @@ -207,7 +220,7 @@ async def refresh( return resp try: - _user, new_access, new_refresh = await auth_service.refresh_token( + _user, new_access, new_refresh, _app_id = await auth_service.refresh_token( refresh_token_str ) except AuthenticationError as exc: @@ -287,7 +300,7 @@ async def me( async def set_password( request: Request, body: SetPasswordRequest, - user: AuthUser, + user: JwtUser, auth_service: AuthService = Depends(get_auth_service), ) -> MessageResponse: """Set a password for an OAuth-only account. @@ -296,7 +309,7 @@ async def set_password( also log in with email + password. Fails if the user already has a password set. - **Authentication**: Required (JWT or API key) + **Authentication**: Required (JWT only — API keys cannot set passwords) **Rate Limits**: 5/min """ @@ -452,6 +465,45 @@ async def reset_password( # ── Device auth flow (extensions, apps, CLIs) ──────────────────────────────── +_CSRF_COOKIE_NAME = "_consent_csrf" +_CSRF_TTL_SECONDS = 600 +_CSRF_TOKEN_BYTES = 32 +_CSRF_HEADER_NAME = "x-requested-with" +_CSRF_HEADER_VALUE = "fetch" +_APP_ID_MAX_LEN = 64 + + +def _get_device_app(request: Request, app_id: str) -> AppEntry | None: + """Look up an app_id in the registry and return it if it's a live device-auth app.""" + if not app_id or len(app_id) > _APP_ID_MAX_LEN: + return None + registry: dict[str, AppEntry] = request.app.state.app_registry + entry = registry.get(app_id) + return entry if entry and entry.is_live_device_app() else None + + +def _validate_redirect_uri(redirect_uri: str, app: AppEntry) -> bool: + """Return True if redirect_uri is empty or in the app's allowlist.""" + return not redirect_uri or redirect_uri in app.redirect_uris + + +def _device_error(request: Request, error: str, status_code: int = 400) -> Response: + """Render the device auth error page.""" + return templates.TemplateResponse( + request, "device_error.html", {"error": error}, status_code=status_code + ) + + +def _build_callback_redirect( + code: str, state: str, redirect_uri: str, app: AppEntry +) -> RedirectResponse: + """Build the redirect to the callback page or a registered redirect_uri.""" + params = urlencode({"code": code, "state": state}) + if redirect_uri and redirect_uri in app.redirect_uris: + separator = "&" if "?" in redirect_uri else "?" + return RedirectResponse(f"{redirect_uri}{separator}{params}", status_code=302) + return RedirectResponse(f"/auth/device/callback?{params}", status_code=302) + @router.get("/auth/device/login", include_in_schema=False) @limiter.limit(Limits.DEVICE_AUTH) @@ -459,40 +511,113 @@ async def device_login( request: Request, user: OptionalUser, auth_service: AuthService = Depends(get_auth_service), + grant_repo: AppGrantRepository = Depends(get_app_grant_repo), + app_id: str = "", redirect_uri: str = "", state: str = "", -) -> RedirectResponse: - """Initiate the device auth flow. - - If the user already has a valid session, generates an auth code and - redirects to the callback page (or a registered redirect_uri). - Otherwise, redirects to the login page. - Used by browser extensions, mobile apps, and other third-party clients. +) -> Response: + """Initiate the device auth flow with app identification and consent. - The ``state`` parameter is passed through for CSRF protection — the - client generates it, the server carries it, the client verifies it. + Validates the app_id against the registry. If the user has an existing + active grant, auto-approves and generates a code. Otherwise shows the + consent screen. """ - if user: + app = _get_device_app(request, app_id) + if not app: + return _device_error(request, "Unknown or unsupported application") + + if not _validate_redirect_uri(redirect_uri, app): + return _device_error(request, "Invalid redirect URI for this application") + + if not user: + params: dict[str, str] = {"app_id": app_id} + if state: + params["state"] = state + if redirect_uri: + params["redirect_uri"] = redirect_uri + next_url = f"/auth/device/login?{urlencode(params)}" + return RedirectResponse(f"/?next={quote(next_url)}", status_code=302) + + # Check for existing active grant (auto-approve) + grant = await grant_repo.find_active_grant(user.user_id, app_id) + if grant: profile = await auth_service.get_user_profile(str(user.user_id)) - code = await auth_service.create_device_auth_code(profile.id, profile.email) - allowed = request.app.state.settings.device_auth_redirect_uris - validated_uri = _validate_redirect_uri(redirect_uri, allowed) - if validated_uri: - separator = "&" if "?" in validated_uri else "?" - return RedirectResponse( - f"{validated_uri}{separator}code={code}&state={state}", - status_code=302, - ) - return RedirectResponse( - f"/auth/device/callback?code={code}&state={state}", status_code=302 + code = await auth_service.create_device_auth_code( + profile.id, profile.email, app_id=app_id ) + return _build_callback_redirect(code, state, redirect_uri, app) + + # No grant: show consent screen + csrf_token = generate_secure_token(_CSRF_TOKEN_BYTES) + profile = await auth_service.get_user_profile(str(user.user_id)) + response = templates.TemplateResponse( + request, + "device_consent.html", + { + "app": app, + "app_id": app_id, + "state": state, + "redirect_uri": redirect_uri, + "csrf_token": csrf_token, + "user": profile, + }, + ) + response.set_cookie( + _CSRF_COOKIE_NAME, + csrf_token, + httponly=True, + secure=request.app.state.settings.jwt.cookie_secure, + samesite="strict", + max_age=_CSRF_TTL_SECONDS, + ) + return response - # Preserve params through the login flow - params = "state=" + state if state else "" - if redirect_uri: - params += ("&" if params else "") + f"redirect_uri={redirect_uri}" - next_url = "/auth/device/login" + (f"?{params}" if params else "") - return RedirectResponse(f"/?next={next_url}", status_code=302) + +@router.post("/auth/device/consent", include_in_schema=False) +@limiter.limit(Limits.DEVICE_AUTH) +async def device_consent_approve( + request: Request, + user: JwtUser, + auth_service: AuthService = Depends(get_auth_service), + grant_repo: AppGrantRepository = Depends(get_app_grant_repo), + app_id: str = Form(""), + state: str = Form(""), + csrf_token: str = Form(""), + redirect_uri: str = Form(""), +) -> Response: + """Handle consent form submission (Allow button).""" + # CSRF validation + cookie_csrf = request.cookies.get(_CSRF_COOKIE_NAME) + if ( + not cookie_csrf + or not csrf_token + or not secrets.compare_digest(csrf_token, cookie_csrf) + ): + return _device_error( + request, "Invalid or expired consent session. Please try again.", 403 + ) + + app = _get_device_app(request, app_id) + if not app: + return _device_error(request, "Unknown or unsupported application") + + if not _validate_redirect_uri(redirect_uri, app): + return _device_error(request, "Invalid redirect URI for this application") + + # Create grant + await grant_repo.create_or_reactivate(user.user_id, app_id) + log.info("app_consent_granted", user_id=str(user.user_id), app_id=app_id) + + # Generate device auth code + profile = await auth_service.get_user_profile(str(user.user_id)) + code = await auth_service.create_device_auth_code( + profile.id, profile.email, app_id=app_id + ) + + # Clear CSRF cookie and redirect + response = _build_callback_redirect(code, state, redirect_uri, app) + response.delete_cookie(_CSRF_COOKIE_NAME) + return response @router.get("/auth/device/callback", include_in_schema=False) @@ -526,6 +651,7 @@ async def device_token( request: Request, body: DeviceTokenRequest, auth_service: AuthService = Depends(get_auth_service), + grant_repo: AppGrantRepository = Depends(get_app_grant_repo), ) -> DeviceTokenResponse: """Exchange a one-time device auth code for JWT tokens. @@ -536,11 +662,102 @@ async def device_token( **Rate Limits**: 10/min """ - user, access_token, refresh_token = await auth_service.exchange_device_code( + user, access_token, refresh_token, app_id = await auth_service.exchange_device_code( body.code.strip() ) + + # Verify the grant is still active (closes the revoke race window) + if app_id: + grant = await grant_repo.find_active_grant(user.id, app_id) + if not grant: + raise AuthenticationError("app access has been revoked") + try: + await grant_repo.touch_last_used(user.id, app_id) + except Exception: + log.warning("touch_last_used_failed", user_id=str(user.id), app_id=app_id) + return DeviceTokenResponse( access_token=access_token, refresh_token=refresh_token, user=UserProfileResponse.from_user(user), ) + + +# ── App token refresh ───────────────────────────────────────────────────────── + + +@router.post( + "/auth/device/refresh", + responses=ERROR_RESPONSES, + openapi_extra=PUBLIC_SECURITY, + operation_id="refreshDeviceTokens", + summary="Refresh Device Auth Tokens", +) +@limiter.limit(Limits.TOKEN_REFRESH) +async def device_refresh( + request: Request, + body: DeviceRefreshRequest, + auth_service: AuthService = Depends(get_auth_service), + grant_repo: AppGrantRepository = Depends(get_app_grant_repo), +) -> DeviceRefreshResponse: + """Refresh an app's JWT tokens using a refresh token. + + Accepts the refresh token in the request body (not cookies) for use + by external apps (browser extensions, desktop, CLI, bots). If the + refresh token contains an ``app_id`` claim, the server verifies the + app grant is still active — revoked apps cannot refresh. + + **Authentication**: Not required (the refresh token itself is the credential) + + **Rate Limits**: 20/min + """ + user, new_access, new_refresh, app_id = await auth_service.refresh_token( + body.refresh_token + ) + + if app_id: + grant = await grant_repo.find_active_grant(user.id, app_id) + if not grant: + raise AuthenticationError("app access has been revoked") + try: + await grant_repo.touch_last_used(user.id, app_id) + except Exception: + log.warning("touch_last_used_failed", user_id=str(user.id), app_id=app_id) + + return DeviceRefreshResponse( + access_token=new_access, + refresh_token=new_refresh, + ) + + +# ── App revocation (dashboard action) ──────────────────────────────────────── + + +@router.post("/auth/device/revoke", include_in_schema=False) +@limiter.limit(Limits.DEVICE_AUTH) +async def revoke_app( + request: Request, + user: JwtUser, + auth_service: AuthService = Depends(get_auth_service), + grant_repo: AppGrantRepository = Depends(get_app_grant_repo), + app_id: str = Form(""), +) -> Response: + """Revoke an app's access (soft-delete grant + invalidate tokens). + + Protected against CSRF by requiring the X-Requested-With header, + which cannot be sent by cross-origin form submissions. + """ + if request.headers.get(_CSRF_HEADER_NAME) != _CSRF_HEADER_VALUE: + return JSONResponse({"error": "invalid request"}, status_code=403) + + if not app_id or len(app_id) > _APP_ID_MAX_LEN: + return JSONResponse({"error": "app_id is required"}, status_code=400) + + revoked = await grant_repo.revoke(user.user_id, app_id) + if not revoked: + return JSONResponse({"error": "no active grant found"}, status_code=404) + + # Invalidate device auth tokens bound to this app via the public service method + await auth_service.revoke_device_tokens(user.user_id, app_id=app_id) + + return JSONResponse({"success": True, "message": f"Access revoked for {app_id}"}) diff --git a/routes/dashboard_routes.py b/routes/dashboard_routes.py index 1fb9f3a8..aae391ba 100644 --- a/routes/dashboard_routes.py +++ b/routes/dashboard_routes.py @@ -7,6 +7,7 @@ GET /dashboard/statistics → statistics page GET /dashboard/settings → settings page GET /dashboard/billing → billing page +GET /dashboard/apps → connected apps + ecosystem GET /dashboard/profile-pictures → available pictures (JSON) POST /dashboard/profile-pictures → set profile picture (JSON) """ @@ -21,10 +22,13 @@ AuthUser, CurrentUser, OptionalUser, + get_app_grant_repo, get_profile_picture_service, ) from errors import NotFoundError from middleware.rate_limiter import Limits, limiter +from repositories.app_grant_repository import AppGrantRepository +from schemas.models.app import AppEntry, AppStatus from services.profile_picture_service import AvailablePicture, ProfilePictureService from shared.logging import get_logger from shared.templates import templates @@ -123,6 +127,48 @@ async def dashboard_billing( return await _render_dashboard_page("dashboard/billing.html", request, user, svc) +@router.get("/apps") +@limiter.limit(Limits.DASHBOARD_READ) +async def dashboard_apps( + request: Request, + user: OptionalUser, + svc: ProfilePictureService = Depends(get_profile_picture_service), + grant_repo: AppGrantRepository = Depends(get_app_grant_repo), +) -> Response: + if user is None: + return _unauth_redirect() + profile = await svc.get_dashboard_profile(user.user_id) + grants = await grant_repo.find_active_for_user(user.user_id) + app_registry: dict[str, AppEntry] = request.app.state.app_registry + grant_map = {g.app_id: g for g in grants} + + connected: list[dict] = [] + available: list[dict] = [] + coming_soon: list[dict] = [] + + for app_id, app in app_registry.items(): + entry = {"app_id": app_id, **app.model_dump()} + if app_id in grant_map: + entry["grant"] = grant_map[app_id] + connected.append(entry) + elif app.status == AppStatus.COMING_SOON: + coming_soon.append(entry) + else: + available.append(entry) + + return templates.TemplateResponse( + request, + "dashboard/apps.html", + { + "host_url": str(request.base_url), + "user": profile, + "connected": connected, + "available": available, + "coming_soon": coming_soon, + }, + ) + + # ── Profile pictures (JSON API) ───────────────────────────────────────────── diff --git a/schemas/dto/requests/auth.py b/schemas/dto/requests/auth.py index cfe27f8c..69f2851f 100644 --- a/schemas/dto/requests/auth.py +++ b/schemas/dto/requests/auth.py @@ -9,6 +9,7 @@ RequestPasswordResetRequest — POST /auth/request-password-reset ResetPasswordRequest — POST /auth/reset-password DeviceTokenRequest — POST /auth/device/token +DeviceRefreshRequest — POST /auth/device/refresh """ from __future__ import annotations @@ -126,3 +127,14 @@ class DeviceTokenRequest(BaseModel): max_length=128, description="One-time auth code from the device callback page", ) + + +class DeviceRefreshRequest(BaseModel): + """Request body for POST /auth/device/refresh.""" + + model_config = ConfigDict(populate_by_name=True) + + refresh_token: str = Field( + min_length=1, + description="JWT refresh token issued by /auth/device/token", + ) diff --git a/schemas/dto/responses/auth.py b/schemas/dto/responses/auth.py index 2aea472e..68a34829 100644 --- a/schemas/dto/responses/auth.py +++ b/schemas/dto/responses/auth.py @@ -201,6 +201,15 @@ class DeviceTokenResponse(BaseModel): user: UserProfileResponse = Field(description="User profile") +class DeviceRefreshResponse(BaseModel): + """Response body for POST /auth/device/refresh (200).""" + + model_config = ConfigDict(populate_by_name=True) + + access_token: str = Field(description="New JWT access token") + refresh_token: str = Field(description="New JWT refresh token") + + class OAuthProviderDetail(BaseModel): """Detailed OAuth provider entry for the providers list endpoint.""" diff --git a/schemas/models/app.py b/schemas/models/app.py new file mode 100644 index 00000000..2a45438a --- /dev/null +++ b/schemas/models/app.py @@ -0,0 +1,43 @@ +""" +App registry types. + +Defines the Pydantic model and enums for app entries loaded from apps.yaml. +Used for YAML validation at startup and typed access throughout the codebase. +""" + +from __future__ import annotations + +from enum import Enum + +from pydantic import BaseModel, Field + + +class AppStatus(str, Enum): + """App availability status.""" + + LIVE = "live" + COMING_SOON = "coming_soon" + + +class AppType(str, Enum): + """App authentication type.""" + + DEVICE_AUTH = "device_auth" + + +class AppEntry(BaseModel): + """A single app entry from the registry.""" + + name: str = Field(min_length=1, max_length=100) + icon: str | None = None + description: str = Field(min_length=1, max_length=300) + verified: bool = False + status: AppStatus = AppStatus.COMING_SOON + type: AppType = AppType.DEVICE_AUTH + redirect_uris: list[str] = [] + links: dict[str, str] = {} + permissions: list[str] = [] + + def is_live_device_app(self) -> bool: + """Check if this app can participate in the device auth consent flow.""" + return self.status == AppStatus.LIVE and self.type == AppType.DEVICE_AUTH diff --git a/schemas/models/app_grant.py b/schemas/models/app_grant.py new file mode 100644 index 00000000..9c24d3fe --- /dev/null +++ b/schemas/models/app_grant.py @@ -0,0 +1,24 @@ +""" +App grant document model. + +Maps to the `app-grants` MongoDB collection. + +Tracks which apps a user has authorized via the consent flow. +Soft-deleted on revoke (revoked_at is set instead of deleting the document). +""" + +from __future__ import annotations + +from datetime import datetime + +from schemas.models.base import MongoBaseModel, PyObjectId + + +class AppGrantDoc(MongoBaseModel): + """Document model for the `app-grants` collection.""" + + user_id: PyObjectId + app_id: str # matches key in config/apps.yaml + granted_at: datetime + last_used_at: datetime | None = None + revoked_at: datetime | None = None # soft delete diff --git a/schemas/models/token.py b/schemas/models/token.py index dc9d3223..5f7d8e26 100644 --- a/schemas/models/token.py +++ b/schemas/models/token.py @@ -3,7 +3,7 @@ Maps to the `verification-tokens` MongoDB collection. -Used for both email verification OTPs and password reset OTPs. +Used for email verification OTPs, password reset OTPs, and device auth codes. token_hash stores SHA-256(otp_code) — the plain OTP is never stored. used_at is None until the token is consumed. attempts tracks failed verification tries (max 5 before the token is dead). @@ -44,3 +44,4 @@ class VerificationTokenDoc(MongoBaseModel): created_at: datetime | None = None used_at: datetime | None = None attempts: int = Field(default=0, ge=0) + app_id: str | None = None # set for device_auth tokens (consent flow) diff --git a/services/auth_service.py b/services/auth_service.py index f07a3982..edde2dd5 100644 --- a/services/auth_service.py +++ b/services/auth_service.py @@ -334,14 +334,17 @@ async def register( return user_doc, access_token, refresh_token, verification_sent - async def refresh_token(self, refresh_token_str: str) -> tuple[UserDoc, str, str]: + async def refresh_token( + self, refresh_token_str: str + ) -> tuple[UserDoc, str, str, str | None]: """Perform stateless token rotation. Verifies the refresh JWT, re-fetches the user to get the latest - ``email_verified`` status, and issues a new token pair. + ``email_verified`` status, and issues a new token pair. The original + ``amr`` and ``app_id`` claims are preserved across rotations. Returns: - (user_doc, new_access_token, new_refresh_token) + (user_doc, new_access_token, new_refresh_token, app_id) Raises: AuthenticationError: Token invalid/expired, or user not found/inactive. @@ -361,9 +364,12 @@ async def refresh_token(self, refresh_token_str: str) -> tuple[UserDoc, str, str ) raise AuthenticationError("invalid or expired refresh token") - log.info("token_refreshed", user_id=user_id) - new_access, new_refresh = self._tokens.issue_tokens(user, "pwd") - return user, new_access, new_refresh + amr = claims.get("amr", ["pwd"])[0] + app_id = claims.get("app_id") + + log.info("token_refreshed", user_id=user_id, amr=amr, app_id=app_id) + new_access, new_refresh = self._tokens.issue_tokens(user, amr, app_id=app_id) + return user, new_access, new_refresh, app_id async def verify_email(self, user_id: str, otp_code: str) -> tuple[str, str]: """Verify a user's email address using an OTP code. @@ -593,35 +599,42 @@ async def get_user_profile(self, user_id: str) -> UserDoc: # ── Device auth flow ───────────────────────────────────────────────────── - async def create_device_auth_code(self, user_id: ObjectId, email: str) -> str: + async def create_device_auth_code( + self, user_id: ObjectId, email: str, app_id: str | None = None + ) -> str: """Generate a one-time auth code for the device auth flow. Returns the raw token (caller redirects to callback with it). """ - await self._token_repo.delete_by_user(user_id, TOKEN_TYPE_DEVICE_AUTH) + await self._token_repo.delete_by_user( + user_id, TOKEN_TYPE_DEVICE_AUTH, app_id=app_id + ) raw_token = generate_secure_token(48) now = datetime.now(timezone.utc) - await self._token_repo.create( - { - "user_id": user_id, - "email": email, - "token_hash": hash_token(raw_token), - "token_type": TOKEN_TYPE_DEVICE_AUTH, - "expires_at": now + timedelta(seconds=DEVICE_AUTH_EXPIRY_SECONDS), - "created_at": now, - "used_at": None, - "attempts": 0, - } - ) - log.info("device_auth_code_created", user_id=str(user_id)) + token_data: dict = { + "user_id": user_id, + "email": email, + "token_hash": hash_token(raw_token), + "token_type": TOKEN_TYPE_DEVICE_AUTH, + "expires_at": now + timedelta(seconds=DEVICE_AUTH_EXPIRY_SECONDS), + "created_at": now, + "used_at": None, + "attempts": 0, + } + if app_id: + token_data["app_id"] = app_id + await self._token_repo.create(token_data) + log.info("device_auth_code_created", user_id=str(user_id), app_id=app_id) return raw_token - async def exchange_device_code(self, code: str) -> tuple[UserDoc, str, str]: + async def exchange_device_code( + self, code: str + ) -> tuple[UserDoc, str, str, str | None]: """Exchange a one-time device auth code for JWT tokens. Returns: - (user_doc, access_token, refresh_token) + (user_doc, access_token, refresh_token, app_id) Raises: AuthenticationError: Code invalid, expired, or already used. @@ -637,6 +650,27 @@ async def exchange_device_code(self, code: str) -> tuple[UserDoc, str, str]: if not user or user.status != UserStatus.ACTIVE: raise AuthenticationError("user not found or inactive") - log.info("device_auth_success", user_id=str(user.id)) - access_token, refresh_token = self._tokens.issue_tokens(user, "ext") - return user, access_token, refresh_token + app_id = token_doc.app_id + log.info("device_auth_success", user_id=str(user.id), app_id=app_id) + access_token, refresh_token = self._tokens.issue_tokens( + user, "ext", app_id=app_id + ) + return user, access_token, refresh_token, app_id + + async def revoke_device_tokens( + self, user_id: ObjectId, app_id: str | None = None + ) -> int: + """Invalidate device auth tokens for a user, optionally filtered by app_id. + + Returns the number of tokens deleted. + """ + count = await self._token_repo.delete_by_user( + user_id, TOKEN_TYPE_DEVICE_AUTH, app_id=app_id + ) + log.info( + "device_tokens_revoked", + user_id=str(user_id), + app_id=app_id, + count=count, + ) + return count diff --git a/services/token_factory.py b/services/token_factory.py index 82d3901f..977fcdc2 100644 --- a/services/token_factory.py +++ b/services/token_factory.py @@ -72,11 +72,16 @@ def generate_access_token(self, user: UserDoc, *, amr: str) -> str: } return pyjwt.encode(payload, self._signing_key(), algorithm=self._algorithm()) - def generate_refresh_token(self, user: UserDoc, *, amr: str) -> str: + def generate_refresh_token( + self, user: UserDoc, *, amr: str, app_id: str | None = None + ) -> str: """Issue a signed JWT refresh token for *user*. Identical to the access token but includes ``"type": "refresh"`` and uses ``refresh_token_ttl_seconds`` for the expiry window. + + When *app_id* is provided (device auth flow), it is embedded as a claim + so the refresh endpoint can enforce grant checks on a per-app basis. """ now = int(datetime.now(timezone.utc).timestamp()) payload: dict[str, Any] = { @@ -89,9 +94,13 @@ def generate_refresh_token(self, user: UserDoc, *, amr: str) -> str: "email_verified": user.email_verified, "type": "refresh", } + if app_id: + payload["app_id"] = app_id return pyjwt.encode(payload, self._signing_key(), algorithm=self._algorithm()) - def issue_tokens(self, user: UserDoc, amr: str) -> tuple[str, str]: + def issue_tokens( + self, user: UserDoc, amr: str, *, app_id: str | None = None + ) -> tuple[str, str]: """Issue an access + refresh token pair for *user*. Returns: @@ -99,7 +108,7 @@ def issue_tokens(self, user: UserDoc, amr: str) -> tuple[str, str]: """ return ( self.generate_access_token(user, amr=amr), - self.generate_refresh_token(user, amr=amr), + self.generate_refresh_token(user, amr=amr, app_id=app_id), ) # ── Token verification ──────────────────────────────────────────────────── diff --git a/shared/app_registry.py b/shared/app_registry.py new file mode 100644 index 00000000..391dd7af --- /dev/null +++ b/shared/app_registry.py @@ -0,0 +1,78 @@ +""" +App registry loader. + +Reads apps.yaml at startup, validates each entry with Pydantic, +and returns a typed dict of AppEntry models. +""" + +from __future__ import annotations + +from pathlib import Path + +import yaml +from pydantic import ValidationError + +from schemas.models.app import AppEntry, AppStatus +from shared.logging import get_logger + +log = get_logger(__name__) + +_PROJECT_ROOT = Path(__file__).parent.parent +_APPS_YAML = _PROJECT_ROOT / "config" / "apps.yaml" +_STATIC_APPS_DIR = _PROJECT_ROOT / "static" / "images" / "apps" + + +def load_app_registry(config_path: Path | None = None) -> dict[str, AppEntry]: + """Load and validate the app registry from YAML. + + Returns a dict mapping app_id -> validated AppEntry model. + Invalid entries are skipped with a warning. + """ + path = config_path or _APPS_YAML + if not path.exists(): + log.warning("app_registry_not_found", path=str(path)) + return {} + + try: + with open(path) as f: + data = yaml.safe_load(f) + except (OSError, yaml.YAMLError) as exc: + log.error("app_registry_parse_error", path=str(path), error=str(exc)) + return {} + + if not data or not isinstance(data, dict): + log.warning("app_registry_invalid_format", path=str(path)) + return {} + + raw_apps = data.get("apps", {}) + if not raw_apps: + log.warning("app_registry_empty", path=str(path)) + return {} + + registry: dict[str, AppEntry] = {} + for app_id, app_data in raw_apps.items(): + try: + entry = AppEntry.model_validate(app_data) + except ValidationError as exc: + log.warning( + "app_registry_entry_invalid", + app_id=app_id, + error=str(exc), + ) + continue + + # Validate icon files exist for live apps + if entry.status == AppStatus.LIVE and entry.icon: + icon_path = _STATIC_APPS_DIR / entry.icon + if not icon_path.exists(): + log.warning( + "app_icon_missing", + app_id=app_id, + icon=entry.icon, + expected_path=str(icon_path), + ) + + registry[app_id] = entry + + log.info("app_registry_loaded", app_count=len(registry)) + return registry diff --git a/static/css/dashboard/apps.css b/static/css/dashboard/apps.css new file mode 100644 index 00000000..bc56f714 --- /dev/null +++ b/static/css/dashboard/apps.css @@ -0,0 +1,414 @@ +/* Apps Page Styles */ + +.apps-section { + margin-bottom: 2rem; +} + +.section-title { + font-size: 0.875rem; + font-weight: 600; + color: var(--text-secondary, #999); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 0.75rem; + padding-left: 2px; +} + +.apps-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.75rem; +} + +/* App Card */ +.app-card { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.02)); + border: 1px solid var(--border-color, #262626); + border-radius: var(--radius-md, 8px); + padding: 1rem; + transition: border-color 0.15s; +} + +.app-card:hover { + border-color: rgba(255, 255, 255, 0.15); +} + +/* Clickable card (available apps) */ +.app-card--link { + text-decoration: none; + color: inherit; + position: relative; + cursor: pointer; +} + +.app-card-arrow { + position: absolute; + top: 0.75rem; + right: 0.75rem; + font-size: 1.1rem; + color: var(--text-secondary, #999); + opacity: 0; + transition: opacity 0.15s, transform 0.15s, filter 0.15s; + transform: translate(-2px, 2px); +} + +.app-card--link:hover .app-card-arrow { + opacity: 1; + transform: translate(0, 0); + filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.3)); +} + +.app-card--link .app-card-header, +.app-card--muted .app-card-header { + margin-bottom: 0; +} + +/* Connected status dot */ +.status-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: #22c55e; + flex-shrink: 0; + /* box-shadow: 0 0 6px rgba(34, 197, 94, 0.4); */ +} + +/* Card footer (last active) */ +.app-card-footer { + padding-top: 0.3rem; + margin-top: 0.1rem; +} + +/* Kebab menu */ +.kebab-menu { + position: absolute; + top: 0.5rem; + right: 0.5rem; +} + +.app-card { + position: relative; +} + +.kebab-btn { + background: transparent; + border: none; + color: var(--text-secondary, #666); + cursor: pointer; + padding: 0.25rem; + border-radius: 4px; + font-size: 1.1rem; + line-height: 1; + transition: color 0.15s, background 0.15s, opacity 0.15s; + opacity: 0; +} + +.app-card:hover .kebab-btn, +.kebab-menu:has(.kebab-dropdown.open) .kebab-btn, +.kebab-btn:focus { + opacity: 1; +} + +.kebab-btn:hover { + color: var(--text-primary, #e5e5e5); + background: rgba(255, 255, 255, 0.08); +} + +.kebab-dropdown { + position: absolute; + top: 100%; + right: 0; + margin-top: 4px; + background: rgba(12, 14, 24, 0.98); + backdrop-filter: blur(20px); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: var(--radius-md, 8px); + padding: 4px; + min-width: 150px; + z-index: 100; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(0, 0, 0, 0.2); + opacity: 0; + visibility: hidden; + transform: translateY(6px); + transition: all 0.2s; +} + +.kebab-dropdown.open { + opacity: 1; + visibility: visible; + transform: translateY(0); +} + +.kebab-item { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 8px 10px; + border: none; + background: transparent; + color: var(--text-secondary, #999); + font-size: 14px; + cursor: pointer; + border-radius: var(--radius-sm, 6px); + transition: all 0.15s; +} + +.kebab-item i { + width: 18px; + height: 18px; + flex-shrink: 0; + font-size: 18px; +} + +.kebab-item:hover { + background: var(--hover-bg, rgba(255, 255, 255, 0.06)); + color: var(--text-primary, #e5e5e5); +} + +.kebab-item--danger:hover { + background: rgba(239, 68, 68, 0.1); + color: #ef4444; +} + +.app-card--muted { + opacity: 0.7; +} + +.app-card--muted:hover { + opacity: 0.85; + border-color: var(--border-color, #262626); +} + +/* Card Header */ +.app-card-header { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.75rem; +} + +.app-icon { + width: 40px; + height: 40px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.06); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + overflow: hidden; + padding: 2px; +} + +.app-icon img { + width: 100%; + height: 100%; + object-fit: contain; + padding: 6px; +} + +.app-icon i { + font-size: 1.25rem; + color: var(--text-secondary, #999); +} + +.app-icon--muted { + filter: grayscale(0.1); +} + +.app-info { + min-width: 0; + flex: 1; +} + +.app-name-row { + display: flex; + align-items: center; + gap: 0.35rem; + margin-bottom: 0.15rem; +} + +.app-name { + font-size: 0.9rem; + font-weight: 600; + color: var(--text-primary, #e5e5e5); +} + +.verified-badge { + color: #3b82f6; + font-size: 0.9rem; +} + +.app-description { + font-size: 0.775rem; + color: var(--text-secondary, #999); + display: block; +} + +.badge { + font-size: 0.65rem; + font-weight: 500; + padding: 0.15rem 0.4rem; + border-radius: 4px; + white-space: nowrap; +} + +.badge-muted { + background: rgba(255, 255, 255, 0.08); + color: var(--text-secondary, #999); +} + +/* Card Meta */ +.app-card-meta { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-bottom: 0.75rem; +} + +.meta-item { + font-size: 0.7rem; + color: var(--text-secondary, #666); + display: flex; + align-items: center; + gap: 0.3rem; +} + +.meta-item i { + font-size: 0.8rem; +} + +/* Card Actions */ +.app-card-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.btn { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.4rem 0.75rem; + border-radius: var(--radius-sm, 6px); + font-size: 0.775rem; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; + border: none; + text-decoration: none; +} + +.btn-sm { + padding: 0.35rem 0.625rem; + font-size: 0.725rem; +} + +.btn-outline { + background: transparent; + border: 1px solid var(--border-color, #333); + color: var(--text-primary, #e5e5e5); +} + +.btn-outline:hover { + background: rgba(255, 255, 255, 0.06); + border-color: rgba(255, 255, 255, 0.2); +} + +.btn-danger-outline { + background: transparent; + border: 1px solid rgba(239, 68, 68, 0.3); + color: #ef4444; +} + +.btn-danger-outline:hover { + background: rgba(239, 68, 68, 0.1); + border-color: rgba(239, 68, 68, 0.5); +} + +.btn-ghost { + background: transparent; + color: var(--text-secondary, #999); +} + +.btn-ghost:hover { + color: var(--text-primary, #e5e5e5); + background: rgba(255, 255, 255, 0.06); +} + +.btn-danger { + background: #ef4444; + color: #fff; +} + +.btn-danger:hover { + background: #dc2626; +} + +/* Empty State */ +.empty-state { + text-align: center; + padding: 2.5rem 1rem; + color: var(--text-secondary, #666); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent); + border: 1px dashed var(--border-color, #262626); + border-radius: var(--radius-md, 8px); +} + +.empty-state i { + font-size: 2rem; + display: block; + margin-bottom: 0.5rem; + opacity: 0.5; +} + +.empty-state p { + font-size: 0.8rem; +} + +/* Disconnect modal overrides */ +#revoke-dialog .modal-icon { + width: 36px; + height: 36px; + border-radius: 8px; + font-size: 18px; +} + +#revoke-dialog .modal-title { + font-size: 18px; +} + +#revoke-dialog .modal-subtitle { + font-size: 13px; +} + +#revoke-dialog .modal-body { + padding: 20px 24px; +} + +#revoke-dialog .warning-content p { + font-size: 14px; + text-align: left; +} + +#revoke-dialog .modal-header { + padding: 16px 20px; +} + +#revoke-dialog .modal-footer { + padding: 16px 20px; +} + +/* Responsive */ +@media (max-width: 1024px) { + .apps-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 640px) { + .apps-grid { + grid-template-columns: 1fr; + } +} diff --git a/static/images/apps/discord-bot.svg b/static/images/apps/discord-bot.svg new file mode 100644 index 00000000..c03e8e12 --- /dev/null +++ b/static/images/apps/discord-bot.svg @@ -0,0 +1,8 @@ + + + \ No newline at end of file diff --git a/static/images/apps/mcp.svg b/static/images/apps/mcp.svg new file mode 100644 index 00000000..5cd83a8b --- /dev/null +++ b/static/images/apps/mcp.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/images/apps/n8n.svg b/static/images/apps/n8n.svg new file mode 100644 index 00000000..82f0a6da --- /dev/null +++ b/static/images/apps/n8n.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/images/apps/raycast.svg b/static/images/apps/raycast.svg new file mode 100644 index 00000000..2ce034a0 --- /dev/null +++ b/static/images/apps/raycast.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/images/apps/slack-bot.svg b/static/images/apps/slack-bot.svg new file mode 100644 index 00000000..ff256fd4 --- /dev/null +++ b/static/images/apps/slack-bot.svg @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/static/images/apps/spoo-cli.svg b/static/images/apps/spoo-cli.svg new file mode 100644 index 00000000..a30e72c1 --- /dev/null +++ b/static/images/apps/spoo-cli.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/static/images/apps/spoo-desktop.svg b/static/images/apps/spoo-desktop.svg new file mode 100644 index 00000000..4fbbd4e7 --- /dev/null +++ b/static/images/apps/spoo-desktop.svg @@ -0,0 +1 @@ + diff --git a/static/images/apps/spoo-mobile.svg b/static/images/apps/spoo-mobile.svg new file mode 100644 index 00000000..e0d53431 --- /dev/null +++ b/static/images/apps/spoo-mobile.svg @@ -0,0 +1,7 @@ + + \ No newline at end of file diff --git a/static/images/apps/spoo-snap.svg b/static/images/apps/spoo-snap.svg new file mode 100644 index 00000000..c24e7087 --- /dev/null +++ b/static/images/apps/spoo-snap.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/images/apps/telegram-bot.svg b/static/images/apps/telegram-bot.svg new file mode 100644 index 00000000..63f03989 --- /dev/null +++ b/static/images/apps/telegram-bot.svg @@ -0,0 +1,11 @@ + + \ No newline at end of file diff --git a/static/images/apps/vscode.svg b/static/images/apps/vscode.svg new file mode 100644 index 00000000..c453e633 --- /dev/null +++ b/static/images/apps/vscode.svg @@ -0,0 +1,41 @@ + diff --git a/static/images/apps/zapier.svg b/static/images/apps/zapier.svg new file mode 100644 index 00000000..6ee0b5ec --- /dev/null +++ b/static/images/apps/zapier.svg @@ -0,0 +1,8 @@ + + + \ No newline at end of file diff --git a/templates/dashboard/apps.html b/templates/dashboard/apps.html new file mode 100644 index 00000000..383ec22a --- /dev/null +++ b/templates/dashboard/apps.html @@ -0,0 +1,244 @@ +{% extends "dashboard/base.html" %} + +{% block title %}Apps | Dashboard | spoo.me{% endblock %} + +{% block head_css %} + + + +{% endblock %} + +{% block content %} +
Manage connected apps and discover the spoo.me ecosystem.
+No apps connected yet. Install an app below to get started.
+