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..062dd64 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,40 @@ 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/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/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/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..cc8822d --- /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 [${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/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..df3f467 100644 --- a/apps/cli/src/routes/user/create.ts +++ b/apps/cli/src/routes/user/create.ts @@ -2,6 +2,18 @@ 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 +26,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/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/routes/+layout.svelte b/apps/publisher-dashboard/src/routes/+layout.svelte index d3b1642..f8bbff6 100644 --- a/apps/publisher-dashboard/src/routes/+layout.svelte +++ b/apps/publisher-dashboard/src/routes/+layout.svelte @@ -4,6 +4,7 @@ import type { Snippet } from "svelte"; import { QueryClient, QueryClientProvider } from "@tanstack/svelte-query"; import { SvelteQueryDevtools } from "@tanstack/svelte-query-devtools"; import { Toaster } from "svelte-sonner"; +import { AuthGuard } from "$lib/components/auth"; interface Props { children: Snippet; @@ -22,7 +23,11 @@ const queryClient = new QueryClient({ - {@render children()} + + {#snippet children()} + {@render children()} + {/snippet} + diff --git a/apps/publisher-dashboard/src/routes/auth/confirm/+page.svelte b/apps/publisher-dashboard/src/routes/auth/confirm/+page.svelte index 00269a9..9a4a24b 100644 --- a/apps/publisher-dashboard/src/routes/auth/confirm/+page.svelte +++ b/apps/publisher-dashboard/src/routes/auth/confirm/+page.svelte @@ -1,5 +1,5 @@