feat(users): add Forgejo profile synchronization and related fields
This commit is contained in:
parent
74056664d4
commit
b16e9bb3c8
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue