create, edit, and sso with apps

This commit is contained in:
Ulysse Carion 2024-05-14 11:30:50 -07:00
parent b049eb8fa5
commit c31ad152b9
18 changed files with 1062 additions and 62 deletions

40
package-lock.json generated
View File

@ -28,6 +28,7 @@
"@types/node": "^20.12.7",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.0",
"@types/uuid": "^9.0.8",
"@typescript-eslint/eslint-plugin": "^7.8.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
@ -47,7 +48,7 @@
"prettier": "^3.2.5",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.51.3",
"react-hook-form": "^7.51.4",
"react-router": "^6.23.0",
"react-router-dom": "^6.23.0",
"sonner": "^1.4.41",
@ -55,8 +56,9 @@
"tailwindcss": "^3.4.3",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.4.5",
"uuid": "^9.0.1",
"xml-formatter": "^3.6.2",
"zod": "^3.23.5"
"zod": "^3.23.8"
}
},
"node_modules/@alloc/quick-lru": {
@ -1273,6 +1275,11 @@
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz",
"integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ=="
},
"node_modules/@types/uuid": {
"version": "9.0.8",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz",
"integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA=="
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.8.0.tgz",
@ -5044,9 +5051,9 @@
}
},
"node_modules/react-hook-form": {
"version": "7.51.3",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.51.3.tgz",
"integrity": "sha512-cvJ/wbHdhYx8aviSWh28w9ImjmVsb5Y05n1+FW786vEZQJV5STNM0pW6ujS+oiBecb0ARBxJFyAnXj9+GHXACQ==",
"version": "7.51.4",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.51.4.tgz",
"integrity": "sha512-V14i8SEkh+V1gs6YtD0hdHYnoL4tp/HX/A45wWQN15CYr9bFRmmRdYStSO5L65lCCZRF+kYiSKhm9alqbcdiVA==",
"engines": {
"node": ">=12.22.0"
},
@ -6373,9 +6380,9 @@
}
},
"node_modules/zod": {
"version": "3.23.5",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.23.5.tgz",
"integrity": "sha512-fkwiq0VIQTksNNA131rDOsVJcns0pfVUjHzLrNBiF/O/Xxb5lQyEXkhZWcJ7npWsYlvs+h0jFWXXy4X46Em1JA==",
"version": "3.23.8",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz",
"integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@ -7089,6 +7096,11 @@
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz",
"integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ=="
},
"@types/uuid": {
"version": "9.0.8",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz",
"integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA=="
},
"@typescript-eslint/eslint-plugin": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.8.0.tgz",
@ -9644,9 +9656,9 @@
}
},
"react-hook-form": {
"version": "7.51.3",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.51.3.tgz",
"integrity": "sha512-cvJ/wbHdhYx8aviSWh28w9ImjmVsb5Y05n1+FW786vEZQJV5STNM0pW6ujS+oiBecb0ARBxJFyAnXj9+GHXACQ==",
"version": "7.51.4",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.51.4.tgz",
"integrity": "sha512-V14i8SEkh+V1gs6YtD0hdHYnoL4tp/HX/A45wWQN15CYr9bFRmmRdYStSO5L65lCCZRF+kYiSKhm9alqbcdiVA==",
"requires": {}
},
"react-is": {
@ -10552,9 +10564,9 @@
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="
},
"zod": {
"version": "3.23.5",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.23.5.tgz",
"integrity": "sha512-fkwiq0VIQTksNNA131rDOsVJcns0pfVUjHzLrNBiF/O/Xxb5lQyEXkhZWcJ7npWsYlvs+h0jFWXXy4X46Em1JA=="
"version": "3.23.8",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz",
"integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g=="
}
}
}

View File

