| 1 | 1 | | /** |
| 2 | | − | * A project's or a workspace's secrets and variables, as one list in the |
| 3 | | − | * way Vercel lists environment variables: each row is a key, its type |
| 2 | + | * A project's or a workspace's secrets and variables, separated by scope: |
| 3 | + | * each row is a key, its type |
| 4 | 4 | | * (Secret or Config), the environments it applies to and who reads it. |
| 5 | 5 | | * Adding and editing happen in a side panel, opened by `?add` or |
| 6 | 6 | | * `?edit=<id>` so the page works without scripts. |
| ⋯ |
| 11 | 11 | | |
| 12 | 12 | | import type { Setting } from "@g1t/contracts"; |
| 13 | 13 | | |
| 14 | + | import { settingOverride } from "../lib/setting-overrides"; |
| 15 | + | import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./ui/table"; |
| 16 | + | |
| 14 | 17 | | import type { SecretsAction, SecretsData } from "../lib/secrets.server"; |
| 15 | 18 | | import { ButtonLink, EmptyState, ErrorText, SubmitButton, TimeAgo } from "./ui"; |
| 16 | 19 | | import { Badge } from "./ui/badge"; |
| ⋯ |
| 38 | 41 | | action, |
| 39 | 42 | | scope, |
| 40 | 43 | | manage, |
| 44 | + | workspacePath, |
| 41 | 45 | | }: { |
| 42 | 46 | | data: SecretsData; |
| 43 | 47 | | action: SecretsAction | undefined; |
| 44 | 48 | | scope: "project" | "workspace"; |
| 45 | 49 | | manage: boolean; |
| 50 | + | workspacePath?: string; |
| 46 | 51 | | }) { |
| 47 | 52 | | const location = useLocation(); |
| 48 | 53 | | const params = new URLSearchParams(location.search); |
| ⋯ |
| 64 | 69 | | ); |
| 65 | 70 | | |
| 66 | 71 | | return ( |
| 67 | | − | <div className="max-w-5xl"> |
| 72 | + | <div className="min-w-0 max-w-5xl"> |
| 68 | 73 | | <header className="flex flex-wrap items-start justify-between gap-4"> |
| 69 | 74 | | <div> |
| 70 | 75 | | <h1 className="text-lg font-semibold tracking-tight">{scope === "project" ? "Environment variables" : "Secrets and variables"}</h1> |
| 71 | 76 | | <p className="mt-1 max-w-2xl text-sm text-muted"> |
| 72 | | − | One list for everything that reads them. Each row says which environments it applies to and whether{" "} |
| 73 | | − | <strong className="font-medium text-fg">workflows</strong> (as <code className="text-fg">secrets.KEY</code>{" "} |
| 74 | | − | and <code className="text-fg">vars.KEY</code>), <strong className="font-medium text-fg">deployments</strong>{" "} |
| 75 | | − | (the build's environment and the running app's <code className="text-fg">env.KEY</code>), or both read it. |
| 77 | + | Secrets and configuration for workflows and deployments, scoped by environment. |
| 76 | 78 | | {scope === "workspace" |
| 77 | | − | ? " Every project, or the ones you link, reads the workspace's; a project's own row of the same key wins." |
| 78 | | − | : " Rows from the workspace are shown too; adding the same key here replaces them for this project."}{" "} |
| 79 | + | ? " Share values with every project or only the projects you choose." |
| 80 | + | : " Project keys override workspace keys of the same type for each shared reader."}{" "} |
| 79 | 81 | | <a href="https://docs.g1t.sh/guides/secrets-and-variables/" className="text-fg hover:underline"> |
| 80 | 82 | | How they are read |
| 81 | 83 | | </a> |
| ⋯ |
| 138 | 140 | | <ErrorText>{data.error}</ErrorText> |
| 139 | 141 | | {!editing && !adding && <div className="mt-2"><ErrorText>{action?.error}</ErrorText></div>} |
| 140 | 142 | | |
| 141 | | − | <div className="mt-4"> |
| 142 | | − | {data.rows.length === 0 ? ( |
| 143 | | − | <EmptyState title="No secrets or variables yet"> |
| 144 | | − | Add one, or paste a <code>.env</code> file into Add to bring many at once. |
| 145 | | − | </EmptyState> |
| 146 | | − | ) : shown.length === 0 ? ( |
| 147 | | − | <EmptyState title="Nothing matches" /> |
| 148 | | − | ) : ( |
| 149 | | − | <Card asChild className="overflow-hidden"> |
| 150 | | − | <ul> |
| 151 | | − | {shown.map((r) => ( |
| 152 | | − | <Row key={r.id} row={r} inherited={r.scope !== scope} manage={manage} /> |
| 153 | | − | ))} |
| 154 | | − | </ul> |
| 155 | | − | </Card> |
| 156 | | − | )} |
| 143 | + | <div className="mt-6 space-y-8"> |
| 144 | + | {(scope === "project" ? ["project", "workspace"] as const : ["workspace"] as const).map((section) => { |
| 145 | + | const rows = shown.filter((r) => r.scope === section); |
| 146 | + | const total = data.rows.filter((r) => r.scope === section).length; |
| 147 | + | return ( |
| 148 | + | <section key={section} aria-label={`${section === "project" ? "Project" : "Workspace"} secrets and variables`}> |
| 149 | + | <div className="mb-3 flex flex-wrap items-center justify-between gap-2"> |
| 150 | + | <div> |
| 151 | + | <h2 className="text-sm font-semibold">{section === "project" ? "Project secrets and variables" : "Workspace secrets and variables"}</h2> |
| 152 | + | {scope === "project" && section === "workspace" && <p className="mt-1 text-xs text-muted">Inherited values shared with this project. A project key replaces the workspace value for its readers, even outside its selected environments.</p>} |
| 153 | + | </div> |
| 154 | + | {scope === "project" && section === "workspace" && workspacePath && <ButtonLink to={workspacePath} variant="outline">View workspace variables</ButtonLink>} |
| 155 | + | </div> |
| 156 | + | {rows.length === 0 ? ( |
| 157 | + | <EmptyState title={total > 0 ? "Nothing matches these filters" : section === "project" ? "No project secrets or variables" : "No workspace secrets or variables"} /> |
| 158 | + | ) : ( |
| 159 | + | <Card className="min-w-0 overflow-hidden"> |
| 160 | + | <Table className="min-w-[850px] table-fixed" frameProps={{ tabIndex: 0, role: "region", "aria-label": `${section} secrets and variables` }}> |
| 161 | + | <colgroup><col style={{ width: "32%" }} /><col style={{ width: "19%" }} /><col style={{ width: "18%" }} /><col style={{ width: "10%" }} /><col style={{ width: "11%" }} /><col style={{ width: "10%" }} /></colgroup> |
| 162 | + | <TableHeader><TableRow> |
| 163 | + | <TableHead>Name / value</TableHead><TableHead>Environments</TableHead><TableHead>Used by</TableHead><TableHead>Type</TableHead><TableHead>Updated</TableHead><TableHead><span className="sr-only">Actions</span></TableHead> |
| 164 | + | </TableRow></TableHeader> |
| 165 | + | <TableBody>{rows.map((r) => <Row key={r.id} row={r} inherited={r.scope !== scope} manage={manage} override={scope === "project" ? settingOverride(r, data.rows) : null} />)}</TableBody> |
| 166 | + | </Table> |
| 167 | + | </Card> |
| 168 | + | )} |
| 169 | + | </section> |
| 170 | + | ); |
| 171 | + | })} |
| 157 | 172 | | </div> |
| 158 | 173 | | |
| 159 | 174 | | {manage && (adding || row) && ( |
| ⋯ |
| 164 | 179 | | ); |
| 165 | 180 | | } |
| 166 | 181 | | |
| 167 | | − | function Row({ row, inherited, manage }: { row: Setting; inherited: boolean; manage: boolean }) { |
| 182 | + | function Row({ row, inherited, manage, override }: { row: Setting; inherited: boolean; manage: boolean; override: string | null }) { |
| 168 | 183 | | const secret = row.kind === "secret"; |
| 169 | 184 | | const hosts = row.brokeredHosts ?? []; |
| 170 | 185 | | return ( |
| 171 | | − | <li className="grid grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_auto] items-center gap-x-4 gap-y-1 border-t border-line px-4 py-3 text-sm first:border-t-0 md:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_minmax(0,1fr)_6rem_6rem_auto]"> |
| 186 | + | <TableRow> |
| 187 | + | <TableCell className="align-middle"> |
| 172 | 188 | | <div className="min-w-0"> |
| 173 | 189 | | <div className="flex min-w-0 items-center gap-2"> |
| 174 | | − | <p className="truncate font-mono text-[0.8125rem]">{row.name}</p> |
| 190 | + | <p className="min-w-0 break-all font-mono text-[0.8125rem]">{row.name}</p> |
| 175 | 191 | | {secret && hosts.length > 0 && ( |
| 176 | 192 | | <Hint label={`Filled in for ${hosts.join(", ")}`}> |
| 177 | 193 | | <Badge tone="accent"> |
| ⋯ |
| 181 | 197 | | </Hint> |
| 182 | 198 | | )} |
| 183 | 199 | | </div> |
| 184 | | − | {row.note && <p className="truncate text-xs text-faint">{row.note}</p>} |
| 200 | + | {row.note && <p className="truncate text-xs text-faint" title={row.note}>{row.note}</p>} |
| 201 | + | {override && <p className="mt-1 text-xs text-accent">{override}</p>} |
| 185 | 202 | | {!secret && row.value != null && <p className="truncate font-mono text-xs text-muted">{row.value}</p>} |
| 186 | 203 | | </div> |
| 187 | | − | <span className="truncate text-muted">{environmentsLabel(row.environments)}</span> |
| 188 | | − | <span className="hidden truncate text-xs text-muted md:block"> |
| 189 | | − | {row.availableTo.map((r) => READERS[r] ?? r).join(" · ")} |
| 190 | | − | </span> |
| 191 | | − | <span className="hidden items-center gap-1.5 text-xs text-muted md:flex"> |
| 192 | | − | {secret ? <Lock size={13} /> : <SlidersHorizontal size={13} />} |
| 193 | | − | {secret ? "Secret" : "Config"} |
| 194 | | − | </span> |
| 195 | | − | <span className="hidden text-xs text-faint md:block"> |
| 196 | | − | <TimeAgo at={row.updatedAt} /> |
| 197 | | − | </span> |
| 198 | | − | <span className="flex items-center justify-end gap-1"> |
| 199 | | − | {inherited ? ( |
| 200 | | − | <span className="rounded-full px-2 py-px text-xs text-muted ring-1 ring-line">Workspace</span> |
| 201 | | − | ) : ( |
| 204 | + | </TableCell> |
| 205 | + | <TableCell className="align-middle break-words text-muted">{environmentsLabel(row.environments)}</TableCell> |
| 206 | + | <TableCell className="align-middle text-xs text-muted">{row.availableTo.map((r) => READERS[r] ?? r).join(", ")}</TableCell> |
| 207 | + | <TableCell className="align-middle text-xs text-muted"><span className="flex items-center gap-1.5"> |
| 208 | + | {secret ? <Lock size={13} className="shrink-0" /> : <SlidersHorizontal size={13} className="shrink-0" />}{secret ? "Secret" : "Config"} |
| 209 | + | </span></TableCell> |
| 210 | + | <TableCell className="align-middle text-xs text-faint"><TimeAgo at={row.updatedAt} /></TableCell> |
| 211 | + | <TableCell className="align-middle"><div className="flex flex-wrap items-center justify-end gap-1"> |
| 212 | + | {!inherited && ( |
| 202 | 213 | | <> |
| 203 | 214 | | {row.projects.length > 0 && ( |
| 204 | 215 | | <Hint label={row.projects.join(", ")}> |
| ⋯ |
| 236 | 247 | | )} |
| 237 | 248 | | </> |
| 238 | 249 | | )} |
| 239 | | − | </span> |
| 240 | | − | </li> |
| 250 | + | </div></TableCell> |
| 251 | + | </TableRow> |
| 241 | 252 | | ); |
| 242 | 253 | | } |
| 243 | 254 | | |