mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-15 15:26:59 +10:00
feat(mcp): add OAuth 2.1 for claude.ai MCP connector (#2829)
* feat(mcp): add OAuth 2.1 authentication for claude.ai MCP connector Enable OAuth 2.1 (RFC 8414 + RFC 7591) for the MCP endpoint using better-auth's MCP plugin. This allows claude.ai and other MCP clients to authenticate via Dynamic Client Registration and Authorization Code flow with PKCE, using the existing login page. - Add `mcp()` plugin to better-auth config with login page redirect - Add `.well-known/oauth-authorization-server` discovery endpoint - Add `.well-known/oauth-protected-resource` metadata endpoint - Update MCP handler to accept Bearer tokens via `getMcpSession` - Retain `x-api-key` fallback for backward compatibility - Return proper HTTP 401 + WWW-Authenticate header for unauthed requests - Add `oauthApplication`, `oauthAccessToken`, `oauthConsent` tables Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(mcp): use typed AuthError and suppress noisy verifyApiKey throws - Replace string-matching error detection with instanceof AuthError - Wrap verifyApiKey in try-catch to avoid logging malformed key errors - Move console.error below auth check so 401s don't pollute logs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(mcp): add database migration for OAuth tables Creates oauth_application, oauth_access_token, and oauth_consent tables required for MCP OAuth 2.1 Dynamic Client Registration flow. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(mcp): resolve OAuth Bearer token auth for oRPC tool calls The oRPC context only checked session cookies and API keys, causing MCP tool calls from OAuth clients (claude.ai) to fail with Unauthorized even though the MCP endpoint itself authenticated successfully. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(mcp): look up user by userId from OAuth access token getMcpSession returns OAuthAccessToken (with userId), not a session object with a user property. Must query the user table by userId. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(mcp): migrate from deprecated mcp() plugin to @better-auth/oauth-provider The better-auth MCP plugin is marked for deprecation in favor of the OAuth Provider plugin. This refactors the entire OAuth 2.1 flow to use @better-auth/oauth-provider with JWT-based token verification, replacing the opaque token lookup via getMcpSession(). Key changes: - Replace mcp() with jwt() + oauthProvider() in auth config - Replace getMcpSession() with verifyAccessToken() (JWT/JWKS) - Replace oauthApplication table with oauthClient (RFC 7591 compliant) - Add oauthRefreshToken table and jwks table for JWT signing keys - Extract shared authBaseUrl and verifyOAuthToken helper - Hoist McpServer to module scope (avoid per-request reconstruction) - Update .well-known discovery endpoints for OAuth Provider Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(mcp): resolve OAuth 2.1 flow for claude.ai MCP connector Multiple fixes required to make the full MCP OAuth flow work with claude.ai's implementation: - Add RFC 8414 discovery route at /.well-known/oauth-authorization-server/api/auth (claude.ai appends the issuer path per spec) - Add /auth/oauth server route to handle login/consent flow (generates auth codes directly, bypassing h3 cookie issues) - Default token_endpoint_auth_method to "none" via onRequest plugin hook (claude.ai omits this field, causing confidential client rejection) - Strip prompt=consent from authorize requests via onRequest hook (better-auth checks prompt before skipConsent, causing redirect loops) - Add validAudiences for MCP resource URL (JWT aud claim contains the MCP URL, not the base URL) - Disable CSRF check for cross-origin OAuth flows - Log token endpoint errors for debugging - Set skipConsent on OAuth clients via /auth/oauth route Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(mcp): harden OAuth security and enforce lock on delete - Scope CSRF bypass to OAuth2 paths only instead of disabling globally - Validate redirect_uri against registered client URIs (prevents code interception) - Use pathname matching instead of fragile url.includes() for route guards - Replace biased modulo code generation with crypto.randomBytes - Enforce resume lock check on delete (previously silently ignored) - Remove debug console.error logging of OAuth token response bodies - Use Response.json() consistently for MCP 401 response Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Update dependencies, refine ignore patterns, and enhance documentation - Updated various dependencies in package.json and pnpm-lock.yaml for improved stability and features. - Adjusted ignore patterns in knip.json to include specific component directories. - Enhanced documentation for the MCP server, clarifying authentication methods and configuration options. - Made minor adjustments to VSCode settings for better code organization. * fix(mcp): resolve OAuth client registration and stale token handling Claude.ai sends token_endpoint_auth_method: "client_secret_post" without a client_secret during Dynamic Client Registration, causing Better Auth to reject it as an unauthenticated confidential client. Force to "none" for unauthenticated registrations. Also catch JWKS verification errors (e.g. key rotation after redeployment) so stale Bearer tokens return 401 instead of 200 with an error body, allowing clients to re-initiate the OAuth flow. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * reiterate on tests --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
This commit is contained in:
@@ -1,422 +0,0 @@
|
||||
import type { UIMessage } from "ai";
|
||||
import type { Operation } from "fast-json-patch";
|
||||
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { eventIteratorToUnproxiedDataStream } from "@orpc/client";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
CircleNotchIcon,
|
||||
PaperPlaneRightIcon,
|
||||
SparkleIcon,
|
||||
StopIcon,
|
||||
TrashSimpleIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useAIStore } from "@/integrations/ai/store";
|
||||
import { client } from "@/integrations/orpc/client";
|
||||
import { applyResumePatches } from "@/utils/resume/patch";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
import { useResumeStore } from "../resume/store/resume";
|
||||
|
||||
/**
|
||||
* Extract patch operations from the latest assistant messages' tool call parts.
|
||||
* Returns operations that haven't been applied yet (tracked by processedToolCallIds).
|
||||
*/
|
||||
function extractNewPatchOperations(
|
||||
messages: UIMessage[],
|
||||
processedIds: Set<string>,
|
||||
): { operations: Operation[]; newIds: string[] } {
|
||||
const operations: Operation[] = [];
|
||||
const newIds: string[] = [];
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.role !== "assistant") continue;
|
||||
for (const part of message.parts) {
|
||||
if (part.type !== "tool-patch_resume") continue;
|
||||
|
||||
const toolPart = part as any;
|
||||
if (toolPart.state !== "output-available") continue;
|
||||
|
||||
const callId = toolPart.toolCallId as string;
|
||||
if (processedIds.has(callId)) continue;
|
||||
|
||||
const result = toolPart.output as { success: boolean; appliedOperations?: Operation[] } | undefined;
|
||||
if (result?.success && result.appliedOperations) {
|
||||
operations.push(...result.appliedOperations);
|
||||
newIds.push(callId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { operations, newIds };
|
||||
}
|
||||
|
||||
/**
|
||||
* LocalStorage helpers for persisting chat messages per resume.
|
||||
*/
|
||||
const STORAGE_KEY_PREFIX = "ai-chat-messages";
|
||||
|
||||
function getStorageKey(resumeId: string): string {
|
||||
return `${STORAGE_KEY_PREFIX}:${resumeId}`;
|
||||
}
|
||||
|
||||
function loadStoredMessages(resumeId: string): UIMessage[] {
|
||||
try {
|
||||
const stored = localStorage.getItem(getStorageKey(resumeId));
|
||||
if (!stored) return [];
|
||||
return JSON.parse(stored) as UIMessage[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveStoredMessages(resumeId: string, messages: UIMessage[]): void {
|
||||
try {
|
||||
localStorage.setItem(getStorageKey(resumeId), JSON.stringify(messages));
|
||||
} catch {
|
||||
// Silently fail if localStorage is full or unavailable
|
||||
}
|
||||
}
|
||||
|
||||
function clearStoredMessages(resumeId: string): void {
|
||||
localStorage.removeItem(getStorageKey(resumeId));
|
||||
}
|
||||
|
||||
function formatPath(path: string): string {
|
||||
return path.replace(/^\//, "").replace(/\//g, " › ");
|
||||
}
|
||||
|
||||
function formatOperationLabel(op: string): string {
|
||||
switch (op) {
|
||||
case "replace":
|
||||
return t`Updated`;
|
||||
case "add":
|
||||
return t`Added`;
|
||||
case "remove":
|
||||
return t`Removed`;
|
||||
default:
|
||||
return op;
|
||||
}
|
||||
}
|
||||
|
||||
function ToolBadge({ state, operations }: { state: string; operations?: { op: string; path: string }[] }) {
|
||||
const isDone = state === "output-available";
|
||||
|
||||
return (
|
||||
<div className="my-0.5 flex flex-col gap-1.5">
|
||||
<span
|
||||
data-state={state}
|
||||
className={cn(
|
||||
"inline-flex w-fit items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium",
|
||||
"data-[state=output-available]:border-emerald-500/30 data-[state=output-available]:bg-emerald-500/10 data-[state=output-available]:text-emerald-600 dark:data-[state=output-available]:text-emerald-400",
|
||||
)}
|
||||
>
|
||||
{isDone ? (
|
||||
<CheckCircleIcon weight="fill" className="size-3" />
|
||||
) : (
|
||||
<CircleNotchIcon className="size-3 animate-spin" />
|
||||
)}
|
||||
{isDone ? "Changes applied" : "Applying changes..."}
|
||||
</span>
|
||||
|
||||
{isDone && operations && operations.length > 0 && (
|
||||
<div className="flex flex-col gap-0.5 pl-1 text-[11px] text-muted-foreground">
|
||||
{operations.map((op, i) => (
|
||||
<span key={i} className="flex items-center gap-1">
|
||||
<span className="opacity-40">•</span>
|
||||
{formatOperationLabel(op.op)} {formatPath(op.path)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageParts({ message }: { message: UIMessage }) {
|
||||
const parts = message.parts;
|
||||
|
||||
if (parts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{parts.map((part, i) => {
|
||||
if (part.type === "step-start") {
|
||||
if (i === 0) return null;
|
||||
return <hr key={i} className="my-0.5 border-border/40" />;
|
||||
}
|
||||
|
||||
if (part.type === "text" && part.text.trim()) {
|
||||
return (
|
||||
<p key={i} className="text-[13px] leading-relaxed whitespace-pre-wrap">
|
||||
{part.text}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (part.type === "reasoning" && part.text.trim()) {
|
||||
return (
|
||||
<p key={i} className="text-[11px] leading-relaxed whitespace-pre-wrap text-muted-foreground italic">
|
||||
{part.text}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (part.type === "tool-patch_resume") {
|
||||
const toolPart = part as any;
|
||||
const state = toolPart.state as string;
|
||||
const operations = toolPart.input?.operations as { op: string; path: string }[] | undefined;
|
||||
return <ToolBadge key={i} state={state} operations={operations} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AIChat() {
|
||||
const enabled = useAIStore((s) => s.enabled);
|
||||
const provider = useAIStore((s) => s.provider);
|
||||
const model = useAIStore((s) => s.model);
|
||||
const apiKey = useAIStore((s) => s.apiKey);
|
||||
const baseURL = useAIStore((s) => s.baseURL);
|
||||
|
||||
const resumeId = useResumeStore((s) => s.resume.id);
|
||||
const resumeData = useResumeStore((s) => s.resume.data);
|
||||
const updateResumeData = useResumeStore((s) => s.updateResumeData);
|
||||
|
||||
const [input, setInput] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const processedToolCallIds = useRef(new Set<string>());
|
||||
|
||||
// Load stored messages once on mount and pre-populate processedToolCallIds
|
||||
// so that already-applied patches are not re-applied.
|
||||
const [initialMessages] = useState(() => {
|
||||
const stored = loadStoredMessages(resumeId);
|
||||
|
||||
for (const msg of stored) {
|
||||
if (msg.role !== "assistant") continue;
|
||||
for (const part of msg.parts) {
|
||||
if (part.type !== "tool-patch_resume") continue;
|
||||
const toolPart = part as any;
|
||||
if (toolPart.state === "output-available") {
|
||||
processedToolCallIds.current.add(toolPart.toolCallId as string);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return stored;
|
||||
});
|
||||
|
||||
const { messages, sendMessage, status, stop, setMessages } = useChat({
|
||||
messages: initialMessages,
|
||||
transport: {
|
||||
async sendMessages(options) {
|
||||
return eventIteratorToUnproxiedDataStream(
|
||||
await client.ai.chat(
|
||||
{
|
||||
provider,
|
||||
model,
|
||||
apiKey,
|
||||
baseURL,
|
||||
messages: options.messages,
|
||||
resumeData,
|
||||
},
|
||||
{ signal: options.abortSignal },
|
||||
),
|
||||
);
|
||||
},
|
||||
reconnectToStream() {
|
||||
throw new Error("Unsupported");
|
||||
},
|
||||
},
|
||||
onError(error) {
|
||||
toast.error("AI chat error", { description: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
// Apply patches when new tool results arrive
|
||||
useEffect(() => {
|
||||
const { operations, newIds } = extractNewPatchOperations(messages, processedToolCallIds.current);
|
||||
if (operations.length === 0) return;
|
||||
|
||||
try {
|
||||
const patched = applyResumePatches(resumeData, operations);
|
||||
updateResumeData((draft) => {
|
||||
Object.assign(draft, patched);
|
||||
});
|
||||
for (const id of newIds) processedToolCallIds.current.add(id);
|
||||
} catch (error) {
|
||||
toast.error("Failed to apply resume changes", {
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}, [messages, resumeData, updateResumeData]);
|
||||
|
||||
// Persist messages to localStorage whenever they change
|
||||
useEffect(() => {
|
||||
saveStoredMessages(resumeId, messages);
|
||||
}, [resumeId, messages]);
|
||||
|
||||
// Auto-scroll to bottom (sentinel so it works with ScrollArea viewport)
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
|
||||
// Refocus the input when the AI finishes responding
|
||||
useEffect(() => {
|
||||
if (status === "ready") inputRef.current?.focus();
|
||||
}, [status]);
|
||||
|
||||
const handleOpenChange = useCallback((nextOpen: boolean) => {
|
||||
setOpen(nextOpen);
|
||||
}, []);
|
||||
|
||||
const handleClearMessages = useCallback(() => {
|
||||
setMessages([]);
|
||||
clearStoredMessages(resumeId);
|
||||
processedToolCallIds.current.clear();
|
||||
}, [resumeId, setMessages]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.SubmitEvent) => {
|
||||
e.preventDefault();
|
||||
if (!input.trim() || status !== "ready") return;
|
||||
await sendMessage({ text: input });
|
||||
setInput("");
|
||||
},
|
||||
[input, status, sendMessage],
|
||||
);
|
||||
|
||||
if (!enabled) return null;
|
||||
|
||||
const isLoading = status === "submitted" || status === "streaming";
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button size="icon" variant="ghost">
|
||||
<SparkleIcon />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<PopoverContent className="flex h-128 w-md flex-col gap-y-0 overflow-hidden p-0" side="top" align="center">
|
||||
{/* Header with clear button */}
|
||||
<div className="flex shrink-0 items-center justify-between border-b px-3 py-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
<Trans>AI Chat</Trans>
|
||||
</p>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="size-7"
|
||||
title={t`Clear chat history`}
|
||||
onClick={handleClearMessages}
|
||||
disabled={messages.length === 0 || isLoading}
|
||||
>
|
||||
<TrashSimpleIcon className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Message list — min-h-0 so flex constrains height and viewport scrolls */}
|
||||
<ScrollArea className="min-h-0 flex-1 px-4">
|
||||
<div className="flex flex-col gap-y-4 pt-4">
|
||||
{messages.length === 0 && (
|
||||
<div className="flex h-full items-center justify-center py-6">
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
<Trans>Ask me to update your resume...</Trans>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{messages.map((message) => (
|
||||
<motion.div
|
||||
key={message.id}
|
||||
className={cn("flex", message.role === "user" ? "justify-end" : "justify-start")}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{ duration: 0.16, ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
layout
|
||||
>
|
||||
<div
|
||||
data-role={message.role}
|
||||
className={cn(
|
||||
"max-w-[85%] rounded-md px-3.5 py-2.5",
|
||||
"data-[role=user]:rounded-br-sm data-[role=user]:bg-primary data-[role=user]:text-primary-foreground",
|
||||
"data-[role=assistant]:rounded-bl-sm data-[role=assistant]:bg-muted data-[role=assistant]:text-foreground",
|
||||
)}
|
||||
>
|
||||
{message.role === "user" ? (
|
||||
<p className="text-[13px] leading-relaxed">
|
||||
{message.parts.map((part, i) =>
|
||||
part.type === "text" ? <span key={i}>{part.text}</span> : null,
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<MessageParts message={message} />
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{status === "submitted" && (
|
||||
<div className="flex justify-start">
|
||||
<div className="rounded-md rounded-bl-sm bg-muted px-3.5 py-2.5">
|
||||
<div className="flex items-center gap-2 text-[13px] text-muted-foreground">
|
||||
<CircleNotchIcon className="size-3 animate-spin" />
|
||||
<span>
|
||||
<Trans>Thinking...</Trans>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={bottomRef} aria-hidden />
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Input form — shrink-0 so it stays visible when content scrolls */}
|
||||
<form onSubmit={handleSubmit} className="flex shrink-0 items-center gap-1.5 border-t px-3 py-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
disabled={!enabled || isLoading}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder={t`e.g. Change my name to...`}
|
||||
className="flex-1 bg-transparent text-[13px] outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
{isLoading ? (
|
||||
<Button type="button" size="icon" variant="ghost" onClick={stop} className="size-7 shrink-0">
|
||||
<StopIcon className="size-3.5" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="submit" size="icon" variant="ghost" disabled={!input.trim()} className="size-7 shrink-0">
|
||||
<PaperPlaneRightIcon className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
import * as React from "react";
|
||||
|
||||
type InputValue = string[] | string;
|
||||
|
||||
interface VisuallyHiddenInputProps<T = InputValue> extends Omit<
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
"value" | "checked" | "onReset"
|
||||
> {
|
||||
value?: T;
|
||||
checked?: boolean;
|
||||
control: HTMLElement | null;
|
||||
bubbles?: boolean;
|
||||
}
|
||||
|
||||
export function VisuallyHiddenInput<T = InputValue>(props: VisuallyHiddenInputProps<T>) {
|
||||
const { control, value, checked, bubbles = true, type = "hidden", style, ...inputProps } = props;
|
||||
|
||||
const isCheckInput = React.useMemo(() => type === "checkbox" || type === "radio" || type === "switch", [type]);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const prevValueRef = React.useRef<{
|
||||
value: T | boolean | undefined;
|
||||
previous: T | boolean | undefined;
|
||||
}>({
|
||||
value: isCheckInput ? checked : value,
|
||||
previous: isCheckInput ? checked : value,
|
||||
});
|
||||
|
||||
const prevValue = React.useMemo(() => {
|
||||
const currentValue = isCheckInput ? checked : value;
|
||||
if (prevValueRef.current.value !== currentValue) {
|
||||
prevValueRef.current.previous = prevValueRef.current.value;
|
||||
prevValueRef.current.value = currentValue;
|
||||
}
|
||||
return prevValueRef.current.previous;
|
||||
}, [isCheckInput, value, checked]);
|
||||
|
||||
const [controlSize, setControlSize] = React.useState<{
|
||||
width?: number;
|
||||
height?: number;
|
||||
}>({});
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!control) {
|
||||
setControlSize({});
|
||||
return;
|
||||
}
|
||||
|
||||
setControlSize({
|
||||
width: control.offsetWidth,
|
||||
height: control.offsetHeight,
|
||||
});
|
||||
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
if (!Array.isArray(entries) || !entries.length) return;
|
||||
|
||||
const entry = entries[0];
|
||||
if (!entry) return;
|
||||
|
||||
let width: number;
|
||||
let height: number;
|
||||
|
||||
if ("borderBoxSize" in entry) {
|
||||
const borderSizeEntry = entry.borderBoxSize;
|
||||
const borderSize = Array.isArray(borderSizeEntry) ? borderSizeEntry[0] : borderSizeEntry;
|
||||
width = borderSize.inlineSize;
|
||||
height = borderSize.blockSize;
|
||||
} else {
|
||||
width = control.offsetWidth;
|
||||
height = control.offsetHeight;
|
||||
}
|
||||
|
||||
setControlSize({ width, height });
|
||||
});
|
||||
|
||||
resizeObserver.observe(control, { box: "border-box" });
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [control]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const input = inputRef.current;
|
||||
if (!input) return;
|
||||
|
||||
const inputProto = window.HTMLInputElement.prototype;
|
||||
const propertyKey = isCheckInput ? "checked" : "value";
|
||||
const eventType = isCheckInput ? "click" : "input";
|
||||
const currentValue = isCheckInput ? checked : value;
|
||||
|
||||
const serializedCurrentValue = isCheckInput
|
||||
? checked
|
||||
: typeof value === "object" && value !== null
|
||||
? JSON.stringify(value)
|
||||
: value;
|
||||
|
||||
const descriptor = Object.getOwnPropertyDescriptor(inputProto, propertyKey);
|
||||
const setter = descriptor?.set?.bind(input);
|
||||
|
||||
if (prevValue !== currentValue && setter) {
|
||||
const event = new Event(eventType, { bubbles });
|
||||
setter.call(input, serializedCurrentValue);
|
||||
input.dispatchEvent(event);
|
||||
}
|
||||
}, [prevValue, value, checked, bubbles, isCheckInput]);
|
||||
|
||||
const composedStyle = React.useMemo<React.CSSProperties>(() => {
|
||||
return {
|
||||
...style,
|
||||
...(controlSize.width !== undefined && controlSize.height !== undefined ? controlSize : {}),
|
||||
border: 0,
|
||||
clip: "rect(0 0 0 0)",
|
||||
clipPath: "inset(50%)",
|
||||
height: "1px",
|
||||
margin: "-1px",
|
||||
overflow: "hidden",
|
||||
padding: 0,
|
||||
position: "absolute",
|
||||
whiteSpace: "nowrap",
|
||||
width: "1px",
|
||||
};
|
||||
}, [style, controlSize]);
|
||||
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
{...inputProps}
|
||||
ref={inputRef}
|
||||
aria-hidden={isCheckInput}
|
||||
tabIndex={-1}
|
||||
defaultChecked={isCheckInput ? checked : undefined}
|
||||
style={composedStyle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ type LevelType = z.infer<typeof levelDesignSchema>["type"];
|
||||
|
||||
type LevelTypeComboboxProps = Omit<SingleComboboxProps, "options">;
|
||||
|
||||
export const getLevelTypeName = (type: LevelType) => {
|
||||
const getLevelTypeName = (type: LevelType) => {
|
||||
return match(type)
|
||||
.with("hidden", () => t`Hidden`)
|
||||
.with("circle", () => t`Circle`)
|
||||
|
||||
@@ -38,4 +38,4 @@ function Badge({
|
||||
});
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
export { Badge };
|
||||
|
||||
@@ -73,4 +73,4 @@ function ButtonGroupSeparator({
|
||||
);
|
||||
}
|
||||
|
||||
export { ButtonGroup, ButtonGroupSeparator, ButtonGroupText, buttonGroupVariants };
|
||||
export { ButtonGroup, ButtonGroupSeparator, ButtonGroupText };
|
||||
|
||||
@@ -244,10 +244,6 @@ function ComboboxChipsInput({ className, ...props }: ComboboxPrimitive.Input.Pro
|
||||
);
|
||||
}
|
||||
|
||||
function useComboboxAnchor() {
|
||||
return React.useRef<HTMLDivElement | null>(null);
|
||||
}
|
||||
|
||||
type ComboboxOption<TValue extends string | number = string> = {
|
||||
value: TValue;
|
||||
label: React.ReactNode;
|
||||
@@ -442,13 +438,11 @@ export {
|
||||
ComboboxItem,
|
||||
ComboboxLabel,
|
||||
ComboboxList,
|
||||
type ComboboxOption,
|
||||
type ComboboxProps,
|
||||
ComboboxRoot,
|
||||
ComboboxSeparator,
|
||||
ComboboxTrigger,
|
||||
ComboboxValue,
|
||||
type ComboboxOption,
|
||||
type MultiComboboxProps,
|
||||
type SingleComboboxProps,
|
||||
useComboboxAnchor,
|
||||
};
|
||||
|
||||
@@ -66,4 +66,4 @@ function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
|
||||
);
|
||||
}
|
||||
|
||||
export { Tabs, TabsContent, TabsList, TabsTrigger, tabsListVariants };
|
||||
export { Tabs, TabsContent, TabsList, TabsTrigger };
|
||||
|
||||
@@ -33,4 +33,4 @@ function Toggle({
|
||||
return <TogglePrimitive data-slot="toggle" className={cn(toggleVariants({ variant, size, className }))} {...props} />;
|
||||
}
|
||||
|
||||
export { Toggle, toggleVariants };
|
||||
export { Toggle };
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { apiKeyClient } from "@better-auth/api-key/client";
|
||||
import { dashClient } from "@better-auth/infra/client";
|
||||
import { oauthProviderClient } from "@better-auth/oauth-provider/client";
|
||||
import { oauthProviderResourceClient } from "@better-auth/oauth-provider/resource-client";
|
||||
import { genericOAuthClient, inferAdditionalFields, twoFactorClient, usernameClient } from "better-auth/client/plugins";
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
|
||||
@@ -20,6 +22,8 @@ const getAuthClient = () => {
|
||||
},
|
||||
}),
|
||||
genericOAuthClient(),
|
||||
oauthProviderClient(),
|
||||
oauthProviderResourceClient(),
|
||||
inferAdditionalFields<typeof auth>(),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { GenericOAuthConfig } from "better-auth/plugins";
|
||||
import type { JWTPayload } from "jose";
|
||||
|
||||
import { apiKey } from "@better-auth/api-key";
|
||||
import { drizzleAdapter } from "@better-auth/drizzle-adapter";
|
||||
import { dash } from "@better-auth/infra";
|
||||
import { oauthProvider } from "@better-auth/oauth-provider";
|
||||
import { BetterAuthError, betterAuth } from "better-auth";
|
||||
import { openAPI } from "better-auth/plugins";
|
||||
import { verifyAccessToken } from "better-auth/oauth2";
|
||||
import { jwt, openAPI } from "better-auth/plugins";
|
||||
import { genericOAuth } from "better-auth/plugins/generic-oauth";
|
||||
import { twoFactor } from "better-auth/plugins/two-factor";
|
||||
import { username } from "better-auth/plugins/username";
|
||||
@@ -18,6 +21,24 @@ import { schema } from "../drizzle";
|
||||
import { db } from "../drizzle/client";
|
||||
import { sendEmail } from "../email/service";
|
||||
|
||||
export const authBaseUrl = process.env.BETTER_AUTH_URL ?? env.APP_URL;
|
||||
|
||||
function getOAuthAudiences(): string[] {
|
||||
const base = authBaseUrl.replace(/\/$/, "");
|
||||
|
||||
return [base, `${base}/`, `${base}/mcp`, `${base}/mcp/`];
|
||||
}
|
||||
|
||||
export async function verifyOAuthToken(token: string): Promise<JWTPayload> {
|
||||
return await verifyAccessToken(token, {
|
||||
jwksUrl: `${authBaseUrl}/api/auth/jwks`,
|
||||
verifyOptions: {
|
||||
issuer: `${authBaseUrl}/api/auth`,
|
||||
audience: getOAuthAudiences(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function isCustomOAuthProviderEnabled() {
|
||||
const hasDiscovery = Boolean(env.OAUTH_DISCOVERY_URL);
|
||||
const hasManual =
|
||||
@@ -86,7 +107,7 @@ const getAuthConfig = () => {
|
||||
|
||||
return betterAuth({
|
||||
appName: "Reactive Resume",
|
||||
baseURL: process.env.BETTER_AUTH_URL ?? env.APP_URL,
|
||||
baseURL: authBaseUrl,
|
||||
secret: process.env.BETTER_AUTH_SECRET ?? env.AUTH_SECRET,
|
||||
|
||||
database: drizzleAdapter(db, { schema, provider: "pg" }),
|
||||
@@ -231,11 +252,20 @@ const getAuthConfig = () => {
|
||||
},
|
||||
|
||||
plugins: [
|
||||
jwt(),
|
||||
openAPI(),
|
||||
genericOAuth({ config: authConfigs }),
|
||||
twoFactor({ issuer: "Reactive Resume" }),
|
||||
apiKey({ enableSessionForAPIKeys: true, rateLimit: { enabled: false } }),
|
||||
dash({ apiKey: env.BETTER_AUTH_API_KEY, activityTracking: { enabled: true } }),
|
||||
oauthProvider({
|
||||
loginPage: "/auth/oauth",
|
||||
consentPage: "/auth/oauth",
|
||||
validAudiences: getOAuthAudiences(),
|
||||
allowDynamicClientRegistration: true,
|
||||
allowUnauthenticatedClientRegistration: true,
|
||||
silenceWarnings: { oauthAuthServerConfig: true },
|
||||
}),
|
||||
username({
|
||||
minUsernameLength: 3,
|
||||
maxUsernameLength: 64,
|
||||
|
||||
@@ -269,8 +269,155 @@ export const apikey = pg.pgTable(
|
||||
],
|
||||
);
|
||||
|
||||
export const jwks = pg.pgTable("jwks", {
|
||||
id: pg
|
||||
.uuid("id")
|
||||
.notNull()
|
||||
.primaryKey()
|
||||
.$defaultFn(() => generateId()),
|
||||
publicKey: pg.text("public_key").notNull(),
|
||||
privateKey: pg.text("private_key").notNull(),
|
||||
createdAt: pg.timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
expiresAt: pg.timestamp("expires_at", { withTimezone: true }),
|
||||
});
|
||||
|
||||
export const oauthClient = pg.pgTable(
|
||||
"oauth_client",
|
||||
{
|
||||
id: pg
|
||||
.uuid("id")
|
||||
.notNull()
|
||||
.primaryKey()
|
||||
.$defaultFn(() => generateId()),
|
||||
clientId: pg.text("client_id").notNull().unique(),
|
||||
clientSecret: pg.text("client_secret"),
|
||||
disabled: pg.boolean("disabled").default(false),
|
||||
skipConsent: pg.boolean("skip_consent"),
|
||||
enableEndSession: pg.boolean("enable_end_session"),
|
||||
subjectType: pg.text("subject_type"),
|
||||
scopes: pg.text("scopes").array(),
|
||||
userId: pg.uuid("user_id").references(() => user.id, { onDelete: "cascade" }),
|
||||
createdAt: pg.timestamp("created_at", { withTimezone: true }).defaultNow(),
|
||||
updatedAt: pg
|
||||
.timestamp("updated_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date()),
|
||||
name: pg.text("name"),
|
||||
uri: pg.text("uri"),
|
||||
icon: pg.text("icon"),
|
||||
contacts: pg.text("contacts").array(),
|
||||
tos: pg.text("tos"),
|
||||
policy: pg.text("policy"),
|
||||
softwareId: pg.text("software_id"),
|
||||
softwareVersion: pg.text("software_version"),
|
||||
softwareStatement: pg.text("software_statement"),
|
||||
redirectUris: pg.text("redirect_uris").array().notNull(),
|
||||
postLogoutRedirectUris: pg.text("post_logout_redirect_uris").array(),
|
||||
tokenEndpointAuthMethod: pg.text("token_endpoint_auth_method"),
|
||||
grantTypes: pg.text("grant_types").array(),
|
||||
responseTypes: pg.text("response_types").array(),
|
||||
public: pg.boolean("public"),
|
||||
type: pg.text("type"),
|
||||
requirePKCE: pg.boolean("require_pkce"),
|
||||
referenceId: pg.text("reference_id"),
|
||||
metadata: pg.jsonb("metadata"),
|
||||
},
|
||||
(t) => [pg.index().on(t.clientId)],
|
||||
);
|
||||
|
||||
export const oauthRefreshToken = pg.pgTable(
|
||||
"oauth_refresh_token",
|
||||
{
|
||||
id: pg
|
||||
.uuid("id")
|
||||
.notNull()
|
||||
.primaryKey()
|
||||
.$defaultFn(() => generateId()),
|
||||
token: pg.text("token").notNull(),
|
||||
clientId: pg
|
||||
.text("client_id")
|
||||
.notNull()
|
||||
.references(() => oauthClient.clientId, { onDelete: "cascade" }),
|
||||
sessionId: pg.uuid("session_id").references(() => session.id, { onDelete: "set null" }),
|
||||
userId: pg
|
||||
.uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
referenceId: pg.text("reference_id"),
|
||||
expiresAt: pg.timestamp("expires_at", { withTimezone: true }),
|
||||
createdAt: pg.timestamp("created_at", { withTimezone: true }).defaultNow(),
|
||||
revoked: pg.timestamp("revoked", { withTimezone: true }),
|
||||
authTime: pg.timestamp("auth_time", { withTimezone: true }),
|
||||
scopes: pg.text("scopes").array().notNull(),
|
||||
},
|
||||
(t) => [pg.index().on(t.token)],
|
||||
);
|
||||
|
||||
export const oauthAccessToken = pg.pgTable(
|
||||
"oauth_access_token",
|
||||
{
|
||||
id: pg
|
||||
.uuid("id")
|
||||
.notNull()
|
||||
.primaryKey()
|
||||
.$defaultFn(() => generateId()),
|
||||
token: pg.text("token").notNull().unique(),
|
||||
clientId: pg
|
||||
.text("client_id")
|
||||
.notNull()
|
||||
.references(() => oauthClient.clientId, { onDelete: "cascade" }),
|
||||
sessionId: pg.uuid("session_id").references(() => session.id, { onDelete: "set null" }),
|
||||
userId: pg.uuid("user_id").references(() => user.id, { onDelete: "cascade" }),
|
||||
referenceId: pg.text("reference_id"),
|
||||
refreshId: pg.uuid("refresh_id").references(() => oauthRefreshToken.id, { onDelete: "cascade" }),
|
||||
expiresAt: pg.timestamp("expires_at", { withTimezone: true }),
|
||||
createdAt: pg.timestamp("created_at", { withTimezone: true }).defaultNow(),
|
||||
scopes: pg.text("scopes").array().notNull(),
|
||||
},
|
||||
(t) => [pg.index().on(t.token)],
|
||||
);
|
||||
|
||||
export const oauthConsent = pg.pgTable(
|
||||
"oauth_consent",
|
||||
{
|
||||
id: pg
|
||||
.uuid("id")
|
||||
.notNull()
|
||||
.primaryKey()
|
||||
.$defaultFn(() => generateId()),
|
||||
clientId: pg
|
||||
.text("client_id")
|
||||
.notNull()
|
||||
.references(() => oauthClient.clientId, { onDelete: "cascade" }),
|
||||
userId: pg.uuid("user_id").references(() => user.id, { onDelete: "cascade" }),
|
||||
referenceId: pg.text("reference_id"),
|
||||
scopes: pg.text("scopes").array().notNull(),
|
||||
createdAt: pg.timestamp("created_at", { withTimezone: true }).defaultNow(),
|
||||
updatedAt: pg
|
||||
.timestamp("updated_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date()),
|
||||
},
|
||||
(t) => [pg.index().on(t.userId, t.clientId)],
|
||||
);
|
||||
|
||||
export const relations = defineRelations(
|
||||
{ user, session, account, verification, twoFactor, passkey, resume, resumeStatistics, apikey },
|
||||
{
|
||||
user,
|
||||
session,
|
||||
account,
|
||||
verification,
|
||||
twoFactor,
|
||||
passkey,
|
||||
resume,
|
||||
resumeStatistics,
|
||||
apikey,
|
||||
jwks,
|
||||
oauthClient,
|
||||
oauthRefreshToken,
|
||||
oauthAccessToken,
|
||||
oauthConsent,
|
||||
},
|
||||
(r) => ({
|
||||
user: {
|
||||
sessions: r.many.session(),
|
||||
@@ -279,12 +426,24 @@ export const relations = defineRelations(
|
||||
passkeys: r.many.passkey(),
|
||||
resumes: r.many.resume(),
|
||||
apiKeys: r.many.apikey(),
|
||||
oauthClients: r.many.oauthClient(),
|
||||
oauthRefreshTokens: r.many.oauthRefreshToken(),
|
||||
oauthAccessTokens: r.many.oauthAccessToken(),
|
||||
oauthConsents: r.many.oauthConsent(),
|
||||
},
|
||||
session: {
|
||||
user: r.one.user({
|
||||
from: r.session.userId,
|
||||
to: r.user.id,
|
||||
}),
|
||||
oauthRefreshTokens: r.many.oauthRefreshToken({
|
||||
from: r.session.id,
|
||||
to: r.oauthRefreshToken.sessionId,
|
||||
}),
|
||||
oauthAccessTokens: r.many.oauthAccessToken({
|
||||
from: r.session.id,
|
||||
to: r.oauthAccessToken.sessionId,
|
||||
}),
|
||||
},
|
||||
account: {
|
||||
user: r.one.user({
|
||||
@@ -326,5 +485,53 @@ export const relations = defineRelations(
|
||||
to: r.user.id,
|
||||
}),
|
||||
},
|
||||
oauthClient: {
|
||||
user: r.one.user({
|
||||
from: r.oauthClient.userId,
|
||||
to: r.user.id,
|
||||
}),
|
||||
oauthRefreshTokens: r.many.oauthRefreshToken({
|
||||
from: r.oauthClient.clientId,
|
||||
to: r.oauthRefreshToken.clientId,
|
||||
}),
|
||||
oauthAccessTokens: r.many.oauthAccessToken({
|
||||
from: r.oauthClient.clientId,
|
||||
to: r.oauthAccessToken.clientId,
|
||||
}),
|
||||
oauthConsents: r.many.oauthConsent({
|
||||
from: r.oauthClient.clientId,
|
||||
to: r.oauthConsent.clientId,
|
||||
}),
|
||||
},
|
||||
oauthRefreshToken: {
|
||||
user: r.one.user({
|
||||
from: r.oauthRefreshToken.userId,
|
||||
to: r.user.id,
|
||||
}),
|
||||
session: r.one.session({
|
||||
from: r.oauthRefreshToken.sessionId,
|
||||
to: r.session.id,
|
||||
}),
|
||||
},
|
||||
oauthAccessToken: {
|
||||
user: r.one.user({
|
||||
from: r.oauthAccessToken.userId,
|
||||
to: r.user.id,
|
||||
}),
|
||||
session: r.one.session({
|
||||
from: r.oauthAccessToken.sessionId,
|
||||
to: r.session.id,
|
||||
}),
|
||||
refreshToken: r.one.oauthRefreshToken({
|
||||
from: r.oauthAccessToken.refreshId,
|
||||
to: r.oauthRefreshToken.id,
|
||||
}),
|
||||
},
|
||||
oauthConsent: {
|
||||
user: r.one.user({
|
||||
from: r.oauthConsent.userId,
|
||||
to: r.user.id,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createJobSearchProvider } from "./factory";
|
||||
import { JSearchProvider } from "./providers/jsearch";
|
||||
|
||||
describe("createJobSearchProvider", () => {
|
||||
it("should return a JSearchProvider instance", () => {
|
||||
const provider = createJobSearchProvider("test-api-key");
|
||||
|
||||
expect(provider).toBeInstanceOf(JSearchProvider);
|
||||
});
|
||||
|
||||
it("should create provider with correct API key", async () => {
|
||||
const provider = createJobSearchProvider("my-secret-key");
|
||||
|
||||
// Provider should have the API key (indirectly verified by checking it's a JSearchProvider)
|
||||
expect(provider).toBeInstanceOf(JSearchProvider);
|
||||
});
|
||||
|
||||
it("should have all required JobSearchProvider methods", () => {
|
||||
const provider = createJobSearchProvider("test-api-key");
|
||||
|
||||
expect(provider).toHaveProperty("search");
|
||||
expect(provider).toHaveProperty("testConnection");
|
||||
|
||||
expect(typeof provider.search).toBe("function");
|
||||
expect(typeof provider.testConnection).toBe("function");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
|
||||
|
||||
import { JSearchProvider } from "./jsearch";
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { Locale } from "@/utils/locale";
|
||||
|
||||
import { env } from "@/utils/env";
|
||||
|
||||
import { auth } from "../auth/config";
|
||||
import { auth, verifyOAuthToken } from "../auth/config";
|
||||
import { db } from "../drizzle/client";
|
||||
import { user } from "../drizzle/schema";
|
||||
|
||||
@@ -16,6 +16,21 @@ interface ORPCContext {
|
||||
reqHeaders?: Headers;
|
||||
}
|
||||
|
||||
async function getUserFromBearerToken(headers: Headers): Promise<User | null> {
|
||||
try {
|
||||
const authHeader = headers.get("authorization");
|
||||
if (!authHeader?.startsWith("Bearer ")) return null;
|
||||
|
||||
const payload = await verifyOAuthToken(authHeader.slice(7));
|
||||
if (!payload?.sub) return null;
|
||||
|
||||
const [userResult] = await db.select().from(user).where(eq(user.id, payload.sub)).limit(1);
|
||||
return userResult ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getUserFromHeaders(headers: Headers): Promise<User | null> {
|
||||
try {
|
||||
const result = await auth.api.getSession({ headers });
|
||||
@@ -47,7 +62,9 @@ export const publicProcedure = base.use(async ({ context, next }) => {
|
||||
const headers = context.reqHeaders ?? new Headers();
|
||||
const apiKey = headers.get("x-api-key");
|
||||
|
||||
const user = apiKey ? await getUserFromApiKey(apiKey) : await getUserFromHeaders(headers);
|
||||
const user = apiKey
|
||||
? await getUserFromApiKey(apiKey)
|
||||
: ((await getUserFromBearerToken(headers)) ?? (await getUserFromHeaders(headers)));
|
||||
|
||||
return next({
|
||||
context: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
|
||||
|
||||
import type { JobResult, PostFilterOptions, RapidApiQuota } from "@/schema/jobs";
|
||||
|
||||
|
||||
@@ -426,6 +426,14 @@ export const resumeService = {
|
||||
},
|
||||
|
||||
delete: async (input: { id: string; userId: string }) => {
|
||||
const [resume] = await db
|
||||
.select({ isLocked: schema.resume.isLocked })
|
||||
.from(schema.resume)
|
||||
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
|
||||
|
||||
if (!resume) throw new ORPCError("NOT_FOUND");
|
||||
if (resume.isLocked) throw new ORPCError("RESUME_LOCKED");
|
||||
|
||||
const storageService = getStorageService();
|
||||
|
||||
const deleteResumePromise = db
|
||||
|
||||
@@ -23,9 +23,13 @@ import { Route as AuthVerify2faRouteImport } from "./routes/auth/verify-2fa";
|
||||
import { Route as AuthResumePasswordRouteImport } from "./routes/auth/resume-password";
|
||||
import { Route as AuthResetPasswordRouteImport } from "./routes/auth/reset-password";
|
||||
import { Route as AuthRegisterRouteImport } from "./routes/auth/register";
|
||||
import { Route as AuthOauthRouteImport } from "./routes/auth/oauth";
|
||||
import { Route as AuthLoginRouteImport } from "./routes/auth/login";
|
||||
import { Route as AuthForgotPasswordRouteImport } from "./routes/auth/forgot-password";
|
||||
import { Route as ApiHealthRouteImport } from "./routes/api/health";
|
||||
import { Route as DotwellKnownOpenidConfigurationRouteImport } from "./routes/[.]well-known/openid-configuration";
|
||||
import { Route as DotwellKnownOauthProtectedResourceRouteImport } from "./routes/[.]well-known/oauth-protected-resource";
|
||||
import { Route as DotwellKnownOauthAuthorizationServerRouteImport } from "./routes/[.]well-known/oauth-authorization-server";
|
||||
import { Route as UsernameSlugRouteImport } from "./routes/$username/$slug";
|
||||
import { Route as BuilderResumeIdRouteRouteImport } from "./routes/builder/$resumeId/route";
|
||||
import { Route as DashboardResumesIndexRouteImport } from "./routes/dashboard/resumes/index";
|
||||
@@ -41,6 +45,8 @@ import { Route as DashboardSettingsAiRouteImport } from "./routes/dashboard/sett
|
||||
import { Route as ApiRpcSplatRouteImport } from "./routes/api/rpc.$";
|
||||
import { Route as ApiOpenapiSplatRouteImport } from "./routes/api/openapi.$";
|
||||
import { Route as ApiAuthSplatRouteImport } from "./routes/api/auth.$";
|
||||
import { Route as DotwellKnownOauthProtectedResourceSplatRouteImport } from "./routes/[.]well-known/oauth-protected-resource.$";
|
||||
import { Route as DotwellKnownOauthAuthorizationServerSplatRouteImport } from "./routes/[.]well-known/oauth-authorization-server.$";
|
||||
import { Route as DashboardSettingsAuthenticationIndexRouteImport } from "./routes/dashboard/settings/authentication/index";
|
||||
|
||||
const SchemaDotjsonRoute = SchemaDotjsonRouteImport.update({
|
||||
@@ -112,6 +118,11 @@ const AuthRegisterRoute = AuthRegisterRouteImport.update({
|
||||
path: "/register",
|
||||
getParentRoute: () => AuthRouteRoute,
|
||||
} as any);
|
||||
const AuthOauthRoute = AuthOauthRouteImport.update({
|
||||
id: "/oauth",
|
||||
path: "/oauth",
|
||||
getParentRoute: () => AuthRouteRoute,
|
||||
} as any);
|
||||
const AuthLoginRoute = AuthLoginRouteImport.update({
|
||||
id: "/login",
|
||||
path: "/login",
|
||||
@@ -127,6 +138,24 @@ const ApiHealthRoute = ApiHealthRouteImport.update({
|
||||
path: "/api/health",
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any);
|
||||
const DotwellKnownOpenidConfigurationRoute =
|
||||
DotwellKnownOpenidConfigurationRouteImport.update({
|
||||
id: "/.well-known/openid-configuration",
|
||||
path: "/.well-known/openid-configuration",
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any);
|
||||
const DotwellKnownOauthProtectedResourceRoute =
|
||||
DotwellKnownOauthProtectedResourceRouteImport.update({
|
||||
id: "/.well-known/oauth-protected-resource",
|
||||
path: "/.well-known/oauth-protected-resource",
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any);
|
||||
const DotwellKnownOauthAuthorizationServerRoute =
|
||||
DotwellKnownOauthAuthorizationServerRouteImport.update({
|
||||
id: "/.well-known/oauth-authorization-server",
|
||||
path: "/.well-known/oauth-authorization-server",
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any);
|
||||
const UsernameSlugRoute = UsernameSlugRouteImport.update({
|
||||
id: "/$username/$slug",
|
||||
path: "/$username/$slug",
|
||||
@@ -207,6 +236,18 @@ const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
|
||||
path: "/api/auth/$",
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any);
|
||||
const DotwellKnownOauthProtectedResourceSplatRoute =
|
||||
DotwellKnownOauthProtectedResourceSplatRouteImport.update({
|
||||
id: "/$",
|
||||
path: "/$",
|
||||
getParentRoute: () => DotwellKnownOauthProtectedResourceRoute,
|
||||
} as any);
|
||||
const DotwellKnownOauthAuthorizationServerSplatRoute =
|
||||
DotwellKnownOauthAuthorizationServerSplatRouteImport.update({
|
||||
id: "/$",
|
||||
path: "/$",
|
||||
getParentRoute: () => DotwellKnownOauthAuthorizationServerRoute,
|
||||
} as any);
|
||||
const DashboardSettingsAuthenticationIndexRoute =
|
||||
DashboardSettingsAuthenticationIndexRouteImport.update({
|
||||
id: "/settings/authentication/",
|
||||
@@ -221,9 +262,13 @@ export interface FileRoutesByFullPath {
|
||||
"/schema.json": typeof SchemaDotjsonRoute;
|
||||
"/builder/$resumeId": typeof BuilderResumeIdRouteRouteWithChildren;
|
||||
"/$username/$slug": typeof UsernameSlugRoute;
|
||||
"/.well-known/oauth-authorization-server": typeof DotwellKnownOauthAuthorizationServerRouteWithChildren;
|
||||
"/.well-known/oauth-protected-resource": typeof DotwellKnownOauthProtectedResourceRouteWithChildren;
|
||||
"/.well-known/openid-configuration": typeof DotwellKnownOpenidConfigurationRoute;
|
||||
"/api/health": typeof ApiHealthRoute;
|
||||
"/auth/forgot-password": typeof AuthForgotPasswordRoute;
|
||||
"/auth/login": typeof AuthLoginRoute;
|
||||
"/auth/oauth": typeof AuthOauthRoute;
|
||||
"/auth/register": typeof AuthRegisterRoute;
|
||||
"/auth/reset-password": typeof AuthResetPasswordRoute;
|
||||
"/auth/resume-password": typeof AuthResumePasswordRoute;
|
||||
@@ -233,6 +278,8 @@ export interface FileRoutesByFullPath {
|
||||
"/auth/": typeof AuthIndexRoute;
|
||||
"/dashboard/": typeof DashboardIndexRoute;
|
||||
"/mcp/": typeof McpIndexRoute;
|
||||
"/.well-known/oauth-authorization-server/$": typeof DotwellKnownOauthAuthorizationServerSplatRoute;
|
||||
"/.well-known/oauth-protected-resource/$": typeof DotwellKnownOauthProtectedResourceSplatRoute;
|
||||
"/api/auth/$": typeof ApiAuthSplatRoute;
|
||||
"/api/openapi/$": typeof ApiOpenapiSplatRoute;
|
||||
"/api/rpc/$": typeof ApiRpcSplatRoute;
|
||||
@@ -251,9 +298,13 @@ export interface FileRoutesByFullPath {
|
||||
export interface FileRoutesByTo {
|
||||
"/schema.json": typeof SchemaDotjsonRoute;
|
||||
"/$username/$slug": typeof UsernameSlugRoute;
|
||||
"/.well-known/oauth-authorization-server": typeof DotwellKnownOauthAuthorizationServerRouteWithChildren;
|
||||
"/.well-known/oauth-protected-resource": typeof DotwellKnownOauthProtectedResourceRouteWithChildren;
|
||||
"/.well-known/openid-configuration": typeof DotwellKnownOpenidConfigurationRoute;
|
||||
"/api/health": typeof ApiHealthRoute;
|
||||
"/auth/forgot-password": typeof AuthForgotPasswordRoute;
|
||||
"/auth/login": typeof AuthLoginRoute;
|
||||
"/auth/oauth": typeof AuthOauthRoute;
|
||||
"/auth/register": typeof AuthRegisterRoute;
|
||||
"/auth/reset-password": typeof AuthResetPasswordRoute;
|
||||
"/auth/resume-password": typeof AuthResumePasswordRoute;
|
||||
@@ -264,6 +315,8 @@ export interface FileRoutesByTo {
|
||||
"/auth": typeof AuthIndexRoute;
|
||||
"/dashboard": typeof DashboardIndexRoute;
|
||||
"/mcp": typeof McpIndexRoute;
|
||||
"/.well-known/oauth-authorization-server/$": typeof DotwellKnownOauthAuthorizationServerSplatRoute;
|
||||
"/.well-known/oauth-protected-resource/$": typeof DotwellKnownOauthProtectedResourceSplatRoute;
|
||||
"/api/auth/$": typeof ApiAuthSplatRoute;
|
||||
"/api/openapi/$": typeof ApiOpenapiSplatRoute;
|
||||
"/api/rpc/$": typeof ApiRpcSplatRoute;
|
||||
@@ -287,9 +340,13 @@ export interface FileRoutesById {
|
||||
"/schema.json": typeof SchemaDotjsonRoute;
|
||||
"/builder/$resumeId": typeof BuilderResumeIdRouteRouteWithChildren;
|
||||
"/$username/$slug": typeof UsernameSlugRoute;
|
||||
"/.well-known/oauth-authorization-server": typeof DotwellKnownOauthAuthorizationServerRouteWithChildren;
|
||||
"/.well-known/oauth-protected-resource": typeof DotwellKnownOauthProtectedResourceRouteWithChildren;
|
||||
"/.well-known/openid-configuration": typeof DotwellKnownOpenidConfigurationRoute;
|
||||
"/api/health": typeof ApiHealthRoute;
|
||||
"/auth/forgot-password": typeof AuthForgotPasswordRoute;
|
||||
"/auth/login": typeof AuthLoginRoute;
|
||||
"/auth/oauth": typeof AuthOauthRoute;
|
||||
"/auth/register": typeof AuthRegisterRoute;
|
||||
"/auth/reset-password": typeof AuthResetPasswordRoute;
|
||||
"/auth/resume-password": typeof AuthResumePasswordRoute;
|
||||
@@ -300,6 +357,8 @@ export interface FileRoutesById {
|
||||
"/auth/": typeof AuthIndexRoute;
|
||||
"/dashboard/": typeof DashboardIndexRoute;
|
||||
"/mcp/": typeof McpIndexRoute;
|
||||
"/.well-known/oauth-authorization-server/$": typeof DotwellKnownOauthAuthorizationServerSplatRoute;
|
||||
"/.well-known/oauth-protected-resource/$": typeof DotwellKnownOauthProtectedResourceSplatRoute;
|
||||
"/api/auth/$": typeof ApiAuthSplatRoute;
|
||||
"/api/openapi/$": typeof ApiOpenapiSplatRoute;
|
||||
"/api/rpc/$": typeof ApiRpcSplatRoute;
|
||||
@@ -324,9 +383,13 @@ export interface FileRouteTypes {
|
||||
| "/schema.json"
|
||||
| "/builder/$resumeId"
|
||||
| "/$username/$slug"
|
||||
| "/.well-known/oauth-authorization-server"
|
||||
| "/.well-known/oauth-protected-resource"
|
||||
| "/.well-known/openid-configuration"
|
||||
| "/api/health"
|
||||
| "/auth/forgot-password"
|
||||
| "/auth/login"
|
||||
| "/auth/oauth"
|
||||
| "/auth/register"
|
||||
| "/auth/reset-password"
|
||||
| "/auth/resume-password"
|
||||
@@ -336,6 +399,8 @@ export interface FileRouteTypes {
|
||||
| "/auth/"
|
||||
| "/dashboard/"
|
||||
| "/mcp/"
|
||||
| "/.well-known/oauth-authorization-server/$"
|
||||
| "/.well-known/oauth-protected-resource/$"
|
||||
| "/api/auth/$"
|
||||
| "/api/openapi/$"
|
||||
| "/api/rpc/$"
|
||||
@@ -354,9 +419,13 @@ export interface FileRouteTypes {
|
||||
to:
|
||||
| "/schema.json"
|
||||
| "/$username/$slug"
|
||||
| "/.well-known/oauth-authorization-server"
|
||||
| "/.well-known/oauth-protected-resource"
|
||||
| "/.well-known/openid-configuration"
|
||||
| "/api/health"
|
||||
| "/auth/forgot-password"
|
||||
| "/auth/login"
|
||||
| "/auth/oauth"
|
||||
| "/auth/register"
|
||||
| "/auth/reset-password"
|
||||
| "/auth/resume-password"
|
||||
@@ -367,6 +436,8 @@ export interface FileRouteTypes {
|
||||
| "/auth"
|
||||
| "/dashboard"
|
||||
| "/mcp"
|
||||
| "/.well-known/oauth-authorization-server/$"
|
||||
| "/.well-known/oauth-protected-resource/$"
|
||||
| "/api/auth/$"
|
||||
| "/api/openapi/$"
|
||||
| "/api/rpc/$"
|
||||
@@ -389,9 +460,13 @@ export interface FileRouteTypes {
|
||||
| "/schema.json"
|
||||
| "/builder/$resumeId"
|
||||
| "/$username/$slug"
|
||||
| "/.well-known/oauth-authorization-server"
|
||||
| "/.well-known/oauth-protected-resource"
|
||||
| "/.well-known/openid-configuration"
|
||||
| "/api/health"
|
||||
| "/auth/forgot-password"
|
||||
| "/auth/login"
|
||||
| "/auth/oauth"
|
||||
| "/auth/register"
|
||||
| "/auth/reset-password"
|
||||
| "/auth/resume-password"
|
||||
@@ -402,6 +477,8 @@ export interface FileRouteTypes {
|
||||
| "/auth/"
|
||||
| "/dashboard/"
|
||||
| "/mcp/"
|
||||
| "/.well-known/oauth-authorization-server/$"
|
||||
| "/.well-known/oauth-protected-resource/$"
|
||||
| "/api/auth/$"
|
||||
| "/api/openapi/$"
|
||||
| "/api/rpc/$"
|
||||
@@ -425,6 +502,9 @@ export interface RootRouteChildren {
|
||||
SchemaDotjsonRoute: typeof SchemaDotjsonRoute;
|
||||
BuilderResumeIdRouteRoute: typeof BuilderResumeIdRouteRouteWithChildren;
|
||||
UsernameSlugRoute: typeof UsernameSlugRoute;
|
||||
DotwellKnownOauthAuthorizationServerRoute: typeof DotwellKnownOauthAuthorizationServerRouteWithChildren;
|
||||
DotwellKnownOauthProtectedResourceRoute: typeof DotwellKnownOauthProtectedResourceRouteWithChildren;
|
||||
DotwellKnownOpenidConfigurationRoute: typeof DotwellKnownOpenidConfigurationRoute;
|
||||
ApiHealthRoute: typeof ApiHealthRoute;
|
||||
PrinterResumeIdRoute: typeof PrinterResumeIdRoute;
|
||||
McpIndexRoute: typeof McpIndexRoute;
|
||||
@@ -534,6 +614,13 @@ declare module "@tanstack/react-router" {
|
||||
preLoaderRoute: typeof AuthRegisterRouteImport;
|
||||
parentRoute: typeof AuthRouteRoute;
|
||||
};
|
||||
"/auth/oauth": {
|
||||
id: "/auth/oauth";
|
||||
path: "/oauth";
|
||||
fullPath: "/auth/oauth";
|
||||
preLoaderRoute: typeof AuthOauthRouteImport;
|
||||
parentRoute: typeof AuthRouteRoute;
|
||||
};
|
||||
"/auth/login": {
|
||||
id: "/auth/login";
|
||||
path: "/login";
|
||||
@@ -555,6 +642,27 @@ declare module "@tanstack/react-router" {
|
||||
preLoaderRoute: typeof ApiHealthRouteImport;
|
||||
parentRoute: typeof rootRouteImport;
|
||||
};
|
||||
"/.well-known/openid-configuration": {
|
||||
id: "/.well-known/openid-configuration";
|
||||
path: "/.well-known/openid-configuration";
|
||||
fullPath: "/.well-known/openid-configuration";
|
||||
preLoaderRoute: typeof DotwellKnownOpenidConfigurationRouteImport;
|
||||
parentRoute: typeof rootRouteImport;
|
||||
};
|
||||
"/.well-known/oauth-protected-resource": {
|
||||
id: "/.well-known/oauth-protected-resource";
|
||||
path: "/.well-known/oauth-protected-resource";
|
||||
fullPath: "/.well-known/oauth-protected-resource";
|
||||
preLoaderRoute: typeof DotwellKnownOauthProtectedResourceRouteImport;
|
||||
parentRoute: typeof rootRouteImport;
|
||||
};
|
||||
"/.well-known/oauth-authorization-server": {
|
||||
id: "/.well-known/oauth-authorization-server";
|
||||
path: "/.well-known/oauth-authorization-server";
|
||||
fullPath: "/.well-known/oauth-authorization-server";
|
||||
preLoaderRoute: typeof DotwellKnownOauthAuthorizationServerRouteImport;
|
||||
parentRoute: typeof rootRouteImport;
|
||||
};
|
||||
"/$username/$slug": {
|
||||
id: "/$username/$slug";
|
||||
path: "/$username/$slug";
|
||||
@@ -660,6 +768,20 @@ declare module "@tanstack/react-router" {
|
||||
preLoaderRoute: typeof ApiAuthSplatRouteImport;
|
||||
parentRoute: typeof rootRouteImport;
|
||||
};
|
||||
"/.well-known/oauth-protected-resource/$": {
|
||||
id: "/.well-known/oauth-protected-resource/$";
|
||||
path: "/$";
|
||||
fullPath: "/.well-known/oauth-protected-resource/$";
|
||||
preLoaderRoute: typeof DotwellKnownOauthProtectedResourceSplatRouteImport;
|
||||
parentRoute: typeof DotwellKnownOauthProtectedResourceRoute;
|
||||
};
|
||||
"/.well-known/oauth-authorization-server/$": {
|
||||
id: "/.well-known/oauth-authorization-server/$";
|
||||
path: "/$";
|
||||
fullPath: "/.well-known/oauth-authorization-server/$";
|
||||
preLoaderRoute: typeof DotwellKnownOauthAuthorizationServerSplatRouteImport;
|
||||
parentRoute: typeof DotwellKnownOauthAuthorizationServerRoute;
|
||||
};
|
||||
"/dashboard/settings/authentication/": {
|
||||
id: "/dashboard/settings/authentication/";
|
||||
path: "/settings/authentication";
|
||||
@@ -685,6 +807,7 @@ const HomeRouteRouteWithChildren = HomeRouteRoute._addFileChildren(
|
||||
interface AuthRouteRouteChildren {
|
||||
AuthForgotPasswordRoute: typeof AuthForgotPasswordRoute;
|
||||
AuthLoginRoute: typeof AuthLoginRoute;
|
||||
AuthOauthRoute: typeof AuthOauthRoute;
|
||||
AuthRegisterRoute: typeof AuthRegisterRoute;
|
||||
AuthResetPasswordRoute: typeof AuthResetPasswordRoute;
|
||||
AuthResumePasswordRoute: typeof AuthResumePasswordRoute;
|
||||
@@ -696,6 +819,7 @@ interface AuthRouteRouteChildren {
|
||||
const AuthRouteRouteChildren: AuthRouteRouteChildren = {
|
||||
AuthForgotPasswordRoute: AuthForgotPasswordRoute,
|
||||
AuthLoginRoute: AuthLoginRoute,
|
||||
AuthOauthRoute: AuthOauthRoute,
|
||||
AuthRegisterRoute: AuthRegisterRoute,
|
||||
AuthResetPasswordRoute: AuthResetPasswordRoute,
|
||||
AuthResumePasswordRoute: AuthResumePasswordRoute,
|
||||
@@ -750,6 +874,36 @@ const BuilderResumeIdRouteRouteChildren: BuilderResumeIdRouteRouteChildren = {
|
||||
const BuilderResumeIdRouteRouteWithChildren =
|
||||
BuilderResumeIdRouteRoute._addFileChildren(BuilderResumeIdRouteRouteChildren);
|
||||
|
||||
interface DotwellKnownOauthAuthorizationServerRouteChildren {
|
||||
DotwellKnownOauthAuthorizationServerSplatRoute: typeof DotwellKnownOauthAuthorizationServerSplatRoute;
|
||||
}
|
||||
|
||||
const DotwellKnownOauthAuthorizationServerRouteChildren: DotwellKnownOauthAuthorizationServerRouteChildren =
|
||||
{
|
||||
DotwellKnownOauthAuthorizationServerSplatRoute:
|
||||
DotwellKnownOauthAuthorizationServerSplatRoute,
|
||||
};
|
||||
|
||||
const DotwellKnownOauthAuthorizationServerRouteWithChildren =
|
||||
DotwellKnownOauthAuthorizationServerRoute._addFileChildren(
|
||||
DotwellKnownOauthAuthorizationServerRouteChildren,
|
||||
);
|
||||
|
||||
interface DotwellKnownOauthProtectedResourceRouteChildren {
|
||||
DotwellKnownOauthProtectedResourceSplatRoute: typeof DotwellKnownOauthProtectedResourceSplatRoute;
|
||||
}
|
||||
|
||||
const DotwellKnownOauthProtectedResourceRouteChildren: DotwellKnownOauthProtectedResourceRouteChildren =
|
||||
{
|
||||
DotwellKnownOauthProtectedResourceSplatRoute:
|
||||
DotwellKnownOauthProtectedResourceSplatRoute,
|
||||
};
|
||||
|
||||
const DotwellKnownOauthProtectedResourceRouteWithChildren =
|
||||
DotwellKnownOauthProtectedResourceRoute._addFileChildren(
|
||||
DotwellKnownOauthProtectedResourceRouteChildren,
|
||||
);
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
HomeRouteRoute: HomeRouteRouteWithChildren,
|
||||
AuthRouteRoute: AuthRouteRouteWithChildren,
|
||||
@@ -757,6 +911,11 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
SchemaDotjsonRoute: SchemaDotjsonRoute,
|
||||
BuilderResumeIdRouteRoute: BuilderResumeIdRouteRouteWithChildren,
|
||||
UsernameSlugRoute: UsernameSlugRoute,
|
||||
DotwellKnownOauthAuthorizationServerRoute:
|
||||
DotwellKnownOauthAuthorizationServerRouteWithChildren,
|
||||
DotwellKnownOauthProtectedResourceRoute:
|
||||
DotwellKnownOauthProtectedResourceRouteWithChildren,
|
||||
DotwellKnownOpenidConfigurationRoute: DotwellKnownOpenidConfigurationRoute,
|
||||
ApiHealthRoute: ApiHealthRoute,
|
||||
PrinterResumeIdRoute: PrinterResumeIdRoute,
|
||||
McpIndexRoute: McpIndexRoute,
|
||||
|
||||
@@ -21,9 +21,6 @@ type LoaderData = Omit<RouterOutput["resume"]["getBySlug"], "data"> & { data: Re
|
||||
export const Route = createFileRoute("/$username/$slug")({
|
||||
component: RouteComponent,
|
||||
loader: async ({ context, params: { username, slug } }) => {
|
||||
// Ignore .well-known requests
|
||||
if (username === ".well-known") throw notFound();
|
||||
|
||||
const resume = await context.queryClient.ensureQueryData(
|
||||
orpc.resume.getBySlug.queryOptions({ input: { username, slug } }),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { oauthProviderAuthServerMetadata } from "@better-auth/oauth-provider";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
import { auth } from "@/integrations/auth/config";
|
||||
|
||||
const handler = oauthProviderAuthServerMetadata(auth);
|
||||
|
||||
export const Route = createFileRoute("/.well-known/oauth-authorization-server/$")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: ({ request }) => handler(request),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { oauthProviderAuthServerMetadata } from "@better-auth/oauth-provider";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
import { auth } from "@/integrations/auth/config";
|
||||
|
||||
const handler = oauthProviderAuthServerMetadata(auth);
|
||||
|
||||
export const Route = createFileRoute("/.well-known/oauth-authorization-server")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: ({ request }) => handler(request),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
import { authClient } from "@/integrations/auth/client";
|
||||
import { authBaseUrl } from "@/integrations/auth/config";
|
||||
|
||||
export const Route = createFileRoute("/.well-known/oauth-protected-resource/$")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async () => {
|
||||
const metadata = await authClient.getProtectedResourceMetadata({
|
||||
resource: authBaseUrl,
|
||||
bearer_methods_supported: ["header"],
|
||||
authorization_servers: [authBaseUrl, `${authBaseUrl}/api/auth`],
|
||||
});
|
||||
|
||||
return Response.json(metadata, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": "public, max-age=15, stale-while-revalidate=15, stale-if-error=86400",
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
import { authClient } from "@/integrations/auth/client";
|
||||
import { authBaseUrl } from "@/integrations/auth/config";
|
||||
|
||||
export const Route = createFileRoute("/.well-known/oauth-protected-resource")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async () => {
|
||||
const metadata = await authClient.getProtectedResourceMetadata({
|
||||
resource: authBaseUrl,
|
||||
bearer_methods_supported: ["header"],
|
||||
authorization_servers: [authBaseUrl, `${authBaseUrl}/api/auth`],
|
||||
});
|
||||
|
||||
return Response.json(metadata, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": "public, max-age=15, stale-while-revalidate=15, stale-if-error=86400",
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { oauthProviderOpenIdConfigMetadata } from "@better-auth/oauth-provider";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
import { auth } from "@/integrations/auth/config";
|
||||
|
||||
const handler = oauthProviderOpenIdConfigMetadata(auth);
|
||||
|
||||
export const Route = createFileRoute("/.well-known/openid-configuration")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: ({ request }) => handler(request),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -2,14 +2,89 @@ import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
import { auth } from "@/integrations/auth/config";
|
||||
|
||||
function sanitizeOAuthAuthorizeRequest(request: Request): Request {
|
||||
if (request.method !== "GET") return request;
|
||||
|
||||
const url = new URL(request.url);
|
||||
if (!url.pathname.endsWith("/oauth2/authorize")) return request;
|
||||
|
||||
const sanitizeValue = (value: string) =>
|
||||
value
|
||||
.replace(/[\r\n\t]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
const sanitizeParam = (key: string) => {
|
||||
const value = url.searchParams.get(key);
|
||||
if (!value) return;
|
||||
url.searchParams.set(key, sanitizeValue(value));
|
||||
};
|
||||
|
||||
sanitizeParam("prompt");
|
||||
sanitizeParam("redirect_uri");
|
||||
sanitizeParam("client_id");
|
||||
sanitizeParam("code_challenge");
|
||||
sanitizeParam("code_challenge_method");
|
||||
sanitizeParam("response_type");
|
||||
sanitizeParam("scope");
|
||||
sanitizeParam("state");
|
||||
sanitizeParam("resource");
|
||||
|
||||
const redirectUri = url.searchParams.get("redirect_uri");
|
||||
if (redirectUri && !URL.canParse(redirectUri)) {
|
||||
try {
|
||||
const decodedRedirectUri = decodeURIComponent(redirectUri);
|
||||
if (URL.canParse(decodedRedirectUri)) {
|
||||
url.searchParams.set("redirect_uri", decodedRedirectUri);
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed encoded values and let Better Auth validation handle them.
|
||||
}
|
||||
}
|
||||
|
||||
if (url.toString() === request.url) return request;
|
||||
return new Request(url, request);
|
||||
}
|
||||
|
||||
async function defaultPublicClientRegistration(request: Request): Promise<Request> {
|
||||
if (request.method !== "POST") return request;
|
||||
|
||||
const url = new URL(request.url);
|
||||
if (!url.pathname.endsWith("/oauth2/register")) return request;
|
||||
|
||||
const cloned = request.clone();
|
||||
let body: Record<string, unknown>;
|
||||
|
||||
try {
|
||||
body = await cloned.json();
|
||||
} catch {
|
||||
return request;
|
||||
}
|
||||
|
||||
// Claude.ai sends token_endpoint_auth_method: "client_secret_post" without a
|
||||
// client_secret, causing Better Auth to require authentication for what is
|
||||
// effectively a public client. Force to "none" for unauthenticated registrations.
|
||||
if (!request.headers.get("authorization")) {
|
||||
body.token_endpoint_auth_method = "none";
|
||||
}
|
||||
|
||||
return new Request(url, {
|
||||
method: request.method,
|
||||
headers: request.headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function handler({ request }: { request: Request }) {
|
||||
const sanitizedRequest = sanitizeOAuthAuthorizeRequest(request);
|
||||
const finalRequest = await defaultPublicClientRegistration(sanitizedRequest);
|
||||
|
||||
if (request.method === "GET" && request.url.endsWith("/spec.json")) {
|
||||
const spec = await auth.api.generateOpenAPISchema();
|
||||
|
||||
return Response.json(spec);
|
||||
}
|
||||
|
||||
return auth.handler(request);
|
||||
return auth.handler(finalRequest);
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/api/auth/$")({
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { eq } from "drizzle-orm";
|
||||
import crypto from "node:crypto";
|
||||
|
||||
import { auth, authBaseUrl } from "@/integrations/auth/config";
|
||||
import { db } from "@/integrations/drizzle/client";
|
||||
import { oauthClient, verification } from "@/integrations/drizzle/schema";
|
||||
import { generateId } from "@/utils/string";
|
||||
|
||||
function generateCode() {
|
||||
return crypto.randomBytes(32).toString("base64url");
|
||||
}
|
||||
|
||||
function hashCode(code: string) {
|
||||
return crypto.createHash("sha256").update(code).digest("base64url");
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/auth/oauth")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }) => {
|
||||
const session = await auth.api.getSession({ headers: request.headers });
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (session?.user) {
|
||||
const clientId = url.searchParams.get("client_id");
|
||||
const redirectUri = url.searchParams.get("redirect_uri");
|
||||
const state = url.searchParams.get("state");
|
||||
const scope = url.searchParams.get("scope");
|
||||
const codeChallenge = url.searchParams.get("code_challenge");
|
||||
const codeChallengeMethod = url.searchParams.get("code_challenge_method");
|
||||
|
||||
if (!clientId || !redirectUri) {
|
||||
return Response.json({ error: "missing client_id or redirect_uri" }, { status: 400 });
|
||||
}
|
||||
|
||||
const [client] = await db.select().from(oauthClient).where(eq(oauthClient.clientId, clientId)).limit(1);
|
||||
|
||||
if (!client) {
|
||||
return Response.json({ error: "invalid client" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!client.redirectUris.includes(redirectUri)) {
|
||||
return Response.json({ error: "invalid redirect_uri" }, { status: 400 });
|
||||
}
|
||||
|
||||
const code = generateCode();
|
||||
const hashedCode = hashCode(code);
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(now.getTime() + 600_000); // 10 min
|
||||
|
||||
await db.insert(verification).values({
|
||||
id: generateId(),
|
||||
identifier: hashedCode,
|
||||
value: JSON.stringify({
|
||||
type: "authorization_code",
|
||||
query: {
|
||||
response_type: "code",
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope,
|
||||
state,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: codeChallengeMethod,
|
||||
},
|
||||
userId: session.user.id,
|
||||
sessionId: session.session.id,
|
||||
authTime: new Date(session.session.createdAt).getTime(),
|
||||
}),
|
||||
expiresAt,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
const callbackUrl = new URL(redirectUri);
|
||||
callbackUrl.searchParams.set("code", code);
|
||||
if (state) callbackUrl.searchParams.set("state", state);
|
||||
callbackUrl.searchParams.set("iss", `${authBaseUrl}/api/auth`);
|
||||
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { Location: callbackUrl.toString() },
|
||||
});
|
||||
}
|
||||
|
||||
// Not logged in — redirect to the real login page with a callback
|
||||
const loginUrl = new URL("/auth/login", url.origin);
|
||||
const oauthParams = new URLSearchParams();
|
||||
for (const [key, value] of url.searchParams) {
|
||||
if (!["exp", "sig"].includes(key)) {
|
||||
oauthParams.set(key, value);
|
||||
}
|
||||
}
|
||||
loginUrl.searchParams.set("callbackURL", `/auth/oauth?${oauthParams.toString()}`);
|
||||
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { Location: loginUrl.toString() },
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -21,7 +21,6 @@ import { useControls } from "react-zoom-pan-pinch";
|
||||
import { toast } from "sonner";
|
||||
import { useCopyToClipboard } from "usehooks-ts";
|
||||
|
||||
import { AIChat } from "@/components/ai/chat";
|
||||
import { useTemporalStore } from "@/components/resume/store/resume";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
@@ -138,7 +137,6 @@ export function BuilderDock() {
|
||||
<DockIcon icon={MagnifyingGlassMinusIcon} title={t`Zoom out`} onClick={() => zoomOut(0.1)} />
|
||||
<DockIcon icon={CubeFocusIcon} title={t`Center view`} onClick={() => centerView()} />
|
||||
<div className="mx-1 h-8 w-px bg-border" />
|
||||
<AIChat />
|
||||
<DockIcon icon={LinkSimpleIcon} title={t`Copy URL`} onClick={() => onCopyUrl()} />
|
||||
<DockIcon icon={FileJsIcon} title={t`Download JSON`} onClick={() => onDownloadJSON()} />
|
||||
<DockIcon icon={FileDocIcon} title={t`Download DOCX`} onClick={() => onDownloadDOCX()} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it } from "vite-plus/test";
|
||||
|
||||
import {
|
||||
buildPostFilters,
|
||||
|
||||
+49
-2
@@ -2,6 +2,8 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
import { auth, authBaseUrl, verifyOAuthToken } from "@/integrations/auth/config";
|
||||
|
||||
import { registerPrompts } from "./-helpers/prompts";
|
||||
import { registerResources } from "./-helpers/resources";
|
||||
import { registerTools } from "./-helpers/tools";
|
||||
@@ -35,13 +37,46 @@ function createMcpServer() {
|
||||
return server;
|
||||
}
|
||||
|
||||
class AuthError extends Error {
|
||||
constructor() {
|
||||
super("Unauthorized");
|
||||
}
|
||||
}
|
||||
|
||||
async function authenticateRequest(request: Request): Promise<void> {
|
||||
// Try OAuth Bearer token first (for claude.ai and other MCP OAuth clients)
|
||||
const authHeader = request.headers.get("authorization");
|
||||
|
||||
if (authHeader?.startsWith("Bearer ")) {
|
||||
try {
|
||||
const payload = await verifyOAuthToken(authHeader.slice(7));
|
||||
if (payload?.sub) return;
|
||||
} catch {
|
||||
// Invalid or expired token (e.g. JWKS key mismatch) — fall through to AuthError
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to API key authentication
|
||||
const apiKey = request.headers.get("x-api-key");
|
||||
|
||||
if (apiKey) {
|
||||
try {
|
||||
const result = await auth.api.verifyApiKey({ body: { key: apiKey } });
|
||||
if (result.valid) return;
|
||||
} catch {
|
||||
// Invalid or malformed key — fall through to AuthError
|
||||
}
|
||||
}
|
||||
|
||||
throw new AuthError();
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/mcp/")({
|
||||
server: {
|
||||
handlers: {
|
||||
ANY: async ({ request }) => {
|
||||
try {
|
||||
const apiKey = request.headers.get("x-api-key");
|
||||
if (!apiKey) throw new Error("Unauthorized");
|
||||
await authenticateRequest(request);
|
||||
|
||||
const server = createMcpServer();
|
||||
const transport = new WebStandardStreamableHTTPServerTransport({
|
||||
@@ -52,6 +87,18 @@ export const Route = createFileRoute("/mcp/")({
|
||||
|
||||
return await transport.handleRequest(request);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return Response.json(
|
||||
{ id: null, jsonrpc: "2.0", error: { code: -32603, message: "Unauthorized" } },
|
||||
{
|
||||
status: 401,
|
||||
headers: {
|
||||
"WWW-Authenticate": `Bearer resource_metadata="${authBaseUrl}/.well-known/oauth-protected-resource"`,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
console.error("[MCP]", error);
|
||||
|
||||
return Response.json({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it } from "vite-plus/test";
|
||||
|
||||
import {
|
||||
applyOptionSchema,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vite-plus/test";
|
||||
|
||||
import { parseColorString } from "./color";
|
||||
|
||||
describe("parseColorString", () => {
|
||||
it("should parse a 6-digit hex color", () => {
|
||||
expect(parseColorString("#ff8800")).toEqual({ r: 255, g: 136, b: 0, a: 1 });
|
||||
});
|
||||
|
||||
it("should parse a 3-digit hex color", () => {
|
||||
expect(parseColorString("#f80")).toEqual({ r: 255, g: 136, b: 0, a: 1 });
|
||||
});
|
||||
|
||||
it("should be case-insensitive for hex", () => {
|
||||
expect(parseColorString("#FF8800")).toEqual(parseColorString("#ff8800"));
|
||||
});
|
||||
|
||||
it("should parse rgb()", () => {
|
||||
expect(parseColorString("rgb(10, 20, 30)")).toEqual({ r: 10, g: 20, b: 30, a: 1 });
|
||||
});
|
||||
|
||||
it("should parse rgba() with alpha", () => {
|
||||
expect(parseColorString("rgba(10, 20, 30, 0.5)")).toEqual({ r: 10, g: 20, b: 30, a: 0.5 });
|
||||
});
|
||||
|
||||
it("should handle whitespace around the value", () => {
|
||||
expect(parseColorString(" #000000 ")).toEqual({ r: 0, g: 0, b: 0, a: 1 });
|
||||
});
|
||||
|
||||
it("should return null for invalid input", () => {
|
||||
expect(parseColorString("not-a-color")).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null for empty string", () => {
|
||||
expect(parseColorString("")).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null for incomplete hex", () => {
|
||||
expect(parseColorString("#ff")).toBeNull();
|
||||
});
|
||||
});
|
||||
+6
-14
@@ -1,31 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it } from "vite-plus/test";
|
||||
|
||||
import { generateFilename } from "./file";
|
||||
|
||||
describe("generateFilename", () => {
|
||||
it("should return name with extension", () => {
|
||||
it("should slugify the prefix and append the extension", () => {
|
||||
expect(generateFilename("My Resume", "docx")).toBe("my-resume.docx");
|
||||
});
|
||||
|
||||
it("should return name without extension when none provided", () => {
|
||||
it("should return slugified name without extension when none provided", () => {
|
||||
expect(generateFilename("My Resume")).toBe("my-resume");
|
||||
});
|
||||
|
||||
it("should preserve the exact resume name with special characters", () => {
|
||||
expect(generateFilename("John Doe - CS Base - Program Coordinator", "docx")).toBe(
|
||||
"john-doe-cs-base-program-coordinator.docx",
|
||||
);
|
||||
});
|
||||
|
||||
it("should work with pdf extension", () => {
|
||||
it("should handle special characters in the prefix", () => {
|
||||
expect(generateFilename("John Doe - CS Base - Program Coordinator", "pdf")).toBe(
|
||||
"john-doe-cs-base-program-coordinator.pdf",
|
||||
);
|
||||
});
|
||||
|
||||
it("should work with json extension", () => {
|
||||
expect(generateFilename("John Doe - CS Base - Program Coordinator", "json")).toBe(
|
||||
"john-doe-cs-base-program-coordinator.json",
|
||||
);
|
||||
it("should handle empty prefix", () => {
|
||||
expect(generateFilename("", "pdf")).toBe(".pdf");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it, vi } from "vite-plus/test";
|
||||
|
||||
// Mock @lingui/core/macro since the `msg` macro requires babel transformation
|
||||
vi.mock("@lingui/core/macro", () => ({
|
||||
msg: (strings: TemplateStringsArray) => ({ id: strings[0] }),
|
||||
}));
|
||||
|
||||
// Import after mock setup
|
||||
const { isLocale, isRTL } = await import("./locale");
|
||||
|
||||
describe("isLocale", () => {
|
||||
it("should return true for valid locales", () => {
|
||||
expect(isLocale("en-US")).toBe(true);
|
||||
expect(isLocale("fr-FR")).toBe(true);
|
||||
expect(isLocale("zh-CN")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for invalid locales", () => {
|
||||
expect(isLocale("xx-YY")).toBe(false);
|
||||
expect(isLocale("english")).toBe(false);
|
||||
expect(isLocale("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isRTL", () => {
|
||||
it("should return true for RTL languages", () => {
|
||||
expect(isRTL("ar-SA")).toBe(true);
|
||||
expect(isRTL("he-IL")).toBe(true);
|
||||
expect(isRTL("fa-IR")).toBe(true);
|
||||
expect(isRTL("ur-PK")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for LTR languages", () => {
|
||||
expect(isRTL("en-US")).toBe(false);
|
||||
expect(isRTL("fr-FR")).toBe(false);
|
||||
expect(isRTL("zh-CN")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,5 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { Document } from "docx";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it } from "vite-plus/test";
|
||||
|
||||
import type { ResumeData } from "@/schema/resume/data";
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { ExternalHyperlink, Paragraph, TextRun } from "docx";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it } from "vite-plus/test";
|
||||
|
||||
import { htmlToParagraphs } from "./html-to-docx";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it } from "vite-plus/test";
|
||||
|
||||
import type { ResumeData } from "@/schema/resume/data";
|
||||
import type { TailorOutput } from "@/schema/tailor";
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from "vite-plus/test";
|
||||
|
||||
import { isObject, sanitizeCss, sanitizeHtml } from "./sanitize";
|
||||
|
||||
describe("sanitizeHtml", () => {
|
||||
it("should return empty string for empty input", () => {
|
||||
expect(sanitizeHtml("")).toBe("");
|
||||
});
|
||||
|
||||
it("should allow safe tags", () => {
|
||||
const html = "<p>Hello <strong>World</strong></p>";
|
||||
expect(sanitizeHtml(html)).toBe(html);
|
||||
});
|
||||
|
||||
it("should strip script tags", () => {
|
||||
expect(sanitizeHtml('<script>alert("xss")</script>')).toBe("");
|
||||
});
|
||||
|
||||
it("should strip event handlers", () => {
|
||||
expect(sanitizeHtml('<img onerror="alert(1)" src="x">')).toBe("");
|
||||
});
|
||||
|
||||
it("should allow links with href", () => {
|
||||
const html = '<a href="https://example.com">link</a>';
|
||||
expect(sanitizeHtml(html)).toContain('href="https://example.com"');
|
||||
});
|
||||
|
||||
it("should strip javascript: hrefs", () => {
|
||||
const result = sanitizeHtml('<a href="javascript:alert(1)">click</a>');
|
||||
expect(result).not.toContain("javascript:");
|
||||
});
|
||||
|
||||
it("should allow list elements", () => {
|
||||
const html = "<ul><li>one</li><li>two</li></ul>";
|
||||
expect(sanitizeHtml(html)).toBe(html);
|
||||
});
|
||||
|
||||
it("should allow table elements", () => {
|
||||
const html = "<table><tr><td>cell</td></tr></table>";
|
||||
expect(sanitizeHtml(html)).toContain("<table>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeCss", () => {
|
||||
it("should return empty string for empty input", () => {
|
||||
expect(sanitizeCss("")).toBe("");
|
||||
});
|
||||
|
||||
it("should pass through normal CSS", () => {
|
||||
expect(sanitizeCss("color: red;")).toBe("color: red;");
|
||||
});
|
||||
|
||||
it("should strip javascript: expressions", () => {
|
||||
expect(sanitizeCss("background: javascript:alert(1)")).not.toContain("javascript:");
|
||||
});
|
||||
|
||||
it("should strip expression() calls", () => {
|
||||
expect(sanitizeCss("width: expression(alert(1))")).not.toContain("expression(");
|
||||
});
|
||||
|
||||
it("should strip behavior: property", () => {
|
||||
expect(sanitizeCss("behavior: url(evil.htc)")).not.toContain("behavior:");
|
||||
});
|
||||
|
||||
it("should strip -moz-binding", () => {
|
||||
expect(sanitizeCss("-moz-binding: url(evil.xml)")).not.toContain("-moz-binding:");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isObject", () => {
|
||||
it("should return true for plain objects", () => {
|
||||
expect(isObject({})).toBe(true);
|
||||
expect(isObject({ a: 1 })).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for arrays", () => {
|
||||
expect(isObject([])).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for null", () => {
|
||||
expect(isObject(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for primitives", () => {
|
||||
expect(isObject("string")).toBe(false);
|
||||
expect(isObject(42)).toBe(false);
|
||||
expect(isObject(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vite-plus/test";
|
||||
|
||||
import { getInitials, slugify, stripHtml, toUsername } from "./string";
|
||||
|
||||
describe("slugify", () => {
|
||||
it("should lowercase and hyphenate spaces", () => {
|
||||
expect(slugify("Hello World")).toBe("hello-world");
|
||||
});
|
||||
|
||||
it("should strip special characters", () => {
|
||||
expect(slugify("Résumé & CV!")).toBe("resume-and-cv");
|
||||
});
|
||||
|
||||
it("should not decamelize camelCase strings", () => {
|
||||
expect(slugify("camelCase")).toBe("camelcase");
|
||||
});
|
||||
|
||||
it("should return empty string for empty input", () => {
|
||||
expect(slugify("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getInitials", () => {
|
||||
it("should return up to two uppercase initials", () => {
|
||||
expect(getInitials("John Doe")).toBe("JD");
|
||||
});
|
||||
|
||||
it("should return one initial for a single name", () => {
|
||||
expect(getInitials("Alice")).toBe("A");
|
||||
});
|
||||
|
||||
it("should take only the first two words", () => {
|
||||
expect(getInitials("John Michael Doe")).toBe("JM");
|
||||
});
|
||||
});
|
||||
|
||||
describe("toUsername", () => {
|
||||
it("should lowercase and strip disallowed characters", () => {
|
||||
expect(toUsername("John Doe!")).toBe("johndoe");
|
||||
});
|
||||
|
||||
it("should keep dots, hyphens, and underscores", () => {
|
||||
expect(toUsername("john.doe-name_ok")).toBe("john.doe-name_ok");
|
||||
});
|
||||
|
||||
it("should trim whitespace", () => {
|
||||
expect(toUsername(" alice ")).toBe("alice");
|
||||
});
|
||||
|
||||
it("should truncate to 64 characters", () => {
|
||||
const long = "a".repeat(100);
|
||||
expect(toUsername(long)).toHaveLength(64);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripHtml", () => {
|
||||
it("should remove HTML tags and trim", () => {
|
||||
expect(stripHtml("<p>Hello <b>World</b></p>")).toBe("Hello World");
|
||||
});
|
||||
|
||||
it("should return empty string for undefined", () => {
|
||||
expect(stripHtml(undefined)).toBe("");
|
||||
});
|
||||
|
||||
it("should return empty string for empty string", () => {
|
||||
expect(stripHtml("")).toBe("");
|
||||
});
|
||||
|
||||
it("should handle nested tags", () => {
|
||||
expect(stripHtml("<div><ul><li>item</li></ul></div>")).toBe("item");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user