Skip to content
222 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Workspace is the workspace's settings, in one place. Its sidebar is grouped, General, Access, Money, Compute, Code, Agents, Chat, Artifacts, Security and Integrations, every page one click away with no settings inside settings, the groups folding and the owner-only pages hidden from members; what is not here yet is marked Soon with a hint. Members is a list with search and filters for role, two-factor and team, and Invite opens a dialog: who, by username, name or email, their role and a note that goes into the invitation; nothing is filled in inline any more. Invitations is its own page with All, Pending, Accepted, Declined, Expired and Revoked filters that say how many, and each row's menu can copy the link, send it again or revoke it; identity learned to send an invitation again and to carry the note. Permissions gathers every rule about who may do what, by part: Code's base permission and member privileges, who creates teams, who creates channels, and, marked Soon with what applies today, forking private repositories, adding agents to conversations, messaging agents directly, creating spaces and default sharing, creating workspace agents and raising budgets, deploying to production and publishing packages. General lost what moved. The permissions guide is new, and the workspaces, people and teams, access, chat, teams, authentication and billing guides say where things are now.1import { AtSign, Mail, UserPlus } from "lucide-react";
2import { type ReactNode, useEffect, useId, useRef, useState } from "react";
3import { useFetcher } from "react-router";
4
5import { MAX_INVITE_MESSAGE, type PersonMatch } from "@g1t/contracts";
6
7import { ErrorText, SubmitButton } from "./ui";
8import { Avatar } from "./ui/avatar";
9import { Button } from "./ui/button";
10import { Command, CommandInput, CommandItem, CommandList } from "./ui/command";
11import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog";
12import { Field, FieldDescription, FieldLabel } from "./ui/field";
13import { SelectField } from "./ui/select";
14import { Textarea } from "./ui/textarea";
15import { UserCard } from "./user-card";
16import { type InviteResult, moreInvitesMailto, workspaceInviteCopy } from "../lib/invites";
17import { PEOPLE_SEARCH_PATH, looksLikeEmail, peopleQuery } from "../lib/people-search";
18
19/**
20 * Inviting someone to the workspace, in a dialog opened from Members and
21 * from Invitations: who (a username, a name to search by, or an email
22 * address), the role they join with, and a note for the email. Posted to
23 * `action` as `action=add` (lib/member-invitations.server.ts); on success the
24 * dialog closes and `onInvited` is told who, so the page can say so.
25 */
26export function InviteDialog({
27 slug,
28 name,
29 inviteOnly,
30 action,
31 onInvited,
32 trigger,
33}: {
34 slug: string;
35 /** The workspace's display name. */
36 name: string;
37 /** Whether sign-up takes an invite: then an invitation to a new address costs one. */
38 inviteOnly: boolean;
39 /** The page's address, whose action handles `add`. */
40 action: string;
41 onInvited?: (who: string) => void;
42 /** The button that opens it; the Invite button when absent. */
43 trigger?: ReactNode;
44}) {
45 const [open, setOpen] = useState(false);
46 const fetcher = useFetcher<InviteResult>();
47 const copy = workspaceInviteCopy(name, inviteOnly);
48 const result = fetcher.state === "idle" ? fetcher.data : undefined;
49 // Sent: the dialog closes and the page says so. Each opening starts clean.
50 const told = useRef<InviteResult | null>(null);
51 useEffect(() => {
52 if (!result || told.current === result) return;
53 told.current = result;
54 if ("invited" in result) {
55 setOpen(false);
56 onInvited?.(result.invited);
57 }
58 }, [result, onInvited]);
59 const error = result && "error" in result ? result : null;
60 const messageId = useId();
61 return (
62 <Dialog open={open} onOpenChange={setOpen}>
63 <DialogTrigger asChild>
64 {trigger ?? (
65 <Button type="button">
66 <UserPlus size={15} />
67 Invite
68 </Button>
69 )}
70 </DialogTrigger>
71 <DialogContent>
72 {open && (
73 <fetcher.Form method="post" action={action} className="grid gap-5">
74 <input type="hidden" name="action" value="add" />
75 <DialogHeader>
76 <DialogTitle>{copy.heading}</DialogTitle>
77 <DialogDescription>{copy.hint}</DialogDescription>
78 </DialogHeader>
79 <WhoField />
80 <Field>
81 <FieldLabel htmlFor={`${messageId}-role`}>Role</FieldLabel>
82 <SelectField
83 id={`${messageId}-role`}
84 name="role"
85 defaultValue="member"
86 options={[
87 { value: "member", label: "Member", description: `Works in ${name} with the base permission on its repositories.` },
88 { value: "owner", label: "Owner", description: "Everything a member can, and manages the workspace itself." },
89 ]}
90 />
91 </Field>
92 <Field>
93 <FieldLabel htmlFor={messageId}>
94 Message <span className="font-normal text-faint">(optional)</span>
95 </FieldLabel>
96 <Textarea id={messageId} name="message" maxLength={MAX_INVITE_MESSAGE} rows={3} placeholder={`A line for the email: why you'd like them in ${name}.`} />
97 <FieldDescription>Quoted in the invitation email, under your name.</FieldDescription>
98 </Field>
99 {error && (
100 <div>
101 <ErrorText>{error.error}</ErrorText>
102 {error.outOfInvites && (
103 <p className="mt-1 text-sm text-muted">
104 <a href={moreInvitesMailto(slug)} className="text-accent underline underline-offset-4">
105 Ask for more invites
106 </a>{" "}
107 for {slug}.
108 </p>
109 )}
110 </div>
111 )}
112 <DialogFooter>
113 <DialogClose asChild>
114 <Button type="button" variant="outline">
115 Cancel
116 </Button>
117 </DialogClose>
118 <SubmitButton fetcher={fetcher} match={{ action: "add" }} pending="Inviting…">
119 <UserPlus size={15} />
120 Invite
121 </SubmitButton>
122 </DialogFooter>
123 </fetcher.Form>
124 )}
125 </DialogContent>
126 </Dialog>
127 );
128}
129
130/**
131 * Who to invite: a search box that offers people on g1t by username and
132 * name as you type, each with their picture and card, and takes an email
133 * address as written. Only what a profile shows is offered, never anyone's
134 * address. Posts what is in the box as `member`.
135 */
136function WhoField() {
137 const [text, setText] = useState("");
138 const [people, setPeople] = useState<PersonMatch[]>([]);
139 const [chosen, setChosen] = useState<string | null>(null);
140 const asked = useRef(0);
141 const id = useId();
142
143 useEffect(() => {
144 const query = chosen === text.trim().replace(/^@+/, "") ? null : peopleQuery(text);
145 if (!query) {
146 setPeople([]);
147 return;
148 }
149 const ask = ++asked.current;
150 const timer = setTimeout(() => {
151 fetch(`${PEOPLE_SEARCH_PATH}?q=${encodeURIComponent(query)}`, { headers: { accept: "application/json" } })
152 .then((response) => (response.ok ? (response.json() as Promise<{ people?: PersonMatch[] }>) : { people: [] }))
153 .then((found) => {
154 if (ask === asked.current) setPeople(found.people ?? []);
155 })
156 .catch(() => {
157 if (ask === asked.current) setPeople([]);
158 });
159 }, 150);
160 return () => clearTimeout(timer);
161 }, [text, chosen]);
162
163 const email = looksLikeEmail(text);
164 const listed = people.length > 0 && !email;
165 return (
166 <Field>
167 <FieldLabel htmlFor={id}>Who</FieldLabel>
168 <Command shouldFilter={false} className="rounded-md border border-line bg-bg has-[input:focus-visible]:border-accent-dim has-[input:focus-visible]:ring-2 has-[input:focus-visible]:ring-accent/25">
169 <CommandInput
170 id={id}
171 name="member"
172 required
173 maxLength={254}
174 autoComplete="off"
175 placeholder="username, name or name@example.com"
176 value={text}
177 onValueChange={(value) => {
178 setText(value);
179 setChosen(null);
180 }}
181 className="h-9"
182 />
183 {listed && (
184 <CommandList aria-label="People on g1t" className="max-h-56 border-t border-line">
185 {people.map((person) => (
186 <CommandItem
187 key={person.username}
188 value={person.username}
189 onSelect={() => {
190 setText(person.username);
191 setChosen(person.username);
192 setPeople([]);
193 }}
194 >
195 <Avatar name={person.username} image={person.avatar} size={20} />
196 <UserCard username={person.username}>
197 <span className="font-mono">{person.username}</span>
198 </UserCard>
199 {person.name && <span className="min-w-0 truncate text-muted">{person.name}</span>}
200 </CommandItem>
201 ))}
202 </CommandList>
203 )}
204 </Command>
205 <FieldDescription className="flex items-center gap-1.5">
206 {email ? (
207 <>
208 <Mail size={12} className="shrink-0" />
209 Invited by email: {text.trim()} gets the invitation, and signs up first if it has no account.
210 </>
211 ) : chosen ? (
212 <>
213 <AtSign size={12} className="shrink-0" />
214 @{chosen} gets the invitation in their notifications and by email.
215 </>
216 ) : (
217 "People on g1t are offered as you type; an email address is sent as written."
218 )}
219 </FieldDescription>
220 </Field>
221 );
222}