@skillcdn/console 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/README.md +50 -2
  2. package/SKILLCDN.md +35 -0
  3. package/dist/cli/cli.d.ts +3 -0
  4. package/dist/cli/cli.d.ts.map +1 -1
  5. package/dist/cli/cli.js +61 -0
  6. package/dist/cli/cli.js.map +1 -1
  7. package/dist/cli/help.d.ts.map +1 -1
  8. package/dist/cli/help.js +5 -0
  9. package/dist/cli/help.js.map +1 -1
  10. package/dist/cli/main.js +2 -0
  11. package/dist/cli/main.js.map +1 -1
  12. package/dist/cli/serve.d.ts +37 -0
  13. package/dist/cli/serve.d.ts.map +1 -0
  14. package/dist/cli/serve.js +218 -0
  15. package/dist/cli/serve.js.map +1 -0
  16. package/dist/console.d.ts +6 -0
  17. package/dist/console.d.ts.map +1 -1
  18. package/dist/console.js +78 -13
  19. package/dist/console.js.map +1 -1
  20. package/dist/data.d.ts.map +1 -1
  21. package/dist/data.js +5 -1
  22. package/dist/data.js.map +1 -1
  23. package/dist/i18n/en.d.ts +4 -0
  24. package/dist/i18n/en.d.ts.map +1 -1
  25. package/dist/i18n/en.js +4 -0
  26. package/dist/i18n/en.js.map +1 -1
  27. package/dist/i18n/ko.d.ts.map +1 -1
  28. package/dist/i18n/ko.js +4 -0
  29. package/dist/i18n/ko.js.map +1 -1
  30. package/dist/schemas.d.ts +1 -0
  31. package/dist/schemas.d.ts.map +1 -1
  32. package/dist/schemas.js +5 -0
  33. package/dist/schemas.js.map +1 -1
  34. package/dist/web.d.ts +16 -0
  35. package/dist/web.d.ts.map +1 -0
  36. package/dist/web.js +149 -0
  37. package/dist/web.js.map +1 -0
  38. package/docs/specs/cli.md +92 -0
  39. package/docs/specs/rest.md +221 -0
  40. package/package.json +14 -3
  41. package/skills/building-a-console/SKILL.md +102 -0
  42. package/skills/building-a-console/references/the-default-console.md +140 -0
  43. package/skills/deploying-a-console/SKILL.md +130 -0
  44. package/skills/deploying-a-console/assets/deployer-policy.json +118 -0
  45. package/skills/deploying-a-console/assets/secrets.example.json +11 -0
  46. package/skills/deploying-a-console/references/access.md +78 -0
  47. package/skills/deploying-a-console/references/resources.md +516 -0
  48. package/skills/deploying-a-console/references/sizing.md +90 -0
  49. package/skills/deploying-a-console/scripts/import-aws-key.mjs +35 -0
  50. package/skills/deploying-a-console/scripts/store-secrets.mjs +114 -0
  51. package/skills/working-the-board/SKILL.md +86 -0
  52. package/src/api.ts +235 -0
  53. package/src/cli/cli.ts +1114 -0
  54. package/src/cli/credentials.ts +68 -0
  55. package/src/cli/directory.ts +59 -0
  56. package/src/cli/format.ts +284 -0
  57. package/src/cli/help.ts +326 -0
  58. package/src/cli/main.ts +39 -0
  59. package/src/cli/serve.ts +276 -0
  60. package/src/cli/terminal.ts +28 -0
  61. package/src/client.ts +533 -0
  62. package/src/components/board.tsx +112 -0
  63. package/src/components/connect.tsx +126 -0
  64. package/src/components/decision-form.tsx +136 -0
  65. package/src/components/decision-list.tsx +278 -0
  66. package/src/components/documents.tsx +569 -0
  67. package/src/components/event-feed.tsx +129 -0
  68. package/src/components/markdown.tsx +72 -0
  69. package/src/components/people.tsx +59 -0
  70. package/src/components/projects.tsx +364 -0
  71. package/src/components/runs.tsx +175 -0
  72. package/src/components/shell.tsx +284 -0
  73. package/src/components/sign-in.tsx +61 -0
  74. package/src/components/skills.tsx +78 -0
  75. package/src/components/task-form.tsx +229 -0
  76. package/src/components/task-view.tsx +272 -0
  77. package/src/components/tokens.tsx +223 -0
  78. package/src/components/ui.tsx +219 -0
  79. package/src/connect-page.tsx +148 -0
  80. package/src/connect.ts +27 -0
  81. package/src/console.tsx +1166 -0
  82. package/src/data.ts +493 -0
  83. package/src/docs-page.tsx +334 -0
  84. package/src/documents.ts +76 -0
  85. package/src/i18n/en.ts +537 -0
  86. package/src/i18n/index.ts +76 -0
  87. package/src/i18n/ko.ts +511 -0
  88. package/src/index.ts +142 -0
  89. package/src/limits.ts +61 -0
  90. package/src/router.ts +295 -0
  91. package/src/routes.ts +106 -0
  92. package/src/schemas.ts +776 -0
  93. package/src/styles/console.css +2432 -0
  94. package/src/text.ts +33 -0
  95. package/src/use-action.ts +37 -0
  96. package/src/vocabulary.ts +109 -0
  97. package/src/web.ts +170 -0
