Skip to content
Clone

fix(web/app): finish the follow-ups the API audit left open

Each item traces back to a report in the (local) audit bundle:

- tag protection: the panel asked for a policy UUID by hand because the module
  assumed the policy CRUD had no HTTP surface — it does. The panel now lists
  policies, creates and deletes them, and keeps the whitelist for the selected
  one. The invite dialog had the mirror-image bug: it offered role grants but
  loaded *groups* (`listSpaceGroups`) into the picker; it now loads roles and
  sends `extra_role_ids` plus the typed email, which the server accepts.
- bans: send `expires_at` on the ban itself now that the body carries it, and
  expose the missing `allow_agent_join` switch. Suspension history asks for
  revoked entries too, since the section is a history.
- repo: `listReleases` bypassed `unwrapApi`, folding every 4xx/5xx into "no
  releases yet" — it goes through the shared unwrap again. The tree read drops
  the inert `show_trees` flag, and three stale comments (shortstat 500s, the
  submodule "repository path", the tree `oid`/`name` wire shape) now describe
  what the server actually answers.
- chat: a room's roster comes from the enclosing space's membership instead of
  a stub that always returned `[]`; the prefetch passes the space name along.
  `updateRoomSetting`/`updateThread` no longer advertise `null` as a clear —
  those fields are single `Option`s, so the empty value clears them.
- types that lied: `revokeSpaceInvite` answers a boolean, `update_profile`
  answers `data: null`, and the invite *list* rows are flat `InviteSummary`s.
ZhenYi committed
parent 515160a commit 98e06cb Browse files

19 files changed

+244 −86