@ -35,6 +35,7 @@
"@types/node": "^20.12.7",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.0",
"@types/uuid": "^9.0.8",
"@typescript-eslint/eslint-plugin": "^7.8.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
@ -54,7 +55,7 @@
"prettier": "^3.2.5",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.51.3",
"react-hook-form": "^7.51.4",
"react-router": "^6.23.0",
"react-router-dom": "^6.23.0",
"sonner": "^1.4.41",
@ -62,7 +63,8 @@
"tailwindcss": "^3.4.3",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.4.5",
"uuid": "^9.0.1",
"xml-formatter": "^3.6.2",
"zod": "^3.23.5"
"zod": "^3.23.8"
}
}

View File

@ -1,12 +1,19 @@
import React from "react";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import { SSOPage } from "@/pages/SSOPage";
import { HomePage } from "@/pages/HomePage";
import { ViewAppPage } from "@/pages/ViewAppPage";
import { Page } from "@/components/Page";
export function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/apps/:appId/sso" element={<SSOPage />} />
<Route path="/" element={<Page />}>
<Route path="/" element={<HomePage />} />
<Route path="/apps/:appId" element={<ViewAppPage />} />
<Route path="/apps/:appId/sso" element={<SSOPage />} />
</Route>
</Routes>
</BrowserRouter>
);

23
src/components/Page.tsx Normal file
View File

@ -0,0 +1,23 @@
import React from "react";
import { Outlet } from "react-router";
export function Page() {
return (
<div>
<div>
<div className="mx-auto max-w-6xl flex items-center gap-x-8">
<img alt="logo" src="/logo.png" className="h-20" />
<h1 className="text-sm text-muted-foreground">
DummyIDP is a dumbed-down Identity Provider you can use to test
Enterprise Single-Sign On for free.
</h1>
</div>
</div>
<div className="p-8">
<div className="mx-auto max-w-6xl">
<Outlet />
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,139 @@
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@ -0,0 +1,56 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 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",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@ -0,0 +1,79 @@
import * as React from "react"
import { cn } from "@/lib/utils"
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"
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"
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"
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"
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"
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"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@ -0,0 +1,9 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

176
src/components/ui/form.tsx Normal file
View File

@ -0,0 +1,176 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
ControllerProps,
FieldPath,
FieldValues,
FormProvider,
useFormContext,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState, formState } = useFormContext()
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
)
})
FormItem.displayName = "FormItem"
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField()
return (
<Label
ref={ref}
className={cn(error && "text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
})
FormLabel.displayName = "FormLabel"
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
})
FormControl.displayName = "FormControl"
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (
<p
ref={ref}
id={formDescriptionId}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
})
FormDescription.displayName = "FormDescription"
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message) : children
if (!body) {
return null
}
return (
<p
ref={ref}
id={formMessageId}
className={cn("text-sm font-medium text-destructive", className)}
{...props}
>
{body}
</p>
)
})
FormMessage.displayName = "FormMessage"
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@ -0,0 +1,25 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<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"
export { Input }

View File

@ -0,0 +1,24 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

33
src/key.ts Normal file
View File