@@ -0,0 +1,272 @@
1
+ import type {
2
+ RestAnswerInput,
3
+ RestArtifact,
4
+ RestDecision,
5
+ RestDecisionInput,
6
+ RestDecisionPatch,
7
+ RestEvent,
8
+ RestPerson,
9
+ RestRun,
10
+ RestTask,
11
+ RestTaskInput,
12
+ TaskState,
13
+ } from "../api.js";
14
+ import { useWords } from "../i18n/index.js";
15
+ import type { TaskFormKind } from "../router.js";
16
+ import { TASK_STATES } from "../vocabulary.js";
17
+ import { DecisionForm } from "./decision-form.js";
18
+ import { DecisionCard } from "./decision-list.js";
19
+ import { EventFeed } from "./event-feed.js";
20
+ import { Markdown } from "./markdown.js";
21
+ import { RunList } from "./runs.js";
22
+ import { TaskForm } from "./task-form.js";
23
+ import { PersonChip, PriorityBadge, StateBadge, Time } from "./ui.js";
24
+
25
+ // One task: what it is, who it is on, what it links to, the agents at work on it, the decisions
26
+ // about it, the tasks that are part of it, everything that happened to it, and the ways to
27
+ // change it. Takes its data as props and nothing from the network; which of its forms is open
28
+ // is told by the page, since each form has an address of its own (ADR-0010).
29
+
30
+ export interface TaskViewProps {
31
+ readonly task: RestTask;
32
+ readonly people: readonly RestPerson[];
33
+ /** Every task of the board, for the breakdown and for what this one may be part of. */
34
+ readonly tasks: readonly RestTask[];
35
+ readonly decisions: readonly RestDecision[];
36
+ /** The runs of the board, or of this task; the ones on this task are shown. */
37
+ readonly runs?: readonly RestRun[] | undefined;
38
+ /** Everything that happened to the task, oldest first, when the page has read it. */
39
+ readonly history?: readonly RestEvent[] | undefined;
40
+ readonly taskHref: (task: RestTask) => string;
41
+ /** Where a decision is answered, for a run that waits for one. */
42
+ readonly decisionHref?: ((decisionId: string) => string) | undefined;
43
+ /** Where a file a run handed in is read. */
44
+ readonly fileHref?: ((artifact: RestArtifact) => string) | undefined;
45
+ /** Where a document of the project is read, by its path, for the links in what was written. */
46
+ readonly docHref?: ((path: string) => string) | undefined;
47
+ /** The form open on the page, if one: the task edited, or a decision raised about it. */
48
+ readonly form?: TaskFormKind | undefined;
49
+ /** Where each form is opened; left out, the forms cannot be opened from here. */
50
+ readonly formHref?: ((form: TaskFormKind) => string) | undefined;
51
+ /** Called when a form is cancelled. */
52
+ readonly onCancel?: (() => void) | undefined;
53
+ readonly busy?: boolean | undefined;
54
+ readonly error?: string | undefined;
55
+ readonly onChange: (task: RestTask, patch: RestTaskInput) => void;
56
+ readonly onMove: (task: RestTask, state: TaskState) => void;
57
+ readonly onRaiseDecision: (input: RestDecisionInput) => void;
58
+ readonly onAnswer: (decision: RestDecision, input: RestAnswerInput) => void;
59
+ /** Called to grow a decision's record with what followed. */
60
+ readonly onUpdateDecision?:
61
+ | ((decision: RestDecision, patch: RestDecisionPatch) => void)
62
+ | undefined;
63
+ readonly onAbandonRun?: ((run: RestRun) => void) | undefined;
64
+ }
65
+
66
+ export function TaskView(props: TaskViewProps) {
67
+ const words = useWords();
68
+ const { task } = props;
69
+ const parent =
70
+ task.parentId === null
71
+ ? undefined
72
+ : props.tasks.find((candidate) => candidate.id === task.parentId);
73
+ const subtasks = props.tasks.filter((candidate) => candidate.parentId === task.id);
74
+ const about = props.decisions.filter((decision) => decision.taskId === task.id);
75
+ const runs = (props.runs ?? []).filter((run) => run.taskId === task.id);
76
+ const tasksById = new Map(props.tasks.map((candidate) => [candidate.id, candidate]));
77
+
78
+ if (props.form === "edit") {
79
+ return (
80
+ <div className="sc-task sc-task-editing">
81
+ <h1 className="sc-task-title">
82
+ <span className="sc-card-number">#{task.number}</span> {task.title}
83
+ </h1>
84
+ <TaskForm
85
+ people={props.people}
86
+ task={task}
87
+ parents={props.tasks}
88
+ busy={props.busy}
89
+ error={props.error}
90
+ onSubmit={(input) => props.onChange(task, input)}
91
+ onCancel={props.onCancel}
92
+ />
93
+ </div>
94
+ );
95
+ }
96
+
97
+ return (
98
+ <div className="sc-task">
99
+ <header className="sc-task-header">
100
+ {parent !== undefined && (
101
+ <p className="sc-task-parent">
102
+ {words.task.partOf}{" "}
103
+ <a href={props.taskHref(parent)}>
104
+ #{parent.number} {parent.title}
105
+ </a>
106
+ </p>
107
+ )}
108
+ <h1 className="sc-task-title">
109
+ <span className="sc-card-number">#{task.number}</span> {task.title}
110
+ </h1>
111
+ <div className="sc-task-badges">
112
+ <StateBadge state={task.state} />
113
+ <PriorityBadge priority={task.priority} />
114
+ </div>
115
+ </header>
116
+ <div className="sc-task-body">
117
+ <div className="sc-task-main">
118
+ {props.error !== undefined && (
119
+ <p className="sc-form-error" role="alert">
120
+ {props.error}
121
+ </p>
122
+ )}
123
+ {task.body.length > 0 ? (
124
+ <Markdown source={task.body} docHref={props.docHref} />
125
+ ) : (
126
+ <p className="sc-muted">{words.task.nothingMore}</p>
127
+ )}
128
+ {task.links.length > 0 && (
129
+ <section className="sc-task-section" aria-label={words.task.links}>
130
+ <h2 className="sc-section-title">{words.task.links}</h2>
131
+ <ul className="sc-link-list">
132
+ {task.links.map((link) => (
133
+ <li key={link.url}>
134
+ <a href={link.url} target="_blank" rel="noopener noreferrer nofollow">
135
+ {link.label ?? link.url}
136
+ </a>
137
+ </li>
138
+ ))}
139
+ </ul>
140
+ </section>
141
+ )}
142
+ {props.form === "ask" && (
143
+ <section className="sc-task-section" aria-label={words.task.raiseDecision}>
144
+ <h2 className="sc-section-title">{words.task.raiseDecision}</h2>
145
+ <DecisionForm
146
+ tasks={props.tasks}
147
+ taskId={task.id}
148
+ busy={props.busy}
149
+ onSubmit={props.onRaiseDecision}
150
+ onCancel={props.onCancel}
151
+ />
152
+ </section>
153
+ )}
154
+ {runs.length > 0 && (
155
+ <section className="sc-task-section" aria-label={words.task.runs}>
156
+ <h2 className="sc-section-title">{words.task.agentsAtWork}</h2>
157
+ <RunList
158
+ runs={runs}
159
+ decisionHref={props.decisionHref}
160
+ fileHref={props.fileHref}
161
+ docHref={props.docHref}
162
+ onAbandon={props.onAbandonRun}
163
+ busy={props.busy}
164
+ />
165
+ </section>
166
+ )}
167
+ {about.length > 0 && (
168
+ <section className="sc-task-section" aria-label={words.task.decisions}>
169
+ <h2 className="sc-section-title">{words.task.decisions}</h2>
170
+ {about.map((decision) => (
171
+ <DecisionCard
172
+ key={decision.id}
173
+ decision={decision}
174
+ task={decision.taskId === null ? undefined : tasksById.get(decision.taskId)}
175
+ docHref={props.docHref}
176
+ onAnswer={props.onAnswer}
177
+ onUpdate={props.onUpdateDecision}
178
+ busy={props.busy}
179
+ />
180
+ ))}
181
+ </section>
182
+ )}
183
+ {props.history !== undefined && props.history.length > 0 && (
184
+ <section className="sc-task-section" aria-label={words.task.history}>
185
+ <h2 className="sc-section-title">{words.task.history}</h2>
186
+ <EventFeed events={props.history} />
187
+ </section>
188
+ )}
189
+ {subtasks.length > 0 && (
190
+ <section className="sc-task-section" aria-label={words.task.subtasks}>
191
+ <h2 className="sc-section-title">{words.task.partOfIt}</h2>
192
+ <ul className="sc-subtasks">
193
+ {subtasks.map((subtask) => (
194
+ <li key={subtask.id}>
195
+ <a href={props.taskHref(subtask)}>
196
+ <span className="sc-card-number">#{subtask.number}</span> {subtask.title}
197
+ </a>{" "}
198
+ <StateBadge state={subtask.state} />
199
+ </li>
200
+ ))}
201
+ </ul>
202
+ </section>
203
+ )}
204
+ </div>
205
+ <aside className="sc-task-aside" aria-label={words.task.facts}>
206
+ <dl className="sc-facts">
207
+ <div>
208
+ <dt>{words.task.state}</dt>
209
+ <dd>
210
+ <select
211
+ className="sc-input"
212
+ aria-label={words.task.state}
213
+ value={task.state}
214
+ disabled={props.busy === true}
215
+ onChange={(event) => props.onMove(task, event.target.value as TaskState)}
216
+ >
217
+ {TASK_STATES.map((state) => (
218
+ <option key={state} value={state}>
219
+ {words.vocabulary.state[state]}
220
+ </option>
221
+ ))}
222
+ </select>
223
+ </dd>
224
+ </div>
225
+ <div>
226
+ <dt>{words.task.form.priority}</dt>
227
+ <dd>{words.vocabulary.priority[task.priority]}</dd>
228
+ </div>
229
+ <div>
230
+ <dt>{words.task.owner}</dt>
231
+ <dd>
232
+ <PersonChip person={task.owner} />
233
+ </dd>
234
+ </div>
235
+ <div>
236
+ <dt>{words.task.assignee}</dt>
237
+ <dd>
238
+ {task.assignee === null ? (
239
+ <span className="sc-muted">{words.common.nobody}</span>
240
+ ) : (
241
+ <PersonChip person={task.assignee} />
242
+ )}
243
+ </dd>
244
+ </div>
245
+ <div>
246
+ <dt>{words.task.written}</dt>
247
+ <dd>
248
+ <Time iso={task.createdAt} />
249
+ </dd>
250
+ </div>
251
+ <div>
252
+ <dt>{words.task.changed}</dt>
253
+ <dd>
254
+ <Time iso={task.updatedAt} />
255
+ </dd>
256
+ </div>
257
+ </dl>
258
+ {props.formHref !== undefined && (
259
+ <div className="sc-task-actions">
260
+ <a className="sc-button sc-button-secondary" href={props.formHref("edit")}>
261
+ {words.common.edit}
262
+ </a>
263
+ <a className="sc-button sc-button-secondary" href={props.formHref("ask")}>
264
+ {words.task.raiseDecision}
265
+ </a>
266
+ </div>
267
+ )}
268
+ </aside>
269
+ </div>
270
+ </div>
271
+ );
272
+ }
@@ -0,0 +1,223 @@
1
+ import { type FormEvent, type ReactNode, useState } from "react";
2
+ import {
3
+ DEFAULT_TOKEN_DAYS,
4
+ MAX_TOKEN_NAME_LENGTH,
5
+ type RestToken,
6
+ type RestTokenInput,
7
+ } from "../api.js";
8
+ import { useWords } from "../i18n/index.js";
9
+ import { Button, Callout, Time } from "./ui.js";
10
+
11
+ // The tokens a person holds for their agents, scripts and consoles of their own, shown as the
12
+ // agents that hold them: the list, the way to make one by hand, and the one just made, whose
13
+ // secret is shown this once. Each takes its data as props and nothing from the network.
14
+
15
+ /** How long a token may be good for, as the form offers it; `never` for one that does not expire. */
16
+ const DAY_CHOICES = ["30", "90", "180", "365", "never"] as const;
17
+ type DayChoice = (typeof DAY_CHOICES)[number];
18
+ const DEFAULT_CHOICE: DayChoice =
19
+ DAY_CHOICES.find((choice) => choice === String(DEFAULT_TOKEN_DAYS)) ?? "90";
20
+
21
+ /**
22
+ * The spans a form offers: the usual ones, within the most days the workspace allows when it
23
+ * names some, and that limit itself; with a limit there is no "never".
24
+ */
25
+ export function dayChoices(daysAtMost: number | null | undefined): readonly string[] {
26
+ if (daysAtMost === null || daysAtMost === undefined) {
27
+ return DAY_CHOICES;
28
+ }
29
+ const within = DAY_CHOICES.filter((choice) => choice !== "never" && Number(choice) < daysAtMost);
30
+ return [...within, String(daysAtMost)];
31
+ }
32
+
33
+ export interface TokenListProps {
34
+ readonly tokens: readonly RestToken[];
35
+ /** Called to take a token away. Left out, tokens cannot be removed here. */
36
+ readonly onRevoke?: ((token: RestToken) => void) | undefined;
37
+ readonly busy?: boolean | undefined;
38
+ readonly empty?: ReactNode;
39
+ }
40
+
41
+ export function TokenList(props: TokenListProps) {
42
+ const words = useWords().agents;
43
+ if (props.tokens.length === 0) {
44
+ return <>{props.empty ?? null}</>;
45
+ }
46
+ return (
47
+ <ul className="sc-token-list" aria-label={words.list}>
48
+ {props.tokens.map((token) => (
49
+ <li key={token.id} className="sc-token">
50
+ <div>
51
+ <p className="sc-token-name">{token.name}</p>
52
+ <p className="sc-token-meta">
53
+ <span>
54
+ {words.connected} <Time iso={token.createdAt} />
55
+ </span>
56
+ <span>
57
+ {token.expiresAt === null ? (
58
+ words.doesNotExpire
59
+ ) : (
60
+ <>
61
+ {words.goodUntil} <Time iso={token.expiresAt} />
62
+ </>
63
+ )}
64
+ </span>
65
+ <span>
66
+ {token.lastUsedAt === null ? (
67
+ words.notActive
68
+ ) : (
69
+ <>
70
+ {words.lastActive} <Time iso={token.lastUsedAt} />
71
+ </>
72
+ )}
73
+ </span>
74
+ </p>
75
+ </div>
76
+ {props.onRevoke !== undefined && (
77
+ <Button
78
+ variant="danger"
79
+ size="sm"
80
+ disabled={props.busy === true}
81
+ onClick={() => props.onRevoke?.(token)}
82
+ >
83
+ {words.disconnect}
84
+ </Button>
85
+ )}
86
+ </li>
87
+ ))}
88
+ </ul>
89
+ );
90
+ }
91
+
92
+ export interface TokenFormProps {
93
+ /** What the token is called to begin with: what the agent calls itself, for one connecting. */
94
+ readonly name?: string | undefined;
95
+ /** The most days a token may be good for here, or nothing when a person chooses. */
96
+ readonly daysAtMost?: number | null | undefined;
97
+ readonly busy?: boolean | undefined;
98
+ readonly error?: string | undefined;
99
+ /** What the button says; "Make the token" when left out. */
100
+ readonly submitLabel?: string | undefined;
101
+ readonly cancelLabel?: string | undefined;
102
+ readonly onSubmit: (input: RestTokenInput) => void;
103
+ readonly onCancel?: (() => void) | undefined;
104
+ }
105
+
106
+ export function TokenForm(props: TokenFormProps) {
107
+ const words = useWords();
108
+ const choices = dayChoices(props.daysAtMost);
109
+ const [name, setName] = useState(props.name ?? "");
110
+ const [choice, setChoice] = useState<string>(
111
+ choices.includes(DEFAULT_CHOICE) ? DEFAULT_CHOICE : (choices.at(-1) ?? DEFAULT_CHOICE),
112
+ );
113
+ const submit = (event: FormEvent) => {
114
+ event.preventDefault();
115
+ props.onSubmit({
116
+ name: name.trim(),
117
+ expiresInDays: choice === "never" ? null : Number(choice),
118
+ });
119
+ };
120
+ return (
121
+ <form className="sc-form" onSubmit={submit}>
122
+ <div className="sc-field-row">
123
+ <label className="sc-field">
124
+ <span className="sc-field-label">{words.agents.form.name}</span>
125
+ <input
126
+ className="sc-input"
127
+ value={name}
128
+ maxLength={MAX_TOKEN_NAME_LENGTH}
129
+ required
130
+ placeholder={words.agents.form.namePlaceholder}
131
+ onChange={(event) => setName(event.target.value)}
132
+ />
133
+ </label>
134
+ <label className="sc-field">
135
+ <span className="sc-field-label">{words.agents.form.goodFor}</span>
136
+ <select
137
+ className="sc-input"
138
+ value={choice}
139
+ onChange={(event) => setChoice(event.target.value)}
140
+ >
141
+ {choices.map((option) => (
142
+ <option key={option} value={option}>
143
+ {option === "never" ? words.agents.doesNotExpire : words.agents.form.days(option)}
144
+ </option>
145
+ ))}
146
+ </select>
147
+ </label>
148
+ </div>
149
+ {props.error !== undefined && (
150
+ <p className="sc-form-error" role="alert">
151
+ {props.error}
152
+ </p>
153
+ )}
154
+ <div className="sc-form-actions">
155
+ <Button
156
+ type="submit"
157
+ variant="primary"
158
+ disabled={props.busy === true || name.trim() === ""}
159
+ >
160
+ {props.submitLabel ?? words.agents.form.make}
161
+ </Button>
162
+ {props.onCancel !== undefined && (
163
+ <Button variant="ghost" onClick={props.onCancel} disabled={props.busy === true}>
164
+ {props.cancelLabel ?? words.common.cancel}
165
+ </Button>
166
+ )}
167
+ </div>
168
+ </form>
169
+ );
170
+ }
171
+
172
+ export interface NewTokenProps {
173
+ readonly token: RestToken;
174
+ /** The secret, which the server answered once and does not keep. */
175
+ readonly secret: string;
176
+ readonly onDone: () => void;
177
+ }
178
+
179
+ /** The token just made, with its secret shown this once and the way to copy it. */
180
+ export function NewToken(props: NewTokenProps) {
181
+ const words = useWords();
182
+ const fresh = words.agents.fresh;
183
+ const [copied, setCopied] = useState(false);
184
+ const copy = () => {
185
+ if (typeof navigator === "undefined" || navigator.clipboard === undefined) {
186
+ return;
187
+ }
188
+ void navigator.clipboard.writeText(props.secret).then(
189
+ () => setCopied(true),
190
+ () => setCopied(false),
191
+ );
192
+ };
193
+ return (
194
+ <Callout
195
+ tone="warning"
196
+ title={fresh.ready(props.token.name)}
197
+ action={
198
+ <Button size="sm" variant="ghost" onClick={props.onDone}>
199
+ {words.common.done}
200
+ </Button>
201
+ }
202
+ >
203
+ <p>{fresh.copyNow}</p>
204
+ <p className="sc-secret">
205
+ <code className="sc-secret-value">{props.secret}</code>
206
+ <Button size="sm" onClick={copy}>
207
+ {copied ? fresh.copied : fresh.copy}
208
+ </Button>
209
+ </p>
210
+ <p>
211
+ {fresh.useIt}{" "}
212
+ {props.token.expiresAt === null ? (
213
+ fresh.removeWhenDone
214
+ ) : (
215
+ <>
216
+ {fresh.goodUntilBefore} <Time iso={props.token.expiresAt} />
217
+ {fresh.goodUntilAfter}
218
+ </>
219
+ )}
220
+ </p>
221
+ </Callout>
222
+ );
223
+ }