diff --git a/.agents/rules/realtime.md b/.agents/rules/realtime.md index 49574c6660..a541fb284a 100644 --- a/.agents/rules/realtime.md +++ b/.agents/rules/realtime.md @@ -6,6 +6,7 @@ `[Authorize] AppHub` mapped at **`/api/v1/realtime/hub`**. Groups: `user:{userId}`, `tenant:{tenantId}`, `channel:{channelId}`. +- **Channel-group join is connect-time + on-demand.** `OnConnectedAsync` auto-joins `user:{id}`, `tenant:{id}`, and every `channel:{id}` the user is *already* a member of. A channel that becomes relevant **after** the socket is live (a new DM, or being added to a channel) is **not** auto-joined — the client must call the membership-gated **`JoinChannel(channelId)`** hub method (the dashboard does this on channel open + reconnect). Without it, group broadcasts silently miss that connection until a page reload re-runs `OnConnectedAsync`. New-DM creation pushes `ChatChannelAdded` to each other participant's `user:{id}` group so their channel list refreshes. - **⚠️ Read the user from `Context.User`, NOT `ICurrentUser`.** `ICurrentUser` flows through `IHttpContextAccessor`, but the negotiate `HttpContext` isn't pinned to subsequent hub invocations → `ICurrentUser` returns nulls inside the hub. Use `Context.User` (the hub's `GetUserId()`/`GetTenantId()` helpers). - Broadcasts are **scoped to groups** (`tenant:{id}`, `user:{id}`, `channel:{id}`), never `Clients.All`. `PresenceChanged` goes to the tenant group. - Redis backplane is added automatically when `CachingOptions:Redis` is set (channel prefix `fsh-signalr`) — required for multi-replica. diff --git a/clients/admin/index.html b/clients/admin/index.html index dd72e62969..f3ece6e87f 100644 --- a/clients/admin/index.html +++ b/clients/admin/index.html @@ -8,7 +8,7 @@
+
Operate every tenant on this instance — identity, multitenancy, billing, and the rest of the system surface, from one place.
diff --git a/clients/admin/src/components/file/image-input.tsx b/clients/admin/src/components/file/image-input.tsx new file mode 100644 index 0000000000..7a9c9c1f74 --- /dev/null +++ b/clients/admin/src/components/file/image-input.tsx @@ -0,0 +1,197 @@ +import { useState } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { Image as ImageIcon, Loader2, Upload, X, Link as LinkIcon } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/cn"; +import { useFileUpload, formatBytes } from "@/hooks/use-file-upload"; +import { getFileMetadata, Visibility } from "@/api/files"; +import { ApiRequestError } from "@/lib/api-client"; + +type Props = { + /** Current image URL (or empty). The component is fully controlled. */ + value: string; + onChange: (next: string) => void; + /** + * Owner binding for the upload. The Files module's per-OwnerType IFileAccessPolicy + * decides who can attach what. For user avatars, ownerType="User" + the user id. + */ + ownerType: string; + ownerId?: string | null; + /** Allowed extensions (lower-case w/ leading dot). Server enforces too. */ + allowedExtensions?: string[]; + maxBytes?: number; + /** Visual treatment for the preview tile — "square" for general images, "circle" for avatars. */ + shape?: "square" | "circle"; + className?: string; +}; + +const IMAGE_EXTS = [".jpg", ".jpeg", ".png", ".webp", ".gif"]; + +/** + * ImageInput — composite control that lets a user either upload a new image + * (presigned PUT to S3/MinIO) OR paste an external URL. After a successful + * upload the component fetches the FileAsset metadata to retrieve the durable + * `publicUrl` and forwards it through `onChange`. + */ +export function ImageInput({ + value, + onChange, + ownerType, + ownerId, + allowedExtensions = IMAGE_EXTS, + maxBytes = 10 * 1024 * 1024, + shape = "square", + className, +}: Props) { + const [mode, setMode] = useState<"upload" | "url">("upload"); + const { upload, progress, isUploading, reset } = useFileUpload({ + ownerType, + ownerId, + category: "Image", + visibility: Visibility.Public, // public so we get a durable URL we can persist on the entity + allowedExtensions, + maxBytes, + }); + + // After upload+finalize, fetch metadata so we get the durable publicUrl. + const resolveUrl = useMutation({ + mutationFn: async (fileAssetId: string) => { + const dto = await getFileMetadata(fileAssetId); + if (!dto.publicUrl) { + throw new Error("Server returned no publicUrl for this file."); + } + return dto.publicUrl; + }, + }); + + const handlePick = () => { + const input = document.createElement("input"); + input.type = "file"; + input.accept = "image/*"; + input.onchange = async () => { + const file = input.files?.[0]; + if (!file) return; + try { + const asset = await upload(file); + const url = await resolveUrl.mutateAsync(asset.id); + onChange(url); + toast.success("Image uploaded"); + // Clear progress so the dropzone re-arms for another upload. + setTimeout(reset, 1500); + } catch (e) { + const message = + e instanceof ApiRequestError + ? (e.problem?.detail ?? e.problem?.title ?? e.message) + : e instanceof Error + ? e.message + : "Upload failed"; + toast.error(message); + } + }; + input.click(); + }; + + const hasImage = value.length > 0; + const isWorking = isUploading || resolveUrl.isPending; + const tileClass = shape === "circle" ? "rounded-full" : "rounded-xl"; + + return ( ++ {mode === "upload" + ? `JPG/PNG/WebP/GIF · up to ${formatBytes(maxBytes)}` + : "Direct link to an image you host elsewhere."} +
++ v0.1 · admin +
+