diff --git a/apps/api-server/src/procedures/me/api-tokens.ts b/apps/api-server/src/procedures/me/api-tokens.ts new file mode 100644 index 0000000..92ba464 --- /dev/null +++ b/apps/api-server/src/procedures/me/api-tokens.ts @@ -0,0 +1,109 @@ +/** + * API token management procedures + * Allows users to create and manage API tokens for CLI/programmatic access + */ + +import { ORPCError } from "@orpc/server"; +import { + generateSecureBase58Token, + hashToken, + TOKEN_PREFIX, +} from "../../utils/crypto.js"; +import { authMiddleware, os } from "../base.js"; + +/** Token expiration: 365 days */ +const TOKEN_EXPIRATION_DAYS = 365; + +/** + * List all API tokens for the current user + * Returns token metadata (not the actual token values) + */ +export const listApiTokens = os.me.apiTokens.list + .use(authMiddleware) + .handler(async ({ context }) => { + const tokens = await context.db + .selectFrom("api_tokens") + .select(["id", "name", "last_used_at", "created_at", "expires_at"]) + .where("user_id", "=", context.user.id) + .orderBy("created_at", "desc") + .execute(); + + return tokens.map((token) => ({ + id: Number(token.id), + name: token.name, + lastUsedAt: token.last_used_at?.toISOString() ?? null, + createdAt: token.created_at.toISOString(), + expiresAt: token.expires_at.toISOString(), + })); + }); + +/** + * Create a new API token + * Requires superuser status and trusted session + */ +export const createApiToken = os.me.apiTokens.create + .use(authMiddleware) + .handler(async ({ input, context }) => { + // Require superuser status + if (!context.user.isSuperuser) { + throw new ORPCError("FORBIDDEN", { + message: "Only superusers can create API tokens.", + }); + } + + // Require trusted session for creating API tokens + if (!context.session.trustedMode) { + throw new ORPCError("FORBIDDEN", { + message: + "Creating API tokens requires a trusted session. Please re-authenticate.", + }); + } + + const { name } = input; + + // Generate a new API token + const token = generateSecureBase58Token(TOKEN_PREFIX); + const tokenHash = await hashToken(token); + + // Calculate expiration + const expiresAt = new Date( + Date.now() + TOKEN_EXPIRATION_DAYS * 24 * 60 * 60 * 1000, + ); + + // Insert into api_tokens table + await context.db + .insertInto("api_tokens") + .values({ + user_id: context.user.id, + token_hash: tokenHash, + name, + expires_at: expiresAt, + }) + .execute(); + + return { + token, + expiresAt: expiresAt.toISOString(), + }; + }); + +/** + * Delete an API token + */ +export const deleteApiToken = os.me.apiTokens.delete + .use(authMiddleware) + .handler(async ({ input, context }) => { + const result = await context.db + .deleteFrom("api_tokens") + .where("id", "=", String(input.tokenId)) + .where("user_id", "=", context.user.id) + .executeTakeFirst(); + + if (result.numDeletedRows === 0n) { + throw new ORPCError("NOT_FOUND", { + message: "API token not found", + }); + } + + return { success: true }; + }); diff --git a/apps/api-server/src/router.ts b/apps/api-server/src/router.ts index e4de764..d040c3f 100644 --- a/apps/api-server/src/router.ts +++ b/apps/api-server/src/router.ts @@ -15,6 +15,11 @@ import { loginRequestMiddleware, os, } from "./procedures/base.js"; +import { + createApiToken, + deleteApiToken, + listApiTokens, +} from "./procedures/me/api-tokens.js"; import { meDelete } from "./procedures/me/delete.js"; import { getDeviceInfo, @@ -303,6 +308,11 @@ export const router = os.router({ listTrustedDevices, untrustDevice, revokeAllTrustedDevices, + apiTokens: { + list: listApiTokens, + create: createApiToken, + delete: deleteApiToken, + }, }, orgs: { list: orgsList, diff --git a/apps/cli/src/routes/auth/login.ts b/apps/cli/src/routes/auth/login.ts index 0cbf447..78f9df1 100644 --- a/apps/cli/src/routes/auth/login.ts +++ b/apps/cli/src/routes/auth/login.ts @@ -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 + */ async function login(this: LocalContext, flags: LoginFlags): Promise { const apiUrl = flags["api-url"] ?? "http://localhost:9861"; @@ -23,117 +28,31 @@ async function login(this: LocalContext, flags: LoginFlags): Promise { 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 `, }, }); diff --git a/apps/cli/src/utils/api-client.ts b/apps/cli/src/utils/api-client.ts index 78f8fd8..00cec6c 100644 --- a/apps/cli/src/utils/api-client.ts +++ b/apps/cli/src/utils/api-client.ts @@ -10,25 +10,48 @@ import { readConfig } from "./config.js"; export type ApiClient = ContractRouterClient; +/** + * 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 => { - const config = await readConfig(); - if (!config) { - throw new Error( - "Not logged in. Run 'reviq bootstrap' or 'reviq auth login' first.", - ); +export function createApiClient(): Promise; + +export function createApiClient( + apiUrl?: string, + token?: string, +): ApiClient | Promise { + // 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 => { + 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; + })(); +} diff --git a/apps/publisher-dashboard/src/lib/components/account/account-nav.svelte b/apps/publisher-dashboard/src/lib/components/account/account-nav.svelte index 9d8bfb7..cbef801 100644 --- a/apps/publisher-dashboard/src/lib/components/account/account-nav.svelte +++ b/apps/publisher-dashboard/src/lib/components/account/account-nav.svelte @@ -1,9 +1,12 @@ + +{#if userQuery.isPending} +
+ +
+{:else if !userQuery.data?.isSuperuser} + + + Access denied. Superuser privileges required. + +{:else} +
+ + {#if newlyCreatedToken} + + + +
+

+ Your new API token has been created! +

+

+ Copy it now - you won't be able to see it again. +

+
+ + {newlyCreatedToken} + + +
+ +
+
+
+ {/if} + + + + + Create API Token + + Create a new API token for CLI or programmatic access. + + + +
+
+ + +
+ +
+
+
+ + + + + API Tokens + + Manage your API tokens. Use these with the CLI: reviq auth login --token <token> + + + + {#if tokensQuery.isPending} +
+ +
+ {:else if tokensQuery.error} + + + Failed to load tokens. Please try again. + + {:else if tokensQuery.data && tokensQuery.data.length > 0} +
+ {#each tokensQuery.data as token (token.id)} +
+
+
+ +
+
+

{token.name}

+

+ Created {formatRelativeTime(token.createdAt)} + {#if token.lastUsedAt} + · Last used {formatRelativeTime(token.lastUsedAt)} + {:else} + · Never used + {/if} +

+ + Expires {formatDate(token.expiresAt)} + +
+
+ +
+ {/each} +
+ {:else} +
+ +

No API tokens yet.

+

Create one to use with the CLI.

+
+ {/if} +
+
+
+ + +{/if} diff --git a/packages/api-contract/src/contract.ts b/packages/api-contract/src/contract.ts index 59ed053..9c57a0c 100644 --- a/packages/api-contract/src/contract.ts +++ b/packages/api-contract/src/contract.ts @@ -13,6 +13,9 @@ import { adminUpdateUserInputSchema, } from "./schemas/admin.js"; import { + apiTokenOutputSchema, + createApiTokenInputSchema, + createApiTokenOutputSchema, forgotPasswordInputSchema, loginPasswordInputSchema, loginRequestInputSchema, @@ -160,6 +163,17 @@ export const contract = oc.router({ .input(z.object({ deviceId: z.number() })) .output(successResponseSchema), revokeAllTrustedDevices: oc.output(successResponseSchema), + + // API tokens for CLI/programmatic access + apiTokens: oc.router({ + list: oc.output(z.array(apiTokenOutputSchema)), + create: oc + .input(createApiTokenInputSchema) + .output(createApiTokenOutputSchema), + delete: oc + .input(z.object({ tokenId: z.number() })) + .output(successResponseSchema), + }), }), orgs: oc.router({ diff --git a/packages/api-contract/src/schemas/auth.ts b/packages/api-contract/src/schemas/auth.ts index c7139ca..b29f42b 100644 --- a/packages/api-contract/src/schemas/auth.ts +++ b/packages/api-contract/src/schemas/auth.ts @@ -81,3 +81,31 @@ export const resetPasswordInputSchema = z.object({ token: z.string(), newPassword: z.string().min(8), }); + +/** + * API token creation input schema + * Creates an API token for CLI/programmatic access + */ +export const createApiTokenInputSchema = z.object({ + name: z.string().min(1).max(100), +}); + +/** + * API token creation output schema + * Returns the token (only shown once) + */ +export const createApiTokenOutputSchema = z.object({ + token: z.string(), + expiresAt: z.string(), +}); + +/** + * API token output schema for listing tokens + */ +export const apiTokenOutputSchema = z.object({ + id: z.number(), + name: z.string(), + lastUsedAt: z.string().nullable(), + createdAt: z.string(), + expiresAt: z.string(), +});