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,
|
loginRequestMiddleware,
|
||||||
os,
|
os,
|
||||||
} from "./procedures/base.js";
|
} from "./procedures/base.js";
|
||||||
|
import {
|
||||||
|
createApiToken,
|
||||||
|
deleteApiToken,
|
||||||
|
listApiTokens,
|
||||||
|
} from "./procedures/me/api-tokens.js";
|
||||||
import { meDelete } from "./procedures/me/delete.js";
|
import { meDelete } from "./procedures/me/delete.js";
|
||||||
import {
|
import {
|
||||||
getDeviceInfo,
|
getDeviceInfo,
|
||||||
@@ -303,6 +308,11 @@ export const router = os.router({
|
|||||||
listTrustedDevices,
|
listTrustedDevices,
|
||||||
untrustDevice,
|
untrustDevice,
|
||||||
revokeAllTrustedDevices,
|
revokeAllTrustedDevices,
|
||||||
|
apiTokens: {
|
||||||
|
list: listApiTokens,
|
||||||
|
create: createApiToken,
|
||||||
|
delete: deleteApiToken,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
orgs: {
|
orgs: {
|
||||||
list: orgsList,
|
list: orgsList,
|
||||||
|
|||||||
@@ -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(`\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(`Logged in as ${authStatus.user.email}`);
|
||||||
console.log("(Press Ctrl+C to cancel)\n");
|
console.log("Credentials saved to ~/.config/reviq/credentials.json");
|
||||||
|
|
||||||
// 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>`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,11 +10,34 @@ 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>;
|
||||||
|
|
||||||
|
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();
|
const config = await readConfig();
|
||||||
if (!config) {
|
if (!config) {
|
||||||
throw new Error(
|
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;
|
return createORPCClient(link) as unknown as ApiClient;
|
||||||
};
|
})();
|
||||||
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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,
|
adminUpdateUserInputSchema,
|
||||||
} from "./schemas/admin.js";
|
} from "./schemas/admin.js";
|
||||||
import {
|
import {
|
||||||
|
apiTokenOutputSchema,
|
||||||
|
createApiTokenInputSchema,
|
||||||
|
createApiTokenOutputSchema,
|
||||||
forgotPasswordInputSchema,
|
forgotPasswordInputSchema,
|
||||||
loginPasswordInputSchema,
|
loginPasswordInputSchema,
|
||||||
loginRequestInputSchema,
|
loginRequestInputSchema,
|
||||||
@@ -160,6 +163,17 @@ export const contract = oc.router({
|
|||||||
.input(z.object({ deviceId: z.number() }))
|
.input(z.object({ deviceId: z.number() }))
|
||||||
.output(successResponseSchema),
|
.output(successResponseSchema),
|
||||||
revokeAllTrustedDevices: oc.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({
|
orgs: oc.router({
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user