Add the harness, envs, per-bot models and chat attachments
- Harness: providers, model catalog synced hourly, automatic free picks, cooldowns, tool loop with context trimming, MCP computer tools - Bots queue runs replies and tasks through the harness; jobs wait (attempt refunded) while every model is resting - Envs: Settings > Environment, scoped general/workspace/computer, written to computers and redacted from tool output - Models: Settings > Models, per-harness provider select defaulting to OpenRouter, and a models modal on each bot's chat (the coordinator's edits the defaults) - Chat: thinking row while a reply runs, file attachments (upload, paste, drop) that replies read as images, audio and document text - Fix media uploads failing after a user's first file, and private files being served from the public media path Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
import type { EnvsOverviewPayload } from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* Under `/api/admin/envs/`.
|
||||
*/
|
||||
path: "save-env" | "delete-env";
|
||||
body: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Posts to an env API route. Every route answers with the fresh page data
|
||||
* in `singleRes`, so the page updates in place.
|
||||
*/
|
||||
export default async function postEnvApi({ path, body }: Params) {
|
||||
return await fetchApi<Record<string, unknown>, APIResponseObject<EnvsOverviewPayload>>(
|
||||
`/api/admin/envs/${path}`,
|
||||
{ method: "POST", body },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useState } from "react";
|
||||
import Form from "@moduletrace/twui/form/Form";
|
||||
import Input from "@moduletrace/twui/form/Input";
|
||||
import Checkbox from "@moduletrace/twui/form/Checkbox";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import type { EnvScope, EnvsOverviewPayload, KnownEnvStatus, PublicEnv } from "@/src/types";
|
||||
import SettingsStatus from "../../(partials)/settings-status";
|
||||
import postEnvApi from "../(functions)/post-env-api";
|
||||
|
||||
type Props = {
|
||||
scope: EnvScope;
|
||||
/**
|
||||
* Edits this env; adds one when not given.
|
||||
*/
|
||||
env?: PublicEnv;
|
||||
/**
|
||||
* Sets this app setting: its key, description and flags are fixed.
|
||||
*/
|
||||
known?: KnownEnvStatus;
|
||||
onSaved: (overview: EnvsOverviewPayload, msg: string) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds or edits an env in one scope. A saved secret is never shown; typing
|
||||
* a new value replaces it, leaving it empty keeps it.
|
||||
*/
|
||||
export default function EnvForm({ scope, env, known, onSaved, onCancel }: Props) {
|
||||
const is_known = Boolean(known || env?.known);
|
||||
const [key, setKey] = useState(env?.key || known?.key || "");
|
||||
const [value, setValue] = useState(env && !env.secret ? env.value || "" : "");
|
||||
const [description, setDescription] = useState(env?.description || "");
|
||||
const [secret, setSecret] = useState(env?.secret ?? known?.secret ?? true);
|
||||
const [expose, setExpose] = useState(env?.expose_to_computers ?? true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
const id = `env-${env?.id || known?.key || "new"}`;
|
||||
const keeps_secret = Boolean(env?.secret);
|
||||
|
||||
const submit = async () => {
|
||||
if (loading) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
|
||||
const res = await postEnvApi({
|
||||
path: "save-env",
|
||||
body: {
|
||||
env_id: env?.id,
|
||||
key,
|
||||
value: keeps_secret && !value ? undefined : value,
|
||||
description: is_known ? undefined : description,
|
||||
paradigm: scope.paradigm,
|
||||
workspace_id: scope.paradigm == "workspace" ? scope.id : null,
|
||||
computer_id: scope.paradigm == "computer" ? scope.id : null,
|
||||
secret,
|
||||
expose_to_computers: expose,
|
||||
},
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (!res.success || !res.singleRes) {
|
||||
setError(res.msg || "Couldn't save the env.");
|
||||
return;
|
||||
}
|
||||
|
||||
onSaved(res.singleRes, res.msg || "Saved.");
|
||||
};
|
||||
|
||||
return (
|
||||
<Form className="w-full gap-4! items-stretch py-4" onSubmit={submit}>
|
||||
<Input
|
||||
id={`${id}-key`}
|
||||
title="Key"
|
||||
label="Key"
|
||||
showLabel
|
||||
placeholder="GITHUB_TOKEN"
|
||||
defaultValue={key}
|
||||
disabled={Boolean(env) || is_known}
|
||||
autoComplete="off"
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
id={`${id}-value`}
|
||||
title="Value"
|
||||
label="Value"
|
||||
showLabel
|
||||
type={secret ? "password" : "text"}
|
||||
autoComplete="off"
|
||||
defaultValue={value}
|
||||
placeholder={keeps_secret ? "Saved. Type a new value to replace it." : ""}
|
||||
info="Encrypted before it's stored."
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
{is_known ? null : (
|
||||
<>
|
||||
<Input
|
||||
id={`${id}-description`}
|
||||
title="Description"
|
||||
label="Description"
|
||||
showLabel
|
||||
placeholder="A token for the company GitHub"
|
||||
defaultValue={description}
|
||||
info="Bots see the key and this description, never the value."
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
<Checkbox
|
||||
title="Secret"
|
||||
label="Secret: hidden once saved, and blanked out of everything bots read"
|
||||
checked={secret}
|
||||
changeHandler={setSecret}
|
||||
/>
|
||||
<Checkbox
|
||||
title="Set on computers"
|
||||
label="Set on computers, for bots' commands and code"
|
||||
checked={expose}
|
||||
changeHandler={setExpose}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Stack className="gap-3! items-stretch">
|
||||
<SettingsStatus status={error ? { error: true, msg: error } : undefined} />
|
||||
<Row className="gap-2! flex-nowrap justify-end">
|
||||
<Button
|
||||
title="Cancel"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
size="small"
|
||||
disabled={loading}
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
title={env ? "Save env" : "Add env"}
|
||||
type="submit"
|
||||
size="small"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
{env ? "Save env" : "Add env"}
|
||||
</Button>
|
||||
</Row>
|
||||
</Stack>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import { Pencil, Plus, Trash2 } from "lucide-react";
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
/**
|
||||
* "set" for an app setting that isn't saved yet.
|
||||
*/
|
||||
mode: "edit" | "set";
|
||||
busy?: boolean;
|
||||
onEdit: () => void;
|
||||
onRemove?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Edit (or Set) and Remove for one env row.
|
||||
*/
|
||||
export default function EnvRowActions({ name, mode, busy, onEdit, onRemove }: Props) {
|
||||
return (
|
||||
<Row className="gap-1! flex-nowrap shrink-0">
|
||||
<Button
|
||||
title={mode == "set" ? `Set ${name}` : `Edit ${name}`}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
size="small"
|
||||
disabled={busy}
|
||||
onClick={onEdit}
|
||||
>
|
||||
{mode == "set" ? <Plus size={16} aria-hidden /> : <Pencil size={16} aria-hidden />}
|
||||
</Button>
|
||||
{onRemove ? (
|
||||
<Button
|
||||
title={`Remove ${name}`}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
size="small"
|
||||
loading={busy}
|
||||
disabled={busy}
|
||||
onClick={onRemove}
|
||||
>
|
||||
<Trash2 size={16} aria-hidden />
|
||||
</Button>
|
||||
) : null}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useState } from "react";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import type { EnvScope, EnvsOverviewPayload, KnownEnvStatus, PublicEnv } from "@/src/types";
|
||||
import postEnvApi from "../(functions)/post-env-api";
|
||||
import EnvForm from "./env-form";
|
||||
import EnvRowActions from "./env-row-actions";
|
||||
|
||||
type Props = {
|
||||
scope: EnvScope;
|
||||
/**
|
||||
* The saved env, if any. An app setting may have none yet.
|
||||
*/
|
||||
env?: PublicEnv;
|
||||
known?: KnownEnvStatus;
|
||||
onChanged: (overview: EnvsOverviewPayload, msg: string) => void;
|
||||
onError: (msg: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* One env: its key, description and value (or "secret"), with Edit and
|
||||
* Remove. Edit opens the form in place.
|
||||
*/
|
||||
export default function EnvRow({ scope, env, known, onChanged, onError }: Props) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const key = env?.key || known?.key || "";
|
||||
|
||||
const remove = async () => {
|
||||
if (!env || busy) return;
|
||||
if (!window.confirm(`Remove ${key}? Computers drop it on their next run.`)) return;
|
||||
|
||||
setBusy(true);
|
||||
const res = await postEnvApi({ path: "delete-env", body: { env_id: env.id } });
|
||||
setBusy(false);
|
||||
|
||||
if (res.success && res.singleRes) onChanged(res.singleRes, res.msg || "Removed.");
|
||||
else onError(res.msg || "Couldn't remove the env.");
|
||||
};
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<Stack className="w-full gap-0! items-stretch border-0 border-b border-solid border-hairline last:border-b-0">
|
||||
<EnvForm
|
||||
scope={scope}
|
||||
env={env}
|
||||
known={known}
|
||||
onSaved={(overview, msg) => {
|
||||
setEditing(false);
|
||||
onChanged(overview, msg);
|
||||
}}
|
||||
onCancel={() => setEditing(false)}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const status = env
|
||||
? env.secret
|
||||
? "Secret, saved"
|
||||
: env.value || "Empty"
|
||||
: known?.from_process
|
||||
? "From the server's environment"
|
||||
: "Not set";
|
||||
|
||||
const flags = env && !env.known && !env.expose_to_computers ? " · not on computers" : "";
|
||||
|
||||
return (
|
||||
<Row className="w-full gap-3! flex-nowrap items-start justify-between py-3 border-0 border-b border-solid border-hairline last:border-b-0">
|
||||
<Stack className="gap-0.5! min-w-0">
|
||||
<Span className="text-sm font-bold leading-5 text-ink truncate">{key}</Span>
|
||||
{env?.description || known?.description ? (
|
||||
<Span className="text-[13px] leading-[18px] text-ink-secondary break-words">
|
||||
{known?.description || env?.description}
|
||||
</Span>
|
||||
) : null}
|
||||
<Span className="text-[13px] leading-[18px] text-ink-secondary break-all">
|
||||
{status}
|
||||
{flags}
|
||||
</Span>
|
||||
</Stack>
|
||||
<EnvRowActions
|
||||
name={key}
|
||||
mode={env ? "edit" : "set"}
|
||||
busy={busy}
|
||||
onEdit={() => setEditing(true)}
|
||||
onRemove={env ? remove : undefined}
|
||||
/>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import Select from "@moduletrace/twui/form/Select";
|
||||
import type { EnvScope } from "@/src/types";
|
||||
import SettingsGroup from "../../(partials)/settings-group";
|
||||
import SettingsStatus from "../../(partials)/settings-status";
|
||||
import scopeValue from "../(utils)/scope-value";
|
||||
|
||||
type Props = {
|
||||
scopes: EnvScope[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
status?: { error?: boolean; success?: boolean; msg?: string };
|
||||
};
|
||||
|
||||
/**
|
||||
* Where the envs below apply: general, a workspace or a computer. Also
|
||||
* shows the last change's status.
|
||||
*/
|
||||
export default function EnvScopeSelect({ scopes, value, onChange, status }: Props) {
|
||||
return (
|
||||
<SettingsGroup
|
||||
title="Where"
|
||||
description="A computer's own envs beat its workspace's, which beat general ones."
|
||||
>
|
||||
<Select
|
||||
id="env-scope"
|
||||
title="Where"
|
||||
label="Where"
|
||||
showLabel
|
||||
options={scopes.map((scope) => ({
|
||||
value: scopeValue({ scope }),
|
||||
title: scope.title,
|
||||
default: scopeValue({ scope }) == value,
|
||||
}))}
|
||||
changeHandler={(next) => onChange(String(next))}
|
||||
/>
|
||||
<SettingsStatus status={status} />
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useState } from "react";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { Plus } from "lucide-react";
|
||||
import type { EnvScope, EnvsOverviewPayload, PublicEnv } from "@/src/types";
|
||||
import SettingsGroup from "../../(partials)/settings-group";
|
||||
import EnvForm from "./env-form";
|
||||
import EnvRow from "./env-row";
|
||||
|
||||
type Props = {
|
||||
scope: EnvScope;
|
||||
/**
|
||||
* The scope's own envs (app settings left out).
|
||||
*/
|
||||
envs: PublicEnv[];
|
||||
onChanged: (overview: EnvsOverviewPayload, msg: string) => void;
|
||||
onError: (msg: string) => void;
|
||||
};
|
||||
|
||||
const Descriptions: Record<EnvScope["paradigm"], string> = {
|
||||
general: "Set on every computer. A workspace or computer can set the same key to override it.",
|
||||
workspace: "Set on this workspace's computers when its bots use them. Beats general, loses to a computer's own.",
|
||||
computer: "Set on this computer only. Beats workspace and general.",
|
||||
};
|
||||
|
||||
/**
|
||||
* The envs bots and computers use, in one scope, with an add form.
|
||||
*/
|
||||
export default function EnvsGroup({ scope, envs, onChanged, onError }: Props) {
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
return (
|
||||
<SettingsGroup title="Envs" description={Descriptions[scope.paradigm]}>
|
||||
<Stack className="w-full gap-0! items-stretch">
|
||||
{envs.map((env) => (
|
||||
<EnvRow
|
||||
key={env.id}
|
||||
scope={scope}
|
||||
env={env}
|
||||
onChanged={onChanged}
|
||||
onError={onError}
|
||||
/>
|
||||
))}
|
||||
{envs.length ? null : (
|
||||
<Span className="text-[13px] leading-5 text-ink-secondary py-3">
|
||||
Nothing set here yet.
|
||||
</Span>
|
||||
)}
|
||||
</Stack>
|
||||
{adding ? (
|
||||
<EnvForm
|
||||
scope={scope}
|
||||
onSaved={(overview, msg) => {
|
||||
setAdding(false);
|
||||
onChanged(overview, msg);
|
||||
}}
|
||||
onCancel={() => setAdding(false)}
|
||||
/>
|
||||
) : (
|
||||
<Row className="gap-3! items-center justify-end">
|
||||
<Button
|
||||
title="Add an env"
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="gray"
|
||||
size="small"
|
||||
onClick={() => setAdding(true)}
|
||||
>
|
||||
<Plus size={16} aria-hidden />
|
||||
Add an env
|
||||
</Button>
|
||||
</Row>
|
||||
)}
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import type { EnvScope, EnvsOverviewPayload, PublicEnv } from "@/src/types";
|
||||
import SettingsGroup from "../../(partials)/settings-group";
|
||||
import EnvRow from "./env-row";
|
||||
|
||||
type Props = {
|
||||
scope: EnvScope;
|
||||
overview: EnvsOverviewPayload;
|
||||
onChanged: (overview: EnvsOverviewPayload, msg: string) => void;
|
||||
onError: (msg: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The app settings: envs the app itself reads. General only, never on
|
||||
* computers.
|
||||
*/
|
||||
export default function KnownEnvsGroup({ scope, overview, onChanged, onError }: Props) {
|
||||
return (
|
||||
<SettingsGroup
|
||||
title="App settings"
|
||||
description="What the app itself reads. A value here wins over the server's environment. These never reach computers or bots."
|
||||
>
|
||||
<Stack className="w-full gap-0! items-stretch">
|
||||
{overview.known.map((known) => (
|
||||
<EnvRow
|
||||
key={known.key}
|
||||
scope={scope}
|
||||
known={known}
|
||||
env={findGeneral({ envs: overview.envs, key: known.key })}
|
||||
onChanged={onChanged}
|
||||
onError={onError}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function findGeneral({ envs, key }: { envs: PublicEnv[]; key: string }) {
|
||||
return envs.find((env) => env.paradigm == "general" && env.key == key);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useContext, useState } from "react";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import type { EnvsOverviewPayload } from "@/src/types";
|
||||
import EnvScopeSelect from "../(partials)/env-scope-select";
|
||||
import EnvsGroup from "../(partials)/envs-group";
|
||||
import KnownEnvsGroup from "../(partials)/known-envs-group";
|
||||
import scopeValue, { envInScope } from "../(utils)/scope-value";
|
||||
|
||||
/**
|
||||
* Pick where (general, a workspace, a computer), then that scope's envs.
|
||||
* General also lists the app settings. Every change saves at once and
|
||||
* refreshes the page data.
|
||||
*/
|
||||
export default function EnvironmentSettingsSection() {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const [overview, setOverview] = useState(pageProps?.envs_overview);
|
||||
const [selected, setSelected] = useState("general");
|
||||
const [status, setStatus] = useState<{ error?: boolean; success?: boolean; msg?: string }>();
|
||||
/**
|
||||
* Remounts the lists when the data changes under them.
|
||||
*/
|
||||
const [version, setVersion] = useState(0);
|
||||
|
||||
if (!overview) return null;
|
||||
|
||||
const scope =
|
||||
overview.scopes.find((s) => scopeValue({ scope: s }) == selected) || overview.scopes[0]!;
|
||||
|
||||
const changed = (next: EnvsOverviewPayload, msg: string) => {
|
||||
setOverview(next);
|
||||
setStatus({ success: true, msg });
|
||||
setVersion((prev) => prev + 1);
|
||||
};
|
||||
const failed = (msg: string) => setStatus({ error: true, msg });
|
||||
|
||||
return (
|
||||
<>
|
||||
<EnvScopeSelect
|
||||
scopes={overview.scopes}
|
||||
value={scopeValue({ scope })}
|
||||
status={status}
|
||||
onChange={(next) => {
|
||||
setSelected(next);
|
||||
setStatus(undefined);
|
||||
}}
|
||||
/>
|
||||
{scope.paradigm == "general" ? (
|
||||
<KnownEnvsGroup
|
||||
key={`known-${version}`}
|
||||
scope={scope}
|
||||
overview={overview}
|
||||
onChanged={changed}
|
||||
onError={failed}
|
||||
/>
|
||||
) : null}
|
||||
<EnvsGroup
|
||||
key={`${scopeValue({ scope })}-${version}`}
|
||||
scope={scope}
|
||||
envs={overview.envs.filter((env) => !env.known && envInScope({ env, scope }))}
|
||||
onChanged={changed}
|
||||
onError={failed}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { PublicEnv } from "@/src/types";
|
||||
import scopeValue, { envInScope } from "./scope-value";
|
||||
|
||||
const env = (fields: Partial<PublicEnv>): PublicEnv => ({
|
||||
id: 1,
|
||||
key: "K",
|
||||
description: "",
|
||||
paradigm: "general",
|
||||
workspace_id: null,
|
||||
computer_id: null,
|
||||
secret: true,
|
||||
expose_to_computers: true,
|
||||
known: false,
|
||||
...fields,
|
||||
});
|
||||
|
||||
describe("scopeValue", () => {
|
||||
test("names each scope", () => {
|
||||
expect(scopeValue({ scope: { paradigm: "general", id: null } })).toBe("general");
|
||||
expect(scopeValue({ scope: { paradigm: "computer", id: 4 } })).toBe("computer:4");
|
||||
});
|
||||
});
|
||||
|
||||
describe("envInScope", () => {
|
||||
test("matches the paradigm and its id", () => {
|
||||
const scope = { paradigm: "workspace" as const, id: 2, title: "" };
|
||||
|
||||
expect(envInScope({ env: env({ paradigm: "workspace", workspace_id: 2 }), scope })).toBe(true);
|
||||
expect(envInScope({ env: env({ paradigm: "workspace", workspace_id: 3 }), scope })).toBe(false);
|
||||
expect(envInScope({ env: env({}), scope })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { EnvScope, PublicEnv } from "@/src/types";
|
||||
|
||||
/**
|
||||
* A scope as one select value: `general`, `workspace:3`, `computer:1`.
|
||||
*/
|
||||
export default function scopeValue({ scope }: { scope: Pick<EnvScope, "paradigm" | "id"> }) {
|
||||
return scope.paradigm == "general" ? "general" : `${scope.paradigm}:${scope.id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an env is set in this scope.
|
||||
*/
|
||||
export function envInScope({ env, scope }: { env: PublicEnv; scope: EnvScope }) {
|
||||
if (env.paradigm != scope.paradigm) return false;
|
||||
if (scope.paradigm == "workspace") return env.workspace_id == scope.id;
|
||||
if (scope.paradigm == "computer") return env.computer_id == scope.id;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { PagePropsType } from "@/src/types";
|
||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
import { SettingsDefaultPath } from "@/src/dict/settings-pages-dict";
|
||||
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
|
||||
import grabEnvsOverview from "@/src/functions/backend/db/envs/grab-envs-overview";
|
||||
import grabSettingsServerProps from "../(functions)/grab-settings-server-props";
|
||||
|
||||
/**
|
||||
* Super admins only; everyone else lands on the general settings.
|
||||
*/
|
||||
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||
const settings = await grabSettingsServerProps(params);
|
||||
|
||||
if (!settings.props) return settings;
|
||||
|
||||
const user = await grabSuperAdminUser({ req: params.req });
|
||||
|
||||
if (!user?.id) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: SettingsDefaultPath,
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
...settings.props,
|
||||
envs_overview: await grabEnvsOverview({ user_id: Number(user.id) }),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import EnvironmentSettingsSection from "./(sections)/environment-settings-section";
|
||||
import SettingsShell from "../(partials)/settings-shell";
|
||||
import settingsPageMeta from "../(functions)/settings-page-meta";
|
||||
|
||||
export default function AdminSettingsEnvironmentPage() {
|
||||
return (
|
||||
<SettingsShell
|
||||
title="Environment"
|
||||
description="Keys and settings your bots and computers use."
|
||||
>
|
||||
<EnvironmentSettingsSection />
|
||||
</SettingsShell>
|
||||
);
|
||||
}
|
||||
|
||||
export const meta = settingsPageMeta({ label: "Environment" });
|
||||
Reference in New Issue
Block a user