File tree 19
Modified view/app/src/domains/chat/model/api.ts View view/app/src/domains/chat/model/api.ts
@@ -31,6 +31,7 @@
3131 ReactionUser,
3232 ReadState,
3333 RoomBrief,
34 RoomMember,
3435 RoomSetting,
3536 SendMessageInput,
3637 SpaceReadStates,
@@ -56,17 +57,25 @@
5657 roomId: string,
5758 cmd: {
5859 name?: string
59 topic?: string | null
60 topic?: string
6061 is_private?: boolean
6162 nsfw?: boolean
62 rate_limit_per_user?: number | null
63 rate_limit_per_user?: number
6364 position?: number
6465 },
6566) => unwrapApi<RoomBrief>(Apis.chat.update_room({ pathParams: { room_id: roomId }, data: cmd }))
6667
6768export const deleteRoom = (roomId: string) => nothing(Apis.chat.delete_room({ pathParams: { room_id: roomId } }))
6869
69export const listRoomMembers = (_roomId: string): Promise<import('./chat').RoomMember[]> => Promise.resolve([])
70/** A room's roster is the enclosing space's membership: the chat service has no
71 * member endpoint of its own, and DMs (`@me`) have no roster to show. */
72export const listRoomMembers = async (spaceName: string, _roomId: string): Promise<RoomMember[]> => {
73 if (!spaceName || spaceName === '@me') return []
74 const page = await unwrapApi<MemberSummaryPage | null>(
75 Apis.space.list_space_members({ pathParams: { space_name: spaceName }, params: { limit: 100 } }),
76 )
77 return (page?.items ?? []).map((row) => toMemberUserRow(row) as unknown as RoomMember)
78}
7079
7180/** Per-viewer channel preferences (mute / favorite / hidden / notification level). */
7281export const getRoomSetting = (roomId: string) =>
@@ -78,8 +87,9 @@
7887 muted?: boolean
7988 favorite?: boolean
8089 hidden?: boolean
81 mute_minutes?: number | null
82 notification_level?: 'Default' | 'All' | 'Mentions' | 'Nothing' | null
90 /** `0` lifts a mute; `null` would read as "unchanged". */
91 mute_minutes?: number
92 notification_level?: 'Default' | 'All' | 'Mentions' | 'Nothing'
8393 },
8494) => unwrapApi<RoomSetting>(Apis.chat.update_room_setting({ pathParams: { room_id: roomId }, data: cmd }))
8595
@@ -279,7 +289,8 @@
279289
280290export const updateThread = (
281291 threadId: string,
282 cmd: { name?: string | null; archived?: boolean | null; locked?: boolean | null },
292 /** An empty `name` clears it server-side; `null` means "unchanged". */
293 cmd: { name?: string; archived?: boolean; locked?: boolean },
283294) => unwrapApi<ThreadBrief>(Apis.chat.update_thread({ pathParams: { thread_id: threadId }, data: cmd }))
284295
285296// `notify_level` is a three-state field, so both follow calls carry a body.
@@ -292,7 +303,7 @@
292303 )
293304
294305export const unfollowThread = (threadId: string) =>
295 nothing(Apis.chat.unfollow_thread({ pathParams: { thread_id: threadId }, data: { notify_level: null } }))
306 nothing(Apis.chat.unfollow_thread({ pathParams: { thread_id: threadId } }))
296307
297308export const markThreadRead = (threadId: string, messageId?: string) =>
298309 nothing(
Modified view/app/src/domains/chat/model/prefetch.ts View view/app/src/domains/chat/model/prefetch.ts
@@ -8,9 +8,9 @@
88 * mount-time fetches. Fire-and-forget by design: a failed prefetch is silent,
99 * the mounting hooks surface real errors themselves.
1010 */
11export function prefetchRoomView(roomId: string): void {
11export function prefetchRoomView(roomId: string, spaceName: string): void {
1212 if (!roomId) return
1313 void prefetch(roomKey(roomId, 'room'), () => getRoom(roomId))
14 void prefetch(roomKey(roomId, 'members'), () => listRoomMembers(roomId).catch(() => []))
14 void prefetch(roomKey(roomId, 'members'), () => listRoomMembers(spaceName, roomId).catch(() => []))
1515 void prefetch(chatSeedKey(roomId, null), () => listMessages(roomId, { limit: MESSAGE_PAGE_SIZE }))
1616}
Modified view/app/src/domains/chat/model/use-room-data.ts View view/app/src/domains/chat/model/use-room-data.ts
@@ -64,7 +64,7 @@
6464 const room = useAsync(() => (gate ? getRoom(roomId) : Promise.resolve(undefined)), deps, {
6565 cacheKey: gate ? roomKey(roomId, 'room') : null,
6666 })
67 const members = useAsync(() => (gate ? listRoomMembers(roomId).catch(() => []) : Promise.resolve([])), deps, {
67 const members = useAsync(() => (gate ? listRoomMembers(spaceName ?? '', roomId).catch(() => []) : Promise.resolve([])), deps, {
6868 cacheKey: gate ? roomKey(roomId, 'members') : null,
6969 })
7070 const pins = useAsync(() => (gate ? listPins(roomId).catch(() => []) : Promise.resolve([])), deps, {
Modified view/app/src/domains/repo/model/blame.ts View view/app/src/domains/repo/model/blame.ts
@@ -29,7 +29,8 @@
2929 }),
3030 )
3131
32/** Resolve a submodule path at `revision` to its repository path (space/name). */
32/** The gitlink commit a submodule path points at `revision` (a bare sha, not a
33 * repo path — the server does not know the remote's space/name). */
3334export const resolveSubmodule = (space: string, repo: string, revision: string, path: string) =>
3435 unwrapApi<string>(
3536 Apis.submodules.submodule_resolve({
Modified view/app/src/domains/repo/model/index.ts View view/app/src/domains/repo/model/index.ts
@@ -97,14 +97,18 @@
9797 type BranchWhitelistRow,
9898 type CreatePolicyInput,
9999 createPolicy,
100 createTagPolicy,
100101 deletePolicy,
102 deleteTagPolicy,
101103 getPolicy,
102104 listBranchWhitelists,
103105 listPolicies,
106 listTagPolicies,
104107 listTagWhitelists,
105108 listUserOptions,
106109 removeBranchWhitelist,
107110 removeTagWhitelist,
111 type TagPolicy,
108112 type TagWhitelistRow,
109113 type UserOption,
110114 updatePolicy,
Modified view/app/src/domains/repo/model/protection.ts View view/app/src/domains/repo/model/protection.ts
@@ -1,13 +1,13 @@
11/**
2 * Protection-rule domain: branch-protection policies (CRUD) and the push /
3 * merge / approval whitelists attached to them, plus the tag-protection
4 * whitelist endpoints (tag *policies* themselves have no HTTP surface yet —
5 * whitelist rows can only be managed against a known policy UUID).
2 * Protection-rule domain: branch-protection policies (CRUD) with their push /
3 * merge / approval whitelists, and the tag-protection policies with their own
4 * whitelists. Both policy kinds are plain CRUD on the server.
65 */
76
87import { toMemberUserRow, unwrapApi } from '@app/lib'
98import type {
109 EnvelopeBranchPolicyDto,
10 EnvelopeTagPolicyDto,
1111 EnvelopeVecBranchWhitelistDto,
1212 EnvelopeVecTagWhitelistDto,
1313 WhitelistKind,
@@ -19,6 +19,7 @@
1919export type BranchPolicy = NonNullable<EnvelopeBranchPolicyDto['data']>
2020export type BranchWhitelistRow = NonNullable<NonNullable<EnvelopeVecBranchWhitelistDto['data']>[number]>
2121export type TagWhitelistRow = NonNullable<NonNullable<EnvelopeVecTagWhitelistDto['data']>[number]>
22export type TagPolicy = NonNullable<EnvelopeTagPolicyDto['data']>
2223
2324export const listPolicies = (space: string, repo: string) =>
2425 unwrapApi<BranchPolicy[] | null>(
@@ -58,11 +59,9 @@
5859 unwrapApi<BranchPolicy>(
5960 Apis.protection.update_policy({
6061 pathParams: { space_name: space, repo_name: repo, policy_id: policyId },
61 data: {
62 ...cmd,
63 protected_file_patterns: cmd.protected_file_patterns ?? null,
64 unprotected_file_patterns: cmd.unprotected_file_patterns ?? null,
65 },
62 // Single `Option<String>`s on the server: absent means "unchanged", and an
63 // empty string is what actually clears a pattern list (null would no-op).
64 data: cmd,
6665 }),
6766 )
6867
@@ -98,6 +97,34 @@
9897 }),
9998 )
10099
100export const listTagPolicies = (space: string, repo: string) =>
101 unwrapApi<TagPolicy[] | null>(
102 Apis.tag_protection.list_tag_policies({ pathParams: { space_name: space, repo_name: repo } }),
103 ).then((rows) => rows ?? [])
104
105export const createTagPolicy = (space: string, repo: string, namePattern: string) =>
106 unwrapApi<TagPolicy>(
107 Apis.tag_protection.create_tag_policy({
108 pathParams: { space_name: space, repo_name: repo },
109 data: { name_pattern: namePattern },
110 }),
111 )
112
113export const updateTagPolicy = (space: string, repo: string, policyId: string, namePattern: string) =>
114 unwrapApi<TagPolicy>(
115 Apis.tag_protection.update_tag_policy({
116 pathParams: { space_name: space, repo_name: repo, policy_id: policyId },
117 data: { name_pattern: namePattern },
118 }),
119 )
120
121export const deleteTagPolicy = (space: string, repo: string, policyId: string) =>
122 unwrapApi<null>(
123 Apis.tag_protection.delete_tag_policy({
124 pathParams: { space_name: space, repo_name: repo, policy_id: policyId },
125 }),
126 )
127
101128export const listTagWhitelists = (space: string, repo: string, policyId: string) =>
102129 unwrapApi<TagWhitelistRow[] | null>(
103130 Apis.tag_protection.list_tag_whitelists({
Modified view/app/src/domains/repo/model/releases.ts View view/app/src/domains/repo/model/releases.ts
@@ -14,18 +14,21 @@
1414 offset: number
1515}
1616
17export const listReleases = (
17export const listReleases = async (
1818 space: string,
1919 repo: string,
2020 params: { limit?: number; offset?: number; draft?: boolean } = {},
21) =>
22 Apis.releases
23 .list_releases({
21): Promise<ReleaseListPage> => {
22 // Go through `unwrapApi` like every other list: bypassing it turned a 4xx/5xx
23 // into an empty page, so the tab could never show why it was empty.
24 const page = await unwrapApi<ReleaseListPage | null>(
25 Apis.releases.list_releases({
2426 pathParams: { space_name: space, repo_name: repo },
2527 params: { limit: params.limit, offset: params.offset, draft: params.draft },
26 })
27 .then((res) => res as unknown as { data?: ReleaseListPage })
28 .then((res) => res.data ?? { items: [], total: 0, limit: 0, offset: 0 })
28 }),
29 )
30 return page ?? { items: [], total: 0, limit: 0, offset: 0 }
31}
2932
3033export const getRelease = (space: string, repo: string, tag: string) =>
3134 unwrapApi<Release>(Apis.releases.get_release({ pathParams: { space_name: space, repo_name: repo, tag } }))
Modified view/app/src/domains/repo/model/tree.ts View view/app/src/domains/repo/model/tree.ts
@@ -3,13 +3,13 @@
33 * endpoints.
44 *
55 * `GET /repos/{s}/{r}/tree/{rev}` is serialised as a bare `serde_json::Value`
6 * on the server, so the generated client types it as `unknown`. The real shape
6 * on the server, so the generated client types it as `unknown`. The live shape
77 * comes from gitbaby's `TreeEntry`:
8 * • `oid` → `{ "Sha1": [20 raw bytes] }` (or `Sha256`), not hex — and the
9 * blob endpoints only accept the full hex oid;
10 * • `name` → already repo-root-relative (`assets/logo.jpg`), so joining it
11 * onto the requested directory again yields `assets/assets/…`.
12 * Normalise both once, here.
8 * • `oid` → plain hex today (`8358e08…`); older builds answered
9 * `{ "Sha1": [20 raw bytes] }`, so both are accepted;
10 * • `name` → the basename (`FUNDING.yml`), while some payloads carried the
11 * repo-root-relative path — both are normalised to `path`.
12 * Normalise both once, here so callers only see `TreeEntry`.
1313 */
1414
1515import { unwrapApi } from '@app/lib'
@@ -90,7 +90,7 @@
9090 const data = await unwrapApi<unknown>(
9191 Apis.trees.get_tree({
9292 pathParams: { space_name: space, repo_name: repo, revision },
93 params: { path, show_trees: true },
93 params: { path },
9494 }),
9595 )
9696 return normalizeTreeRows(data, path)
Modified view/app/src/domains/repo/settings/ui/sections/protection-settings.tsx View view/app/src/domains/repo/settings/ui/sections/protection-settings.tsx
@@ -6,14 +6,18 @@
66 type BranchWhitelistRow,
77 type CreatePolicyInput,
88 createPolicy,
9 createTagPolicy,
910 deletePolicy,
11 deleteTagPolicy,
1012 getPolicy,
1113 listBranchWhitelists,
1214 listPolicies,
15 listTagPolicies,
1316 listTagWhitelists,
1417 listUserOptions,
1518 removeBranchWhitelist,
1619 removeTagWhitelist,
20 type TagPolicy,
1721 type TagWhitelistRow,
1822 updatePolicy,
1923 type WhitelistKind,
@@ -389,21 +393,52 @@
389393function TagProtectionPanel({ space, repo }: { space: string; repo: string }) {
390394 const { t } = useT()
391395 const [policyId, setPolicyId] = useState('')
396 const [pattern, setPattern] = useState('')
392397 const [tick, setTick] = useState(0)
393398 const [userId, setUserId] = useState(0)
394399 const [error, setError] = useState<string | null>(null)
400 const policiesState = useAsync(() => listTagPolicies(space, repo), [space, repo, tick])
401 const policies = policiesState.data ?? []
402 // The panel used to ask for a policy UUID by hand because the CRUD was assumed
403 // missing; the server has it, so the first policy selected is simply the first.
404 const activeId = policyId || policies[0]?.id || ''
395405 const rowsState = useAsync(
396 () => (policyId ? listTagWhitelists(space, repo, policyId) : Promise.resolve([] as TagWhitelistRow[])),
397 [space, repo, policyId, tick],
406 () => (activeId ? listTagWhitelists(space, repo, activeId) : Promise.resolve([] as TagWhitelistRow[])),
407 [space, repo, activeId, tick],
398408 )
399409 const users = useAsync(() => listUserOptions(space), [space])
400410 const rows = rowsState.data ?? []
401411
412 async function create() {
413 const value = pattern.trim()
414 if (!value) return
415 setError(null)
416 try {
417 const created = await createTagPolicy(space, repo, value)
418 setPattern('')
419 setPolicyId(created.id)
420 setTick((n) => n + 1)
421 } catch (f) {
422 setError(f instanceof Error ? f.message : t('repo.protection.createFailed'))
423 }
424 }
425
426 async function removePolicy(policy: TagPolicy) {
427 setError(null)
428 try {
429 await deleteTagPolicy(space, repo, policy.id)
430 if (policyId === policy.id) setPolicyId('')
431 setTick((n) => n + 1)
432 } catch (f) {
433 setError(f instanceof Error ? f.message : t('repo.protection.deleteFailed'))
434 }
435 }
436
402437 async function add() {
403 if (!userId || !policyId) return
438 if (!userId || !activeId) return
404439 setError(null)
405440 try {
406 await addTagWhitelist(space, repo, policyId, userId)
441 await addTagWhitelist(space, repo, activeId, userId)
407442 setUserId(0)
408443 setTick((n) => n + 1)
409444 } catch (f) {
@@ -414,21 +449,58 @@
414449 return (
415450 <div className="mt-6 rounded-xl border border-shell-hairline bg-shell-field p-4">
416451 <h4 className="text-[13.5px] font-semibold">{t('repo.protection.tagWhitelistTitle')}</h4>
417 <p className="mt-1 text-[12px] leading-5 text-[#8a6100]">{t('repo.protection.tagWhitelistNote')}</p>
452 <p className="mt-1 text-[12px] leading-5 text-shell-ink-muted">
453 {t('repo.protection.tagWhitelistNote')}
454 </p>
418455 {error ? <p className="mt-2 text-[12px] text-[#b42318]">{error}</p> : null}
419 <input
420 className={`${modalInputClass} mt-2 h-8`}
421 placeholder={t('repo.protection.tagPolicyIdPlaceholder')}
422 value={policyId}
423 onChange={(event) => setPolicyId(event.target.value.trim())}
424 />
425 {policyId ? (
456 <div className="mt-2 flex flex-wrap items-center gap-2">
457 <input
458 className={`${modalInputClass} h-8 min-w-[180px] flex-1`}
459 placeholder={t('repo.protection.tagPolicyNamePlaceholder')}
460 value={pattern}
461 onChange={(event) => setPattern(event.target.value)}
462 />
463 <Button className="h-8 text-[12.5px]" disabled={!pattern.trim()} onClick={() => void create()}>
464 {t('repo.protection.tagPolicyCreate')}
465 </Button>
466 </div>
467 {policies.length === 0 ? (
468 <PanelNote text={t('repo.protection.tagPolicyEmpty')} />
469 ) : (
470 <ul className="mt-2 space-y-1">
471 {policies.map((policy) => (
472 <li
473 key={policy.id}
474 className={`flex items-center gap-2 rounded px-2.5 py-1.5 text-[12.5px] ${
475 policy.id === activeId ? 'bg-shell-active' : 'bg-shell-surface'
476 }`}
477 >
478 <button
479 type="button"
480 onClick={() => setPolicyId(policy.id)}
481 className="min-w-0 flex-1 truncate text-left font-mono"
482 >
483 {policy.name_pattern}
484 </button>
485 <button
486 type="button"
487 aria-label={t('common.action.remove')}
488 onClick={() => void removePolicy(policy)}
489 className="text-shell-ink-muted hover:text-[#b42318]"
490 >
491 ×
492 </button>
493 </li>
494 ))}
495 </ul>
496 )}
497 {activeId ? (
426498 <div className="mt-2">
427499 <div className="flex flex-wrap items-center gap-2 text-[12px]">
428500 <select
429501 value={userId}
430502 onChange={(event) => setUserId(Number(event.target.value))}
431 className={`h-8 flex-1 min-w-[140px] ${modalInputClass}`}
503 className={`h-8 min-w-[140px] flex-1 ${modalInputClass}`}
432504 >
433505 <option value="">{t('repo.protection.selectUser')}</option>
434506 {(users.data ?? []).map((user) => (
@@ -457,7 +529,7 @@
457529 type="button"
458530 aria-label={t('common.action.remove')}
459531 onClick={() =>
460 void removeTagWhitelist(space, repo, policyId, row.user_id)
532 void removeTagWhitelist(space, repo, activeId, row.user_id)
461533 .then(() => setTick((n) => n + 1))
462534 .catch(() => undefined)
463535 }
Modified view/app/src/domains/settings/model/api.ts View view/app/src/domains/settings/model/api.ts
@@ -26,7 +26,7 @@
2626
2727/* ---- account & profile ---------------------------------------------------- */
2828export const updateProfile = (cmd: UpdateProfileCmd) =>
29 unwrapApi<UserPublic>(Apis.account.update_profile({ data: cmd }))
29 unwrapApi<null>(Apis.account.update_profile({ data: cmd }))
3030export const updateUsername = (username: string) =>
3131 unwrapApi<UserPublic>(Apis.account.update_username({ data: { username } }))
3232
@@ -102,7 +102,11 @@
102102 unwrapApi<unknown>(Apis.auth.auth_ssh_key_delete({ pathParams: { key_id: keyId } }))
103103
104104/* ---- account status ------------------------------------------------------- */
105/** The endpoint answers with a bare array, not the paged envelope. The section
106 * is a punishment *history*, so revoked entries are included too. */
105107export const listSuspensions = async (): Promise<SuspensionRow[]> => {
106 const page = await unwrapApi<{ items?: SuspensionRow[] }>(Apis.user.my_suspensions({ params: {} }))
107 return page?.items ?? []
108 const rows = await unwrapApi<SuspensionRow[] | null>(
109 Apis.user.my_suspensions({ params: { include_revoked: true } }),
110 )
111 return rows ?? []
108112}
Modified view/app/src/domains/space/management/model/space-admin.ts View view/app/src/domains/space/management/model/space-admin.ts
@@ -9,7 +9,7 @@
99 * (`EnvelopeMemberDto` etc.) instead of being read off the list call.
1010 */
1111
12import { type ApiEnvelope, ApiFailure, unwrapApi } from '@app/lib'
12import { type ApiEnvelope, ApiFailure, toMemberUserRow, unwrapApi } from '@app/lib'
1313import type {
1414 AddMemberCmd,
1515 AssignGroupCmd,
@@ -17,8 +17,10 @@
1717 CreateInviteCmd,
1818 CreateJoinRuleCmd,
1919 EnvelopeGroupDto,
20 EnvelopeVecRoleSummary,
2021 EnvelopeInviteSummary,
2122 EnvelopeJoinRequestDto,
23 EnvelopePageInviteSummary,
2224 EnvelopeJoinRuleDto,
2325 EnvelopeMemberDto,
2426 EnvelopeMemberSummary,
@@ -35,11 +37,14 @@
3537 UpdateJoinRuleCmd,
3638 UpdateMemberCmd,
3739} from '@app/services/api'
38import Apis, { trackFetch } from '@app/services/api'
40import Apis, { type MemberSummaryRow, trackFetch } from '@app/services/api'
3941
4042export type SpaceMember = NonNullable<EnvelopeMemberDto['data']>
4143export type SpaceGroup = NonNullable<EnvelopeGroupDto['data']>
44export type SpaceRole = NonNullable<NonNullable<EnvelopeVecRoleSummary['data']>[number]>
45/** `POST …/invites` returns `{ invite, token }`; the list rows are flat. */
4246export type SpaceInvite = NonNullable<EnvelopeInviteSummary['data']>
47export type SpaceInviteRow = NonNullable<NonNullable<EnvelopePageInviteSummary['data']>['items']>[number]
4348export type SpaceBan = NonNullable<EnvelopeMemberSummary['data']>
4449export type SpaceJoinRule = NonNullable<EnvelopeJoinRuleDto['data']>
4550export type SpaceImage = NonNullable<EnvelopeSpaceImageDto['data']>
@@ -78,7 +83,7 @@
7883}
7984
8085/** Page size for every settings list; the overlay has no pagination controls. */
81export const ADMIN_PAGE_LIMIT = 200
86export const ADMIN_PAGE_LIMIT = 100
8287
8388/* ---------------------------------------------------------------- identity */
8489
@@ -88,34 +93,52 @@
8893
8994/* ----------------------------------------------------------------- members */
9095
96/** Rows come back flat (`MemberSummary`), so `user` is folded in here. */
9197export const listSpaceMembers = (spaceName: string) =>
92 pageRows<SpaceMember>(
98 pageRows<MemberSummaryRow>(
9399 Apis.space.list_space_members({
94100 pathParams: { space_name: spaceName },
95101 params: { limit: ADMIN_PAGE_LIMIT },
96102 }),
103 ).then((rows) => rows.map((row) => toMemberUserRow(row) as unknown as SpaceMember))
104
105/** The `{member_id}` segment is the membership UUID, not the numeric user id. */
106export const getSpaceMember = async (spaceName: string, userId: number) => {
107 const member = await findSpaceMember(spaceName, userId)
108 return unwrapApi<SpaceMember>(
109 Apis.space.get_member({ pathParams: { space_name: spaceName, member_id: member.id } }),
97110 )
98
99export const getSpaceMember = (spaceName: string, userId: number) =>
100 unwrapApi<SpaceMember>(Apis.space.get_member({ pathParams: { space_name: spaceName, user_id: userId } }))
111}
101112
102113export const addSpaceMember = (spaceName: string, cmd: AddMemberCmd) =>
103114 unwrapApi<SpaceMember>(Apis.space.add_member({ pathParams: { space_name: spaceName }, data: cmd }))
104115
105export const updateSpaceMember = (spaceName: string, userId: number, cmd: UpdateMemberCmd) =>
106 unwrapApi<SpaceMember>(
107 Apis.space.update_member({ pathParams: { space_name: spaceName, user_id: userId }, data: cmd }),
116export const updateSpaceMember = async (spaceName: string, userId: number, cmd: UpdateMemberCmd) => {
117 const member = await findSpaceMember(spaceName, userId)
118 return unwrapApi<SpaceMember>(
119 Apis.space.update_member({ pathParams: { space_name: spaceName, member_id: member.id }, data: cmd }),
108120 )
121}
109122
110123/** The viewer's own row: nickname alias and notification prefs need no rights. */
111124export const updateMySpaceMembership = (spaceName: string, cmd: UpdateMemberCmd) =>
112125 unwrapApi<SpaceMember>(Apis.space.update_self_member({ pathParams: { space_name: spaceName }, data: cmd }))
113126
114export const removeSpaceMember = (spaceName: string, userId: number) =>
115 unwrapApi<null>(Apis.space.remove_member({ pathParams: { space_name: spaceName, user_id: userId } }))
127export const removeSpaceMember = async (spaceName: string, userId: number) => {
128 const member = await findSpaceMember(spaceName, userId)
129 return unwrapApi<null>(
130 Apis.space.remove_member({ pathParams: { space_name: spaceName, member_id: member.id } }),
131 )
132}
116133
117134/* ------------------------------------------------------------ roles/groups */
118135
136/** Invites grant roles, not groups — this is the list the invite form needs. */
137export const listSpaceRoles = (spaceName: string) =>
138 unwrapApi<SpaceRole[] | null>(Apis.space.space_role_list({ pathParams: { space_name: spaceName } })).then(
139 (rows) => rows ?? [],
140 )
141
119142export const listSpaceGroups = (spaceName: string) =>
120143 pageRows<SpaceGroup>(
121144 Apis.space.list_groups({
@@ -160,17 +183,16 @@
160183/* ----------------------------------------------------------------- invites */
161184
162185export const listSpaceInvites = (spaceName: string) =>
163 pageRows<SpaceInvite>(
186 pageRows<SpaceInviteRow>(
164187 Apis.space.list_invites({
165188 pathParams: { space_name: spaceName },
166189 params: { limit: ADMIN_PAGE_LIMIT },
167190 }),
168191 )
169192
193/** Answers a boolean, not the revoked invite row. */
170194export const revokeSpaceInvite = (spaceName: string, inviteId: string) =>
171 unwrapApi<SpaceInvite | null>(
172 Apis.space.revoke_invite({ pathParams: { space_name: spaceName, invite_id: inviteId } }),
173 )
195 unwrapApi<boolean>(Apis.space.revoke_invite({ pathParams: { space_name: spaceName, invite_id: inviteId } }))
174196
175197export const createSpaceInvite = (spaceName: string, cmd: CreateInviteCmd) =>
176198 unwrapApi<SpaceInvite>(Apis.space.create_invite({ pathParams: { space_name: spaceName }, data: cmd }))
@@ -244,7 +266,7 @@
244266 return unwrapApi<SpaceBan>(
245267 Apis.space.space_member_ban({
246268 pathParams: { space_name: spaceName, member_id: member.id },
247 data: { reason: cmd.reason ?? null },
269 data: { reason: cmd.reason ?? null, expires_at: cmd.expires_at ?? null },
248270 }),
249271 )
250272}
Modified view/app/src/domains/space/management/ui/space-settings/bans-section.tsx View view/app/src/domains/space/management/ui/space-settings/bans-section.tsx
@@ -29,6 +29,7 @@
2929 allow_speak: 'space.bans.allowSpeak',
3030 allow_push_repo: 'space.bans.allowPushRepo',
3131 allow_comment_any: 'space.bans.allowCommentAny',
32 allow_agent_join: 'space.bans.allowAgentJoin',
3233} as const
3334
3435const BAN_FLAGS = (Object.keys(BAN_FLAG_KEYS) as Array<keyof typeof BAN_FLAG_KEYS>).map((key) => ({
@@ -129,6 +130,7 @@
129130 allow_speak: ban.ban_allow_speak,
130131 allow_push_repo: ban.ban_allow_push_repo,
131132 allow_comment_any: ban.ban_allow_comment_any,
133 allow_agent_join: ban.ban_allow_agent_join,
132134 }))
133135 const [reason, setReason] = useState(ban.ban_reason ?? '')
134136 const { t } = useT()
Modified view/app/src/domains/space/management/ui/space-header.tsx View view/app/src/domains/space/management/ui/space-header.tsx
@@ -25,7 +25,7 @@
2525 fetchSpaceDir,
2626 leaveSpace,
2727} from '../model/api'
28import { findUserByUsername, getSpaceImage, listSpaceGroups } from '../model/space-admin'
28import { findUserByUsername, getSpaceImage, listSpaceRoles } from '../model/space-admin'
2929import { PrivacyDialog } from './privacy-dialog'
3030import { MANAGE_PERMS, parentDirOptions } from './space-settings/meta'
3131
@@ -52,7 +52,7 @@
5252 const [menuOpen, setMenuOpen] = useState(false)
5353 const [dialog, setDialog] = useState<Dialog>(null)
5454 const [dirOptions, setDirOptions] = useState<{ value: string; label: string }[]>([])
55 const [groupOptions, setGroupOptions] = useState<{ value: string; label: string }[]>([])
55 const [roleOptions, setRoleOptions] = useState<{ value: string; label: string }[]>([])
5656 const [failedBannerUrl, setFailedBannerUrl] = useState<string | null>(null)
5757 const rootRef = useRef<HTMLDivElement>(null)
5858
@@ -123,18 +123,18 @@
123123 useEffect(() => {
124124 if (dialog !== 'invite') return
125125 let active = true
126 listSpaceGroups(space.name).then(
127 (groups) => {
126 listSpaceRoles(space.name).then(
127 (roles) => {
128128 if (active)
129 setGroupOptions(
130 groups.map((group) => ({
131 value: group.id,
132 label: group.unicode_emoji ? `${group.unicode_emoji} ${group.name}` : group.name,
129 setRoleOptions(
130 roles.map((role) => ({
131 value: role.id,
132 label: role.emoji ? `${role.emoji} ${role.name}` : role.name,
133133 })),
134134 )
135135 },
136136 () => {
137 if (active) setGroupOptions([])
137 if (active) setRoleOptions([])
138138 },
139139 )
140140 return () => {
@@ -253,9 +253,9 @@
253253 },
254254 {
255255 kind: 'multiselect',
256 key: 'group_ids',
256 key: 'extra_role_ids',
257257 label: t('space.invite.groups'),
258 options: groupOptions,
258 options: roleOptions,
259259 emptyLabel: t('space.invite.groupsEmpty'),
260260 hint: t('space.invite.groupsHint'),
261261 },
@@ -268,6 +268,8 @@
268268 const target = email ? null : (await findUserByUsername(username)).user_id
269269 await createInvite(space.name, {
270270 target_user_id: target,
271 email: email || null,
272 extra_role_ids: Array.isArray(values.extra_role_ids) ? (values.extra_role_ids as string[]) : [],
271273 max_uses: 0,
272274 ttl_secs: expires ? Math.max(60, Math.floor((new Date(expires).getTime() - Date.now()) / 1000)) : null,
273275 })
Modified view/app/src/i18n/messages/en/repo.ts View view/app/src/i18n/messages/en/repo.ts
@@ -437,8 +437,12 @@
437437 'The matching whitelist switch is off in this policy; entries will have no effect.',
438438 'repo.protection.tagWhitelistTitle': 'Tag protection whitelist',
439439 'repo.protection.tagWhitelistNote':
440 'The backend does not yet expose list/create endpoints for tag protection policies; manage the user whitelist by policy UUID here.',
441 'repo.protection.tagPolicyIdPlaceholder': 'Tag protection policy UUID',
440 'A tag push is rejected when it matches a policy and the pusher is not whitelisted.',
441 'repo.protection.tagPolicyNamePlaceholder': 'Tag pattern, e.g. release/*',
442 'repo.protection.tagPolicyCreate': 'New tag policy',
443 'repo.protection.tagPolicyEmpty': 'No tag protection policies yet.',
444 'repo.protection.createFailed': 'Could not create.',
445 'repo.protection.deleteFailed': 'Could not delete.',
442446 'repo.protection.branchRequired': 'The branch pattern is required.',
443447 'repo.protection.updated': 'Policy updated.',
444448 'repo.protection.created': 'Policy created.',
Modified view/app/src/i18n/messages/en/space.ts View view/app/src/i18n/messages/en/space.ts
@@ -478,6 +478,7 @@
478478 'space.bans.allowSpeak': 'Allow speaking in voice channels',
479479 'space.bans.allowPushRepo': 'Allow pushing to repositories',
480480 'space.bans.allowCommentAny': 'Allow commenting on any issue',
481 'space.bans.allowAgentJoin': 'Allow agents to join the space',
481482 'space.channelPermissions.sub2': 'Hide a folder or channel from specific roles and members, or force it visible.',
482483 'space.channelPermissions.stance.deny': 'Hidden',
483484 'space.channelPermissions.stance.inherit': 'Inherit',
Modified view/app/src/i18n/messages/zh-CN/repo.ts View view/app/src/i18n/messages/zh-CN/repo.ts
@@ -425,8 +425,12 @@
425425 'repo.protection.whitelistEmpty': '该策略暂无白名单成员。',
426426 'repo.protection.whitelistDisabledNote': '对应白名单开关未在策略中启用,添加后不会生效。',
427427 'repo.protection.tagWhitelistTitle': '标签保护白名单',
428 'repo.protection.tagWhitelistNote': '后端尚未提供标签保护策略的列表/创建接口,此处按策略 UUID 维护其用户白名单。',
429 'repo.protection.tagPolicyIdPlaceholder': '标签保护策略 UUID',
428 'repo.protection.tagWhitelistNote': '标签命中策略且推送者不在白名单内时,服务端拒绝该 tag 推送。',
429 'repo.protection.tagPolicyNamePlaceholder': '标签模式,如 release/*',
430 'repo.protection.tagPolicyCreate': '新建标签保护策略',
431 'repo.protection.tagPolicyEmpty': '还没有标签保护策略。',
432 'repo.protection.createFailed': '创建失败',
433 'repo.protection.deleteFailed': '删除失败',
430434 'repo.protection.branchRequired': '分支模式不能为空。',
431435 'repo.protection.updated': '策略已更新。',
432436 'repo.protection.created': '策略已创建。',
Modified view/app/src/i18n/messages/zh-CN/space.ts View view/app/src/i18n/messages/zh-CN/space.ts
@@ -474,6 +474,7 @@
474474 'space.bans.allowSpeak': '允许在语音频道发言',
475475 'space.bans.allowPushRepo': '允许推送仓库',
476476 'space.bans.allowCommentAny': '允许评论任意问题',
477 'space.bans.allowAgentJoin': '允许 Agent 加入空间',
477478 'space.channelPermissions.sub2': '对单个文件夹或频道隐藏、或强制显示给特定角色与成员。',
478479 'space.channelPermissions.stance.deny': '隐藏',
479480 'space.channelPermissions.stance.inherit': '跟随',
Modified view/app/src/pages/workspace/model/prefetch.ts View view/app/src/pages/workspace/model/prefetch.ts
@@ -68,7 +68,7 @@
6868 .filter((room) => room.kind !== 'Category' && room.kind !== 'Thread' && room.kind !== 'Voice' && !room.hidden)
6969 .sort((a, b) => (b.unread_count ?? 0) - (a.unread_count ?? 0) || Number(b.favorite) - Number(a.favorite))
7070 .slice(0, IDLE_ROOM_COUNT)
71 for (const room of ranked) prefetchRoomView(room.id)
71 for (const room of ranked) prefetchRoomView(room.id, spaceName)
7272 if (repos[0]) void prefetchRepoView(spaceName, repos[0].name)
7373 if (boards[0]) prefetchBoardView(spaceName, boards[0].id)
7474 })
Modified view/app/src/pages/workspace/ui/sidebar-nav.tsx View view/app/src/pages/workspace/ui/sidebar-nav.tsx
@@ -422,10 +422,10 @@
422422 const hoverPrefetch = useCallback(
423423 (view: View) => {
424424 if (view.kind === 'room') {
425 if (!view.voice && view.id) prefetchRoomView(view.id)
425 if (!view.voice && view.id) prefetchRoomView(view.id, spaceName)
426426 } else if (view.kind === 'dm') {
427427 const roomId = view.href?.split('/').pop()
428 if (roomId) prefetchRoomView(decodeURIComponent(roomId))
428 if (roomId) prefetchRoomView(decodeURIComponent(roomId), spaceName)
429429 } else if (view.kind === 'repo') {
430430 void prefetchRepoView(spaceName, view.label)
431431 } else if (view.kind === 'board' && view.assetId) {