Add API token management for CLI authentication

- Add reviq auth login --token <token> command for CLI authentication
- Create /account/api-tokens page for token management (superuser only)
- Add me.apiTokens endpoints (list, create, delete)
- Require superuser status and trusted session for token creation
- Show API Tokens nav link only for superusers

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
igm
2026-01-10 18:58:27 +08:00
parent 42badf3c52
commit a7d6beaf5a
8 changed files with 566 additions and 130 deletions

View File

@@ -1,17 +1,22 @@
import type { LocalContext } from "../../context.js";
import { buildCommand } from "@stricli/core";
import { readConfig } from "../../utils/config.js";
import { generateToken, hashToken } from "../../utils/token.js";
import { createApiClient } from "../../utils/api-client.js";
import { readConfig, writeConfig } from "../../utils/config.js";
interface LoginFlags {
email: string;
token: string;
"api-url"?: string;
}
interface LoginStatusOutput {
status: "pending" | "completed" | "expired";
}
/**
* Login to RevIQ with an API token
*
* To get an API token:
* 1. Log in to the web dashboard
* 2. Go to Account Settings > API Tokens
* 3. Create a new token and copy it
* 4. Run: reviq auth login --token <your-token>
*/
async function login(this: LocalContext, flags: LoginFlags): Promise<void> {
const apiUrl = flags["api-url"] ?? "http://localhost:9861";
@@ -23,117 +28,31 @@ async function login(this: LocalContext, flags: LoginFlags): Promise<void> {
return;
}
console.log("Starting login flow...\n");
// Generate a unique callback token for this login request
const callbackToken = generateToken();
const callbackTokenHash = hashToken(callbackToken);
console.log("Validating API token...\n");
try {
// Create login request
const createResponse = await fetch(
`${apiUrl}/api/v1/rpc/auth.createLoginRequest`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: flags.email }),
},
);
// Create a temporary API client with the provided token
const api = createApiClient(apiUrl, flags.token);
if (!createResponse.ok) {
const text = await createResponse.text();
console.error(`Error creating login request: ${text}`);
this.process.exit(1);
}
// Validate the token by fetching the user's auth status
const authStatus = await api.me.authStatus();
// Construct the login URL
const loginUrl = new URL(`${apiUrl}/login`);
loginUrl.searchParams.set("email", flags.email);
loginUrl.searchParams.set("cli_callback", callbackTokenHash);
// Save credentials
await writeConfig({
apiUrl,
token: flags.token,
email: authStatus.user.email,
});
console.log("Opening browser for authentication...");
console.log(`\nIf the browser doesn't open, visit:`);
console.log(` ${loginUrl.toString()}\n`);
// Try to open the browser
const openCommand =
process.platform === "darwin"
? "open"
: process.platform === "win32"
? "start"
: "xdg-open";
try {
const proc = Bun.spawn([openCommand, loginUrl.toString()], {
stdout: "ignore",
stderr: "ignore",
});
await proc.exited;
} catch {
// Ignore errors opening browser - user can use the URL
}
console.log("Waiting for login to complete...");
console.log("(Press Ctrl+C to cancel)\n");
// Poll for completion
const maxAttempts = 120; // 2 minutes at 1 second intervals
let attempts = 0;
while (attempts < maxAttempts) {
await new Promise((resolve) => setTimeout(resolve, 1000));
attempts++;
try {
const statusResponse = await fetch(
`${apiUrl}/api/v1/rpc/auth.loginIfRequestIsCompleted`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CLI-Callback-Token": callbackToken,
},
},
);
if (statusResponse.ok) {
const status = (await statusResponse.json()) as LoginStatusOutput;
if (status.status === "completed") {
// Login completed - we should have received a token
// For now, we'll need the API to return the token
console.log("Login completed successfully!");
// TODO: The API needs to return the session token when login completes
// For now, this is a placeholder
console.log(
"\nNote: Browser-based login flow requires API integration.",
);
console.log("Use 'reviq bootstrap' to create initial credentials.");
return;
}
if (status.status === "expired") {
console.error("Login request expired. Please try again.");
this.process.exit(1);
}
}
} catch {
// Ignore polling errors and continue
}
// Show progress indicator
process.stdout.write(".");
}
console.log("\n\nLogin timed out. Please try again.");
this.process.exit(1);
console.log(`Logged in as ${authStatus.user.email}`);
console.log("Credentials saved to ~/.config/reviq/credentials.json");
} catch (error) {
console.error(
"Error:",
"Login failed:",
error instanceof Error ? error.message : String(error),
);
console.log("\nMake sure your API token is valid.");
console.log("You can create a new token at: /account/api-tokens");
this.process.exit(1);
}
}
@@ -142,10 +61,10 @@ export const loginCommand = buildCommand({
func: login,
parameters: {
flags: {
email: {
token: {
kind: "parsed",
parse: String,
brief: "Email address to login with",
brief: "API token from the web dashboard",
},
"api-url": {
kind: "parsed",
@@ -156,8 +75,13 @@ export const loginCommand = buildCommand({
},
},
docs: {
brief: "Login to RevIQ",
fullDescription:
"Opens a browser to complete authentication and stores the credentials locally.",
brief: "Login to RevIQ with an API token",
fullDescription: `Authenticates with RevIQ using an API token.
To get an API token:
1. Log in to the web dashboard at http://localhost:9861
2. Go to Account Settings > API Tokens
3. Create a new token and copy it
4. Run: reviq auth login --token <your-token>`,
},
});

View File

@@ -10,25 +10,48 @@ import { readConfig } from "./config.js";
export type ApiClient = ContractRouterClient<typeof contract>;
/**
* Create an oRPC API client with provided credentials
*/
export function createApiClient(apiUrl: string, token: string): ApiClient;
/**
* Create an oRPC API client with the stored credentials
* Throws an error if not logged in
*/
export const createApiClient = async (): Promise<ApiClient> => {
const config = await readConfig();
if (!config) {
throw new Error(
"Not logged in. Run 'reviq bootstrap' or 'reviq auth login' first.",
);
export function createApiClient(): Promise<ApiClient>;
export function createApiClient(
apiUrl?: string,
token?: string,
): ApiClient | Promise<ApiClient> {
// If both arguments are provided, create client directly
if (apiUrl !== undefined && token !== undefined) {
const link = new RPCLink({
url: `${apiUrl}/api/v1/rpc`,
headers: {
"X-API-Key": token,
},
});
return createORPCClient(link) as unknown as ApiClient;
}
const link = new RPCLink({
url: `${config.apiUrl}/api/v1/rpc`,
headers: {
"X-API-Key": config.token,
},
});
// Otherwise, read from config asynchronously
return (async (): Promise<ApiClient> => {
const config = await readConfig();
if (!config) {
throw new Error(
"Not logged in. Run 'reviq bootstrap' or 'reviq auth login' first.",
);
}
// Cast to ApiClient for type-safe API calls
return createORPCClient(link) as unknown as ApiClient;
};
const link = new RPCLink({
url: `${config.apiUrl}/api/v1/rpc`,
headers: {
"X-API-Key": config.token,
},
});
return createORPCClient(link) as unknown as ApiClient;
})();
}