@skillcdn/console 0.4.0 → 0.4.1
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.
- package/README.md +1 -0
- package/dist/api.d.ts +1 -1
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +1 -1
- package/dist/api.js.map +1 -1
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +36 -11
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +25 -5
- package/dist/cli/help.js.map +1 -1
- package/dist/client.d.ts +12 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +4 -0
- package/dist/client.js.map +1 -1
- package/dist/compat.d.ts +1 -1
- package/dist/compat.js +2 -2
- package/dist/components/markdown.d.ts.map +1 -1
- package/dist/components/markdown.js +6 -3
- package/dist/components/markdown.js.map +1 -1
- package/dist/components/pack-connect.d.ts +3 -1
- package/dist/components/pack-connect.d.ts.map +1 -1
- package/dist/components/pack-connect.js +4 -4
- package/dist/components/pack-connect.js.map +1 -1
- package/dist/components/pack-token.d.ts +3 -1
- package/dist/components/pack-token.d.ts.map +1 -1
- package/dist/components/pack-token.js +1 -1
- package/dist/components/pack-token.js.map +1 -1
- package/dist/components/people.d.ts.map +1 -1
- package/dist/components/people.js +14 -3
- package/dist/components/people.js.map +1 -1
- package/dist/components/remote-pictures.d.ts +6 -0
- package/dist/components/remote-pictures.d.ts.map +1 -0
- package/dist/components/remote-pictures.js +8 -0
- package/dist/components/remote-pictures.js.map +1 -0
- package/dist/components/sign-in.d.ts.map +1 -1
- package/dist/components/sign-in.js +49 -2
- package/dist/components/sign-in.js.map +1 -1
- package/dist/components/ui.d.ts +3 -2
- package/dist/components/ui.d.ts.map +1 -1
- package/dist/components/ui.js +6 -3
- package/dist/components/ui.js.map +1 -1
- package/dist/console.css +57 -5
- package/dist/console.d.ts.map +1 -1
- package/dist/console.js +13 -6
- package/dist/console.js.map +1 -1
- package/dist/data.d.ts +4 -1
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +16 -19
- package/dist/data.js.map +1 -1
- package/dist/i18n/en.d.ts +14 -1
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +14 -1
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/ko.d.ts.map +1 -1
- package/dist/i18n/ko.js +10 -1
- package/dist/i18n/ko.js.map +1 -1
- package/dist/limits.d.ts +14 -0
- package/dist/limits.d.ts.map +1 -1
- package/dist/limits.js +14 -0
- package/dist/limits.js.map +1 -1
- package/dist/routes.d.ts +2 -0
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +2 -0
- package/dist/routes.js.map +1 -1
- package/dist/schemas.d.ts +30 -1
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +15 -4
- package/dist/schemas.js.map +1 -1
- package/dist/web.d.ts.map +1 -1
- package/dist/web.js +3 -2
- package/dist/web.js.map +1 -1
- package/docs/specs/cli.md +5 -5
- package/docs/specs/rest.md +22 -11
- package/package.json +2 -2
- package/skills/building-a-console/SKILL.md +2 -2
- package/skills/working-the-board/references/setting-up.md +1 -1
- package/src/api.ts +8 -0
- package/src/cli/cli.ts +39 -12
- package/src/cli/help.ts +30 -4
- package/src/client.ts +30 -1
- package/src/compat.ts +2 -2
- package/src/components/markdown.tsx +6 -3
- package/src/components/pack-connect.tsx +10 -7
- package/src/components/pack-token.tsx +4 -1
- package/src/components/people.tsx +17 -7
- package/src/components/remote-pictures.ts +19 -0
- package/src/components/sign-in.tsx +114 -13
- package/src/components/ui.tsx +6 -3
- package/src/console.tsx +24 -5
- package/src/data.ts +20 -20
- package/src/i18n/en.ts +15 -1
- package/src/i18n/ko.ts +11 -1
- package/src/limits.ts +14 -0
- package/src/routes.ts +2 -0
- package/src/schemas.ts +17 -4
- package/src/styles/console.css +57 -5
- package/src/web.ts +3 -2
|
@@ -2,11 +2,13 @@ import ReactMarkdown from "react-markdown";
|
|
|
2
2
|
import remarkGfm from "remark-gfm";
|
|
3
3
|
import { isDocumentPath } from "../documents.js";
|
|
4
4
|
import { type Picture, PictureThumb } from "./pictures.js";
|
|
5
|
+
import { useRemotePictureSrc } from "./remote-pictures.js";
|
|
5
6
|
|
|
6
7
|
// What people and agents write is untrusted. It is rendered as React elements, never as HTML:
|
|
7
8
|
// raw HTML in the source stays text, links are limited to a few schemes and open elsewhere,
|
|
8
|
-
// and
|
|
9
|
-
// is a document's path (ADR-0009)
|
|
9
|
+
// and a picture from elsewhere, named over https, is read through the console (ADR-0048),
|
|
10
|
+
// lazily and without a referrer. A link whose destination is a document's path (ADR-0009)
|
|
11
|
+
// leads to the document's page, when the page knows where.
|
|
10
12
|
|
|
11
13
|
const SAFE_LINK = /^(https?:|mailto:)/i;
|
|
12
14
|
const SAFE_IMAGE = /^https:\/\//i;
|
|
@@ -29,6 +31,7 @@ export interface MarkdownProps {
|
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
export function Markdown(props: MarkdownProps) {
|
|
34
|
+
const remote = useRemotePictureSrc();
|
|
32
35
|
return (
|
|
33
36
|
<div className="sc-prose">
|
|
34
37
|
<ReactMarkdown
|
|
@@ -62,7 +65,7 @@ export function Markdown(props: MarkdownProps) {
|
|
|
62
65
|
img({ src, alt, title }) {
|
|
63
66
|
const named = typeof src === "string" ? props.fileSrc?.(src) : undefined;
|
|
64
67
|
const url =
|
|
65
|
-
named ?? (typeof src === "string" && SAFE_IMAGE.test(src) ? src : undefined);
|
|
68
|
+
named ?? (typeof src === "string" && SAFE_IMAGE.test(src) ? remote(src) : undefined);
|
|
66
69
|
if (url === undefined) {
|
|
67
70
|
return <span className="sc-prose-image-text">{alt ?? ""}</span>;
|
|
68
71
|
}
|
|
@@ -6,8 +6,9 @@ import { Button, Time } from "./ui.js";
|
|
|
6
6
|
// A private pack connected with a code a person approves (ADR-0036): an owner, or for the
|
|
7
7
|
// workspace's token an administrator, asks, the console asks the deployment and shows what it
|
|
8
8
|
// answered, the code and the page, and the person approves there; the page asks the console
|
|
9
|
-
//
|
|
10
|
-
//
|
|
9
|
+
// to claim the token at the interval the deployment named, until the token is kept or the
|
|
10
|
+
// request ended; reading how the request stands, when the page opens, changes nothing
|
|
11
|
+
// (ADR-0046). Takes its data through the callbacks and keeps nothing itself.
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* How a connection stands, as the component needs it: what the API answers for a project's
|
|
@@ -20,8 +21,10 @@ export type PackConnectState =
|
|
|
20
21
|
export interface PackConnectProps {
|
|
21
22
|
/** Asks the deployment to connect; answers what the person is shown. */
|
|
22
23
|
readonly onStart: () => Promise<RestPackConnectStarted>;
|
|
23
|
-
/** How the connection stands
|
|
24
|
+
/** How the connection stands, read when the page opens; changes nothing. */
|
|
24
25
|
readonly poll: (signal?: AbortSignal) => Promise<PackConnectState>;
|
|
26
|
+
/** Asks the console to claim the token at the deployment: pending still, connected, or ended. */
|
|
27
|
+
readonly claim: (signal?: AbortSignal) => Promise<PackConnectState>;
|
|
25
28
|
/** Gives the connection up. */
|
|
26
29
|
readonly onCancel: () => Promise<void>;
|
|
27
30
|
/** Called once the token is kept. */
|
|
@@ -47,7 +50,7 @@ const MIN_INTERVAL_SECONDS = 2;
|
|
|
47
50
|
export function PackConnect(props: PackConnectProps) {
|
|
48
51
|
const words = useWords();
|
|
49
52
|
const pack = words.projects.packToken;
|
|
50
|
-
const { poll, onConnected } = props;
|
|
53
|
+
const { poll, claim, onConnected } = props;
|
|
51
54
|
const [shown, setShown] = useState<Shown>({ kind: "idle" });
|
|
52
55
|
const [problem, setProblem] = useState<string | undefined>(undefined);
|
|
53
56
|
|
|
@@ -64,7 +67,7 @@ export function PackConnect(props: PackConnectProps) {
|
|
|
64
67
|
return () => controller.abort();
|
|
65
68
|
}, [poll]);
|
|
66
69
|
|
|
67
|
-
// While waiting, the console is asked
|
|
70
|
+
// While waiting, the console is asked to claim at the interval the deployment named; a new
|
|
68
71
|
// answer, pending still, is a new state, which schedules the next asking.
|
|
69
72
|
useEffect(() => {
|
|
70
73
|
if (shown.kind !== "waiting") {
|
|
@@ -72,7 +75,7 @@ export function PackConnect(props: PackConnectProps) {
|
|
|
72
75
|
}
|
|
73
76
|
const controller = new AbortController();
|
|
74
77
|
const timer = setTimeout(() => {
|
|
75
|
-
|
|
78
|
+
claim(controller.signal)
|
|
76
79
|
.then((state) => {
|
|
77
80
|
if (controller.signal.aborted) {
|
|
78
81
|
return;
|
|
@@ -102,7 +105,7 @@ export function PackConnect(props: PackConnectProps) {
|
|
|
102
105
|
controller.abort();
|
|
103
106
|
clearTimeout(timer);
|
|
104
107
|
};
|
|
105
|
-
}, [shown,
|
|
108
|
+
}, [shown, claim, onConnected]);
|
|
106
109
|
|
|
107
110
|
const start = () => {
|
|
108
111
|
setProblem(undefined);
|
|
@@ -20,8 +20,10 @@ export interface PackTokenWaysProps {
|
|
|
20
20
|
readonly busy?: boolean | undefined;
|
|
21
21
|
/** Asks the deployment to connect; answers what the person is shown. */
|
|
22
22
|
readonly onStart: () => Promise<RestPackConnectStarted>;
|
|
23
|
-
/** How the connection stands
|
|
23
|
+
/** How the connection stands, read when the page opens; changes nothing. */
|
|
24
24
|
readonly poll: (signal?: AbortSignal) => Promise<PackConnectState>;
|
|
25
|
+
/** Asks the console to claim the token at the deployment (ADR-0046). */
|
|
26
|
+
readonly claim: (signal?: AbortSignal) => Promise<PackConnectState>;
|
|
25
27
|
/** Gives the connection up. */
|
|
26
28
|
readonly onCancelConnect: () => Promise<void>;
|
|
27
29
|
/** Called once the token is kept. */
|
|
@@ -46,6 +48,7 @@ export function PackTokenWays(props: PackTokenWaysProps) {
|
|
|
46
48
|
<PackConnect
|
|
47
49
|
onStart={props.onStart}
|
|
48
50
|
poll={props.poll}
|
|
51
|
+
claim={props.claim}
|
|
49
52
|
onCancel={props.onCancelConnect}
|
|
50
53
|
onConnected={props.onConnected}
|
|
51
54
|
busy={props.busy}
|
|
@@ -57,6 +57,19 @@ export function PeopleList(props: PeopleListProps) {
|
|
|
57
57
|
person.source !== "organization" &&
|
|
58
58
|
props.me?.id !== person.id;
|
|
59
59
|
const organization = props.organization ?? undefined;
|
|
60
|
+
/** What let the person in, by its name: the organization, the domain of their login, or an administrator. */
|
|
61
|
+
const noteOf = (person: RestPerson): string => {
|
|
62
|
+
if (person.source === "organization") {
|
|
63
|
+
return organization === undefined
|
|
64
|
+
? people.source.organization
|
|
65
|
+
: people.ofOrganization(organization.name);
|
|
66
|
+
}
|
|
67
|
+
if (person.source === "domain") {
|
|
68
|
+
const at = person.login.lastIndexOf("@");
|
|
69
|
+
return at === -1 ? people.source.domain : people.ofDomain(person.login.slice(at + 1));
|
|
70
|
+
}
|
|
71
|
+
return people.source.listed;
|
|
72
|
+
};
|
|
60
73
|
return (
|
|
61
74
|
<div className="sc-people-page">
|
|
62
75
|
{organization !== undefined && (
|
|
@@ -79,8 +92,8 @@ export function PeopleList(props: PeopleListProps) {
|
|
|
79
92
|
<span className="sc-person-you">{words.common.you}</span>
|
|
80
93
|
)}
|
|
81
94
|
<span className="sc-person-name sc-muted">
|
|
82
|
-
{person.name
|
|
83
|
-
<span className="sc-person-note">
|
|
95
|
+
{person.name !== null && <>{person.login} · </>}
|
|
96
|
+
<span className="sc-person-note">{noteOf(person)}</span>
|
|
84
97
|
</span>
|
|
85
98
|
{props.onChangeRole === undefined ? (
|
|
86
99
|
<RoleBadge role={person.role} />
|
|
@@ -163,12 +176,9 @@ export function PeopleList(props: PeopleListProps) {
|
|
|
163
176
|
<li key={person.id} className="sc-person-row sc-person-row-out">
|
|
164
177
|
<PersonChip person={person} size="md" />
|
|
165
178
|
<span className="sc-person-name sc-muted">
|
|
166
|
-
{person.name
|
|
179
|
+
{person.name !== null && <>{person.login} · </>}
|
|
167
180
|
<span className="sc-person-note">
|
|
168
|
-
{"
|
|
169
|
-
{person.source === "organization"
|
|
170
|
-
? people.leftOrganization
|
|
171
|
-
: people.source[person.source]}
|
|
181
|
+
{person.source === "organization" ? people.leftOrganization : noteOf(person)}
|
|
172
182
|
{person.deactivatedAt !== null && (
|
|
173
183
|
<>
|
|
174
184
|
{" "}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createContext, useContext } from "react";
|
|
2
|
+
import { REST_ROUTES } from "../routes.js";
|
|
3
|
+
|
|
4
|
+
// Where a picture from elsewhere is read (ADR-0048): through the console, which fetches it and
|
|
5
|
+
// keeps it a while, so that the person's browser reaches nobody's server and the pages load
|
|
6
|
+
// nothing from elsewhere. The composition provides the client's way to it; a component outside
|
|
7
|
+
// one reads it at the API's own path, which a console served beside its API answers.
|
|
8
|
+
|
|
9
|
+
/** The address a picture from elsewhere is read at, given the https URL that names it. */
|
|
10
|
+
export type RemotePictureSrc = (url: string) => string;
|
|
11
|
+
|
|
12
|
+
export const defaultRemotePictureSrc: RemotePictureSrc = (url) =>
|
|
13
|
+
`${REST_ROUTES.pictures}?src=${encodeURIComponent(url)}`;
|
|
14
|
+
|
|
15
|
+
export const RemotePicturesContext = createContext<RemotePictureSrc>(defaultRemotePictureSrc);
|
|
16
|
+
|
|
17
|
+
export function useRemotePictureSrc(): RemotePictureSrc {
|
|
18
|
+
return useContext(RemotePicturesContext);
|
|
19
|
+
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { type MouseEvent, useEffect, useState } from "react";
|
|
2
|
+
import { loginPath, type ProviderKey, type RestProvider, type SignInFailure } from "../api.js";
|
|
2
3
|
import { useWords } from "../i18n/index.js";
|
|
3
4
|
import type { Brand } from "./shell.js";
|
|
4
|
-
import { Callout } from "./ui.js";
|
|
5
|
+
import { Callout, cx } from "./ui.js";
|
|
5
6
|
|
|
6
7
|
// The page a person sees before they are anyone: the way to each provider the deployment has,
|
|
7
|
-
// and what went wrong the last time, when something did.
|
|
8
|
+
// who gets in through it, and what went wrong the last time, when something did.
|
|
8
9
|
|
|
9
10
|
export interface SignInProps {
|
|
10
11
|
readonly title: string;
|
|
@@ -19,6 +20,35 @@ export interface SignInProps {
|
|
|
19
20
|
|
|
20
21
|
export function SignIn(props: SignInProps) {
|
|
21
22
|
const words = useWords().signIn;
|
|
23
|
+
// The way being taken: its button says so from the click until the page leaves, and says so
|
|
24
|
+
// no more when the browser brings this page back from its cache, as on the way back.
|
|
25
|
+
const [taken, setTaken] = useState<ProviderKey | undefined>(undefined);
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
const shown = (event: PageTransitionEvent): void => {
|
|
28
|
+
if (event.persisted) {
|
|
29
|
+
setTaken(undefined);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
window.addEventListener("pageshow", shown);
|
|
33
|
+
return () => window.removeEventListener("pageshow", shown);
|
|
34
|
+
}, []);
|
|
35
|
+
const take = (provider: ProviderKey) => (event: MouseEvent<HTMLAnchorElement>) => {
|
|
36
|
+
if (taken !== undefined) {
|
|
37
|
+
// One way at a time: a second click, on any of the buttons, goes nowhere.
|
|
38
|
+
event.preventDefault();
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
setTaken(provider);
|
|
42
|
+
};
|
|
43
|
+
const admitsOf = (admits: NonNullable<RestProvider["admits"]>): string => {
|
|
44
|
+
if (admits.organization !== null) {
|
|
45
|
+
return words.admits.organization(admits.organization);
|
|
46
|
+
}
|
|
47
|
+
if (admits.domains.length > 0) {
|
|
48
|
+
return words.admits.domains(admits.domains.join(", "));
|
|
49
|
+
}
|
|
50
|
+
return words.admits.listed;
|
|
51
|
+
};
|
|
22
52
|
return (
|
|
23
53
|
<div className="sc-sign-in">
|
|
24
54
|
{props.brand?.symbol !== undefined && (
|
|
@@ -44,18 +74,89 @@ export function SignIn(props: SignInProps) {
|
|
|
44
74
|
</Callout>
|
|
45
75
|
) : (
|
|
46
76
|
<div className="sc-sign-in-ways">
|
|
47
|
-
{props.providers.map((provider) =>
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
key={provider.key}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
77
|
+
{props.providers.map((provider) => {
|
|
78
|
+
const going = taken === provider.key;
|
|
79
|
+
return (
|
|
80
|
+
<div key={provider.key} className="sc-sign-in-way">
|
|
81
|
+
{/* A real navigation: signing in happens at the provider, not in this page. */}
|
|
82
|
+
<a
|
|
83
|
+
className={cx(
|
|
84
|
+
"sc-button sc-button-primary sc-sign-in-button",
|
|
85
|
+
going && "sc-sign-in-button-going",
|
|
86
|
+
)}
|
|
87
|
+
href={loginPath(provider.key, props.returnTo)}
|
|
88
|
+
aria-busy={going ? true : undefined}
|
|
89
|
+
aria-disabled={taken !== undefined && !going ? true : undefined}
|
|
90
|
+
onClick={take(provider.key)}
|
|
91
|
+
>
|
|
92
|
+
<ProviderMark provider={provider.key} />
|
|
93
|
+
<span className="sc-sign-in-button-label">
|
|
94
|
+
{going ? words.going(provider.label) : words.signInWith(provider.label)}
|
|
95
|
+
</span>
|
|
96
|
+
{going && (
|
|
97
|
+
<span className="sc-spinner sc-sign-in-spinner" aria-hidden="true">
|
|
98
|
+
<span className="sc-spinner-ring" />
|
|
99
|
+
</span>
|
|
100
|
+
)}
|
|
101
|
+
</a>
|
|
102
|
+
{provider.admits !== undefined && (
|
|
103
|
+
<p className="sc-sign-in-admits">{admitsOf(provider.admits)}</p>
|
|
104
|
+
)}
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
})}
|
|
57
108
|
</div>
|
|
58
109
|
)}
|
|
59
110
|
</div>
|
|
60
111
|
);
|
|
61
112
|
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The provider's mark, as people know it from sign-in buttons elsewhere, beside the label and
|
|
116
|
+
* decorative: the host's own mark in the button's text color, Google's lettermark on a light
|
|
117
|
+
* tile, since its colors are its own. A provider without a mark shows the label alone.
|
|
118
|
+
*/
|
|
119
|
+
function ProviderMark(props: { readonly provider: ProviderKey }) {
|
|
120
|
+
if (props.provider === "gh") {
|
|
121
|
+
return (
|
|
122
|
+
<svg
|
|
123
|
+
className="sc-sign-in-mark"
|
|
124
|
+
viewBox="0 0 16 16"
|
|
125
|
+
width="20"
|
|
126
|
+
height="20"
|
|
127
|
+
aria-hidden="true"
|
|
128
|
+
focusable="false"
|
|
129
|
+
>
|
|
130
|
+
<path
|
|
131
|
+
fill="currentColor"
|
|
132
|
+
d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"
|
|
133
|
+
/>
|
|
134
|
+
</svg>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
if (props.provider === "google") {
|
|
138
|
+
return (
|
|
139
|
+
<span className="sc-sign-in-mark sc-sign-in-mark-tile" aria-hidden="true">
|
|
140
|
+
<svg viewBox="0 0 48 48" width="16" height="16" aria-hidden="true" focusable="false">
|
|
141
|
+
<path
|
|
142
|
+
fill="#FFC107"
|
|
143
|
+
d="M43.611 20.083H42V20H24v8h11.303c-1.649 4.657-6.08 8-11.303 8-6.627 0-12-5.373-12-12s5.373-12 12-12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C34.046 6.053 29.268 4 24 4 12.955 4 4 12.955 4 24s8.955 20 20 20 20-8.955 20-20c0-1.341-.138-2.65-.389-3.917z"
|
|
144
|
+
/>
|
|
145
|
+
<path
|
|
146
|
+
fill="#FF3D00"
|
|
147
|
+
d="M6.306 14.691l6.571 4.819C14.655 15.108 18.961 12 24 12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C34.046 6.053 29.268 4 24 4 16.318 4 9.656 8.337 6.306 14.691z"
|
|
148
|
+
/>
|
|
149
|
+
<path
|
|
150
|
+
fill="#4CAF50"
|
|
151
|
+
d="M24 44c5.166 0 9.86-1.977 13.409-5.192l-6.19-5.238A11.91 11.91 0 0 1 24 36c-5.202 0-9.619-3.317-11.283-7.946l-6.522 5.025C9.505 39.556 16.227 44 24 44z"
|
|
152
|
+
/>
|
|
153
|
+
<path
|
|
154
|
+
fill="#1976D2"
|
|
155
|
+
d="M43.611 20.083H42V20H24v8h11.303a12.04 12.04 0 0 1-4.087 5.571l.003-.002 6.19 5.238C36.971 39.205 44 34 44 24c0-1.341-.138-2.65-.389-3.917z"
|
|
156
|
+
/>
|
|
157
|
+
</svg>
|
|
158
|
+
</span>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
}
|
package/src/components/ui.tsx
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from "../api.js";
|
|
12
12
|
import { dayOf, daysBetween, formatDay, today } from "../dates.js";
|
|
13
13
|
import { useLanguage, useWords } from "../i18n/index.js";
|
|
14
|
+
import { useRemotePictureSrc } from "./remote-pictures.js";
|
|
14
15
|
|
|
15
16
|
// Small building blocks. Each one is a class in styles/console.css and nothing more. Classes
|
|
16
17
|
// are prefixed `sc-` so that a custom console's own styles never collide with them. The words
|
|
@@ -96,13 +97,15 @@ export function personName(person: {
|
|
|
96
97
|
}
|
|
97
98
|
|
|
98
99
|
/**
|
|
99
|
-
* A person's picture as the git host serves it
|
|
100
|
-
* a whole when there is none or it cannot be loaded,
|
|
100
|
+
* A person's picture as the git host serves it, read through the console (ADR-0048): loaded
|
|
101
|
+
* lazily, without a referrer, and gone as a whole when there is none or it cannot be loaded,
|
|
102
|
+
* leaving the initial of the name instead.
|
|
101
103
|
*/
|
|
102
104
|
export function Avatar(props: {
|
|
103
105
|
readonly person: RestPerson;
|
|
104
106
|
readonly size?: "xs" | "sm" | "md" | "lg";
|
|
105
107
|
}) {
|
|
108
|
+
const remote = useRemotePictureSrc();
|
|
106
109
|
const size = props.size ?? "md";
|
|
107
110
|
const initial = personName(props.person).slice(0, 1).toUpperCase();
|
|
108
111
|
if (props.person.avatar === null) {
|
|
@@ -118,7 +121,7 @@ export function Avatar(props: {
|
|
|
118
121
|
return (
|
|
119
122
|
<img
|
|
120
123
|
className={cx("sc-avatar", `sc-avatar-${size}`)}
|
|
121
|
-
src={props.person.avatar}
|
|
124
|
+
src={remote(props.person.avatar)}
|
|
122
125
|
alt=""
|
|
123
126
|
loading="lazy"
|
|
124
127
|
decoding="async"
|
package/src/console.tsx
CHANGED
|
@@ -58,6 +58,7 @@ import {
|
|
|
58
58
|
type ProjectListProps,
|
|
59
59
|
ProjectMark,
|
|
60
60
|
} from "./components/projects.js";
|
|
61
|
+
import { type RemotePictureSrc, RemotePicturesContext } from "./components/remote-pictures.js";
|
|
61
62
|
import { GoalTimeline } from "./components/roadmap.js";
|
|
62
63
|
import { RulesForm, RulesView } from "./components/rules.js";
|
|
63
64
|
import {
|
|
@@ -396,14 +397,18 @@ function ProjectsPage(props: {
|
|
|
396
397
|
readonly making: boolean;
|
|
397
398
|
}) {
|
|
398
399
|
const words = useWords().projects;
|
|
399
|
-
const { data, components, navigation
|
|
400
|
+
const { data, components, navigation } = props;
|
|
400
401
|
const { busy, error, act } = useAction();
|
|
401
402
|
const href = (project: RestProject) => projectHref(project.key);
|
|
403
|
+
// A project is made by an administrator (ADR-0045): a member sees the list alone, the form's
|
|
404
|
+
// address included.
|
|
405
|
+
const administrator = data.me?.person?.role === "admin";
|
|
406
|
+
const making = props.making && administrator;
|
|
402
407
|
return (
|
|
403
408
|
<>
|
|
404
409
|
<div className="sc-page-head">
|
|
405
410
|
<h1 className="sc-page-title">{words.title}</h1>
|
|
406
|
-
{!making && (
|
|
411
|
+
{administrator && !making && (
|
|
407
412
|
<LinkButton href={PATHS.newProject} variant="primary">
|
|
408
413
|
{words.newProject}
|
|
409
414
|
</LinkButton>
|
|
@@ -436,8 +441,12 @@ function ProjectsPage(props: {
|
|
|
436
441
|
empty={
|
|
437
442
|
<EmptyState
|
|
438
443
|
title={words.empty.title}
|
|
439
|
-
body={words.empty.body}
|
|
440
|
-
action={
|
|
444
|
+
body={administrator ? words.empty.body : words.empty.bodyMember}
|
|
445
|
+
action={
|
|
446
|
+
administrator ? (
|
|
447
|
+
<LinkButton href={PATHS.newProject}>{words.newProject}</LinkButton>
|
|
448
|
+
) : undefined
|
|
449
|
+
}
|
|
441
450
|
/>
|
|
442
451
|
}
|
|
443
452
|
/>
|
|
@@ -1432,6 +1441,7 @@ function ProjectSettingsPage(props: {
|
|
|
1432
1441
|
busy={busy}
|
|
1433
1442
|
onStart={data.actions.connectPackToken}
|
|
1434
1443
|
poll={data.actions.packTokenConnection}
|
|
1444
|
+
claim={data.actions.claimPackTokenConnection}
|
|
1435
1445
|
onCancelConnect={data.actions.cancelPackTokenConnection}
|
|
1436
1446
|
onConnected={() => navigation.replace(here)}
|
|
1437
1447
|
onSubmit={(token) =>
|
|
@@ -1654,6 +1664,7 @@ function WorkspaceSettingsPage(props: {
|
|
|
1654
1664
|
busy={busy}
|
|
1655
1665
|
onStart={data.actions.connectWorkspacePackToken}
|
|
1656
1666
|
poll={data.actions.workspacePackTokenConnection}
|
|
1667
|
+
claim={data.actions.claimWorkspacePackTokenConnection}
|
|
1657
1668
|
onCancelConnect={data.actions.cancelWorkspacePackTokenConnection}
|
|
1658
1669
|
onConnected={() => navigation.replace(PATHS.settings)}
|
|
1659
1670
|
onSubmit={(token) =>
|
|
@@ -2334,6 +2345,8 @@ function WorkspacePage(props: {
|
|
|
2334
2345
|
export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
|
|
2335
2346
|
const components: ConsoleComponents = { ...DEFAULT_COMPONENTS, ...config.components };
|
|
2336
2347
|
const client = config.client ?? createClient({ baseUrl: config.baseUrl ?? "" });
|
|
2348
|
+
/** Where the pages read a picture from elsewhere (ADR-0048): through this client's console. */
|
|
2349
|
+
const pictureSrc: RemotePictureSrc = (url) => client.pictureUrl(url);
|
|
2337
2350
|
const fallbackTitle = config.title ?? "Console";
|
|
2338
2351
|
const languages = config.languages ?? DEFAULT_LANGUAGES;
|
|
2339
2352
|
const pages = config.pages ?? [];
|
|
@@ -2628,7 +2641,13 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
|
|
|
2628
2641
|
</components.Shell>
|
|
2629
2642
|
);
|
|
2630
2643
|
}
|
|
2631
|
-
return
|
|
2644
|
+
return (
|
|
2645
|
+
<LanguageContext.Provider value={pack}>
|
|
2646
|
+
<RemotePicturesContext.Provider value={pictureSrc}>
|
|
2647
|
+
{content}
|
|
2648
|
+
</RemotePicturesContext.Provider>
|
|
2649
|
+
</LanguageContext.Provider>
|
|
2650
|
+
);
|
|
2632
2651
|
}
|
|
2633
2652
|
|
|
2634
2653
|
return {
|
package/src/data.ts
CHANGED
|
@@ -66,12 +66,12 @@ import {
|
|
|
66
66
|
|
|
67
67
|
// What the console shows, and how it stays current: the workspace (who is signed in, the
|
|
68
68
|
// projects, the people, the tokens) is loaded once; the project the page is on is loaded whole,
|
|
69
|
-
// its feed
|
|
69
|
+
// its feed as it stands, the newest page, and from then on the project's stream says when something
|
|
70
70
|
// changed: on every event the lists are loaded again, which is the boring way to be right.
|
|
71
71
|
|
|
72
72
|
/** How many events the feed keeps on the page. */
|
|
73
73
|
const FEED_KEEP = 200;
|
|
74
|
-
/** How many pages of
|
|
74
|
+
/** How many pages of a task's own events are read for its thread, which is read from the beginning. */
|
|
75
75
|
const FEED_MAX_PAGES = 20;
|
|
76
76
|
/** How long changes that arrive together are gathered before the lists are loaded once. */
|
|
77
77
|
const RELOAD_DELAY_MS = 150;
|
|
@@ -89,8 +89,10 @@ export interface ConsoleActions {
|
|
|
89
89
|
removePackToken(): Promise<RestProject>;
|
|
90
90
|
/** Asks the deployment to connect the project's pack with a code a person approves (ADR-0036); for an owner. */
|
|
91
91
|
connectPackToken(): Promise<RestPackConnectStarted>;
|
|
92
|
-
/** How that connection stands
|
|
92
|
+
/** How that connection stands, read and changed nothing. */
|
|
93
93
|
packTokenConnection(signal?: AbortSignal): Promise<RestPackConnect>;
|
|
94
|
+
/** Asks the console to claim the token at the deployment (ADR-0046); once connected, the project is the one answered. */
|
|
95
|
+
claimPackTokenConnection(signal?: AbortSignal): Promise<RestPackConnect>;
|
|
94
96
|
cancelPackTokenConnection(): Promise<void>;
|
|
95
97
|
addMember(input: RestMemberInput): Promise<RestMember>;
|
|
96
98
|
updateMember(personId: string, patch: RestMemberPatch): Promise<RestMember>;
|
|
@@ -177,6 +179,7 @@ export interface ConsoleActions {
|
|
|
177
179
|
removeWorkspacePackToken(): Promise<RestWorkspace>;
|
|
178
180
|
connectWorkspacePackToken(): Promise<RestPackConnectStarted>;
|
|
179
181
|
workspacePackTokenConnection(signal?: AbortSignal): Promise<RestWorkspacePackConnect>;
|
|
182
|
+
claimWorkspacePackTokenConnection(signal?: AbortSignal): Promise<RestWorkspacePackConnect>;
|
|
180
183
|
cancelWorkspacePackTokenConnection(): Promise<void>;
|
|
181
184
|
/** One person of the workspace by their exact login, or nobody: what an owner lists someone the People page does not show by. */
|
|
182
185
|
findPerson(login: string, signal?: AbortSignal): Promise<RestPerson | undefined>;
|
|
@@ -247,23 +250,18 @@ export interface ConsoleData {
|
|
|
247
250
|
reload(): void;
|
|
248
251
|
}
|
|
249
252
|
|
|
250
|
-
/**
|
|
253
|
+
/**
|
|
254
|
+
* The feed of a project as it stands: its newest page, oldest first, from which the stream
|
|
255
|
+
* goes on. Read in one request, where reading it from the beginning was a round trip per
|
|
256
|
+
* hundred events, each waited for before the board was shown.
|
|
257
|
+
*/
|
|
251
258
|
async function readFeed(
|
|
252
259
|
client: ConsoleClient,
|
|
253
260
|
key: string,
|
|
254
261
|
signal: AbortSignal,
|
|
255
262
|
): Promise<RestEvent[]> {
|
|
256
|
-
const events:
|
|
257
|
-
|
|
258
|
-
for (let page = 0; page < FEED_MAX_PAGES; page += 1) {
|
|
259
|
-
const result = await client.project(key).events(after, {}, signal);
|
|
260
|
-
events.push(...result.items);
|
|
261
|
-
after = result.items.at(-1)?.id ?? after;
|
|
262
|
-
if (!result.more) {
|
|
263
|
-
break;
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
return events.slice(-FEED_KEEP);
|
|
263
|
+
const result = await client.project(key).events(0, { latest: true }, signal);
|
|
264
|
+
return result.items.slice(-FEED_KEEP);
|
|
267
265
|
}
|
|
268
266
|
|
|
269
267
|
/** How many pages of the current tasks are read before the board is shown as it stands. */
|
|
@@ -468,7 +466,7 @@ export function useConsoleData(client: ConsoleClient, projectKey: string | undef
|
|
|
468
466
|
return () => controller.abort();
|
|
469
467
|
}, [client, generation, refreshWorkspace]);
|
|
470
468
|
|
|
471
|
-
// The project the page is on: its lists, its feed
|
|
469
|
+
// The project the page is on: its lists, its feed as it stands, the newest page, and its stream from
|
|
472
470
|
// where the feed ends. Another project, or none, starts over.
|
|
473
471
|
// biome-ignore lint/correctness/useExhaustiveDependencies: `generation` is the trigger
|
|
474
472
|
useEffect(() => {
|
|
@@ -612,8 +610,9 @@ export function useConsoleData(client: ConsoleClient, projectKey: string | undef
|
|
|
612
610
|
return changed;
|
|
613
611
|
},
|
|
614
612
|
connectPackToken: () => scope().connectPackToken(),
|
|
615
|
-
|
|
616
|
-
|
|
613
|
+
packTokenConnection: (signal) => scope().packTokenConnection(signal),
|
|
614
|
+
async claimPackTokenConnection(signal) {
|
|
615
|
+
const state = await scope().claimPackTokenConnection(signal);
|
|
617
616
|
if (state.status === "connected") {
|
|
618
617
|
setProject(state.project);
|
|
619
618
|
}
|
|
@@ -756,8 +755,9 @@ export function useConsoleData(client: ConsoleClient, projectKey: string | undef
|
|
|
756
755
|
return workspace;
|
|
757
756
|
},
|
|
758
757
|
connectWorkspacePackToken: () => client.connectWorkspacePackToken(),
|
|
759
|
-
|
|
760
|
-
|
|
758
|
+
workspacePackTokenConnection: (signal) => client.workspacePackTokenConnection(signal),
|
|
759
|
+
async claimWorkspacePackTokenConnection(signal) {
|
|
760
|
+
const state = await client.claimWorkspacePackTokenConnection(signal);
|
|
761
761
|
if (state.status === "connected") {
|
|
762
762
|
const { workspace } = state;
|
|
763
763
|
setMe((current) => (current === undefined ? undefined : { ...current, workspace }));
|
package/src/i18n/en.ts
CHANGED
|
@@ -157,7 +157,15 @@ export const en = {
|
|
|
157
157
|
|
|
158
158
|
signIn: {
|
|
159
159
|
lead: "Join an organization made of AI agents.",
|
|
160
|
-
|
|
160
|
+
signInWith: (provider: string) => `Sign in with ${provider}`,
|
|
161
|
+
/** The button while the browser is on its way to the provider. */
|
|
162
|
+
going: (provider: string) => `Going to ${provider}…`,
|
|
163
|
+
/** The line under a button: who gets in through that way. */
|
|
164
|
+
admits: {
|
|
165
|
+
organization: (name: string) => `For the members of ${name}, the organization at GitHub.`,
|
|
166
|
+
domains: (domains: string) => `For accounts at ${domains}.`,
|
|
167
|
+
listed: "For the people an administrator added.",
|
|
168
|
+
},
|
|
161
169
|
nobody: {
|
|
162
170
|
title: "Nobody can sign in here yet",
|
|
163
171
|
body: "Signing in is not configured on this console. Whoever runs it sets it up as deploy/README.md describes.",
|
|
@@ -176,6 +184,8 @@ export const en = {
|
|
|
176
184
|
empty: {
|
|
177
185
|
title: "No projects yet",
|
|
178
186
|
body: "Make the first one: what it is for, and who is in it. Or ask an owner to add you to theirs.",
|
|
187
|
+
bodyMember:
|
|
188
|
+
"An administrator makes the projects. Ask one for the first, or an owner to add you to theirs.",
|
|
179
189
|
},
|
|
180
190
|
list: "Projects",
|
|
181
191
|
private: "Private",
|
|
@@ -889,6 +899,10 @@ export const en = {
|
|
|
889
899
|
domain: "Of the domain",
|
|
890
900
|
listed: "Added by an administrator",
|
|
891
901
|
} satisfies Record<MembershipSource, string>,
|
|
902
|
+
/** A person the organization let in, by the organization's name: never read as their own. */
|
|
903
|
+
ofOrganization: (name: string) => `Member of ${name}`,
|
|
904
|
+
/** A person a Workspace domain let in, by the domain of their login. */
|
|
905
|
+
ofDomain: (domain: string) => `Account at ${domain}`,
|
|
892
906
|
at: { gh: "at GitHub", google: "at Google" } satisfies Record<ProviderKey, string>,
|
|
893
907
|
organization: (name: string) => `The organization ${name} at GitHub says who is in.`,
|
|
894
908
|
organizationRead: "Last read",
|
package/src/i18n/ko.ts
CHANGED
|
@@ -119,7 +119,13 @@ export const ko: Messages = {
|
|
|
119
119
|
|
|
120
120
|
signIn: {
|
|
121
121
|
lead: "AI 에이전트로 이뤄진 조직에 합류하세요.",
|
|
122
|
-
|
|
122
|
+
signInWith: (provider) => `${provider}로 로그인`,
|
|
123
|
+
going: (provider) => `${provider}로 가는 중…`,
|
|
124
|
+
admits: {
|
|
125
|
+
organization: (name) => `GitHub 조직 ${name}의 멤버가 로그인할 수 있습니다.`,
|
|
126
|
+
domains: (domains) => `${domains} 계정이 로그인할 수 있습니다.`,
|
|
127
|
+
listed: "관리자가 추가한 사람이 로그인할 수 있습니다.",
|
|
128
|
+
},
|
|
123
129
|
nobody: {
|
|
124
130
|
title: "아직 아무도 로그인할 수 없습니다",
|
|
125
131
|
body: "이 콘솔에는 로그인이 설정되어 있지 않습니다. 운영자가 deploy/README.md에 따라 설정합니다.",
|
|
@@ -138,6 +144,8 @@ export const ko: Messages = {
|
|
|
138
144
|
empty: {
|
|
139
145
|
title: "아직 프로젝트가 없습니다",
|
|
140
146
|
body: "첫 프로젝트를 만드세요. 무엇을 위한 것인지, 누가 함께하는지. 또는 소유자에게 추가를 요청하세요.",
|
|
147
|
+
bodyMember:
|
|
148
|
+
"프로젝트는 관리자가 만듭니다. 관리자에게 첫 프로젝트를, 또는 소유자에게 추가를 요청하세요.",
|
|
141
149
|
},
|
|
142
150
|
list: "프로젝트",
|
|
143
151
|
private: "비공개",
|
|
@@ -844,6 +852,8 @@ export const ko: Messages = {
|
|
|
844
852
|
domain: "도메인의 계정",
|
|
845
853
|
listed: "관리자가 추가함",
|
|
846
854
|
},
|
|
855
|
+
ofOrganization: (name) => `${name} 조직의 구성원`,
|
|
856
|
+
ofDomain: (domain) => `${domain} 도메인의 계정`,
|
|
847
857
|
at: { gh: "GitHub", google: "Google" },
|
|
848
858
|
organization: (name) => `GitHub 조직 ${name}이(가) 구성원을 정합니다.`,
|
|
849
859
|
organizationRead: "마지막으로 읽음",
|
package/src/limits.ts
CHANGED
|
@@ -79,3 +79,17 @@ export const MAX_PROJECT_SYMBOL_LENGTH = 8;
|
|
|
79
79
|
export const MAX_PROJECT_ICON_BYTES = 512 * 1024;
|
|
80
80
|
/** How many items a listing answers with when none is asked for, and the most one may ask for. */
|
|
81
81
|
export const DEFAULT_PAGE_LIMIT = 50;
|
|
82
|
+
/**
|
|
83
|
+
* How many projects one workspace holds, how many tasks, goals, decisions and pages one project
|
|
84
|
+
* holds, and how many runs one task carries, those put away included: bounds a runaway script
|
|
85
|
+
* meets and real work does not.
|
|
86
|
+
*/
|
|
87
|
+
export const MAX_PROJECTS_PER_WORKSPACE = 500;
|
|
88
|
+
export const MAX_TASKS_PER_PROJECT = 20_000;
|
|
89
|
+
export const MAX_GOALS_PER_PROJECT = 1_000;
|
|
90
|
+
export const MAX_DECISIONS_PER_PROJECT = 20_000;
|
|
91
|
+
export const MAX_DOCUMENTS_PER_PROJECT = 5_000;
|
|
92
|
+
export const MAX_RUNS_PER_TASK = 500;
|
|
93
|
+
/** How many streams of the live feed one person holds at once, across their tabs, and how many one process of the console holds for everyone. */
|
|
94
|
+
export const MAX_STREAMS_PER_PERSON = 20;
|
|
95
|
+
export const MAX_STREAMS = 1_000;
|
package/src/routes.ts
CHANGED
|
@@ -9,6 +9,8 @@ export const REST_ROUTES = {
|
|
|
9
9
|
me: "/api/v1/me",
|
|
10
10
|
/** The token a request is made with, for it to give itself up. */
|
|
11
11
|
meToken: "/api/v1/me/token",
|
|
12
|
+
/** A picture from elsewhere, fetched by the console for a page (ADR-0048); `?src=` names it. */
|
|
13
|
+
pictures: "/api/v1/pictures",
|
|
12
14
|
/** The workspace's settings (ADR-0035), changed by an administrator; read through `me`. */
|
|
13
15
|
workspace: "/api/v1/workspace",
|
|
14
16
|
/** The SkillCDN token the organization's pack is read with (ADR-0036), an administrator's; `/connect` under it connects one with a code. */
|