Fix review findings: secrets in snapshots, URL spoofing, migration data loss
Adversarial review of the branch produced findings across four areas. This addresses them, plus the Windows CI environment. Secrets. commit_container_snapshot baked the container's full env into the per-project snapshot image, so the shared OAuth token — and the AWS keys, git token and gateway master key — outlived revocation and were readable via docker inspect. Verified against Engine 29.6 that a commit body's config merges over the container's: keys cannot be dropped but can be overwritten, so all of them now commit as KEY=. clear_claude_token additionally rewrites images from earlier builds and reports honestly when a tag could not be rewritten. The recommendation to move the token out of env entirely was not taken, with reasoning: apiKeyHelper is a different auth method that outranks CLAUDE_CODE_OAUTH_TOKEN rather than a transport for it, and no file-based delivery exists. The durable exposure — the image — is what is closed here. Separately noted, not fixed: entrypoint.sh captures the token into the scheduler's .env inside the persisted volume. URL spoofing. Three call sites reached openUrl with container-controlled strings, one of which the review missed (the WebLinksAddon handler). The sign-in URL was scraped from container output with a longest-match tie-break and no userinfo check, so claude.ai@evil.tld rendered as "claude.ai…" in a truncating element. There is now one sanitizer in front of every sink — scheme allowlist, no userinfo, C0/C1 and quote rejection, host allowlist for the sign-in case, first-match — and the origin renders un-truncated. The toast is keyed so a changed URL remounts, closing a bait-and-switch where the user read one URL and clicked another. Migration. The rollback pin was best-effort: a tag failure was logged and the migration continued past remove_container, after which the final commit overwrote the only copy of the old system layer. It now aborts before anything destructive and reads the tag back. /var was destroyed while the ordinary recreate path preserves it — making the "safe" alternative to Reset more destructive than Reset's alternative; data-bearing subtrees are now detected and disclosed in the pre-flight rather than copied, since tarring a live database onto a different base's packages is a corruption risk. resume_migration now verifies the migration-state label instead of reporting success for a container that never swapped. dismiss actually resolves the record rather than leaving the feature permanently refusing to migrate. Start and Reset are guarded while a migration is live. Lifecycle. The gateway no longer publishes on 0.0.0.0 — bind address and advertised URL are derived together so they cannot drift. Disabling it now stops it. App exit runs teardown concurrently under a budget with a visible shutting-down state instead of blocking for minutes. Auto-starts retry when Docker is not up yet, and the polling-recovery path now reconciles, so interrupted migrations are still recovered. Auth-bridge forwards are capped, closing a container-driven fd exhaustion. Windows CI. build-windows failed on this branch with "linker link.exe not found". The runner had no MSVC build tools and the workflow assumed a hand-provisioned machine, so a bare runner registers, accepts jobs and fails at link time after downloading the whole crate graph. The job now installs the VC++ workload when vswhere cannot find it, matching how it already conditionally installs Rust and Node. 192 Rust tests, 274 frontend tests, both builds clean, zero warnings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,11 @@ import {
|
||||
authErrorMessage,
|
||||
useClaudeTokenAcquisition,
|
||||
} from "../../hooks/useClaudeAuth";
|
||||
import {
|
||||
ANTHROPIC_SIGN_IN_HOSTS,
|
||||
sanitizeRelayUrl,
|
||||
urlOrigin,
|
||||
} from "../../lib/urlRelay";
|
||||
|
||||
interface Props {
|
||||
/** Project whose running container is borrowed to run the CLI. */
|
||||
@@ -85,11 +90,30 @@ export default function ClaudeAuthModal({
|
||||
? PHASE_STATUS.finishing
|
||||
: PHASE_STATUS.waiting;
|
||||
|
||||
// Split for display only. `flow.signInUrl` has already passed the host
|
||||
// allowlist; this decides which half of it an ellipsis is allowed to eat.
|
||||
const signInOrigin = flow.signInUrl ? (urlOrigin(flow.signInUrl) ?? "") : "";
|
||||
const signInPath = flow.signInUrl
|
||||
? flow.signInUrl.slice(signInOrigin.length)
|
||||
: "";
|
||||
|
||||
const handleOpen = async () => {
|
||||
if (!flow.signInUrl) return;
|
||||
setLinkError(null);
|
||||
// Re-validated at the sink. `extractSignInUrl` already applies the host
|
||||
// allowlist, so a failure here means that invariant broke — which is the
|
||||
// one moment it matters that the last step before the OS opener checks.
|
||||
const target = sanitizeRelayUrl(flow.signInUrl, {
|
||||
allowHosts: ANTHROPIC_SIGN_IN_HOSTS,
|
||||
});
|
||||
if (!target) {
|
||||
setLinkError(
|
||||
"That link is not an Anthropic sign-in address and was not opened. Start authentication again.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await openUrl(flow.signInUrl);
|
||||
await openUrl(target);
|
||||
} catch (e) {
|
||||
setLinkError(
|
||||
authErrorMessage(
|
||||
@@ -103,8 +127,19 @@ export default function ClaudeAuthModal({
|
||||
const handleCopy = async () => {
|
||||
if (!flow.signInUrl) return;
|
||||
setLinkError(null);
|
||||
// Copying is the manual route to the same browser, so it gets the same
|
||||
// check — a link too dangerous to open is too dangerous to hand over.
|
||||
const target = sanitizeRelayUrl(flow.signInUrl, {
|
||||
allowHosts: ANTHROPIC_SIGN_IN_HOSTS,
|
||||
});
|
||||
if (!target) {
|
||||
setLinkError(
|
||||
"That link is not an Anthropic sign-in address and was not copied. Start authentication again.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(flow.signInUrl);
|
||||
await navigator.clipboard.writeText(target);
|
||||
setCopied(true);
|
||||
} catch (e) {
|
||||
setLinkError(
|
||||
@@ -185,16 +220,32 @@ export default function ClaudeAuthModal({
|
||||
{flow.signInUrl ? (
|
||||
<div className="mt-1 space-y-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* The origin is rendered at full length and the path is the
|
||||
only part allowed to truncate. A single `truncate` element
|
||||
showing the whole URL is a spoofing primitive: pad the
|
||||
front and the ellipsis eats the half that decides where the
|
||||
user's Anthropic password goes. */}
|
||||
<a
|
||||
href={flow.signInUrl}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void handleOpen();
|
||||
}}
|
||||
className="min-w-0 flex-1 truncate px-2.5 py-1.5 font-mono text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] transition-colors"
|
||||
className="flex min-w-0 flex-1 items-baseline px-2.5 py-1.5 font-mono text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] transition-colors"
|
||||
title={flow.signInUrl}
|
||||
>
|
||||
{flow.signInUrl}
|
||||
<span
|
||||
data-testid="claude-auth-url-origin"
|
||||
className="shrink-0 font-semibold [overflow-wrap:anywhere]"
|
||||
>
|
||||
{signInOrigin}
|
||||
</span>
|
||||
<span
|
||||
data-testid="claude-auth-url-path"
|
||||
className="min-w-0 truncate text-[var(--text-secondary)]"
|
||||
>
|
||||
{signInPath}
|
||||
</span>
|
||||
</a>
|
||||
<Button size="md" onClick={() => void handleOpen()}>
|
||||
Open
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import GatewaySettings from "./GatewaySettings";
|
||||
import type { AppSettings, GatewayStatus } from "../../lib/types";
|
||||
|
||||
const getGatewayStatus = vi.fn();
|
||||
const stopGateway = vi.fn();
|
||||
const startGateway = vi.fn();
|
||||
const checkGatewayHealth = vi.fn();
|
||||
const saveSettings = vi.fn();
|
||||
|
||||
vi.mock("../../lib/tauri-commands", () => ({
|
||||
getGatewayStatus: () => getGatewayStatus(),
|
||||
startGateway: () => startGateway(),
|
||||
stopGateway: () => stopGateway(),
|
||||
checkGatewayHealth: () => checkGatewayHealth(),
|
||||
pullGatewayImage: vi.fn(),
|
||||
buildGatewayImage: vi.fn(),
|
||||
setGatewayApiKey: vi.fn(),
|
||||
clearGatewayApiKey: vi.fn(),
|
||||
getGatewayAuthToken: vi.fn(),
|
||||
regenerateGatewayAuthToken: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn(async () => vi.fn()) }));
|
||||
|
||||
let appSettings: AppSettings | null = null;
|
||||
vi.mock("../../hooks/useSettings", () => ({
|
||||
useSettings: () => ({ appSettings, saveSettings }),
|
||||
}));
|
||||
|
||||
const settingsWithGateway = (enabled: boolean): AppSettings =>
|
||||
({
|
||||
gateway: { enabled, port: 4000, provider: "openai", api_base: null, models: [] },
|
||||
}) as unknown as AppSettings;
|
||||
|
||||
const status = (over: Partial<GatewayStatus> = {}): GatewayStatus => ({
|
||||
container_exists: true,
|
||||
running: true,
|
||||
port: 4000,
|
||||
image_exists: true,
|
||||
model_count: 0,
|
||||
has_api_key: false,
|
||||
base_url: "http://host.docker.internal:4000",
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("GatewaySettings", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
appSettings = settingsWithGateway(false);
|
||||
getGatewayStatus.mockResolvedValue(status());
|
||||
checkGatewayHealth.mockResolvedValue(true);
|
||||
saveSettings.mockImplementation(async (s: AppSettings) => s);
|
||||
stopGateway.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("keeps a working Stop button when the gateway is disabled but its container exists", async () => {
|
||||
render(<GatewaySettings />);
|
||||
|
||||
const stop = await screen.findByRole("button", { name: "Stop" });
|
||||
// The configuration UI stays hidden — only the container row survives.
|
||||
expect(screen.queryByLabelText("Provider")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("gateway-leftover-container")).toHaveTextContent(
|
||||
/gateway container is still present/i,
|
||||
);
|
||||
// Status is a word, not just a colour.
|
||||
expect(screen.getByTestId("gateway-leftover-container")).toHaveTextContent(
|
||||
/Running on port 4000/,
|
||||
);
|
||||
|
||||
fireEvent.click(stop);
|
||||
await waitFor(() => expect(stopGateway).toHaveBeenCalledTimes(1));
|
||||
// Stopping re-reads status: once on mount, once after the action.
|
||||
await waitFor(() => expect(getGatewayStatus).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it("shows nothing extra when the gateway is disabled and no container exists", async () => {
|
||||
getGatewayStatus.mockResolvedValue(status({ container_exists: false, running: false }));
|
||||
render(<GatewaySettings />);
|
||||
|
||||
await waitFor(() => expect(getGatewayStatus).toHaveBeenCalled());
|
||||
expect(screen.queryByTestId("gateway-leftover-container")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Stop" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("re-reads container status after toggling the gateway off", async () => {
|
||||
appSettings = settingsWithGateway(true);
|
||||
render(<GatewaySettings />);
|
||||
|
||||
await waitFor(() => expect(getGatewayStatus).toHaveBeenCalledTimes(1));
|
||||
|
||||
// The backend stops the container as part of update_settings, so the UI has
|
||||
// to re-read rather than trust the status it already has.
|
||||
getGatewayStatus.mockResolvedValue(status({ running: false }));
|
||||
fireEvent.click(screen.getByRole("switch", { name: "Model gateway" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(saveSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ gateway: expect.objectContaining({ enabled: false }) }),
|
||||
),
|
||||
);
|
||||
await waitFor(() => expect(getGatewayStatus).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
});
|
||||
@@ -79,9 +79,17 @@ export default function GatewaySettings() {
|
||||
refreshStatus();
|
||||
}, [refreshStatus]);
|
||||
|
||||
/**
|
||||
* Persist a gateway settings change, then re-read the container status.
|
||||
*
|
||||
* `update_settings` reconciles the container itself — it stops the gateway
|
||||
* when `enabled` goes false and recreates it on a port change — so the status
|
||||
* we are holding is stale the moment the save returns.
|
||||
*/
|
||||
const patch = async (changes: Partial<GatewaySettingsType>) => {
|
||||
if (!appSettings) return;
|
||||
await saveSettings({ ...appSettings, gateway: { ...gateway, ...changes } });
|
||||
await refreshStatus();
|
||||
};
|
||||
|
||||
const savePort = async () => {
|
||||
@@ -190,6 +198,13 @@ export default function GatewaySettings() {
|
||||
? "Stopped"
|
||||
: "Image ready";
|
||||
|
||||
// Rendered in whichever branch is live — only one of them ever mounts.
|
||||
const errorLine = error ? (
|
||||
<p className="text-xs text-[var(--error)]" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Model Gateway</label>
|
||||
@@ -213,6 +228,27 @@ export default function GatewaySettings() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/*
|
||||
Turning the gateway off hides its configuration, but a container that
|
||||
already exists must stay reachable — otherwise a leftover container
|
||||
keeps its port bound with no UI left to stop it.
|
||||
*/}
|
||||
{!gateway.enabled && status?.container_exists && (
|
||||
<div className="space-y-2" data-testid="gateway-leftover-container">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<StatusIndicator tone={tone} label={statusLabel} className="text-xs" />
|
||||
<Button variant="danger" disabled={loading} onClick={() => run(stopGateway)}>
|
||||
{loading ? "Working…" : "Stop"}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||
The gateway container is still present. Stop it here if it is still running; it
|
||||
will not be started again while the gateway is off.
|
||||
</p>
|
||||
{errorLine}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{gateway.enabled && (
|
||||
<>
|
||||
{/* ── Container ─────────────────────────────────────────────── */}
|
||||
@@ -247,11 +283,7 @@ export default function GatewaySettings() {
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-[var(--error)]" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{errorLine}
|
||||
|
||||
{/* ── Provider ──────────────────────────────────────────────── */}
|
||||
<Field
|
||||
@@ -395,10 +427,10 @@ export default function GatewaySettings() {
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-[var(--text-secondary)] leading-snug">
|
||||
Set a project's backend to <strong>OpenAI Compatible</strong> and use these
|
||||
values. On native Linux Docker, where{" "}
|
||||
<code className="font-mono">host.docker.internal</code> is not injected into
|
||||
containers, use <code className="font-mono">http://172.17.0.1:{gateway.port}</code>{" "}
|
||||
instead.
|
||||
values. The base URL below is the one your Docker engine actually needs —{" "}
|
||||
<code className="font-mono">host.docker.internal</code> on Docker Desktop, the
|
||||
bridge gateway address on native Linux, where that name is not injected into
|
||||
containers.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import SharedAuthSettings from "./SharedAuthSettings";
|
||||
import type { Project } from "../../lib/types";
|
||||
import { useAppState } from "../../store/appState";
|
||||
import type { ClearTokenOutcome, Project } from "../../lib/types";
|
||||
|
||||
const hasClaudeToken = vi.fn();
|
||||
const clearClaudeToken = vi.fn();
|
||||
@@ -63,8 +64,29 @@ describe("SharedAuthSettings", () => {
|
||||
vi.clearAllMocks();
|
||||
projects = [];
|
||||
hasClaudeToken.mockResolvedValue(false);
|
||||
useAppState.setState({ toasts: [] });
|
||||
});
|
||||
|
||||
/** Open the confirmation and go through with it. */
|
||||
async function revoke(outcome: Partial<ClearTokenOutcome>) {
|
||||
projects = [running()];
|
||||
hasClaudeToken.mockResolvedValue(true);
|
||||
clearClaudeToken.mockResolvedValue({
|
||||
snapshots_scrubbed: [],
|
||||
snapshots_failed: [],
|
||||
snapshots_superseded: [],
|
||||
docker_unavailable: null,
|
||||
...outcome,
|
||||
});
|
||||
render(<SharedAuthSettings />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Revoke" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Revoke token" }));
|
||||
await waitFor(() =>
|
||||
expect(useAppState.getState().toasts.length).toBeGreaterThan(0),
|
||||
);
|
||||
return useAppState.getState().toasts[0];
|
||||
}
|
||||
|
||||
it("disables Authenticate and says why when nothing is running", async () => {
|
||||
projects = [baseProject];
|
||||
render(<SharedAuthSettings />);
|
||||
@@ -123,4 +145,48 @@ describe("SharedAuthSettings", () => {
|
||||
await screen.findByText("keyring backend unavailable");
|
||||
expect(screen.queryByRole("button", { name: "Revoke" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── Revoking has to tell the truth ──────────────────────────────────────
|
||||
// Deleting the keychain entry is only part of it. `docker commit` copies the
|
||||
// token into each project's snapshot image, and an image outlives every
|
||||
// container built from it — so a "removed" message while a snapshot still
|
||||
// holds a live ~1-year credential is the wrong thing to say.
|
||||
|
||||
it("says so plainly when snapshot images were cleared too", async () => {
|
||||
const toast = await revoke({
|
||||
snapshots_scrubbed: ["triple-c-snapshot-p1:latest"],
|
||||
});
|
||||
expect(toast.kind).toBe("success");
|
||||
expect(toast.message).toMatch(/1 snapshot image/);
|
||||
});
|
||||
|
||||
it("reports an error, not success, when an image still holds the token", async () => {
|
||||
const toast = await revoke({
|
||||
snapshots_failed: ["triple-c-snapshot-p1:latest: image has child images"],
|
||||
});
|
||||
expect(toast.kind).toBe("error");
|
||||
expect(toast.message).toMatch(/still in some images/i);
|
||||
expect(toast.detail).toMatch(/triple-c-snapshot-p1/);
|
||||
});
|
||||
|
||||
it("does not claim the images are clean when Docker could not be reached", async () => {
|
||||
const toast = await revoke({ docker_unavailable: "Docker is not running" });
|
||||
expect(toast.kind).toBe("error");
|
||||
expect(toast.detail).toMatch(/Docker could not be reached/);
|
||||
});
|
||||
|
||||
it("mentions a retained image layer without calling the revoke a failure", async () => {
|
||||
const toast = await revoke({
|
||||
snapshots_scrubbed: ["triple-c-snapshot-p1:latest"],
|
||||
snapshots_superseded: ["triple-c-snapshot-p1:latest"],
|
||||
});
|
||||
expect(toast.kind).toBe("success");
|
||||
expect(toast.detail).toMatch(/still on disk because a container is running/);
|
||||
});
|
||||
|
||||
it("still succeeds plainly when there was nothing to scrub", async () => {
|
||||
const toast = await revoke({});
|
||||
expect(toast.kind).toBe("success");
|
||||
expect(toast.message).toBe("Shared Claude token removed from the keychain.");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -64,13 +64,52 @@ export default function SharedAuthSettings() {
|
||||
const handleRevoke = async () => {
|
||||
setRevoking(true);
|
||||
try {
|
||||
await clearClaudeToken();
|
||||
const outcome = await clearClaudeToken();
|
||||
setConfirmRevoke(false);
|
||||
await refresh();
|
||||
pushToast({
|
||||
kind: "success",
|
||||
message: "Shared Claude token removed from the keychain.",
|
||||
});
|
||||
|
||||
// The keychain entry is gone either way. What matters here is the copy of
|
||||
// the token that `docker commit` baked into each project's snapshot
|
||||
// image: that one outlives every container, and `docker image inspect`
|
||||
// will keep printing it until the image is rewritten. If that could not
|
||||
// be done, the revocation is incomplete and saying "removed" would be a
|
||||
// lie.
|
||||
if (outcome.docker_unavailable) {
|
||||
pushToast({
|
||||
kind: "error",
|
||||
message: "Token removed from the keychain, but snapshots were not checked.",
|
||||
detail:
|
||||
`Docker could not be reached (${outcome.docker_unavailable}), so any snapshot image ` +
|
||||
"built before this version may still contain the token in its environment. " +
|
||||
"Start Docker and revoke again to clear them.",
|
||||
});
|
||||
} else if (outcome.snapshots_failed.length > 0) {
|
||||
pushToast({
|
||||
kind: "error",
|
||||
message: "Token removed from the keychain, but it is still in some images.",
|
||||
detail:
|
||||
`${outcome.snapshots_failed.length} snapshot image(s) could not be rewritten and ` +
|
||||
"still contain the token, readable via `docker image inspect`. Reset those " +
|
||||
`projects to remove the images. Details: ${outcome.snapshots_failed.join("; ")}`,
|
||||
});
|
||||
} else if (outcome.snapshots_scrubbed.length > 0) {
|
||||
pushToast({
|
||||
kind: "success",
|
||||
message: `Shared Claude token removed, and cleared from ${outcome.snapshots_scrubbed.length} snapshot image(s).`,
|
||||
detail:
|
||||
outcome.snapshots_superseded.length > 0
|
||||
? "The pre-rewrite image layer for " +
|
||||
`${outcome.snapshots_superseded.join(", ")} is still on disk because a ` +
|
||||
"container is running from it. It goes away once that project is restarted " +
|
||||
"(which recreates the container) and Docker prunes the leftover."
|
||||
: undefined,
|
||||
});
|
||||
} else {
|
||||
pushToast({
|
||||
kind: "success",
|
||||
message: "Shared Claude token removed from the keychain.",
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
pushToast({
|
||||
kind: "error",
|
||||
@@ -220,6 +259,13 @@ export default function SharedAuthSettings() {
|
||||
container starts. Existing running containers keep working until they are
|
||||
restarted.
|
||||
</p>
|
||||
<p className="mt-2 text-[13px] text-[var(--text-secondary)] leading-snug">
|
||||
Each project’s snapshot image is also rewritten, because{" "}
|
||||
<code className="font-mono">docker commit</code> copies the token into it
|
||||
and an image outlives every container built from it. If any image
|
||||
cannot be rewritten you will be told which, and the token stays readable
|
||||
in it until that project is Reset.
|
||||
</p>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user