Vectrum
Sign In

Let organization owners upload an avatar

Owners upload a PNG, JPEG, GIF or WebP (up to 1 MB) in the
organization's General settings. It shows wherever the organization
does, and on its repositories that have no image of their own. Files
are sniffed by their magic bytes, stored in a protected field, and
served from /organizations/<org>/avatar?v=<upload time>.

The repository image's upload form and checks are now shared
(components/image-upload-form.tsx, lib/uploaded-images.ts).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
JjohannSocommitted 1 parent dd295d0commitf721608

Showing 25 changed files with 328 additions and 128 deletions.

+2−8apps/web/app/[owner]/[repo]/image/route.tsView File
@@ -1,6 +1,7 @@
11 import { getViewer } from "@/lib/auth";
22 import { normalizeRepoName } from "@/lib/git";
33 import { findRepo, getRepoImage, repoAccess } from "@/lib/repos";
4+ import { imageResponse } from "@/lib/uploaded-images";
45
56 /** The repository's uploaded image. URLs carry `?v=<upload time>`, so responses can be cached for good. */
67 export async function GET(_request: Request, ctx: RouteContext<"/[owner]/[repo]/image">) {
@@ -11,12 +12,5 @@ export async function GET(_request: Request, ctx: RouteContext<"/[owner]/[repo]/
1112
1213 const image = await getRepoImage(repo.id);
1314 if (!image) return new Response("Not found", { status: 404 });
14- return new Response(new Uint8Array(image.data), {
15- headers: {
16- "Content-Type": image.contentType,
17- "X-Content-Type-Options": "nosniff",
18- "Cache-Control": `${repo.visibility === "private" ? "private" : "public"}, max-age=31536000, immutable`,
19- "Content-Security-Policy": "default-src 'none'; sandbox",
20- },
21- });
15+ return imageResponse(image, repo.visibility);
2216 }
+2−2apps/web/app/[owner]/[repo]/page.tsxView File
@@ -17,7 +17,7 @@ import {
1717 } from "@/components/icons";
1818 import { RecentPushes } from "@/components/pulls/recent-pushes";
1919 import { TreeView } from "@/components/tree-view";
20- import { Avatar, Blankslate, Box, ButtonLink, inputClass, Label } from "@/components/ui";
20+ import { Avatar, avatarUrl, Blankslate, Box, ButtonLink, inputClass, Label } from "@/components/ui";
2121 import { countOpenPulls } from "@/lib/pulls";
2222 import { encodePath, formatDate, timeAgo } from "@/lib/format";
2323 import { commitCount, listBranches, listContributors, listFilesWithSizes, listTags } from "@/lib/git";
@@ -244,7 +244,7 @@ export default async function RepoHome({ params }: PageProps<"/[owner]/[repo]">)
244244 />
245245 ) : (
246246 <Link href={`/${repo.owner}`} title={repo.owner} className="inline-flex rounded-full focus-visible:focus-ring">
247- <Avatar name={repo.owner} size={32} />
247+ <Avatar name={repo.owner} src={avatarUrl({ username: repo.owner, avatarVersion: repo.ownerAvatarVersion })} size={32} />
248248 </Link>
249249 )}
250250 </section>
+2−2apps/web/app/[owner]/[repo]/settings/access/page.tsxView File
@@ -6,7 +6,7 @@ import { ConfirmButton } from "@/components/confirm-button";
66 import { PeopleIcon } from "@/components/icons";
77 import { SettingsSection } from "@/components/metal";
88 import { ListRow, PlateSection } from "@/components/settings-plate";
9- import { Avatar, Blankslate, Label, PageTitle } from "@/components/ui";
9+ import { Avatar, avatarUrl, Blankslate, Label, PageTitle } from "@/components/ui";
1010 import { timeAgo } from "@/lib/format";
1111 import { orgRole } from "@/lib/orgs";
1212 import { getRepo } from "@/lib/repo";
@@ -30,7 +30,7 @@ export default async function AccessPage({ params }: PageProps<"/[owner]/[repo]/
3030 <div className="flex flex-col gap-7">
3131 <SettingsSection title={`People with access · ${collaborators.length + 1}`}>
3232 <ListRow
33- lead={<Avatar name={repo.owner} size={32} />}
33+ lead={<Avatar name={repo.owner} src={avatarUrl({ username: repo.owner, avatarVersion: repo.ownerAvatarVersion })} size={32} />}
3434 title={
3535 <>
3636 <Link href={`/${repo.owner}`} className="truncate hover:underline">
+13−56apps/web/app/[owner]/[repo]/settings/forms.tsxView File
@@ -1,13 +1,14 @@
11 "use client";
22
3- import { useActionState, useEffect, useState } from "react";
3+ import { useActionState } from "react";
44 import { Checkbox } from "@unlocalhosted/metalui";
55 import type { FormState } from "@/app/actions/types";
66 import { DeleteZone } from "@/components/delete-zone";
7- import { BookIcon, GlobeIcon, LockIcon, PencilIcon, UndoIcon, UploadIcon } from "@/components/icons";
7+ import { ImageUploadForm } from "@/components/image-upload-form";
8+ import { BookIcon, GlobeIcon, LockIcon, PencilIcon } from "@/components/icons";
89 import { ChoiceCards, TextField } from "@/components/settings-fields";
910 import { PlateBody, PlateFooter } from "@/components/settings-plate";
10- import { Alert, Button, buttonClass, RepoAvatar, type RepoImageRef } from "@/components/ui";
11+ import { Alert, Button, RepoAvatar, type RepoImageRef } from "@/components/ui";
1112
1213 type Action = (prev: FormState, formData: FormData) => Promise<FormState>;
1314
@@ -20,60 +21,16 @@ export function ImageForm({
2021 resetAction: () => Promise<void>;
2122 repo: RepoImageRef;
2223 }) {
23- const [preview, setPreview] = useState<string | null>(null);
24- const [state, formAction, pending] = useActionState(async (prev: FormState, formData: FormData) => {
25- const result = await action(prev, formData);
26- if (result?.success) setPreview(null);
27- return result;
28- }, undefined);
29- useEffect(() => () => {
30- if (preview) URL.revokeObjectURL(preview);
31- }, [preview]);
32-
3324 return (
34- <PlateBody>
35- <div className="flex flex-wrap items-start gap-5">
36- <RepoAvatar repo={repo} src={preview ?? undefined} size={72} />
37- <div className="flex min-w-60 flex-1 flex-col gap-3">
38- <p className="type-body text-ink2">
39- Shown next to the repository everywhere. Until you upload one, it’s the owner’s avatar. PNG, JPEG, GIF or WebP, up to 1&nbsp;MB.
40- </p>
41- <div className="flex flex-wrap items-center gap-2">
42- <form action={formAction} className="flex items-center gap-2">
43- {/* The file input stays a real input; its label is drawn as MetalUI's standard cap. */}
44- <label className={buttonClass("default", "has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-green-deep has-focus-visible:outline-solid")}>
45- <UploadIcon /> {repo.imageVersion ? "Replace Image" : "Upload Image"}
46- <input
47- type="file"
48- name="image"
49- accept="image/png,image/jpeg,image/gif,image/webp"
50- required
51- className="sr-only"
52- onChange={(e) => {
53- const file = e.target.files?.[0];
54- setPreview(file ? URL.createObjectURL(file) : null);
55- }}
56- />
57- </label>
58- {preview && (
59- <Button variant="primary" disabled={pending}>
60- {pending ? "Saving…" : "Save Image"}
61- </Button>
62- )}
63- </form>
64- {repo.imageVersion && !preview && (
65- <form action={resetAction}>
66- <Button variant="invisible" icon={<UndoIcon />}>
67- Reset to Owner’s Avatar
68- </Button>
69- </form>
70- )}
71- </div>
72- {state?.error && <Alert>{state.error}</Alert>}
73- {state?.success && !preview && <Alert kind="success">{state.success}</Alert>}
74- </div>
75- </div>
76- </PlateBody>
25+ <ImageUploadForm
26+ action={action}
27+ resetAction={resetAction}
28+ hasImage={!!repo.imageVersion}
29+ noun="Image"
30+ resetLabel="Reset to Owner’s Avatar"
31+ description="Shown next to the repository everywhere. Until you upload one, it’s the owner’s avatar."
32+ renderImage={(src) => <RepoAvatar repo={repo} src={src} size={72} />}
33+ />
7734 );
7835 }
7936
+2−2apps/web/app/[owner]/org-profile.tsxView File
@@ -4,7 +4,7 @@ import { CalendarIcon, GearIcon, OrganizationIcon, PeopleIcon, PlusIcon, RepoIco
44 import { FieldIcon, FieldInput } from "@/components/metal";
55 import { OrgInvitations } from "@/components/org-invitations";
66 import { RepoList } from "@/components/repo-list";
7- import { Avatar, Blankslate, Box, ButtonLink, Counter, Label } from "@/components/ui";
7+ import { Avatar, avatarUrl, Blankslate, Box, ButtonLink, Counter, Label } from "@/components/ui";
88 import { formatDate } from "@/lib/format";
99 import { withActivity } from "@/lib/listing";
1010 import { listInvitationsFor, listOrgMembers, orgRole, ROLE_INFO, roleAtLeast } from "@/lib/orgs";
@@ -30,7 +30,7 @@ export async function OrgProfile({ org, viewer, q }: { org: User; viewer: User |
3030 <div className="mx-auto grid w-full max-w-[1280px] gap-8 px-4 py-10 md:grid-cols-[300px_1fr] md:px-6">
3131 <aside className="flex flex-col gap-4">
3232 <Box className="p-6 md:sticky md:top-20">
33- <Avatar name={org.name || org.username} size={88} />
33+ <Avatar name={org.name || org.username} src={avatarUrl(org)} size={88} />
3434 <h1 className="mt-5">
3535 {org.name && <span className={`block ${heading}`}>{org.name}</span>}
3636 <span className={`block ${org.name ? "mt-1 type-readout text-ink3" : heading}`}>@{org.username}</span>
+20−0apps/web/app/actions/orgs.tsView File
@@ -11,8 +11,10 @@ import {
1111 isOrgRole,
1212 OrgError,
1313 removeMember,
14+ removeOrgAvatar,
1415 ROLE_INFO,
1516 setMemberRole,
17+ setOrgAvatar,
1618 updateOrgProfile,
1719 } from "@/lib/orgs";
1820 import { ActionError, orgForAction, viewerOrThrow } from "./guard";
@@ -64,6 +66,24 @@ export async function updateOrgAction(name: string, _prev: FormState, formData:
6466 });
6567 }
6668
69+ export async function updateOrgAvatarAction(name: string, _prev: FormState, formData: FormData) {
70+ return attempt(async () => {
71+ const { org } = await orgForAction(name, "owner");
72+ const file = formData.get("image");
73+ if (!(file instanceof File)) throw new OrgError("Choose an image to upload.");
74+ await setOrgAvatar(org.id, new Uint8Array(await file.arrayBuffer()));
75+ // The avatar shows up in sidebars and lists everywhere, beside the organization's repositories too.
76+ refresh();
77+ return { success: "Avatar updated." };
78+ });
79+ }
80+
81+ export async function removeOrgAvatarAction(name: string) {
82+ const { org } = await orgForAction(name, "owner");
83+ await removeOrgAvatar(org.id);
84+ refresh();
85+ }
86+
6787 export async function deleteOrgAction(name: string, _prev: FormState, formData: FormData) {
6888 const result = await attempt(async () => {
6989 const { org } = await orgForAction(name, "owner");
+11−0apps/web/app/organizations/[org]/avatar/route.tsaddedView File
@@ -0,0 +1,11 @@
1+ import { getOrg, getOrgAvatar } from "@/lib/orgs";
2+ import { imageResponse } from "@/lib/uploaded-images";
3+
4+ /** The organization's uploaded avatar. URLs carry `?v=<upload time>`, so responses can be cached for good. */
5+ export async function GET(_request: Request, ctx: RouteContext<"/organizations/[org]/avatar">) {
6+ const org = await getOrg((await ctx.params).org);
7+ const image = org ? await getOrgAvatar(org.id) : null;
8+ if (!image) return new Response("Not found", { status: 404 });
9+ // Organizations and their profiles are public.
10+ return imageResponse(image, "public");
11+ }
+24−1apps/web/app/organizations/[org]/settings/forms.tsxView File
@@ -5,9 +5,10 @@ import { FormField, Select, useToast } from "@unlocalhosted/metalui";
55 import type { FormState } from "@/app/actions/types";
66 import { DeleteZone } from "@/components/delete-zone";
77 import { PersonIcon, PlusIcon } from "@/components/icons";
8+ import { ImageUploadForm } from "@/components/image-upload-form";
89 import { ChoiceCards, TextareaField, TextField } from "@/components/settings-fields";
910 import { PlateBody, PlateFooter } from "@/components/settings-plate";
10- import { Alert, Button } from "@/components/ui";
11+ import { Alert, Avatar, avatarUrl, Button } from "@/components/ui";
1112 import { ORG_ROLES, ROLE_INFO, type OrgRole } from "@/lib/org-roles";
1213
1314 type Action = (prev: FormState, formData: FormData) => Promise<FormState>;
@@ -34,6 +35,28 @@ export function OrgProfileForm({ action, org }: { action: Action; org: { usernam
3435 );
3536 }
3637
38+ export function OrgAvatarForm({
39+ action,
40+ resetAction,
41+ org,
42+ }: {
43+ action: Action;
44+ resetAction: () => Promise<void>;
45+ org: { username: string; name: string; avatarVersion: number | null };
46+ }) {
47+ return (
48+ <ImageUploadForm
49+ action={action}
50+ resetAction={resetAction}
51+ hasImage={!!org.avatarVersion}
52+ noun="Avatar"
53+ resetLabel="Remove Avatar"
54+ description="Shown on the organization’s profile and next to it everywhere, and on its repositories that have no image of their own. Until you upload one, it’s the organization’s initials."
55+ renderImage={(src) => <Avatar name={org.name || org.username} src={src ?? avatarUrl(org)} size={72} />}
56+ />
57+ );
58+ }
59+
3760 export function DeleteOrgZone({ action, name }: { action: Action; name: string }) {
3861 return (
3962 <DeleteZone
+2−2apps/web/app/organizations/[org]/settings/layout.tsxView File
@@ -1,7 +1,7 @@
11 import Link from "next/link";
22 import { GearIcon, PeopleIcon } from "@/components/icons";
33 import { SettingsNav } from "@/components/settings-nav";
4- import { Avatar } from "@/components/ui";
4+ import { Avatar, avatarUrl } from "@/components/ui";
55 import { getOwnedOrg } from "./load";
66
77 export default async function OrgSettingsLayout({ params, children }: LayoutProps<"/organizations/[org]/settings">) {
@@ -10,7 +10,7 @@ export default async function OrgSettingsLayout({ params, children }: LayoutProp
1010 return (
1111 <div className="mx-auto w-full max-w-[1100px] px-4 py-10 md:px-6">
1212 <div className="mb-8 flex items-center gap-4">
13- <Avatar name={org.name || org.username} size={52} />
13+ <Avatar name={org.name || org.username} src={avatarUrl(org)} size={52} />
1414 <div>
1515 <h1 className="text-2xl font-semibold tracking-[-0.03em]">
1616 <Link href={`/${org.username}`} className="hover:underline">
+9−2apps/web/app/organizations/[org]/settings/page.tsxView File
@@ -1,8 +1,8 @@
11 import type { Metadata } from "next";
2- import { deleteOrgAction, updateOrgAction } from "@/app/actions/orgs";
2+ import { deleteOrgAction, removeOrgAvatarAction, updateOrgAction, updateOrgAvatarAction } from "@/app/actions/orgs";
33 import { PlateSection } from "@/components/settings-plate";
44 import { PageTitle } from "@/components/ui";
5- import { DeleteOrgZone, OrgProfileForm } from "./forms";
5+ import { DeleteOrgZone, OrgAvatarForm, OrgProfileForm } from "./forms";
66 import { getOwnedOrg } from "./load";
77
88 export const metadata: Metadata = { title: "Organization settings" };
@@ -16,6 +16,13 @@ export default async function OrgSettingsPage({ params }: PageProps<"/organizati
1616 <PlateSection title="Profile">
1717 <OrgProfileForm action={updateOrgAction.bind(null, org.username)} org={{ username: org.username, name: org.name, bio: org.bio }} />
1818 </PlateSection>
19+ <PlateSection title="Avatar">
20+ <OrgAvatarForm
21+ action={updateOrgAvatarAction.bind(null, org.username)}
22+ resetAction={removeOrgAvatarAction.bind(null, org.username)}
23+ org={{ username: org.username, name: org.name, avatarVersion: org.avatarVersion }}
24+ />
25+ </PlateSection>
1926 <PlateSection title="Danger zone">
2027 <DeleteOrgZone action={deleteOrgAction.bind(null, org.username)} name={org.username} />
2128 </PlateSection>
+3−3apps/web/app/page.tsxView File
@@ -20,7 +20,7 @@ import {
2020 } from "@/components/icons";
2121 import { CardDescription, CardTitle } from "@/components/metal";
2222 import { RepoFilterList } from "@/components/repo-filter-list";
23- import { Avatar, Blankslate, Box, ButtonLink, Counter, Label, RefChip, RepoAvatar } from "@/components/ui";
23+ import { Avatar, avatarUrl, Blankslate, Box, ButtonLink, Counter, Label, RefChip, RepoAvatar } from "@/components/ui";
2424 import { getViewer } from "@/lib/auth";
2525 import { shortSha, timeAgo } from "@/lib/format";
2626 import { recentActivity, withActivity } from "@/lib/listing";
@@ -150,7 +150,7 @@ async function Dashboard({ viewer }: { viewer: User }) {
150150 New
151151 </ButtonLink>
152152 </div>
153- <RepoFilterList repos={mine.map((r) => ({ owner: r.owner, name: r.name, visibility: r.visibility, imageVersion: r.imageVersion }))} />
153+ <RepoFilterList repos={mine.map((r) => ({ owner: r.owner, name: r.name, visibility: r.visibility, imageVersion: r.imageVersion, ownerAvatarVersion: r.ownerAvatarVersion }))} />
154154 </Box>
155155 <Box className="p-4">
156156 <div className="mb-2 flex items-center justify-between gap-2">
@@ -166,7 +166,7 @@ async function Dashboard({ viewer }: { viewer: User }) {
166166 {orgs.map(({ org }) => (
167167 <li key={org.id}>
168168 <Link href={`/${org.username}`} className={`flex items-center gap-2.5 px-2 py-1.5 ${liftRow}`}>
169- <OrganizationIcon width={14} height={14} className="shrink-0 text-ink3" />
169+ {org.avatarVersion ? <Avatar name={org.username} src={avatarUrl(org)} size={16} /> : <OrganizationIcon width={14} height={14} className="shrink-0 text-ink3" />}
170170 <span className="truncate type-ui text-ink">{org.username}</span>
171171 </Link>
172172 </li>
+2−2apps/web/app/settings/organizations/page.tsxView File
@@ -7,7 +7,7 @@ import { GearIcon, OrganizationIcon, PlusIcon } from "@/components/icons";
77 import { SettingsSection } from "@/components/metal";
88 import { OrgInvitations } from "@/components/org-invitations";
99 import { ListRow } from "@/components/settings-plate";
10- import { Avatar, Blankslate, ButtonLink, Label, PageTitle } from "@/components/ui";
10+ import { Avatar, avatarUrl, Blankslate, ButtonLink, Label, PageTitle } from "@/components/ui";
1111 import { requireViewer } from "@/lib/auth";
1212 import { timeAgo } from "@/lib/format";
1313 import { listInvitationsFor, listOrgsOf, ROLE_INFO } from "@/lib/orgs";
@@ -39,7 +39,7 @@ export default async function OrganizationsSettingsPage() {
3939 {memberships.map(({ org, role, joinedAt }) => (
4040 <ListRow
4141 key={org.id}
42- lead={<Avatar name={org.name || org.username} size={32} />}
42+ lead={<Avatar name={org.name || org.username} src={avatarUrl(org)} size={32} />}
4343 title={
4444 <>
4545 <Link href={`/${org.username}`} className="truncate hover:underline">
+1−1apps/web/components/header.tsxView File
@@ -13,7 +13,7 @@ import { ButtonLink } from "./ui";
1313
1414 // The sidebar and the search box both list them; one query per request.
1515 const sidebarRepos = cache(async (viewerId: string) =>
16- (await listReposForUser(viewerId)).map((r) => ({ owner: r.owner, name: r.name, imageVersion: r.imageVersion })),
16+ (await listReposForUser(viewerId)).map((r) => ({ owner: r.owner, name: r.name, imageVersion: r.imageVersion, ownerAvatarVersion: r.ownerAvatarVersion })),
1717 );
1818
1919 const viewerOf = (viewer: User) => ({ username: viewer.username, name: viewer.name });
+85−0apps/web/components/image-upload-form.tsxaddedView File
@@ -0,0 +1,85 @@
1+ "use client";
2+
3+ import { useActionState, useEffect, useState, type ReactNode } from "react";
4+ import type { FormState } from "@/app/actions/types";
5+ import { UndoIcon, UploadIcon } from "./icons";
6+ import { PlateBody } from "./settings-plate";
7+ import { Alert, Button, buttonClass } from "./ui";
8+
9+ /**
10+ * Uploads a picture (a repository's image, an organization's avatar) with a preview of the chosen
11+ * file before it's saved, and offers to remove the one there is.
12+ */
13+ export function ImageUploadForm({
14+ action,
15+ resetAction,
16+ hasImage,
17+ noun,
18+ resetLabel,
19+ description,
20+ renderImage,
21+ }: {
22+ action: (prev: FormState, formData: FormData) => Promise<FormState>;
23+ resetAction: () => Promise<void>;
24+ hasImage: boolean;
25+ /** "Image" or "Avatar", for the buttons. */
26+ noun: string;
27+ resetLabel: string;
28+ description: ReactNode;
29+ /** The picture as it's shown, or with `src` (the chosen file) instead. */
30+ renderImage: (src?: string) => ReactNode;
31+ }) {
32+ const [preview, setPreview] = useState<string | null>(null);
33+ const [state, formAction, pending] = useActionState(async (prev: FormState, formData: FormData) => {
34+ const result = await action(prev, formData);
35+ if (result?.success) setPreview(null);
36+ return result;
37+ }, undefined);
38+ useEffect(() => () => {
39+ if (preview) URL.revokeObjectURL(preview);
40+ }, [preview]);
41+
42+ return (
43+ <PlateBody>
44+ <div className="flex flex-wrap items-start gap-5">
45+ {renderImage(preview ?? undefined)}
46+ <div className="flex min-w-60 flex-1 flex-col gap-3">
47+ <p className="type-body text-ink2">{description} PNG, JPEG, GIF or WebP, up to 1&nbsp;MB.</p>
48+ <div className="flex flex-wrap items-center gap-2">
49+ <form action={formAction} className="flex items-center gap-2">
50+ {/* The file input stays a real input; its label is drawn as MetalUI's standard cap. */}
51+ <label className={buttonClass("default", "has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-green-deep has-focus-visible:outline-solid")}>
52+ <UploadIcon /> {hasImage ? `Replace ${noun}` : `Upload ${noun}`}
53+ <input
54+ type="file"
55+ name="image"
56+ accept="image/png,image/jpeg,image/gif,image/webp"
57+ required
58+ className="sr-only"
59+ onChange={(e) => {
60+ const file = e.target.files?.[0];
61+ setPreview(file ? URL.createObjectURL(file) : null);
62+ }}
63+ />
64+ </label>
65+ {preview && (
66+ <Button variant="primary" disabled={pending}>
67+ {pending ? "Saving…" : `Save ${noun}`}
68+ </Button>
69+ )}
70+ </form>
71+ {hasImage && !preview && (
72+ <form action={resetAction}>
73+ <Button variant="invisible" icon={<UndoIcon />}>
74+ {resetLabel}
75+ </Button>
76+ </form>
77+ )}
78+ </div>
79+ {state?.error && <Alert>{state.error}</Alert>}
80+ {state?.success && !preview && <Alert kind="success">{state.success}</Alert>}
81+ </div>
82+ </div>
83+ </PlateBody>
84+ );
85+ }
+2−2apps/web/components/org-invitations.tsxView File
@@ -6,7 +6,7 @@ import type { OrgInvitation } from "@/lib/orgs";
66 import { ActionButton } from "./action-button";
77 import { CheckIcon } from "./icons";
88 import { ListRow } from "./settings-plate";
9- import { Avatar, Box, Label } from "./ui";
9+ import { Avatar, avatarUrl, Box, Label } from "./ui";
1010
1111 /** Invitations to join organizations, for the person invited: accept (and land on the organization) or decline. */
1212 export function OrgInvitations({ invitations }: { invitations: OrgInvitation[] }) {
@@ -16,7 +16,7 @@ export function OrgInvitations({ invitations }: { invitations: OrgInvitation[] }
1616 {invitations.map((inv) => (
1717 <ListRow
1818 key={inv.id}
19- lead={<Avatar name={inv.org.name || inv.org.username} size={32} />}
19+ lead={<Avatar name={inv.org.name || inv.org.username} src={avatarUrl(inv.org)} size={32} />}
2020 title={
2121 <>
2222 <span>
+2−2apps/web/components/repo-filter-list.tsxView File
@@ -4,9 +4,9 @@ import Link from "next/link";
44 import { useState } from "react";
55 import { Field } from "@unlocalhosted/metalui";
66 import { LockIcon, SearchIcon } from "./icons";
7- import { RepoAvatar } from "./ui";
7+ import { RepoAvatar, type RepoImageRef } from "./ui";
88
9- type Item = { owner: string; name: string; visibility: "public" | "private"; imageVersion: number | null };
9+ type Item = RepoImageRef & { visibility: "public" | "private" };
1010
1111 /** The viewer's repositories: a compact search well that filters as you type, then rows that lift on hover. */
1212 export function RepoFilterList({ repos }: { repos: Item[] }) {
+2−2apps/web/components/search-box.tsxView File
@@ -4,12 +4,12 @@ import { useRouter } from "next/navigation";
44 import { useEffect, useState, useSyncExternalStore } from "react";
55 import { CommandPalette, SearchField, type CommandPaletteItem } from "@unlocalhosted/metalui";
66 import { CompassIcon, GearIcon, HomeIcon, KeyIcon, PlusIcon, SearchIcon, ShieldLockIcon } from "./icons";
7- import { RepoAvatar } from "./ui";
7+ import { RepoAvatar, type RepoImageRef } from "./ui";
88
99 const noSubscribe = () => () => {};
1010 const isApple = () => /Mac|iPhone|iPad/.test(navigator.platform);
1111
12- export type PaletteRepo = { owner: string; name: string; imageVersion: number | null };
12+ export type PaletteRepo = RepoImageRef;
1313
1414 /**
1515 * The header's search well (MetalUI's SearchField) and the ⌘K palette it opens: jump to a repository,
+2−2apps/web/components/sidebar-nav.tsxView File
@@ -9,11 +9,11 @@ import type { Theme } from "@/lib/theme";
99 import { BRAND, LogoMark } from "./brand";
1010 import { ChevronsUpDownIcon, CompassIcon, GearIcon, HomeIcon, KeyIcon, MenuIcon, OrganizationIcon, PersonIcon, PlusIcon, RepoIcon, ShieldLockIcon, SignOutIcon } from "./icons";
1111 import { ThemeToggle } from "./theme-toggle";
12- import { RepoAvatar } from "./ui";
12+ import { RepoAvatar, type RepoImageRef } from "./ui";
1313
1414 type Item = { href: string; label: string; icon: ComponentType<SVGProps<SVGSVGElement>>; match: (p: string) => boolean };
1515
16- export type SidebarRepo = { owner: string; name: string; imageVersion: number | null };
16+ export type SidebarRepo = RepoImageRef;
1717 export type SidebarViewer = { username: string; name: string };
1818
1919 function navItems(username: string): Item[] {
+26−7apps/web/components/ui.tsxView File
@@ -56,11 +56,24 @@ function Identicon({ name, size }: { name: string; size: number }) {
5656 );
5757 }
5858
59+ /** Where an account's uploaded avatar is served (organizations set one in their settings), if it has one. */
60+ export const avatarUrl = (account: { username: string; avatarVersion?: number | null }) =>
61+ account.avatarVersion ? `/organizations/${account.username}/avatar?v=${account.avatarVersion}` : undefined;
62+
5963 /**
60- * A person: MetalUI's raised disc with their initials. Its sizes are 24, 32 and 44; smaller avatars
61- * (inline beside a name) are the small disc scaled down.
64+ * A person or organization: their uploaded avatar in a round cap, or else MetalUI's raised disc with
65+ * their initials. Its sizes are 24, 32 and 44; smaller avatars (inline beside a name) are the small
66+ * disc scaled down.
6267 */
63- export function Avatar({ name, size = 20, className = "" }: { name: string; size?: number; className?: string }) {
68+ export function Avatar({ name, size = 20, className = "", src }: { name: string; size?: number; className?: string; src?: string }) {
69+ if (src) {
70+ return (
71+ <span className={`material-cap inline-flex shrink-0 overflow-hidden rounded-full select-none ${className}`} style={{ width: size, height: size }} aria-hidden>
72+ {/* eslint-disable-next-line @next/next/no-img-element -- tiny, already-sized images */}
73+ <img src={src} alt="" width={size} height={size} className="h-full w-full object-cover" />
74+ </span>
75+ );
76+ }
6477 const muSize = size >= 40 ? "large" : size >= 30 ? "regular" : "small";
6578 const base = muSize === "large" ? 44 : muSize === "regular" ? 32 : 24;
6679 return (
@@ -73,13 +86,19 @@ export function Avatar({ name, size = 20, className = "" }: { name: string; size
7386 );
7487 }
7588
76- export type RepoImageRef = { owner: string; name: string; imageVersion: number | null };
89+ export type RepoImageRef = {
90+ owner: string;
91+ name: string;
92+ imageVersion: number | null;
93+ /** The owning organization's avatar, shown when the repository has no image of its own. */
94+ ownerAvatarVersion?: number | null;
95+ };
7796
7897 export const repoImageUrl = (repo: RepoImageRef) => `/${repo.owner}/${repo.name}/image?v=${repo.imageVersion}`;
7998
8099 /**
81- * A repository's "logo" on a raised key: the image uploaded in the repository's settings, or else an
82- * identicon engraved from the owner's name.
100+ * A repository's "logo" on a raised key: the image uploaded in the repository's settings, or else its
101+ * organization's avatar, or else an identicon engraved from the owner's name.
83102 */
84103 export function RepoAvatar({
85104 repo,
@@ -93,7 +112,7 @@ export function RepoAvatar({
93112 /** Show this image instead, e.g. a local preview before uploading. */
94113 src?: string;
95114 }) {
96- const image = src ?? (repo.imageVersion ? repoImageUrl(repo) : null);
115+ const image = src ?? (repo.imageVersion ? repoImageUrl(repo) : avatarUrl({ username: repo.owner, avatarVersion: repo.ownerAvatarVersion }));
97116 return (
98117 <span
99118 className={`material-cap inline-flex shrink-0 overflow-hidden select-none ${className}`}
+16−0apps/web/lib/orgs.tsView File
@@ -2,6 +2,7 @@ import { randomBytes } from "node:crypto";
22 import { count, db, filter, findById, findFirst, isUniqueViolation, withLock, type RecordModel } from "./db";
33 import { isValidOwner } from "./git";
44 import { ORG_ROLES, roleAtLeast, type OrgRole } from "./org-roles";
5+ import { readStoredImage, uploadedImage } from "./uploaded-images";
56 import { getUserByUsername, toUser, type User } from "./users";
67
78 export * from "./org-roles";
@@ -56,6 +57,21 @@ export async function updateOrgProfile(orgId: string, data: { displayName: strin
5657 await (await db()).collection("users").update(orgId, { name: data.displayName.trim().slice(0, 100), bio: data.description.trim().slice(0, 300) });
5758 }
5859
60+ export async function setOrgAvatar(orgId: string, data: Uint8Array) {
61+ const image = uploadedImage(data, "avatar");
62+ if ("error" in image) throw new OrgError(image.error);
63+ await (await db()).collection("users").update(orgId, { avatar: image.file, avatar_type: image.type, avatar_updated_at: Date.now() });
64+ }
65+
66+ export async function removeOrgAvatar(orgId: string) {
67+ await (await db()).collection("users").update(orgId, { avatar: null, avatar_type: "", avatar_updated_at: 0 });
68+ }
69+
70+ export async function getOrgAvatar(orgId: string) {
71+ const row = await findById("users", orgId);
72+ return row?.kind === "org" && row.avatar ? readStoredImage(row, row.avatar, row.avatar_type) : null;
73+ }
74+
5975 /** Deletes an organization without repositories, and its memberships and invitations with it. */
6076 export async function deleteOrg(org: User) {
6177 const repos = await count("repos", filter("owner = {:id}", { id: org.id }));
+8−29apps/web/lib/repos.tsView File
@@ -16,6 +16,7 @@ import {
1616 import { commitFiles } from "./git-write";
1717 import { deleteRepoImages } from "./registry";
1818 import { deleteRepoReleaseFiles } from "./releases";
19+ import { readStoredImage, uploadedImage } from "./uploaded-images";
1920 import type { AgentPush } from "./branch-rules";
2021 import { orgRole, repoAccessOfRole } from "./orgs";
2122 import { getPersonByUsername, type User } from "./users";
@@ -39,6 +40,8 @@ export type RepoRecord = {
3940 pushedAt: number | null;
4041 /** When a custom image was uploaded (used to bust caches), or null to show the owner's avatar. */
4142 imageVersion: number | null;
43+ /** The owning organization's avatar version, shown in place of a missing image (see lib/users.ts). */
44+ ownerAvatarVersion: number | null;
4245 wikiEnabled: boolean;
4346 /** Where agents may push (see lib/branch-rules.ts). */
4447 agentPush: AgentPush;
@@ -66,6 +69,7 @@ function toRepo(r: RecordModel): RepoRecord {
6669 createdAt: r.created_at,
6770 pushedAt: orNull(r.pushed_at),
6871 imageVersion: r.image ? orNull(r.image_updated_at) : null,
72+ ownerAvatarVersion: ownerRecord.kind === "org" && ownerRecord.avatar ? orNull(ownerRecord.avatar_updated_at) : null,
6973 wikiEnabled: !r.wiki_disabled,
7074 agentPush: r.agent_push || "any",
7175 forkOf: parent
@@ -362,30 +366,10 @@ export async function deleteRepo(repo: RepoRecord) {
362366 // ---------------------------------------------------------------------------
363367 // Repository image
364368
365- export const MAX_REPO_IMAGE_BYTES = 1024 * 1024;
366-
367- const IMAGE_TYPES: Record<string, string> = { "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif", "image/webp": "webp" };
368-
369- /** Recognizes the raster formats we accept by their magic bytes; the browser-reported type isn't trusted. */
370- function sniffImageType(data: Uint8Array) {
371- const starts = (...bytes: number[]) => bytes.every((b, i) => data[i] === b);
372- if (starts(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)) return "image/png";
373- if (starts(0xff, 0xd8, 0xff)) return "image/jpeg";
374- if (starts(0x47, 0x49, 0x46, 0x38)) return "image/gif";
375- if (starts(0x52, 0x49, 0x46, 0x46) && String.fromCharCode(...data.subarray(8, 12)) === "WEBP") return "image/webp";
376- return null;
377- }
378-
379369 export async function setRepoImage(repoId: string, data: Uint8Array) {
380- if (data.byteLength === 0) throw new RepoError("Choose an image to upload.");
381- if (data.byteLength > MAX_REPO_IMAGE_BYTES) throw new RepoError("Images can be at most 1 MB.");
382- const contentType = sniffImageType(data);
383- if (!contentType) throw new RepoError("Use a PNG, JPEG, GIF or WebP image.");
384- await (await repos()).update(repoId, {
385- image: new File([new Uint8Array(data)], `image.${IMAGE_TYPES[contentType]}`, { type: contentType }),
386- image_type: contentType,
387- image_updated_at: Date.now(),
388- });
370+ const image = uploadedImage(data, "image");
371+ if ("error" in image) throw new RepoError(image.error);
372+ await (await repos()).update(repoId, { image: image.file, image_type: image.type, image_updated_at: Date.now() });
389373 }
390374
391375 export async function removeRepoImage(repoId: string) {
@@ -394,12 +378,7 @@ export async function removeRepoImage(repoId: string) {
394378
395379 export async function getRepoImage(repoId: string) {
396380 const row = await findById("repos", repoId);
397- if (!row?.image) return null;
398- const pb = await db();
399- // The file field is protected: downloads need a short-lived file token.
400- const res = await fetch(pb.files.getURL(row, row.image, { token: await pb.files.getToken() }));
401- if (!res.ok) return null;
402- return { contentType: row.image_type as string, data: new Uint8Array(await res.arrayBuffer()) };
381+ return row?.image ? readStoredImage(row, row.image, row.image_type) : null;
403382 }
404383
405384 // ---------------------------------------------------------------------------
+48−0apps/web/lib/uploaded-images.tsaddedView File
@@ -0,0 +1,48 @@
1+ import { db, type RecordModel } from "./db";
2+
3+ // Pictures people upload: repositories' images and organizations' avatars. They're stored in
4+ // protected PocketBase file fields and served by the app with their sniffed type.
5+
6+ export const MAX_UPLOADED_IMAGE_BYTES = 1024 * 1024;
7+
8+ const EXTENSIONS: Record<string, string> = { "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif", "image/webp": "webp" };
9+
10+ /** Recognizes the raster formats we accept by their magic bytes; the browser-reported type isn't trusted. */
11+ function sniffImageType(data: Uint8Array) {
12+ const starts = (...bytes: number[]) => bytes.every((b, i) => data[i] === b);
13+ if (starts(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)) return "image/png";
14+ if (starts(0xff, 0xd8, 0xff)) return "image/jpeg";
15+ if (starts(0x47, 0x49, 0x46, 0x38)) return "image/gif";
16+ if (starts(0x52, 0x49, 0x46, 0x46) && String.fromCharCode(...data.subarray(8, 12)) === "WEBP") return "image/webp";
17+ return null;
18+ }
19+
20+ /** The upload as a file named `<name>.<ext>` to store, or why it's refused. */
21+ export function uploadedImage(data: Uint8Array, name: string): { file: File; type: string } | { error: string } {
22+ if (data.byteLength === 0) return { error: "Choose an image to upload." };
23+ if (data.byteLength > MAX_UPLOADED_IMAGE_BYTES) return { error: "Images can be at most 1 MB." };
24+ const type = sniffImageType(data);
25+ if (!type) return { error: "Use a PNG, JPEG, GIF or WebP image." };
26+ return { file: new File([new Uint8Array(data)], `${name}.${EXTENSIONS[type]}`, { type }), type };
27+ }
28+
29+ /** A stored image's bytes, or null. */
30+ export async function readStoredImage(record: RecordModel, filename: string, type: string) {
31+ const pb = await db();
32+ // The file fields are protected: downloads need a short-lived file token.
33+ const res = await fetch(pb.files.getURL(record, filename, { token: await pb.files.getToken() }));
34+ if (!res.ok) return null;
35+ return { contentType: type, data: new Uint8Array(await res.arrayBuffer()) };
36+ }
37+
38+ /** The response for a stored image: cacheable for good (URLs carry `?v=<upload time>`) and inert. */
39+ export function imageResponse(image: { contentType: string; data: Uint8Array }, visibility: "public" | "private") {
40+ return new Response(new Uint8Array(image.data), {
41+ headers: {
42+ "Content-Type": image.contentType,
43+ "X-Content-Type-Options": "nosniff",
44+ "Cache-Control": `${visibility}, max-age=31536000, immutable`,
45+ "Content-Security-Policy": "default-src 'none'; sandbox",
46+ },
47+ });
48+ }
+13−2apps/web/lib/users.tsView File
@@ -1,5 +1,5 @@
11 import { ClientResponseError } from "pocketbase";
2- import { client, db, filter, findById, findFirst, isUniqueViolation, type RecordModel } from "./db";
2+ import { client, db, filter, findById, findFirst, isUniqueViolation, orNull, type RecordModel } from "./db";
33 import { isValidOwner } from "./git";
44
55 // Accounts are PocketBase auth records: PocketBase hashes and checks passwords and issues the
@@ -14,13 +14,24 @@ export type User = {
1414 createdAt: number;
1515 /** An organization (lib/orgs.ts): it owns repositories, but nobody signs in as it. */
1616 isOrg: boolean;
17+ /** When an avatar was uploaded (used to bust caches), or null to draw the initials. */
18+ avatarVersion: number | null;
1719 };
1820
1921 /** PocketBase dates read "2026-01-02 03:04:05.678Z". */
2022 export const parseDate = (value: string) => Date.parse(value.replace(" ", "T"));
2123
2224 export function toUser(r: RecordModel): User {
23- return { id: r.id, username: r.username, name: r.name, email: r.email, bio: r.bio, createdAt: parseDate(r.created), isOrg: r.kind === "org" };
25+ return {
26+ id: r.id,
27+ username: r.username,
28+ name: r.name,
29+ email: r.email,
30+ bio: r.bio,
31+ createdAt: parseDate(r.created),
32+ isOrg: r.kind === "org",
33+ avatarVersion: r.avatar ? orNull(r.avatar_updated_at) : null,
34+ };
2435 }
2536
2637 export function validatePassword(password: string) {
+30−0apps/web/pb_migrations/1791500000_avatars.jsaddedView File
@@ -0,0 +1,30 @@
1+ // Uploaded avatars for accounts (for now, organizations set one in their settings; see lib/orgs.ts).
2+ // Like a repository's image: one raster file, protected so only the server reads it, with its type
3+ // and upload time (avatar_updated_at, which busts caches) beside it.
4+
5+ const MIME_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
6+
7+ migrate(
8+ (app) => {
9+ const users = app.findCollectionByNameOrId("users");
10+ // PocketBase's default users collection comes with an open "avatar" field that takes SVGs.
11+ const avatar = users.fields.getByName("avatar");
12+ if (avatar) {
13+ avatar.maxSelect = 1;
14+ avatar.maxSize = 1024 * 1024;
15+ avatar.mimeTypes = MIME_TYPES;
16+ avatar.thumbs = [];
17+ avatar.protected = true;
18+ } else {
19+ users.fields.add(new FileField({ name: "avatar", maxSelect: 1, maxSize: 1024 * 1024, mimeTypes: MIME_TYPES, protected: true }));
20+ }
21+ users.fields.add(new TextField({ name: "avatar_type", max: 20 }));
22+ users.fields.add(new NumberField({ name: "avatar_updated_at", onlyInt: true, min: 0 }));
23+ app.save(users);
24+ },
25+ (app) => {
26+ const users = app.findCollectionByNameOrId("users");
27+ for (const name of ["avatar_type", "avatar_updated_at"]) users.fields.removeByName(name);
28+ app.save(users);
29+ },
30+ );
+1−1docs/features.mdView File
@@ -4,7 +4,7 @@
44 - **Two-factor authentication** with an authenticator app (TOTP: 1Password, Google Authenticator, Authy…): set up under *Settings → Two-factor auth* by scanning a QR code, with ten one-time recovery codes for a lost device. It guards signing in with a password, on the website and in the Android app; SSH keys, API tokens and connected apps work as before
55 - **Repositories**: public or private, rename, delete, optional README on creation
66 - **Collaborators**: give other users push access to a repository
7- - **Organizations**, like on GitHub: accounts that own repositories for a team, at `/<org>/<repo>`. Whoever creates one is its owner and invites people by username (*Settings → Organizations*, or the organization's *Members* settings). Invited people accept or decline from their dashboard. Each member has a role that applies to all of the organization's repositories: *Read* (sees them, private ones too), *Write* (pushes, manages issues and pull requests), *Admin* (also creates repositories, changes their settings and deletes them) or *Owner* (also manages the organization, its members and invitations). Owners change roles and remove members at any time; an organization always keeps at least one owner. Single repositories can still add outside collaborators. Agents can create repositories in organizations where their person is an admin or owner (`create_repository` with `owner`)
7+ - **Organizations**, like on GitHub: accounts that own repositories for a team, at `/<org>/<repo>`. Whoever creates one is its owner and invites people by username (*Settings → Organizations*, or the organization's *Members* settings). Invited people accept or decline from their dashboard. Each member has a role that applies to all of the organization's repositories: *Read* (sees them, private ones too), *Write* (pushes, manages issues and pull requests), *Admin* (also creates repositories, changes their settings and deletes them) or *Owner* (also manages the organization, its members and invitations). Owners change roles and remove members at any time; an organization always keeps at least one owner. Owners can upload an avatar for the organization (PNG, JPEG, GIF or WebP, up to 1 MB) in its *General* settings; its repositories without an image of their own show it too. Single repositories can still add outside collaborators. Agents can create repositories in organizations where their person is an admin or owner (`create_repository` with `owner`)
88 - **Forks**: anyone who can read a repository can fork it (the *Fork* button) into a copy of their own, of its default branch or all branches. Forks carry a *Fork* label and a "forked from" link everywhere they're listed, and the original lists them under *Forks*. A fork's front page says how far it is ahead of and behind the original, with *Sync Fork* (a fast-forward, or a merge commit if the fork has commits of its own) and *Contribute* (a pull request back). Forks of private repositories stay private
99 - **Git over SSH**: built-in SSH server (default port 2222). Add public keys under *Settings → SSH keys*
1010 - **Git over HTTPS**: smart HTTP protocol (v0 and v2). Authenticate with API tokens