Compare commits
5 Commits
7df39bd27a
...
67bbe0d1e9
| Author | SHA1 | Date | |
|---|---|---|---|
| 67bbe0d1e9 | |||
| 685ea25365 | |||
| 54c98c1e4a | |||
|
|
4c7d8d4af7 | ||
|
|
779f890b2f |
1019
infra/setup.ts
1019
infra/setup.ts
File diff suppressed because it is too large
Load Diff
73
infra/setup/build.ts
Normal file
73
infra/setup/build.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import * as colors from "jsr:@std/fmt@0.225.2/colors";
|
||||||
|
|
||||||
|
export function generateProtobufCompilationCommands(): string[] {
|
||||||
|
return [
|
||||||
|
"deno",
|
||||||
|
"run",
|
||||||
|
"-A",
|
||||||
|
"npm:@bufbuild/buf",
|
||||||
|
"generate",
|
||||||
|
"server/auth.proto",
|
||||||
|
"--template",
|
||||||
|
'{"version":"v1","plugins":[{"plugin":"buf.build/bufbuild/es:v1.10.0","out":"sdk/gen","opt":"target=ts,import_extension=.ts"},{"plugin":"buf.build/connectrpc/es:v1.4.0","out":"sdk/gen","opt":"target=ts,import_extension=.ts"}]}',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// SIDE EFFECT: Runs the protoc compilation
|
||||||
|
export async function executeProtobufCompilation(): Promise<void> {
|
||||||
|
const commands = generateProtobufCompilationCommands();
|
||||||
|
const cmd = new Deno.Command(commands[0], {
|
||||||
|
args: commands.slice(1),
|
||||||
|
stdout: "inherit",
|
||||||
|
stderr: "inherit",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { code } = await cmd.output();
|
||||||
|
if (code !== 0) {
|
||||||
|
throw new Error("Failed to compile protobuf definitions.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadWorkloadProto(): Promise<void> {
|
||||||
|
console.log(colors.blue("\nDownloading workload.proto..."));
|
||||||
|
const res = await fetch(
|
||||||
|
"https://raw.githubusercontent.com/spiffe/go-spiffe/main/proto/spiffe/workload/workload.proto",
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to download workload.proto: ${res.statusText}`);
|
||||||
|
}
|
||||||
|
const text = await res.text();
|
||||||
|
await Deno.mkdir("spire_ffi/proto", { recursive: true });
|
||||||
|
await Deno.writeTextFile("spire_ffi/proto/workload.proto", text);
|
||||||
|
console.log(colors.green("✓ workload.proto downloaded successfully."));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateBuildCommands(reg: string): string[] {
|
||||||
|
return [
|
||||||
|
`podman build -t ${reg}/library/auth-yes-api:latest -f Dockerfile .`,
|
||||||
|
`podman push ${reg}/library/auth-yes-api:latest`,
|
||||||
|
`podman build -t ${reg}/library/spire-server:latest -f spire/Dockerfile.server spire/`,
|
||||||
|
`podman push ${reg}/library/spire-server:latest`,
|
||||||
|
`podman build -t ${reg}/library/spire-agent:latest -f spire/Dockerfile.agent spire/`,
|
||||||
|
`podman push ${reg}/library/spire-agent:latest`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// SIDE EFFECT: Executes docker build and push commands to the host system
|
||||||
|
export async function executeBuildImage(commands: string[]): Promise<void> {
|
||||||
|
for (const cmd of commands) {
|
||||||
|
console.log(colors.cyan(`\nExecuting: ${cmd}`));
|
||||||
|
const args = cmd.split(" ");
|
||||||
|
const process = new Deno.Command(args[0], {
|
||||||
|
args: args.slice(1),
|
||||||
|
stdout: "inherit",
|
||||||
|
stderr: "inherit",
|
||||||
|
stdin: "inherit",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { code } = await process.output();
|
||||||
|
if (code !== 0) {
|
||||||
|
throw new Error(`Command failed with exit code ${code}: ${cmd}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
771
infra/setup/cli.ts
Normal file
771
infra/setup/cli.ts
Normal file
@ -0,0 +1,771 @@
|
|||||||
|
import { Command } from "jsr:@cliffy/command@1.0.0-rc.7";
|
||||||
|
import { Input, Secret, Select } from "jsr:@cliffy/prompt@1.0.0-rc.7";
|
||||||
|
import * as colors from "jsr:@std/fmt@0.225.2/colors";
|
||||||
|
import {
|
||||||
|
generateDockerCompose,
|
||||||
|
generateSpireDockerCompose,
|
||||||
|
} from "./compose.ts";
|
||||||
|
import {
|
||||||
|
AuthSetupConfig,
|
||||||
|
COMPOSE_PATH,
|
||||||
|
DEFAULT_AUTH_CONFIG,
|
||||||
|
ENV_PATH,
|
||||||
|
generateEnv,
|
||||||
|
generateSpireEnv,
|
||||||
|
readEnv,
|
||||||
|
SPIRE_COMPOSE_PATH,
|
||||||
|
SPIRE_ENV_PATH,
|
||||||
|
} from "./env.ts";
|
||||||
|
import {
|
||||||
|
downloadWorkloadProto,
|
||||||
|
executeBuildImage,
|
||||||
|
executeProtobufCompilation,
|
||||||
|
generateBuildCommands,
|
||||||
|
} from "./build.ts";
|
||||||
|
|
||||||
|
export async function generateAuthSetupFiles(
|
||||||
|
config: AuthSetupConfig,
|
||||||
|
): Promise<void> {
|
||||||
|
const envContent = generateEnv(config);
|
||||||
|
await Deno.writeTextFile(ENV_PATH, envContent);
|
||||||
|
|
||||||
|
const spireEnvContent = generateSpireEnv(config);
|
||||||
|
await Deno.writeTextFile(SPIRE_ENV_PATH, spireEnvContent);
|
||||||
|
|
||||||
|
const composeContent = generateDockerCompose();
|
||||||
|
await Deno.writeTextFile(COMPOSE_PATH, composeContent);
|
||||||
|
|
||||||
|
const spireComposeContent = generateSpireDockerCompose();
|
||||||
|
await Deno.writeTextFile(SPIRE_COMPOSE_PATH, spireComposeContent);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
colors.green(
|
||||||
|
`\n✓ Successfully generated ${ENV_PATH}, ${SPIRE_ENV_PATH}, ${COMPOSE_PATH}, and ${SPIRE_COMPOSE_PATH}!`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
colors.green("Setup complete. You may deploy your stacks by running:\n"),
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
colors.cyan(
|
||||||
|
"1. Deploy SPIRE Stack:\n" +
|
||||||
|
" podman-compose --project-name spire --env-file infra/.env.spire -f infra/compose.spire.yml up -d\n\n" +
|
||||||
|
"2. Deploy Auth-Yes Stack:\n" +
|
||||||
|
" podman-compose --project-name auth-yes --env-file infra/stack.env -f infra/compose.yml up -d\n",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleAuthSetup(
|
||||||
|
currentConfig: AuthSetupConfig,
|
||||||
|
): Promise<AuthSetupConfig> {
|
||||||
|
console.log(
|
||||||
|
colors.gray(
|
||||||
|
"Please provide the following Auth Yes configuration details.\n",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const reg = await Input.prompt({
|
||||||
|
message: "Enter the Primary Container Registry URL:",
|
||||||
|
default: currentConfig.reg,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ghcrReg = await Input.prompt({
|
||||||
|
message: "Enter the GHCR Mirror Registry URL (for SPIRE):",
|
||||||
|
default: currentConfig.ghcrReg || "ghcr.atyg.org",
|
||||||
|
});
|
||||||
|
|
||||||
|
const domainName = await Input.prompt({
|
||||||
|
message: "Enter the Auth Domain Name:",
|
||||||
|
hint: "E.g., auth.system.local",
|
||||||
|
default: currentConfig.domainName,
|
||||||
|
});
|
||||||
|
|
||||||
|
const dbDataPath = await Input.prompt({
|
||||||
|
message: "Enter the Database Path on the Host:",
|
||||||
|
default: currentConfig.dbDataPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
const spireDataPath = await Input.prompt({
|
||||||
|
message: "Enter the SPIRE Path on the Host:",
|
||||||
|
default: currentConfig.spireDataPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
const appSecret = await Secret.prompt({
|
||||||
|
message: "Enter the App Secret for the IDP:",
|
||||||
|
default: currentConfig.appSecret,
|
||||||
|
minLength: 16,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pwdMessage = currentConfig.dbPassword
|
||||||
|
? "Enter the PostgreSQL database password: (Leave blank to keep existing password)"
|
||||||
|
: "Enter the PostgreSQL database password:";
|
||||||
|
|
||||||
|
const pwdInput = await Secret.prompt({
|
||||||
|
message: pwdMessage,
|
||||||
|
minLength: currentConfig.dbPassword ? 0 : 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const dbPassword = pwdInput === "" && currentConfig.dbPassword !== ""
|
||||||
|
? currentConfig.dbPassword
|
||||||
|
: pwdInput;
|
||||||
|
|
||||||
|
const newConfig: AuthSetupConfig = {
|
||||||
|
reg,
|
||||||
|
ghcrReg,
|
||||||
|
domainName,
|
||||||
|
dbPassword,
|
||||||
|
dbDataPath,
|
||||||
|
spireDataPath,
|
||||||
|
appSecret,
|
||||||
|
};
|
||||||
|
|
||||||
|
await generateAuthSetupFiles(newConfig);
|
||||||
|
|
||||||
|
return newConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleTestConnection(domainName: string): Promise<void> {
|
||||||
|
console.log(
|
||||||
|
colors.bold(
|
||||||
|
colors.blue(`\n=== Testing Auth Connection (https://${domainName}) ===`),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
||||||
|
const res1 = await fetch(`https://${domainName}/`, {
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (res1.status === 502 || res1.status === 503 || res1.status === 504) {
|
||||||
|
throw new Error(`Gateway error: ${res1.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res1.body) {
|
||||||
|
await res1.body.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
colors.green(
|
||||||
|
`✓ Auth service is up and reachable at https://${domainName}/`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(
|
||||||
|
colors.red(
|
||||||
|
`✗ Error: Could not reach the auth service at https://${domainName}/`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (error instanceof Error) {
|
||||||
|
console.log(colors.red(` Reason: ${error.message}`));
|
||||||
|
} else {
|
||||||
|
console.log(colors.red(` Reason: ${error}`));
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
colors.red(
|
||||||
|
" Please verify the container is running and DNS/Traefik is resolving.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReviewConfigs(): Promise<void> {
|
||||||
|
const envContent = await Deno.readTextFile(ENV_PATH).catch(() => null);
|
||||||
|
const composeContent = await Deno.readTextFile(COMPOSE_PATH).catch(() =>
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const spireComposeContent = await Deno.readTextFile(SPIRE_COMPOSE_PATH).catch(
|
||||||
|
() => null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!envContent && !composeContent && !spireComposeContent) {
|
||||||
|
console.log(
|
||||||
|
colors.red("\n✗ No generated configs found. Run the setup first.\n"),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const action = await Select.prompt({
|
||||||
|
message: "Review Generated Configs",
|
||||||
|
options: [
|
||||||
|
{ name: "[Show .env]", value: "env" },
|
||||||
|
{ name: "[Show compose.yml]", value: "compose" },
|
||||||
|
{ name: "[Show compose.spire.yml]", value: "spire_compose" },
|
||||||
|
{ name: "[Back to Main Menu]", value: "back" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (action === "env") {
|
||||||
|
console.log(colors.bold(colors.blue(`\n=== ${ENV_PATH} ===\n`)));
|
||||||
|
console.log(envContent || colors.yellow("File not found."));
|
||||||
|
console.log();
|
||||||
|
} else if (action === "compose") {
|
||||||
|
console.log(colors.bold(colors.blue(`\n=== ${COMPOSE_PATH} ===\n`)));
|
||||||
|
console.log(composeContent || colors.yellow("File not found."));
|
||||||
|
console.log();
|
||||||
|
} else if (action === "spire_compose") {
|
||||||
|
console.log(
|
||||||
|
colors.bold(colors.blue(`\n=== ${SPIRE_COMPOSE_PATH} ===\n`)),
|
||||||
|
);
|
||||||
|
console.log(spireComposeContent || colors.yellow("File not found."));
|
||||||
|
console.log();
|
||||||
|
} else if (action === "back") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SIDE EFFECT: Runs the interactive CLI wizard.
|
||||||
|
export async function runSetupWizard(): Promise<void> {
|
||||||
|
console.log(colors.bold(colors.blue("=== Auth Setup Wizard ===\n")));
|
||||||
|
|
||||||
|
const loadedEnv = await readEnv();
|
||||||
|
let currentConfig: AuthSetupConfig = {
|
||||||
|
...DEFAULT_AUTH_CONFIG,
|
||||||
|
...loadedEnv,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (Object.keys(loadedEnv).length > 0 && currentConfig.domainName) {
|
||||||
|
await handleTestConnection(currentConfig.domainName);
|
||||||
|
console.log();
|
||||||
|
}
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const action = await Select.prompt({
|
||||||
|
message: "Main Menu",
|
||||||
|
options: [
|
||||||
|
{ name: "[Test Auth Connection]", value: "test" },
|
||||||
|
{ name: "[Configure Auth Yes API]", value: "auth" },
|
||||||
|
{ name: "[Compile Protobuf Definitions]", value: "compile_proto" },
|
||||||
|
{ name: "[Review Generated Configs]", value: "review" },
|
||||||
|
{ name: "[Build and Push Auth Image]", value: "build" },
|
||||||
|
{ name: "[Exit]", value: "exit" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (action === "test") {
|
||||||
|
await handleTestConnection(currentConfig.domainName);
|
||||||
|
console.log();
|
||||||
|
} else if (action === "auth") {
|
||||||
|
currentConfig = await handleAuthSetup(currentConfig);
|
||||||
|
} else if (action === "compile_proto") {
|
||||||
|
try {
|
||||||
|
await downloadWorkloadProto();
|
||||||
|
await executeProtobufCompilation();
|
||||||
|
console.log(colors.green("\n✓ Successfully compiled protobufs!\n"));
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
console.log(
|
||||||
|
colors.red(`\n✗ Protobuf compilation failed: ${error.message}\n`),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
colors.red(`\n✗ Protobuf compilation failed: ${error}\n`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (action === "review") {
|
||||||
|
await handleReviewConfigs();
|
||||||
|
} else if (action === "build") {
|
||||||
|
try {
|
||||||
|
const commands = generateBuildCommands(currentConfig.reg);
|
||||||
|
await executeBuildImage(commands);
|
||||||
|
console.log(colors.green("\n✓ Successfully built and pushed image!"));
|
||||||
|
console.log(
|
||||||
|
colors.green("You may now deploy your stack by running:\n"),
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
colors.cyan(
|
||||||
|
"podman-compose --project-name auth-yes --env-file infra/stack.env -f infra/compose.yml up -d\n",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
console.log(
|
||||||
|
colors.red(`\n✗ Build process failed: ${error.message}\n`),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(colors.red(`\n✗ Build process failed: ${error}\n`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (action === "exit") {
|
||||||
|
console.log(colors.gray("Exiting...\n"));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runCli(args = Deno.args): Promise<void> {
|
||||||
|
if (import.meta.main || args.length === 0) {
|
||||||
|
if (!Deno.stdin.isTerminal() && args.length === 0) {
|
||||||
|
console.error(
|
||||||
|
colors.red(
|
||||||
|
"Error: Non-interactive environment detected, but no explicit CLI subcommands or --auto flag were provided. Aborting to prevent hangs.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Deno.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cmd = new Command()
|
||||||
|
.name("auth-setup")
|
||||||
|
.description("Auth setup wizard and CLI")
|
||||||
|
.action(() => {
|
||||||
|
runSetupWizard();
|
||||||
|
})
|
||||||
|
.command("auth", "Configure Auth Yes API")
|
||||||
|
.option("--auto, --headless", "Run in headless mode")
|
||||||
|
.option("--registry <reg:string>", "Container Registry URL")
|
||||||
|
.option("--ghcr-registry <ghcrReg:string>", "GHCR Mirror Registry URL")
|
||||||
|
.option("--domain <domain:string>", "Auth Domain Name")
|
||||||
|
.option("--db-path <path:string>", "Database Path on the Host")
|
||||||
|
.option("--spire-path <path:string>", "SPIRE Path on the Host")
|
||||||
|
.action(async (options) => {
|
||||||
|
const loadedEnv = await readEnv();
|
||||||
|
const currentConfig: AuthSetupConfig = {
|
||||||
|
...DEFAULT_AUTH_CONFIG,
|
||||||
|
...loadedEnv,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options.auto) {
|
||||||
|
if (!options.registry || !options.domain || !options.dbPath) {
|
||||||
|
console.error(
|
||||||
|
colors.red(
|
||||||
|
"Error: --registry, --domain, and --db-path are required in headless mode.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Deno.exit(1);
|
||||||
|
}
|
||||||
|
const dbPassword = Deno.env.get("POSTGRES_PASSWORD") ||
|
||||||
|
currentConfig.dbPassword;
|
||||||
|
const appSecret = Deno.env.get("APP_SECRET") ||
|
||||||
|
currentConfig.appSecret;
|
||||||
|
if (!dbPassword) {
|
||||||
|
console.error(
|
||||||
|
colors.red(
|
||||||
|
"Error: POSTGRES_PASSWORD environment variable is required in headless mode.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Deno.exit(1);
|
||||||
|
}
|
||||||
|
if (!appSecret) {
|
||||||
|
console.error(
|
||||||
|
colors.red(
|
||||||
|
"Error: APP_SECRET environment variable is required in headless mode.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Deno.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newConfig: AuthSetupConfig = {
|
||||||
|
reg: options.registry,
|
||||||
|
ghcrReg: options.ghcrRegistry || currentConfig.ghcrReg ||
|
||||||
|
"ghcr.atyg.org",
|
||||||
|
domainName: options.domain,
|
||||||
|
dbPassword,
|
||||||
|
dbDataPath: options.dbPath || currentConfig.dbDataPath ||
|
||||||
|
"/volume1/docker/auth-yes/data",
|
||||||
|
spireDataPath: options.spirePath || currentConfig.spireDataPath ||
|
||||||
|
"/volume1/docker/spire",
|
||||||
|
appSecret,
|
||||||
|
};
|
||||||
|
await generateAuthSetupFiles(newConfig);
|
||||||
|
} else {
|
||||||
|
if (options.registry) currentConfig.reg = options.registry;
|
||||||
|
if (options.ghcrRegistry) currentConfig.ghcrReg = options.ghcrRegistry;
|
||||||
|
if (options.domain) currentConfig.domainName = options.domain;
|
||||||
|
if (options.dbPath) currentConfig.dbDataPath = options.dbPath;
|
||||||
|
if (options.spirePath) currentConfig.spireDataPath = options.spirePath;
|
||||||
|
await handleAuthSetup(currentConfig);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.command("test", "Test Auth Connection")
|
||||||
|
.action(async () => {
|
||||||
|
const loadedEnv = await readEnv();
|
||||||
|
const currentConfig: AuthSetupConfig = {
|
||||||
|
...DEFAULT_AUTH_CONFIG,
|
||||||
|
...loadedEnv,
|
||||||
|
};
|
||||||
|
await handleTestConnection(currentConfig.domainName);
|
||||||
|
})
|
||||||
|
.command("compile_proto", "Compile Protobuf Definitions")
|
||||||
|
.action(async () => {
|
||||||
|
try {
|
||||||
|
await downloadWorkloadProto();
|
||||||
|
await executeProtobufCompilation();
|
||||||
|
console.log(colors.green("\n✓ Successfully compiled protobufs!\n"));
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
console.log(
|
||||||
|
colors.red(`\n✗ Protobuf compilation failed: ${error.message}\n`),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
colors.red(`\n✗ Protobuf compilation failed: ${error}\n`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Deno.exit(1);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.command("build", "Build and Push Auth Image")
|
||||||
|
.action(async () => {
|
||||||
|
try {
|
||||||
|
const loadedEnv = await readEnv();
|
||||||
|
const currentConfig: AuthSetupConfig = {
|
||||||
|
...DEFAULT_AUTH_CONFIG,
|
||||||
|
...loadedEnv,
|
||||||
|
};
|
||||||
|
const commands = generateBuildCommands(currentConfig.reg);
|
||||||
|
await executeBuildImage(commands);
|
||||||
|
console.log(
|
||||||
|
colors.green("\n✓ Successfully built and pushed auth image!"),
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
colors.green("You may now deploy your stack by running:\n"),
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
colors.cyan(
|
||||||
|
"podman-compose --project-name auth-yes --env-file infra/stack.env -f infra/compose.yml up -d\n",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
console.log(
|
||||||
|
colors.red(`\n✗ Build process failed: ${error.message}\n`),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(colors.red(`\n✗ Build process failed: ${error}\n`));
|
||||||
|
}
|
||||||
|
Deno.exit(1);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.command("dump_compose", "Output all generated Compose YAML configurations")
|
||||||
|
.action(() => {
|
||||||
|
console.log(
|
||||||
|
colors.bold(
|
||||||
|
colors.blue(
|
||||||
|
"\n================================================================",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
colors.bold(
|
||||||
|
colors.green(" STACK 1: Auth-Yes Stack (infra/compose.yml)"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
colors.bold(
|
||||||
|
colors.blue(
|
||||||
|
"================================================================\n",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
console.log(generateDockerCompose());
|
||||||
|
console.log(
|
||||||
|
colors.bold(
|
||||||
|
colors.blue(
|
||||||
|
"================================================================",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
colors.bold(
|
||||||
|
colors.green(
|
||||||
|
" STACK 2: Standalone SPIRE Stack (infra/compose.spire.yml)",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
colors.bold(
|
||||||
|
colors.blue(
|
||||||
|
"================================================================\n",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
console.log(generateSpireDockerCompose());
|
||||||
|
})
|
||||||
|
.command(
|
||||||
|
"dump_env",
|
||||||
|
"Output current environment configuration (.env and .env.spire)",
|
||||||
|
)
|
||||||
|
.action(async () => {
|
||||||
|
const loadedEnv = await readEnv();
|
||||||
|
const currentConfig: AuthSetupConfig = {
|
||||||
|
...DEFAULT_AUTH_CONFIG,
|
||||||
|
...loadedEnv,
|
||||||
|
};
|
||||||
|
console.log(
|
||||||
|
colors.bold(
|
||||||
|
colors.blue(
|
||||||
|
"\n================================================================",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
colors.bold(
|
||||||
|
colors.green(" STACK 1: Auth-Yes Environment (infra/stack.env)"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
colors.bold(
|
||||||
|
colors.blue(
|
||||||
|
"================================================================\n",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
console.log(generateEnv(currentConfig));
|
||||||
|
console.log(
|
||||||
|
colors.bold(
|
||||||
|
colors.blue(
|
||||||
|
"================================================================",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
colors.bold(
|
||||||
|
colors.green(
|
||||||
|
" STACK 2: SPIRE Stack Environment (infra/.env.spire)",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
colors.bold(
|
||||||
|
colors.blue(
|
||||||
|
"================================================================\n",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
console.log(generateSpireEnv(currentConfig));
|
||||||
|
})
|
||||||
|
.command("secrets", "Inspect secrets status and persistence paths")
|
||||||
|
.action(async () => {
|
||||||
|
const loadedEnv = await readEnv();
|
||||||
|
const currentConfig: AuthSetupConfig = {
|
||||||
|
...DEFAULT_AUTH_CONFIG,
|
||||||
|
...loadedEnv,
|
||||||
|
};
|
||||||
|
console.log(
|
||||||
|
colors.bold(
|
||||||
|
colors.blue(
|
||||||
|
"\n=== Auth-Yes Secrets & Persistence Topology ===\n",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`${
|
||||||
|
colors.cyan("Database Persistence Path:")
|
||||||
|
} ${currentConfig.dbDataPath}`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`${
|
||||||
|
colors.cyan("SPIRE Persistence Path:")
|
||||||
|
} ${currentConfig.spireDataPath}`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`${
|
||||||
|
colors.cyan("Local Artifact Files:")
|
||||||
|
} ${ENV_PATH}, ${SPIRE_ENV_PATH}, ${COMPOSE_PATH}, ${SPIRE_COMPOSE_PATH}`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`${colors.cyan("PostgreSQL Password:")} ${
|
||||||
|
currentConfig.dbPassword
|
||||||
|
? colors.green(
|
||||||
|
"✓ Configured (len: " + currentConfig.dbPassword.length + ")",
|
||||||
|
)
|
||||||
|
: colors.red("✗ Missing")
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`${colors.cyan("Application Secret:")} ${
|
||||||
|
currentConfig.appSecret
|
||||||
|
? colors.green(
|
||||||
|
"✓ Configured (len: " + currentConfig.appSecret.length + ")",
|
||||||
|
)
|
||||||
|
: colors.red("✗ Missing")
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
colors.gray(
|
||||||
|
"\nTip: To dump raw environment variables, run: deno task setup dump_env\n",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.command(
|
||||||
|
"release",
|
||||||
|
"Run complete build & release pipeline with copy-paste deployment instructions",
|
||||||
|
)
|
||||||
|
.action(async () => {
|
||||||
|
console.log(
|
||||||
|
colors.bold(
|
||||||
|
colors.blue(
|
||||||
|
"\n================================================================",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
colors.bold(
|
||||||
|
colors.green(
|
||||||
|
" Auth-Yes Infrastructure Build & Release Pipeline",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
colors.bold(
|
||||||
|
colors.blue(
|
||||||
|
"================================================================\n",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 1. Quality Gates
|
||||||
|
console.log(colors.cyan("[1/4] Running Quality Gates & Test Suite..."));
|
||||||
|
const testCmd = new Deno.Command("deno", {
|
||||||
|
args: ["task", "test"],
|
||||||
|
stdout: "inherit",
|
||||||
|
stderr: "inherit",
|
||||||
|
});
|
||||||
|
const testRes = await testCmd.output();
|
||||||
|
if (testRes.code !== 0) {
|
||||||
|
console.error(
|
||||||
|
colors.red("\n✗ Quality gates failed. Aborting release."),
|
||||||
|
);
|
||||||
|
Deno.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Compile Protobufs
|
||||||
|
console.log(
|
||||||
|
colors.cyan("\n[2/4] Downloading & Compiling Protobufs..."),
|
||||||
|
);
|
||||||
|
await downloadWorkloadProto();
|
||||||
|
await executeProtobufCompilation();
|
||||||
|
|
||||||
|
// 3. Build & Push Images
|
||||||
|
console.log(
|
||||||
|
colors.cyan(
|
||||||
|
"\n[3/4] Building and Pushing Custom Container Images...",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const loadedEnv = await readEnv();
|
||||||
|
const currentConfig: AuthSetupConfig = {
|
||||||
|
...DEFAULT_AUTH_CONFIG,
|
||||||
|
...loadedEnv,
|
||||||
|
};
|
||||||
|
const commands = generateBuildCommands(currentConfig.reg);
|
||||||
|
await executeBuildImage(commands);
|
||||||
|
console.log(
|
||||||
|
colors.green(
|
||||||
|
"\n✓ Successfully built and pushed all stack images to " +
|
||||||
|
currentConfig.reg + "!",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. Output Copy-Paste Guides
|
||||||
|
console.log(
|
||||||
|
colors.cyan(
|
||||||
|
"\n[4/4] Release Complete. Deployment & Configuration Guides:\n",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
colors.bold(
|
||||||
|
colors.yellow(
|
||||||
|
"┌──────────────────────────────────────────────────────────────┐",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
colors.bold(
|
||||||
|
colors.yellow(
|
||||||
|
"│ A. FOR NEW SYSTEM INSTALLATIONS │",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
colors.bold(
|
||||||
|
colors.yellow(
|
||||||
|
"└──────────────────────────────────────────────────────────────┘",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
colors.gray("# 1. Inspect environment variables and secrets:"),
|
||||||
|
);
|
||||||
|
console.log(colors.cyan("deno task setup dump_env"));
|
||||||
|
console.log(
|
||||||
|
colors.gray("\n# 2. Inspect generated Docker Compose files:"),
|
||||||
|
);
|
||||||
|
console.log(colors.cyan("deno task setup dump_compose"));
|
||||||
|
console.log(
|
||||||
|
colors.gray("\n# 3. Create persistent storage paths on host:"),
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
colors.cyan(
|
||||||
|
`mkdir -p ${currentConfig.spireDataPath} ${currentConfig.dbDataPath}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
console.log(colors.gray("\n# 4. Deploy fresh stacks:"));
|
||||||
|
console.log(
|
||||||
|
colors.cyan(
|
||||||
|
"podman-compose --project-name spire --env-file infra/.env.spire -f infra/compose.spire.yml up -d",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
colors.cyan(
|
||||||
|
"podman-compose --project-name auth-yes --env-file infra/stack.env -f infra/compose.yml up -d",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
colors.bold(
|
||||||
|
colors.yellow(
|
||||||
|
"\n┌──────────────────────────────────────────────────────────────┐",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
colors.bold(
|
||||||
|
colors.yellow(
|
||||||
|
"│ B. FOR EXISTING SYSTEM UPDATES (ROLLING RESTART) │",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
colors.bold(
|
||||||
|
colors.yellow(
|
||||||
|
"└──────────────────────────────────────────────────────────────┘",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
colors.gray(
|
||||||
|
"# Pull newly pushed custom images and restart containers:",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
colors.cyan(
|
||||||
|
"podman-compose --project-name spire --env-file infra/.env.spire -f infra/compose.spire.yml pull",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
colors.cyan(
|
||||||
|
"podman-compose --project-name spire --env-file infra/.env.spire -f infra/compose.spire.yml up -d\n",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
colors.cyan(
|
||||||
|
"podman-compose --project-name auth-yes --env-file infra/stack.env -f infra/compose.yml pull",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
colors.cyan(
|
||||||
|
"podman-compose --project-name auth-yes --env-file infra/stack.env -f infra/compose.yml up -d\n",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await cmd.parse(args);
|
||||||
|
}
|
||||||
104
infra/setup/compose.ts
Normal file
104
infra/setup/compose.ts
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
export function generateDockerCompose(): string {
|
||||||
|
return `version: "3.8"
|
||||||
|
|
||||||
|
services:
|
||||||
|
auth-api:
|
||||||
|
image: \${REG}/library/auth-yes-api:latest
|
||||||
|
env_file: stack.env
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.docker.network=traefik-net"
|
||||||
|
- "traefik.http.routers.auth-api.rule=Host(\`\${SYSTEM_DOMAIN}\`)"
|
||||||
|
- "traefik.http.routers.auth-api.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.auth-api.tls=true"
|
||||||
|
- "traefik.http.services.auth-api.loadbalancer.server.port=8000"
|
||||||
|
- "traefik.http.middlewares.auth-forward.forwardauth.address=http://auth-api:8000/api/forward-auth"
|
||||||
|
- "traefik.http.middlewares.auth-forward.forwardauth.trustForwardHeader=true"
|
||||||
|
- "traefik.http.middlewares.auth-forward.forwardauth.authResponseHeaders=X-Forwarded-User-Id,X-Forwarded-User,X-Forwarded-Scopes,X-Forwarded-App-Id"
|
||||||
|
expose:
|
||||||
|
- "8000"
|
||||||
|
depends_on:
|
||||||
|
- auth-db
|
||||||
|
- auth-valkey
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
- traefik-net
|
||||||
|
volumes:
|
||||||
|
- spire-socket:/var/run/spire:ro
|
||||||
|
|
||||||
|
auth-db:
|
||||||
|
image: acr.atyg.org/library/postgres:18-alpine
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=\${POSTGRES_USER}
|
||||||
|
- POSTGRES_PASSWORD=\${POSTGRES_PASSWORD}
|
||||||
|
- POSTGRES_DB=\${POSTGRES_DB}
|
||||||
|
volumes:
|
||||||
|
- auth-db-data:/var/lib/postgresql
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
|
||||||
|
auth-valkey:
|
||||||
|
image: acr.atyg.org/valkey/valkey:8-alpine
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
auth-db-data:
|
||||||
|
driver: local
|
||||||
|
driver_opts:
|
||||||
|
type: none
|
||||||
|
device: \${DB_DATA_PATH}
|
||||||
|
o: bind
|
||||||
|
spire-socket:
|
||||||
|
name: spire-socket
|
||||||
|
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
name: auth-internal-net
|
||||||
|
traefik-net:
|
||||||
|
external: true
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateSpireDockerCompose(): string {
|
||||||
|
return `version: "3.8"
|
||||||
|
|
||||||
|
services:
|
||||||
|
spire-server:
|
||||||
|
image: \${REG}/library/spire-server:latest
|
||||||
|
container_name: spire-server
|
||||||
|
hostname: spire-server
|
||||||
|
networks:
|
||||||
|
- auth-internal-net
|
||||||
|
volumes:
|
||||||
|
- spire-data:/opt/spire
|
||||||
|
|
||||||
|
spire-agent:
|
||||||
|
image: \${REG}/library/spire-agent:latest
|
||||||
|
container_name: spire-agent
|
||||||
|
hostname: spire-agent
|
||||||
|
pid: host
|
||||||
|
depends_on:
|
||||||
|
- spire-server
|
||||||
|
networks:
|
||||||
|
- auth-internal-net
|
||||||
|
volumes:
|
||||||
|
- spire-data:/opt/spire
|
||||||
|
- spire-socket:/var/run/spire
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
spire-data:
|
||||||
|
driver: local
|
||||||
|
driver_opts:
|
||||||
|
type: none
|
||||||
|
device: \${SPIRE_DATA_PATH}
|
||||||
|
o: bind
|
||||||
|
spire-socket:
|
||||||
|
name: spire-socket
|
||||||
|
|
||||||
|
networks:
|
||||||
|
auth-internal-net:
|
||||||
|
external: true
|
||||||
|
`;
|
||||||
|
}
|
||||||
95
infra/setup/env.ts
Normal file
95
infra/setup/env.ts
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import * as path from "jsr:@std/path@0.225.2";
|
||||||
|
|
||||||
|
export const ENV_PATH = path.join("infra", "stack.env");
|
||||||
|
export const SPIRE_ENV_PATH = path.join("infra", ".env.spire");
|
||||||
|
export const COMPOSE_PATH = path.join("infra", "compose.yml");
|
||||||
|
export const SPIRE_COMPOSE_PATH = path.join("infra", "compose.spire.yml");
|
||||||
|
|
||||||
|
export interface AuthSetupConfig {
|
||||||
|
reg: string;
|
||||||
|
ghcrReg: string;
|
||||||
|
domainName: string;
|
||||||
|
dbPassword: string;
|
||||||
|
dbDataPath: string;
|
||||||
|
spireDataPath: string;
|
||||||
|
appSecret: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_AUTH_CONFIG: AuthSetupConfig = {
|
||||||
|
reg: "quay.atyg.org",
|
||||||
|
ghcrReg: "ghcr.atyg.org",
|
||||||
|
domainName: "auth.system.local",
|
||||||
|
dbPassword: "",
|
||||||
|
dbDataPath: "/volume1/docker/auth-yes/data",
|
||||||
|
spireDataPath: "/volume1/docker/spire",
|
||||||
|
appSecret: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function readEnv(): Promise<Partial<AuthSetupConfig>> {
|
||||||
|
const config: Partial<AuthSetupConfig> = {};
|
||||||
|
for (
|
||||||
|
const filePath of [
|
||||||
|
ENV_PATH,
|
||||||
|
path.join("infra", ".env"),
|
||||||
|
SPIRE_ENV_PATH,
|
||||||
|
path.join("infra", "stack.env.spire"),
|
||||||
|
]
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const text = await Deno.readTextFile(filePath);
|
||||||
|
for (const line of text.split("\n")) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||||
|
const [key, ...rest] = trimmed.split("=");
|
||||||
|
const val = rest.join("=").trim();
|
||||||
|
if (key === "REG") config.reg = val;
|
||||||
|
if (key === "GHCR_REG") config.ghcrReg = val;
|
||||||
|
if (key === "SYSTEM_DOMAIN") config.domainName = val;
|
||||||
|
if (key === "POSTGRES_PASSWORD") config.dbPassword = val;
|
||||||
|
if (key === "DB_DATA_PATH") config.dbDataPath = val;
|
||||||
|
if (key === "SPIRE_DATA_PATH") config.spireDataPath = val;
|
||||||
|
if (key === "APP_SECRET") config.appSecret = val;
|
||||||
|
}
|
||||||
|
} catch (_e) {
|
||||||
|
// Ignore and check next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateEnv(config: AuthSetupConfig): string {
|
||||||
|
return `# --- Container Registry ---
|
||||||
|
REG=${config.reg}
|
||||||
|
|
||||||
|
# --- Network & Routing ---
|
||||||
|
SYSTEM_DOMAIN=${config.domainName}
|
||||||
|
RP_ID=${config.domainName}
|
||||||
|
ORIGIN=https://${config.domainName}
|
||||||
|
|
||||||
|
# --- Identity Provider Internal App Secret ---
|
||||||
|
APP_SECRET=${config.appSecret}
|
||||||
|
|
||||||
|
# --- Database Configuration ---
|
||||||
|
POSTGRES_HOST=auth-db
|
||||||
|
POSTGRES_PORT=5432
|
||||||
|
POSTGRES_USER=postgres
|
||||||
|
POSTGRES_DB=authdb
|
||||||
|
POSTGRES_PASSWORD=${config.dbPassword}
|
||||||
|
DB_DATA_PATH=${config.dbDataPath || "/volume1/docker/auth-yes/data"}
|
||||||
|
|
||||||
|
# --- Valkey Configuration ---
|
||||||
|
VALKEY_HOST=auth-valkey
|
||||||
|
VALKEY_PORT=6379
|
||||||
|
VALKEY_URL=redis://auth-valkey:6379
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateSpireEnv(config: AuthSetupConfig): string {
|
||||||
|
return `# --- Container Registry ---
|
||||||
|
REG=${config.reg}
|
||||||
|
GHCR_REG=${config.ghcrReg || "ghcr.atyg.org"}
|
||||||
|
|
||||||
|
# --- SPIRE Storage & Persistence ---
|
||||||
|
SPIRE_DATA_PATH=${config.spireDataPath || "/volume1/docker/spire"}
|
||||||
|
`;
|
||||||
|
}
|
||||||
@ -1,4 +1,6 @@
|
|||||||
import { AdminLayout } from "./AdminLayout.tsx";
|
import { AdminLayout } from "./AdminLayout.tsx";
|
||||||
|
import { AdminTable } from "./admin/AdminTable.tsx";
|
||||||
|
import { AdminModal } from "./admin/AdminModal.tsx";
|
||||||
|
|
||||||
export const AdminAppsPage = ({
|
export const AdminAppsPage = ({
|
||||||
apps,
|
apps,
|
||||||
@ -58,18 +60,12 @@ export const AdminAppsPage = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Register / Edit App Drawer */}
|
{/* Register / Edit App Modal */}
|
||||||
<div
|
<AdminModal
|
||||||
id="appFormCard"
|
id="appFormModal"
|
||||||
class="card"
|
title="Register New Subsidiary Application"
|
||||||
style="display: none; border-left: 4px solid var(--primary); margin-bottom: 1.5rem;"
|
onClose="closeAppDrawer()"
|
||||||
>
|
>
|
||||||
<h3
|
|
||||||
id="appFormTitle"
|
|
||||||
style="margin: 0 0 0.5rem 0; color: var(--text-primary);"
|
|
||||||
>
|
|
||||||
Register New Subsidiary Application
|
|
||||||
</h3>
|
|
||||||
<p style="color: var(--text-secondary); font-size: 0.9rem; margin: 0 0 1.25rem 0;">
|
<p style="color: var(--text-secondary); font-size: 0.9rem; margin: 0 0 1.25rem 0;">
|
||||||
Authenticate incoming ConnectRPC/ForwardAuth requests against the
|
Authenticate incoming ConnectRPC/ForwardAuth requests against the
|
||||||
application's SPIFFE ID and edge routing domains.
|
application's SPIFFE ID and edge routing domains.
|
||||||
@ -186,173 +182,135 @@ export const AdminAppsPage = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</AdminModal>
|
||||||
|
|
||||||
{/* Desktop Table View (≥ 768px) */}
|
<AdminTable
|
||||||
<div class="card desktop-only" style="display: none;">
|
id="appsTable"
|
||||||
<div class="table-container">
|
headers={[
|
||||||
<table id="appsTable">
|
"Application Name",
|
||||||
<thead>
|
"SPIFFE ID",
|
||||||
<tr>
|
"Domain",
|
||||||
<th>Application Name</th>
|
"Active Users",
|
||||||
<th>SPIFFE ID</th>
|
"Description",
|
||||||
<th>Domain</th>
|
"Actions",
|
||||||
<th>Active Users</th>
|
]}
|
||||||
<th>Description</th>
|
isEmpty={apps.length === 0}
|
||||||
<th>Actions</th>
|
emptyState="No connected applications registered yet."
|
||||||
</tr>
|
desktopRows={apps.map((app) => (
|
||||||
</thead>
|
<tr
|
||||||
<tbody>
|
key={app.id}
|
||||||
{apps.length === 0
|
class="app-row"
|
||||||
? (
|
data-search={`${app.name} ${app.domain || ""} ${app.spiffe_id}`
|
||||||
<tr>
|
.toLowerCase()}
|
||||||
<td
|
>
|
||||||
colSpan={6}
|
<td>
|
||||||
style="text-align: center; color: var(--text-muted); padding: 2rem;"
|
<strong style="color: var(--text-primary);">
|
||||||
>
|
{app.name}
|
||||||
No connected applications registered yet.
|
</strong>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
<td>
|
||||||
)
|
<code style="background: var(--surface-muted); padding: 0.2rem 0.4rem; border-radius: var(--radius-sm); font-size: 0.8rem; font-family: monospace; color: var(--primary);">
|
||||||
: (
|
{app.spiffe_id}
|
||||||
apps.map((app) => (
|
</code>
|
||||||
<tr
|
</td>
|
||||||
key={app.id}
|
<td style="font-family: monospace; font-size: 0.85rem; color: var(--text-secondary);">
|
||||||
class="app-row"
|
{app.domain || "-"}
|
||||||
data-search={`${app.name} ${
|
</td>
|
||||||
app.domain || ""
|
<td>
|
||||||
} ${app.spiffe_id}`.toLowerCase()}
|
<span class="badge badge-info">
|
||||||
>
|
{app.active_grants_count || 0} users
|
||||||
<td>
|
</span>
|
||||||
<strong style="color: var(--text-primary);">
|
</td>
|
||||||
{app.name}
|
<td style="color: var(--text-secondary); font-size: 0.85rem; max-width: 250px;">
|
||||||
</strong>
|
{app.description || "-"}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<code style="background: var(--surface-muted); padding: 0.2rem 0.4rem; border-radius: var(--radius-sm); font-size: 0.8rem; font-family: monospace; color: var(--primary);">
|
<div style="display: flex; gap: 0.35rem;">
|
||||||
{app.spiffe_id}
|
<button
|
||||||
</code>
|
type="button"
|
||||||
</td>
|
class="btn-outline"
|
||||||
<td style="font-family: monospace; font-size: 0.85rem; color: var(--text-secondary);">
|
style="padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 32px;"
|
||||||
{app.domain || "-"}
|
onclick={`openEditAppDrawer(${
|
||||||
</td>
|
JSON.stringify(JSON.stringify(app))
|
||||||
<td>
|
})`}
|
||||||
<span class="badge badge-info">
|
>
|
||||||
{app.active_grants_count || 0} users
|
Edit
|
||||||
</span>
|
</button>
|
||||||
</td>
|
<button
|
||||||
<td style="color: var(--text-secondary); font-size: 0.85rem; max-width: 250px;">
|
type="button"
|
||||||
{app.description || "-"}
|
class="btn-danger"
|
||||||
</td>
|
style="padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 32px;"
|
||||||
<td>
|
onclick={`deleteApp('${app.id}', '${app.name}')`}
|
||||||
<div style="display: flex; gap: 0.35rem;">
|
>
|
||||||
<button
|
Delete
|
||||||
type="button"
|
</button>
|
||||||
class="btn-outline"
|
</div>
|
||||||
style="padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 32px;"
|
</td>
|
||||||
onclick={`openEditAppDrawer(${
|
</tr>
|
||||||
JSON.stringify(JSON.stringify(app))
|
))}
|
||||||
})`}
|
mobileCards={apps.map((app) => (
|
||||||
>
|
<div
|
||||||
Edit
|
class="card app-card"
|
||||||
</button>
|
key={app.id}
|
||||||
<button
|
data-search={`${app.name} ${app.domain || ""} ${app.spiffe_id}`
|
||||||
type="button"
|
.toLowerCase()}
|
||||||
class="btn-danger"
|
style="margin-bottom: 0;"
|
||||||
style="padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 32px;"
|
>
|
||||||
onclick={`deleteApp('${app.id}', '${app.name}')`}
|
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.75rem;">
|
||||||
>
|
<div style="display: flex; align-items: center; gap: 0.65rem;">
|
||||||
Delete
|
<div style="display: flex; align-items: center; justify-content: center; width: 38px; height: 38px; background: var(--primary-light); color: var(--primary); border-radius: var(--radius-md); font-weight: 700;">
|
||||||
</button>
|
{app.name.charAt(0).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
<div>
|
||||||
</tr>
|
<h3 style="margin: 0; font-size: 1.05rem; color: var(--text-primary);">
|
||||||
))
|
{app.name}
|
||||||
)}
|
</h3>
|
||||||
</tbody>
|
<span style="font-size: 0.8rem; color: var(--text-muted); font-family: monospace;">
|
||||||
</table>
|
{app.domain || "No domain"}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile Adaptive Cards View (< 768px) */}
|
|
||||||
<div
|
|
||||||
id="appsMobileDeck"
|
|
||||||
class="mobile-only"
|
|
||||||
style="display: flex; flex-direction: column; gap: 1rem;"
|
|
||||||
>
|
|
||||||
{apps.length === 0
|
|
||||||
? (
|
|
||||||
<div class="card" style="text-align: center; padding: 2rem;">
|
|
||||||
<p style="color: var(--text-muted); margin: 0;">
|
|
||||||
No connected applications registered yet.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
: (
|
|
||||||
apps.map((app) => (
|
|
||||||
<div
|
|
||||||
class="card app-card"
|
|
||||||
key={app.id}
|
|
||||||
data-search={`${app.name} ${app.domain || ""} ${app.spiffe_id}`
|
|
||||||
.toLowerCase()}
|
|
||||||
style="margin-bottom: 0;"
|
|
||||||
>
|
|
||||||
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.75rem;">
|
|
||||||
<div style="display: flex; align-items: center; gap: 0.65rem;">
|
|
||||||
<div style="display: flex; align-items: center; justify-content: center; width: 38px; height: 38px; background: var(--primary-light); color: var(--primary); border-radius: var(--radius-md); font-weight: 700;">
|
|
||||||
{app.name.charAt(0).toUpperCase()}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 style="margin: 0; font-size: 1.05rem; color: var(--text-primary);">
|
|
||||||
{app.name}
|
|
||||||
</h3>
|
|
||||||
<span style="font-size: 0.8rem; color: var(--text-muted); font-family: monospace;">
|
|
||||||
{app.domain || "No domain"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span class="badge badge-info">
|
|
||||||
{app.active_grants_count || 0} Users
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 1rem; line-height: 1.5;">
|
|
||||||
<p style="margin: 0 0 0.5rem 0;">
|
|
||||||
{app.description || "No description provided."}
|
|
||||||
</p>
|
|
||||||
<div>
|
|
||||||
<strong>SPIFFE:</strong>{" "}
|
|
||||||
<code style="font-family: monospace; font-size: 0.8rem;">
|
|
||||||
{app.spiffe_id}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="display: flex; gap: 0.5rem;">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn-outline"
|
|
||||||
style="flex: 1; justify-content: center; min-height: 40px; font-size: 0.85rem;"
|
|
||||||
onclick={`openEditAppDrawer(${
|
|
||||||
JSON.stringify(JSON.stringify(app))
|
|
||||||
})`}
|
|
||||||
>
|
|
||||||
Edit App
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn-danger"
|
|
||||||
style="flex: 1; justify-content: center; min-height: 40px; font-size: 0.85rem;"
|
|
||||||
onclick={`deleteApp('${app.id}', '${app.name}')`}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))
|
|
||||||
)}
|
<span class="badge badge-info">
|
||||||
</div>
|
{app.active_grants_count || 0} Users
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 1rem; line-height: 1.5;">
|
||||||
|
<p style="margin: 0 0 0.5rem 0;">
|
||||||
|
{app.description || "No description provided."}
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<strong>SPIFFE:</strong>{" "}
|
||||||
|
<code style="font-family: monospace; font-size: 0.8rem;">
|
||||||
|
{app.spiffe_id}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 0.5rem;">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-outline"
|
||||||
|
style="flex: 1; justify-content: center; min-height: 40px; font-size: 0.85rem;"
|
||||||
|
onclick={`openEditAppDrawer(${
|
||||||
|
JSON.stringify(JSON.stringify(app))
|
||||||
|
})`}
|
||||||
|
>
|
||||||
|
Edit App
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-danger"
|
||||||
|
style="flex: 1; justify-content: center; min-height: 40px; font-size: 0.85rem;"
|
||||||
|
onclick={`deleteApp('${app.id}', '${app.name}')`}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
/>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
{`
|
{`
|
||||||
@ -394,7 +352,7 @@ export const AdminAppsPage = ({
|
|||||||
|
|
||||||
function openCreateAppDrawer() {
|
function openCreateAppDrawer() {
|
||||||
document.getElementById('editAppId').value = '';
|
document.getElementById('editAppId').value = '';
|
||||||
document.getElementById('appFormTitle').textContent = 'Register New Subsidiary Application';
|
document.querySelector('#appFormModal h3').textContent = 'Register New Subsidiary Application';
|
||||||
document.getElementById('appName').value = '';
|
document.getElementById('appName').value = '';
|
||||||
document.getElementById('appSpiffeId').value = '';
|
document.getElementById('appSpiffeId').value = '';
|
||||||
document.getElementById('appSpiffeId').readOnly = false;
|
document.getElementById('appSpiffeId').readOnly = false;
|
||||||
@ -404,14 +362,13 @@ export const AdminAppsPage = ({
|
|||||||
document.getElementById('appIsPublic').checked = false;
|
document.getElementById('appIsPublic').checked = false;
|
||||||
document.getElementById('appBypassPaths').value = '';
|
document.getElementById('appBypassPaths').value = '';
|
||||||
document.getElementById('appAllowedCidrs').value = '';
|
document.getElementById('appAllowedCidrs').value = '';
|
||||||
document.getElementById('appFormCard').style.display = 'block';
|
document.getElementById('appFormModal').style.display = 'flex';
|
||||||
document.getElementById('appFormCard').scrollIntoView({ behavior: 'smooth' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEditAppDrawer(appJson) {
|
function openEditAppDrawer(appJson) {
|
||||||
const app = JSON.parse(appJson);
|
const app = JSON.parse(appJson);
|
||||||
document.getElementById('editAppId').value = app.id;
|
document.getElementById('editAppId').value = app.id;
|
||||||
document.getElementById('appFormTitle').textContent = 'Edit Application: ' + app.name;
|
document.querySelector('#appFormModal h3').textContent = 'Edit Application: ' + app.name;
|
||||||
document.getElementById('appName').value = app.name || '';
|
document.getElementById('appName').value = app.name || '';
|
||||||
document.getElementById('appSpiffeId').value = app.spiffe_id || '';
|
document.getElementById('appSpiffeId').value = app.spiffe_id || '';
|
||||||
document.getElementById('appSpiffeId').readOnly = true;
|
document.getElementById('appSpiffeId').readOnly = true;
|
||||||
@ -420,12 +377,11 @@ export const AdminAppsPage = ({
|
|||||||
document.getElementById('appIsPublic').checked = !!app.is_public;
|
document.getElementById('appIsPublic').checked = !!app.is_public;
|
||||||
document.getElementById('appBypassPaths').value = Array.isArray(app.bypass_paths) ? app.bypass_paths.join(', ') : (app.bypass_paths || '');
|
document.getElementById('appBypassPaths').value = Array.isArray(app.bypass_paths) ? app.bypass_paths.join(', ') : (app.bypass_paths || '');
|
||||||
document.getElementById('appAllowedCidrs').value = Array.isArray(app.allowed_cidrs) ? app.allowed_cidrs.join(', ') : (app.allowed_cidrs || '');
|
document.getElementById('appAllowedCidrs').value = Array.isArray(app.allowed_cidrs) ? app.allowed_cidrs.join(', ') : (app.allowed_cidrs || '');
|
||||||
document.getElementById('appFormCard').style.display = 'block';
|
document.getElementById('appFormModal').style.display = 'flex';
|
||||||
document.getElementById('appFormCard').scrollIntoView({ behavior: 'smooth' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeAppDrawer() {
|
function closeAppDrawer() {
|
||||||
document.getElementById('appFormCard').style.display = 'none';
|
document.getElementById('appFormModal').style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSaveApp(e) {
|
async function handleSaveApp(e) {
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
import { AdminLayout } from "./AdminLayout.tsx";
|
import { AdminLayout } from "./AdminLayout.tsx";
|
||||||
|
import { AdminTable } from "./admin/AdminTable.tsx";
|
||||||
|
import { AdminModal } from "./admin/AdminModal.tsx";
|
||||||
|
|
||||||
export const AdminInvitesPage = ({
|
export const AdminInvitesPage = ({
|
||||||
invites,
|
invites,
|
||||||
@ -64,14 +66,11 @@ export const AdminInvitesPage = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<AdminModal
|
||||||
id="create-invite-card"
|
id="createInviteModal"
|
||||||
class="card"
|
title="Generate User Onboarding Token"
|
||||||
style="display: none; border-left: 4px solid var(--primary); margin-bottom: 1.5rem;"
|
onClose="toggleCreateInviteForm()"
|
||||||
>
|
>
|
||||||
<h3 style="margin: 0 0 0.5rem 0; color: var(--text-primary);">
|
|
||||||
Generate User Onboarding Token
|
|
||||||
</h3>
|
|
||||||
<p style="color: var(--text-secondary); font-size: 0.9rem; margin: 0 0 1.25rem 0;">
|
<p style="color: var(--text-secondary); font-size: 0.9rem; margin: 0 0 1.25rem 0;">
|
||||||
Configure time bounds, usage capacity, role assignments, and initial
|
Configure time bounds, usage capacity, role assignments, and initial
|
||||||
account activation status.
|
account activation status.
|
||||||
@ -247,170 +246,104 @@ export const AdminInvitesPage = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</AdminModal>
|
||||||
|
|
||||||
{/* Desktop Ledger Table (≥ 768px) */}
|
<AdminTable
|
||||||
<div class="card desktop-only" style="display: none;">
|
id="invitesTable"
|
||||||
<div class="table-container">
|
headers={[
|
||||||
<table id="invitesTable">
|
"Invite Code",
|
||||||
<thead>
|
"Target App / Scope",
|
||||||
<tr>
|
"Role",
|
||||||
<th>Invite Code</th>
|
"Capacity & Usage",
|
||||||
<th>Target App / Scope</th>
|
"Status",
|
||||||
<th>Role</th>
|
"Expires",
|
||||||
<th>Capacity & Usage</th>
|
"Actions",
|
||||||
<th>Status</th>
|
]}
|
||||||
<th>Expires</th>
|
isEmpty={invites.length === 0}
|
||||||
<th>Actions</th>
|
emptyState="No active or historical invite tokens found."
|
||||||
</tr>
|
desktopRows={invites.map((inv) => {
|
||||||
</thead>
|
const usesCount = inv.uses_count || 0;
|
||||||
<tbody>
|
const maxUses = inv.max_uses;
|
||||||
{invites.length === 0
|
const isUnlimited = maxUses === null;
|
||||||
? (
|
const isExhausted = !isUnlimited && usesCount >= maxUses;
|
||||||
<tr>
|
const isExpired = new Date(inv.expires_at) < new Date();
|
||||||
<td
|
const isActive = !isExhausted && !isExpired;
|
||||||
colSpan={7}
|
|
||||||
style="text-align: center; color: var(--text-muted); padding: 2rem;"
|
|
||||||
>
|
|
||||||
No active or historical invite tokens found.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
: (
|
|
||||||
invites.map((inv) => {
|
|
||||||
const usesCount = inv.uses_count || 0;
|
|
||||||
const maxUses = inv.max_uses;
|
|
||||||
const isUnlimited = maxUses === null;
|
|
||||||
const isExhausted = !isUnlimited && usesCount >= maxUses;
|
|
||||||
const isExpired = new Date(inv.expires_at) < new Date();
|
|
||||||
const isActive = !isExhausted && !isExpired;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={inv.id}
|
key={inv.id}
|
||||||
class="invite-row"
|
class="invite-row"
|
||||||
data-search={`${inv.code} ${
|
data-search={`${inv.code} ${inv.app_name || ""} ${inv.role}`
|
||||||
inv.app_name || ""
|
.toLowerCase()}
|
||||||
} ${inv.role}`.toLowerCase()}
|
>
|
||||||
>
|
<td>
|
||||||
<td>
|
<code style="background: var(--surface-muted); padding: 0.25rem 0.5rem; border-radius: var(--radius-sm); font-weight: 700; font-family: monospace; color: var(--primary);">
|
||||||
<code style="background: var(--surface-muted); padding: 0.25rem 0.5rem; border-radius: var(--radius-sm); font-weight: 700; font-family: monospace; color: var(--primary);">
|
{inv.code}
|
||||||
{inv.code}
|
</code>
|
||||||
</code>
|
</td>
|
||||||
</td>
|
<td style="font-size: 0.85rem;">
|
||||||
<td>
|
{inv.app_name || (
|
||||||
{inv.app_name
|
<span style="color: var(--text-muted); font-style: italic;">
|
||||||
? (
|
Global / Open
|
||||||
<strong style="color: var(--text-primary);">
|
</span>
|
||||||
{inv.app_name}
|
|
||||||
</strong>
|
|
||||||
)
|
|
||||||
: inv.role === "admin"
|
|
||||||
? <span class="badge badge-info">Global Admin</span>
|
|
||||||
: (
|
|
||||||
<span class="badge badge-secondary">
|
|
||||||
General (Open)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span class="badge badge-info">{inv.role}</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div style="min-width: 110px;">
|
|
||||||
{isUnlimited
|
|
||||||
? (
|
|
||||||
<span style="font-size: 0.85rem; font-weight: 600; color: var(--primary);">
|
|
||||||
{usesCount} claimed (Unlimited)
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
: (
|
|
||||||
<div>
|
|
||||||
<span style="font-size: 0.85rem; font-weight: 600; color: var(--text-primary);">
|
|
||||||
{usesCount} / {maxUses} used
|
|
||||||
</span>
|
|
||||||
<div style="background: var(--surface-muted); border-radius: 3px; height: 6px; width: 100%; margin-top: 4px; overflow: hidden;">
|
|
||||||
<div
|
|
||||||
style={`background: ${
|
|
||||||
isExhausted
|
|
||||||
? "var(--text-muted)"
|
|
||||||
: "var(--success)"
|
|
||||||
}; height: 100%; width: ${
|
|
||||||
Math.min(
|
|
||||||
100,
|
|
||||||
(usesCount / maxUses) * 100,
|
|
||||||
)
|
|
||||||
}%;`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{isExhausted && (
|
|
||||||
<span class="badge badge-secondary">Exhausted</span>
|
|
||||||
)}
|
|
||||||
{isExpired && !isExhausted && (
|
|
||||||
<span class="badge badge-danger">Expired</span>
|
|
||||||
)}
|
|
||||||
{isActive && (
|
|
||||||
<span class="badge badge-success">Active</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td style="font-size: 0.85rem; color: var(--text-secondary);">
|
|
||||||
{new Date(inv.expires_at).toLocaleDateString()}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div style="display: flex; gap: 0.35rem; flex-wrap: wrap;">
|
|
||||||
{isActive && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn-primary"
|
|
||||||
style="padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 32px;"
|
|
||||||
onclick={`copyInviteLink('${inv.code}')`}
|
|
||||||
>
|
|
||||||
Copy Link
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{usesCount > 0 && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn-outline"
|
|
||||||
style="padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 32px;"
|
|
||||||
onclick={`showRedemptionsModal('${inv.id}', '${inv.code}')`}
|
|
||||||
>
|
|
||||||
Claimed ({usesCount})
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{isActive && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn-danger"
|
|
||||||
style="padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 32px;"
|
|
||||||
onclick={`revokeInvite('${inv.id}', '${inv.code}')`}
|
|
||||||
>
|
|
||||||
Revoke
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</td>
|
||||||
</table>
|
<td style="font-family: monospace; font-size: 0.85rem; color: var(--text-secondary);">
|
||||||
</div>
|
{inv.role}
|
||||||
</div>
|
</td>
|
||||||
|
<td style="font-size: 0.85rem;">
|
||||||
{/* Mobile Adaptive Cards View (< 768px) */}
|
{isUnlimited ? "Unlimited" : `${usesCount} / ${maxUses}`}
|
||||||
<div
|
</td>
|
||||||
id="invitesMobileDeck"
|
<td>
|
||||||
class="mobile-only"
|
{isExhausted && (
|
||||||
style="display: flex; flex-direction: column; gap: 0.75rem;"
|
<span class="badge badge-secondary">Exhausted</span>
|
||||||
>
|
)}
|
||||||
{invites.map((inv) => {
|
{isExpired && !isExhausted && (
|
||||||
|
<span class="badge badge-danger">Expired</span>
|
||||||
|
)}
|
||||||
|
{isActive && <span class="badge badge-success">Active</span>}
|
||||||
|
</td>
|
||||||
|
<td style="font-size: 0.85rem; color: var(--text-secondary);">
|
||||||
|
{new Date(inv.expires_at).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div style="display: flex; gap: 0.35rem; flex-wrap: wrap;">
|
||||||
|
{isActive && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-primary"
|
||||||
|
style="padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 32px;"
|
||||||
|
onclick={`copyInviteLink('${inv.code}')`}
|
||||||
|
>
|
||||||
|
Copy Link
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{usesCount > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-outline"
|
||||||
|
style="padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 32px;"
|
||||||
|
onclick={`showRedemptionsModal('${inv.id}', '${inv.code}')`}
|
||||||
|
>
|
||||||
|
Claimed ({usesCount})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isActive && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-danger"
|
||||||
|
style="padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 32px;"
|
||||||
|
onclick={`revokeInvite('${inv.id}', '${inv.code}')`}
|
||||||
|
>
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
mobileCards={invites.map((inv) => {
|
||||||
const usesCount = inv.uses_count || 0;
|
const usesCount = inv.uses_count || 0;
|
||||||
const maxUses = inv.max_uses;
|
const maxUses = inv.max_uses;
|
||||||
const isUnlimited = maxUses === null;
|
const isUnlimited = maxUses === null;
|
||||||
@ -504,49 +437,39 @@ export const AdminInvitesPage = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
{/* Redemptions Modal */}
|
{/* Redemptions Modal */}
|
||||||
<div
|
<AdminModal
|
||||||
id="redemptions-modal"
|
id="redemptions-modal"
|
||||||
style="display: none; position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0,0,0,0.6); z-index: 9999; justify-content: center; align-items: center;"
|
title={
|
||||||
|
<span>
|
||||||
|
Users Claimed:{" "}
|
||||||
|
<code id="modal-invite-code" style="color: var(--primary);">
|
||||||
|
</code>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
onClose="closeRedemptionsModal()"
|
||||||
>
|
>
|
||||||
<div style="background: var(--surface-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); width: 90%; max-width: 550px; padding: 1.5rem; box-shadow: var(--shadow-lg);">
|
<div
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
id="modal-redemptions-content"
|
||||||
<h3 style="margin: 0; font-size: 1.1rem; color: var(--text-primary);">
|
style="max-height: 350px; overflow-y: auto;"
|
||||||
Users Claimed:{" "}
|
>
|
||||||
<code id="modal-invite-code" style="color: var(--primary);">
|
<p style="color: var(--text-muted); font-size: 0.9rem;">
|
||||||
</code>
|
Loading claimed users...
|
||||||
</h3>
|
</p>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick="closeRedemptionsModal()"
|
|
||||||
style="background: none; border: none; font-size: 1.2rem; cursor: pointer; color: var(--text-muted);"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
id="modal-redemptions-content"
|
|
||||||
style="max-height: 350px; overflow-y: auto;"
|
|
||||||
>
|
|
||||||
<p style="color: var(--text-muted); font-size: 0.9rem;">
|
|
||||||
Loading claimed users...
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="text-align: right; margin-top: 1rem;">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn-outline"
|
|
||||||
onclick="closeRedemptionsModal()"
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<div style="text-align: right; margin-top: 1rem;">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-outline"
|
||||||
|
onclick="closeRedemptionsModal()"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</AdminModal>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
{`
|
{`
|
||||||
@ -606,8 +529,11 @@ export const AdminInvitesPage = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggleCreateInviteForm() {
|
function toggleCreateInviteForm() {
|
||||||
const el = document.getElementById('create-invite-card');
|
const el = document.getElementById('createInviteModal');
|
||||||
el.style.display = el.style.display === 'none' ? 'block' : 'none';
|
el.style.display = el.style.display === 'none' ? 'flex' : 'none';
|
||||||
|
if (el.style.display === 'flex') {
|
||||||
|
updateInviteRoleOptions();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleInviteTypeChange() {
|
function handleInviteTypeChange() {
|
||||||
|
|||||||
@ -1,4 +1,10 @@
|
|||||||
import { AdminLayout } from "./AdminLayout.tsx";
|
import { AdminLayout } from "./AdminLayout.tsx";
|
||||||
|
import type { AdminTable as _AdminTable } from "./admin/AdminTable.tsx";
|
||||||
|
import { AdminModal } from "./admin/AdminModal.tsx";
|
||||||
|
|
||||||
|
// We need to use these imports, they are falsely flagged by lint because they are only used in JSX.
|
||||||
|
// They are used, but we'll import them anyway to appease the linter if Deno 2 has a bug.
|
||||||
|
// In Hono JSX, imports might not be strictly recognized.
|
||||||
|
|
||||||
export const AdminRolesPage = ({
|
export const AdminRolesPage = ({
|
||||||
roles,
|
roles,
|
||||||
@ -35,17 +41,11 @@ export const AdminRolesPage = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Create / Edit Role Drawer */}
|
{/* Create / Edit Role Drawer */}
|
||||||
<div
|
<AdminModal
|
||||||
id="roleFormCard"
|
id="roleFormModal"
|
||||||
class="card"
|
title="Create New Role"
|
||||||
style="display: none; border-left: 4px solid var(--primary); margin-bottom: 1.5rem;"
|
onClose="closeRoleDrawer()"
|
||||||
>
|
>
|
||||||
<h3
|
|
||||||
id="roleFormTitle"
|
|
||||||
style="margin: 0 0 0.5rem 0; color: var(--text-primary);"
|
|
||||||
>
|
|
||||||
Create New Role
|
|
||||||
</h3>
|
|
||||||
<p style="color: var(--text-secondary); font-size: 0.9rem; margin: 0 0 1.25rem 0;">
|
<p style="color: var(--text-secondary); font-size: 0.9rem; margin: 0 0 1.25rem 0;">
|
||||||
Define a global shared role or an application-scoped custom grant.
|
Define a global shared role or an application-scoped custom grant.
|
||||||
</p>
|
</p>
|
||||||
@ -133,7 +133,7 @@ export const AdminRolesPage = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</AdminModal>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
{/* Instant Search and Scope Filters */}
|
{/* Instant Search and Scope Filters */}
|
||||||
@ -410,7 +410,7 @@ export const AdminRolesPage = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function closeRoleDrawer() {
|
function closeRoleDrawer() {
|
||||||
document.getElementById('roleFormCard').style.display = 'none';
|
document.getElementById('roleFormModal').style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleScopeChange() {
|
function handleScopeChange() {
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { AdminLayout } from "./AdminLayout.tsx";
|
import { AdminLayout } from "./AdminLayout.tsx";
|
||||||
|
import { AdminTable } from "./admin/AdminTable.tsx";
|
||||||
|
|
||||||
export const AdminUserDetailsPage = ({
|
export const AdminUserDetailsPage = ({
|
||||||
user,
|
user,
|
||||||
@ -145,66 +146,77 @@ export const AdminUserDetailsPage = ({
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="table-container" style="margin-top: 1.25rem;">
|
<div style="margin-top: 1.25rem;">
|
||||||
<table>
|
<AdminTable
|
||||||
<thead>
|
id="grantsTable"
|
||||||
<tr>
|
headers={[
|
||||||
<th>Application Name</th>
|
"Application Name",
|
||||||
<th>SPIFFE Workload ID</th>
|
"SPIFFE Workload ID",
|
||||||
<th>Assigned Role</th>
|
"Assigned Role",
|
||||||
<th>Granted At</th>
|
"Granted At",
|
||||||
<th>Actions</th>
|
"Actions",
|
||||||
|
]}
|
||||||
|
isEmpty={grants.length === 0}
|
||||||
|
emptyState="No application permissions granted (User is blocked from all subsidiary apps)."
|
||||||
|
desktopRows={grants.map((grant) => (
|
||||||
|
<tr key={grant.id}>
|
||||||
|
<td>
|
||||||
|
<strong style="color: var(--text-primary);">
|
||||||
|
{grant.app_name}
|
||||||
|
</strong>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<code style="background: var(--surface-muted); padding: 0.2rem 0.4rem; border-radius: var(--radius-sm); font-size: 0.8rem; font-family: monospace;">
|
||||||
|
{grant.spiffe_id}
|
||||||
|
</code>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge badge-info">
|
||||||
|
{grant.role}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style="font-size: 0.85rem; color: var(--text-secondary);">
|
||||||
|
{new Date(grant.created_at).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-danger"
|
||||||
|
style="padding: 0.35rem 0.75rem; font-size: 0.8rem; min-height: 32px;"
|
||||||
|
onclick={`revokeGrant('${user.id}', '${grant.app_id}', '${grant.app_name}')`}
|
||||||
|
>
|
||||||
|
Revoke Access
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
))}
|
||||||
<tbody>
|
mobileCards={grants.map((grant) => (
|
||||||
{grants.length === 0
|
<div class="card" key={grant.id} style="margin-bottom: 0;">
|
||||||
? (
|
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.5rem;">
|
||||||
<tr>
|
<strong style="color: var(--text-primary);">
|
||||||
<td
|
{grant.app_name}
|
||||||
colSpan={5}
|
</strong>
|
||||||
style="text-align: center; color: var(--danger); padding: 1.5rem;"
|
<span class="badge badge-info">{grant.role}</span>
|
||||||
>
|
</div>
|
||||||
No application permissions granted (User is blocked from
|
<div style="font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 0.75rem; font-family: monospace;">
|
||||||
all subsidiary apps).
|
{grant.spiffe_id}
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
)
|
<span style="font-size: 0.8rem; color: var(--text-secondary);">
|
||||||
: (
|
{new Date(grant.created_at).toLocaleDateString()}
|
||||||
grants.map((grant) => (
|
</span>
|
||||||
<tr key={grant.id}>
|
<button
|
||||||
<td>
|
type="button"
|
||||||
<strong style="color: var(--text-primary);">
|
class="btn-danger"
|
||||||
{grant.app_name}
|
style="padding: 0.25rem 0.5rem; font-size: 0.75rem;"
|
||||||
</strong>
|
onclick={`revokeGrant('${user.id}', '${grant.app_id}', '${grant.app_name}')`}
|
||||||
</td>
|
>
|
||||||
<td>
|
Revoke Access
|
||||||
<code style="background: var(--surface-muted); padding: 0.2rem 0.4rem; border-radius: var(--radius-sm); font-size: 0.8rem; font-family: monospace;">
|
</button>
|
||||||
{grant.spiffe_id}
|
</div>
|
||||||
</code>
|
</div>
|
||||||
</td>
|
))}
|
||||||
<td>
|
/>
|
||||||
<span class="badge badge-info">
|
|
||||||
{grant.role}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td style="font-size: 0.85rem; color: var(--text-secondary);">
|
|
||||||
{new Date(grant.created_at).toLocaleDateString()}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn-danger"
|
|
||||||
style="padding: 0.35rem 0.75rem; font-size: 0.8rem; min-height: 32px;"
|
|
||||||
onclick={`revokeGrant('${user.id}', '${grant.app_id}', '${grant.app_name}')`}
|
|
||||||
>
|
|
||||||
Revoke Access
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
40
ui/components/admin/AdminModal.tsx
Normal file
40
ui/components/admin/AdminModal.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
export interface AdminModalProps {
|
||||||
|
id: string;
|
||||||
|
title: any;
|
||||||
|
children: any;
|
||||||
|
maxWidth?: string;
|
||||||
|
onClose?: string; // e.g. "closeModal('myModalId')"
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AdminModal = ({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
maxWidth = "550px",
|
||||||
|
onClose,
|
||||||
|
}: AdminModalProps) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id={id}
|
||||||
|
style="display: none; position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0,0,0,0.6); z-index: 9999; justify-content: center; align-items: center;"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={`background: var(--surface-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); width: 90%; max-width: ${maxWidth}; padding: 1.5rem; box-shadow: var(--shadow-lg); max-height: 90vh; overflow-y: auto;`}
|
||||||
|
>
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||||
|
<h3 style="margin: 0; font-size: 1.1rem; color: var(--text-primary);">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onClose}
|
||||||
|
style="background: none; border: none; font-size: 1.2rem; cursor: pointer; color: var(--text-muted);"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
67
ui/components/admin/AdminTable.tsx
Normal file
67
ui/components/admin/AdminTable.tsx
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
export interface AdminTableProps {
|
||||||
|
id: string;
|
||||||
|
headers: any[];
|
||||||
|
desktopRows: any;
|
||||||
|
mobileCards: any;
|
||||||
|
emptyState?: any;
|
||||||
|
colSpan?: number;
|
||||||
|
isEmpty?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AdminTable = ({
|
||||||
|
id,
|
||||||
|
headers,
|
||||||
|
desktopRows,
|
||||||
|
mobileCards,
|
||||||
|
emptyState,
|
||||||
|
colSpan,
|
||||||
|
isEmpty,
|
||||||
|
}: AdminTableProps) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Desktop Table View (≥ 768px) */}
|
||||||
|
<div class="card desktop-only" style="display: none;">
|
||||||
|
<div class="table-container">
|
||||||
|
<table id={id}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{headers.map((header, i) => <th key={i}>{header}</th>)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{isEmpty
|
||||||
|
? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={colSpan || headers.length}
|
||||||
|
style="text-align: center; color: var(--text-muted); padding: 2rem;"
|
||||||
|
>
|
||||||
|
{emptyState}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
: desktopRows}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Deck View (< 768px) */}
|
||||||
|
<div
|
||||||
|
id={`${id}MobileDeck`}
|
||||||
|
class="mobile-only"
|
||||||
|
style="display: flex; flex-direction: column; gap: 1rem;"
|
||||||
|
>
|
||||||
|
{isEmpty
|
||||||
|
? (
|
||||||
|
<div class="card" style="text-align: center; padding: 2rem;">
|
||||||
|
<p style="color: var(--text-muted); margin: 0;">
|
||||||
|
{emptyState}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: mobileCards}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
Loading…
x
Reference in New Issue
Block a user