| 1 | "use client"; |
| 2 | |
| 3 | import { useRouter } from |
| 4 | import { useState } |
| 5 | import { toast } |
| 6 | |
| 7 | import { SettingCard } |
| 8 | import { RoleSelect } |
| 9 | import { |
| 10 | Field, |
| 11 | FieldContent, |
| 12 | FieldDescription, |
| 13 | FieldGroup, |
| 14 | FieldLabel, |
| 15 | FieldTitle, |
| 16 | } from "@/components/ui/field"; |
| 17 | import { Checkbox } |
| 18 | import { Input } |
| 19 | import { Switch } |
| 20 | import { Textarea } |
| 21 | import { apiClient, apiErrorMessage |
| 22 | import type { components |
| 23 | import { |
| 24 | type CollaboratorRole, |
| 25 | collaboratorRoles, |
| 26 | roleLabels, |
| 27 | } from "@/lib/repository-role"; |
| 28 | |
| 29 | type Settings |
| 30 | type Changes |
| 31 | |
| 32 | const NONE = " |
| 33 | |
| 34 | /** Saves a slice of the settings and re-renders the page from what was stored. */ |
| 35 | function useSaveSettings(slug: |
| 36 | const router = |
| 37 | const [saving, |
| 38 | |
| 39 | const save = |
| 40 | setSaving(true); |
| 41 | const { error |
| 42 | "/api/organizations/{slug}/settings", |
| 43 | { params: { path: |
| 44 | ); |
| 45 | setSaving(false); |
| 46 | if (error) return |
| 47 | toast.success(done |
| 48 | router.refresh(); |
| 49 | }; |
| 50 | |
| 51 | return { save, |
| 52 | } |
| 53 | |
| 54 | /** What outsiders see on the profile: a description and where to find the organization. Blank fields are cleared. */ |
| 55 | export function OrganizationProfileSettings({ |
| 56 | slug, |
| 57 | settings, |
| 58 | }: { |
| 59 | slug: string |
| 60 | settings: Settings |
| 61 | }) { |
| 62 | const { save, |
| 63 | const [profile, |
| 64 | description: settings.description |
| 65 | website: settings.website |
| 66 | location: settings.location |
| 67 | email: settings.email |
| 68 | }); |
| 69 | const changed = |
| 70 | (key) => |
| 71 | ); |
| 72 | const field = |
| 73 | id: `organization-${key |
| 74 | value: profile[key |
| 75 | onChange: ( |
| 76 | event: React |
| 77 | ) => setProfile( |
| 78 | }); |
| 79 | |
| 80 | return ( |
| 81 | <SettingCard |
| 82 | title="Profile" |
| 83 | hint="Shown to everyone on the organization's page." |
| 84 | onSubmit={() => |
| 85 | save( |
| 86 | { |
| 87 | description: profile.description |
| 88 | website: profile.website |
| 89 | location: profile.location |
| 90 | email: profile.email |
| 91 | }, |
| 92 | "Profile saved", |
| 93 | ) |
| 94 | } |
| 95 | pending={saving} |
| 96 | canSave={changed} |
| 97 | > |
| 98 | <FieldGroup> |
| 99 | <Field> |
| 100 | <FieldLabel htmlFor=" |
| 101 | Description |
| 102 | </FieldLabel> |
| 103 | <Textarea rows={ |
| 104 | </Field> |
| 105 | <div className="grid gap-4 sm:grid-cols-2 |
| 106 | <Field> |
| 107 | <FieldLabel htmlFor=" |
| 108 | <Input |
| 109 | type="url" |
| 110 | placeholder="https://example.com" |
| 111 | {...field("website |
| 112 | /> |
| 113 | </Field> |
| 114 | <Field> |
| 115 | <FieldLabel htmlFor=" |
| 116 | <Input {...field |
| 117 | </Field> |
| 118 | <Field> |
| 119 | <FieldLabel htmlFor=" |
| 120 | <Input type=" |
| 121 | </Field> |
| 122 | </div> |
| 123 | </FieldGroup> |
| 124 | </SettingCard> |
| 125 | ); |
| 126 | } |
| 127 | |
| 128 | /** What members may do: their standing access to every repository, what they may create, and whether private work may be forked. */ |
| 129 | export function OrganizationPrivileges({ |
| 130 | slug, |
| 131 | settings, |
| 132 | }: { |
| 133 | slug: string |
| 134 | settings: Settings |
| 135 | }) { |
| 136 | const { save, |
| 137 | const [policy, |
| 138 | basePermission: settings.basePermission |
| 139 | membersCanCreatePublicRepositories: |
| 140 | settings.membersCanCreatePublicRepositories, |
| 141 | membersCanCreatePrivateRepositories: |
| 142 | settings.membersCanCreatePrivateRepositories, |
| 143 | allowPrivateForks: settings.allowPrivateForks |
| 144 | defaultBranch: settings.defaultBranch |
| 145 | }); |
| 146 | const changed = |
| 147 | policy.basePermission !== |
| 148 | policy.membersCanCreatePublicRepositories !== |
| 149 | settings.membersCanCreatePublicRepositories || |
| 150 | policy.membersCanCreatePrivateRepositories !== |
| 151 | settings.membersCanCreatePrivateRepositories || |
| 152 | policy.allowPrivateForks !== |
| 153 | policy.defaultBranch !== |
| 154 | const toggle = |
| 155 | key: |
| 156 | | "membersCanCreatePublicRepositories |
| 157 | | "membersCanCreatePrivateRepositories |
| 158 | | "allowPrivateForks" |
| 159 | title: string, |
| 160 | description: string, |
| 161 | ) => ( |
| 162 | <Field orientation=" |
| 163 | <FieldContent> |
| 164 | <FieldTitle>{title} |
| 165 | <FieldDescription>{description} |
| 166 | </FieldContent> |
| 167 | <Switch |
| 168 | aria-label={title} |
| 169 | checked={policy[key |
| 170 | onCheckedChange={(checked) |
| 171 | /> |
| 172 | </Field> |
| 173 | ); |
| 174 | |
| 175 | return ( |
| 176 | <SettingCard |
| 177 | title="Member privileges" |
| 178 | hint="Admins and owners are never limited by these." |
| 179 | onSubmit={() => |
| 180 | save( |
| 181 | { ...policy, |
| 182 | "Member privileges saved", |
| 183 | ) |
| 184 | } |
| 185 | pending={saving} |
| 186 | canSave={changed} |
| 187 | > |
| 188 | <FieldGroup> |
| 189 | <Field orientation=" |
| 190 | <FieldContent> |
| 191 | <FieldTitle>Base permission</FieldTitle |
| 192 | <FieldDescription> |
| 193 | Every member's access to every repository. Teams and |
| 194 | collaborators can raise it; “None” leaves private |
| 195 | repositories to them. |
| 196 | </FieldDescription> |
| 197 | </FieldContent> |
| 198 | <RoleSelect<CollaboratorRole |
| 199 | label="Base permission" |
| 200 | roles={[NONE, |
| 201 | labels={{ ... |
| 202 | value={policy.basePermission |
| 203 | onChange={(next) |
| 204 | setPolicy({ |
| 205 | ...policy, |
| 206 | basePermission: next === |
| 207 | }) |
| 208 | } |
| 209 | /> |
| 210 | </Field> |
| 211 | {toggle( |
| 212 | "membersCanCreatePublicRepositories", |
| 213 | "Members can create public repositories", |
| 214 | "Otherwise only admins can.", |
| 215 | )} |
| 216 | {toggle( |
| 217 | "membersCanCreatePrivateRepositories", |
| 218 | "Members can create private repositories", |
| 219 | "Otherwise only admins can.", |
| 220 | )} |
| 221 | {toggle( |
| 222 | "allowPrivateForks", |
| 223 | "Allow forking private repositories", |
| 224 | "Anyone who can read one could then copy it outside the organization.", |
| 225 | )} |
| 226 | <Field> |
| 227 | <FieldLabel htmlFor=" |
| 228 | Default branch for new repositories |
| 229 | </FieldLabel> |
| 230 | <Input |
| 231 | id="organization-default-branch" |
| 232 | placeholder="main" |
| 233 | value={policy.defaultBranch |
| 234 | onChange={(event) |
| 235 | setPolicy({ ...policy |
| 236 | } |
| 237 | className="max-w-xs" |
| 238 | /> |
| 239 | <FieldDescription> |
| 240 | A repository opens on it once a branch by that name is pushed. |
| 241 | </FieldDescription> |
| 242 | </Field> |
| 243 | </FieldGroup> |
| 244 | </SettingCard> |
| 245 | ); |
| 246 | } |
| 247 | |
| 248 | const MAX_PINNED = 6 |
| 249 | |
| 250 | /** Which repositories open the profile, in the order they are picked. */ |
| 251 | export function PinnedRepositoriesSetting({ |
| 252 | slug, |
| 253 | repositories, |
| 254 | pinned, |
| 255 | }: { |
| 256 | slug: string |
| 257 | repositories: string |
| 258 | pinned: string |
| 259 | }) { |
| 260 | const router = |
| 261 | const [picked, |
| 262 | const [saving, |
| 263 | const changed = |
| 264 | |
| 265 | async function save |
| 266 | setSaving(true); |
| 267 | const { error |
| 268 | params: { path: { |
| 269 | body: { repositories: picked |
| 270 | }); |
| 271 | setSaving(false); |
| 272 | if (error) return |
| 273 | toast.success("Pinned repositories saved |
| 274 | router.refresh(); |
| 275 | } |
| 276 | |
| 277 | return ( |
| 278 | <SettingCard |
| 279 | title="Pinned repositories" |
| 280 | hint={`Up to ${MAX_PINNED |
| 281 | onSubmit={save} |
| 282 | pending={saving} |
| 283 | canSave={changed} |
| 284 | > |
| 285 | {repositories.length === |
| 286 | <p className="text-sm text-muted-foreground |
| 287 | The organization has no repositories yet. |
| 288 | </p> |
| 289 | ) : ( |
| 290 | <ul className="grid gap-2 sm:grid-cols-2 |
| 291 | {repositories.map(( |
| 292 | const position = |
| 293 | return ( |
| 294 | <li key={repository |
| 295 | <Field orientation=" |
| 296 | <Checkbox |
| 297 | id={`pin-${repository |
| 298 | checked={position >= |
| 299 | disabled={position < |
| 300 | onCheckedChange={(checked) |
| 301 | setPicked( |
| 302 | checked |
| 303 | ? [...picked, |
| 304 | : picked.filter( |
| 305 | ) |
| 306 | } |
| 307 | /> |
| 308 | <FieldLabel |
| 309 | htmlFor={`pin-${repository |
| 310 | className="font-normal" |
| 311 | > |
| 312 | {repository} |
| 313 | {position >= 0 |
| 314 | <span className="text-muted-foreground |
| 315 | #{position + 1} |
| 316 | </span> |
| 317 | )} |
| 318 | </FieldLabel> |
| 319 | </Field> |
| 320 | </li> |
| 321 | ); |
| 322 | })} |
| 323 | </ul> |
| 324 | )} |
| 325 | </SettingCard> |
| 326 | ); |
| 327 | } |