Some checks failed
Test / frontend-tests (push) Waiting to run
Test / wiki-sync (push) Waiting to run
Test / rust-tests (push) Waiting to run
Test / frontend-typecheck (push) Waiting to run
Auto Tag / auto-tag (push) Successful in 4s
Test / rust-fmt-check (push) Failing after 2m12s
Test / rust-clippy (push) Has been cancelled
Release / build-windows-amd64 (push) Has been cancelled
Release / build-macos-arm64 (push) Has been cancelled
Release / build-linux-arm64 (push) Has been cancelled
Release / build-linux-amd64 (push) Has been cancelled
Implement three authentication methods for Confluence, ServiceNow, and Azure DevOps: 1. **OAuth2** - Traditional OAuth flow for enterprise SSO environments 2. **Embedded Browser** - Webview-based login that captures session cookies/tokens - Solves VPN constraints: users authenticate off-VPN via web UI - Extracted credentials work on-VPN for API calls - Based on confluence-publisher agent pattern 3. **Manual Token** - Direct API token/PAT input as fallback **Changes:** - Add webview_auth.rs module for embedded browser authentication - Implement authenticate_with_webview and extract_cookies_from_webview commands - Implement save_manual_token command with validation - Add AuthMethod enum to support all three modes - Add RadioGroup UI component for mode selection - Complete rewrite of Integrations settings page with mode-specific UI - Add secondary button variant for UI consistency **VPN-friendly design:** Users can authenticate via webview when off-VPN (web UI accessible), then use extracted cookies for API calls when on-VPN (API requires VPN). Addresses enterprise SSO limitations where OAuth app registration is blocked. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
397 lines
13 KiB
TypeScript
397 lines
13 KiB
TypeScript
import React from "react";
|
|
import { cva, type VariantProps } from "class-variance-authority";
|
|
import { clsx, type ClassValue } from "clsx";
|
|
|
|
function cn(...inputs: ClassValue[]) {
|
|
return clsx(inputs);
|
|
}
|
|
|
|
// ─── Button ──────────────────────────────────────────────────────────────────
|
|
|
|
const buttonVariants = cva(
|
|
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
|
{
|
|
variants: {
|
|
variant: {
|
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
|
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
|
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
|
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
|
link: "text-primary underline-offset-4 hover:underline",
|
|
},
|
|
size: {
|
|
default: "h-10 px-4 py-2",
|
|
sm: "h-9 rounded-md px-3",
|
|
lg: "h-11 rounded-md px-8",
|
|
},
|
|
},
|
|
defaultVariants: {
|
|
variant: "default",
|
|
size: "default",
|
|
},
|
|
}
|
|
);
|
|
|
|
export interface ButtonProps
|
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
|
VariantProps<typeof buttonVariants> {}
|
|
|
|
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
({ className, variant, size, ...props }, ref) => (
|
|
<button
|
|
className={cn(buttonVariants({ variant, size, className }))}
|
|
ref={ref}
|
|
{...props}
|
|
/>
|
|
)
|
|
);
|
|
Button.displayName = "Button";
|
|
|
|
// ─── Card ────────────────────────────────────────────────────────────────────
|
|
|
|
export const Card = React.forwardRef<
|
|
HTMLDivElement,
|
|
React.HTMLAttributes<HTMLDivElement>
|
|
>(({ className, ...props }, ref) => (
|
|
<div
|
|
ref={ref}
|
|
className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)}
|
|
{...props}
|
|
/>
|
|
));
|
|
Card.displayName = "Card";
|
|
|
|
export const CardHeader = React.forwardRef<
|
|
HTMLDivElement,
|
|
React.HTMLAttributes<HTMLDivElement>
|
|
>(({ className, ...props }, ref) => (
|
|
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
|
|
));
|
|
CardHeader.displayName = "CardHeader";
|
|
|
|
export const CardTitle = React.forwardRef<
|
|
HTMLParagraphElement,
|
|
React.HTMLAttributes<HTMLHeadingElement>
|
|
>(({ className, ...props }, ref) => (
|
|
<h3
|
|
ref={ref}
|
|
className={cn("text-2xl font-semibold leading-none tracking-tight", className)}
|
|
{...props}
|
|
/>
|
|
));
|
|
CardTitle.displayName = "CardTitle";
|
|
|
|
export const CardDescription = React.forwardRef<
|
|
HTMLParagraphElement,
|
|
React.HTMLAttributes<HTMLParagraphElement>
|
|
>(({ className, ...props }, ref) => (
|
|
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
|
));
|
|
CardDescription.displayName = "CardDescription";
|
|
|
|
export const CardContent = React.forwardRef<
|
|
HTMLDivElement,
|
|
React.HTMLAttributes<HTMLDivElement>
|
|
>(({ className, ...props }, ref) => (
|
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
|
));
|
|
CardContent.displayName = "CardContent";
|
|
|
|
export const CardFooter = React.forwardRef<
|
|
HTMLDivElement,
|
|
React.HTMLAttributes<HTMLDivElement>
|
|
>(({ className, ...props }, ref) => (
|
|
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
|
|
));
|
|
CardFooter.displayName = "CardFooter";
|
|
|
|
// ─── Input ───────────────────────────────────────────────────────────────────
|
|
|
|
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
|
|
|
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|
({ className, type, ...props }, ref) => (
|
|
<input
|
|
type={type}
|
|
className={cn(
|
|
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
|
className
|
|
)}
|
|
ref={ref}
|
|
{...props}
|
|
/>
|
|
)
|
|
);
|
|
Input.displayName = "Input";
|
|
|
|
// ─── Label ───────────────────────────────────────────────────────────────────
|
|
|
|
export interface LabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {}
|
|
|
|
export const Label = React.forwardRef<HTMLLabelElement, LabelProps>(
|
|
({ className, ...props }, ref) => (
|
|
<label
|
|
ref={ref}
|
|
className={cn(
|
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
|
className
|
|
)}
|
|
{...props}
|
|
/>
|
|
)
|
|
);
|
|
Label.displayName = "Label";
|
|
|
|
// ─── Textarea ────────────────────────────────────────────────────────────────
|
|
|
|
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
|
|
|
export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|
({ className, ...props }, ref) => (
|
|
<textarea
|
|
className={cn(
|
|
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
|
className
|
|
)}
|
|
ref={ref}
|
|
{...props}
|
|
/>
|
|
)
|
|
);
|
|
Textarea.displayName = "Textarea";
|
|
|
|
// ─── Select ──────────────────────────────────────────────────────────────────
|
|
|
|
interface SelectContextValue {
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
open: boolean;
|
|
setOpen: (open: boolean) => void;
|
|
}
|
|
|
|
const SelectContext = React.createContext<SelectContextValue | null>(null);
|
|
|
|
interface SelectProps {
|
|
value?: string;
|
|
onValueChange?: (value: string) => void;
|
|
children: React.ReactNode;
|
|
}
|
|
|
|
export function Select({ value = "", onValueChange, children }: SelectProps) {
|
|
const [open, setOpen] = React.useState(false);
|
|
const onChange = React.useCallback(
|
|
(v: string) => {
|
|
onValueChange?.(v);
|
|
setOpen(false);
|
|
},
|
|
[onValueChange]
|
|
);
|
|
return (
|
|
<SelectContext.Provider value={{ value, onChange, open, setOpen }}>
|
|
<div className="relative">{children}</div>
|
|
</SelectContext.Provider>
|
|
);
|
|
}
|
|
|
|
export function SelectTrigger({
|
|
className,
|
|
children,
|
|
}: {
|
|
className?: string;
|
|
children: React.ReactNode;
|
|
}) {
|
|
const ctx = React.useContext(SelectContext)!;
|
|
return (
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
|
className
|
|
)}
|
|
onClick={() => ctx.setOpen(!ctx.open)}
|
|
onBlur={() => setTimeout(() => ctx.setOpen(false), 150)}
|
|
>
|
|
{children}
|
|
<svg className="h-4 w-4 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
export function SelectValue({ placeholder }: { placeholder?: string }) {
|
|
const ctx = React.useContext(SelectContext)!;
|
|
return <span className={ctx.value ? "text-foreground" : "text-muted-foreground"}>{ctx.value || placeholder}</span>;
|
|
}
|
|
|
|
export function SelectContent({
|
|
children,
|
|
className,
|
|
}: {
|
|
children: React.ReactNode;
|
|
className?: string;
|
|
}) {
|
|
const ctx = React.useContext(SelectContext)!;
|
|
if (!ctx.open) return null;
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md border bg-card p-1 shadow-md",
|
|
className
|
|
)}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function SelectItem({
|
|
value,
|
|
children,
|
|
}: {
|
|
value: string;
|
|
children: React.ReactNode;
|
|
}) {
|
|
const ctx = React.useContext(SelectContext)!;
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm text-foreground outline-none hover:bg-accent hover:text-accent-foreground",
|
|
ctx.value === value && "bg-accent text-accent-foreground"
|
|
)}
|
|
onMouseDown={(e) => {
|
|
e.preventDefault();
|
|
ctx.onChange(value);
|
|
}}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Badge ───────────────────────────────────────────────────────────────────
|
|
|
|
const badgeVariants = cva(
|
|
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
|
{
|
|
variants: {
|
|
variant: {
|
|
default: "border-transparent bg-primary text-primary-foreground",
|
|
secondary: "border-transparent bg-secondary text-secondary-foreground",
|
|
destructive: "border-transparent bg-destructive text-destructive-foreground",
|
|
outline: "text-foreground",
|
|
success: "border-transparent bg-green-600 text-white",
|
|
},
|
|
},
|
|
defaultVariants: {
|
|
variant: "default",
|
|
},
|
|
}
|
|
);
|
|
|
|
export interface BadgeProps
|
|
extends React.HTMLAttributes<HTMLDivElement>,
|
|
VariantProps<typeof badgeVariants> {}
|
|
|
|
export function Badge({ className, variant, ...props }: BadgeProps) {
|
|
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
|
}
|
|
|
|
// ─── Progress ────────────────────────────────────────────────────────────────
|
|
|
|
interface ProgressProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
value?: number;
|
|
max?: number;
|
|
}
|
|
|
|
export function Progress({ value = 0, max = 100, className, ...props }: ProgressProps) {
|
|
const percentage = Math.min(100, Math.max(0, (value / max) * 100));
|
|
return (
|
|
<div
|
|
className={cn("relative h-4 w-full overflow-hidden rounded-full bg-secondary", className)}
|
|
{...props}
|
|
>
|
|
<div
|
|
className="h-full bg-primary transition-all duration-300"
|
|
style={{ width: `${percentage}%` }}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Separator ───────────────────────────────────────────────────────────────
|
|
|
|
interface SeparatorProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
orientation?: "horizontal" | "vertical";
|
|
}
|
|
|
|
export function Separator({
|
|
orientation = "horizontal",
|
|
className,
|
|
...props
|
|
}: SeparatorProps) {
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"shrink-0 bg-border",
|
|
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
|
className
|
|
)}
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// ─── RadioGroup ──────────────────────────────────────────────────────────────
|
|
|
|
interface RadioGroupContextValue {
|
|
value: string;
|
|
onValueChange: (value: string) => void;
|
|
}
|
|
|
|
const RadioGroupContext = React.createContext<RadioGroupContextValue | null>(null);
|
|
|
|
interface RadioGroupProps {
|
|
value: string;
|
|
onValueChange: (value: string) => void;
|
|
className?: string;
|
|
children: React.ReactNode;
|
|
}
|
|
|
|
export function RadioGroup({ value, onValueChange, className, children }: RadioGroupProps) {
|
|
return (
|
|
<RadioGroupContext.Provider value={{ value, onValueChange }}>
|
|
<div className={cn("space-y-2", className)}>{children}</div>
|
|
</RadioGroupContext.Provider>
|
|
);
|
|
}
|
|
|
|
interface RadioGroupItemProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
|
value: string;
|
|
}
|
|
|
|
export const RadioGroupItem = React.forwardRef<HTMLInputElement, RadioGroupItemProps>(
|
|
({ value, className, ...props }, ref) => {
|
|
const ctx = React.useContext(RadioGroupContext);
|
|
if (!ctx) throw new Error("RadioGroupItem must be used within RadioGroup");
|
|
|
|
return (
|
|
<input
|
|
ref={ref}
|
|
type="radio"
|
|
className={cn(
|
|
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
|
className
|
|
)}
|
|
checked={ctx.value === value}
|
|
onChange={() => ctx.onValueChange(value)}
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
);
|
|
RadioGroupItem.displayName = "RadioGroupItem";
|
|
|
|
export { cn };
|