@ -0,0 +1,33 @@
export const GLOBAL_NONSECURE_KEY = {
kty: "RSA",
kid: "hSDIRRN-JumgcJ6YrpvCHpc0X46FLAzWXNFxPdmfmz0",
n: "qGaBCZZvw28aHP3nZdjhKUmWlYgrvZGKWE5lL1-sNiGpZUkewYf1nuxLOsz_T5i8LIRa5lX2Ckx4hodnEQ8OpbdEnsobnV-f-z-oW_By1_4JkOPJ8YPNiz5ciB_unnpTjwSZh5akH9pRbriBJuc-qu1aZUUGKw58jHlpjc25u1Oyf6DRdt1fQvnv42BK89AdeH8Y9cDvEIhabngbmSqOjPFsoQUDQNLfJvqgdFBYeogAz_uYkx1RVk0hXWrtzYkiACXbe4Nar7YpMTZHmWI_8qO-PAqidUq1Ooapfp6LbmuxMc5oOkEf8t1Ara3GCybtHv4hOFY29QjRO22ubAGrIw",
e: "AQAB",
d: "Eggj0gx_PCyF3cvcPr4Z4gtkqe9SS7KtXyZJ1GhIruUs19EcD3oI9XL03T99KR9AKv4jI53ZwiGNGE6gXSXBGkKFAQHAMjo-ja8zzmBxU6p6iL6zbX6BAGN1kgflS6fqkZpa_DdHrLd6V8I-5hUF01SmBMj-z5Z2BK6tfEcml6XCVFBzd0QqZuACrdO9ScZvL1cd4DCHJJNBarCVgC9akxOi9THtgD72EYJQyVcsLbzKqoxnT9JW6onCBBKtUaP8fEzp9WMK6APJCkYK5I-1lX8tSf48-lzgBrn3cb9Zo5DYcGo0bJgVwzI4w-HS_etiOOrURmbbX1x9W_rGBhS8tQ",
p: "7HH9QMjZmaD0UvDtdrX1aBgo8ZCWYShMfxzt1gIy6jaSgmcyGmMJeB9s-nCGopy8Iyd4jlggmgnWP4CqYbl6fmvZ-4ULHuc0DGaR1P6zhsF4fI6zCxWm6ijyIXM75Q67ccokLQeRzINKoOmkEQ2hgFKhiaKN4TVfanufECfvqzU",
q: "tlPfEoqbGqYJtxKlND0ihXMTjSVGTbwUBUXUdzYUx7nKOGQSeCLYXbpm4KIBEYa4GNpuub8vnZD8c-FuzIb-yKH66nCa_Q2q8zhfM764pAao9P1OSjbXkA7mL6nO6LwAk7OFNa22z0H5GTRkzX8DJiAG90z_67qrQPmfmFfZ7_c",
dp: "uBegSNqYoYax9AnluXG-mseEyV_71bWcqbOKcf_QSI8ozyMt3WGSs9Yz8WG_UciaqvxGXv26lHRoPZUeE2xoNRof5DcYC32UBrute5q7iIYGG2S3fj_jb5llvCmOTq-DvfrW48BrAkKOzm5a8XQddF3hq9nGgbweiDesBtYxQqk",
dq: "OD9joBq2guAaOyo7YQRDNBwuOer3519QZdgHFcfPXVZJtl_Y-sr1KOUqe74-yiNfg_tPEWqTy-5Ak5dGUT6MN2URPWYDynF-_Y20gQgjeia71OiYUHjew4h1JtsiA9aL7wUA4XB35zSZHld1iZfXZtmWjJBqm1R5JJBd1ee0Sj0",
qi: "ypr_PYU5UrV_Lw22vpa8wJ-C_Ux9nhiz4WtU4jgEX_RofgMra4iDXIVUL6BajWYZ7vj7_AFN2tELTMQ3Xhfc4FQ-CBQkvsLoXO6fzttdxBd_yO01YtijiT_ZwozO3wwzVuN65EGNCQ9haF3vEWEfTEN_-x7RG6xZnFtel6X8rqI",
};
export const GLOBAL_NONSECURE_CERT = `-----BEGIN CERTIFICATE-----
MIIDBzCCAe+gAwIBAgIUCLBK4f75EXEe4gyroYnVaqLoSp4wDQYJKoZIhvcNAQEL
BQAwEzERMA8GA1UEAwwIZHVtbXlpZHAwHhcNMjQwNTEzMjE1NDE2WhcNMzQwNTEx
MjE1NDE2WjATMREwDwYDVQQDDAhkdW1teWlkcDCCASIwDQYJKoZIhvcNAQEBBQAD
ggEPADCCAQoCggEBAKhmgQmWb8NvGhz952XY4SlJlpWIK72RilhOZS9frDYhqWVJ
HsGH9Z7sSzrM/0+YvCyEWuZV9gpMeIaHZxEPDqW3RJ7KG51fn/s/qFvwctf+CZDj
yfGDzYs+XIgf7p56U48EmYeWpB/aUW64gSbnPqrtWmVFBisOfIx5aY3NubtTsn+g
0XbdX0L57+NgSvPQHXh/GPXA7xCIWm54G5kqjozxbKEFA0DS3yb6oHRQWHqIAM/7
mJMdUVZNIV1q7c2JIgAl23uDWq+2KTE2R5liP/KjvjwKonVKtTqGqX6ei25rsTHO
aDpBH/LdQK2txgsm7R7+IThWNvUI0TttrmwBqyMCAwEAAaNTMFEwHQYDVR0OBBYE
FD142gxIAJMhpgMkgpzmRNoW9XbEMB8GA1UdIwQYMBaAFD142gxIAJMhpgMkgpzm
RNoW9XbEMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBADQd6k6z
FIc20GfGHY5C2MFwyGOmP5/UG/JiTq7Zky28G6D0NA0je+GztzXx7VYDfCfHxLcm
2k5t9nYhb9kVawiLUUDVF6s+yZUXA4gUA3KoTWh1/oRxR3ggW7dKYm9fsNOdQAbx
UUkzp7HLZ45ZlpKUS0hO7es+fPyF5KVw0g0SrtQWwWucnQMAQE9m+B0aOf+92y7J
QkdgdR8Gd/XZ4NZfoOnKV7A1utT4rWxYCgICeRTHx9tly5OhPW4hQr5qOpngcsJ9
vhr86IjznQXhfj3hql5lA3VbHW04ro37ROIkh2bShDq5dwJJHpYCGrF3MQv8S3m+
jzGhYL6m9gFTm/8=
-----END CERTIFICATE-----
`;

