Compare commits

...

2 Commits

Author SHA1 Message Date
igm
8f3a1f2962 Merge origin/master into reviq-auth-login-command
Resolved conflicts:
- apps/api-server/src/router.ts: Use meRoutes from master
- packages/api-contract/src/contract.ts: Keep master's nested sessions/devices/invites structure, add apiTokens

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 19:03:37 +08:00
igm
a7d6beaf5a 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>
2026-01-10 18:58:27 +08:00
8 changed files with 562 additions and 130 deletions

View File

@@ -2,6 +2,7 @@
* Me routes - consolidated exports for os.router() * Me routes - consolidated exports for os.router()
*/ */
import { createApiToken, deleteApiToken, listApiTokens } from "./api-tokens.js";
import { meAuthStatus } from "./auth-status.js"; import { meAuthStatus } from "./auth-status.js";
import { meDelete } from "./delete.js"; import { meDelete } from "./delete.js";
import { import {
@@ -54,4 +55,9 @@ export const meRoutes = {
untrust: untrustDevice, untrust: untrustDevice,
revokeAll: revokeAllTrustedDevices, revokeAll: revokeAllTrustedDevices,
}, },
apiTokens: {
list: listApiTokens,
create: createApiToken,
delete: deleteApiToken,
},
}; };

View File

@@ -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 };
});

View File

