From 26e524d92ab7d31774325611675583a76f74997c Mon Sep 17 00:00:00 2001 From: Waleed Khalid Date: Fri, 11 Sep 2026 22:42:14 +0500 Subject: [PATCH] Add team administration: general, members, statuses, automation, repositories Closes #16 for everything the API exposes, with one deliberate exception noted below. Rules render as English -- "When an issue changes status and it is unassigned, assign it to Ada Lovelace." A rule is read far more often than it is written, usually to work out why an issue moved, and a grid of dropdowns does not answer that. Every lookup falls back by name, because a rule outlives the label it points at and a sentence with "undefined" in it is worse than one saying the label is gone. Reordering statuses sends every id at once, as the API asks: two people dragging columns would otherwise interleave into an order neither chose. It is driven by arrows rather than a drag -- the board already spends its drag on cards, and a second drag surface inside a sheet is both harder to hit and harder to verify. Deleting a status requires naming where its issues go, so the confirm offers the other columns rather than guessing. Guessing which column somebody's work lands in is not a decision to make for them. A webhook secret is not put on screen until it is asked for; these screens get shown to other people. Rule *authoring* is not here. A rule is a trigger plus six optional conditions plus seven optional actions, which is a form that belongs on a wider screen -- everything else about rules (reading, enabling, disabling, deleting, the run log) is. The panel says so and links out, as the issue allows for Jira import. Another correction from checking: demoting the last admin is 409, not 400. Co-Authored-By: Claude Opus 5 --- .claude/launch.json | 14 + README.md | 9 +- package-lock.json | 12 + package.json | 1 + src/api/__tests__/live-team-admin.test.ts | 169 +++++++++ src/team/admin/__tests__/automation.test.ts | 69 ++++ src/team/admin/automation-panel.tsx | 235 ++++++++++++ src/team/admin/members-panel.tsx | 249 +++++++++++++ src/team/admin/share.ts | 28 ++ src/team/admin/statuses-panel.tsx | 331 +++++++++++++++++ src/team/admin/team-admin-sheet.tsx | 379 ++++++++++++++++++++ src/team/teams-home.tsx | 14 + 12 files changed, 1506 insertions(+), 4 deletions(-) create mode 100644 .claude/launch.json create mode 100644 src/api/__tests__/live-team-admin.test.ts create mode 100644 src/team/admin/__tests__/automation.test.ts create mode 100644 src/team/admin/automation-panel.tsx create mode 100644 src/team/admin/members-panel.tsx create mode 100644 src/team/admin/share.ts create mode 100644 src/team/admin/statuses-panel.tsx create mode 100644 src/team/admin/team-admin-sheet.tsx diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..ffa1c6e --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,14 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "start", + "runtimeExecutable": "npm", + "runtimeArgs": [ + "run", + "start" + ], + "port": 3000 + } + ] +} diff --git a/README.md b/README.md index adf846b..79c6561 100644 --- a/README.md +++ b/README.md @@ -74,10 +74,11 @@ the real client modules against it, and the onboarding one writes real rows SOFTTRACK_LIVE_URL=http://localhost:8000 npm test ``` -Registration is throttled per IP and charged even on success, so running the -onboarding suite repeatedly will eventually 429. The counter lives in the API -process, so `docker compose restart backend` clears it without touching the -database. +The live suites between them register a handful of real accounts per run, and +registration is throttled per IP and charged even on success — so a few full runs +in a row will eventually 429. The counter lives in the API process, so +`docker compose restart backend && docker compose up -d` clears it without +touching the database. Note `experiments.typedRoutes` is a dev-time aid: the route union is written by `expo start`, not by `expo export`, so outside the dev server every path diff --git a/package-lock.json b/package-lock.json index 1db268b..76ba300 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "axios": "^1.20.0", "expo": "~57.0.22", "expo-blur": "~57.0.3", + "expo-clipboard": "~57.0.2", "expo-constants": "~57.0.18", "expo-document-picker": "~57.0.2", "expo-file-system": "~57.0.7", @@ -8094,6 +8095,17 @@ "react-native": "*" } }, + "node_modules/expo-clipboard": { + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-57.0.2.tgz", + "integrity": "sha512-VB4Au8X/RbvJKUtJk+87vdtveWSNEa5RzY3ooeO6VNF7Rd49RbQXZOu3/TfEW9p7mU8TCfyTvjyCiAUwSoSJ4w==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-constants": { "version": "57.0.18", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.18.tgz", diff --git a/package.json b/package.json index 760153d..8e335d1 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "axios": "^1.20.0", "expo": "~57.0.22", "expo-blur": "~57.0.3", + "expo-clipboard": "~57.0.2", "expo-constants": "~57.0.18", "expo-document-picker": "~57.0.2", "expo-file-system": "~57.0.7", diff --git a/src/api/__tests__/live-team-admin.test.ts b/src/api/__tests__/live-team-admin.test.ts new file mode 100644 index 0000000..79a08c1 --- /dev/null +++ b/src/api/__tests__/live-team-admin.test.ts @@ -0,0 +1,169 @@ +/** + * @jest-environment node + */ +import { + loginAuthLoginPost, + registerAuthRegisterPost, +} from '@/api/generated/endpoints/auth/auth'; +import { + acceptInviteInvitesTokenAcceptPost, + createInviteTeamsTeamIdInvitesPost, + listInvitesTeamsTeamIdInvitesGet, + revokeInviteTeamsTeamIdInvitesInviteIdDelete, +} from '@/api/generated/endpoints/invites/invites'; +import { + createIssueTeamsTeamIdIssuesPost, + deleteIssueIssuesIssueIdDelete, + getIssueIssuesIssueIdGet, +} from '@/api/generated/endpoints/issues/issues'; +import { + createStatusTeamsTeamIdStatusesPost, + deleteStatusStatusesStatusIdDelete, + listStatusesTeamsTeamIdStatusesGet, + reorderStatusesTeamsTeamIdStatusesOrderPut, +} from '@/api/generated/endpoints/statuses/statuses'; +import { + createTeamTeamsPost, + listTeamMembersTeamsTeamIdMembersGet, + removeTeamMemberTeamsTeamIdMembersUserIdDelete, + updateTeamMemberRoleTeamsTeamIdMembersUserIdPatch, + updateTeamTeamsTeamIdPatch, +} from '@/api/generated/endpoints/teams/teams'; +import type { TeamRead } from '@/api/generated/models'; +import { setInstanceUrl } from '@/api/instance'; +import { persistToken } from '@/auth/session'; + +/** + * Team administration against a real instance. + * + * Works on a team created for the run rather than the demo one, because these + * delete statuses and change roles. + */ +const LIVE_URL = process.env.SOFTTRACK_LIVE_URL; +const describeLive = LIVE_URL ? describe : describe.skip; + +const RUN = Date.now().toString(36).slice(-5); +const KEY = `T${RUN}`.replace(/[^A-Za-z]/g, 'X').toUpperCase().slice(0, 6); + +describeLive('team administration against a live instance', () => { + let team: TeamRead; + let adminToken: string; + let otherToken: string; + let otherId: number; + + beforeAll(async () => { + await setInstanceUrl(LIVE_URL!); + const admin = await loginAuthLoginPost({ + username: process.env.SOFTTRACK_LIVE_EMAIL ?? 'demo@softtrack.dev', + password: process.env.SOFTTRACK_LIVE_PASSWORD ?? 'password123', + }); + adminToken = admin.access_token; + await persistToken(adminToken); + + team = await createTeamTeamsPost({ name: `Admin ${RUN}`, key: KEY }); + + const other = await registerAuthRegisterPost({ + email: `mob-admin-${RUN}@example.com`, + password: 'password123', + full_name: 'Second Admin', + }); + otherToken = other.access_token; + otherId = other.user.id; + }); + + it('renames the team but never its key', async () => { + const updated = await updateTeamTeamsTeamIdPatch(team.id, { + name: `Renamed ${RUN}`, + description: 'Set from mobile', + }); + expect(updated.name).toBe(`Renamed ${RUN}`); + expect(updated.description).toBe('Set from mobile'); + // The key is permanent, which is why the field is read-only. + expect(updated.key).toBe(KEY); + }); + + it('invites, lists, and revokes', async () => { + const invite = await createInviteTeamsTeamIdInvitesPost(team.id, { + email: `revoke-${RUN}@example.com`, + role: 'member', + }); + expect((await listInvitesTeamsTeamIdInvitesGet(team.id)).map((i) => i.id)).toContain( + invite.id, + ); + + await revokeInviteTeamsTeamIdInvitesInviteIdDelete(team.id, invite.id); + expect((await listInvitesTeamsTeamIdInvitesGet(team.id)).map((i) => i.id)).not.toContain( + invite.id, + ); + }); + + it('refuses to leave the team without another admin', async () => { + // The guard the Manage sheet explains rather than discovers. + await expect( + removeTeamMemberTeamsTeamIdMembersUserIdDelete(team.id, otherId), + ).rejects.toMatchObject({ response: { status: 404 } }); + + const members = await listTeamMembersTeamsTeamIdMembersGet(team.id); + const me = members[0]; + await expect( + updateTeamMemberRoleTeamsTeamIdMembersUserIdPatch(team.id, me.user.id, { + role: 'member', + }), + // 409: demoting the last admin conflicts with the team still needing one, + // which is the guard the Manage sheet explains up front. + ).rejects.toMatchObject({ response: { status: 409 } }); + }); + + it('promotes a second member to admin', async () => { + const invite = await createInviteTeamsTeamIdInvitesPost(team.id, { + email: `mob-admin-${RUN}@example.com`, + role: 'member', + }); + await persistToken(otherToken); + await acceptInviteInvitesTokenAcceptPost(invite.token); + + await persistToken(adminToken); + const promoted = await updateTeamMemberRoleTeamsTeamIdMembersUserIdPatch( + team.id, + otherId, + { role: 'admin' }, + ); + expect(promoted.role).toBe('admin'); + }); + + it('creates, reorders and deletes a status, moving its issues', async () => { + const before = await listStatusesTeamsTeamIdStatusesGet(team.id); + const created = await createStatusTeamsTeamIdStatusesPost(team.id, { + name: `Review ${RUN}`, + category: 'started', + }); + + // Reordering sends every id at once, so two people cannot interleave into + // an order neither chose. + const reversed = [created.id, ...before.map((s) => s.id)]; + const reordered = await reorderStatusesTeamsTeamIdStatusesOrderPut(team.id, { + status_ids: reversed, + }); + expect(reordered.map((s) => s.id)).toEqual(reversed); + + const issue = await createIssueTeamsTeamIdIssuesPost(team.id, { + title: `In deleted status ${RUN}`, + status_id: created.id, + }); + + const destination = before[0]; + await deleteStatusStatusesStatusIdDelete(created.id, { move_to_id: destination.id }); + + // Where the issues go is required rather than guessed -- so they are + // exactly where they were sent. + expect((await getIssueIssuesIssueIdGet(issue.id)).status.id).toBe(destination.id); + await deleteIssueIssuesIssueIdDelete(issue.id).catch(() => undefined); + }); + + it('will not delete a status without saying where its issues go', async () => { + const statuses = await listStatusesTeamsTeamIdStatusesGet(team.id); + await expect( + deleteStatusStatusesStatusIdDelete(statuses[0].id, {} as never), + ).rejects.toMatchObject({ response: { status: 422 } }); + }); +}); diff --git a/src/team/admin/__tests__/automation.test.ts b/src/team/admin/__tests__/automation.test.ts new file mode 100644 index 0000000..3a50428 --- /dev/null +++ b/src/team/admin/__tests__/automation.test.ts @@ -0,0 +1,69 @@ +import { describeRule } from '@/team/admin/automation-panel'; + +const LOOK = { + status: (id: number) => (id === 1 ? 'In Progress' : 'a deleted status'), + label: (id: number) => (id === 2 ? 'bug' : 'a deleted label'), + project: (id: number) => (id === 3 ? 'Platform' : 'a deleted project'), + member: (id: number) => (id === 4 ? 'Ada Lovelace' : 'a former member'), + cycle: (id: number) => (id === 5 ? 'Cycle 14' : 'a deleted cycle'), +}; + +function rule(over: Record = {}) { + return { + id: 1, + team_id: 1, + name: 'Rule', + trigger: 'issue_created', + is_enabled: true, + conditions: {}, + actions: {}, + created_by: { id: 1, username: 'a', full_name: 'A', avatar_color: '#000' }, + created_at: '', + updated_at: '', + ...over, + } as never as Parameters[0]; +} + +describe('describeRule', () => { + it('reads as a sentence rather than a grid of fields', () => { + expect( + describeRule(rule({ actions: { set_priority: 'high' } }), LOOK), + ).toBe('When an issue is created, set its priority to High.'); + }); + + it('joins conditions with "and"', () => { + expect( + describeRule( + rule({ + trigger: 'status_changed', + conditions: { if_status_id: 1, if_unassigned: true }, + actions: { set_assignee_id: 4 }, + }), + LOOK, + ), + ).toBe( + 'When an issue changes status and it is in In Progress and it is unassigned, assign it to Ada Lovelace.', + ); + }); + + it('joins multiple actions', () => { + expect( + describeRule( + rule({ actions: { add_label_id: 2, move_to_active_cycle: true } }), + LOOK, + ), + ).toBe('When an issue is created, add the bug label, and move it into the active cycle.'); + }); + + it('names a row the team has since deleted rather than printing undefined', () => { + // A rule outlives the label it points at, and a sentence with "undefined" + // in it is worse than one that says the label is gone. + expect(describeRule(rule({ actions: { add_label_id: 99 } }), LOOK)).toContain( + 'a deleted label', + ); + }); + + it('says so when a rule does nothing', () => { + expect(describeRule(rule(), LOOK)).toBe('When an issue is created, do nothing.'); + }); +}); diff --git a/src/team/admin/automation-panel.tsx b/src/team/admin/automation-panel.tsx new file mode 100644 index 0000000..a13ea0c --- /dev/null +++ b/src/team/admin/automation-panel.tsx @@ -0,0 +1,235 @@ +import { useState } from 'react'; +import { Alert as RNAlert, Pressable, Switch, View } from 'react-native'; +import { useQueryClient } from '@tanstack/react-query'; + +import { + deleteRuleAutomationRulesRuleIdDelete, + updateRuleAutomationRulesRuleIdPatch, + useListRulesTeamsTeamIdAutomationRulesGet, + useListRunsTeamsTeamIdAutomationRunsGet, +} from '@/api/generated/endpoints/automations/automations'; +import type { + AutomationRuleRead, + AutomationTrigger, + RuleActions, + RuleConditions, + TeamRead, +} from '@/api/generated/models'; +import { errorDetail } from '@/api/errors'; +import { useTeamData } from '@/board/use-team-data'; +import { PRIORITY_LABEL } from '@/issues/issue-meta'; +import { Alert, AppText, Button, Card } from '@/ui/primitives'; +import { Sheet } from '@/ui/sheet'; +import { useTokens } from '@/ui/theme'; + +/** + * Automation rules and their run log. + * + * Rules render as English rather than as a form of dropdowns. A rule is read + * far more often than it is written -- usually to work out why an issue moved -- + * and "When an issue is created, set its priority to High" answers that in a + * glance where a grid of selects does not. + */ +const TRIGGER_TEXT: Record = { + issue_created: 'an issue is created', + status_changed: 'an issue changes status', + issue_assigned: 'an issue is assigned', + comment_added: 'a comment is added', + cycle_completed: 'a cycle is completed', + branch_created: 'a branch is created', + pull_request_opened: 'a pull request is opened', + pull_request_merged: 'a pull request is merged', +}; + +type Lookup = { + status: (id: number) => string; + label: (id: number) => string; + project: (id: number) => string; + member: (id: number) => string; + cycle: (id: number) => string; +}; + +function conditionText(conditions: RuleConditions, look: Lookup): string[] { + const parts: string[] = []; + if (conditions.if_status_id) parts.push(`it is in ${look.status(conditions.if_status_id)}`); + if (conditions.if_priority) parts.push(`its priority is ${PRIORITY_LABEL[conditions.if_priority]}`); + if (conditions.if_label_id) parts.push(`it has the ${look.label(conditions.if_label_id)} label`); + if (conditions.if_project_id) parts.push(`it is in ${look.project(conditions.if_project_id)}`); + if (conditions.if_assignee_id) parts.push(`it is assigned to ${look.member(conditions.if_assignee_id)}`); + if (conditions.if_unassigned) parts.push('it is unassigned'); + return parts; +} + +function actionText(actions: RuleActions, look: Lookup): string[] { + const parts: string[] = []; + if (actions.set_status_id) parts.push(`move it to ${look.status(actions.set_status_id)}`); + if (actions.set_priority) parts.push(`set its priority to ${PRIORITY_LABEL[actions.set_priority]}`); + if (actions.set_assignee_id) parts.push(`assign it to ${look.member(actions.set_assignee_id)}`); + if (actions.add_label_id) parts.push(`add the ${look.label(actions.add_label_id)} label`); + if (actions.set_cycle_id) parts.push(`put it in ${look.cycle(actions.set_cycle_id)}`); + if (actions.move_to_active_cycle) parts.push('move it into the active cycle'); + if (actions.comment_body) parts.push('leave a comment'); + return parts; +} + +/** A rule as a sentence. Exported because the phrasing is worth testing. */ +export function describeRule(rule: AutomationRuleRead, look: Lookup): string { + const when = TRIGGER_TEXT[rule.trigger] ?? rule.trigger; + const ifs = conditionText(rule.conditions, look); + const dos = actionText(rule.actions, look); + + const condition = ifs.length > 0 ? ` and ${ifs.join(' and ')}` : ''; + const action = dos.length > 0 ? dos.join(', and ') : 'do nothing'; + return `When ${when}${condition}, ${action}.`; +} + +export function AutomationPanel({ + visible, + onClose, + team, + isAdmin, +}: { + visible: boolean; + onClose: () => void; + team: TeamRead; + isAdmin: boolean; +}) { + const t = useTokens(); + const queryClient = useQueryClient(); + const data = useTeamData(team); + + const rules = useListRulesTeamsTeamIdAutomationRulesGet(team.id, { + query: { enabled: visible }, + }); + const runs = useListRunsTeamsTeamIdAutomationRunsGet( + team.id, + { limit: 20 }, + { query: { enabled: visible } }, + ); + + const [error, setError] = useState(null); + + const look: Lookup = { + // A rule can outlive the row it names, so every lookup has a fallback + // rather than rendering "undefined" into a sentence. + status: (id) => data.statuses.find((s) => s.id === id)?.name ?? 'a deleted status', + label: (id) => data.labels.find((l) => l.id === id)?.name ?? 'a deleted label', + project: (id) => data.projects.find((p) => p.id === id)?.name ?? 'a deleted project', + member: (id) => + data.members.find((m) => m.user.id === id)?.user.full_name ?? 'a former member', + cycle: (id) => data.cycles.find((c) => c.id === id)?.display_name ?? 'a deleted cycle', + }; + + async function run(action: () => Promise, fallback: string) { + setError(null); + try { + await action(); + await queryClient.invalidateQueries({ + queryKey: [`/teams/${team.id}/automation-rules`], + }); + } catch (err) { + setError(errorDetail(err, fallback)); + } + } + + if (!visible) return null; + + return ( + + + {error ? {error} : null} + + {(rules.data ?? []).map((rule) => ( + + + + {rule.name} + + + void run( + () => + updateRuleAutomationRulesRuleIdPatch(rule.id, { + is_enabled: value, + }).then(() => undefined), + 'Could not change that rule.', + ) + } + /> + + + + {describeRule(rule, look)} + + + {isAdmin ? ( + + RNAlert.alert(`Delete ${rule.name}?`, 'This cannot be undone.', [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Delete', + style: 'destructive', + onPress: () => + void run( + () => + deleteRuleAutomationRulesRuleIdDelete(rule.id).then( + () => undefined, + ), + 'Could not delete that rule.', + ), + }, + ]) + } + > + + Delete + + + ) : null} + + ))} + + {(rules.data ?? []).length === 0 && !rules.isPending ? ( + + No automation rules yet. + + ) : null} + + {/* Rules can be read, enabled, disabled and deleted here. Authoring one + is a trigger plus six optional conditions plus seven optional + actions, which is a form that belongs on a wider screen -- the web + settings page has it. */} + {isAdmin ? ( + + New rules are written on the web settings page; everything else about + them is here. + + ) : null} + + + RECENT RUNS + {(runs.data?.items ?? []).map((entry) => ( + + + {entry.rule_name} + + + {entry.issue_identifier ? `${entry.issue_identifier} · ` : ''} + {entry.summary} + + + ))} + {(runs.data?.items ?? []).length === 0 && !runs.isPending ? ( + Nothing has run yet. + ) : null} + + + + ); +} diff --git a/src/team/admin/members-panel.tsx b/src/team/admin/members-panel.tsx new file mode 100644 index 0000000..a1d630a --- /dev/null +++ b/src/team/admin/members-panel.tsx @@ -0,0 +1,249 @@ +import { useState } from 'react'; +import { Alert as RNAlert, Pressable, View } from 'react-native'; +import { useQueryClient } from '@tanstack/react-query'; + +import { + createInviteTeamsTeamIdInvitesPost, + revokeInviteTeamsTeamIdInvitesInviteIdDelete, + useListInvitesTeamsTeamIdInvitesGet, +} from '@/api/generated/endpoints/invites/invites'; +import { + removeTeamMemberTeamsTeamIdMembersUserIdDelete, + updateTeamMemberRoleTeamsTeamIdMembersUserIdPatch, + useListTeamMembersTeamsTeamIdMembersGet, +} from '@/api/generated/endpoints/teams/teams'; +import type { TeamRead, UserMe } from '@/api/generated/models'; +import { errorDetail } from '@/api/errors'; +import { getInstanceUrl } from '@/api/instance'; +import { shareInvite } from '@/team/admin/share'; +import { Alert, AppText, Avatar, Button, Card, Field, RoleChip } from '@/ui/primitives'; +import { Sheet } from '@/ui/sheet'; +import { useTokens } from '@/ui/theme'; + +/** + * Members and invitations. + * + * Every guard here is the server's -- last-active-admin, self-demotion, admin + * only. The client mirrors the messages rather than the rules, so an action that + * would be refused says why instead of failing opaquely. + */ +export function MembersPanel({ + visible, + onClose, + team, + user, + isAdmin, +}: { + visible: boolean; + onClose: () => void; + team: TeamRead; + user: UserMe | null; + isAdmin: boolean; +}) { + const t = useTokens(); + const queryClient = useQueryClient(); + const members = useListTeamMembersTeamsTeamIdMembersGet(team.id, { + query: { enabled: visible }, + }); + const invites = useListInvitesTeamsTeamIdInvitesGet(team.id, { + query: { enabled: visible && isAdmin }, + }); + + const [email, setEmail] = useState(''); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const list = members.data ?? []; + const admins = list.filter((member) => member.role === 'admin'); + + async function run(action: () => Promise, fallback: string) { + setError(null); + setBusy(true); + try { + await action(); + await queryClient.invalidateQueries({ queryKey: [`/teams/${team.id}/members`] }); + await queryClient.invalidateQueries({ queryKey: [`/teams/${team.id}/invites`] }); + } catch (err) { + setError(errorDetail(err, fallback)); + } finally { + setBusy(false); + } + } + + if (!visible) return null; + + return ( + + + {error ? {error} : null} + + {list.map((member) => { + const isSelf = member.user.id === user?.id; + const isLastAdmin = member.role === 'admin' && admins.length === 1; + + return ( + + + + + {member.user.full_name} + {isSelf ? ' (you)' : ''} + + + @{member.user.username} + + + + + + {isAdmin ? ( + { + const options: Parameters[2] = [ + { text: 'Cancel', style: 'cancel' }, + ]; + + if (!isLastAdmin) { + options.push({ + text: member.role === 'admin' ? 'Make member' : 'Make admin', + onPress: () => + void run( + () => + updateTeamMemberRoleTeamsTeamIdMembersUserIdPatch( + team.id, + member.user.id, + { role: member.role === 'admin' ? 'member' : 'admin' }, + ).then(() => undefined), + 'Could not change that role.', + ), + }); + } + + options.push({ + text: isSelf ? 'Leave team' : 'Remove from team', + style: 'destructive', + onPress: () => + void run( + () => + removeTeamMemberTeamsTeamIdMembersUserIdDelete( + team.id, + member.user.id, + ).then(() => undefined), + isSelf ? 'Could not leave the team.' : 'Could not remove them.', + ), + }); + + RNAlert.alert( + member.user.full_name, + isLastAdmin + ? 'The last admin cannot be demoted — promote somebody else first.' + : undefined, + options, + ); + }} + > + + Manage + + + ) : null} + + ); + })} + + {isAdmin ? ( + + INVITE + + {(invites.data ?? []).map((invite) => ( + + + + {invite.email} + + Invited as {invite.role} + + + void shareInvite(invite.token, team.name, getInstanceUrl()) + } + > + + Share + + + + void run( + () => + revokeInviteTeamsTeamIdInvitesInviteIdDelete( + team.id, + invite.id, + ).then(() => undefined), + 'Could not revoke that invitation.', + ) + } + > + + Revoke + + + + ))} + + + + + ) : null} + + + ); +} diff --git a/src/team/admin/share.ts b/src/team/admin/share.ts new file mode 100644 index 0000000..3f6cea4 --- /dev/null +++ b/src/team/admin/share.ts @@ -0,0 +1,28 @@ +import { Share } from 'react-native'; + +/** + * Hand an invitation link to the system share sheet. + * + * This instance sends no email, so the link only reaches anybody if the person + * creating it can pass it on. The web URL is shared rather than the custom + * scheme: it opens for someone with no app installed, and the app can pick it + * up from there. + */ +export async function shareInvite( + token: string, + teamName: string, + instanceUrl: string | null, +): Promise { + const link = instanceUrl + ? `${instanceUrl}/invite/${token}` + : `softtrack://invite/${token}`; + + try { + await Share.share({ + message: `Join ${teamName} on SoftTrack: ${link}`, + url: link, + }); + } catch { + // Dismissing the share sheet is not a failure worth reporting. + } +} diff --git a/src/team/admin/statuses-panel.tsx b/src/team/admin/statuses-panel.tsx new file mode 100644 index 0000000..8297ab6 --- /dev/null +++ b/src/team/admin/statuses-panel.tsx @@ -0,0 +1,331 @@ +import { useState } from 'react'; +import { Alert as RNAlert, Pressable, View } from 'react-native'; +import { useQueryClient } from '@tanstack/react-query'; + +import { + createStatusTeamsTeamIdStatusesPost, + deleteStatusStatusesStatusIdDelete, + reorderStatusesTeamsTeamIdStatusesOrderPut, + updateStatusStatusesStatusIdPatch, + useListStatusesTeamsTeamIdStatusesGet, +} from '@/api/generated/endpoints/statuses/statuses'; +import { StatusCategory, type StatusRead, type TeamRead } from '@/api/generated/models'; +import { errorDetail } from '@/api/errors'; +import { Icon } from '@/ui/icon'; +import { Alert, AppText, Button, Card, Field } from '@/ui/primitives'; +import { Sheet } from '@/ui/sheet'; +import { useTokens } from '@/ui/theme'; + +/** + * The team's board columns. + * + * Reordering moves one row at a time with arrows rather than a drag. The board + * already spends its drag gesture on cards, and a second drag surface inside a + * sheet is both harder to hit and harder to verify -- arrows do the same job + * with a target a thumb can actually land on. + */ +const CATEGORY_ORDER: StatusCategory[] = [ + StatusCategory.backlog, + StatusCategory.unstarted, + StatusCategory.started, + StatusCategory.done, + StatusCategory.cancelled, +]; + +const CATEGORY_HINT: Record = { + backlog: 'Not committed to yet', + unstarted: 'Accepted, not begun', + started: 'Work in flight', + done: 'Finished', + cancelled: 'Closed without being delivered', +}; + +export function StatusesPanel({ + visible, + onClose, + team, + isAdmin, +}: { + visible: boolean; + onClose: () => void; + team: TeamRead; + isAdmin: boolean; +}) { + const t = useTokens(); + const queryClient = useQueryClient(); + const statuses = useListStatusesTeamsTeamIdStatusesGet(team.id, { + query: { enabled: visible }, + }); + + const [name, setName] = useState(''); + const [category, setCategory] = useState(StatusCategory.unstarted); + const [editing, setEditing] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const items = statuses.data ?? []; + + async function run(action: () => Promise, fallback: string) { + setError(null); + setBusy(true); + try { + await action(); + await queryClient.invalidateQueries({ queryKey: [`/teams/${team.id}/statuses`] }); + // Columns are what the board draws, so it has to re-read them. + void queryClient.invalidateQueries({ queryKey: [`/teams/${team.id}/issues`] }); + } catch (err) { + setError(errorDetail(err, fallback)); + } finally { + setBusy(false); + } + } + + /** Every id at once: two people reordering would otherwise interleave. */ + function move(index: number, delta: number) { + const next = [...items]; + const target = index + delta; + if (target < 0 || target >= next.length) return; + [next[index], next[target]] = [next[target], next[index]]; + void run( + () => + reorderStatusesTeamsTeamIdStatusesOrderPut(team.id, { + status_ids: next.map((status) => status.id), + }).then(() => undefined), + 'Could not reorder the columns.', + ); + } + + function confirmDelete(status: StatusRead) { + const others = items.filter((candidate) => candidate.id !== status.id); + if (others.length === 0) { + setError('A team needs at least one status.'); + return; + } + + // Where the issues go is required rather than defaulted: guessing which + // column somebody's work lands in is not a decision to make for them. + RNAlert.alert( + `Delete ${status.name}?`, + 'Its issues have to go somewhere. Choose a column.', + [ + { text: 'Cancel', style: 'cancel' }, + ...others.slice(0, 4).map((destination) => ({ + text: `Move to ${destination.name}`, + onPress: () => + void run( + () => + deleteStatusStatusesStatusIdDelete(status.id, { + move_to_id: destination.id, + }).then(() => undefined), + 'Could not delete that status.', + ), + })), + ], + ); + } + + if (!visible) return null; + + return ( + + + {error ? {error} : null} + + {items.map((status, index) => ( + + + + + {status.name} + + {CATEGORY_HINT[status.category]} + + + {isAdmin ? ( + <> + move(index, -1)} + style={{ padding: 6, opacity: index === 0 ? 0.3 : 1 }} + > + ↑ + + move(index, 1)} + style={{ padding: 6, opacity: index === items.length - 1 ? 0.3 : 1 }} + > + ↓ + + setEditing(status)} + style={{ padding: 6 }} + > + + + + ) : null} + + ))} + + {isAdmin ? ( + + NEW STATUS + + + + {CATEGORY_ORDER.map((option) => { + const active = category === option; + return ( + setCategory(option)} + style={{ + paddingHorizontal: 10, + paddingVertical: 6, + borderRadius: t.radius.pill, + backgroundColor: active ? t.line.navActive : t.line.well, + }} + > + + {option} + + + ); + })} + + {CATEGORY_HINT[category]} + + + + ) : null} + + + {editing ? ( + setEditing(null)} + onRun={run} + onDelete={() => { + const target = editing; + setEditing(null); + confirmDelete(target); + }} + /> + ) : null} + + ); +} + +function EditStatusSheet({ + status, + onClose, + onRun, + onDelete, +}: { + status: StatusRead; + onClose: () => void; + onRun: (action: () => Promise, fallback: string) => Promise; + onDelete: () => void; +}) { + const t = useTokens(); + const [name, setName] = useState(''); + const [category, setCategory] = useState(status.category); + + return ( + + + + + + {CATEGORY_ORDER.map((option) => { + const active = category === option; + return ( + setCategory(option)} + style={{ + paddingHorizontal: 10, + paddingVertical: 6, + borderRadius: t.radius.pill, + backgroundColor: active ? t.line.navActive : t.line.well, + }} + > + + {option} + + + ); + })} + + + + + + + + ); +} diff --git a/src/team/admin/team-admin-sheet.tsx b/src/team/admin/team-admin-sheet.tsx new file mode 100644 index 0000000..27bcfeb --- /dev/null +++ b/src/team/admin/team-admin-sheet.tsx @@ -0,0 +1,379 @@ +import { useState } from 'react'; +import { Alert as RNAlert, Linking, Pressable, Share, View } from 'react-native'; +import * as Clipboard from 'expo-clipboard'; +import { useQueryClient } from '@tanstack/react-query'; + +import { + deleteRepositoryRepositoriesRepositoryIdDelete, + rotateSecretRepositoriesRepositoryIdRotatePost, + useListRepositoriesTeamsTeamIdRepositoriesGet, +} from '@/api/generated/endpoints/integrations/integrations'; +import { updateTeamTeamsTeamIdPatch } from '@/api/generated/endpoints/teams/teams'; +import { useListTeamMembersTeamsTeamIdMembersGet } from '@/api/generated/endpoints/teams/teams'; +import type { TeamRead } from '@/api/generated/models'; +import { errorDetail } from '@/api/errors'; +import { getInstanceUrl } from '@/api/instance'; +import { useAuth } from '@/auth/auth-context'; +import { AutomationPanel } from '@/team/admin/automation-panel'; +import { MembersPanel } from '@/team/admin/members-panel'; +import { StatusesPanel } from '@/team/admin/statuses-panel'; +import { Alert, AppText, Button, Card, Field } from '@/ui/primitives'; +import { Sheet } from '@/ui/sheet'; +import { useTokens } from '@/ui/theme'; + +/** + * Team administration, per `docs/design/mobile/153-team-admin.svg`. + * + * A menu of panels rather than one long form: five unrelated areas on one phone + * screen would be a scroll with no landmarks. + */ +type Panel = 'general' | 'members' | 'statuses' | 'automation' | 'repositories' | null; + +export function TeamAdminSheet({ + visible, + onClose, + team, +}: { + visible: boolean; + onClose: () => void; + team: TeamRead; +}) { + const t = useTokens(); + const { user } = useAuth(); + const [panel, setPanel] = useState(null); + + const members = useListTeamMembersTeamsTeamIdMembersGet(team.id, { + query: { enabled: visible }, + }); + // The server enforces every admin rule; this only decides what to offer, so + // nothing is presented that would be refused. + const isAdmin = + (members.data ?? []).find((member) => member.user.id === user?.id)?.role === 'admin'; + + if (!visible) return null; + + return ( + + + {( + [ + ['general', 'General'], + ['members', 'Members and invitations'], + ['statuses', 'Statuses'], + ['automation', 'Automation'], + ['repositories', 'Repositories'], + ] as const + ).map(([key, label]) => ( + setPanel(key)} + style={{ + paddingVertical: 14, + paddingHorizontal: 10, + borderRadius: t.radius.control, + }} + > + {label} + + ))} + + {!isAdmin ? ( + + You are a member of this team, so some settings are read-only. + + ) : null} + + {/* Importing from Jira is a long, file-driven flow that stays on the + web settings page, as the issue allows. */} + { + const base = getInstanceUrl(); + if (base) void Linking.openURL(`${base}/settings/teams/${team.key}/general`); + }} + style={{ paddingVertical: 14, paddingHorizontal: 10 }} + > + + Import from Jira (opens the web app) + + + + + setPanel(null)} + team={team} + isAdmin={isAdmin} + /> + setPanel(null)} + team={team} + user={user} + isAdmin={isAdmin} + /> + setPanel(null)} + team={team} + isAdmin={isAdmin} + /> + setPanel(null)} + team={team} + isAdmin={isAdmin} + /> + setPanel(null)} + team={team} + isAdmin={isAdmin} + /> + + ); +} + +function GeneralPanel({ + visible, + onClose, + team, + isAdmin, +}: { + visible: boolean; + onClose: () => void; + team: TeamRead; + isAdmin: boolean; +}) { + const queryClient = useQueryClient(); + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + if (!visible) return null; + + return ( + + + {error ? {error} : null} + + + + + + {isAdmin ? ( + + ) : null} + + + ); +} + +function RepositoriesPanel({ + visible, + onClose, + team, + isAdmin, +}: { + visible: boolean; + onClose: () => void; + team: TeamRead; + isAdmin: boolean; +}) { + const t = useTokens(); + const queryClient = useQueryClient(); + const repositories = useListRepositoriesTeamsTeamIdRepositoriesGet(team.id, { + query: { enabled: visible }, + }); + const [error, setError] = useState(null); + const [revealed, setRevealed] = useState(null); + + async function run(action: () => Promise, fallback: string) { + setError(null); + try { + await action(); + await queryClient.invalidateQueries({ + queryKey: [`/teams/${team.id}/repositories`], + }); + } catch (err) { + setError(errorDetail(err, fallback)); + } + } + + if (!visible) return null; + const items = repositories.data ?? []; + + return ( + + + {error ? {error} : null} + + {items.map((repository) => ( + + {repository.full_name} + + {repository.provider} ·{' '} + {repository.last_delivery_at + ? `last delivery ${repository.last_delivery_at.slice(0, 10)}` + : 'nothing delivered yet'} + + + + void Clipboard.setStringAsync(repository.webhook_url)} + > + + Copy payload URL + + + + {/* A secret is not put on screen until it is asked for -- these + screens get shown to other people. */} + { + if (revealed === repository.id) { + void Clipboard.setStringAsync(repository.secret); + } else { + setRevealed(repository.id); + } + }} + > + + {revealed === repository.id ? 'Copy secret' : 'Reveal secret'} + + + + {isAdmin ? ( + + RNAlert.alert( + 'Rotate the secret?', + 'Deliveries stop until the new secret is set on the repository.', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Rotate', + style: 'destructive', + onPress: () => + void run( + () => + rotateSecretRepositoriesRepositoryIdRotatePost( + repository.id, + ).then(() => undefined), + 'Could not rotate the secret.', + ), + }, + ], + ) + } + > + + Rotate secret + + + ) : null} + + {isAdmin ? ( + + RNAlert.alert( + `Disconnect ${repository.full_name}?`, + 'Branches and pull requests stop being linked to issues.', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Disconnect', + style: 'destructive', + onPress: () => + void run( + () => + deleteRepositoryRepositoriesRepositoryIdDelete( + repository.id, + ).then(() => undefined), + 'Could not disconnect that repository.', + ), + }, + ], + ) + } + > + + Disconnect + + + ) : null} + + void Share.share({ message: repository.webhook_url })} + > + + Share URL + + + + + {revealed === repository.id ? ( + + {repository.secret} + + ) : null} + + ))} + + {items.length === 0 && !repositories.isPending ? ( + + No repositories connected. + + ) : null} + + + ); +} diff --git a/src/team/teams-home.tsx b/src/team/teams-home.tsx index 7ba43a3..1651a21 100644 --- a/src/team/teams-home.tsx +++ b/src/team/teams-home.tsx @@ -6,6 +6,7 @@ import { useQueryClient } from '@tanstack/react-query'; import type { TeamRead } from '@/api/generated/models'; import { InvitesBanner } from '@/team/invites-banner'; import { NewTeamSheet } from '@/team/new-team-sheet'; +import { TeamAdminSheet } from '@/team/admin/team-admin-sheet'; import { useTeams } from '@/team/team-context'; import { useMemberCounts } from '@/team/use-member-counts'; import { Icon } from '@/ui/icon'; @@ -28,11 +29,13 @@ function TeamRow({ memberCount, active, onPress, + onLongPress, }: { team: TeamRead; memberCount: number | undefined; active: boolean; onPress: () => void; + onLongPress: () => void; }) { const t = useTokens(); return ( @@ -41,6 +44,7 @@ function TeamRow({ accessibilityLabel={team.name} accessibilityState={active ? { selected: true } : {}} onPress={onPress} + onLongPress={onLongPress} > (null); const [refreshing, setRefreshing] = useState(false); const filtered = useMemo(() => { @@ -132,6 +137,7 @@ export function TeamsHome() { memberCount={counts[item.id]} active={item.key === teamKey} onPress={() => setTeamKey(item.key)} + onLongPress={() => setAdministering(item)} /> )} ListHeaderComponent={ @@ -189,6 +195,14 @@ export function TeamsHome() { /> )} + {administering ? ( + setAdministering(null)} + team={administering} + /> + ) : null} + setCreating(false)} /> );