diff --git a/backend/app/api/users.py b/backend/app/api/users.py index 1ec5351..eea2207 100644 --- a/backend/app/api/users.py +++ b/backend/app/api/users.py @@ -29,9 +29,11 @@ from app.models.organizations import Organization from app.models.task_dependencies import TaskDependency from app.models.task_fingerprints import TaskFingerprint from app.models.tasks import Task +from app.models.forgejo_connections import ForgejoConnection from app.models.users import User from app.schemas.common import OkResponse from app.schemas.users import UserRead, UserUpdate +from app.services.forgejo_client import ForgejoClientError, get_forgejo_client if TYPE_CHECKING: from sqlmodel.ext.asyncio.session import AsyncSession @@ -224,6 +226,68 @@ async def update_me( return UserRead.model_validate(user) +@router.post("/me/forgejo-sync", response_model=UserRead) +async def sync_me_forgejo_profile( + session: AsyncSession = SESSION_DEP, + auth: AuthContext = AUTH_CONTEXT_DEP, +) -> UserRead: + """Fetch the user's Forgejo profile and cache name + avatar on this account. + + Requires an active Forgejo connection on the user's current organisation. + Sets ``use_forgejo_profile=True`` so the fetched values are used in the UI. + """ + if auth.actor_type != "user" or auth.user is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) + + user: User = auth.user + if user.active_organization_id is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="No active organisation set on this account.", + ) + + connection = ( + await session.exec( + select(ForgejoConnection) + .where(ForgejoConnection.organization_id == user.active_organization_id) + .where(ForgejoConnection.active.is_(True)) + .order_by(ForgejoConnection.created_at.asc()) + .limit(1) + ) + ).first() + if connection is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No active Forgejo connection found for this organisation.", + ) + if not connection.token: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Forgejo connection has no token configured.", + ) + + try: + async with get_forgejo_client(connection) as client: + forgejo_user = await client.get_user() + except ForgejoClientError as exc: + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Failed to fetch Forgejo profile: {exc}", + ) + + raw_name = forgejo_user.get("full_name") or forgejo_user.get("login") or "" + raw_avatar = forgejo_user.get("avatar_url") or "" + user.forgejo_display_name = str(raw_name) or None + user.forgejo_avatar_url = str(raw_avatar) or None + user.use_forgejo_profile = True + session.add(user) + await session.commit() + await session.refresh(user) + return UserRead.model_validate(user) + + @router.delete("/me", response_model=OkResponse) async def delete_me( session: AsyncSession = SESSION_DEP, diff --git a/backend/app/models/users.py b/backend/app/models/users.py index 5173c7d..9d6436d 100644 --- a/backend/app/models/users.py +++ b/backend/app/models/users.py @@ -29,3 +29,6 @@ class User(QueryModel, table=True): foreign_key="organizations.id", index=True, ) + use_forgejo_profile: bool = Field(default=False) + forgejo_avatar_url: str | None = None + forgejo_display_name: str | None = None diff --git a/backend/app/schemas/users.py b/backend/app/schemas/users.py index 12768ae..97f81e0 100644 --- a/backend/app/schemas/users.py +++ b/backend/app/schemas/users.py @@ -67,6 +67,7 @@ class UserUpdate(SQLModel): timezone: str | None = None notes: str | None = None context: str | None = None + use_forgejo_profile: bool | None = None class UserRead(UserBase): @@ -80,3 +81,15 @@ class UserRead(UserBase): description="Whether this user has tenant-wide super-admin privileges.", examples=[False], ) + use_forgejo_profile: bool = Field( + default=False, + description="Whether to display Forgejo profile name and avatar.", + ) + forgejo_avatar_url: str | None = Field( + default=None, + description="Cached Forgejo avatar URL.", + ) + forgejo_display_name: str | None = Field( + default=None, + description="Cached Forgejo display name.", + ) diff --git a/backend/migrations/versions/a3c5e7f9b1d2_add_forgejo_profile_fields_to_users.py b/backend/migrations/versions/a3c5e7f9b1d2_add_forgejo_profile_fields_to_users.py new file mode 100644 index 0000000..aeaf69f --- /dev/null +++ b/backend/migrations/versions/a3c5e7f9b1d2_add_forgejo_profile_fields_to_users.py @@ -0,0 +1,44 @@ +"""Add forgejo profile fields to users. + +Revision ID: a3c5e7f9b1d2 +Revises: f7d8e9a0b1c2 +Create Date: 2026-05-22 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = "a3c5e7f9b1d2" +down_revision = "f7d8e9a0b1c2" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "users", + sa.Column( + "use_forgejo_profile", + sa.Boolean(), + nullable=False, + server_default=sa.text("false"), + ), + ) + op.add_column( + "users", + sa.Column("forgejo_avatar_url", sa.String(), nullable=True), + ) + op.add_column( + "users", + sa.Column("forgejo_display_name", sa.String(), nullable=True), + ) + # Remove server default so SQLModel manages the value going forward + op.alter_column("users", "use_forgejo_profile", server_default=None) + + +def downgrade() -> None: + op.drop_column("users", "forgejo_display_name") + op.drop_column("users", "forgejo_avatar_url") + op.drop_column("users", "use_forgejo_profile") diff --git a/frontend/src/api/generated/model/userRead.ts b/frontend/src/api/generated/model/userRead.ts index 6082df9..aff5241 100644 --- a/frontend/src/api/generated/model/userRead.ts +++ b/frontend/src/api/generated/model/userRead.ts @@ -29,4 +29,10 @@ export interface UserRead { id: string; /** Whether this user has tenant-wide super-admin privileges. */ is_super_admin: boolean; + /** Whether to display Forgejo profile name and avatar. */ + use_forgejo_profile: boolean; + /** Cached Forgejo avatar URL. */ + forgejo_avatar_url?: string | null; + /** Cached Forgejo display name. */ + forgejo_display_name?: string | null; } diff --git a/frontend/src/api/generated/model/userUpdate.ts b/frontend/src/api/generated/model/userUpdate.ts index 7e19638..8d52418 100644 --- a/frontend/src/api/generated/model/userUpdate.ts +++ b/frontend/src/api/generated/model/userUpdate.ts @@ -15,4 +15,5 @@ export interface UserUpdate { timezone?: string | null; notes?: string | null; context?: string | null; + use_forgejo_profile?: boolean | null; } diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index 3478279..3824443 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -118,6 +118,7 @@ type GatewaySnapshot = GatewayTarget & { }; const DASH = "—"; +const FORGEJO_DASHBOARD_REFETCH_INTERVAL_MS = 5 * 60 * 1000; const DASHBOARD_RANGE = "7d"; const DASHBOARD_RANGE_DAYS = 7; const DASHBOARD_RANGE_LABEL = "7 days"; @@ -492,7 +493,7 @@ export default function DashboardPage() { const forgejoRepositoriesQuery = useQuery({ queryKey: ["dashboard", "forgejo", "repositories"], enabled: Boolean(isSignedIn), - refetchInterval: 60_000, + refetchInterval: FORGEJO_DASHBOARD_REFETCH_INTERVAL_MS, refetchOnMount: "always", queryFn: () => getForgejoRepositories(), }); @@ -515,7 +516,7 @@ export default function DashboardPage() { !forgejoRepositoriesQuery.isLoading && !forgejoRepositoriesQuery.error, ), - refetchInterval: 60_000, + refetchInterval: FORGEJO_DASHBOARD_REFETCH_INTERVAL_MS, refetchOnMount: "always", queryFn: () => { if (!forgejoOrganizationId) return Promise.resolve(null); @@ -531,7 +532,7 @@ export default function DashboardPage() { !forgejoRepositoriesQuery.isLoading && !forgejoRepositoriesQuery.error, ), - refetchInterval: 60_000, + refetchInterval: FORGEJO_DASHBOARD_REFETCH_INTERVAL_MS, refetchOnMount: "always", queryFn: () => { if (!forgejoOrganizationId) return Promise.resolve(null); @@ -542,7 +543,7 @@ export default function DashboardPage() { const forgejoLastPushQuery = useQuery({ queryKey: ["dashboard", "forgejo", "last-push", forgejoOrganizationId], enabled: Boolean(isSignedIn && forgejoOrganizationId), - refetchInterval: 60_000, + refetchInterval: FORGEJO_DASHBOARD_REFETCH_INTERVAL_MS, refetchOnMount: "always", queryFn: () => { if (!forgejoOrganizationId) return Promise.resolve(null); diff --git a/frontend/src/app/settings/page.tsx b/frontend/src/app/settings/page.tsx index ca31e56..f34ae9d 100644 --- a/frontend/src/app/settings/page.tsx +++ b/frontend/src/app/settings/page.tsx @@ -14,6 +14,7 @@ import { GitBranch, Globe, Mail, + RefreshCw, RotateCcw, Save, Trash2, @@ -27,7 +28,7 @@ import { useGetMeApiV1UsersMeGet, useUpdateMeApiV1UsersMePatch, } from "@/api/generated/users/users"; -import { ApiError } from "@/api/mutator"; +import { ApiError, customFetch } from "@/api/mutator"; import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout"; import { Button } from "@/components/ui/button"; import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog"; @@ -53,6 +54,9 @@ export default function SettingsPage() { const [saveSuccess, setSaveSuccess] = useState(null); const [deleteError, setDeleteError] = useState(null); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [forgejoSyncing, setForgejoSyncing] = useState(false); + const [forgejoSyncError, setForgejoSyncError] = useState(null); + const [forgejoSyncSuccess, setForgejoSyncSuccess] = useState(null); const meQuery = useGetMeApiV1UsersMeGet< getMeApiV1UsersMeGetResponse, @@ -121,6 +125,27 @@ export default function SettingsPage() { }, }); + const handleForgejoSync = async () => { + setForgejoSyncing(true); + setForgejoSyncError(null); + setForgejoSyncSuccess(null); + try { + await customFetch("/api/v1/users/me/forgejo-sync", { method: "POST" }); + setForgejoSyncSuccess("Forgejo profile synced successfully."); + await queryClient.invalidateQueries({ queryKey: meQueryKey }); + } catch (err) { + setForgejoSyncError(err instanceof Error ? err.message : "Sync failed."); + } finally { + setForgejoSyncing(false); + } + }; + + const handleDisableForgejoProfile = async () => { + await updateMeMutation.mutateAsync({ data: { use_forgejo_profile: false } }); + setForgejoSyncSuccess(null); + setForgejoSyncError(null); + }; + const handleSave = async (event: React.FormEvent) => { event.preventDefault(); if (!isSignedIn) return; @@ -252,6 +277,80 @@ export default function SettingsPage() { +
+

