diff --git a/SOCIAL_MEDIA_LINKS_ADMIN.md b/SOCIAL_MEDIA_LINKS_ADMIN.md new file mode 100644 index 000000000..4a9221e17 --- /dev/null +++ b/SOCIAL_MEDIA_LINKS_ADMIN.md @@ -0,0 +1,236 @@ +# Social Media Links Management + +This document explains how to manage dynamic social media links for the SurfSense homepage footer. + +## Overview + +Social media links are now managed dynamically through the backend API. No hardcoded links exist in the frontend code. Administrators can add, edit, or remove links, and they will automatically appear or disappear from the homepage footer. + +## API Endpoints + +All admin endpoints require authentication and **superuser** privileges. + +### Base URL +``` +{BACKEND_URL}/api/v1/social-media-links +``` + +### 1. Get All Links (Admin) +```bash +GET /api/v1/social-media-links +Authorization: Bearer {your_jwt_token} +``` + +**Response:** +```json +[ + { + "id": 1, + "platform": "MASTODON", + "url": "https://mastodon.social/@username", + "label": "Follow us on Mastodon", + "display_order": 0, + "is_active": true, + "created_at": "2025-11-17T00:00:00Z" + } +] +``` + +### 2. Get Public Links (No Auth Required) +```bash +GET /api/v1/social-media-links/public +``` + +This endpoint returns only active links, ordered by `display_order`. Used by the homepage footer. + +### 3. Create a Link (Admin) +```bash +POST /api/v1/social-media-links +Authorization: Bearer {your_jwt_token} +Content-Type: application/json + +{ + "platform": "MASTODON", + "url": "https://mastodon.social/@username", + "label": "Mastodon", + "display_order": 0, + "is_active": true +} +``` + +**Supported Platforms:** +- `MASTODON` +- `PIXELFED` +- `BOOKWYRM` +- `LEMMY` +- `PEERTUBE` +- `GITHUB` +- `GITLAB` +- `MATRIX` +- `LINKEDIN` +- `WEBSITE` +- `EMAIL` +- `OTHER` + +### 4. Update a Link (Admin) +```bash +PATCH /api/v1/social-media-links/{link_id} +Authorization: Bearer {your_jwt_token} +Content-Type: application/json + +{ + "url": "https://new-url.example.com", + "is_active": false +} +``` + +You can update any field(s). Omit fields you don't want to change. + +### 5. Delete a Link (Admin) +```bash +DELETE /api/v1/social-media-links/{link_id} +Authorization: Bearer {your_jwt_token} +``` + +## Example: Using cURL + +### Add a Mastodon Link +```bash +# First, login to get JWT token +TOKEN=$(curl -X POST "http://localhost:8000/auth/jwt/login" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "username=admin@example.com&password=yourpassword" \ + | jq -r '.access_token') + +# Create the link +curl -X POST "http://localhost:8000/api/v1/social-media-links" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "platform": "MASTODON", + "url": "https://mastodon.social/@surfsense", + "label": "Mastodon", + "display_order": 1, + "is_active": true + }' +``` + +### Add Multiple Links +```bash +# Pixelfed +curl -X POST "http://localhost:8000/api/v1/social-media-links" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "platform": "PIXELFED", + "url": "https://pixelfed.social/username", + "label": "Pixelfed", + "display_order": 2, + "is_active": true + }' + +# Bookwyrm +curl -X POST "http://localhost:8000/api/v1/social-media-links" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "platform": "BOOKWYRM", + "url": "https://bookwyrm.social/user/username", + "label": "Bookwyrm", + "display_order": 3, + "is_active": true + }' +``` + +### Update a Link +```bash +# Hide a link without deleting it +curl -X PATCH "http://localhost:8000/api/v1/social-media-links/1" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"is_active": false}' +``` + +### List All Links +```bash +curl "http://localhost:8000/api/v1/social-media-links" \ + -H "Authorization: Bearer $TOKEN" +``` + +### Delete a Link +```bash +curl -X DELETE "http://localhost:8000/api/v1/social-media-links/1" \ + -H "Authorization: Bearer $TOKEN" +``` + +## Icon Mapping + +The frontend automatically maps platform names to appropriate icons: + +| Platform | Icon | +|----------|------| +| MASTODON | Mastodon logo | +| PIXELFED | Photo icon | +| BOOKWYRM | Book icon | +| GITHUB | GitHub logo | +| GITLAB | GitLab logo | +| LINKEDIN | LinkedIn logo | +| WEBSITE | World/globe icon | +| EMAIL | Mail icon | +| MATRIX | Matrix logo | +| PEERTUBE | TV/video icon | +| LEMMY | World icon | +| OTHER | World icon | + +## How It Works + +1. **Backend:** Social media links are stored in the `social_media_links` database table +2. **Public API:** The `/api/v1/social-media-links/public` endpoint returns only active links +3. **Frontend:** The footer component (`components/homepage/footer.tsx`) fetches links on page load +4. **Display:** Only links with `is_active=true` are shown, ordered by `display_order` +5. **Icons:** Platform-specific icons are automatically selected based on the `platform` field + +## Database Migration + +To apply the database schema: + +```bash +cd surfsense_backend +alembic upgrade head +``` + +This will create the `social_media_links` table and the `socialmediaplatform` enum type. + +## Security Notes + +- **Admin endpoints require superuser privileges** - regular users cannot manage links +- **Public endpoint is unauthenticated** - anyone can view active links (as intended for homepage display) +- **No hardcoded URLs** - all social media links come from the database +- **Validation** - URLs are validated (max 500 chars), labels max 100 chars + +## Future Enhancements + +Future improvements could include: + +1. **Web UI:** A graphical admin panel in the dashboard for managing links +2. **Drag-and-drop reordering:** UI to easily reorder links by changing `display_order` +3. **Link analytics:** Track clicks on social media links +4. **Per-user links:** Allow different users to have different social links (currently global) +5. **Link preview:** Show how the footer will look before saving changes + +## Troubleshooting + +**Links not appearing on homepage:** +1. Check that `is_active=true` for the link +2. Verify the backend API is reachable from frontend +3. Check browser console for fetch errors +4. Confirm `NEXT_PUBLIC_FASTAPI_BACKEND_URL` environment variable is set correctly + +**Cannot create links:** +1. Ensure you're logged in as a superuser +2. Check JWT token is valid and not expired +3. Verify request payload matches the schema + +**Icons not displaying:** +1. Check that the platform name exactly matches one of the supported platforms (case-sensitive) +2. Unsupported platforms will default to the world/globe icon diff --git a/surfsense_backend/alembic/versions/37_add_social_media_links_table.py b/surfsense_backend/alembic/versions/37_add_social_media_links_table.py new file mode 100644 index 000000000..1f100122a --- /dev/null +++ b/surfsense_backend/alembic/versions/37_add_social_media_links_table.py @@ -0,0 +1,59 @@ +"""add social_media_links table + +Revision ID: 37_add_social_media_links_table +Revises: 36_remove_fk_constraints_for_global_llm_configs +Create Date: 2025-11-17 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '37_add_social_media_links_table' +down_revision: Union[str, None] = '36_remove_fk_constraints_for_global_llm_configs' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Create enum type + social_media_platform_enum = sa.Enum( + 'MASTODON', 'PIXELFED', 'BOOKWYRM', 'LEMMY', 'PEERTUBE', + 'GITHUB', 'GITLAB', 'MATRIX', 'LINKEDIN', 'WEBSITE', 'EMAIL', 'OTHER', + name='socialmediaplatform' + ) + social_media_platform_enum.create(op.get_bind(), checkfirst=True) + + # Create social_media_links table + op.create_table( + 'social_media_links', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('platform', social_media_platform_enum, nullable=False), + sa.Column('url', sa.String(length=500), nullable=False), + sa.Column('label', sa.String(length=100), nullable=True), + sa.Column('display_order', sa.Integer(), nullable=False, server_default='0'), + sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'), + sa.Column('created_at', sa.TIMESTAMP(timezone=True), nullable=False, server_default=sa.text('now()')), + sa.PrimaryKeyConstraint('id') + ) + + # Create indexes + op.create_index('ix_social_media_links_id', 'social_media_links', ['id']) + op.create_index('ix_social_media_links_platform', 'social_media_links', ['platform']) + op.create_index('ix_social_media_links_created_at', 'social_media_links', ['created_at']) + + +def downgrade() -> None: + # Drop indexes + op.drop_index('ix_social_media_links_created_at', table_name='social_media_links') + op.drop_index('ix_social_media_links_platform', table_name='social_media_links') + op.drop_index('ix_social_media_links_id', table_name='social_media_links') + + # Drop table + op.drop_table('social_media_links') + + # Drop enum type + sa.Enum(name='socialmediaplatform').drop(op.get_bind(), checkfirst=True) diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index ce386e554..2db2474bd 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -124,6 +124,21 @@ class LogStatus(str, Enum): FAILED = "FAILED" +class SocialMediaPlatform(str, Enum): + MASTODON = "MASTODON" + PIXELFED = "PIXELFED" + BOOKWYRM = "BOOKWYRM" + LEMMY = "LEMMY" + PEERTUBE = "PEERTUBE" + GITHUB = "GITHUB" + GITLAB = "GITLAB" + MATRIX = "MATRIX" + LINKEDIN = "LINKEDIN" + WEBSITE = "WEBSITE" + EMAIL = "EMAIL" + OTHER = "OTHER" + + class Base(DeclarativeBase): pass @@ -358,6 +373,16 @@ class Log(BaseModel, TimestampMixin): search_space = relationship("SearchSpace", back_populates="logs") +class SocialMediaLink(BaseModel, TimestampMixin): + __tablename__ = "social_media_links" + + platform = Column(SQLAlchemyEnum(SocialMediaPlatform), nullable=False, index=True) + url = Column(String(500), nullable=False) + label = Column(String(100), nullable=True) # Optional custom label + display_order = Column(Integer, nullable=False, default=0) # For ordering links + is_active = Column(Boolean, nullable=False, default=True) # Toggle visibility + + if config.AUTH_TYPE == "GOOGLE": class OAuthAccount(Base): diff --git a/surfsense_backend/app/routes/__init__.py b/surfsense_backend/app/routes/__init__.py index 1c7e3505f..9866ef20e 100644 --- a/surfsense_backend/app/routes/__init__.py +++ b/surfsense_backend/app/routes/__init__.py @@ -17,6 +17,7 @@ from .luma_add_connector_route import router as luma_add_connector_router from .podcasts_routes import router as podcasts_router from .search_source_connectors_routes import router as search_source_connectors_router from .search_spaces_routes import router as search_spaces_router +from .social_media_links_routes import router as social_media_links_router router = APIRouter() @@ -31,3 +32,4 @@ router.include_router(airtable_add_connector_router) router.include_router(luma_add_connector_router) router.include_router(llm_config_router) router.include_router(logs_router) +router.include_router(social_media_links_router) diff --git a/surfsense_backend/app/routes/social_media_links_routes.py b/surfsense_backend/app/routes/social_media_links_routes.py new file mode 100644 index 000000000..411c118e3 --- /dev/null +++ b/surfsense_backend/app/routes/social_media_links_routes.py @@ -0,0 +1,151 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db import SocialMediaLink, User, get_async_session +from app.schemas.social_media_links import ( + SocialMediaLinkCreate, + SocialMediaLinkPublic, + SocialMediaLinkRead, + SocialMediaLinkUpdate, +) +from app.users import current_active_user + +router = APIRouter(prefix="/api/v1/social-media-links", tags=["Social Media Links"]) + + +@router.get("/public", response_model=list[SocialMediaLinkPublic]) +async def get_public_social_media_links( + db: AsyncSession = Depends(get_async_session), +): + """ + Get all active social media links for public display (no authentication required). + Returns links ordered by display_order. + """ + query = ( + select(SocialMediaLink) + .where(SocialMediaLink.is_active == True) + .order_by(SocialMediaLink.display_order, SocialMediaLink.id) + ) + result = await db.execute(query) + links = result.scalars().all() + return links + + +@router.get("", response_model=list[SocialMediaLinkRead]) +async def get_all_social_media_links( + db: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + """ + Get all social media links (admin only). + Requires authentication and superuser privileges. + """ + if not user.is_superuser: + raise HTTPException(status_code=403, detail="Insufficient permissions") + + query = select(SocialMediaLink).order_by( + SocialMediaLink.display_order, SocialMediaLink.id + ) + result = await db.execute(query) + links = result.scalars().all() + return links + + +@router.post("", response_model=SocialMediaLinkRead, status_code=201) +async def create_social_media_link( + link_data: SocialMediaLinkCreate, + db: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + """ + Create a new social media link (admin only). + Requires authentication and superuser privileges. + """ + if not user.is_superuser: + raise HTTPException(status_code=403, detail="Insufficient permissions") + + new_link = SocialMediaLink(**link_data.model_dump()) + db.add(new_link) + await db.commit() + await db.refresh(new_link) + return new_link + + +@router.get("/{link_id}", response_model=SocialMediaLinkRead) +async def get_social_media_link( + link_id: int, + db: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + """ + Get a specific social media link by ID (admin only). + Requires authentication and superuser privileges. + """ + if not user.is_superuser: + raise HTTPException(status_code=403, detail="Insufficient permissions") + + query = select(SocialMediaLink).where(SocialMediaLink.id == link_id) + result = await db.execute(query) + link = result.scalar_one_or_none() + + if not link: + raise HTTPException(status_code=404, detail="Social media link not found") + + return link + + +@router.patch("/{link_id}", response_model=SocialMediaLinkRead) +async def update_social_media_link( + link_id: int, + link_data: SocialMediaLinkUpdate, + db: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + """ + Update a social media link (admin only). + Requires authentication and superuser privileges. + """ + if not user.is_superuser: + raise HTTPException(status_code=403, detail="Insufficient permissions") + + query = select(SocialMediaLink).where(SocialMediaLink.id == link_id) + result = await db.execute(query) + link = result.scalar_one_or_none() + + if not link: + raise HTTPException(status_code=404, detail="Social media link not found") + + # Update only provided fields + update_data = link_data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(link, field, value) + + await db.commit() + await db.refresh(link) + return link + + +@router.delete("/{link_id}", status_code=204) +async def delete_social_media_link( + link_id: int, + db: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + """ + Delete a social media link (admin only). + Requires authentication and superuser privileges. + """ + if not user.is_superuser: + raise HTTPException(status_code=403, detail="Insufficient permissions") + + query = select(SocialMediaLink).where(SocialMediaLink.id == link_id) + result = await db.execute(query) + link = result.scalar_one_or_none() + + if not link: + raise HTTPException(status_code=404, detail="Social media link not found") + + await db.delete(link) + await db.commit() + return None diff --git a/surfsense_backend/app/schemas/social_media_links.py b/surfsense_backend/app/schemas/social_media_links.py new file mode 100644 index 000000000..2c29f9c32 --- /dev/null +++ b/surfsense_backend/app/schemas/social_media_links.py @@ -0,0 +1,42 @@ +from datetime import datetime + +from pydantic import BaseModel, Field, HttpUrl + +from app.db import SocialMediaPlatform + + +class SocialMediaLinkBase(BaseModel): + platform: SocialMediaPlatform + url: str = Field(..., max_length=500) + label: str | None = Field(None, max_length=100) + display_order: int = Field(default=0, ge=0) + is_active: bool = Field(default=True) + + +class SocialMediaLinkCreate(SocialMediaLinkBase): + pass + + +class SocialMediaLinkUpdate(BaseModel): + platform: SocialMediaPlatform | None = None + url: str | None = Field(None, max_length=500) + label: str | None = Field(None, max_length=100) + display_order: int | None = Field(None, ge=0) + is_active: bool | None = None + + +class SocialMediaLinkRead(SocialMediaLinkBase): + id: int + created_at: datetime + + model_config = {"from_attributes": True} + + +class SocialMediaLinkPublic(BaseModel): + """Public-facing schema with only necessary fields for display""" + id: int + platform: SocialMediaPlatform + url: str + label: str | None + + model_config = {"from_attributes": True} diff --git a/surfsense_web/app/(home)/login/GoogleLoginButton.tsx b/surfsense_web/app/(home)/login/GoogleLoginButton.tsx deleted file mode 100644 index 52d79d72b..000000000 --- a/surfsense_web/app/(home)/login/GoogleLoginButton.tsx +++ /dev/null @@ -1,102 +0,0 @@ -"use client"; -import { IconBrandGoogleFilled } from "@tabler/icons-react"; -import { motion } from "motion/react"; -import { useTranslations } from "next-intl"; -import { Logo } from "@/components/Logo"; -import { AmbientBackground } from "./AmbientBackground"; - -export function GoogleLoginButton() { - const t = useTranslations("auth"); - - const handleGoogleLogin = () => { - // Redirect to Google OAuth authorization URL - fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/google/authorize`) - .then((response) => { - if (!response.ok) { - throw new Error("Failed to get authorization URL"); - } - return response.json(); - }) - .then((data) => { - if (data.authorization_url) { - window.location.href = data.authorization_url; - } else { - console.error("No authorization URL received"); - } - }) - .catch((error) => { - console.error("Error during Google login:", error); - }); - }; - return ( -
- {t("cloud_dev_notice")}{" "} - - {t("docs")} - {" "} - {t("cloud_dev_self_hosted")} -
-© SurfSense 2025
-