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:
109
apps/api-server/src/procedures/me/api-tokens.ts
Normal file
109
apps/api-server/src/procedures/me/api-tokens.ts
Normal 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 };
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
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",
|
||||
// Save credentials
|
||||
await writeConfig({
|
||||
apiUrl,
|
||||
token: flags.token,
|
||||
email: authStatus.user.email,
|
||||
});
|
||||
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>`,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -10,11 +10,34 @@ 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> => {
|
||||
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;
|
||||
}
|
||||
|
||||
// Otherwise, read from config asynchronously
|
||||
return (async (): Promise<ApiClient> => {
|
||||
const config = await readConfig();
|
||||
if (!config) {
|
||||
throw new Error(
|
||||
@@ -29,6 +52,6 @@ export const createApiClient = async (): Promise<ApiClient> => {
|
||||
},
|
||||
});
|
||||
|
||||
// Cast to ApiClient for type-safe API calls
|
||||
return createORPCClient(link) as unknown as ApiClient;
|
||||
};
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<script lang="ts">
|
||||
import ClockIcon from "@lucide/svelte/icons/clock";
|
||||
import KeyRoundIcon from "@lucide/svelte/icons/key-round";
|
||||
import MonitorIcon from "@lucide/svelte/icons/monitor";
|
||||
import ShieldCheckIcon from "@lucide/svelte/icons/shield-check";
|
||||
import UserIcon from "@lucide/svelte/icons/user";
|
||||
import { createQuery } from "@tanstack/svelte-query";
|
||||
import { page } from "$app/stores";
|
||||
import { api } from "$lib/api/client";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
interface Props {
|
||||
@@ -12,13 +15,33 @@ interface 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/auth", label: "Authentication", icon: ShieldCheckIcon },
|
||||
{ href: "/account/devices", label: "Devices", icon: MonitorIcon },
|
||||
{ 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 {
|
||||
if (href === "/account") {
|
||||
return pathname === "/account";
|
||||
|
||||
@@ -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 <token></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}
|
||||
@@ -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({
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user