@@ -1,17 +1,22 @@
import type { LocalContext } from "../../context.js"; import type { LocalContext } from "../../context.js";
import { buildCommand } from "@stricli/core"; import { buildCommand } from "@stricli/core";
import { readConfig } from "../../utils/config.js"; import { createApiClient } from "../../utils/api-client.js";
import { generateToken, hashToken } from "../../utils/token.js"; import { readConfig, writeConfig } from "../../utils/config.js";
interface LoginFlags { interface LoginFlags {
email: string; token: string;
"api-url"?: 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> { async function login(this: LocalContext, flags: LoginFlags): Promise<void> {
const apiUrl = flags["api-url"] ?? "http://localhost:9861"; const apiUrl = flags["api-url"] ?? "http://localhost:9861";
@@ -23,117 +28,31 @@ async function login(this: LocalContext, flags: LoginFlags): Promise<void> {
return; return;
} }
console.log("Starting login flow...\n"); console.log("Validating API token...\n");
// Generate a unique callback token for this login request
const callbackToken = generateToken();
const callbackTokenHash = hashToken(callbackToken);
try { try {
// Create login request // Create a temporary API client with the provided token
const createResponse = await fetch( const api = createApiClient(apiUrl, flags.token);
`${apiUrl}/api/v1/rpc/auth.createLoginRequest`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: flags.email }),
},
);
if (!createResponse.ok) { // Validate the token by fetching the user's auth status
const text = await createResponse.text(); const authStatus = await api.me.authStatus();
console.error(`Error creating login request: ${text}`);
this.process.exit(1);
}
// Construct the login URL // Save credentials
const loginUrl = new URL(`${apiUrl}/login`); await writeConfig({
loginUrl.searchParams.set("email", flags.email); apiUrl,
loginUrl.searchParams.set("cli_callback", callbackTokenHash); token: flags.token,
email: authStatus.user.email,
});
console.log("Opening browser for authentication..."); console.log(`Logged in as ${authStatus.user.email}`);
console.log(`\nIf the browser doesn't open, visit:`); console.log("Credentials saved to ~/.config/reviq/credentials.json");
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);
} catch (error) { } catch (error) {
console.error( console.error(
"Error:", "Login failed:",
error instanceof Error ? error.message : String(error), 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); this.process.exit(1);
} }
} }
@@ -142,10 +61,10 @@ export const loginCommand = buildCommand({
func: login, func: login,
parameters: { parameters: {
flags: { flags: {
email: { token: {
kind: "parsed", kind: "parsed",
parse: String, parse: String,
brief: "Email address to login with", brief: "API token from the web dashboard",
}, },
"api-url": { "api-url": {
kind: "parsed", kind: "parsed",
@@ -156,8 +75,13 @@ export const loginCommand = buildCommand({
}, },
}, },
docs: { docs: {
brief: "Login to RevIQ", brief: "Login to RevIQ with an API token",
fullDescription: fullDescription: `Authenticates with RevIQ using an API token.
"Opens a browser to complete authentication and stores the credentials locally.",
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>; 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 * Create an oRPC API client with the stored credentials
* Throws an error if not logged in * Throws an error if not logged in
*/ */
export const createApiClient = async (): Promise<ApiClient> => { export function createApiClient(): Promise<ApiClient>;
const config = await readConfig();
if (!config) { export function createApiClient(
throw new Error( apiUrl?: string,
"Not logged in. Run 'reviq bootstrap' or 'reviq auth login' first.", 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({ // Otherwise, read from config asynchronously
url: `${config.apiUrl}/api/v1/rpc`, return (async (): Promise<ApiClient> => {
headers: { const config = await readConfig();
"X-API-Key": config.token, if (!config) {
}, throw new Error(
}); "Not logged in. Run 'reviq bootstrap' or 'reviq auth login' first.",
);
}
// Cast to ApiClient for type-safe API calls const link = new RPCLink({
return createORPCClient(link) as unknown as ApiClient; url: `${config.apiUrl}/api/v1/rpc`,
}; headers: {
"X-API-Key": config.token,
},
});
return createORPCClient(link) as unknown as ApiClient;
})();
}

View File

@@ -1,9 +1,12 @@
<script lang="ts"> <script lang="ts">
import ClockIcon from "@lucide/svelte/icons/clock"; import ClockIcon from "@lucide/svelte/icons/clock";
import KeyRoundIcon from "@lucide/svelte/icons/key-round";
import MonitorIcon from "@lucide/svelte/icons/monitor"; import MonitorIcon from "@lucide/svelte/icons/monitor";
import ShieldCheckIcon from "@lucide/svelte/icons/shield-check"; import ShieldCheckIcon from "@lucide/svelte/icons/shield-check";
import UserIcon from "@lucide/svelte/icons/user"; import UserIcon from "@lucide/svelte/icons/user";
import { createQuery } from "@tanstack/svelte-query";
import { page } from "$app/stores"; import { page } from "$app/stores";
import { api } from "$lib/api/client";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
interface Props { interface Props {
@@ -12,13 +15,33 @@ interface Props {
let { class: className }: Props = $props(); let { class: className }: Props = $props();
const navItems = [ // Fetch current user to check superuser status
const userQuery = createQuery(() => ({
queryKey: ["me"],
queryFn: () => api.me.get(),
}));
const baseNavItems = [
{ href: "/account", label: "Profile", icon: UserIcon }, { href: "/account", label: "Profile", icon: UserIcon },
{ href: "/account/auth", label: "Authentication", icon: ShieldCheckIcon }, { href: "/account/auth", label: "Authentication", icon: ShieldCheckIcon },
{ href: "/account/devices", label: "Devices", icon: MonitorIcon }, { href: "/account/devices", label: "Devices", icon: MonitorIcon },
{ href: "/account/sessions", label: "Sessions", icon: ClockIcon }, { href: "/account/sessions", label: "Sessions", icon: ClockIcon },
]; ];
// Add API Tokens link for superusers only
const navItems = $derived(
userQuery.data?.isSuperuser
? [
...baseNavItems,
{
href: "/account/api-tokens",
label: "API Tokens",
icon: KeyRoundIcon,
},
]
: baseNavItems,
);
function isActive(href: string, pathname: string): boolean { function isActive(href: string, pathname: string): boolean {
if (href === "/account") { if (href === "/account") {
return pathname === "/account"; return pathname === "/account";

View File

@@ -0,0 +1,305 @@
<script lang="ts">
import {
AlertCircle,
Check,
Copy,
KeyRound,
Loader2,
Plus,
Trash2,
} from "@lucide/svelte";
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner";
import { goto } from "$app/navigation";
import { api } from "$lib/api/client";
import { ConfirmDialog } from "$lib/components/account";
import { Alert, AlertDescription } from "$lib/components/ui/alert";
import { Badge } from "$lib/components/ui/badge";
import { Button } from "$lib/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "$lib/components/ui/card";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
const queryClient = useQueryClient();
// Fetch current user to check superuser status
const userQuery = createQuery(() => ({
queryKey: ["me"],
queryFn: () => api.me.get(),
}));
// Redirect non-superusers
$effect(() => {
if (userQuery.data && !userQuery.data.isSuperuser) {
toast.error("Access denied. Superuser privileges required.");
goto("/account");
}
});
const tokensQuery = createQuery(() => ({
queryKey: ["api-tokens"],
queryFn: () => api.me.apiTokens.list(),
enabled: userQuery.data?.isSuperuser ?? false,
}));
let confirmDialogOpen = $state(false);
let selectedTokenId = $state<number | null>(null);
let isDeleting = $state(false);
// Create token form state
let newTokenName = $state("");
let isCreating = $state(false);
let newlyCreatedToken = $state<string | null>(null);
let tokenCopied = $state(false);
function formatDate(date: Date | string): string {
return new Date(date).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
});
}
function formatRelativeTime(date: Date | string): string {
const diffDays = Math.floor(
(Date.now() - new Date(date).getTime()) / 86400000,
);
if (diffDays === 0) {
return "Today";
}
if (diffDays === 1) {
return "Yesterday";
}
if (diffDays < 7) {
return `${diffDays} days ago`;
}
if (diffDays < 30) {
return `${Math.floor(diffDays / 7)} weeks ago`;
}
return formatDate(date);
}
async function handleCreateToken(e: Event) {
e.preventDefault();
if (!newTokenName.trim() || isCreating) {
return;
}
isCreating = true;
try {
const result = await api.me.apiTokens.create({ name: newTokenName.trim() });
newlyCreatedToken = result.token;
newTokenName = "";
await queryClient.invalidateQueries({ queryKey: ["api-tokens"] });
toast.success("API token created");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to create token");
} finally {
isCreating = false;
}
}
async function copyToken() {
if (!newlyCreatedToken) {
return;
}
try {
await navigator.clipboard.writeText(newlyCreatedToken);
tokenCopied = true;
toast.success("Token copied to clipboard");
setTimeout(() => {
tokenCopied = false;
}, 2000);
} catch {
toast.error("Failed to copy token");
}
}
function dismissNewToken() {
newlyCreatedToken = null;
tokenCopied = false;
}
async function handleDelete() {
if (!selectedTokenId || isDeleting) {
return;
}
isDeleting = true;
try {
await api.me.apiTokens.delete({ tokenId: selectedTokenId });
await queryClient.invalidateQueries({ queryKey: ["api-tokens"] });
toast.success("API token deleted");
confirmDialogOpen = false;
selectedTokenId = null;
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to delete token");
} finally {
isDeleting = false;
}
}
</script>
{#if userQuery.isPending}
<div class="flex items-center justify-center py-12">
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
</div>
{:else if !userQuery.data?.isSuperuser}
<Alert variant="destructive">
<AlertCircle class="h-4 w-4" />
<AlertDescription>Access denied. Superuser privileges required.</AlertDescription>
</Alert>
{:else}
<div class="space-y-6">
<!-- Newly Created Token Banner -->
{#if newlyCreatedToken}
<Alert class="border-green-500 bg-green-50 dark:bg-green-950">
<KeyRound class="h-4 w-4 text-green-600" />
<AlertDescription>
<div class="space-y-2">
<p class="font-medium text-green-800 dark:text-green-200">
Your new API token has been created!
</p>
<p class="text-sm text-green-700 dark:text-green-300">
Copy it now - you won't be able to see it again.
</p>
<div class="flex items-center gap-2">
<code class="flex-1 rounded bg-green-100 px-2 py-1 font-mono text-sm text-green-900 dark:bg-green-900 dark:text-green-100">
{newlyCreatedToken}
</code>
<Button
size="sm"
variant="outline"
onclick={copyToken}
class="shrink-0"
>
{#if tokenCopied}
<Check class="h-4 w-4" />
{:else}
<Copy class="h-4 w-4" />
{/if}
</Button>
</div>
<Button
size="sm"
variant="ghost"
onclick={dismissNewToken}
class="mt-2"
>
I've copied my token
</Button>
</div>
</AlertDescription>
</Alert>
{/if}
<!-- Create Token -->
<Card>
<CardHeader>
<CardTitle>Create API Token</CardTitle>
<CardDescription>
Create a new API token for CLI or programmatic access.
</CardDescription>
</CardHeader>
<CardContent>
<form onsubmit={handleCreateToken} class="flex gap-3">
<div class="flex-1">
<Label for="token-name" class="sr-only">Token name</Label>
<Input
id="token-name"
type="text"
placeholder="Token name (e.g., CLI, CI/CD)"
bind:value={newTokenName}
disabled={isCreating}
/>
</div>
<Button type="submit" disabled={!newTokenName.trim() || isCreating}>
{#if isCreating}
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
{:else}
<Plus class="mr-2 h-4 w-4" />
{/if}
Create Token
</Button>
</form>
</CardContent>
</Card>
<!-- Existing Tokens -->
<Card>
<CardHeader>
<CardTitle>API Tokens</CardTitle>
<CardDescription>
Manage your API tokens. Use these with the CLI: <code class="rounded bg-muted px-1 py-0.5 text-xs">reviq auth login --token &lt;token&gt;</code>
</CardDescription>
</CardHeader>
<CardContent>
{#if tokensQuery.isPending}
<div class="flex items-center justify-center py-8">
<Loader2 class="h-6 w-6 animate-spin text-muted-foreground" />
</div>
{:else if tokensQuery.error}
<Alert variant="destructive">
<AlertCircle class="h-4 w-4" />
<AlertDescription>Failed to load tokens. Please try again.</AlertDescription>
</Alert>
{:else if tokensQuery.data && tokensQuery.data.length > 0}
<div class="divide-y">
{#each tokensQuery.data as token (token.id)}
<div class="flex items-center justify-between py-3 first:pt-0 last:pb-0">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
<KeyRound class="h-5 w-5 text-muted-foreground" />
</div>
<div>
<p class="text-sm font-medium">{token.name}</p>
<p class="text-xs text-muted-foreground">
Created {formatRelativeTime(token.createdAt)}
{#if token.lastUsedAt}
· Last used {formatRelativeTime(token.lastUsedAt)}
{:else}
· Never used
{/if}
</p>
<Badge variant="outline" class="text-xs">
Expires {formatDate(token.expiresAt)}
</Badge>
</div>
</div>
<Button
variant="outline"
size="sm"
onclick={() => { selectedTokenId = token.id; confirmDialogOpen = true; }}
>
<Trash2 class="h-4 w-4" />
</Button>
</div>
{/each}
</div>
{:else}
<div class="flex flex-col items-center justify-center py-8 text-center">
<KeyRound class="mb-2 h-8 w-8 text-muted-foreground/50" />
<p class="text-sm text-muted-foreground">No API tokens yet.</p>
<p class="text-xs text-muted-foreground">Create one to use with the CLI.</p>
</div>
{/if}
</CardContent>
</Card>
</div>
<ConfirmDialog
bind:open={confirmDialogOpen}
title="Delete this API token?"
description="This will immediately revoke access for any applications using this token. This action cannot be undone."
confirmText="Delete token"
variant="destructive"
loading={isDeleting}
onConfirm={handleDelete}
/>
{/if}

View File

@@ -13,6 +13,9 @@ import {
adminUpdateUserInputSchema, adminUpdateUserInputSchema,
} from "./schemas/admin.js"; } from "./schemas/admin.js";
import { import {
apiTokenOutputSchema,
createApiTokenInputSchema,
createApiTokenOutputSchema,
forgotPasswordInputSchema, forgotPasswordInputSchema,
loginPasswordInputSchema, loginPasswordInputSchema,
loginRequestInputSchema, loginRequestInputSchema,
@@ -181,6 +184,17 @@ export const contract = oc.router({
.output(successResponseSchema), .output(successResponseSchema),
revokeAll: oc.output(successResponseSchema), revokeAll: 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({ orgs: oc.router({

View File

@@ -81,3 +81,31 @@ export const resetPasswordInputSchema = z.object({
token: z.string(), token: z.string(),
newPassword: z.string().min(8), 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(),
});