| 1 | import { Check, KeyRound, X } from "lucide-react"; |
| 2 | import { Form, Link, redirect } from "react-router"; |
| 3 | |
| 4 | import type { MemberToken } from "@g1t/contracts"; |
| 5 | |
| 6 | import type { Route } from "./+types/personal-access-tokens"; |
| 7 | import { page } from "../../lib/meta"; |
| 8 | import { Avatar, EmptyState, ErrorText, Input, SubmitButton, TimeAgo } from "../../components/ui"; |
| 9 | import { Badge } from "../../components/ui/badge"; |
| 10 | import { SelectField } from "../../components/ui/select"; |
| 11 | import { SwitchCard } from "../../components/ui/switch"; |
| 12 | import { TokenBadges, TokenFacts, TokenMeta } from "../../components/token-list"; |
| 13 | import { identity } from "../../lib/services.server"; |
| 14 | import { currentTokensPath, lifetimeFromForm } from "../../lib/access-tokens"; |
| 15 | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server"; |
| 16 | |
| 17 | // A workspace's rules for its members' personal access tokens, the tokens |
| 18 | // that reach it, and approving the ones made for it. Owners only; identity |
| 19 | // decides (services/identity/src/token_reach.rs). |
| 20 | |
| 21 | export function meta({ params, ...args }: Route.MetaArgs) { |
| 22 | return page(args, { title: `Personal access tokens · ${params.owner} · g1t` }); |
| 23 | } |
| 24 | |
| 25 | export async function loader({ request, params, context }: Route.LoaderArgs) { |
| 26 | const slug = params.owner.toLowerCase(); |
| 27 | // `?kind=` filtered the two kinds tokens used to come in. |
| 28 | const moved = currentTokensPath(`/${slug}/-/personal-access-tokens`, new URL(request.url).searchParams); |
| 29 | if (moved) throw redirect(moved, 301); |
| 30 | const viewer = getViewer(context); |
| 31 | const role = roleIn(viewer, params.owner); |
| 32 | if (!viewer || role !== "owner") return { slug, role, policy: null, tokens: [] as MemberToken[] }; |
| 33 | const [policy, tokens] = await Promise.all([identity.getTokenPolicy(slug, viewer), identity.listMemberTokens(viewer, slug)]); |
| 34 | return { slug, role, policy: unwrap(policy), tokens: unwrap(tokens) }; |
| 35 | } |
| 36 | |
| 37 | export async function action({ request, params, context }: Route.ActionArgs) { |
| 38 | assertSameOrigin(request); |
| 39 | const user = requireUser(context, request); |
| 40 | const form = await request.formData(); |
| 41 | const slug = params.owner; |
| 42 | const id = String(form.get("id") ?? ""); |
| 43 | const reason = String(form.get("reason") ?? "").trim() || null; |
| 44 | switch (form.get("intent")) { |
| 45 | case "policy": { |
| 46 | const lifetime = lifetimeFromForm(form.get("max_lifetime_days")); |
| 47 | if (!lifetime.ok) return { error: lifetime.error, saved: false }; |
| 48 | const saved = await identity.setTokenPolicy(user, slug, { |
| 49 | allowTokensForAllWorkspaces: form.get("allow_tokens_for_all_workspaces") === "on", |
| 50 | allowTokensForThisWorkspace: form.get("allow_tokens_for_this_workspace") === "on", |
| 51 | requireApproval: form.get("require_approval") === "on", |
| 52 | maxLifetimeDays: lifetime.value ?? 0, |
| 53 | forbidNoExpiry: form.get("forbid_no_expiry") === "on", |
| 54 | }); |
| 55 | return saved.ok ? { error: null, saved: true } : { error: saved.error.message, saved: false }; |
| 56 | } |
| 57 | case "approve": |
| 58 | case "deny": { |
| 59 | const reviewed = await identity.reviewTokenRequest(user, slug, id, form.get("intent") === "approve", reason); |
| 60 | return reviewed.ok ? { error: null, saved: false } : { error: reviewed.error.message, saved: false }; |
| 61 | } |
| 62 | case "revoke": { |
| 63 | const revoked = await identity.revokeMemberToken(user, slug, id, reason); |
| 64 | return revoked.ok ? { error: null, saved: false } : { error: revoked.error.message, saved: false }; |
| 65 | } |
| 66 | } |
| 67 | return null; |
| 68 | } |
| 69 | |
| 70 | const LIFETIMES = [ |
| 71 | ["", "No limit"], |
| 72 | ["7", "7 days"], |
| 73 | ["30", "30 days"], |
| 74 | ["60", "60 days"], |
| 75 | ["90", "90 days"], |
| 76 | ["180", "180 days"], |
| 77 | ["366", "1 year"], |
| 78 | ] as const; |
| 79 | |
| 80 | export default function PersonalAccessTokens({ loaderData, actionData }: Route.ComponentProps) { |
| 81 | const { slug, role, policy, tokens } = loaderData; |
| 82 | if (role !== "owner" || !policy) { |
| 83 | return ( |
| 84 | <EmptyState title="Owners only"> |
| 85 | Only an owner of {slug} can see which personal access tokens reach it. Your own tokens are under{" "} |
| 86 | <Link to="/settings/tokens" className="text-fg underline underline-offset-4"> |
| 87 | your settings |
| 88 | </Link> |
| 89 | . |
| 90 | </EmptyState> |
| 91 | ); |
| 92 | } |
| 93 | const pending = tokens.filter((member) => member.token.status === "pending"); |
| 94 | const listed = tokens.filter((member) => member.token.status !== "pending"); |
| 95 | const lifetime = policy.maxLifetimeDays == null ? "" : String(policy.maxLifetimeDays); |
| 96 | return ( |
| 97 | <div className="max-w-3xl space-y-10"> |
| 98 | <section aria-labelledby="pat-rules"> |
| 99 | <h2 id="pat-rules" className="font-medium"> |
| 100 | Rules |
| 101 | </h2> |
| 102 | <p className="mt-1 text-sm text-muted"> |
| 103 | They apply from each token's next request, to tokens made before them too. A token they keep out still works |
| 104 | elsewhere, and reads {slug}'s public repositories as anyone can. |
| 105 | </p> |
| 106 | <Form method="post" className="mt-4 space-y-3"> |
| 107 | <input type="hidden" name="intent" value="policy" /> |
| 108 | <SwitchCard |
| 109 | name="allow_tokens_for_this_workspace" |
| 110 | defaultChecked={policy.allowTokensForThisWorkspace} |
| 111 | title={`Allow tokens made for ${slug}`} |
| 112 | > |
| 113 | Members may make tokens for {slug} alone, reaching only the repositories and permissions they choose. |
| 114 | </SwitchCard> |
| 115 | <SwitchCard name="require_approval" defaultChecked={policy.requireApproval} title={`Require approval of tokens made for ${slug}`}> |
| 116 | A member's token made for {slug} waits for an owner to approve it, and again when they widen it. Owners' own |
| 117 | tokens never wait. |
| 118 | </SwitchCard> |
| 119 | <SwitchCard |
| 120 | name="allow_tokens_for_all_workspaces" |
| 121 | defaultChecked={policy.allowTokensForAllWorkspaces} |
| 122 | title="Allow tokens made for all of a member's workspaces" |
| 123 | > |
| 124 | A token made for every workspace its owner belongs to reaches {slug} too. Off: such tokens no longer reach {slug}, |
| 125 | and members make a token for {slug} alone instead. |
| 126 | </SwitchCard> |
| 127 | <SwitchCard name="forbid_no_expiry" defaultChecked={policy.forbidNoExpiry} title="Tokens must expire"> |
| 128 | A token that never expires does not reach {slug}. |
| 129 | </SwitchCard> |
| 130 | <div className="flex flex-col gap-3 rounded-xl border border-line bg-surface p-4 sm:flex-row sm:items-center sm:justify-between"> |
| 131 | <label htmlFor="pat-lifetime" className="min-w-0"> |
| 132 | <span className="block text-sm font-medium text-fg">Longest lifetime</span> |
| 133 | <span className="mt-1 block text-sm text-muted">A token that lasts longer does not reach {slug}.</span> |
| 134 | </label> |
| 135 | <SelectField |
| 136 | id="pat-lifetime" |
| 137 | name="max_lifetime_days" |
| 138 | defaultValue={LIFETIMES.some(([value]) => value === lifetime) ? lifetime : ""} |
| 139 | className="h-auto w-full py-2 sm:w-40" |
| 140 | options={LIFETIMES.map(([value, label]) => ({ value, label }))} |
| 141 | /> |
| 142 | </div> |
| 143 | <div className="flex items-center gap-3"> |
| 144 | <SubmitButton match={{ intent: "policy" }} pending="Saving…"> |
| 145 | Save rules |
| 146 | </SubmitButton> |
| 147 | {actionData?.saved && <span className="text-sm text-success">Saved.</span>} |
| 148 | {policy.updatedBy && ( |
| 149 | <span className="text-xs text-faint"> |
| 150 | Last changed by <span className="font-mono">{policy.updatedBy}</span> |
| 151 | {policy.updatedAt && ( |
| 152 | <> |
| 153 | {" "} |
| 154 | <TimeAgo at={policy.updatedAt} /> |
| 155 | </> |
| 156 | )} |
| 157 | </span> |
| 158 | )} |
| 159 | </div> |
| 160 | </Form> |
| 161 | </section> |
| 162 | |
| 163 | <ErrorText>{actionData?.error}</ErrorText> |
| 164 | |
| 165 | <section aria-labelledby="pat-requests"> |
| 166 | <h2 id="pat-requests" className="flex items-center gap-2 font-medium"> |
| 167 | Waiting for approval |
| 168 | {pending.length > 0 && <Badge tone="warn">{pending.length}</Badge>} |
| 169 | </h2> |
| 170 | {pending.length === 0 ? ( |
| 171 | <p className="mt-2 text-sm text-muted">No token is waiting for approval.</p> |
| 172 | ) : ( |
| 173 | <ul className="mt-3 divide-y divide-line rounded-xl border border-line"> |
| 174 | {pending.map((member) => ( |
| 175 | <li key={member.token.id} className="px-4 py-3"> |
| 176 | <TokenLine member={member} /> |
| 177 | <Form method="post" className="mt-3 flex flex-col gap-2 sm:flex-row sm:items-center"> |
| 178 | <input type="hidden" name="id" value={member.token.id} /> |
| 179 | <div className="min-w-0 grow"> |
| 180 | <Input name="reason" maxLength={500} placeholder="Note for the owner (optional)" aria-label="Note for the token's owner" /> |
| 181 | </div> |
| 182 | <div className="flex shrink-0 gap-2"> |
| 183 | <SubmitButton name="intent" value="approve" match={{ intent: "approve", id: member.token.id }} pending="Approving…"> |
| 184 | <Check size={14} /> Approve |
| 185 | </SubmitButton> |
| 186 | <SubmitButton name="intent" value="deny" variant="quiet" match={{ intent: "deny", id: member.token.id }} pending="Denying…"> |
| 187 | <X size={14} /> Deny |
| 188 | </SubmitButton> |
| 189 | </div> |
| 190 | </Form> |
| 191 | </li> |
| 192 | ))} |
| 193 | </ul> |
| 194 | )} |
| 195 | </section> |
| 196 | |
| 197 | <section aria-labelledby="pat-active"> |
| 198 | <h2 id="pat-active" className="font-medium"> |
| 199 | Tokens that can reach {slug} |
| 200 | </h2> |
| 201 | <p className="mt-1 text-sm text-muted"> |
| 202 | Members' and outside collaborators' tokens, never the tokens themselves. The workspace's own tokens are under{" "} |
| 203 | <Link to={`/${slug}/-/tokens`} className="text-fg underline underline-offset-4"> |
| 204 | Access tokens |
| 205 | </Link> |
| 206 | . |
| 207 | </p> |
| 208 | {listed.length === 0 ? ( |
| 209 | <div className="mt-3"> |
| 210 | <EmptyState title="No tokens">No member has a personal access token that can reach {slug}.</EmptyState> |
| 211 | </div> |
| 212 | ) : ( |
| 213 | <ul className="mt-3 divide-y divide-line rounded-xl border border-line"> |
| 214 | {listed.map((member) => ( |
| 215 | <li key={member.token.id} className="flex flex-col gap-3 px-4 py-3 sm:flex-row sm:items-start"> |
| 216 | <div className="min-w-0 grow"> |
| 217 | <TokenLine member={member} /> |
| 218 | </div> |
| 219 | {member.token.status !== "revoked" && member.blockedBy !== "revoked" && ( |
| 220 | <Form method="post" className="shrink-0"> |
| 221 | <input type="hidden" name="intent" value="revoke" /> |
| 222 | <input type="hidden" name="id" value={member.token.id} /> |
| 223 | <SubmitButton variant="quiet" match={{ intent: "revoke", id: member.token.id }} pending="Revoking…"> |
| 224 | Revoke |
| 225 | </SubmitButton> |
| 226 | </Form> |
| 227 | )} |
| 228 | </li> |
| 229 | ))} |
| 230 | </ul> |
| 231 | )} |
| 232 | </section> |
| 233 | </div> |
| 234 | ); |
| 235 | } |
| 236 | |
| 237 | /** One member's token: whose, what it is, what it reaches and may do. */ |
| 238 | function TokenLine({ member }: { member: MemberToken }) { |
| 239 | const { token } = member; |
| 240 | return ( |
| 241 | <div className="min-w-0 space-y-1.5"> |
| 242 | <p className="flex min-w-0 flex-wrap items-center gap-2 text-sm"> |
| 243 | <Avatar name={member.owner} size={18} /> |
| 244 | <Link to={`/u/${member.owner}`} className="font-mono text-fg hover:underline"> |
| 245 | {member.owner} |
| 246 | </Link> |
| 247 | <span className="flex min-w-0 items-center gap-1.5 font-medium"> |
| 248 | <KeyRound size={13} className="shrink-0 text-faint" /> |
| 249 | <span className="truncate">{token.name}</span> |
| 250 | </span> |
| 251 | <TokenBadges token={token} /> |
| 252 | {(token.status ?? "active") === "active" && !member.reaches && member.blockedBy && ( |
| 253 | <Badge tone="danger">Kept out: {member.blockedBy}</Badge> |
| 254 | )} |
| 255 | </p> |
| 256 | {token.description && <p className="text-xs text-muted">{token.description}</p>} |
| 257 | <TokenMeta token={token} /> |
| 258 | <TokenFacts token={token} /> |
| 259 | </div> |
| 260 | ); |
| 261 | } |