+ + Profile Source +

+

+ Use your Forgejo account name and avatar throughout the app. + Requires an active Forgejo connection. +

+ +
+ {profile?.use_forgejo_profile ? ( +
+ {profile.forgejo_avatar_url ? ( + // eslint-disable-next-line @next/next/no-img-element + Forgejo avatar + ) : null} + + Using Forgejo profile:{" "} + + {profile.forgejo_display_name ?? "—"} + + + + +
+ ) : ( +
+ +
+ )} + + {forgejoSyncError ? ( +
+ {forgejoSyncError} +
+ ) : null} + {forgejoSyncSuccess ? ( +
+ {forgejoSyncSuccess} +
+ ) : null} +
+
+
diff --git a/frontend/src/components/git/ForgejoIssueMetricCards.tsx b/frontend/src/components/git/ForgejoIssueMetricCards.tsx index 2c01279..569ec30 100644 --- a/frontend/src/components/git/ForgejoIssueMetricCards.tsx +++ b/frontend/src/components/git/ForgejoIssueMetricCards.tsx @@ -49,12 +49,13 @@ const parseDate = (value: string | null | undefined): Date | null => { const formatRelativeTimestampLive = (date: Date, nowMs: number): string => { const diff = Math.max(0, nowMs - date.getTime()); - const minutes = Math.round(diff / 60000); - if (minutes < 1) return "Just now"; + const seconds = Math.round(diff / 1000); + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.floor(seconds / 60); if (minutes < 60) return `${minutes}m ago`; - const hours = Math.round(minutes / 60); + const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours}h ago`; - const days = Math.round(hours / 24); + const days = Math.floor(hours / 24); return `${days}d ago`; }; @@ -99,7 +100,7 @@ const toneValueClasses: Record = { function buildSyncHealthCard( metrics: ForgejoIssueMetrics | null, repositories: ForgejoRepository[], - nowMs: number, + nowMs: number | null, ): MetricCard { const repositoryCount = repositories.length; const syncErrorCount = Object.values(metrics?.sync_error_counts ?? {}).filter( @@ -114,9 +115,9 @@ function buildSyncHealthCard( const latestSync = newestDate( metricSyncDates.length > 0 ? metricSyncDates : repositorySyncDates, ); - const latestSyncAge = latestSync + const latestSyncAge = latestSync && nowMs != null ? Math.max(0, nowMs - latestSync.getTime()) - : Number.POSITIVE_INFINITY; + : 0; if (repositoryCount === 0) { return { @@ -152,7 +153,7 @@ function buildSyncHealthCard( return { title: "Last Sync Health", value: "Stale", - caption: `Last sync ${formatRelativeTimestampLive(latestSync, nowMs)}.`, + caption: `Updated ${formatRelativeTimestampLive(latestSync, nowMs ?? latestSync.getTime())}.`, href: "/git-projects/repositories", tone: "amber", icon: Clock3, @@ -161,7 +162,7 @@ function buildSyncHealthCard( return { title: "Last Sync Health", value: "Healthy", - caption: `Last sync ${formatRelativeTimestampLive(latestSync, nowMs)}.`, + caption: `Updated ${formatRelativeTimestampLive(latestSync, nowMs ?? latestSync.getTime())}.`, href: "/git-projects/repositories", tone: "cyan", icon: ShieldCheck, @@ -224,11 +225,15 @@ export function ForgejoIssueMetricCards({ isLoading = false, error, }: ForgejoIssueMetricCardsProps) { - const [nowMs, setNowMs] = useState(0); + const [nowMs, setNowMs] = useState(null); useEffect(() => { + const initialTick = window.setTimeout(() => setNowMs(Date.now()), 0); const id = window.setInterval(() => setNowMs(Date.now()), 1000); - return () => window.clearInterval(id); + return () => { + window.clearTimeout(initialTick); + window.clearInterval(id); + }; }, []); const openIssues = metrics?.open_issues ?? 0; diff --git a/frontend/src/components/organisms/UserMenu.tsx b/frontend/src/components/organisms/UserMenu.tsx index 258d940..b837dfe 100644 --- a/frontend/src/components/organisms/UserMenu.tsx +++ b/frontend/src/components/organisms/UserMenu.tsx @@ -30,19 +30,21 @@ type UserMenuProps = { className?: string; displayName?: string; displayEmail?: string; + forgejoAvatarUrl?: string | null; }; export function UserMenu({ className, displayName: displayNameFromDb, displayEmail: displayEmailFromDb, + forgejoAvatarUrl, }: UserMenuProps) { const [open, setOpen] = useState(false); const { user } = useUser(); const localMode = isLocalAuthMode(); if (!user && !localMode) return null; - const avatarUrl = localMode ? null : (user?.imageUrl ?? null); + const avatarUrl = forgejoAvatarUrl ?? (localMode ? null : (user?.imageUrl ?? null)); const avatarLabelSource = displayNameFromDb ?? (localMode ? "Local User" : user?.id) ?? "U"; const avatarLabel = avatarLabelSource.slice(0, 1).toUpperCase(); @@ -75,14 +77,11 @@ export function UserMenu({ : "bg-gradient-to-br from-[color:var(--primary-navy,var(--accent))] to-[color:var(--secondary-navy,var(--accent-strong))]", )} > - {avatarUrl ? ( - User avatar + {forgejoAvatarUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + User avatar + ) : avatarUrl ? ( + User avatar ) : ( avatarLabel )} @@ -105,14 +104,11 @@ export function UserMenu({ : "bg-gradient-to-br from-[color:var(--primary-navy,var(--accent))] to-[color:var(--secondary-navy,var(--accent-strong))]", )} > - {avatarUrl ? ( - User avatar + {forgejoAvatarUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + User avatar + ) : avatarUrl ? ( + User avatar ) : ( avatarLabel )} diff --git a/frontend/src/components/templates/DashboardShell.tsx b/frontend/src/components/templates/DashboardShell.tsx index 518dc83..e4a10bf 100644 --- a/frontend/src/components/templates/DashboardShell.tsx +++ b/frontend/src/components/templates/DashboardShell.tsx @@ -53,8 +53,13 @@ export function DashboardShell({ }, }); const profile = meQuery.data?.status === 200 ? meQuery.data.data : null; - const displayName = profile?.name ?? profile?.preferred_name ?? "Operator"; + const displayName = profile?.use_forgejo_profile + ? (profile.forgejo_display_name ?? profile.name ?? profile.preferred_name ?? "Operator") + : (profile?.name ?? profile?.preferred_name ?? "Operator"); const displayEmail = profile?.email ?? ""; + const forgejoAvatarUrl = profile?.use_forgejo_profile + ? (profile.forgejo_avatar_url ?? null) + : null; useEffect(() => { if (!isSignedIn || isOnboardingPath) return; @@ -155,7 +160,7 @@ export function DashboardShell({

- +