feat(users): add Forgejo profile synchronization and related fields

This commit is contained in:
null 2026-05-22 20:29:13 -05:00
parent 74056664d4
commit b16e9bb3c8
11 changed files with 272 additions and 35 deletions

View File

@ -29,9 +29,11 @@ from app.models.organizations import Organization
from app.models.task_dependencies import TaskDependency from app.models.task_dependencies import TaskDependency
from app.models.task_fingerprints import TaskFingerprint from app.models.task_fingerprints import TaskFingerprint
from app.models.tasks import Task from app.models.tasks import Task
from app.models.forgejo_connections import ForgejoConnection
from app.models.users import User from app.models.users import User
from app.schemas.common import OkResponse from app.schemas.common import OkResponse
from app.schemas.users import UserRead, UserUpdate from app.schemas.users import UserRead, UserUpdate
from app.services.forgejo_client import ForgejoClientError, get_forgejo_client
if TYPE_CHECKING: if TYPE_CHECKING:
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
@ -224,6 +226,68 @@ async def update_me(
return UserRead.model_validate(user) 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) @router.delete("/me", response_model=OkResponse)
async def delete_me( async def delete_me(
session: AsyncSession = SESSION_DEP, session: AsyncSession = SESSION_DEP,

View File

@ -29,3 +29,6 @@ class User(QueryModel, table=True):
foreign_key="organizations.id", foreign_key="organizations.id",
index=True, index=True,
) )
use_forgejo_profile: bool = Field(default=False)
forgejo_avatar_url: str | None = None
forgejo_display_name: str | None = None

View File

@ -67,6 +67,7 @@ class UserUpdate(SQLModel):
timezone: str | None = None timezone: str | None = None
notes: str | None = None notes: str | None = None
context: str | None = None context: str | None = None
use_forgejo_profile: bool | None = None
class UserRead(UserBase): class UserRead(UserBase):
@ -80,3 +81,15 @@ class UserRead(UserBase):
description="Whether this user has tenant-wide super-admin privileges.", description="Whether this user has tenant-wide super-admin privileges.",
examples=[False], 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.",
)

View File

@ -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")

View File

@ -29,4 +29,10 @@ export interface UserRead {
id: string; id: string;
/** Whether this user has tenant-wide super-admin privileges. */ /** Whether this user has tenant-wide super-admin privileges. */
is_super_admin: boolean; 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;
} }

View File

@ -15,4 +15,5 @@ export interface UserUpdate {
timezone?: string | null; timezone?: string | null;
notes?: string | null; notes?: string | null;
context?: string | null; context?: string | null;
use_forgejo_profile?: boolean | null;
} }

View File

