mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
feat: verify organization GitHub profiles (#3169)
This commit is contained in:
@@ -163,6 +163,7 @@ jobs:
|
||||
specs: |
|
||||
e2e/local-auth/header-profile-link.pw.test.ts
|
||||
e2e/local-auth/manage-context-proof.pw.test.ts
|
||||
e2e/local-auth/publisher-github-profile.pw.test.ts
|
||||
- name: moderation-malicious
|
||||
specs: e2e/local-auth/malicious-skill-ban-flow.pw.test.ts
|
||||
- name: star-sync
|
||||
|
||||
Vendored
+4
@@ -27,6 +27,7 @@ import type * as functions from "../functions.js";
|
||||
import type * as githubAccountAgeBackfill from "../githubAccountAgeBackfill.js";
|
||||
import type * as githubIdentity from "../githubIdentity.js";
|
||||
import type * as githubImport from "../githubImport.js";
|
||||
import type * as githubOrgMemberships from "../githubOrgMemberships.js";
|
||||
import type * as githubSkillSources from "../githubSkillSources.js";
|
||||
import type * as githubSkillSync from "../githubSkillSync.js";
|
||||
import type * as githubSkillSyncNode from "../githubSkillSyncNode.js";
|
||||
@@ -70,6 +71,7 @@ import type * as lib_githubAuth from "../lib/githubAuth.js";
|
||||
import type * as lib_githubHandoff from "../lib/githubHandoff.js";
|
||||
import type * as lib_githubIdentity from "../lib/githubIdentity.js";
|
||||
import type * as lib_githubImport from "../lib/githubImport.js";
|
||||
import type * as lib_githubOrgMemberships from "../lib/githubOrgMemberships.js";
|
||||
import type * as lib_githubProfileSync from "../lib/githubProfileSync.js";
|
||||
import type * as lib_githubSkillScans from "../lib/githubSkillScans.js";
|
||||
import type * as lib_githubSkillSync from "../lib/githubSkillSync.js";
|
||||
@@ -194,6 +196,7 @@ declare const fullApi: ApiFromModules<{
|
||||
githubAccountAgeBackfill: typeof githubAccountAgeBackfill;
|
||||
githubIdentity: typeof githubIdentity;
|
||||
githubImport: typeof githubImport;
|
||||
githubOrgMemberships: typeof githubOrgMemberships;
|
||||
githubSkillSources: typeof githubSkillSources;
|
||||
githubSkillSync: typeof githubSkillSync;
|
||||
githubSkillSyncNode: typeof githubSkillSyncNode;
|
||||
@@ -237,6 +240,7 @@ declare const fullApi: ApiFromModules<{
|
||||
"lib/githubHandoff": typeof lib_githubHandoff;
|
||||
"lib/githubIdentity": typeof lib_githubIdentity;
|
||||
"lib/githubImport": typeof lib_githubImport;
|
||||
"lib/githubOrgMemberships": typeof lib_githubOrgMemberships;
|
||||
"lib/githubProfileSync": typeof lib_githubProfileSync;
|
||||
"lib/githubSkillScans": typeof lib_githubSkillScans;
|
||||
"lib/githubSkillSync": typeof lib_githubSkillSync;
|
||||
|
||||
+67
-4
@@ -156,6 +156,14 @@ describe("handleDeletedUserSignIn", () => {
|
||||
});
|
||||
|
||||
describe("GitHub auth provider", () => {
|
||||
it("requests read-only GitHub organization membership access", () => {
|
||||
const provider = createGitHubAuthProvider() as {
|
||||
options?: { authorization?: { params?: { scope?: string } } };
|
||||
};
|
||||
|
||||
expect(provider.options?.authorization?.params?.scope?.split(" ")).toContain("read:org");
|
||||
});
|
||||
|
||||
it("does not link ClawHub accounts by GitHub profile email", () => {
|
||||
const provider = createGitHubAuthProvider() as {
|
||||
options?: { allowDangerousEmailAccountLinking?: boolean };
|
||||
@@ -181,19 +189,74 @@ describe("GitHub auth provider", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed when the GitHub provider receives a malformed profile", () => {
|
||||
it("fails closed when the GitHub provider receives a malformed profile", async () => {
|
||||
const provider = createGitHubAuthProvider() as {
|
||||
options?: { profile?: (profile: Record<string, unknown>) => Record<string, unknown> };
|
||||
options?: {
|
||||
profile?: (
|
||||
profile: Record<string, unknown>,
|
||||
tokens: { access_token?: string },
|
||||
) => Promise<Record<string, unknown>>;
|
||||
};
|
||||
};
|
||||
|
||||
expect(() => provider.options?.profile?.({ message: "Bad credentials" })).toThrow(
|
||||
await expect(provider.options?.profile?.({ message: "Bad credentials" }, {})).rejects.toThrow(
|
||||
"GitHub OAuth profile is missing a valid numeric id",
|
||||
);
|
||||
expect(provider.options?.profile?.({ id: 123456, login: "fixture-user" })).toEqual({
|
||||
await expect(
|
||||
provider.options?.profile?.({ id: 123456, login: "fixture-user" }, {}),
|
||||
).resolves.toEqual({
|
||||
id: "123456",
|
||||
name: "fixture-user",
|
||||
email: undefined,
|
||||
image: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("adds a verified GitHub organization snapshot to the OAuth profile", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify([
|
||||
{
|
||||
state: "active",
|
||||
role: "member",
|
||||
organization: { id: 42, login: "trycua" },
|
||||
},
|
||||
]),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
const provider = createGitHubAuthProvider() as {
|
||||
options?: {
|
||||
profile?: (
|
||||
profile: Record<string, unknown>,
|
||||
tokens: { access_token?: string },
|
||||
) => Promise<Record<string, unknown>>;
|
||||
};
|
||||
};
|
||||
|
||||
await expect(
|
||||
provider.options?.profile?.(
|
||||
{ id: 123456, login: "fixture-user" },
|
||||
{ access_token: "test-token-placeholder" },
|
||||
),
|
||||
).resolves.toEqual({
|
||||
id: "123456",
|
||||
name: "fixture-user",
|
||||
email: undefined,
|
||||
image: undefined,
|
||||
githubOrgMembershipSync: {
|
||||
memberships: [
|
||||
{
|
||||
githubOrgId: "42",
|
||||
login: "trycua",
|
||||
avatarUrl: undefined,
|
||||
role: "member",
|
||||
},
|
||||
],
|
||||
syncedAt: expect.any(Number),
|
||||
truncated: false,
|
||||
},
|
||||
});
|
||||
fetchMock.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
+34
-1
@@ -6,6 +6,12 @@ import { ConvexError } from "convex/values";
|
||||
import { internal } from "./_generated/api";
|
||||
import type { DataModel, Id } from "./_generated/dataModel";
|
||||
import { isLocalDevAuthEnabled } from "./lib/devAuth";
|
||||
import {
|
||||
GITHUB_ORG_MEMBERSHIP_SYNC_PROFILE_KEY,
|
||||
fetchActiveGitHubOrgMemberships,
|
||||
readGitHubOrgMembershipSync,
|
||||
replaceGitHubOrgMemberships,
|
||||
} from "./lib/githubOrgMemberships";
|
||||
import { shouldScheduleGitHubProfileSync } from "./lib/githubProfileSync";
|
||||
|
||||
export const BANNED_REAUTH_MESSAGE =
|
||||
@@ -39,15 +45,34 @@ export function createGitHubAuthProvider() {
|
||||
return GitHub({
|
||||
clientId: process.env.AUTH_GITHUB_ID ?? "",
|
||||
clientSecret: process.env.AUTH_GITHUB_SECRET ?? "",
|
||||
authorization: {
|
||||
params: { scope: "read:user user:email read:org" },
|
||||
},
|
||||
// GitHub's OAuth email must not be treated as a ClawHub account key. The
|
||||
// immutable GitHub provider account id is the only account-linking key.
|
||||
allowDangerousEmailAccountLinking: false,
|
||||
profile(profile) {
|
||||
async profile(profile, tokens) {
|
||||
let githubOrgMembershipSync;
|
||||
const accessToken = tokens.access_token?.trim();
|
||||
if (accessToken) {
|
||||
try {
|
||||
githubOrgMembershipSync = await fetchActiveGitHubOrgMemberships(accessToken);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[auth] GitHub organization membership sync failed: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: normalizeGitHubProfileId(profile.id),
|
||||
name: profile.login,
|
||||
email: profile.email ?? undefined,
|
||||
image: profile.avatar_url,
|
||||
...(githubOrgMembershipSync
|
||||
? { [GITHUB_ORG_MEMBERSHIP_SYNC_PROFILE_KEY]: githubOrgMembershipSync }
|
||||
: {}),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -120,6 +145,7 @@ function userDataFromAuthProfile(args: {
|
||||
const {
|
||||
emailVerified: profileEmailVerified,
|
||||
phoneVerified: profilePhoneVerified,
|
||||
[GITHUB_ORG_MEMBERSHIP_SYNC_PROFILE_KEY]: _githubOrgMembershipSync,
|
||||
...profile
|
||||
} = args.profile;
|
||||
const emailVerified =
|
||||
@@ -187,6 +213,7 @@ export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
|
||||
*/
|
||||
async createOrUpdateUser(ctx, args) {
|
||||
const userData = userDataFromAuthProfile(args);
|
||||
const githubOrgMembershipSync = readGitHubOrgMembershipSync(args.profile);
|
||||
if (args.existingUserId !== null) {
|
||||
const userId = args.existingUserId as Id<"users">;
|
||||
const existingUser = await ctx.db.get(userId);
|
||||
@@ -194,11 +221,17 @@ export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
|
||||
return userId;
|
||||
}
|
||||
await ctx.db.patch(userId, userData);
|
||||
if (githubOrgMembershipSync) {
|
||||
await replaceGitHubOrgMemberships(ctx, userId, githubOrgMembershipSync);
|
||||
}
|
||||
await schedulePostUserCreatedOrUpdated(ctx, userId, existingUser);
|
||||
return userId;
|
||||
}
|
||||
|
||||
const userId = await ctx.db.insert("users", userData);
|
||||
if (githubOrgMembershipSync) {
|
||||
await replaceGitHubOrgMemberships(ctx, userId, githubOrgMembershipSync);
|
||||
}
|
||||
const user = await ctx.db.get(userId);
|
||||
await schedulePostUserCreatedOrUpdated(ctx, userId, user);
|
||||
return userId;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { query } from "./functions";
|
||||
import { getOptionalActiveAuthUserId } from "./lib/access";
|
||||
|
||||
export const listMine = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const userId = await getOptionalActiveAuthUserId(ctx);
|
||||
if (!userId) {
|
||||
return { syncedAt: null, truncated: false, memberships: [] };
|
||||
}
|
||||
const user = await ctx.db.get(userId);
|
||||
if (!user || user.deletedAt || user.deactivatedAt) {
|
||||
return { syncedAt: null, truncated: false, memberships: [] };
|
||||
}
|
||||
|
||||
const memberships = await ctx.db
|
||||
.query("githubOrgMemberships")
|
||||
.withIndex("by_user", (q) => q.eq("userId", userId))
|
||||
.collect();
|
||||
memberships.sort((left, right) => left.login.localeCompare(right.login));
|
||||
|
||||
return {
|
||||
syncedAt: user.githubOrgMembershipsSyncedAt ?? null,
|
||||
truncated: user.githubOrgMembershipsTruncated ?? false,
|
||||
memberships: memberships.map(({ githubOrgId, login, avatarUrl, role, syncedAt }) => ({
|
||||
githubOrgId,
|
||||
login,
|
||||
avatarUrl: avatarUrl ?? null,
|
||||
role,
|
||||
syncedAt,
|
||||
})),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
fetchActiveGitHubOrgMemberships,
|
||||
readGitHubOrgMembershipSync,
|
||||
} from "./githubOrgMemberships";
|
||||
|
||||
describe("GitHub organization memberships", () => {
|
||||
it("loads active memberships from the authenticated GitHub API", async () => {
|
||||
const fetchImpl = vi.fn(async () => {
|
||||
return new Response(
|
||||
JSON.stringify([
|
||||
{
|
||||
state: "active",
|
||||
role: "member",
|
||||
organization: {
|
||||
id: 2,
|
||||
login: "trycua",
|
||||
avatar_url: "https://avatars.githubusercontent.com/u/2",
|
||||
},
|
||||
},
|
||||
{
|
||||
state: "active",
|
||||
role: "admin",
|
||||
organization: {
|
||||
id: 1,
|
||||
login: "openclaw",
|
||||
avatar_url: "https://avatars.githubusercontent.com/u/1",
|
||||
},
|
||||
},
|
||||
{
|
||||
state: "pending",
|
||||
role: "member",
|
||||
organization: { id: 3, login: "pending-org" },
|
||||
},
|
||||
]),
|
||||
{ status: 200 },
|
||||
);
|
||||
});
|
||||
|
||||
const result = await fetchActiveGitHubOrgMemberships("github-token", {
|
||||
fetchImpl: fetchImpl as typeof fetch,
|
||||
now: 123,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
syncedAt: 123,
|
||||
truncated: false,
|
||||
memberships: [
|
||||
{
|
||||
githubOrgId: "1",
|
||||
login: "openclaw",
|
||||
avatarUrl: "https://avatars.githubusercontent.com/u/1",
|
||||
role: "admin",
|
||||
},
|
||||
{
|
||||
githubOrgId: "2",
|
||||
login: "trycua",
|
||||
avatarUrl: "https://avatars.githubusercontent.com/u/2",
|
||||
role: "member",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/user/memberships/orgs?state=active"),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: "Bearer github-token",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects GitHub API failures without accepting partial membership data", async () => {
|
||||
const fetchImpl = vi.fn(async () => new Response("Forbidden", { status: 403 }));
|
||||
|
||||
await expect(
|
||||
fetchActiveGitHubOrgMemberships("github-token", {
|
||||
fetchImpl: fetchImpl as typeof fetch,
|
||||
}),
|
||||
).rejects.toThrow("GitHub organization membership lookup failed (403)");
|
||||
});
|
||||
|
||||
it("loads every GitHub organization membership page", async () => {
|
||||
const firstPage = Array.from({ length: 100 }, (_, index) => ({
|
||||
state: "active",
|
||||
role: "member",
|
||||
organization: { id: index + 1, login: `org-${index + 1}` },
|
||||
}));
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify(firstPage), { status: 200 }))
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify([
|
||||
{
|
||||
state: "active",
|
||||
role: "admin",
|
||||
organization: { id: 101, login: "org-101" },
|
||||
},
|
||||
]),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
|
||||
const result = await fetchActiveGitHubOrgMemberships("github-token", {
|
||||
fetchImpl: fetchImpl as typeof fetch,
|
||||
now: 123,
|
||||
});
|
||||
|
||||
expect(result.memberships).toHaveLength(101);
|
||||
expect(result.truncated).toBe(false);
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||
expect(fetchImpl).toHaveBeenLastCalledWith(
|
||||
expect.stringContaining("page=2"),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("validates membership snapshots before they reach the database", () => {
|
||||
expect(
|
||||
readGitHubOrgMembershipSync({
|
||||
githubOrgMembershipSync: {
|
||||
syncedAt: 123,
|
||||
truncated: false,
|
||||
memberships: [
|
||||
{ githubOrgId: "1", login: "openclaw", role: "admin" },
|
||||
{ githubOrgId: "invalid", login: "spoofed", role: "member" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
syncedAt: 123,
|
||||
truncated: false,
|
||||
memberships: [{ githubOrgId: "1", login: "openclaw", role: "admin" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import type { Id } from "../_generated/dataModel";
|
||||
import type { MutationCtx } from "../_generated/server";
|
||||
|
||||
const GITHUB_API = "https://api.github.com";
|
||||
const GITHUB_API_VERSION = "2022-11-28";
|
||||
const GITHUB_ORG_PAGE_SIZE = 100;
|
||||
|
||||
export const GITHUB_ORG_MEMBERSHIP_VERIFICATION_MAX_AGE_MS = 15 * 60 * 1000;
|
||||
export const GITHUB_ORG_MEMBERSHIP_SYNC_PROFILE_KEY = "githubOrgMembershipSync";
|
||||
|
||||
type FetchImpl = typeof fetch;
|
||||
|
||||
export type GitHubOrgMembership = {
|
||||
githubOrgId: string;
|
||||
login: string;
|
||||
avatarUrl?: string;
|
||||
role: "admin" | "member";
|
||||
};
|
||||
|
||||
export type GitHubOrgMembershipSync = {
|
||||
memberships: GitHubOrgMembership[];
|
||||
syncedAt: number;
|
||||
truncated: boolean;
|
||||
};
|
||||
|
||||
type GitHubMembershipPayload = {
|
||||
state?: unknown;
|
||||
role?: unknown;
|
||||
organization?: {
|
||||
id?: unknown;
|
||||
login?: unknown;
|
||||
avatar_url?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
export async function fetchActiveGitHubOrgMemberships(
|
||||
accessToken: string,
|
||||
options: { fetchImpl?: FetchImpl; now?: number } = {},
|
||||
): Promise<GitHubOrgMembershipSync> {
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
const memberships: GitHubOrgMembership[] = [];
|
||||
|
||||
for (let page = 1; ; page += 1) {
|
||||
const response = await fetchImpl(
|
||||
`${GITHUB_API}/user/memberships/orgs?state=active&per_page=${GITHUB_ORG_PAGE_SIZE}&page=${page}`,
|
||||
{
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"User-Agent": "clawhub/github-org-memberships",
|
||||
"X-GitHub-Api-Version": GITHUB_API_VERSION,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub organization membership lookup failed (${response.status})`);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as unknown;
|
||||
if (!Array.isArray(payload)) {
|
||||
throw new Error("GitHub organization membership lookup returned invalid data");
|
||||
}
|
||||
|
||||
for (const row of payload) {
|
||||
const membership = parseGitHubOrgMembership(row);
|
||||
if (!membership) continue;
|
||||
memberships.push(membership);
|
||||
}
|
||||
|
||||
if (payload.length < GITHUB_ORG_PAGE_SIZE) {
|
||||
return {
|
||||
memberships: dedupeMemberships(memberships),
|
||||
syncedAt: options.now ?? Date.now(),
|
||||
truncated: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function readGitHubOrgMembershipSync(
|
||||
profile: Record<string, unknown>,
|
||||
): GitHubOrgMembershipSync | null {
|
||||
const value = profile[GITHUB_ORG_MEMBERSHIP_SYNC_PROFILE_KEY];
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const candidate = value as Partial<GitHubOrgMembershipSync>;
|
||||
if (
|
||||
!Array.isArray(candidate.memberships) ||
|
||||
typeof candidate.syncedAt !== "number" ||
|
||||
!Number.isFinite(candidate.syncedAt) ||
|
||||
typeof candidate.truncated !== "boolean"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const memberships = candidate.memberships
|
||||
.map((membership) => parseStoredMembership(membership))
|
||||
.filter((membership): membership is GitHubOrgMembership => membership !== null);
|
||||
return {
|
||||
memberships: dedupeMemberships(memberships),
|
||||
syncedAt: candidate.syncedAt,
|
||||
truncated: candidate.truncated,
|
||||
};
|
||||
}
|
||||
|
||||
export async function replaceGitHubOrgMemberships(
|
||||
ctx: Pick<MutationCtx, "db">,
|
||||
userId: Id<"users">,
|
||||
sync: GitHubOrgMembershipSync,
|
||||
) {
|
||||
const existing = await ctx.db
|
||||
.query("githubOrgMemberships")
|
||||
.withIndex("by_user", (q) => q.eq("userId", userId))
|
||||
.collect();
|
||||
const existingByOrgId = new Map(
|
||||
existing.map((membership) => [membership.githubOrgId, membership]),
|
||||
);
|
||||
const nextIds = new Set<string>();
|
||||
|
||||
for (const membership of sync.memberships) {
|
||||
nextIds.add(membership.githubOrgId);
|
||||
const current = existingByOrgId.get(membership.githubOrgId);
|
||||
const value = {
|
||||
userId,
|
||||
githubOrgId: membership.githubOrgId,
|
||||
login: membership.login,
|
||||
avatarUrl: membership.avatarUrl,
|
||||
role: membership.role,
|
||||
syncedAt: sync.syncedAt,
|
||||
};
|
||||
if (current) {
|
||||
await ctx.db.patch(current._id, value);
|
||||
} else {
|
||||
await ctx.db.insert("githubOrgMemberships", value);
|
||||
}
|
||||
}
|
||||
|
||||
for (const membership of existing) {
|
||||
if (!nextIds.has(membership.githubOrgId)) {
|
||||
await ctx.db.delete(membership._id);
|
||||
}
|
||||
}
|
||||
|
||||
await ctx.db.patch(userId, {
|
||||
githubOrgMembershipsSyncedAt: sync.syncedAt,
|
||||
githubOrgMembershipsTruncated: sync.truncated || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function parseGitHubOrgMembership(value: unknown): GitHubOrgMembership | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const row = value as GitHubMembershipPayload;
|
||||
if (row.state !== "active" || (row.role !== "admin" && row.role !== "member")) return null;
|
||||
return parseStoredMembership({
|
||||
githubOrgId: normalizeNumericId(row.organization?.id),
|
||||
login: row.organization?.login,
|
||||
avatarUrl: row.organization?.avatar_url,
|
||||
role: row.role,
|
||||
});
|
||||
}
|
||||
|
||||
function parseStoredMembership(value: unknown): GitHubOrgMembership | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const row = value as Partial<GitHubOrgMembership>;
|
||||
const githubOrgId = normalizeNumericId(row.githubOrgId);
|
||||
const login = typeof row.login === "string" ? row.login.trim() : "";
|
||||
const role = row.role;
|
||||
if (!githubOrgId || !isGitHubLogin(login) || (role !== "admin" && role !== "member")) {
|
||||
return null;
|
||||
}
|
||||
const avatarUrl =
|
||||
typeof row.avatarUrl === "string" && isHttpsUrl(row.avatarUrl.trim())
|
||||
? row.avatarUrl.trim()
|
||||
: undefined;
|
||||
return { githubOrgId, login, avatarUrl, role };
|
||||
}
|
||||
|
||||
function normalizeNumericId(value: unknown) {
|
||||
if (typeof value === "number" && Number.isSafeInteger(value) && value > 0) return String(value);
|
||||
if (typeof value === "string" && /^[1-9]\d*$/.test(value.trim())) return value.trim();
|
||||
return null;
|
||||
}
|
||||
|
||||
function isGitHubLogin(value: string) {
|
||||
return /^[a-z\d](?:[a-z\d-]{0,37}[a-z\d])?$/i.test(value);
|
||||
}
|
||||
|
||||
function isHttpsUrl(value: string) {
|
||||
try {
|
||||
return new URL(value).protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function dedupeMemberships(memberships: GitHubOrgMembership[]) {
|
||||
return [
|
||||
...new Map(memberships.map((membership) => [membership.githubOrgId, membership])).values(),
|
||||
].sort((left, right) => left.login.localeCompare(right.login));
|
||||
}
|
||||
@@ -133,4 +133,29 @@ describe("public publisher mapping", () => {
|
||||
expect(toPublicPublisher(publisher)).not.toHaveProperty("official");
|
||||
expect(toPublicPublisher(publisher, { official: true })?.official).toBe(true);
|
||||
});
|
||||
|
||||
it("exposes a verified GitHub profile without exposing verification internals", () => {
|
||||
const publisher = {
|
||||
_id: "publishers:cua",
|
||||
_creationTime: 1,
|
||||
kind: "org",
|
||||
handle: "cua",
|
||||
displayName: "Cua",
|
||||
githubHandle: "trycua",
|
||||
githubOrgId: "42",
|
||||
githubVerifiedAt: 123,
|
||||
githubVerifiedByUserId: "users:admin",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
} as Doc<"publishers">;
|
||||
|
||||
expect(toPublicPublisher(publisher)).toEqual(
|
||||
expect.objectContaining({
|
||||
githubHandle: "trycua",
|
||||
githubVerifiedAt: 123,
|
||||
}),
|
||||
);
|
||||
expect(toPublicPublisher(publisher)).not.toHaveProperty("githubOrgId");
|
||||
expect(toPublicPublisher(publisher)).not.toHaveProperty("githubVerifiedByUserId");
|
||||
});
|
||||
});
|
||||
|
||||
+12
-1
@@ -9,7 +9,16 @@ export type PublicUser = Pick<
|
||||
|
||||
export type PublicPublisher = Pick<
|
||||
Doc<"publishers">,
|
||||
"_id" | "_creationTime" | "kind" | "handle" | "displayName" | "image" | "bio" | "linkedUserId"
|
||||
| "_id"
|
||||
| "_creationTime"
|
||||
| "kind"
|
||||
| "handle"
|
||||
| "displayName"
|
||||
| "image"
|
||||
| "bio"
|
||||
| "linkedUserId"
|
||||
| "githubHandle"
|
||||
| "githubVerifiedAt"
|
||||
> & { official?: boolean };
|
||||
|
||||
export type PublicSkillStats = {
|
||||
@@ -130,6 +139,8 @@ export function toPublicPublisher(
|
||||
image: publisher.image,
|
||||
bio: publisher.bio,
|
||||
linkedUserId: publisher.linkedUserId,
|
||||
githubHandle: publisher.githubHandle,
|
||||
githubVerifiedAt: publisher.githubVerifiedAt,
|
||||
...(options?.official ? { official: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -66,6 +66,10 @@ export const RETENTION_POLICIES = {
|
||||
retention: "Convex Auth session total duration.",
|
||||
}),
|
||||
authAccounts: permanent("Provider account links for active users."),
|
||||
githubOrgMemberships: derived(
|
||||
"Active GitHub organization memberships synced during OAuth.",
|
||||
"Reconnect the GitHub account with read:org access.",
|
||||
),
|
||||
authRefreshTokens: ephemeral("Convex Auth refresh tokens expire after inactive duration.", {
|
||||
expirationField: "expirationTime",
|
||||
expirationIndex: "by_expiration_time",
|
||||
|
||||
@@ -377,6 +377,7 @@ const updateProfileHandler = (
|
||||
image?: string;
|
||||
imageStorageId?: string;
|
||||
imageUploadTicket?: string;
|
||||
githubOrgId?: string | null;
|
||||
}>
|
||||
)._handler;
|
||||
|
||||
@@ -6354,6 +6355,138 @@ describe("publishers membership controls", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("links only a freshly verified GitHub organization membership", async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("users:admin" as never);
|
||||
const publisher = {
|
||||
_id: "publishers:org",
|
||||
kind: "org",
|
||||
handle: "cua",
|
||||
displayName: "Cua",
|
||||
image: undefined,
|
||||
bio: undefined,
|
||||
};
|
||||
const patch = vi.fn(async () => {});
|
||||
const ctx = {
|
||||
db: {
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "users:admin") return { _id: id };
|
||||
if (id === "publishers:org") return publisher;
|
||||
return null;
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
unique: vi.fn().mockResolvedValue({
|
||||
_id: "publisherMembers:admin",
|
||||
publisherId: "publishers:org",
|
||||
userId: "users:admin",
|
||||
role: "admin",
|
||||
}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (table === "githubOrgMemberships") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
unique: vi.fn().mockResolvedValue({
|
||||
userId: "users:admin",
|
||||
githubOrgId: "42",
|
||||
login: "trycua",
|
||||
role: "member",
|
||||
syncedAt: Date.now(),
|
||||
}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (table === "officialPublishers") return emptyOfficialPublishersQuery();
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
patch,
|
||||
insert: vi.fn(async () => "auditLogs:1"),
|
||||
delete: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
normalizeId: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
await updateProfileHandler(ctx as never, {
|
||||
publisherId: "publishers:org",
|
||||
displayName: "Cua",
|
||||
githubOrgId: "42",
|
||||
});
|
||||
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"publishers:org",
|
||||
expect.objectContaining({
|
||||
githubHandle: "trycua",
|
||||
githubOrgId: "42",
|
||||
githubVerifiedAt: expect.any(Number),
|
||||
githubVerifiedByUserId: "users:admin",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects stale GitHub organization membership verification", async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("users:admin" as never);
|
||||
const ctx = {
|
||||
db: {
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "users:admin") return { _id: id };
|
||||
if (id === "publishers:org") {
|
||||
return {
|
||||
_id: id,
|
||||
kind: "org",
|
||||
handle: "cua",
|
||||
displayName: "Cua",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
unique: vi.fn().mockResolvedValue({
|
||||
publisherId: "publishers:org",
|
||||
userId: "users:admin",
|
||||
role: "admin",
|
||||
}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (table === "githubOrgMemberships") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
unique: vi.fn().mockResolvedValue({
|
||||
userId: "users:admin",
|
||||
githubOrgId: "42",
|
||||
login: "trycua",
|
||||
role: "member",
|
||||
syncedAt: Date.now() - 16 * 60 * 1000,
|
||||
}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
patch: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
normalizeId: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
updateProfileHandler(ctx as never, {
|
||||
publisherId: "publishers:org",
|
||||
displayName: "Cua",
|
||||
githubOrgId: "42",
|
||||
}),
|
||||
).rejects.toThrow("Reconnect GitHub to verify your organization membership");
|
||||
});
|
||||
|
||||
it("issues logo upload tickets only to org admins", async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("users:admin" as never);
|
||||
const insert = vi.fn(async () => "publisherImageUploadTickets:1");
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Doc, Id } from "./_generated/dataModel";
|
||||
import type { MutationCtx, QueryCtx } from "./_generated/server";
|
||||
import { action, internalMutation, internalQuery, mutation, query } from "./functions";
|
||||
import { assertAdmin, getOptionalActiveAuthUserId, requireUser } from "./lib/access";
|
||||
import { GITHUB_ORG_MEMBERSHIP_VERIFICATION_MAX_AGE_MS } from "./lib/githubOrgMemberships";
|
||||
import { isPublicSkillDoc } from "./lib/globalStats";
|
||||
import { isOfficialPublisher, toPublicPublisherWithOfficial } from "./lib/officialPublishers";
|
||||
import { extractPackageDigestFields, upsertPackageSearchDigest } from "./lib/packageSearchDigest";
|
||||
@@ -2273,6 +2274,7 @@ export const listMine = query({
|
||||
publisher: {
|
||||
...(includePublishedItems ? publicPublisher : withoutPublishedItems(publicPublisher)),
|
||||
imageStorageId: publisher?.imageStorageId,
|
||||
githubOrgId: publisher?.githubOrgId,
|
||||
},
|
||||
role: publisher?.kind === "user" ? "owner" : membership.role,
|
||||
};
|
||||
@@ -2296,6 +2298,7 @@ export const listMine = query({
|
||||
publisher: {
|
||||
...(includePublishedItems ? personalPublisher : withoutPublishedItems(personalPublisher)),
|
||||
imageStorageId: personalPublisherDoc?.imageStorageId,
|
||||
githubOrgId: personalPublisherDoc?.githubOrgId,
|
||||
},
|
||||
role: "owner",
|
||||
});
|
||||
@@ -3095,6 +3098,7 @@ export const updateProfile = mutation({
|
||||
image: v.optional(v.string()),
|
||||
imageStorageId: v.optional(v.id("_storage")),
|
||||
imageUploadTicket: v.optional(v.id("publisherImageUploadTickets")),
|
||||
githubOrgId: v.optional(v.union(v.string(), v.null())),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const { userId } = await requireUser(ctx);
|
||||
@@ -3178,11 +3182,59 @@ export const updateProfile = mutation({
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
let githubPatch:
|
||||
| {
|
||||
githubHandle: string;
|
||||
githubOrgId: string;
|
||||
githubVerifiedAt: number;
|
||||
githubVerifiedByUserId: Id<"users">;
|
||||
}
|
||||
| {
|
||||
githubHandle: undefined;
|
||||
githubOrgId: undefined;
|
||||
githubVerifiedAt: undefined;
|
||||
githubVerifiedByUserId: undefined;
|
||||
}
|
||||
| undefined;
|
||||
if (args.githubOrgId === null) {
|
||||
githubPatch = {
|
||||
githubHandle: undefined,
|
||||
githubOrgId: undefined,
|
||||
githubVerifiedAt: undefined,
|
||||
githubVerifiedByUserId: undefined,
|
||||
};
|
||||
} else if (args.githubOrgId !== undefined) {
|
||||
const githubOrgId = args.githubOrgId.trim();
|
||||
if (!/^[1-9]\d*$/.test(githubOrgId)) {
|
||||
throw new ConvexError("Select a GitHub organization from your connected account");
|
||||
}
|
||||
const githubMembership = await ctx.db
|
||||
.query("githubOrgMemberships")
|
||||
.withIndex("by_user_and_github_org", (q) =>
|
||||
q.eq("userId", userId).eq("githubOrgId", githubOrgId),
|
||||
)
|
||||
.unique();
|
||||
if (
|
||||
!githubMembership ||
|
||||
now - githubMembership.syncedAt > GITHUB_ORG_MEMBERSHIP_VERIFICATION_MAX_AGE_MS
|
||||
) {
|
||||
throw new ConvexError("Reconnect GitHub to verify your organization membership");
|
||||
}
|
||||
githubPatch = {
|
||||
githubHandle: githubMembership.login,
|
||||
githubOrgId: githubMembership.githubOrgId,
|
||||
githubVerifiedAt: now,
|
||||
githubVerifiedByUserId: userId,
|
||||
};
|
||||
}
|
||||
const nextGithubHandle = githubPatch ? githubPatch.githubHandle : publisher.githubHandle;
|
||||
const nextGithubOrgId = githubPatch ? githubPatch.githubOrgId : publisher.githubOrgId;
|
||||
await ctx.db.patch(publisher._id, {
|
||||
displayName,
|
||||
bio,
|
||||
image: imageUrl,
|
||||
imageStorageId,
|
||||
...githubPatch,
|
||||
updatedAt: now,
|
||||
});
|
||||
if (
|
||||
@@ -3202,6 +3254,8 @@ export const updateProfile = mutation({
|
||||
bio,
|
||||
image: imageUrl,
|
||||
imageStorageId,
|
||||
githubHandle: nextGithubHandle,
|
||||
githubOrgId: nextGithubOrgId,
|
||||
},
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
@@ -173,6 +173,8 @@ const users = defineTable({
|
||||
githubCreatedAt: v.optional(v.number()),
|
||||
githubFetchedAt: v.optional(v.number()),
|
||||
githubProfileSyncedAt: v.optional(v.number()),
|
||||
githubOrgMembershipsSyncedAt: v.optional(v.number()),
|
||||
githubOrgMembershipsTruncated: v.optional(v.boolean()),
|
||||
trustedPublisher: v.optional(v.boolean()),
|
||||
publishedSkills: v.optional(v.number()),
|
||||
totalStars: v.optional(v.number()),
|
||||
@@ -218,6 +220,10 @@ const publishers = defineTable({
|
||||
bio: v.optional(v.string()),
|
||||
image: v.optional(v.string()),
|
||||
imageStorageId: v.optional(v.id("_storage")),
|
||||
githubHandle: v.optional(v.string()),
|
||||
githubOrgId: v.optional(v.string()),
|
||||
githubVerifiedAt: v.optional(v.number()),
|
||||
githubVerifiedByUserId: v.optional(v.id("users")),
|
||||
linkedUserId: v.optional(v.id("users")),
|
||||
trustedPublisher: v.optional(v.boolean()),
|
||||
publishedSkills: v.optional(v.number()),
|
||||
@@ -254,6 +260,17 @@ const publishers = defineTable({
|
||||
"updatedAt",
|
||||
]);
|
||||
|
||||
const githubOrgMemberships = defineTable({
|
||||
userId: v.id("users"),
|
||||
githubOrgId: v.string(),
|
||||
login: v.string(),
|
||||
avatarUrl: v.optional(v.string()),
|
||||
role: v.union(v.literal("admin"), v.literal("member")),
|
||||
syncedAt: v.number(),
|
||||
})
|
||||
.index("by_user", ["userId"])
|
||||
.index("by_user_and_github_org", ["userId", "githubOrgId"]);
|
||||
|
||||
const publisherMembers = defineTable({
|
||||
publisherId: v.id("publishers"),
|
||||
userId: v.id("users"),
|
||||
@@ -3329,6 +3346,7 @@ export default defineSchema({
|
||||
authRefreshTokens,
|
||||
users,
|
||||
publishers,
|
||||
githubOrgMemberships,
|
||||
publisherMembers,
|
||||
publisherInvites,
|
||||
publisherImageUploadTickets,
|
||||
|
||||
+18
-1
@@ -333,11 +333,21 @@ function makeDevPersonaCtx() {
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "githubOrgMemberships") {
|
||||
return {
|
||||
withIndex: vi.fn((name: string) => {
|
||||
if (name !== "by_user") {
|
||||
throw new Error(`Unexpected githubOrgMemberships index ${name}`);
|
||||
}
|
||||
return { collect: vi.fn(async () => []) };
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected table ${table}`);
|
||||
});
|
||||
|
||||
return {
|
||||
ctx: { db: { patch, get, insert, query, normalizeId: vi.fn() } } as never,
|
||||
ctx: { db: { patch, get, insert, query, delete: vi.fn(), normalizeId: vi.fn() } } as never,
|
||||
auditLogs,
|
||||
inserts,
|
||||
patches,
|
||||
@@ -1996,6 +2006,13 @@ describe("users profile audit logs", () => {
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "githubOrgMemberships") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
collect: vi.fn(async () => []),
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "authAccounts" || table === "authSessions") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
|
||||
+54
-1
@@ -73,6 +73,7 @@ type DeletedAccountCleanupResult = {
|
||||
authVerificationCodes: number;
|
||||
authSessions: number;
|
||||
authRefreshTokens: number;
|
||||
githubOrgMemberships: number;
|
||||
apiTokens: number;
|
||||
personalPublisherDeleted: boolean;
|
||||
};
|
||||
@@ -360,6 +361,11 @@ async function hardDeleteSelfDeletedAccountState(
|
||||
.withIndex("by_user", (q) => q.eq("userId", user._id))
|
||||
.collect();
|
||||
for (const token of tokens) await ctx.db.delete(token._id);
|
||||
const githubOrgMemberships = await ctx.db
|
||||
.query("githubOrgMemberships")
|
||||
.withIndex("by_user", (q) => q.eq("userId", user._id))
|
||||
.collect();
|
||||
for (const membership of githubOrgMemberships) await ctx.db.delete(membership._id);
|
||||
|
||||
const personalPublisher = user.personalPublisherId
|
||||
? await ctx.db.get(user.personalPublisherId)
|
||||
@@ -405,7 +411,12 @@ async function hardDeleteSelfDeletedAccountState(
|
||||
});
|
||||
await ctx.runMutation(internal.telemetry.clearUserTelemetryInternal, { userId: user._id });
|
||||
const authState = await purgeAuthStateForUser(ctx, user._id);
|
||||
return { ...authState, apiTokens: tokens.length, personalPublisherDeleted };
|
||||
return {
|
||||
...authState,
|
||||
githubOrgMemberships: githubOrgMemberships.length,
|
||||
apiTokens: tokens.length,
|
||||
personalPublisherDeleted,
|
||||
};
|
||||
}
|
||||
|
||||
async function scrubDeletedUserTombstone(ctx: MutationCtx, user: Doc<"users">, deletedAt: number) {
|
||||
@@ -426,6 +437,8 @@ async function scrubDeletedUserTombstone(ctx: MutationCtx, user: Doc<"users">, d
|
||||
isAnonymous: undefined,
|
||||
bio: undefined,
|
||||
githubCreatedAt: undefined,
|
||||
githubOrgMembershipsSyncedAt: undefined,
|
||||
githubOrgMembershipsTruncated: undefined,
|
||||
updatedAt: deletedAt,
|
||||
});
|
||||
}
|
||||
@@ -463,6 +476,20 @@ const DEV_OFFICIAL_ORG = {
|
||||
displayName: "Local Official Org",
|
||||
reason: "dev-persona.official-org-member",
|
||||
} as const;
|
||||
const DEV_GITHUB_ORGS = [
|
||||
{
|
||||
githubOrgId: "100000001",
|
||||
login: "openclaw",
|
||||
avatarUrl: "https://avatars.githubusercontent.com/u/188567264?v=4",
|
||||
role: "admin" as const,
|
||||
},
|
||||
{
|
||||
githubOrgId: "100000002",
|
||||
login: "trycua",
|
||||
avatarUrl: "https://avatars.githubusercontent.com/u/175698028?v=4",
|
||||
role: "member" as const,
|
||||
},
|
||||
];
|
||||
|
||||
type DevPersona = keyof typeof DEV_PERSONAS;
|
||||
|
||||
@@ -540,11 +567,35 @@ export const upsertDevPersonaInternal = internalMutation({
|
||||
});
|
||||
if (args.persona === "officialOrgMember") {
|
||||
await ensureDevOfficialOrgMembership(ctx, user, now);
|
||||
await replaceDevGitHubOrgMemberships(ctx, user._id, now);
|
||||
}
|
||||
return userId;
|
||||
},
|
||||
});
|
||||
|
||||
async function replaceDevGitHubOrgMemberships(
|
||||
ctx: MutationCtx,
|
||||
userId: Id<"users">,
|
||||
syncedAt: number,
|
||||
) {
|
||||
const existing = await ctx.db
|
||||
.query("githubOrgMemberships")
|
||||
.withIndex("by_user", (q) => q.eq("userId", userId))
|
||||
.collect();
|
||||
for (const membership of existing) await ctx.db.delete(membership._id);
|
||||
for (const membership of DEV_GITHUB_ORGS) {
|
||||
await ctx.db.insert("githubOrgMemberships", {
|
||||
userId,
|
||||
...membership,
|
||||
syncedAt,
|
||||
});
|
||||
}
|
||||
await ctx.db.patch(userId, {
|
||||
githubOrgMembershipsSyncedAt: syncedAt,
|
||||
githubOrgMembershipsTruncated: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureDevOfficialOrgMembership(ctx: MutationCtx, user: Doc<"users">, now: number) {
|
||||
let publisher = await getPublisherByHandle(ctx, DEV_OFFICIAL_ORG.handle);
|
||||
let publisherId = publisher?._id;
|
||||
@@ -1071,6 +1122,8 @@ export const deleteAccount = mutation({
|
||||
isAnonymous: undefined,
|
||||
bio: undefined,
|
||||
githubCreatedAt: undefined,
|
||||
githubOrgMembershipsSyncedAt: undefined,
|
||||
githubOrgMembershipsTruncated: undefined,
|
||||
updatedAt: now,
|
||||
});
|
||||
await ctx.db.insert("auditLogs", {
|
||||
|
||||
+20
-18
@@ -27,6 +27,8 @@ const FINGERPRINT_SALT_LINES = [
|
||||
"### Local browser release evidence and storage handoff notes",
|
||||
] as const;
|
||||
|
||||
type LocalAuthPersona = DevPersona | "officialOrgMember";
|
||||
|
||||
function hashFixtureInput(value: string) {
|
||||
let hash = 0;
|
||||
for (const char of value) {
|
||||
@@ -273,15 +275,17 @@ export async function completeMockPrePublicationChecks(args: {
|
||||
return { ...completion, claim };
|
||||
}
|
||||
|
||||
function devPersonaHeaderPattern(persona: DevPersona, expectedHandle: string) {
|
||||
function devPersonaHeaderPattern(persona: LocalAuthPersona, expectedHandle: string) {
|
||||
const displayName =
|
||||
persona === "owner"
|
||||
? "Local Owner"
|
||||
: persona === "user"
|
||||
? "Local User"
|
||||
: persona === "abusePublisher"
|
||||
? "Local Abuse Test Publisher"
|
||||
: "Local Admin";
|
||||
: persona === "officialOrgMember"
|
||||
? "Local Official Org Member"
|
||||
: persona === "abusePublisher"
|
||||
? "Local Abuse Test Publisher"
|
||||
: "Local Admin";
|
||||
const displayNamePattern =
|
||||
persona === "abusePublisher"
|
||||
? `${escapeRegExp("Local Abuse Test Publishe")}.*`
|
||||
@@ -293,8 +297,9 @@ function devPersonaHeaderPattern(persona: DevPersona, expectedHandle: string) {
|
||||
return new RegExp(`@(?:${exactHandle}|${displayNamePattern})`, "i");
|
||||
}
|
||||
|
||||
function devPersonaMenuLabel(persona: DevPersona) {
|
||||
function devPersonaMenuLabel(persona: LocalAuthPersona) {
|
||||
if (persona === "abusePublisher") return "abuse publisher";
|
||||
if (persona === "officialOrgMember") return "org member";
|
||||
return persona;
|
||||
}
|
||||
|
||||
@@ -309,12 +314,14 @@ function parseSkillDetailPath(pathname: string) {
|
||||
throw new Error(`Expected skill detail path, received ${pathname}`);
|
||||
}
|
||||
|
||||
function devPersonaHandle(persona: DevPersona) {
|
||||
function devPersonaHandle(persona: LocalAuthPersona) {
|
||||
return persona === "owner"
|
||||
? "local"
|
||||
: persona === "abusePublisher"
|
||||
? "local-abuse"
|
||||
: `local-${persona}`;
|
||||
: persona === "officialOrgMember"
|
||||
? "local-official-member"
|
||||
: persona === "abusePublisher"
|
||||
? "local-abuse"
|
||||
: `local-${persona}`;
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -358,20 +365,15 @@ export function escapeRegExp(value: string) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
export async function expectLocalPersonaActive(page: Page, persona: DevPersona) {
|
||||
const expectedHandle =
|
||||
persona === "owner"
|
||||
? "local"
|
||||
: persona === "abusePublisher"
|
||||
? "local-abuse"
|
||||
: `local-${persona}`;
|
||||
export async function expectLocalPersonaActive(page: Page, persona: LocalAuthPersona) {
|
||||
const expectedHandle = devPersonaHandle(persona);
|
||||
await expect(page.locator("header .user-trigger")).toContainText(
|
||||
devPersonaHeaderPattern(persona, expectedHandle),
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
}
|
||||
|
||||
export async function signInAsLocalPersona(page: Page, persona: DevPersona) {
|
||||
export async function signInAsLocalPersona(page: Page, persona: LocalAuthPersona) {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||
try {
|
||||
@@ -512,7 +514,7 @@ async function waitForPublishSkillMetadataForm(page: Page) {
|
||||
await page.getByTestId("upload-input").waitFor({ state: "attached", timeout: 15_000 });
|
||||
}
|
||||
|
||||
export async function signInAsLocalPublisher(page: Page, persona: DevPersona) {
|
||||
export async function signInAsLocalPublisher(page: Page, persona: LocalAuthPersona) {
|
||||
await signInAsLocalPersona(page, persona);
|
||||
await page.goto("/skills/publish", { waitUntil: "domcontentloaded" });
|
||||
await waitForPublishSkillForm(page);
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expectHealthyPage, trackRuntimeErrors, waitForHydration } from "../helpers/runtimeErrors";
|
||||
import { signInAsLocalPersona } from "./helpers";
|
||||
|
||||
test.skip(
|
||||
process.env.VITE_ENABLE_DEV_AUTH !== "1",
|
||||
"publisher GitHub profile tests require the local dev auth runner",
|
||||
);
|
||||
|
||||
test.setTimeout(180_000);
|
||||
|
||||
test("org admins can link a verified GitHub organization to the public profile", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const errors = trackRuntimeErrors(page);
|
||||
|
||||
await signInAsLocalPersona(page, "officialOrgMember");
|
||||
errors.length = 0;
|
||||
|
||||
await page.goto("/settings?view=organizations", { waitUntil: "domcontentloaded" });
|
||||
await waitForHydration(page);
|
||||
|
||||
const githubOrgSelect = page.getByRole("combobox", { name: "GitHub organization" });
|
||||
await expect(githubOrgSelect).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
if ((await githubOrgSelect.textContent())?.includes("@trycua")) {
|
||||
await githubOrgSelect.click();
|
||||
await page.getByRole("option", { name: "No GitHub organization" }).click();
|
||||
await page.getByRole("button", { name: "Save changes" }).click();
|
||||
await expect(page.getByText("Organization updated", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
await githubOrgSelect.click();
|
||||
await page.getByRole("option", { name: "@trycua · member" }).click();
|
||||
await page.getByRole("button", { name: "Save changes" }).click();
|
||||
await expect(page.getByText("Organization updated", { exact: true })).toBeVisible();
|
||||
|
||||
await page.goto("/local-official-org", { waitUntil: "domcontentloaded" });
|
||||
await waitForHydration(page);
|
||||
|
||||
const githubLink = page.getByRole("link", { name: "GitHub · @trycua" });
|
||||
await expect(githubLink).toHaveAttribute("href", "https://github.com/trycua");
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("publisher-github-profile.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
|
||||
await expectHealthyPage(page, errors);
|
||||
});
|
||||
@@ -40,6 +40,20 @@ derive the actor server-side from Convex Auth (`getAuthUserId` via
|
||||
`requireUser`/`getOptionalActiveAuthUserId`). They must not accept client-supplied
|
||||
user ids, usernames, handles, or emails for authorization.
|
||||
|
||||
## Organization GitHub profile verification
|
||||
|
||||
Organization publisher GitHub links are profile metadata, not ClawHub account
|
||||
identity or artifact provenance. GitHub OAuth requests `read:org` and uses the
|
||||
access token only during the OAuth callback to fetch the signed-in user's active
|
||||
organization memberships. ClawHub stores the resulting immutable GitHub
|
||||
organization ids and current logins, but never stores the provider access token.
|
||||
|
||||
Changing an organization publisher's GitHub link requires both ClawHub
|
||||
owner/admin access and a fresh server-side GitHub membership snapshot for the
|
||||
selected immutable organization id. The stored login may determine the public
|
||||
profile URL, but it must not grant publishing authority, Official status,
|
||||
trusted-publishing authority, or source/artifact provenance.
|
||||
|
||||
Staff recovery for a personal publisher whose GitHub principal is no longer
|
||||
accessible must not rewrite or merge Convex Auth `authAccounts` rows. The only
|
||||
supported permanent recovery path is an admin-only personal publisher recovery
|
||||
|
||||
@@ -70,6 +70,7 @@ const publisher = {
|
||||
handle: "nvidia",
|
||||
image: null,
|
||||
kind: "org" as const,
|
||||
githubHandle: "nvidia",
|
||||
official: true,
|
||||
publishedItems: [],
|
||||
stats: {
|
||||
@@ -127,6 +128,24 @@ describe("user profile route", () => {
|
||||
expect(screen.getByRole("link", { name: /github/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not guess a GitHub profile for an unlinked organization", async () => {
|
||||
const unlinkedPublisher = { ...publisher, githubHandle: undefined };
|
||||
loaderDataMock.mockReturnValue({ publisher: unlinkedPublisher });
|
||||
queryMock.mockImplementation((_query, args: Record<string, unknown> | "skip") => {
|
||||
if (args === "skip") return undefined;
|
||||
if ("publisherHandle" in args) return { publisher: unlinkedPublisher, members: [] };
|
||||
if ("kind" in args) return null;
|
||||
return unlinkedPublisher;
|
||||
});
|
||||
|
||||
const route = await loadRoute();
|
||||
const Component = route.__config.component as ComponentType;
|
||||
|
||||
render(<Component />);
|
||||
|
||||
expect(screen.queryByRole("link", { name: /github/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("shows edit profile instead of report on the viewer's own publisher page", async () => {
|
||||
const personalPublisher = {
|
||||
...publisher,
|
||||
|
||||
+10
-1
@@ -7,7 +7,16 @@ export type PublicUser = Pick<
|
||||
|
||||
export type PublicPublisher = Pick<
|
||||
Doc<"publishers">,
|
||||
"_id" | "_creationTime" | "kind" | "handle" | "displayName" | "image" | "bio" | "linkedUserId"
|
||||
| "_id"
|
||||
| "_creationTime"
|
||||
| "kind"
|
||||
| "handle"
|
||||
| "displayName"
|
||||
| "image"
|
||||
| "bio"
|
||||
| "linkedUserId"
|
||||
| "githubHandle"
|
||||
| "githubVerifiedAt"
|
||||
> & { official?: boolean };
|
||||
|
||||
type PublicPublisherStats = {
|
||||
|
||||
@@ -65,6 +65,8 @@ const orgMembership = {
|
||||
kind: "org",
|
||||
image: null,
|
||||
bio: "OpenClaw publisher",
|
||||
githubHandle: null as string | null,
|
||||
githubOrgId: null as string | null,
|
||||
official: true,
|
||||
},
|
||||
role: "owner",
|
||||
@@ -78,6 +80,8 @@ const personalMembership = {
|
||||
kind: "user",
|
||||
image: null,
|
||||
bio: null,
|
||||
githubHandle: null as string | null,
|
||||
githubOrgId: null as string | null,
|
||||
official: false,
|
||||
},
|
||||
role: "owner",
|
||||
@@ -136,6 +140,11 @@ function mockSignedInSettings({
|
||||
githubSources = [],
|
||||
pendingInvites = [],
|
||||
myInvites = [],
|
||||
githubOrgMemberships = {
|
||||
syncedAt: null,
|
||||
truncated: false,
|
||||
memberships: [],
|
||||
},
|
||||
membersLoading = false,
|
||||
deletionInventoryLoading = false,
|
||||
}: {
|
||||
@@ -146,6 +155,17 @@ function mockSignedInSettings({
|
||||
deletionInventoryLoading?: boolean;
|
||||
pendingInvites?: PublisherInviteFixture[];
|
||||
myInvites?: PublisherInviteFixture[];
|
||||
githubOrgMemberships?: {
|
||||
syncedAt: number | null;
|
||||
truncated: boolean;
|
||||
memberships: Array<{
|
||||
githubOrgId: string;
|
||||
login: string;
|
||||
avatarUrl: string | null;
|
||||
role: "admin" | "member";
|
||||
syncedAt: number;
|
||||
}>;
|
||||
};
|
||||
githubSources?: Array<{
|
||||
_id: string;
|
||||
repo: string;
|
||||
@@ -198,6 +218,7 @@ function mockSignedInSettings({
|
||||
if (args === "skip") return undefined;
|
||||
if (queryName === "tokens:listMine") return [];
|
||||
if (queryName === "publishers:listMine") return memberships;
|
||||
if (queryName === "githubOrgMemberships:listMine") return githubOrgMemberships;
|
||||
if (queryName === "publishers:getDeletionInventory") {
|
||||
return deletionInventoryLoading ? undefined : [];
|
||||
}
|
||||
@@ -223,6 +244,7 @@ function getLastQueryArgs(functionName: string) {
|
||||
|
||||
describe("Settings", () => {
|
||||
beforeEach(() => {
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
window.history.replaceState(null, "", "/settings");
|
||||
useQueryMock.mockReset();
|
||||
useMutationMock.mockReset();
|
||||
@@ -316,6 +338,199 @@ describe("Settings", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("connects GitHub from organization settings when memberships are unavailable", () => {
|
||||
const signIn = vi.fn().mockResolvedValue(undefined);
|
||||
useAuthActionsMock.mockReturnValue({
|
||||
signIn,
|
||||
signOut: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
mockSignedInSettings({ search: { view: "organizations" } });
|
||||
|
||||
render(<Settings />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Connect GitHub organizations" }));
|
||||
|
||||
expect(signIn).toHaveBeenCalledWith("github", {
|
||||
redirectTo: "/settings?view=organizations&ownerHandle=openclaw",
|
||||
});
|
||||
});
|
||||
|
||||
it("selects a verified GitHub organization and saves its immutable id", async () => {
|
||||
const updateOrgProfile = vi.fn().mockResolvedValue({ ok: true });
|
||||
useMutationMock.mockImplementation((mutation) =>
|
||||
getFunctionName(mutation) === "publishers:updateProfile" ? updateOrgProfile : vi.fn(),
|
||||
);
|
||||
const syncedAt = Date.now();
|
||||
mockSignedInSettings({
|
||||
search: { view: "organizations" },
|
||||
githubOrgMemberships: {
|
||||
syncedAt,
|
||||
truncated: false,
|
||||
memberships: [
|
||||
{
|
||||
githubOrgId: "42",
|
||||
login: "trycua",
|
||||
avatarUrl: null,
|
||||
role: "member",
|
||||
syncedAt,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
render(<Settings />);
|
||||
|
||||
fireEvent.click(screen.getByLabelText("GitHub organization"));
|
||||
fireEvent.click(await screen.findByText("@trycua · member"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateOrgProfile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
publisherId: "publisher_openclaw",
|
||||
githubOrgId: "42",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("refreshes a linked GitHub organization handle after a rename", async () => {
|
||||
const updateOrgProfile = vi.fn().mockResolvedValue({ ok: true });
|
||||
useMutationMock.mockImplementation((mutation) =>
|
||||
getFunctionName(mutation) === "publishers:updateProfile" ? updateOrgProfile : vi.fn(),
|
||||
);
|
||||
const syncedAt = Date.now();
|
||||
mockSignedInSettings({
|
||||
search: { view: "organizations" },
|
||||
memberships: [
|
||||
{
|
||||
...orgMembership,
|
||||
publisher: {
|
||||
...orgMembership.publisher,
|
||||
githubHandle: "old-cua",
|
||||
githubOrgId: "42",
|
||||
},
|
||||
},
|
||||
],
|
||||
githubOrgMemberships: {
|
||||
syncedAt,
|
||||
truncated: false,
|
||||
memberships: [
|
||||
{
|
||||
githubOrgId: "42",
|
||||
login: "trycua",
|
||||
avatarUrl: null,
|
||||
role: "member",
|
||||
syncedAt,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
render(<Settings />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateOrgProfile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
publisherId: "publisher_openclaw",
|
||||
githubOrgId: "42",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("saves unrelated profile changes when the linked GitHub organization is unavailable", async () => {
|
||||
const updateOrgProfile = vi.fn().mockResolvedValue({ ok: true });
|
||||
useMutationMock.mockImplementation((mutation) =>
|
||||
getFunctionName(mutation) === "publishers:updateProfile" ? updateOrgProfile : vi.fn(),
|
||||
);
|
||||
mockSignedInSettings({
|
||||
search: { view: "organizations" },
|
||||
memberships: [
|
||||
{
|
||||
...orgMembership,
|
||||
publisher: {
|
||||
...orgMembership.publisher,
|
||||
githubHandle: "trycua",
|
||||
githubOrgId: "42",
|
||||
},
|
||||
},
|
||||
],
|
||||
githubOrgMemberships: {
|
||||
syncedAt: Date.now(),
|
||||
truncated: false,
|
||||
memberships: [],
|
||||
},
|
||||
});
|
||||
|
||||
render(<Settings />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Display name"), {
|
||||
target: { value: "Renamed publisher" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateOrgProfile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
displayName: "Renamed publisher",
|
||||
githubOrgId: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("allows removing a GitHub organization after membership verification expires", async () => {
|
||||
const updateOrgProfile = vi.fn().mockResolvedValue({ ok: true });
|
||||
useMutationMock.mockImplementation((mutation) =>
|
||||
getFunctionName(mutation) === "publishers:updateProfile" ? updateOrgProfile : vi.fn(),
|
||||
);
|
||||
const staleSyncedAt = Date.now() - 16 * 60 * 1000;
|
||||
mockSignedInSettings({
|
||||
search: { view: "organizations" },
|
||||
memberships: [
|
||||
{
|
||||
...orgMembership,
|
||||
publisher: {
|
||||
...orgMembership.publisher,
|
||||
githubHandle: "trycua",
|
||||
githubOrgId: "42",
|
||||
},
|
||||
},
|
||||
],
|
||||
githubOrgMemberships: {
|
||||
syncedAt: staleSyncedAt,
|
||||
truncated: false,
|
||||
memberships: [
|
||||
{
|
||||
githubOrgId: "42",
|
||||
login: "trycua",
|
||||
avatarUrl: null,
|
||||
role: "member",
|
||||
syncedAt: staleSyncedAt,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
render(<Settings />);
|
||||
|
||||
fireEvent.click(screen.getByLabelText("GitHub organization"));
|
||||
fireEvent.click(await screen.findByText("No GitHub organization"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateOrgProfile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
publisherId: "publisher_openclaw",
|
||||
githubOrgId: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("lets organization owners confirm org deletion", async () => {
|
||||
const deleteOrg = vi.fn().mockResolvedValue({ deleted: true });
|
||||
useMutationMock.mockReturnValue(deleteOrg);
|
||||
|
||||
+161
-1
@@ -16,6 +16,7 @@ import {
|
||||
Package,
|
||||
Palette,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Save,
|
||||
Send,
|
||||
ShieldAlert,
|
||||
@@ -113,6 +114,9 @@ type PublisherMembership = {
|
||||
image?: string | null;
|
||||
imageStorageId?: Id<"_storage"> | null;
|
||||
bio?: string | null;
|
||||
githubHandle?: string | null;
|
||||
githubOrgId?: string | null;
|
||||
githubVerifiedAt?: number | null;
|
||||
official?: boolean;
|
||||
stats?: {
|
||||
skills: number;
|
||||
@@ -125,6 +129,18 @@ type PublisherMembership = {
|
||||
role: "owner" | "admin" | "publisher";
|
||||
};
|
||||
|
||||
type GitHubOrgMembershipsResult = {
|
||||
syncedAt: number | null;
|
||||
truncated: boolean;
|
||||
memberships: Array<{
|
||||
githubOrgId: string;
|
||||
login: string;
|
||||
avatarUrl: string | null;
|
||||
role: "admin" | "member";
|
||||
syncedAt: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
type PublisherDeletionInventory = {
|
||||
handle: string;
|
||||
stats: {
|
||||
@@ -253,7 +269,7 @@ const themeToggleItemClass =
|
||||
|
||||
export function Settings() {
|
||||
const navigate = useNavigate();
|
||||
const { signOut } = useAuthActions();
|
||||
const { signIn, signOut } = useAuthActions();
|
||||
const { isAuthenticated, isLoading: isAuthLoading, me } = useAuthStatus();
|
||||
const updateProfile = useMutation(api.users.updateProfile);
|
||||
const deleteAccount = useMutation(api.users.deleteAccount);
|
||||
@@ -269,6 +285,10 @@ export function Settings() {
|
||||
api.publishers.listMine,
|
||||
shouldLoadAccountScopedQueries ? { includePublishedItems: false } : "skip",
|
||||
) as Array<PublisherMembership> | undefined;
|
||||
const githubOrgMemberships = useQuery(
|
||||
api.githubOrgMemberships.listMine,
|
||||
shouldLoadAccountScopedQueries ? {} : "skip",
|
||||
) as GitHubOrgMembershipsResult | undefined;
|
||||
const createOrg = useMutation(api.publishers.createOrg);
|
||||
const deleteOrg = useMutation(api.publishers.deleteOrg);
|
||||
const createOrgImageUpload = useMutation(api.publishers.createImageUpload);
|
||||
@@ -295,6 +315,7 @@ export function Settings() {
|
||||
const [selectedOrgImage, setSelectedOrgImage] = useState("");
|
||||
const [selectedOrgImageFile, setSelectedOrgImageFile] = useState<File | null>(null);
|
||||
const [selectedOrgImagePreview, setSelectedOrgImagePreview] = useState<string | null>(null);
|
||||
const [selectedGitHubOrgId, setSelectedGitHubOrgId] = useState("");
|
||||
const [isUploadingOrgImage, setIsUploadingOrgImage] = useState(false);
|
||||
const [selectedSourcePublisherId, setSelectedSourcePublisherId] = useState("");
|
||||
const [githubRepo, setGithubRepo] = useState("");
|
||||
@@ -334,10 +355,25 @@ export function Settings() {
|
||||
null;
|
||||
const selectedOrg =
|
||||
orgs.find((entry) => entry.publisher.handle === selectedOrgHandle) ?? orgs[0] ?? null;
|
||||
const githubOrgMembershipsFresh = Boolean(
|
||||
githubOrgMemberships?.syncedAt && Date.now() - githubOrgMemberships.syncedAt <= 15 * 60 * 1000,
|
||||
);
|
||||
const selectedGitHubOrgMembership = githubOrgMemberships?.memberships.find(
|
||||
(membership) => membership.githubOrgId === selectedGitHubOrgId,
|
||||
);
|
||||
const linkedGitHubHandleNeedsRefresh = Boolean(
|
||||
githubOrgMembershipsFresh &&
|
||||
selectedGitHubOrgId &&
|
||||
selectedGitHubOrgId === selectedOrg?.publisher.githubOrgId &&
|
||||
selectedGitHubOrgMembership &&
|
||||
selectedGitHubOrgMembership?.login !== selectedOrg.publisher.githubHandle,
|
||||
);
|
||||
const hasOrgProfileChanges = selectedOrg
|
||||
? selectedOrgDisplayName !== (selectedOrg.publisher.displayName ?? "") ||
|
||||
selectedOrgBio !== (selectedOrg.publisher.bio ?? "") ||
|
||||
selectedOrgImage !== (selectedOrg.publisher.image ?? "") ||
|
||||
selectedGitHubOrgId !== (selectedOrg.publisher.githubOrgId ?? "") ||
|
||||
linkedGitHubHandleNeedsRefresh ||
|
||||
selectedOrgImageFile !== null
|
||||
: false;
|
||||
const hasProfileChanges = me
|
||||
@@ -427,12 +463,14 @@ export function Settings() {
|
||||
setSelectedOrgDisplayName("");
|
||||
setSelectedOrgBio("");
|
||||
setSelectedOrgImage("");
|
||||
setSelectedGitHubOrgId("");
|
||||
setSelectedOrgImageFile(null);
|
||||
return;
|
||||
}
|
||||
setSelectedOrgDisplayName(selectedOrg.publisher.displayName ?? "");
|
||||
setSelectedOrgBio(selectedOrg.publisher.bio ?? "");
|
||||
setSelectedOrgImage(selectedOrg.publisher.image ?? "");
|
||||
setSelectedGitHubOrgId(selectedOrg.publisher.githubOrgId ?? "");
|
||||
setSelectedOrgImageFile(null);
|
||||
}, [selectedOrg]);
|
||||
|
||||
@@ -466,6 +504,7 @@ export function Settings() {
|
||||
const activeSectionLoading =
|
||||
(activeView === "organizations" &&
|
||||
(publisherMemberships === undefined ||
|
||||
githubOrgMemberships === undefined ||
|
||||
(selectedOrg && selectedOrg.role !== "publisher" && orgMembers === undefined))) ||
|
||||
(activeView === "tokens" && tokens === undefined);
|
||||
|
||||
@@ -535,6 +574,11 @@ export function Settings() {
|
||||
if (!selectedOrg) return;
|
||||
setIsUploadingOrgImage(true);
|
||||
try {
|
||||
const githubOrgId =
|
||||
selectedGitHubOrgId === (selectedOrg.publisher.githubOrgId ?? "") &&
|
||||
!linkedGitHubHandleNeedsRefresh
|
||||
? undefined
|
||||
: selectedGitHubOrgId || null;
|
||||
if (selectedOrgImageFile) {
|
||||
const upload = await createOrgImageUpload({
|
||||
publisherId: selectedOrg.publisher._id,
|
||||
@@ -546,6 +590,7 @@ export function Settings() {
|
||||
bio: selectedOrgBio || undefined,
|
||||
imageStorageId: imageStorageId as Id<"_storage">,
|
||||
imageUploadTicket: upload.uploadTicket,
|
||||
githubOrgId,
|
||||
});
|
||||
} else {
|
||||
await updateOrgProfile({
|
||||
@@ -556,6 +601,7 @@ export function Settings() {
|
||||
imageStorageId: selectedOrgImage
|
||||
? (selectedOrg.publisher.imageStorageId ?? undefined)
|
||||
: undefined,
|
||||
githubOrgId,
|
||||
});
|
||||
}
|
||||
setSelectedOrgImageFile(null);
|
||||
@@ -567,6 +613,13 @@ export function Settings() {
|
||||
}
|
||||
}
|
||||
|
||||
async function onConnectGitHubOrganizations() {
|
||||
const ownerHandle = selectedOrg?.publisher.handle;
|
||||
const search = new URLSearchParams({ view: "organizations" });
|
||||
if (ownerHandle) search.set("ownerHandle", ownerHandle);
|
||||
await signIn("github", { redirectTo: `/settings?${search.toString()}` });
|
||||
}
|
||||
|
||||
function onOrgImageFileChange(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0] ?? null;
|
||||
event.target.value = "";
|
||||
@@ -1104,6 +1157,105 @@ export function Settings() {
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="lg:col-span-2">
|
||||
<Field
|
||||
label="GitHub organization"
|
||||
htmlFor="settings-selected-org-github"
|
||||
>
|
||||
{githubOrgMemberships?.syncedAt ||
|
||||
selectedOrg.publisher.githubOrgId ? (
|
||||
<>
|
||||
<div className="flex min-w-0 flex-col gap-2 sm:flex-row">
|
||||
<Select
|
||||
value={selectedGitHubOrgId || "__none__"}
|
||||
onValueChange={(value) =>
|
||||
setSelectedGitHubOrgId(
|
||||
value === "__none__" ? "" : value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="settings-selected-org-github"
|
||||
className="h-11 min-w-0 flex-1"
|
||||
>
|
||||
<SelectValue placeholder="Select a GitHub organization" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">
|
||||
No GitHub organization
|
||||
</SelectItem>
|
||||
{selectedOrg.publisher.githubOrgId &&
|
||||
!(githubOrgMemberships?.memberships ?? []).some(
|
||||
(membership) =>
|
||||
membership.githubOrgId ===
|
||||
selectedOrg.publisher.githubOrgId,
|
||||
) ? (
|
||||
<SelectItem
|
||||
value={selectedOrg.publisher.githubOrgId}
|
||||
disabled
|
||||
>
|
||||
@{selectedOrg.publisher.githubHandle} · unavailable
|
||||
</SelectItem>
|
||||
) : null}
|
||||
{(githubOrgMemberships?.memberships ?? []).map(
|
||||
(membership) => (
|
||||
<SelectItem
|
||||
key={membership.githubOrgId}
|
||||
value={membership.githubOrgId}
|
||||
disabled={!githubOrgMembershipsFresh}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<OrgLogoSmall
|
||||
image={membership.avatarUrl}
|
||||
name={membership.login}
|
||||
handle={membership.login}
|
||||
className="h-6 w-6"
|
||||
/>
|
||||
<span className="truncate">
|
||||
@{membership.login} · {membership.role}
|
||||
</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-11 shrink-0"
|
||||
onClick={() => void onConnectGitHubOrganizations()}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-[color:var(--ink-soft)]">
|
||||
{githubOrgMembershipsFresh
|
||||
? "Only organizations where your GitHub account is an active member are shown."
|
||||
: "Reconnect GitHub to choose another organization. You can still remove the current link."}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex min-w-0 flex-col items-start gap-2">
|
||||
<Button
|
||||
id="settings-selected-org-github"
|
||||
type="button"
|
||||
variant="outline"
|
||||
aria-label="Connect GitHub organizations"
|
||||
onClick={() => void onConnectGitHubOrganizations()}
|
||||
>
|
||||
<GitHubIcon size={16} />
|
||||
Connect GitHub
|
||||
</Button>
|
||||
<p className="text-xs text-[color:var(--ink-soft)]">
|
||||
GitHub will ask for read-only access to your organization
|
||||
memberships.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
<div className="lg:col-span-2">
|
||||
<Field label="Bio" htmlFor="settings-selected-org-bio">
|
||||
<Textarea
|
||||
@@ -2802,6 +2954,14 @@ function Field({
|
||||
);
|
||||
}
|
||||
|
||||
function GitHubIcon({ size = 16 }: { size?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M12 .7a11.5 11.5 0 0 0-3.64 22.4c.58.1.79-.25.79-.56v-2.02c-3.22.7-3.9-1.37-3.9-1.37-.52-1.34-1.29-1.7-1.29-1.7-1.05-.72.08-.7.08-.7 1.16.08 1.78 1.2 1.78 1.2 1.04 1.77 2.72 1.26 3.39.96.1-.75.4-1.26.74-1.55-2.57-.3-5.28-1.29-5.28-5.73 0-1.27.45-2.3 1.2-3.12-.12-.3-.52-1.48.11-3.08 0 0 .98-.31 3.16 1.19a10.9 10.9 0 0 1 5.75 0c2.18-1.5 3.16-1.19 3.16-1.19.63 1.6.23 2.78.11 3.08.75.82 1.2 1.85 1.2 3.12 0 4.46-2.71 5.43-5.3 5.72.42.36.79 1.07.79 2.16v3.02c0 .31.21.67.8.56A11.5 11.5 0 0 0 12 .7Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function useActiveSettingsView() {
|
||||
const navigate = useNavigate({ from: "/settings" });
|
||||
const search = useSearch({ from: "/settings" });
|
||||
|
||||
+25
-22
@@ -547,6 +547,7 @@ export function PublisherProfilePage({
|
||||
| null
|
||||
| undefined;
|
||||
const publisher = queriedPublisher === undefined ? loaderPublisher : queriedPublisher;
|
||||
const githubHandle = publisher?.kind === "org" ? publisher.githubHandle : publisher?.handle;
|
||||
|
||||
const publishedDisplay = useQuery(
|
||||
api.publishers.getPublishedDisplayManifest,
|
||||
@@ -827,28 +828,30 @@ export function PublisherProfilePage({
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section
|
||||
className="publisher-profile-detail-block publisher-profile-details-links"
|
||||
aria-label="Links"
|
||||
>
|
||||
<h2 className="publisher-profile-detail-label">Links</h2>
|
||||
<div className="publisher-profile-meta-row">
|
||||
<a
|
||||
className="publisher-profile-meta-link"
|
||||
href={`https://github.com/${publisher.handle}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<GitHubIcon size={14} />
|
||||
GitHub
|
||||
<ArrowUpRight
|
||||
className="publisher-profile-meta-link-external-icon"
|
||||
size={12}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
{githubHandle ? (
|
||||
<section
|
||||
className="publisher-profile-detail-block publisher-profile-details-links"
|
||||
aria-label="Links"
|
||||
>
|
||||
<h2 className="publisher-profile-detail-label">Links</h2>
|
||||
<div className="publisher-profile-meta-row">
|
||||
<a
|
||||
className="publisher-profile-meta-link"
|
||||
href={`https://github.com/${githubHandle}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<GitHubIcon size={14} />
|
||||
GitHub · @{githubHandle}
|
||||
<ArrowUpRight
|
||||
className="publisher-profile-meta-link-external-icon"
|
||||
size={12}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user