diff --git a/apps/api-server/src/procedures/admin/auth/complete-login.ts b/apps/api-server/src/procedures/admin/auth/complete-login.ts index d49a1bd..74e49d7 100644 --- a/apps/api-server/src/procedures/admin/auth/complete-login.ts +++ b/apps/api-server/src/procedures/admin/auth/complete-login.ts @@ -9,24 +9,41 @@ export const adminAuthCompleteLogin = os.admin.auth.completeLogin .use(authMiddleware) .use(superuserMiddleware) .handler(async ({ input, context }) => { - const loginRequest = await context.db + const email = input.email.toLowerCase(); + + // First check if any login request exists for this email + const anyRequest = await context.db .selectFrom("login_requests") - .where("email", "=", input.email.toLowerCase()) - .where("completed_at", "is", null) - .where("expires_at", ">", new Date()) + .where("email", "=", email) .orderBy("created_at", "desc") - .select(["id"]) + .select(["id", "completed_at", "expires_at"]) .executeTakeFirst(); - if (!loginRequest) { + if (!anyRequest) { throw new ORPCError("NOT_FOUND", { - message: "No pending login request found", + message: `No login request found for ${email}`, }); } + // Check if already completed + if (anyRequest.completed_at) { + throw new ORPCError("BAD_REQUEST", { + message: "Login request already completed", + }); + } + + // Check if expired + if (new Date(anyRequest.expires_at) < new Date()) { + throw new ORPCError("BAD_REQUEST", { + message: + "Login request expired (15 min limit). Start a new login flow.", + }); + } + + // Complete the login request await context.db .updateTable("login_requests") .set({ completed_at: new Date() }) - .where("id", "=", loginRequest.id) + .where("id", "=", anyRequest.id) .execute(); }); diff --git a/apps/api-server/src/procedures/auth/create-login-request.ts b/apps/api-server/src/procedures/auth/create-login-request.ts index 4cc8237..0cc0935 100644 --- a/apps/api-server/src/procedures/auth/create-login-request.ts +++ b/apps/api-server/src/procedures/auth/create-login-request.ts @@ -11,9 +11,9 @@ import { setCookie, } from "../../utils/cookies.js"; import { - generateBase58Token, generateDeviceFingerprint, generateExpiry, + generateSecureBase58Token, } from "../../utils/crypto.js"; import { getGeoInfo, getUserAgent } from "../../utils/geo.js"; import { isDeviceTrusted } from "../../utils/session.js"; @@ -62,7 +62,7 @@ export const createLoginRequest = os.auth.createLoginRequest.handler( if (!user) { // Generate placeholder token (base58) for anti-enumeration // This prevents attackers from knowing if an email exists based on response - const placeholderToken = generateBase58Token(); + const placeholderToken = generateSecureBase58Token("login_"); // Set placeholder login request token cookie setCookie( @@ -107,7 +107,7 @@ export const createLoginRequest = os.auth.createLoginRequest.handler( // Create login request with secure token const expiresAt = generateExpiry(COOKIE_DURATIONS.LOGIN_REQUEST); - const token = generateBase58Token(); + const token = generateSecureBase58Token("login_"); await context.db .insertInto("login_requests") diff --git a/apps/api-server/src/procedures/auth/forgot-password.ts b/apps/api-server/src/procedures/auth/forgot-password.ts index 9f71fc8..55a676b 100644 --- a/apps/api-server/src/procedures/auth/forgot-password.ts +++ b/apps/api-server/src/procedures/auth/forgot-password.ts @@ -7,7 +7,10 @@ */ import { TOKEN_DURATIONS } from "../../utils/cookies.js"; -import { generateExpiry, generateSecureToken } from "../../utils/crypto.js"; +import { + generateExpiry, + generateSecureBase58Token, +} from "../../utils/crypto.js"; import { sendPasswordResetEmail } from "../../utils/email.js"; import { os } from "../base.js"; @@ -33,8 +36,8 @@ export const forgotPassword = os.auth.forgotPassword.handler( .where("user_id", "=", user.id) .execute(); - // Generate secure token (64 hex chars) - const token = generateSecureToken(); + // Generate secure base58 token + const token = generateSecureBase58Token(); // Create password reset record with 1 hour expiry const expiresAt = generateExpiry(TOKEN_DURATIONS.PASSWORD_RESET); diff --git a/apps/api-server/src/procedures/auth/login-password.ts b/apps/api-server/src/procedures/auth/login-password.ts index dcd2296..456fd97 100644 --- a/apps/api-server/src/procedures/auth/login-password.ts +++ b/apps/api-server/src/procedures/auth/login-password.ts @@ -5,7 +5,6 @@ import { ORPCError } from "@orpc/server"; import { COOKIE_NAMES, getCookie } from "../../utils/cookies.js"; -import { generateSecureToken } from "../../utils/crypto.js"; import { sendLoginConfirmationEmail } from "../../utils/email.js"; import { verifyPassword } from "../../utils/password.js"; import { isDeviceTrusted } from "../../utils/session.js"; @@ -47,6 +46,7 @@ export const loginPassword = os.auth.loginPassword.handler( "login_requests.id", "login_requests.user_id", "login_requests.email", + "login_requests.token", "login_requests.device_fingerprint", "login_requests.expires_at", "login_requests.completed_at", @@ -106,19 +106,9 @@ export const loginPassword = os.auth.loginPassword.handler( .where("id", "=", result.id) .execute(); } else { - // Device is untrusted - generate confirmation token and send email - const confirmationToken = generateSecureToken(); - - await context.db - .updateTable("login_requests") - .set({ - token: confirmationToken, - }) - .where("id", "=", result.id) - .execute(); - - // Send login confirmation email - await sendLoginConfirmationEmail(result.email, confirmationToken); + // Device is untrusted - send confirmation email with existing token + // The same base58 token is used for both cookie lookup and email confirmation + await sendLoginConfirmationEmail(result.email, result.token); } // Return void (success) diff --git a/apps/api-server/src/procedures/auth/resend-verification.ts b/apps/api-server/src/procedures/auth/resend-verification.ts index ad1b190..5f2eff7 100644 --- a/apps/api-server/src/procedures/auth/resend-verification.ts +++ b/apps/api-server/src/procedures/auth/resend-verification.ts @@ -5,13 +5,16 @@ * Flow: * 1. Check if email is already verified (return early if so) * 2. Delete any existing verification tokens for this user - * 3. Generate new secure token (64 hex chars) + * 3. Generate new secure base58 token * 4. Create new email_verifications record with 24 hour expiry * 5. Send verification email (stubbed) */ import { TOKEN_DURATIONS } from "../../utils/cookies.js"; -import { generateExpiry, generateSecureToken } from "../../utils/crypto.js"; +import { + generateExpiry, + generateSecureBase58Token, +} from "../../utils/crypto.js"; import { sendVerificationEmail } from "../../utils/email.js"; import { authMiddleware, os } from "../base.js"; @@ -30,8 +33,8 @@ export const resendVerificationEmail = os.auth.resendVerificationEmail .where("user_id", "=", context.user.id) .execute(); - // Generate new secure token - const token = generateSecureToken(); + // Generate new secure base58 token + const token = generateSecureBase58Token(); const expiresAt = generateExpiry(TOKEN_DURATIONS.EMAIL_VERIFICATION); // Create new verification record diff --git a/apps/api-server/src/procedures/auth/signup.ts b/apps/api-server/src/procedures/auth/signup.ts index e821362..33338d5 100644 --- a/apps/api-server/src/procedures/auth/signup.ts +++ b/apps/api-server/src/procedures/auth/signup.ts @@ -17,7 +17,10 @@ import { setCookie, TOKEN_DURATIONS, } from "../../utils/cookies.js"; -import { generateExpiry, generateSecureToken } from "../../utils/crypto.js"; +import { + generateExpiry, + generateSecureBase58Token, +} from "../../utils/crypto.js"; import { sendVerificationEmail } from "../../utils/email.js"; import { getGeoInfo, getUserAgent } from "../../utils/geo.js"; import { hashPassword, validatePassword } from "../../utils/password.js"; @@ -262,7 +265,7 @@ export const signup = os.auth.signup.handler(async ({ input, context }) => { ); // Generate verification token - const verificationToken = generateSecureToken(); + const verificationToken = generateSecureBase58Token(); const expiresAt = generateExpiry(TOKEN_DURATIONS.EMAIL_VERIFICATION); // Store verification token (store raw token, not hash - it's already high-entropy) diff --git a/apps/api-server/src/procedures/orgs/invites.ts b/apps/api-server/src/procedures/orgs/invites.ts index 43e4729..4089ad2 100644 --- a/apps/api-server/src/procedures/orgs/invites.ts +++ b/apps/api-server/src/procedures/orgs/invites.ts @@ -4,7 +4,10 @@ import { ORPCError } from "@orpc/server"; import { ORG_INVITE_EXPIRY_DAYS } from "../../constants.js"; -import { generateExpiry, generateSecureToken } from "../../utils/crypto.js"; +import { + generateExpiry, + generateSecureBase58Token, +} from "../../utils/crypto.js"; import { sendOrgInviteEmail } from "../../utils/email.js"; import { authMiddleware, os } from "../base.js"; import { getMembership, lookupOrgBySlug, requireRole } from "./helpers.js"; @@ -88,7 +91,7 @@ export const invitesCreate = os.orgs.invites.create } // Generate invite token and expiry - const token = generateSecureToken(); + const token = generateSecureBase58Token(); const expiresAt = generateExpiry(ORG_INVITE_EXPIRY_DAYS * 24 * 60 * 60); try { diff --git a/apps/api-server/src/utils/cookies.ts b/apps/api-server/src/utils/cookies.ts index b2c17ca..947254a 100644 --- a/apps/api-server/src/utils/cookies.ts +++ b/apps/api-server/src/utils/cookies.ts @@ -1,8 +1,16 @@ /** * Cookie configuration for authentication * All cookies use 'rev.' prefix, HttpOnly, Secure, SameSite=Lax + * + * Uses oRPC cookie helpers for proper cookie handling */ +export { + deleteCookie, + getCookie, + setCookie, +} from "@orpc/server/helpers"; + export const COOKIE_NAMES = { SESSION_TOKEN: "rev.session_token", DEVICE_FINGERPRINT: "rev.device_fingerprint", @@ -39,71 +47,3 @@ export const COOKIE_OPTIONS = { maxAge: COOKIE_DURATIONS.LOGIN_REQUEST, }, } as const; - -/** - * Cookie options type for setCookie function - */ -export interface CookieOptions { - httpOnly?: boolean; - secure?: boolean; - sameSite?: "strict" | "lax" | "none"; - path?: string; - maxAge?: number; -} - -/** - * Parse cookie string and get a specific cookie value - */ -export const getCookie = ( - headers: Headers, - name: string, -): string | undefined => { - const cookieHeader = headers.get("Cookie"); - if (!cookieHeader) { - return undefined; - } - - const cookies = cookieHeader.split(";").map((c) => c.trim()); - for (const cookie of cookies) { - const [cookieName, ...valueParts] = cookie.split("="); - if (cookieName === name) { - return valueParts.join("="); - } - } - return undefined; -}; - -/** - * Set a cookie in the response headers - */ -export const setCookie = ( - headers: Headers, - name: string, - value: string, - options: CookieOptions, -): void => { - const parts = [`${name}=${value}`]; - if (options.httpOnly) { - parts.push("HttpOnly"); - } - if (options.secure) { - parts.push("Secure"); - } - if (options.sameSite) { - parts.push(`SameSite=${options.sameSite}`); - } - if (options.path) { - parts.push(`Path=${options.path}`); - } - if (options.maxAge) { - parts.push(`Max-Age=${String(options.maxAge)}`); - } - headers.append("Set-Cookie", parts.join("; ")); -}; - -/** - * Delete a cookie by setting it to expire immediately - */ -export const deleteCookie = (headers: Headers, name: string): void => { - headers.append("Set-Cookie", `${name}=; Path=/; Max-Age=0`); -}; diff --git a/apps/api-server/src/utils/crypto.ts b/apps/api-server/src/utils/crypto.ts index 9b3c25f..b6eab81 100644 --- a/apps/api-server/src/utils/crypto.ts +++ b/apps/api-server/src/utils/crypto.ts @@ -1,5 +1,8 @@ import { base58 } from "@scure/base"; +// Re-export generateSecureBase58Token from shared utils +export { generateSecureBase58Token } from "@reviq/utils"; + /** * Token prefix for all RevIQ API tokens */ @@ -62,58 +65,6 @@ export const generateDeviceFingerprint = (): string => { return crypto.randomUUID(); }; -/** - * Generate a secure random token for email verification, password reset, etc. - * Uses 32 bytes (256 bits) of entropy - * Uses Web Crypto API for Cloudflare Workers compatibility - */ -export const generateSecureToken = (): string => { - const bytes = new Uint8Array(32); - crypto.getRandomValues(bytes); - return Array.from(bytes) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); -}; - -/** - * Base58 alphabet (Bitcoin-style, no 0, O, I, l) - */ -const BASE58_ALPHABET = - "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; - -/** - * Generate a cryptographically secure base58 token - * Uses 24 bytes (192 bits) of entropy, producing ~33 character output - */ -export const generateBase58Token = (byteLength = 24): string => { - const bytes = new Uint8Array(byteLength); - crypto.getRandomValues(bytes); - - // Convert bytes to base58 - let result = ""; - let num = BigInt(0); - for (const byte of bytes) { - num = num * 256n + BigInt(byte); - } - - while (num > 0n) { - const remainder = Number(num % 58n); - result = BASE58_ALPHABET.charAt(remainder) + result; - num /= 58n; - } - - // Handle leading zeros - for (const byte of bytes) { - if (byte === 0) { - result = BASE58_ALPHABET.charAt(0) + result; - } else { - break; - } - } - - return result; -}; - /** * Generate expiration date */ diff --git a/apps/cli/package.json b/apps/cli/package.json index aeed952..e331b7d 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -17,6 +17,9 @@ }, "dependencies": { "@noble/hashes": "^2.0.1", + "@orpc/client": "^1.13.2", + "@orpc/contract": "^1.13.2", + "@reviq/api-contract": "workspace:*", "@reviq/db": "workspace:*", "@scure/base": "^2.0.0", "@stricli/auto-complete": "^1.0.0", diff --git a/apps/cli/src/app.ts b/apps/cli/src/app.ts index 747763f..1c49f97 100644 --- a/apps/cli/src/app.ts +++ b/apps/cli/src/app.ts @@ -6,4 +6,10 @@ export const app = buildApplication(rootRouteMap, { versionInfo: { currentVersion: "0.0.0", }, + scanner: { + caseStyle: "allow-kebab-for-camel", + }, + documentation: { + caseStyle: "convert-camel-to-kebab", + }, }); diff --git a/apps/cli/src/routes/_command.ts b/apps/cli/src/routes/_command.ts index 35acde1..034ebf9 100644 --- a/apps/cli/src/routes/_command.ts +++ b/apps/cli/src/routes/_command.ts @@ -1,4 +1,5 @@ import { buildRouteMap } from "@stricli/core"; +import { adminRouteMap } from "./admin/_command.js"; import { authRouteMap } from "./auth/_command.js"; import { bootstrapCommand } from "./bootstrap.js"; import { completionsCommand } from "./completions.js"; @@ -8,6 +9,7 @@ import { userRouteMap } from "./user/_command.js"; export const rootRouteMap = buildRouteMap({ routes: { bootstrap: bootstrapCommand, + admin: adminRouteMap, auth: authRouteMap, user: userRouteMap, org: orgRouteMap, diff --git a/apps/cli/src/routes/admin/_command.ts b/apps/cli/src/routes/admin/_command.ts new file mode 100644 index 0000000..d5d239e --- /dev/null +++ b/apps/cli/src/routes/admin/_command.ts @@ -0,0 +1,11 @@ +import { buildRouteMap } from "@stricli/core"; +import { completeLoginCommand } from "./complete-login.js"; + +export const adminRouteMap = buildRouteMap({ + routes: { + "complete-login": completeLoginCommand, + }, + docs: { + brief: "Admin commands (requires superuser)", + }, +}); diff --git a/apps/cli/src/routes/admin/complete-login.ts b/apps/cli/src/routes/admin/complete-login.ts new file mode 100644 index 0000000..908d575 --- /dev/null +++ b/apps/cli/src/routes/admin/complete-login.ts @@ -0,0 +1,51 @@ +import type { LocalContext } from "../../context.js"; +import { ORPCError } from "@orpc/client"; +import { buildCommand } from "@stricli/core"; +import { createApiClient } from "../../utils/api-client.js"; + +interface CompleteLoginFlags { + email: string; +} + +async function completeLogin( + this: LocalContext, + flags: CompleteLoginFlags, +): Promise { + try { + const api = await createApiClient(); + + await api.admin.auth.completeLogin({ + email: flags.email, + }); + + console.log(`Completed login request for: ${flags.email}`); + } catch (error) { + if (error instanceof ORPCError) { + console.error(`Error [${String(error.code)}]:`, error.message); + } else { + console.error( + "Error:", + error instanceof Error ? error.message : String(error), + ); + } + this.process.exit(1); + } +} + +export const completeLoginCommand = buildCommand({ + func: completeLogin, + parameters: { + flags: { + email: { + kind: "parsed", + parse: String, + brief: "Email address of user with pending login request", + }, + }, + }, + docs: { + brief: "Complete pending login request", + fullDescription: + "Completes the most recent pending login request for a user. This is useful for development when email sending is not configured or to bypass email confirmation.", + }, +}); diff --git a/apps/cli/src/routes/auth/status.ts b/apps/cli/src/routes/auth/status.ts index 55150ae..9edc729 100644 --- a/apps/cli/src/routes/auth/status.ts +++ b/apps/cli/src/routes/auth/status.ts @@ -4,39 +4,11 @@ import { createApiClient } from "../../utils/api-client.js"; import { getConfigPath, readConfig } from "../../utils/config.js"; import { TOKEN_PREFIX } from "../../utils/token.js"; -interface AuthStatusResponse { - user: { - id: number; - email: string; - displayName: string | null; - fullName: string | null; - isSuperuser: boolean; - emailVerified: boolean; - }; - auth: - | { - method: "api_token"; - tokenId: string; - tokenName: string; - expiresAt: string; - lastUsedAt: string | null; - createdAt: string; - } - | { - method: "session"; - sessionId: string; - expiresAt: string; - createdAt: string; - }; -} - -function formatDate(dateStr: string): string { - const date = new Date(dateStr); +function formatDate(date: Date): string { return date.toLocaleString(); } -function formatRelativeTime(dateStr: string): string { - const date = new Date(dateStr); +function formatRelativeTime(date: Date): string { const now = new Date(); const diffMs = date.getTime() - now.getTime(); const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); @@ -86,8 +58,8 @@ async function status(this: LocalContext): Promise { // Try to fetch status from API console.log("\nAPI Status:"); try { - const client = await createApiClient(); - const response = await client.call("me.authStatus"); + const api = await createApiClient(); + const response = await api.me.authStatus(); // User info console.log("\n User:"); diff --git a/apps/cli/src/routes/bootstrap.ts b/apps/cli/src/routes/bootstrap.ts index 0b28b4a..a6d4ee6 100644 --- a/apps/cli/src/routes/bootstrap.ts +++ b/apps/cli/src/routes/bootstrap.ts @@ -6,6 +6,7 @@ import { writeConfig } from "../utils/config.js"; interface BootstrapFlags { email: string; password: string; + dangerouslyOverwriteExisting: boolean; } async function bootstrap( @@ -28,6 +29,7 @@ async function bootstrap( const result = await executeBootstrap(db, { email: flags.email, password: flags.password, + dangerouslyOverwriteExisting: flags.dangerouslyOverwriteExisting, }); console.log(`Created superuser: ${result.user.email}`); @@ -68,6 +70,11 @@ export const bootstrapCommand = buildCommand({ parse: String, brief: "Password for the superuser", }, + dangerouslyOverwriteExisting: { + kind: "boolean", + brief: "Delete existing user and reviq org if they exist", + default: false, + }, }, }, docs: { diff --git a/apps/cli/src/routes/org/add-site.ts b/apps/cli/src/routes/org/add-site.ts index cb3e360..281e5e7 100644 --- a/apps/cli/src/routes/org/add-site.ts +++ b/apps/cli/src/routes/org/add-site.ts @@ -9,9 +9,9 @@ interface AddSiteFlags { async function addSite(this: LocalContext, flags: AddSiteFlags): Promise { try { - const client = await createApiClient(); + const api = await createApiClient(); - await client.call("admin.orgs.addSite", { + await api.admin.orgs.addSite({ slug: flags.org, domain: flags.domain, }); diff --git a/apps/cli/src/routes/org/create.ts b/apps/cli/src/routes/org/create.ts index f0898ba..cc9b217 100644 --- a/apps/cli/src/routes/org/create.ts +++ b/apps/cli/src/routes/org/create.ts @@ -13,9 +13,9 @@ async function create( flags: CreateOrgFlags, ): Promise { try { - const client = await createApiClient(); + const api = await createApiClient(); - const result = await client.call<{ slug: string }>("admin.orgs.create", { + const result = await api.admin.orgs.create({ slug: flags.slug, displayName: flags.name, ownerEmail: flags.owner, diff --git a/apps/cli/src/routes/org/list.ts b/apps/cli/src/routes/org/list.ts index da47357..a1e91c0 100644 --- a/apps/cli/src/routes/org/list.ts +++ b/apps/cli/src/routes/org/list.ts @@ -2,19 +2,11 @@ import type { LocalContext } from "../../context.js"; import { buildCommand } from "@stricli/core"; import { createApiClient } from "../../utils/api-client.js"; -interface OrgOutput { - id: number; - slug: string; - displayName: string; - logoUrl: string | null; - createdAt: string; -} - async function list(this: LocalContext): Promise { try { - const client = await createApiClient(); + const api = await createApiClient(); - const orgs = await client.call("admin.orgs.list", {}); + const orgs = await api.admin.orgs.list(); if (orgs.length === 0) { console.log("No organizations found"); @@ -27,7 +19,7 @@ async function list(this: LocalContext): Promise { for (const org of orgs) { console.log(org.slug); console.log(` Name: ${org.displayName}`); - console.log(` Created: ${new Date(org.createdAt).toLocaleDateString()}`); + console.log(` Created: ${org.createdAt.toLocaleDateString()}`); console.log(); } diff --git a/apps/cli/src/routes/user/confirm-email.ts b/apps/cli/src/routes/user/confirm-email.ts index b70705b..1863376 100644 --- a/apps/cli/src/routes/user/confirm-email.ts +++ b/apps/cli/src/routes/user/confirm-email.ts @@ -11,9 +11,9 @@ async function confirmEmail( flags: ConfirmEmailFlags, ): Promise { try { - const client = await createApiClient(); + const api = await createApiClient(); - await client.call("admin.users.confirmEmail", { + await api.admin.users.confirmEmail({ email: flags.email, }); diff --git a/apps/cli/src/routes/user/create.ts b/apps/cli/src/routes/user/create.ts index 66bb39e..453b4c3 100644 --- a/apps/cli/src/routes/user/create.ts +++ b/apps/cli/src/routes/user/create.ts @@ -2,6 +2,22 @@ import type { LocalContext } from "../../context.js"; import { buildCommand } from "@stricli/core"; import { createApiClient } from "../../utils/api-client.js"; +type OrgRole = "owner" | "admin" | "member"; + +const validRoles: OrgRole[] = ["owner", "admin", "member"]; + +function parseRole(role: string | undefined): OrgRole | undefined { + if (!role) { + return undefined; + } + if (validRoles.includes(role as OrgRole)) { + return role as OrgRole; + } + throw new Error( + `Invalid role: ${role}. Must be one of: ${validRoles.join(", ")}`, + ); +} + interface CreateUserFlags { email: string; name?: string; @@ -14,13 +30,14 @@ async function create( flags: CreateUserFlags, ): Promise { try { - const client = await createApiClient(); + const orgRole = parseRole(flags.role); + const api = await createApiClient(); - await client.call("admin.users.create", { + await api.admin.users.create({ email: flags.email, name: flags.name, orgSlug: flags.org, - orgRole: flags.role, + orgRole, }); console.log(`Created user: ${flags.email}`); diff --git a/apps/cli/src/utils/api-client.ts b/apps/cli/src/utils/api-client.ts index 70cdf18..78f8fd8 100644 --- a/apps/cli/src/utils/api-client.ts +++ b/apps/cli/src/utils/api-client.ts @@ -2,18 +2,19 @@ * API client utilities for CLI commands */ +import type { ContractRouterClient } from "@orpc/contract"; +import type { contract } from "@reviq/api-contract"; +import { createORPCClient } from "@orpc/client"; +import { RPCLink } from "@orpc/client/fetch"; import { readConfig } from "./config.js"; -export interface ApiClientError { - code: string; - message: string; -} +export type ApiClient = ContractRouterClient; /** - * Create an API client with the stored credentials + * Create an oRPC API client with the stored credentials * Throws an error if not logged in */ -export const createApiClient = async () => { +export const createApiClient = async (): Promise => { const config = await readConfig(); if (!config) { throw new Error( @@ -21,41 +22,13 @@ export const createApiClient = async () => { ); } - return { - /** - * Call an oRPC procedure - */ - call: async (path: string, input?: unknown): Promise => { - const url = `${config.apiUrl}/api/v1/rpc/${path}`; - const response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-API-Key": config.token, - }, - body: input !== undefined ? JSON.stringify(input) : undefined, - }); - - if (!response.ok) { - const text = await response.text(); - let errorMessage = `API error: ${String(response.status)} ${response.statusText}`; - try { - const error = JSON.parse(text) as ApiClientError; - if (error.message) { - errorMessage = error.message; - } - } catch { - if (text) { - errorMessage = text; - } - } - throw new Error(errorMessage); - } - - return response.json() as Promise; + const link = new RPCLink({ + url: `${config.apiUrl}/api/v1/rpc`, + headers: { + "X-API-Key": config.token, }, - config, - }; -}; + }); -export type ApiClient = Awaited>; + // Cast to ApiClient for type-safe API calls + return createORPCClient(link) as unknown as ApiClient; +}; diff --git a/apps/publisher-dashboard/src/lib/components/admin/index.ts b/apps/publisher-dashboard/src/lib/components/admin/index.ts new file mode 100644 index 0000000..45c9048 --- /dev/null +++ b/apps/publisher-dashboard/src/lib/components/admin/index.ts @@ -0,0 +1 @@ +export { default as SuperuserBadge } from "./superuser-badge.svelte"; diff --git a/apps/publisher-dashboard/src/lib/components/admin/superuser-badge.svelte b/apps/publisher-dashboard/src/lib/components/admin/superuser-badge.svelte new file mode 100644 index 0000000..ba13cfe --- /dev/null +++ b/apps/publisher-dashboard/src/lib/components/admin/superuser-badge.svelte @@ -0,0 +1,9 @@ + + + + + Superuser + diff --git a/apps/publisher-dashboard/src/lib/components/auth/auth-guard.svelte b/apps/publisher-dashboard/src/lib/components/auth/auth-guard.svelte new file mode 100644 index 0000000..adb3e82 --- /dev/null +++ b/apps/publisher-dashboard/src/lib/components/auth/auth-guard.svelte @@ -0,0 +1,35 @@ + + +{#if isAuthPage || userQuery.data || userQuery.isPending} + {@render children()} +{/if} diff --git a/apps/publisher-dashboard/src/lib/components/auth/index.ts b/apps/publisher-dashboard/src/lib/components/auth/index.ts index 75f95c5..f5c9fea 100644 --- a/apps/publisher-dashboard/src/lib/components/auth/index.ts +++ b/apps/publisher-dashboard/src/lib/components/auth/index.ts @@ -1,3 +1,4 @@ +export { default as AuthGuard } from "./auth-guard.svelte"; export { default as ErrorAlert } from "./error-alert.svelte"; export { default as PasswordFormField } from "./password-form-field.svelte"; export { default as PasswordInput } from "./password-input.svelte"; diff --git a/apps/publisher-dashboard/src/lib/components/layout/app-sidebar.svelte b/apps/publisher-dashboard/src/lib/components/layout/app-sidebar.svelte index 3c64773..349d6a1 100644 --- a/apps/publisher-dashboard/src/lib/components/layout/app-sidebar.svelte +++ b/apps/publisher-dashboard/src/lib/components/layout/app-sidebar.svelte @@ -1,5 +1,7 @@