View File

@ -37,8 +37,6 @@ async function signatureValue(
): Promise<string> {
const digest = await digestValue(assertionData);
const enc = new TextEncoder();
console.log("signature part");
console.log(signaturePart(digest));
const signatureData = await crypto.subtle.sign(
"RSASSA-PKCS1-v1_5",
key,
@ -53,8 +51,6 @@ function signaturePart(digest: string): string {
async function digestValue(assertionData: AssertionData): Promise<string> {
const enc = new TextEncoder();
console.log("digest part");
console.log(digestPart(assertionData));
const digestData = await crypto.subtle.digest(
"SHA-256",
enc.encode(digestPart(assertionData)),

26
src/lib/store.ts Normal file
View File

@ -0,0 +1,26 @@
import { useEffect, useState } from "react";
interface StoreData {
apps: Record<string, App>;
}
interface App {
id: string;
spAcsUrl: string;
spEntityId: string;
}
export function useStore(): [StoreData, (_: StoreData) => void] {
const [state, setState] = useState<StoreData>(() => {
const item = localStorage.getItem("store-data");
return item === null ? { apps: {} } : JSON.parse(item);
});
return [
state,
(state) => {
localStorage.setItem("store-data", JSON.stringify(state));
setState(state);
},
];
}

6
src/lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

29
src/pages/HomePage.tsx Normal file
View File

@ -0,0 +1,29 @@
import React from "react";
import { Button } from "@/components/ui/button";
import { useStore } from "@/lib/store";
import { v4 as uuidv4 } from "uuid";
import { useNavigate } from "react-router";
export function HomePage() {
const [storeData, setStoreData] = useStore();
const navigate = useNavigate();
const handleCreateApp = () => {
const id = uuidv4();
const newState = {
...storeData,
apps: {
...storeData.apps,
[id]: {
id,
spAcsUrl: "",
},
},
};
console.log("want new state", newState);
setStoreData(newState);
navigate(`/apps/${id}`);
};
return <Button onClick={handleCreateApp}>Create new app</Button>;
}

View File

@ -1,62 +1,181 @@
import React, { useEffect, useRef, useState } from "react";
import { encodeAssertion } from "@/lib/saml";
import { useNavigate } from "react-router";
import { GLOBAL_NONSECURE_KEY } from "@/key";
import { useStore } from "@/lib/store";
import { useParams } from "react-router";
import { z } from "zod";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { useSearchParams } from "react-router-dom";
import moment from "moment";
const formSchema = z.object({
email: z.string().email({ message: "Email must be a well-formed email." }),
firstName: z.string(),
lastName: z.string(),
});
export function SSOPage() {
const navigate = useNavigate();
const [storeData, _] = useStore();
const { appId } = useParams();
const app = storeData.apps[appId!];
const [searchParams] = useSearchParams();
const samlRequest = searchParams.get("SAMLRequest");
const [sessionId, setSessionId] = useState("");
useEffect(() => {
(async () => {
const jwk = {
kty: "RSA",
kid: "hSDIRRN-JumgcJ6YrpvCHpc0X46FLAzWXNFxPdmfmz0",
n: "qGaBCZZvw28aHP3nZdjhKUmWlYgrvZGKWE5lL1-sNiGpZUkewYf1nuxLOsz_T5i8LIRa5lX2Ckx4hodnEQ8OpbdEnsobnV-f-z-oW_By1_4JkOPJ8YPNiz5ciB_unnpTjwSZh5akH9pRbriBJuc-qu1aZUUGKw58jHlpjc25u1Oyf6DRdt1fQvnv42BK89AdeH8Y9cDvEIhabngbmSqOjPFsoQUDQNLfJvqgdFBYeogAz_uYkx1RVk0hXWrtzYkiACXbe4Nar7YpMTZHmWI_8qO-PAqidUq1Ooapfp6LbmuxMc5oOkEf8t1Ara3GCybtHv4hOFY29QjRO22ubAGrIw",
e: "AQAB",
d: "Eggj0gx_PCyF3cvcPr4Z4gtkqe9SS7KtXyZJ1GhIruUs19EcD3oI9XL03T99KR9AKv4jI53ZwiGNGE6gXSXBGkKFAQHAMjo-ja8zzmBxU6p6iL6zbX6BAGN1kgflS6fqkZpa_DdHrLd6V8I-5hUF01SmBMj-z5Z2BK6tfEcml6XCVFBzd0QqZuACrdO9ScZvL1cd4DCHJJNBarCVgC9akxOi9THtgD72EYJQyVcsLbzKqoxnT9JW6onCBBKtUaP8fEzp9WMK6APJCkYK5I-1lX8tSf48-lzgBrn3cb9Zo5DYcGo0bJgVwzI4w-HS_etiOOrURmbbX1x9W_rGBhS8tQ",
p: "7HH9QMjZmaD0UvDtdrX1aBgo8ZCWYShMfxzt1gIy6jaSgmcyGmMJeB9s-nCGopy8Iyd4jlggmgnWP4CqYbl6fmvZ-4ULHuc0DGaR1P6zhsF4fI6zCxWm6ijyIXM75Q67ccokLQeRzINKoOmkEQ2hgFKhiaKN4TVfanufECfvqzU",
q: "tlPfEoqbGqYJtxKlND0ihXMTjSVGTbwUBUXUdzYUx7nKOGQSeCLYXbpm4KIBEYa4GNpuub8vnZD8c-FuzIb-yKH66nCa_Q2q8zhfM764pAao9P1OSjbXkA7mL6nO6LwAk7OFNa22z0H5GTRkzX8DJiAG90z_67qrQPmfmFfZ7_c",
dp: "uBegSNqYoYax9AnluXG-mseEyV_71bWcqbOKcf_QSI8ozyMt3WGSs9Yz8WG_UciaqvxGXv26lHRoPZUeE2xoNRof5DcYC32UBrute5q7iIYGG2S3fj_jb5llvCmOTq-DvfrW48BrAkKOzm5a8XQddF3hq9nGgbweiDesBtYxQqk",
dq: "OD9joBq2guAaOyo7YQRDNBwuOer3519QZdgHFcfPXVZJtl_Y-sr1KOUqe74-yiNfg_tPEWqTy-5Ak5dGUT6MN2URPWYDynF-_Y20gQgjeia71OiYUHjew4h1JtsiA9aL7wUA4XB35zSZHld1iZfXZtmWjJBqm1R5JJBd1ee0Sj0",
qi: "ypr_PYU5UrV_Lw22vpa8wJ-C_Ux9nhiz4WtU4jgEX_RofgMra4iDXIVUL6BajWYZ7vj7_AFN2tELTMQ3Xhfc4FQ-CBQkvsLoXO6fzttdxBd_yO01YtijiT_ZwozO3wwzVuN65EGNCQ9haF3vEWEfTEN_-x7RG6xZnFtel6X8rqI",
};
if (!samlRequest) {
return;
}
const key = await window.crypto.subtle.importKey(
"jwk",
jwk,
{
name: "RSASSA-PKCS1-v1_5",
hash: "SHA-256",
},
true,
["sign"],
);
const samlRequestXML = atob(
// url to std base64
samlRequest.replace(/-/g, "+").replace(/_/g, "/"),
);
const assertion = await encodeAssertion(key, {
idpEntityId: "IDP_ENTITY_ID",
subjectId: "ulysse@dummyidp.com",
spEntityId:
"http://localhost:8080/saml/saml_conn_e4wryo0hq30mcrzc32b67otha",
sessionId: "",
expire: "2025-01-01T00:00:00Z",
now: "2022-01-01T00:00:00Z",
});
const parser = new DOMParser();
const doc = parser.parseFromString(samlRequestXML, "text/xml");
inputRef.current!.value = assertion;
inputRef.current!.form!.submit();
})();
// use xpath to get the AuthnRequest ID, throwing away all namespace information to make that easier
const id = doc.evaluate(
"string(/_:AuthnRequest/@ID)",
doc,
(_) => "urn:oasis:names:tc:SAML:2.0:protocol",
XPathResult.STRING_TYPE,
null,
).stringValue;
setSessionId(id);
}, [samlRequest]);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
email: "",
firstName: "",
lastName: "",
},
});
// const formRef = useRef<HTMLFormElement>(null);
async function onSubmit(values: z.infer<typeof formSchema>, e: any) {
const key = await window.crypto.subtle.importKey(
"jwk",
GLOBAL_NONSECURE_KEY,
{
name: "RSASSA-PKCS1-v1_5",
hash: "SHA-256",
},
true,
["sign"],
);
const now = moment(new Date());
const expire = moment(new Date()).add(1, "hour");
inputRef.current!.value = await encodeAssertion(key, {
idpEntityId: `https://dummyidp.com/apps/${app.id}`,
subjectId: values.email,
spEntityId: app.spEntityId,
sessionId: sessionId,
now: now.format(),
expire: expire.format(),
});
inputRef.current!.form!.submit();
}
const inputRef = useRef<HTMLInputElement>(null);
return (
<div>
<>
<form
method="post"
action="http://localhost:8080/saml/saml_conn_e4wryo0hq30mcrzc32b67otha/acs"
// ref={formRef}
>
<input type="hidden" name="SAMLResponse" ref={inputRef} />
</form>
</div>
<Card className="mx-auto max-w-xl">
<CardHeader>
<CardTitle>Log on</CardTitle>
<CardDescription>
Enter some details about who you want DummyIDP to log you in as.
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid grid-cols-2 gap-4"
>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="firstName"
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>First Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="lastName"
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Last Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="col-span-2 flex justify-end">
<Button>Log on</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
</>
);
}

239
src/pages/ViewAppPage.tsx Normal file
View File

@ -0,0 +1,239 @@
import React, { useState } from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { useStore } from "@/lib/store";
import { useParams } from "react-router";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { GLOBAL_NONSECURE_CERT } from "@/key";
import { Link } from "react-router-dom";
const formSchema = z.object({
spAcsUrl: z
.string()
.url({ message: "Service Provider ACS URL must be a valid URL." }),
spEntityId: z.string(),
});
export function ViewAppPage() {
const [storeData, setStoreData] = useStore();
const { appId } = useParams();
const app = storeData.apps[appId!];
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
spAcsUrl: app.spAcsUrl,
spEntityId: app.spEntityId,
},
});
const [open, setOpen] = useState(false);
function onSubmit(values: z.infer<typeof formSchema>, e: any) {
e.preventDefault();
setStoreData({
...storeData,
apps: {
...storeData.apps,
[app.id]: {
...storeData.apps[app.id],
spAcsUrl: values.spAcsUrl,
spEntityId: values.spEntityId,
},
},
});
setOpen(false);
}
return (
<div className="flex flex-col gap-y-8">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex flex-col space-y-1.5">
<div className="flex gap-4">
<CardTitle>App</CardTitle>
<span className="text-xs font-mono bg-gray-100 py-1 px-2 rounded-sm">
{app.id}
</span>
</div>
<CardDescription>
This is a dummy app. This is a dumbed-down equivalent to an
app/tile in Okta, Google Workspace, Microsoft Entra, etc.
</CardDescription>
</div>
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger asChild>
<Button variant="outline">Edit</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Edit app</AlertDialogTitle>
<AlertDialogDescription>
Edit the settings associated with this dummy SSO app.
</AlertDialogDescription>
</AlertDialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8"
>
<FormField
control={form.control}
name="spAcsUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Service Provider ACS URL</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
This tells DummyIDP where to redirect people when
they want to log in to this app. Okta calls this a
"Single sign-on URL".
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="spEntityId"
render={({ field }) => (
<FormItem>
<FormLabel>Service Provider Entity ID</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
This tells DummyIDP what the "ID" of the app is when
doing a SAML sign-on. Okta calls this "Audience URI
(SP Entity ID)".
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Button type="submit">Submit</Button>
</AlertDialogFooter>
</form>
</Form>
</AlertDialogContent>
</AlertDialog>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-4 gap-y-2">
<div className="text-sm col-span-1 text-muted-foreground">
Service Provider ACS URL
</div>
<div className="text-sm col-span-3">{app.spAcsUrl}</div>
<div className="text-sm col-span-1 text-muted-foreground">
Service Provider Entity ID
</div>
<div className="text-sm col-span-3">{app.spEntityId}</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Start SAML login</CardTitle>
<CardDescription>
Click the button below to do the equivalent of clicking on an
app/tile in Okta, Google Workspace, Microsoft Entra, etc.
</CardDescription>
</CardHeader>
<CardContent>
<Button asChild>
<Link to={`/apps/${app.id}/sso`}>Sign on</Link>
</Button>
<p className="mt-8 text-sm text-muted-foreground">
This will perform a SAML IDP-initiated flow, meaning you'll be
redirected to the Service Provider ACS URL, which you've configured
as <span className="font-semibold">{app.spAcsUrl}</span>.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Identity Provider settings</CardTitle>
<CardDescription>
These are settings that the identity provider (i.e. DummyIDP, in
this case) chooses.
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-4 gap-y-2">
<div className="text-sm col-span-1 text-muted-foreground">
Identity Provider Sign-on URL
</div>
<div className="text-sm col-span-3">
https://dummyidp.com/apps/{app.id}/sso
</div>
<div className="text-sm col-span-1 text-muted-foreground">
Identity Provider Entity ID
</div>
<div className="text-sm col-span-3">
https://dummyidp.com/apps/{app.id}
</div>
<div className="text-sm col-span-4 text-muted-foreground">
Certificate
</div>
<div className="col-span-4">
<div className="bg-black rounded-lg px-6 py-4 inline-block">
<code className="text-sm text-white">
<pre>{GLOBAL_NONSECURE_CERT}</pre>
</code>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
}