@ -118,6 +118,7 @@ type GatewaySnapshot = GatewayTarget & {
}; };
const DASH = "—"; const DASH = "—";
const FORGEJO_DASHBOARD_REFETCH_INTERVAL_MS = 5 * 60 * 1000;
const DASHBOARD_RANGE = "7d"; const DASHBOARD_RANGE = "7d";
const DASHBOARD_RANGE_DAYS = 7; const DASHBOARD_RANGE_DAYS = 7;
const DASHBOARD_RANGE_LABEL = "7 days"; const DASHBOARD_RANGE_LABEL = "7 days";
@ -492,7 +493,7 @@ export default function DashboardPage() {
const forgejoRepositoriesQuery = useQuery<ForgejoRepository[], Error>({ const forgejoRepositoriesQuery = useQuery<ForgejoRepository[], Error>({
queryKey: ["dashboard", "forgejo", "repositories"], queryKey: ["dashboard", "forgejo", "repositories"],
enabled: Boolean(isSignedIn), enabled: Boolean(isSignedIn),
refetchInterval: 60_000, refetchInterval: FORGEJO_DASHBOARD_REFETCH_INTERVAL_MS,
refetchOnMount: "always", refetchOnMount: "always",
queryFn: () => getForgejoRepositories(), queryFn: () => getForgejoRepositories(),
}); });
@ -515,7 +516,7 @@ export default function DashboardPage() {
!forgejoRepositoriesQuery.isLoading && !forgejoRepositoriesQuery.isLoading &&
!forgejoRepositoriesQuery.error, !forgejoRepositoriesQuery.error,
), ),
refetchInterval: 60_000, refetchInterval: FORGEJO_DASHBOARD_REFETCH_INTERVAL_MS,
refetchOnMount: "always", refetchOnMount: "always",
queryFn: () => { queryFn: () => {
if (!forgejoOrganizationId) return Promise.resolve(null); if (!forgejoOrganizationId) return Promise.resolve(null);
@ -531,7 +532,7 @@ export default function DashboardPage() {
!forgejoRepositoriesQuery.isLoading && !forgejoRepositoriesQuery.isLoading &&
!forgejoRepositoriesQuery.error, !forgejoRepositoriesQuery.error,
), ),
refetchInterval: 60_000, refetchInterval: FORGEJO_DASHBOARD_REFETCH_INTERVAL_MS,
refetchOnMount: "always", refetchOnMount: "always",
queryFn: () => { queryFn: () => {
if (!forgejoOrganizationId) return Promise.resolve(null); if (!forgejoOrganizationId) return Promise.resolve(null);
@ -542,7 +543,7 @@ export default function DashboardPage() {
const forgejoLastPushQuery = useQuery({ const forgejoLastPushQuery = useQuery({
queryKey: ["dashboard", "forgejo", "last-push", forgejoOrganizationId], queryKey: ["dashboard", "forgejo", "last-push", forgejoOrganizationId],
enabled: Boolean(isSignedIn && forgejoOrganizationId), enabled: Boolean(isSignedIn && forgejoOrganizationId),
refetchInterval: 60_000, refetchInterval: FORGEJO_DASHBOARD_REFETCH_INTERVAL_MS,
refetchOnMount: "always", refetchOnMount: "always",
queryFn: () => { queryFn: () => {
if (!forgejoOrganizationId) return Promise.resolve(null); if (!forgejoOrganizationId) return Promise.resolve(null);

View File

@ -14,6 +14,7 @@ import {
GitBranch, GitBranch,
Globe, Globe,
Mail, Mail,
RefreshCw,
RotateCcw, RotateCcw,
Save, Save,
Trash2, Trash2,
@ -27,7 +28,7 @@ import {
useGetMeApiV1UsersMeGet, useGetMeApiV1UsersMeGet,
useUpdateMeApiV1UsersMePatch, useUpdateMeApiV1UsersMePatch,
} from "@/api/generated/users/users"; } from "@/api/generated/users/users";
import { ApiError } from "@/api/mutator"; import { ApiError, customFetch } from "@/api/mutator";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout"; import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog"; import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog";
@ -53,6 +54,9 @@ export default function SettingsPage() {
const [saveSuccess, setSaveSuccess] = useState<string | null>(null); const [saveSuccess, setSaveSuccess] = useState<string | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null); const [deleteError, setDeleteError] = useState<string | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [forgejoSyncing, setForgejoSyncing] = useState(false);
const [forgejoSyncError, setForgejoSyncError] = useState<string | null>(null);
const [forgejoSyncSuccess, setForgejoSyncSuccess] = useState<string | null>(null);
const meQuery = useGetMeApiV1UsersMeGet< const meQuery = useGetMeApiV1UsersMeGet<
getMeApiV1UsersMeGetResponse, 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<HTMLFormElement>) => { const handleSave = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
if (!isSignedIn) return; if (!isSignedIn) return;
@ -252,6 +277,80 @@ export default function SettingsPage() {
</form> </form>
</section> </section>
<section className="rounded-xl border border-border bg-card p-6 shadow-sm">
<h2 className="flex items-center gap-2 text-base font-semibold text-foreground">
<GitBranch className="h-4 w-4 text-muted-foreground" />
Profile Source
</h2>
<p className="mt-1 text-sm text-muted-foreground">
Use your Forgejo account name and avatar throughout the app.
Requires an active Forgejo connection.
</p>
<div className="mt-4 flex flex-col gap-3">
{profile?.use_forgejo_profile ? (
<div className="flex flex-wrap items-center gap-3">
{profile.forgejo_avatar_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={profile.forgejo_avatar_url}
alt="Forgejo avatar"
className="h-9 w-9 rounded-full object-cover ring-2 ring-border"
/>
) : null}
<span className="text-sm text-muted-foreground">
Using Forgejo profile:{" "}
<span className="font-medium text-foreground">
{profile.forgejo_display_name ?? "—"}
</span>
</span>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleForgejoSync}
disabled={forgejoSyncing || updateMeMutation.isPending}
>
<RefreshCw className="h-4 w-4" />
{forgejoSyncing ? "Syncing…" : "Re-sync"}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleDisableForgejoProfile}
disabled={forgejoSyncing || updateMeMutation.isPending}
>
Use app profile
</Button>
</div>
) : (
<div>
<Button
type="button"
variant="outline"
onClick={handleForgejoSync}
disabled={forgejoSyncing}
>
<RefreshCw className="h-4 w-4" />
{forgejoSyncing ? "Syncing…" : "Use Forgejo profile"}
</Button>
</div>
)}
{forgejoSyncError ? (
<div className="rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-700">
{forgejoSyncError}
</div>
) : null}
{forgejoSyncSuccess ? (
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-700">
{forgejoSyncSuccess}
</div>
) : null}
</div>
</section>
<section className="rounded-xl border border-border bg-card p-6 shadow-sm"> <section className="rounded-xl border border-border bg-card p-6 shadow-sm">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"> <div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0"> <div className="min-w-0">

View File

@ -49,12 +49,13 @@ const parseDate = (value: string | null | undefined): Date | null => {
const formatRelativeTimestampLive = (date: Date, nowMs: number): string => { const formatRelativeTimestampLive = (date: Date, nowMs: number): string => {
const diff = Math.max(0, nowMs - date.getTime()); const diff = Math.max(0, nowMs - date.getTime());
const minutes = Math.round(diff / 60000); const seconds = Math.round(diff / 1000);
if (minutes < 1) return "Just now"; if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`; 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`; if (hours < 24) return `${hours}h ago`;
const days = Math.round(hours / 24); const days = Math.floor(hours / 24);
return `${days}d ago`; return `${days}d ago`;
}; };
@ -99,7 +100,7 @@ const toneValueClasses: Record<MetricTone, string> = {
function buildSyncHealthCard( function buildSyncHealthCard(
metrics: ForgejoIssueMetrics | null, metrics: ForgejoIssueMetrics | null,
repositories: ForgejoRepository[], repositories: ForgejoRepository[],
nowMs: number, nowMs: number | null,
): MetricCard { ): MetricCard {
const repositoryCount = repositories.length; const repositoryCount = repositories.length;
const syncErrorCount = Object.values(metrics?.sync_error_counts ?? {}).filter( const syncErrorCount = Object.values(metrics?.sync_error_counts ?? {}).filter(
@ -114,9 +115,9 @@ function buildSyncHealthCard(
const latestSync = newestDate( const latestSync = newestDate(
metricSyncDates.length > 0 ? metricSyncDates : repositorySyncDates, metricSyncDates.length > 0 ? metricSyncDates : repositorySyncDates,
); );
const latestSyncAge = latestSync const latestSyncAge = latestSync && nowMs != null
? Math.max(0, nowMs - latestSync.getTime()) ? Math.max(0, nowMs - latestSync.getTime())
: Number.POSITIVE_INFINITY; : 0;
if (repositoryCount === 0) { if (repositoryCount === 0) {
return { return {
@ -152,7 +153,7 @@ function buildSyncHealthCard(
return { return {
title: "Last Sync Health", title: "Last Sync Health",
value: "Stale", value: "Stale",
caption: `Last sync ${formatRelativeTimestampLive(latestSync, nowMs)}.`, caption: `Updated ${formatRelativeTimestampLive(latestSync, nowMs ?? latestSync.getTime())}.`,
href: "/git-projects/repositories", href: "/git-projects/repositories",
tone: "amber", tone: "amber",
icon: Clock3, icon: Clock3,
@ -161,7 +162,7 @@ function buildSyncHealthCard(
return { return {
title: "Last Sync Health", title: "Last Sync Health",
value: "Healthy", value: "Healthy",
caption: `Last sync ${formatRelativeTimestampLive(latestSync, nowMs)}.`, caption: `Updated ${formatRelativeTimestampLive(latestSync, nowMs ?? latestSync.getTime())}.`,
href: "/git-projects/repositories", href: "/git-projects/repositories",
tone: "cyan", tone: "cyan",
icon: ShieldCheck, icon: ShieldCheck,
@ -224,11 +225,15 @@ export function ForgejoIssueMetricCards({
isLoading = false, isLoading = false,
error, error,
}: ForgejoIssueMetricCardsProps) { }: ForgejoIssueMetricCardsProps) {
const [nowMs, setNowMs] = useState(0); const [nowMs, setNowMs] = useState<number | null>(null);
useEffect(() => { useEffect(() => {
const initialTick = window.setTimeout(() => setNowMs(Date.now()), 0);
const id = window.setInterval(() => setNowMs(Date.now()), 1000); 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; const openIssues = metrics?.open_issues ?? 0;

View File

@ -30,19 +30,21 @@ type UserMenuProps = {
className?: string; className?: string;
displayName?: string; displayName?: string;
displayEmail?: string; displayEmail?: string;
forgejoAvatarUrl?: string | null;
}; };
export function UserMenu({ export function UserMenu({
className, className,
displayName: displayNameFromDb, displayName: displayNameFromDb,
displayEmail: displayEmailFromDb, displayEmail: displayEmailFromDb,
forgejoAvatarUrl,
}: UserMenuProps) { }: UserMenuProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const { user } = useUser(); const { user } = useUser();
const localMode = isLocalAuthMode(); const localMode = isLocalAuthMode();
if (!user && !localMode) return null; if (!user && !localMode) return null;
const avatarUrl = localMode ? null : (user?.imageUrl ?? null); const avatarUrl = forgejoAvatarUrl ?? (localMode ? null : (user?.imageUrl ?? null));
const avatarLabelSource = const avatarLabelSource =
displayNameFromDb ?? (localMode ? "Local User" : user?.id) ?? "U"; displayNameFromDb ?? (localMode ? "Local User" : user?.id) ?? "U";
const avatarLabel = avatarLabelSource.slice(0, 1).toUpperCase(); 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))]", : "bg-gradient-to-br from-[color:var(--primary-navy,var(--accent))] to-[color:var(--secondary-navy,var(--accent-strong))]",
)} )}
> >
{avatarUrl ? ( {forgejoAvatarUrl ? (
<Image // eslint-disable-next-line @next/next/no-img-element
src={avatarUrl} <img src={forgejoAvatarUrl} alt="User avatar" className="h-9 w-9 object-cover" />
alt="User avatar" ) : avatarUrl ? (
width={36} <Image src={avatarUrl} alt="User avatar" width={36} height={36} className="h-9 w-9 object-cover" />
height={36}
className="h-9 w-9 object-cover"
/>
) : ( ) : (
avatarLabel 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))]", : "bg-gradient-to-br from-[color:var(--primary-navy,var(--accent))] to-[color:var(--secondary-navy,var(--accent-strong))]",
)} )}
> >
{avatarUrl ? ( {forgejoAvatarUrl ? (
<Image // eslint-disable-next-line @next/next/no-img-element
src={avatarUrl} <img src={forgejoAvatarUrl} alt="User avatar" className="h-10 w-10 object-cover" />
alt="User avatar" ) : avatarUrl ? (
width={40} <Image src={avatarUrl} alt="User avatar" width={40} height={40} className="h-10 w-10 object-cover" />
height={40}
className="h-10 w-10 object-cover"
/>
) : ( ) : (
avatarLabel avatarLabel
)} )}

View File

@ -53,8 +53,13 @@ export function DashboardShell({
}, },
}); });
const profile = meQuery.data?.status === 200 ? meQuery.data.data : null; 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 displayEmail = profile?.email ?? "";
const forgejoAvatarUrl = profile?.use_forgejo_profile
? (profile.forgejo_avatar_url ?? null)
: null;
useEffect(() => { useEffect(() => {
if (!isSignedIn || isOnboardingPath) return; if (!isSignedIn || isOnboardingPath) return;
@ -155,7 +160,7 @@ export function DashboardShell({
</p> </p>
</div> </div>
<ThemeToggle /> <ThemeToggle />
<UserMenu displayName={displayName} displayEmail={displayEmail} /> <UserMenu displayName={displayName} displayEmail={displayEmail} forgejoAvatarUrl={forgejoAvatarUrl} />
</div> </div>
</SignedIn> </SignedIn>
</div> </div>