Move to Next.js (#2)

* tmp

* reset

* begin conversion to nextjs

* more appeasing of stuff

* about to add shadcn

* tmp commit

* switch to new-york style

* fix nextjs build error

* tmp

* start hooking up users to login page

* e2e flows

* preview stuff

* shuffle around where assertion is displayed

* start copy stuff

* tooltip for lack of sp settings

* update footer

* add wordmark, navbar to index

* add scim stuff

* fix build error

* title, copy updates

* more copy, adjusting

* fix tailwindui weirdness

* fix copy

* instant setup

* hard-code insecure key

* footer copy, docslink urls

* subtitles, copy updates
This commit is contained in:
Ulysse Carion 2024-10-03 09:57:43 -07:00 committed by GitHub
parent cacfa55999
commit 7f173bcabf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
71 changed files with 3730 additions and 9728 deletions

1
.env
View File

@ -1 +0,0 @@
APP_API_URL=http://localhost:8081/internal/connect

5
.env.development.local Normal file
View File

@ -0,0 +1,5 @@
# Created by Vercel CLI
KV_REST_API_READ_ONLY_TOKEN="Al5bAAIgcDELml5s72fqDKBsRv2Ta-0pcrnxSG42mePYdaU1ARM7xg"
KV_REST_API_TOKEN="AV5bAAIjcDEyMDdkNmViZGNkM2M0MzcxYjdiNmU4NGQ5N2Y1M2JhOHAxMA"
KV_REST_API_URL="https://curious-mammal-24155.upstash.io"
KV_URL="redis://default:AV5bAAIjcDEyMDdkNmViZGNkM2M0MzcxYjdiNmU4NGQ5N2Y1M2JhOHAxMA@curious-mammal-24155.upstash.io:6379"

View File

@ -1,30 +0,0 @@
{
"env": {
"browser": true,
"es2021": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": [
"@typescript-eslint",
"react"
],
"settings": {
"react": {
"version": "detect"
}
},
"rules": {
"react/no-unescaped-entities": "off",
"@typescript-eslint/no-explicit-any": "off"
}
}

10
.gitignore vendored
View File

@ -1,9 +1,3 @@
/node_modules
/public
!/public/index.html
!/public/logo.png
!/public/wordart.png
!/public/loading.gif
# Local Netlify folder
.netlify
/.next
.vercel

2
.npmrc
View File

@ -1,2 +0,0 @@
@fortawesome:registry=https://npm.fontawesome.com/
//npm.fontawesome.com/:_authToken=${FONTAWESOME_NPM_AUTH_TOKEN}

View File

@ -1,29 +0,0 @@
import * as esbuild from "esbuild";
const APP_BUILD_IS_DEV = process.env.APP_BUILD_IS_DEV === "1";
const define = {
global: 'window',
...Object.fromEntries(
Object.entries(process.env)
.filter(([k, _v]) => k.startsWith("APP_"))
.map(([k, v]) => [`process.env.${k}`, JSON.stringify(v)]),
)}
const context = await esbuild.context({
entryPoints: ["./src"],
outfile: "./public/index.js",
minify: !APP_BUILD_IS_DEV,
bundle: true,
sourcemap: true,
target: ["chrome58", "firefox57", "safari11", "edge18"],
define,
});
if (APP_BUILD_IS_DEV) {
console.log("watching");
await context.watch();
} else {
await context.rebuild();
await context.dispose();
}

View File

@ -1,13 +1,13 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"css": "src/app/global.css",
"baseColor": "slate",
"cssVariables": true,
"cssVariables": false,
"prefix": ""
},
"aliases": {

View File

@ -1,19 +0,0 @@
const jose = require("node-jose");
const fs = require("fs");
// Function to convert RSA private key to JWK
async function convertToJWK() {
// Read the PEM formatted private key
const keyPEM = fs.readFileSync("dummyidp.key");
// Create a keystore
const keystore = jose.JWK.createKeyStore();
// Add the RSA private key to the keystore
const jwk = await keystore.add(keyPEM, "pem");
// Output the JWK
console.log(JSON.stringify(jwk.toJSON(true), null, 4));
}
convertToJWK().catch(console.error);

View File

@ -1,19 +0,0 @@
-----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

@ -1,28 +0,0 @@
-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCoZoEJlm/Dbxoc
/edl2OEpSZaViCu9kYpYTmUvX6w2IallSR7Bh/We7Es6zP9PmLwshFrmVfYKTHiG
h2cRDw6lt0SeyhudX5/7P6hb8HLX/gmQ48nxg82LPlyIH+6eelOPBJmHlqQf2lFu
uIEm5z6q7VplRQYrDnyMeWmNzbm7U7J/oNF23V9C+e/jYErz0B14fxj1wO8QiFpu
eBuZKo6M8WyhBQNA0t8m+qB0UFh6iADP+5iTHVFWTSFdau3NiSIAJdt7g1qvtikx
NkeZYj/yo748CqJ1SrU6hql+notua7Exzmg6QR/y3UCtrcYLJu0e/iE4Vjb1CNE7
ba5sAasjAgMBAAECggEAEggj0gx/PCyF3cvcPr4Z4gtkqe9SS7KtXyZJ1GhIruUs
19EcD3oI9XL03T99KR9AKv4jI53ZwiGNGE6gXSXBGkKFAQHAMjo+ja8zzmBxU6p6
iL6zbX6BAGN1kgflS6fqkZpa/DdHrLd6V8I+5hUF01SmBMj+z5Z2BK6tfEcml6XC
VFBzd0QqZuACrdO9ScZvL1cd4DCHJJNBarCVgC9akxOi9THtgD72EYJQyVcsLbzK
qoxnT9JW6onCBBKtUaP8fEzp9WMK6APJCkYK5I+1lX8tSf48+lzgBrn3cb9Zo5DY
cGo0bJgVwzI4w+HS/etiOOrURmbbX1x9W/rGBhS8tQKBgQDscf1AyNmZoPRS8O12
tfVoGCjxkJZhKEx/HO3WAjLqNpKCZzIaYwl4H2z6cIainLwjJ3iOWCCaCdY/gKph
uXp+a9n7hQse5zQMZpHU/rOGwXh8jrMLFabqKPIhczvlDrtxyiQtB5HMg0qg6aQR
DaGAUqGJoo3hNV9qe58QJ++rNQKBgQC2U98Sipsapgm3EqU0PSKFcxONJUZNvBQF
RdR3NhTHuco4ZBJ4IthdumbgogERhrgY2m65vy+dkPxz4W7Mhv7IofrqcJr9Darz
OF8zvrikBqj0/U5KNteQDuYvqc7ovACTs4U1rbbPQfkZNGTNfwMmIAb3TP/ruqtA
+Z+YV9nv9wKBgQC4F6BI2pihhrH0CeW5cb6ax4TJX/vVtZyps4px/9BIjyjPIy3d
YZKz1jPxYb9RyJqq/EZe/bqUdGg9lR4TbGg1Gh/kNxgLfZQGu617mruIhgYbZLd+
P+NvmWW8KY5Or4O9+tbjwGsCQo7OblrxdB10XeGr2caBvB6IN6wG1jFCqQKBgDg/
Y6AatoLgGjsqO2EEQzQcLjnq9+dfUGXYBxXHz11WSbZf2PrK9SjlKnu+PsojX4P7
TxFqk8vuQJOXRlE+jDdlET1mA8pxfv2NtIEII3omu9TomFB43sOIdSbbIgPWi+8F
AOFwd+c0mR5XdYmX12bZloyQaptUeSSQXdXntEo9AoGBAMqa/z2FOVK1fy8Ntr6W
vMCfgv1MfZ4Ys+FrVOI4BF/0aH4DK2uIg1yFVC+gWo1mGe74+/wBTdrRC0zEN14X
3OBUPggUJL7C6Fzun87bXcQXf8jtNWLYo4k/2cKMzt8MM1bjeuRBjQkPYWhd7xFh
H0xDf/se0RusWZxbXpel/K6i
-----END PRIVATE KEY-----

View File

@ -1,4 +0,0 @@
[[redirects]]
from = "/*"
to = "/index.html"
status = 200

View File

@ -1,21 +0,0 @@
console.log("test1")
export default async (req, context) => {
console.log("test2")
const formData = await req.formData()
let query = ""
formData.forEach((value, key) => {
query += encodeURIComponent(key) + "=" + encodeURIComponent(value.toString())
})
return new Response(null, {
status: 303,
headers: {
location: `/apps/${context.params.appId}/sso?${query}`,
}
})
}
export const config = {
path: "/saml-post/:appId"
};

5
next-env.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.

8
next.config.js Normal file
View File

@ -0,0 +1,8 @@
/** @type {import('next').NextConfig} */
module.exports = {
experimental: {
serverActions: {
allowedOrigins: ["*"], // we need to support POSTs from arbitrary SAML SPs
},
},
};

9964
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,71 +1,40 @@
{
"name": "app",
"version": "0.0.0",
"scripts": {
"build": "npm-run-all tailwind esbuild",
"dev": "dotenv -- npm-run-all -l -p serve tailwind-watch esbuild-watch tsc-watch",
"serve": "http-server -c-1 -p 8084 -P http://localhost:8084?",
"tailwind": "tailwindcss -i ./src/index.css -o ./public/index.css",
"tailwind-watch": "tailwindcss -i ./src/index.css -o ./public/index.css --watch",
"esbuild": "node ./build.mjs",
"esbuild-watch": "APP_BUILD_IS_DEV=1 node ./build.mjs",
"tsc": "tsc --noEmit",
"tsc-watch": "tsc --noEmit --watch --preserveWatchOutput",
"fmt": "prettier --write .",
"fmt-check": "prettier --check ."
"dev": "next",
"build": "next build"
},
"dependencies": {
"@bufbuild/buf": "^1.31.0",
"@bufbuild/protobuf": "^1.9.0",
"@connectrpc/connect": "^1.4.0",
"@connectrpc/connect-query": "^1.3.1",
"@connectrpc/connect-web": "^1.4.0",
"@hookform/resolvers": "^3.3.4",
"@netlify/functions": "^2.7.0",
"@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-collapsible": "^1.0.3",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4",
"@react-oauth/google": "^0.12.1",
"@tailwindcss/forms": "^0.5.7",
"@tanstack/react-query": "^5.32.0",
"@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",
"@floating-ui/react": "^0.26.24",
"@hookform/resolvers": "^3.9.0",
"@radix-ui/react-accordion": "^1.2.0",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-slot": "^1.1.0",
"@vercel/kv": "^3.0.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"dotenv-cli": "^7.4.1",
"eckles": "^1.4.1",
"esbuild": "^0.20.2",
"eslint": "^8.57.0",
"eslint-plugin-react": "^7.34.1",
"eslint-plugin-react-hooks": "^4.6.2",
"highlight.js": "^11.9.0",
"http-server": "^14.1.1",
"lucide-react": "^0.376.0",
"highlight.js": "^11.10.0",
"lucide-react": "^0.446.0",
"moment": "^2.30.1",
"next": "^14.2.13",
"next-themes": "^0.3.0",
"node-jose": "^2.2.0",
"npm-run-all": "^4.1.5",
"prettier": "^3.2.5",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.51.4",
"react-router": "^6.23.0",
"react-router-dom": "^6.23.0",
"sonner": "^1.4.41",
"tailwind-merge": "^2.3.0",
"tailwindcss": "^3.4.3",
"prettier": "^3.3.3",
"react-hook-form": "^7.53.0",
"sonner": "^1.5.0",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.4.5",
"uuid": "^9.0.1",
"xml-formatter": "^3.6.2",
"ulid": "^2.3.0",
"xml-formatter": "^3.6.3",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "22.7.4",
"@types/react": "18.3.10",
"autoprefixer": "^10.4.20",
"eslint": "^8.57.1",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.13",
"typescript": "5.6.2"
}
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@ -1,24 +0,0 @@
<!DOCTYPE html>
<html lang="en" class="h-full">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/index.css">
<link rel="icon" href="/apple-touch-icon.png">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&family=Roboto+Mono:ital,wght@0,100..700;1,100..700&display=swap" rel="stylesheet">
<style>
body {
background-image: url("/wordart.png");
}
</style>
</head>
<body>
<div id="react-root" class="h-full min-h-screen"></div>
</body>
<script src="/index.js"></script>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

View File

@ -1,22 +0,0 @@
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";
import { InstantSetupPage } from "@/pages/InstantSetupPage";
export function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/instant-setup" element={<InstantSetupPage />} />
<Route path="/apps/:appId/sso" element={<SSOPage />} />
<Route path="/" element={<Page />}>
<Route path="/" element={<HomePage />} />
<Route path="/apps/:appId" element={<ViewAppPage />} />
</Route>
</Routes>
</BrowserRouter>
);
}

124
src/app/actions.ts Normal file
View File

@ -0,0 +1,124 @@
"use server";
import { kv } from "@vercel/kv";
import { redirect } from "next/navigation";
import { ulid } from "ulid";
import { App } from "@/app/app";
import { list } from "postcss";
export async function createApp() {
const id = `app_${ulid().toLowerCase()}`;
await kv.hset(id, {
id,
users: [
{ email: "john.doe@example.com", firstName: "John", lastName: "Doe" },
{
email: "abraham.lincoln@example.com",
firstName: "Abraham",
lastName: "Lincoln",
},
],
});
redirect(`/apps/${id}`);
}
export async function getApp(id: string): Promise<App | undefined> {
const result = await kv.hgetall(id);
if (!result) {
return undefined;
}
return result as unknown as App;
}
export async function upsertApp(app: App): Promise<void> {
// get a list of users being deleted, so we can SCIM DELETE them later
const oldApp = (await kv.hgetall(app.id)) as App | undefined;
const deletedUserEmails: string[] = [];
if (oldApp) {
// could do this with sets, but NextJS doesn't seem to support
// set.difference, so there's very little gain
for (const oldUser of oldApp.users) {
let found = false;
for (const newUser of app.users) {
if (newUser.email === oldUser.email) {
found = true;
}
}
if (!found) {
deletedUserEmails.push(oldUser.email);
}
}
}
// update the app
await kv.hset(app.id, app);
// scim sync
if (app.scimBaseUrl && app.scimBearerToken) {
// Carry out a scim sync; our approach is stateless and is close to Okta's
// syncing approach.
//
// For each user, list users filtered by email address. If we get a result,
// PUT against the resulting user ID. If we don't get a result, POST a new
// user. Do not persist state about assigned user IDs between syncs.
for (const user of app.users) {
const userId = await scimUserByEmail(app, user.email);
if (userId) {
await fetch(`${app.scimBaseUrl}/Users/${userId}`, {
method: "PUT",
headers: { Authorization: `Bearer ${app.scimBearerToken}` },
body: JSON.stringify({
userName: user.email,
name: {
givenName: user.firstName,
familyName: user.lastName,
},
}),
});
} else {
await fetch(`${app.scimBaseUrl}/Users`, {
method: "POST",
headers: { Authorization: `Bearer ${app.scimBearerToken}` },
body: JSON.stringify({
userName: user.email,
name: {
givenName: user.firstName,
familyName: user.lastName,
},
}),
});
}
}
// delete removed users
for (const email of deletedUserEmails) {
const userId = await scimUserByEmail(app, email);
if (userId) {
await fetch(`${app.scimBaseUrl}/Users/${userId}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${app.scimBearerToken}` },
});
}
}
}
}
async function scimUserByEmail(
app: App,
email: string,
): Promise<string | undefined> {
const filter = new URLSearchParams({
filter: `userName eq "${email}"`,
});
const listResponse = await fetch(`${app.scimBaseUrl}/Users?${filter}`, {
headers: { Authorization: `Bearer ${app.scimBearerToken}` },
});
const listBody = await listResponse.json();
if (listBody?.Resources?.length > 0) {
return listBody.Resources[0].id;
}
return undefined;
}

22
src/app/app.ts Normal file
View File

@ -0,0 +1,22 @@
export type App = {
id: string;
users: AppUser[];
spAcsUrl?: string;
spEntityId?: string;
scimBaseUrl?: string;
scimBearerToken?: string;
};
export type AppUser = {
email: string;
firstName: string;
lastName: string;
};
export function appIdpEntityId(app: App): string {
return `https://dummyidp.com/apps/${app.id}`;
}
export function appIdpRedirectUrl(app: App): string {
return `https://${process.env.VERCEL_URL}/apps/${app.id}/sso`;
}

View File

@ -0,0 +1,91 @@
"use client";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { App } from "@/app/app";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { upsertApp } from "@/app/actions";
import { toast } from "sonner";
const formSchema = z.object({
scimBaseUrl: z
.string()
.min(1, {
message: "SCIM Base URL is required.",
})
.url({
message: "SCIM Base URL must be a valid URL.",
}),
scimBearerToken: z.string().min(1, {
message: "A SCIM Bearer Token is required.",
}),
});
export function SCIMSettingsForm({ app }: { app: App }) {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
scimBaseUrl: app.scimBaseUrl ?? "",
scimBearerToken: app.scimBearerToken ?? "",
},
});
async function onSubmit(values: z.infer<typeof formSchema>) {
await upsertApp({
...app,
scimBaseUrl: values.scimBaseUrl,
scimBearerToken: values.scimBearerToken,
});
toast.success("App SCIM settings updated");
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="scimBaseUrl"
render={({ field }) => (
<FormItem>
<FormLabel>SCIM Base URL</FormLabel>
<FormControl>
<Input placeholder="https://..." {...field} />
</FormControl>
{/*<FormDescription>sp acs url</FormDescription>*/}
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="scimBearerToken"
render={({ field }) => (
<FormItem>
<FormLabel>SCIM Bearer Token</FormLabel>
<FormControl>
<Input placeholder="https://..." {...field} />
</FormControl>
{/*<FormDescription>sp acs url</FormDescription>*/}
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? "Saving" : "Save"}
</Button>
</form>
</Form>
);
}

View File

@ -0,0 +1,86 @@
"use client";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { App } from "@/app/app";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { upsertApp } from "@/app/actions";
import { toast } from "sonner";
const formSchema = z.object({
spAcsUrl: z.string().min(1, {
message: "Service Provider ACS URL is required.",
}),
spEntityId: z.string().min(1, {
message: "Service Provider Entity ID is required.",
}),
});
export function SPSettingsForm({ app }: { app: App }) {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
spAcsUrl: app.spAcsUrl ?? "",
spEntityId: app.spEntityId ?? "",
},
});
async function onSubmit(values: z.infer<typeof formSchema>) {
await upsertApp({
...app,
spAcsUrl: values.spAcsUrl,
spEntityId: values.spEntityId,
});
toast.success("App SP settings updated");
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="spAcsUrl"
render={({ field }) => (
<FormItem>
<FormLabel>SP ACS URL</FormLabel>
<FormControl>
<Input placeholder="https://..." {...field} />
</FormControl>
{/*<FormDescription>sp acs url</FormDescription>*/}
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="spEntityId"
render={({ field }) => (
<FormItem>
<FormLabel>SP Entity ID</FormLabel>
<FormControl>
<Input placeholder="https://..." {...field} />
</FormControl>
{/*<FormDescription>sp acs url</FormDescription>*/}
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? "Saving" : "Save"}
</Button>
</form>
</Form>
);
}

View File

@ -0,0 +1,60 @@
"use client";
import { Button } from "@/components/ui/button";
import { App } from "@/app/app";
import {
autoUpdate,
offset,
useFloating,
useFocus,
useHover,
useInteractions,
} from "@floating-ui/react";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
export function SimulateLoginButton({ app }: { app: App }) {
const disabled = !app.spAcsUrl || !app.spEntityId;
const [open, setOpen] = useState(false);
const { refs, floatingStyles, context } = useFloating({
placement: "top",
middleware: [offset(8)],
onOpenChange: setOpen,
whileElementsMounted: autoUpdate,
});
const { getReferenceProps, getFloatingProps } = useInteractions([
useHover(context),
useFocus(context),
]);
return (
<>
<div ref={refs.setReference} {...getReferenceProps()}>
<Button asChild>
<Link
href={`/apps/${app.id}/login`}
aria-disabled={disabled}
className="aria-disabled:pointer-events-none aria-disabled:opacity-50"
>
Simulate SAML Login
</Link>
</Button>
</div>
<div
ref={refs.setFloating}
{...getFloatingProps()}
style={floatingStyles}
>
{disabled && open && (
<div className="pointer-events-none bg-black rounded-sm text-white text-xs max-w-[400px] p-2">
Configure SAML SP Settings first.
</div>
)}
</div>
</>
);
}

View File

@ -0,0 +1,160 @@
"use client";
import { z } from "zod";
import { useFieldArray, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { App } from "@/app/app";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { upsertApp } from "@/app/actions";
import { toast } from "sonner";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { TrashIcon } from "@radix-ui/react-icons";
const formSchema = z.object({
users: z.array(
z.object({
email: z
.string()
.min(1, { message: "Email is required" })
.email({ message: "Email must be a valid email" }),
firstName: z.string().min(1, { message: "First name is required" }),
lastName: z.string().min(1, { message: "Last name is required" }),
}),
),
});
export function UsersSettingsForm({ app }: { app: App }) {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
users: app.users ?? [],
},
});
const { fields, remove, append } = useFieldArray({
name: "users",
control: form.control,
});
async function onSubmit(values: z.infer<typeof formSchema>) {
await upsertApp({
...app,
users: values.users,
});
toast.success("App user settings updated");
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<Table>
<TableHeader>
<TableRow>
<TableHead>Email</TableHead>
<TableHead>First Name</TableHead>
<TableHead>Last Name</TableHead>
<TableHead className="w-[36px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{fields.map((field, index) => (
<TableRow key={field.id}>
<TableCell>
<FormField
control={form.control}
name={`users.${index}.email`}
render={({ field }) => (
<FormItem>
<FormControl>
<Input
placeholder="john.doe@example.com"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
<TableCell>
<FormField
control={form.control}
name={`users.${index}.firstName`}
render={({ field }) => (
<FormItem>
<FormControl>
<Input placeholder="John" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
<TableCell>
<FormField
control={form.control}
name={`users.${index}.lastName`}
render={({ field }) => (
<FormItem>
<FormControl>
<Input placeholder="Doe" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
<TableCell>
<Button
variant="outline"
size="icon"
onClick={() => remove(index)}
>
<TrashIcon className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<Button
type="button"
variant="outline"
size="sm"
className="mt-2"
onClick={() => append({ email: "", firstName: "", lastName: "" })}
>
Add User
</Button>
<div>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? "Saving" : "Save"}
</Button>
</div>
</form>
</Form>
);
}

View File

@ -0,0 +1,107 @@
"use client";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { LoginForm } from "@/app/apps/[id]/login/LoginForm";
import Link from "next/link";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { XmlCodeBlock } from "@/components/XmlCodeBlock";
import formatXml from "xml-formatter";
import { App } from "@/app/app";
import { useState } from "react";
import { DocsLink } from "@/components/DocsLink";
export default function LoginCard({
app,
samlRequest,
}: {
app: App;
samlRequest: string;
}) {
const [assertion, setAssertion] = useState("");
return (
<div>
<Card className="mt-8 mx-auto max-w-2xl">
<CardHeader>
<CardTitle>Log in</CardTitle>
<CardDescription>
In a "real" IDP, this is the page where your customer puts in their
corporate username and password. In DummyIDP, you can just choose
who you want to log in as.
</CardDescription>
</CardHeader>
<CardContent>
{app.users.length > 0 ? (
<LoginForm
app={app}
samlRequest={samlRequest}
onAssertionChange={setAssertion}
/>
) : (
<p className="text-sm text-muted-foreground">
Cannot log in with SAML, because this application doesn't have any
users.{" "}
<Link href={`/apps/${app.id}`} className="underline decoration-2">
Edit this application
</Link>{" "}
to add users, and then try here again.
</p>
)}
</CardContent>
</Card>
<Accordion className="mt-8 mx-auto max-w-2xl" type="multiple">
<AccordionItem value="saml-request-details">
<AccordionTrigger>
Service Provider AuthnRequest Details
</AccordionTrigger>
<AccordionContent>
{samlRequest ? (
<>
<p>
Here are details on the request DummyIDP received from your
application.
</p>
<div className="mt-4">
<XmlCodeBlock code={formatXml(samlRequest)} />
</div>
</>
) : (
<p>
This is an IDP-initiated SAML login. AuthnRequests only apply to
SP-initiated SAML logins.
</p>
)}
</AccordionContent>
</AccordionItem>
<AccordionItem value="saml-assertion-preview">
<AccordionTrigger>SAML Assertion Preview</AccordionTrigger>
<AccordionContent>
<p>
A preview of the SAML assertion DummyIDP is going to send to your
application.
</p>
{assertion && (
<div className="mt-4">
<XmlCodeBlock code={formatXml(atob(assertion))} />
</div>
)}
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
}

View File

@ -0,0 +1,168 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { App, appIdpEntityId, AppUser } from "@/app/app";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { useEffect, useMemo, useRef, useState } from "react";
import { encodeAssertion } from "@/lib/saml";
import moment from "moment";
import { XmlCodeBlock } from "@/components/XmlCodeBlock";
import formatXml from "xml-formatter";
import { INSECURE_PRIVATE_KEY } from "@/lib/insecure-cert";
const FormSchema = z.object({
userIndex: z.string({
required_error: "Please select a user to proceed as.",
}),
});
export function LoginForm({
app,
samlRequest,
onAssertionChange,
}: {
app: App;
samlRequest: string;
onAssertionChange: (assertion: string) => void;
}) {
const sessionId = useMemo(() => {
if (samlRequest === "") {
return "";
}
const parser = new DOMParser();
const doc = parser.parseFromString(samlRequest, "text/xml");
// use xpath to get the AuthnRequest ID, throwing away all namespace information to make that easier
return doc.evaluate(
"string(/_:AuthnRequest/@ID)",
doc,
() => "urn:oasis:names:tc:SAML:2.0:protocol",
XPathResult.STRING_TYPE,
null,
).stringValue;
}, [samlRequest]);
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: { userIndex: "0" },
});
const userIndex = form.watch("userIndex");
const user = useMemo(() => {
return app.users[parseInt(userIndex)];
}, [app, userIndex]);
const [assertion, setAssertion] = useState("");
useEffect(() => {
(async () => {
const key = await window.crypto.subtle.importKey(
"jwk",
JSON.parse(atob(INSECURE_PRIVATE_KEY)),
{
name: "RSASSA-PKCS1-v1_5",
hash: "SHA-256",
},
true,
["sign"],
);
const now = moment(new Date()).add(-1, "hour");
const expire = moment(new Date()).add(1, "hour");
setAssertion(
await encodeAssertion(key, {
idpEntityId: appIdpEntityId(app),
subjectId: user.email,
firstName: user.firstName,
lastName: user.lastName,
spEntityId: app.spEntityId!,
sessionId: sessionId,
now: now.format(),
expire: expire.format(),
}),
);
})();
}, [userIndex]);
useEffect(() => {
onAssertionChange(assertion);
}, [assertion]);
const inputRef = useRef<HTMLInputElement>(null);
function handleSubmit(data: z.infer<typeof FormSchema>) {
inputRef.current!.value = assertion;
inputRef.current!.form!.action = app.spAcsUrl!;
inputRef.current!.form!.submit();
}
return (
<div>
<form method="post">
<input type="hidden" name="SAMLResponse" ref={inputRef} />
</form>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
<FormField
control={form.control}
name="userIndex"
render={({ field }) => (
<FormItem>
<FormLabel>Proceed As</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value.toString()}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a user to proceed as" />
</SelectTrigger>
</FormControl>
<SelectContent>
{app.users.map((user, index) => (
<SelectItem key={index} value={index.toString()}>
{user.firstName} {user.lastName} ({user.email})
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
Select a user that you want to log in with SAML as.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Proceed as {user.email}</Button>
</form>
</Form>
</div>
);
}

View File

@ -0,0 +1,72 @@
import Navbar from "@/components/Navbar";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { getApp } from "@/app/actions";
import { GradientBackground } from "@/components/GradientBackground";
import LoginCard from "@/app/apps/[id]/login/LoginCard";
import { Metadata } from "next";
import { DocsLink } from "@/components/DocsLink";
export const metadata: Metadata = {
title: "Simulate SAML Login",
};
export default async function Page({
params,
searchParams,
}: {
params: { id: string };
searchParams: { SAMLRequest: string };
}) {
const app = await getApp(params.id);
if (app === undefined) {
return <h1>not found</h1>;
}
const samlRequest = searchParams.SAMLRequest
? atob(searchParams.SAMLRequest)
: "";
return (
<div className="overflow-hidden">
<GradientBackground />
<Navbar />
<div className="px-8">
<div className="mx-auto max-w-7xl">
<Breadcrumb className="mt-8">
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="/">Home</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>Apps</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink href={`/apps/${app.id}`}>
{app.id}
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>Simulate SAML Login</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<h1 className="mt-2 text-3xl font-semibold">Simulate SAML login</h1>
<p className="mt-1 text-muted-foreground">
Simulate a SAML login as any user you've configured on this DummyIDP
app.
<DocsLink to="https://ssoready.com/docs/dummyidp#simulating-saml-logins" />
</p>
<LoginCard app={app} samlRequest={samlRequest} />
</div>
</div>
</div>
);
}

174
src/app/apps/[id]/page.tsx Normal file
View File

@ -0,0 +1,174 @@
import { getApp } from "@/app/actions";
import { PlusGridItem, PlusGridRow } from "@/components/PlusGrid";
import Link from "next/link";
import Navbar from "@/components/Navbar";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { DocsLink } from "@/components/DocsLink";
import { Label } from "@/components/ui/label";
import { appIdpEntityId, appIdpRedirectUrl } from "@/app/app";
import { useMemo } from "react";
import { ArrowDownToLineIcon } from "lucide-react";
import { SPSettingsForm } from "@/app/apps/[id]/SPSettingsForm";
import { ap } from "@upstash/redis/zmscore-uDFFyCiZ";
import { GradientBackground } from "@/components/GradientBackground";
import { UsersSettingsForm } from "@/app/apps/[id]/UsersSettingsForm";
import { Button } from "@/components/ui/button";
import { SimulateLoginButton } from "@/app/apps/[id]/SimulateLoginButton";
import { SCIMSettingsForm } from "@/app/apps/[id]/SCIMSettingsForm";
import { Metadata } from "next";
import { INSECURE_PUBLIC_CERTIFICATE } from "@/lib/insecure-cert";
export const metadata: Metadata = {
title: "App",
};
export default async function Page({ params }: { params: { id: string } }) {
const app = await getApp(params.id);
if (app === undefined) {
return <h1>not found</h1>;
}
const certificateDownloadURL = `data:text/plain,${INSECURE_PUBLIC_CERTIFICATE}`;
return (
<div className="overflow-hidden">
<GradientBackground />
<Navbar />
<div className="px-8">
<div className="mx-auto max-w-7xl">
<Breadcrumb className="mt-8">
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="/">Home</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>Apps</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink href={`/apps/${app.id}`}>
{app.id}
</BreadcrumbLink>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="flex items-center justify-between">
<div>
<h1 className="mt-2 text-3xl font-semibold">{app.id}</h1>
<p className="mt-1 text-muted-foreground">
A DummyIDP app lets you emulate your customer's identity
provider.
<DocsLink to="https://ssoready.com/docs/dummyidp#creating-a-dummyidp-app" />
</p>
</div>
<SimulateLoginButton app={app} />
</div>
<div className="mt-8 grid grid-cols-6 gap-4">
<Card className="col-span-3">
<CardHeader>
<CardTitle>
SAML IDP Settings
<DocsLink to="https://ssoready.com/docs/dummyidp#idp-settings" />
</CardTitle>
<CardDescription>
These are SAML settings that identity providers (DummyIDP, in
this case) assign. Normally, they'll come from your customer's
Okta/Google/Microsoft/etc instead. You need to put these into
your application's SAML settings.
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-y-2">
<div>
<Label>IDP Entity ID</Label>
<div className="text-sm text-muted-foreground">
{appIdpEntityId(app)}
</div>
</div>
<div>
<Label>IDP Redirect URL</Label>
<div className="text-sm text-muted-foreground">
{appIdpRedirectUrl(app)}
</div>
</div>
<div>
<Label>IDP Certificate</Label>
<a
href={certificateDownloadURL}
download="DummyIDP Certificate.crt"
className="block text-sm text-blue-600"
>
<span>Download (.crt)</span>
</a>
</div>
</div>
</CardContent>
</Card>
<Card className="col-span-3">
<CardHeader>
<CardTitle>
SAML SP Settings
<DocsLink to="https://ssoready.com/docs/dummyidp#sp-settings" />
</CardTitle>
<CardDescription>
These are SAML settings assigned by the service provider
("SP"), i.e. your application. You need to copy those settings
from your application into here.
</CardDescription>
</CardHeader>
<CardContent>
<SPSettingsForm app={app} />
</CardContent>
</Card>
<Card className="col-span-2">
<CardHeader>
<CardTitle>
SCIM Settings
<DocsLink to="https://ssoready.com/docs/dummyidp#scim-settings" />
</CardTitle>
<CardDescription>
Settings for directory syncing over SCIM. Optional.
</CardDescription>
</CardHeader>
<CardContent>
<SCIMSettingsForm app={app} />
</CardContent>
</Card>
<Card className="col-span-4">
<CardHeader>
<CardTitle>
Users
<DocsLink to="https://ssoready.com/docs/dummyidp#users-settings" />
</CardTitle>
<CardDescription>
You can simulate SAML logins from this list of users. They'll
be synced over SCIM if you've configured it.
</CardDescription>
</CardHeader>
<CardContent>
<UsersSettingsForm app={app} />
</CardContent>
</Card>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,11 @@
import { NextRequest, NextResponse } from "next/server";
export async function POST(
req: NextRequest,
{ params }: { params: { id: string } },
) {
const url = req.nextUrl.clone();
url.pathname = `/apps/${params.id}/login`;
url.search = new URLSearchParams((await req.formData()) as any).toString();
return NextResponse.redirect(url, 302);
}

81
src/app/globals.css Normal file
View File

@ -0,0 +1,81 @@
@import "highlight.js/styles/github-dark-dimmed.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--font-sans: 'Switzer, system-ui, sans-serif';
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@ -0,0 +1,22 @@
import { NextRequest } from "next/server";
import { upsertApp } from "@/app/actions";
import { redirect } from "next/navigation";
export async function GET(req: NextRequest) {
const searchParams = req.nextUrl.searchParams;
const appId = searchParams.get("appId")!;
const spAcsUrl = searchParams.get("spAcsUrl")!;
const spEntityId = searchParams.get("spEntityId")!;
const email = searchParams.get("email")!;
const firstName = searchParams.get("firstName")!;
const lastName = searchParams.get("lastName")!;
await upsertApp({
id: appId,
spAcsUrl: spAcsUrl,
spEntityId: spEntityId,
users: [{ email, firstName, lastName }],
});
redirect(`/apps/${appId}/login`);
}

47
src/app/layout.tsx Normal file
View File

@ -0,0 +1,47 @@
import { Inter, Roboto_Mono } from "next/font/google";
import { Toaster } from "@/components/ui/sonner";
import "./globals.css";
import { Footer } from "@/components/Footer";
import { Metadata } from "next";
const inter = Inter({
subsets: ["latin"],
variable: "--font-sans",
display: "swap",
});
const roboto_mono = Roboto_Mono({
subsets: ["latin"],
variable: "--font-mono",
display: "swap",
});
export const metadata: Metadata = {
title: {
template: "%s - DummyIDP",
default: "DummyIDP - SAML testing made easy",
},
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className={`${inter.variable} ${roboto_mono.variable}`}>
<head>
<link
rel="stylesheet"
href="https://api.fontshare.com/css?f%5B%5D=switzer@400,500,600,700&amp;display=swap"
/>
</head>
<body>
{children}
<Footer />
<Toaster />
</body>
</html>
);
}

50
src/app/page.tsx Normal file
View File

@ -0,0 +1,50 @@
import CreateAppButton from "@/components/CreateAppButton";
import Navbar from "@/components/Navbar";
import Link from "next/link";
import { ChevronRightIcon } from "@radix-ui/react-icons";
export default function Page() {
return (
<div className="relative overflow-hidden">
<div className="absolute inset-2 bottom-0 rounded-[2rem] ring-1 ring-inset ring-black/5 bg-[linear-gradient(115deg,var(--tw-gradient-stops))] from-splash-1 from-[28%] via-splash-2 via-[70%] to-splash-3 sm:bg-[linear-gradient(145deg,var(--tw-gradient-stops))]"></div>
<div className="relative px-6 lg:px-8">
<div className="mx-auto max-w-2xl lg:max-w-7xl">
<div className="-ml-6 pt-12">
<Navbar
banner={
<Link
href="https://ssoready.com"
className="flex items-center gap-1 rounded-full bg-purple-950/35 px-3 py-0.5 text-sm/6 font-medium text-white hover:bg-purple-950/30"
>
Implementing SAML? Check out SSOReady
<ChevronRightIcon className="size-4" />
</Link>
}
/>
</div>
<div className="pb-24 pt-16 sm:pb-32 sm:pt-24 md:pb-48 md:pt-32">
<h1 className="font-display text-balance text-6xl/[0.9] font-medium tracking-tight text-gray-950 sm:text-8xl/[0.8] md:text-9xl/[0.8]">
SAML made easy
</h1>
<p className="mt-8 max-w-lg text-xl/7 font-medium text-gray-950/75 sm:text-2xl/8">
DummyIDP lets you test SAML and SCIM without setting up a
full-blown identity provider
</p>
<div className="mt-12 flex flex-col gap-x-6 gap-y-4 sm:flex-row">
<CreateAppButton />
<a
className="relative inline-flex items-center justify-center px-4 py-[calc(theme(spacing.2)-1px)] rounded-full border border-transparent bg-white/15 shadow-md ring-1 ring-[#D15052]/15 after:absolute after:inset-0 after:rounded-full after:shadow-[inset_0_0_2px_1px_#ffffff4d] whitespace-nowrap text-base font-medium text-gray-950 disabled:bg-white/15 hover:bg-white/20 disabled:opacity-40"
data-headlessui-state=""
href="https://ssoready.com/docs"
>
See docs
</a>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,15 @@
"use client";
import { createApp } from "@/app/actions";
export default function CreateAppButton() {
return (
<a
className="inline-flex items-center justify-center px-4 py-[calc(theme(spacing.2)-1px)] rounded-full border border-transparent bg-gray-950 shadow-md whitespace-nowrap text-base font-medium text-white disabled:bg-gray-950 hover:bg-gray-800 data-disabled:opacity-40"
onClick={() => createApp()}
href="#"
>
Get started
</a>
);
}

View File

@ -0,0 +1,14 @@
import { ArrowUpRightIcon } from "lucide-react";
import Link from "next/link";
export function DocsLink({ to }: { to: string }) {
return (
<Link
className="ml-4 hover:underline active:text-blue-800 text-xs font-semibold text-blue-600 inline-flex items-center"
href={to}
>
Docs
<ArrowUpRightIcon className="ml-0.5 h-3 w-3" />
</Link>
);
}

114
src/components/Footer.tsx Normal file
View File

@ -0,0 +1,114 @@
import { PlusGridItem, PlusGridRow } from "@/components/PlusGrid";
import Link from "next/link";
function SocialIconX(props: any) {
return (
<svg viewBox="0 0 16 16" fill="currentColor" {...props}>
<path d="M12.6 0h2.454l-5.36 6.778L16 16h-4.937l-3.867-5.594L2.771 16H.316l5.733-7.25L0 0h5.063l3.495 5.114L12.6 0zm-.86 14.376h1.36L4.323 1.539H2.865l8.875 12.837z" />
</svg>
);
}
function SocialIconLinkedIn(props: any) {
return (
<svg viewBox="0 0 16 16" fill="currentColor" {...props}>
<path d="M14.82 0H1.18A1.169 1.169 0 000 1.154v13.694A1.168 1.168 0 001.18 16h13.64A1.17 1.17 0 0016 14.845V1.15A1.171 1.171 0 0014.82 0zM4.744 13.64H2.369V5.996h2.375v7.644zm-1.18-8.684a1.377 1.377 0 11.52-.106 1.377 1.377 0 01-.527.103l.007.003zm10.075 8.683h-2.375V9.921c0-.885-.015-2.025-1.234-2.025-1.218 0-1.425.966-1.425 1.968v3.775H6.233V5.997H8.51v1.05h.032c.317-.601 1.09-1.235 2.246-1.235 2.405-.005 2.851 1.578 2.851 3.63v4.197z" />
</svg>
);
}
function SocialIconGitHub(props: any) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
viewBox="0 0 16 16"
{...props}
>
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8" />
</svg>
);
}
export function Footer() {
return (
<footer className="mt-16">
<div className="relative bg-[linear-gradient(115deg,var(--tw-gradient-stops))] from-splash-1 from-[28%] via-splash-2 via-[70%] to-splash-3 sm:bg-[linear-gradient(145deg,var(--tw-gradient-stops))]">
<div className="absolute inset-2 rounded-[2rem] bg-white/80"></div>
<div className="px-6 lg:px-8">
<div className="mx-auto max-w-7xl">
<div className="relative pb-16 pt-20 text-center sm:py-24">
<h2 className="font-mono text-xs/5 font-semibold uppercase tracking-widest text-gray-500 data-[dark]:text-gray-400">
Implement SAML + SCIM today
</h2>
<p className="mt-6 text-3xl font-medium tracking-tight text-gray-950 sm:text-5xl">
Implementing SAML SSO?
</p>
<p className="mx-auto mt-6 max-w-md text-sm/6 text-gray-500">
Free and open-source, SSOReady makes adding SAML and SCIM
support fast and easy for engineers.
</p>
<div className="mt-6 flex gap-x-6 justify-center">
<a
className="w-full sm:w-auto inline-flex items-center justify-center px-4 py-[calc(theme(spacing.2)-1px)] rounded-full border border-transparent bg-gray-950 shadow-md whitespace-nowrap text-base font-medium text-white disabled:bg-gray-950 hover:bg-gray-800 disabled:opacity-40"
href="https://ssoready.com"
>
Try SSOReady
</a>
<a
className="relative inline-flex items-center justify-center px-4 py-[calc(theme(spacing.2)-1px)] rounded-full border border-transparent bg-white/15 shadow-md ring-1 ring-[#D15052]/15 after:absolute after:inset-0 after:rounded-full after:shadow-[inset_0_0_2px_1px_#ffffff4d] whitespace-nowrap text-base font-medium text-gray-950 data-[disabled]:bg-white/15 hover:bg-white/20 disabled:opacity-40"
href="https://ssoready.com/docs"
>
Read the docs
</a>
</div>
</div>
<div className="pb-16">
<PlusGridRow className="flex justify-between">
<div>
<PlusGridItem className="py-3">
<div className="text-sm/6 text-gray-950">
&copy; 2024 Codomain Data Corporation (d.b.a. SSOReady)
</div>
</PlusGridItem>
</div>
<div className="flex">
<PlusGridItem className="flex items-center gap-8 py-3">
<Link
href="https://x.com/ssoready"
target="_blank"
aria-label="Visit us on X"
className="text-gray-950 data-[hover]:text-gray-950/75"
>
<SocialIconX className="size-4" />
</Link>
<Link
href="https://linkedin.com/company/ssoready"
target="_blank"
aria-label="Visit us on LinkedIn"
className="text-gray-950 data-[hover]:text-gray-950/75"
>
<SocialIconLinkedIn className="size-4" />
</Link>
<Link
href="https://github.com/ssoready/ssoready"
target="_blank"
aria-label="Visit us on GitHub"
className="text-gray-950 data-[hover]:text-gray-950/75"
>
<SocialIconGitHub className="size-4" />
</Link>
</PlusGridItem>
</div>
</PlusGridRow>
</div>
</div>
</div>
</div>
</footer>
);
}

View File

@ -0,0 +1,11 @@
export function GradientBackground() {
return (
<div className="relative mx-auto max-w-7xl">
<div
className={
"absolute -right-60 -top-44 h-60 w-[36rem] transform-gpu md:right-0 bg-[linear-gradient(115deg,var(--tw-gradient-stops))] from-splash-1 from-[28%] via-splash-2 via-[70%] to-splash-3 rotate-[-10deg] rounded-full blur-3xl"
}
/>
</div>
);
}

58
src/components/Navbar.tsx Normal file
View File

@ -0,0 +1,58 @@
import { PlusGridItem, PlusGridRow } from "@/components/PlusGrid";
import Link from "next/link";
import Image from "next/image";
import wordmark from "@/wordmark.svg";
import React from "react";
const links = [
{ href: "https://ssoready.com/docs/dummyidp", label: "Docs" },
{ href: "https://github.com/ssoready/dummyidp", label: "GitHub" },
{ href: "https://ssoready.com", label: "SSOReady" },
];
function DesktopNav() {
return (
<nav className="relative hidden lg:flex">
{links.map(({ href, label }) => (
<PlusGridItem key={href} className="relative flex">
<Link
href={href}
className="flex items-center px-4 py-3 text-base font-medium text-gray-950 bg-blend-multiply hover:bg-black/[2.5%]"
>
{label}
</Link>
</PlusGridItem>
))}
</nav>
);
}
export default function Navbar({ banner }: { banner?: React.ReactNode }) {
return (
<div className="px-8">
<div className="mx-auto max-w-7xl mt-12">
<div>
<PlusGridRow className="relative flex justify-between">
<div className="relative flex gap-6">
<PlusGridItem className="items-center flex">
<Link href="/" title="Home">
<Image
src={wordmark}
alt="wordmark"
className="h-12 w-auto"
/>
</Link>
</PlusGridItem>
{banner && (
<div className="relative hidden items-center py-3 lg:flex">
{banner}
</div>
)}
</div>
<DesktopNav />
</PlusGridRow>
</div>
</div>
</div>
);
}

View File

@ -1,52 +0,0 @@
import React, { useEffect } from "react";
import { Outlet } from "react-router";
const logMessage = `
/$$$$$$ /$$$$$$ /$$ /$$/$$
/$$__ $$/$$__ $| $$$ /$$| $$
| $$ \\__| $$ \\ $| $$$$ /$$$| $$
| $$$$$$| $$$$$$$| $$ $$/$$ $| $$
\\____ $| $$__ $| $$ $$$| $| $$
/$$ \\ $| $$ | $| $$\\ $ | $| $$
| $$$$$$| $$ | $| $$ \\/ | $| $$$$$$$$
\\______/|__/ |__|__/ |__|________/
/$$ /$$/$$ /$$$$$$
| $$ | $| $$ /$$__ $$
| $$ | $| $$/$$ /$| $$ \\__/$$$$$$
| $$$$$$$| $| $$ | $| $$$$ /$$__ $$
|_____ $| $| $$ | $| $$_/ | $$$$$$$$
| $| $| $$ | $| $$ | $$_____/
| $| $| $$$$$$| $$ | $$$$$$$
|__|__/\\____ $|__/ \\_______/
/$$ | $$
| $$$$$$/
\\______/
`;
export function Page() {
useEffect(() => {
console.log(logMessage);
}, []);
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,85 @@
import clsx from "clsx";
import React from "react";
export function PlusGridRow({
className = "",
children,
}: {
className?: string;
children?: React.ReactNode;
}): JSX.Element {
return (
<div
className={clsx(
className,
"group/row relative isolate pt-[calc(theme(spacing.2)+1px)] last:pb-[calc(theme(spacing.2)+1px)]",
)}
>
<div
aria-hidden="true"
className="absolute inset-y-0 left-1/2 -z-10 w-screen -translate-x-1/2"
>
<div className="absolute inset-x-0 top-0 border-t border-black/5"></div>
<div className="absolute inset-x-0 top-2 border-t border-black/5"></div>
<div className="absolute inset-x-0 bottom-0 hidden border-b border-black/5 group-last/row:block"></div>
<div className="absolute inset-x-0 bottom-2 hidden border-b border-black/5 group-last/row:block"></div>
</div>
{children}
</div>
);
}
export function PlusGridItem({
className = "",
children,
}: {
className?: string;
children?: React.ReactNode;
}) {
return (
<div className={clsx(className, "group/item relative")}>
<PlusGridIcon
placement="top left"
className="hidden group-first/item:block"
/>
<PlusGridIcon placement="top right" />
<PlusGridIcon
placement="bottom left"
className="hidden group-last/row:group-first/item:block"
/>
<PlusGridIcon
placement="bottom right"
className="hidden group-last/row:block"
/>
{children}
</div>
);
}
export function PlusGridIcon({
className = "",
placement,
}: {
className?: string;
placement: string;
}) {
let [yAxis, xAxis] = placement.split(" ");
let yClass = yAxis === "top" ? "-top-2" : "-bottom-2";
let xClass = xAxis === "left" ? "-left-2" : "-right-2";
return (
<svg
viewBox="0 0 15 15"
aria-hidden="true"
className={clsx(
className,
"absolute size-[15px] fill-black/10",
yClass,
xClass,
)}
>
<path d="M8 0H7V7H0V8H7V15H8V8H15V7H8V0Z" />
</svg>
);
}

View File

@ -0,0 +1,20 @@
import hljs from "highlight.js/lib/core";
import xml from "highlight.js/lib/languages/xml";
import formatXml from "xml-formatter";
hljs.registerLanguage("xml", xml);
export function XmlCodeBlock({ code }: { code: string }) {
return (
<div className="mt-4 text-xs font-mono text-gray-50 bg-gray-950 py-2 px-2 rounded-md max-w-full overflow-auto">
<code>
<pre
dangerouslySetInnerHTML={{
__html: hljs.highlight(code, {
language: "xml",
}).value,
}}
/>
</code>
</div>
);
}

View File

@ -0,0 +1,57 @@
"use client"
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDownIcon } from "@radix-ui/react-icons"
import { cn } from "@/lib/utils"
const Accordion = AccordionPrimitive.Root
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b", className)}
{...props}
/>
))
AccordionItem.displayName = "AccordionItem"
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDownIcon className="h-4 w-4 shrink-0 text-slate-500 transition-transform duration-200 dark:text-slate-400" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@ -1,139 +0,0 @@
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,115 @@
import * as React from "react"
import { ChevronRightIcon, DotsHorizontalIcon } from "@radix-ui/react-icons"
import { Slot } from "@radix-ui/react-slot"
import { cn } from "@/lib/utils"
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ReactNode
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
Breadcrumb.displayName = "Breadcrumb"
const BreadcrumbList = React.forwardRef<
HTMLOListElement,
React.ComponentPropsWithoutRef<"ol">
>(({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-slate-500 sm:gap-2.5 dark:text-slate-400",
className
)}
{...props}
/>
))
BreadcrumbList.displayName = "BreadcrumbList"
const BreadcrumbItem = React.forwardRef<
HTMLLIElement,
React.ComponentPropsWithoutRef<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
))
BreadcrumbItem.displayName = "BreadcrumbItem"
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
<Comp
ref={ref}
className={cn("transition-colors hover:text-slate-950 dark:hover:text-slate-50", className)}
{...props}
/>
)
})
BreadcrumbLink.displayName = "BreadcrumbLink"
const BreadcrumbPage = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<"span">
>(({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-slate-950 dark:text-slate-50", className)}
{...props}
/>
))
BreadcrumbPage.displayName = "BreadcrumbPage"
const BreadcrumbSeparator = ({
children,
className,
...props
}: React.ComponentProps<"li">) => (
<li
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRightIcon />}
</li>
)
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
const BreadcrumbEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<DotsHorizontalIcon className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
)
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

View File

@ -5,25 +5,26 @@ 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",
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-slate-950 disabled:pointer-events-none disabled:opacity-50 dark:focus-visible:ring-slate-300",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
default:
"bg-slate-900 text-slate-50 shadow hover:bg-slate-900/90 dark:bg-slate-50 dark:text-slate-900 dark:hover:bg-slate-50/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
"bg-red-500 text-slate-50 shadow-sm hover:bg-red-500/90 dark:bg-red-900 dark:text-slate-50 dark:hover:bg-red-900/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
"border border-slate-200 bg-white shadow-sm hover:bg-slate-100 hover:text-slate-900 dark:border-slate-800 dark:bg-slate-950 dark:hover:bg-slate-800 dark:hover:text-slate-50",
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",
"bg-slate-100 text-slate-900 shadow-sm hover:bg-slate-100/80 dark:bg-slate-800 dark:text-slate-50 dark:hover:bg-slate-800/80",
ghost: "hover:bg-slate-100 hover:text-slate-900 dark:hover:bg-slate-800 dark:hover:text-slate-50",
link: "text-slate-900 underline-offset-4 hover:underline dark:text-slate-50",
},
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",
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {

View File

@ -9,7 +9,7 @@ const Card = React.forwardRef<
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
"rounded-xl border border-slate-200 bg-white text-slate-950 shadow dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50",
className
)}
{...props}
@ -35,10 +35,7 @@ const CardTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
@ -50,7 +47,7 @@ const CardDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
className={cn("text-sm text-slate-500 dark:text-slate-400", className)}
{...props}
/>
))

View File

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

View File

@ -1,3 +1,5 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
@ -93,7 +95,7 @@ const FormLabel = React.forwardRef<
return (
<Label
ref={ref}
className={cn(error && "text-destructive", className)}
className={cn(error && "text-red-500 dark:text-red-900", className)}
htmlFor={formItemId}
{...props}
/>
@ -133,7 +135,7 @@ const FormDescription = React.forwardRef<
<p
ref={ref}
id={formDescriptionId}
className={cn("text-sm text-muted-foreground", className)}
className={cn("text-[0.8rem] text-slate-500 dark:text-slate-400", className)}
{...props}
/>
)
@ -155,7 +157,7 @@ const FormMessage = React.forwardRef<
<p
ref={ref}
id={formMessageId}
className={cn("text-sm font-medium text-destructive", className)}
className={cn("text-[0.8rem] font-medium text-red-500 dark:text-red-900", className)}
{...props}
>
{body}

View File

@ -11,7 +11,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<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",
"flex h-9 w-full rounded-md border border-slate-200 bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-slate-950 placeholder:text-slate-500 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-slate-950 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-800 dark:file:text-slate-50 dark:placeholder:text-slate-400 dark:focus-visible:ring-slate-300",
className
)}
ref={ref}

View File

@ -1,3 +1,5 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"

View File

@ -0,0 +1,164 @@
"use client"
import * as React from "react"
import {
CaretSortIcon,
CheckIcon,
ChevronDownIcon,
ChevronUpIcon,
} from "@radix-ui/react-icons"
import * as SelectPrimitive from "@radix-ui/react-select"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-slate-200 bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-white placeholder:text-slate-500 focus:outline-none focus:ring-1 focus:ring-slate-950 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1 dark:border-slate-800 dark:ring-offset-slate-950 dark:placeholder:text-slate-400 dark:focus:ring-slate-300",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<CaretSortIcon className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-slate-200 bg-white text-slate-950 shadow-md 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-slate-100 focus:text-slate-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-slate-800 dark:focus:text-slate-50",
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-slate-100 dark:bg-slate-800", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@ -1,29 +0,0 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

View File

@ -0,0 +1,31 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner } from "sonner"
type ToasterProps = React.ComponentProps<typeof Sonner>
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-white group-[.toaster]:text-slate-950 group-[.toaster]:border-slate-200 group-[.toaster]:shadow-lg dark:group-[.toaster]:bg-slate-950 dark:group-[.toaster]:text-slate-50 dark:group-[.toaster]:border-slate-800",
description: "group-[.toast]:text-slate-500 dark:group-[.toast]:text-slate-400",
actionButton:
"group-[.toast]:bg-slate-900 group-[.toast]:text-slate-50 dark:group-[.toast]:bg-slate-50 dark:group-[.toast]:text-slate-900",
cancelButton:
"group-[.toast]:bg-slate-100 group-[.toast]:text-slate-500 dark:group-[.toast]:bg-slate-800 dark:group-[.toast]:text-slate-400",
},
}}
{...props}
/>
)
}
export { Toaster }

120
src/components/ui/table.tsx Normal file
View File

@ -0,0 +1,120 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-slate-100/50 font-medium [&>tr]:last:border-b-0 dark:bg-slate-800/50",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-slate-100/50 data-[state=selected]:bg-slate-100 dark:hover:bg-slate-800/50 dark:data-[state=selected]:bg-slate-800",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-slate-500 [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px] dark:text-slate-400",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-slate-500 dark:text-slate-400", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@ -1,60 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 224 71.4% 4.1%;
--card: 0 0% 100%;
--card-foreground: 224 71.4% 4.1%;
--popover: 0 0% 100%;
--popover-foreground: 224 71.4% 4.1%;
--primary: 262.1 83.3% 57.8%;
--primary-foreground: 210 20% 98%;
--secondary: 220 14.3% 95.9%;
--secondary-foreground: 220.9 39.3% 11%;
--muted: 220 14.3% 95.9%;
--muted-foreground: 220 8.9% 46.1%;
--accent: 220 14.3% 95.9%;
--accent-foreground: 220.9 39.3% 11%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 20% 98%;
--border: 220 13% 91%;
--input: 220 13% 91%;
--ring: 262.1 83.3% 57.8%;
--radius: 0.5rem;
}
.dark {
--background: 224 71.4% 4.1%;
--foreground: 210 20% 98%;
--card: 224 71.4% 4.1%;
--card-foreground: 210 20% 98%;
--popover: 224 71.4% 4.1%;
--popover-foreground: 210 20% 98%;
--primary: 263.4 70% 50.4%;
--primary-foreground: 210 20% 98%;
--secondary: 215 27.9% 16.9%;
--secondary-foreground: 210 20% 98%;
--muted: 215 27.9% 16.9%;
--muted-foreground: 217.9 10.6% 64.9%;
--accent: 215 27.9% 16.9%;
--accent-foreground: 210 20% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 20% 98%;
--border: 215 27.9% 16.9%;
--input: 215 27.9% 16.9%;
--ring: 263.4 70% 50.4%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@ -1,6 +0,0 @@
import React from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
const root = createRoot(document.getElementById("react-root")!);
root.render(<App />);

View File

@ -1,33 +0,0 @@
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-----
`;

5
src/lib/insecure-cert.ts Normal file
View File

@ -0,0 +1,5 @@
export const INSECURE_PUBLIC_CERTIFICATE =
"-----BEGIN CERTIFICATE-----\nMIIDBzCCAe+gAwIBAgIUCLBK4f75EXEe4gyroYnVaqLoSp4wDQYJKoZIhvcNAQEL\nBQAwEzERMA8GA1UEAwwIZHVtbXlpZHAwHhcNMjQwNTEzMjE1NDE2WhcNMzQwNTEx\nMjE1NDE2WjATMREwDwYDVQQDDAhkdW1teWlkcDCCASIwDQYJKoZIhvcNAQEBBQAD\nggEPADCCAQoCggEBAKhmgQmWb8NvGhz952XY4SlJlpWIK72RilhOZS9frDYhqWVJ\nHsGH9Z7sSzrM/0+YvCyEWuZV9gpMeIaHZxEPDqW3RJ7KG51fn/s/qFvwctf+CZDj\nyfGDzYs+XIgf7p56U48EmYeWpB/aUW64gSbnPqrtWmVFBisOfIx5aY3NubtTsn+g\n0XbdX0L57+NgSvPQHXh/GPXA7xCIWm54G5kqjozxbKEFA0DS3yb6oHRQWHqIAM/7\nmJMdUVZNIV1q7c2JIgAl23uDWq+2KTE2R5liP/KjvjwKonVKtTqGqX6ei25rsTHO\naDpBH/LdQK2txgsm7R7+IThWNvUI0TttrmwBqyMCAwEAAaNTMFEwHQYDVR0OBBYE\nFD142gxIAJMhpgMkgpzmRNoW9XbEMB8GA1UdIwQYMBaAFD142gxIAJMhpgMkgpzm\nRNoW9XbEMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBADQd6k6z\nFIc20GfGHY5C2MFwyGOmP5/UG/JiTq7Zky28G6D0NA0je+GztzXx7VYDfCfHxLcm\n2k5t9nYhb9kVawiLUUDVF6s+yZUXA4gUA3KoTWh1/oRxR3ggW7dKYm9fsNOdQAbx\nUUkzp7HLZ45ZlpKUS0hO7es+fPyF5KVw0g0SrtQWwWucnQMAQE9m+B0aOf+92y7J\nQkdgdR8Gd/XZ4NZfoOnKV7A1utT4rWxYCgICeRTHx9tly5OhPW4hQr5qOpngcsJ9\nvhr86IjznQXhfj3hql5lA3VbHW04ro37ROIkh2bShDq5dwJJHpYCGrF3MQv8S3m+\njzGhYL6m9gFTm/8=\n-----END CERTIFICATE-----";
export const INSECURE_PRIVATE_KEY =
"eyJrdHkiOiJSU0EiLCJraWQiOiJoU0RJUlJOLUp1bWdjSjZZcnB2Q0hwYzBYNDZGTEF6V1hORnhQZG1mbXowIiwibiI6InFHYUJDWlp2dzI4YUhQM25aZGpoS1VtV2xZZ3J2WkdLV0U1bEwxLXNOaUdwWlVrZXdZZjFudXhMT3N6X1Q1aThMSVJhNWxYMkNreDRob2RuRVE4T3BiZEVuc29iblYtZi16LW9XX0J5MV80SmtPUEo4WVBOaXo1Y2lCX3VubnBUandTWmg1YWtIOXBSYnJpQkp1Yy1xdTFhWlVVR0t3NThqSGxwamMyNXUxT3lmNkRSZHQxZlF2bnY0MkJLODlBZGVIOFk5Y0R2RUloYWJuZ2JtU3FPalBGc29RVURRTkxmSnZxZ2RGQlllb2dBel91WWt4MVJWazBoWFdydHpZa2lBQ1hiZTROYXI3WXBNVFpIbVdJXzhxTy1QQXFpZFVxMU9vYXBmcDZMYm11eE1jNW9Pa0VmOHQxQXJhM0dDeWJ0SHY0aE9GWTI5UWpSTzIydWJBR3JJdyIsImUiOiJBUUFCIiwiZCI6IkVnZ2owZ3hfUEN5RjNjdmNQcjRaNGd0a3FlOVNTN0t0WHlaSjFHaElydVVzMTlFY0Qzb0k5WEwwM1Q5OUtSOUFLdjRqSTUzWndpR05HRTZnWFNYQkdrS0ZBUUhBTWpvLWphOHp6bUJ4VTZwNmlMNnpiWDZCQUdOMWtnZmxTNmZxa1pwYV9EZEhyTGQ2VjhJLTVoVUYwMVNtQk1qLXo1WjJCSzZ0ZkVjbWw2WENWRkJ6ZDBRcVp1QUNyZE85U2NadkwxY2Q0RENISkpOQmFyQ1ZnQzlha3hPaTlUSHRnRDcyRVlKUXlWY3NMYnpLcW94blQ5Slc2b25DQkJLdFVhUDhmRXpwOVdNSzZBUEpDa1lLNUktMWxYOHRTZjQ4LWx6Z0JybjNjYjlabzVEWWNHbzBiSmdWd3pJNHctSFNfZXRpT09yVVJtYmJYMXg5V19yR0JoUzh0USIsInAiOiI3SEg5UU1qWm1hRDBVdkR0ZHJYMWFCZ284WkNXWVNoTWZ4enQxZ0l5NmphU2dtY3lHbU1KZUI5cy1uQ0dvcHk4SXlkNGpsZ2dtZ25XUDRDcVlibDZmbXZaLTRVTEh1YzBER2FSMVA2emhzRjRmSTZ6Q3hXbTZpanlJWE03NVE2N2Njb2tMUWVSeklOS29PbWtFUTJoZ0ZLaGlhS040VFZmYW51ZkVDZnZxelUiLCJxIjoidGxQZkVvcWJHcVlKdHhLbE5EMGloWE1UalNWR1Rid1VCVVhVZHpZVXg3bktPR1FTZUNMWVhicG00S0lCRVlhNEdOcHV1Yjh2blpEOGMtRnV6SWIteUtINjZuQ2FfUTJxOHpoZk03NjRwQWFvOVAxT1NqYlhrQTdtTDZuTzZMd0FrN09GTmEyMnowSDVHVFJrelg4REppQUc5MHpfNjdxclFQbWZtRmZaN19jIiwiZHAiOiJ1QmVnU05xWW9ZYXg5QW5sdVhHLW1zZUV5Vl83MWJXY3FiT0tjZl9RU0k4b3p5TXQzV0dTczlZejhXR19VY2lhcXZ4R1h2MjZsSFJvUFpVZUUyeG9OUm9mNURjWUMzMlVCcnV0ZTVxN2lJWUdHMlMzZmpfamI1bGx2Q21PVHEtRHZmclc0OEJyQWtLT3ptNWE4WFFkZEYzaHE5bkdnYndlaURlc0J0WXhRcWsiLCJkcSI6Ik9EOWpvQnEyZ3VBYU95bzdZUVJETkJ3dU9lcjM1MTlRWmRnSEZjZlBYVlpKdGxfWS1zcjFLT1VxZTc0LXlpTmZnX3RQRVdxVHktNUFrNWRHVVQ2TU4yVVJQV1lEeW5GLV9ZMjBnUWdqZWlhNzFPaVlVSGpldzRoMUp0c2lBOWFMN3dVQTRYQjM1elNaSGxkMWlaZlhadG1XakpCcW0xUjVKSkJkMWVlMFNqMCIsInFpIjoieXByX1BZVTVVclZfTHcyMnZwYTh3Si1DX1V4OW5oaXo0V3RVNGpnRVhfUm9mZ01yYTRpRFhJVlVMNkJhaldZWjd2ajdfQUZOMnRFTFRNUTNYaGZjNEZRLUNCUWt2c0xvWE82Znp0dGR4QmRfeU8wMVl0aWppVF9ad296TzN3d3pWdU42NUVHTkNROWhhRjN2RVdFZlRFTl8teDdSRzZ4Wm5GdGVsNlg4cnFJIn0K";

View File

@ -1,27 +0,0 @@
import { useEffect, useState } from "react";
interface StoreData {
apps: Record<string, App>;
}
interface App {
id: string;
spAcsUrl: string;
spEntityId: string;
requiredDomain: 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);
},
];
}

View File

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

View File

@ -1,29 +0,0 @@
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();
setStoreData({
...storeData,
apps: {
...storeData.apps,
[id]: {
id,
spAcsUrl: "",
spEntityId: "",
requiredDomain: "",
},
},
});
navigate(`/apps/${id}`);
};
return <Button onClick={handleCreateApp}>Create new app</Button>;
}

View File

@ -1,45 +0,0 @@
import React, { useEffect } from "react";
import { createSearchParams, useSearchParams } from "react-router-dom";
import { useNavigate } from "react-router";
import { useStore } from "@/lib/store";
export function InstantSetupPage() {
const [storeData, setStoreData] = useStore();
const [searchParams] = useSearchParams();
const appId = searchParams.get("appId")!;
const spAcsUrl = searchParams.get("spAcsUrl")!;
const spEntityId = searchParams.get("spEntityId")!;
const requiredDomain = searchParams.get("requiredDomain")!;
const email = searchParams.get("email")!;
const firstName = searchParams.get("firstName")!;
const lastName = searchParams.get("lastName")!;
const navigate = useNavigate();
useEffect(() => {
setStoreData({
...storeData,
apps: {
...storeData.apps,
[appId]: {
id: appId,
spAcsUrl,
spEntityId,
requiredDomain,
},
},
});
navigate(
{
pathname: `/apps/${appId}/sso`,
search: createSearchParams({ email, firstName, lastName }).toString(),
},
{
replace: true,
},
);
}, [appId, spAcsUrl, spEntityId]);
return <></>;
}

View File

@ -1,234 +0,0 @@
import React, { useEffect, useRef, useState } from "react";
import { encodeAssertion } from "@/lib/saml";
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";
import { clsx } from "clsx";
import { Separator } from "@/components/ui/separator";
const formSchema = z.object({
email: z.string().min(1, { message: "Email is required." }),
firstName: z.string(),
lastName: z.string(),
});
export function SSOPage() {
const [storeData, _] = useStore();
const { appId } = useParams();
const app = storeData.apps[appId!];
const [searchParams] = useSearchParams();
const email = searchParams.get("email");
const firstName = searchParams.get("firstName");
const lastName = searchParams.get("lastName");
const samlRequest = searchParams.get("SAMLRequest");
const [sessionId, setSessionId] = useState("");
useEffect(() => {
if (!samlRequest) {
return;
}
const samlRequestXML = atob(
// url to std base64
samlRequest.replace(/-/g, "+").replace(/_/g, "/"),
);
const parser = new DOMParser();
const doc = parser.parseFromString(samlRequestXML, "text/xml");
// 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: email ?? "",
firstName: firstName ?? "",
lastName: lastName ?? "",
},
});
async function onSubmit(values: z.infer<typeof formSchema>, e: any) {
setLoading(true);
setTimeout(async () => {
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()).add(-1, "hour");
const expire = moment(new Date()).add(1, "hour");
inputRef.current!.value = await encodeAssertion(key, {
idpEntityId: `https://dummyidp.com/apps/${app.id}`,
subjectId: `${values.email}@${app.requiredDomain}`,
firstName: values.firstName,
lastName: values.lastName,
spEntityId: app.spEntityId,
sessionId: sessionId,
now: now.format(),
expire: expire.format(),
});
inputRef.current!.form!.action = app.spAcsUrl;
inputRef.current!.form!.submit();
}, 1000);
}
const [loading, setLoading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
return (
<div className="flex w-screen h-screen">
<form method="post">
<input type="hidden" name="SAMLResponse" ref={inputRef} />
</form>
<div className="m-auto max-w-xl relative">
{loading && (
<div className="absolute bg-black/80 inset-0 flex justify-center items-center z-10 dark text-white">
<img className="m-auto" alt="loading" src="/loading.gif" />
</div>
)}
<Card>
<CardHeader>
<CardTitle>Log on</CardTitle>
<CardDescription>
Enter some details about who you want DummyIDP to say you are.
</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>
<div className="flex">
<Input
className="rounded-r-none"
placeholder="wouldyoulikehelp"
{...field}
/>
<span className="inline-flex text-sm items-center rounded-r-md border border-l-0 border-input px-3 text-muted-foreground">
@{app.requiredDomain}
</span>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="firstName"
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>First Name</FormLabel>
<FormControl>
<Input placeholder="Clippy" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="lastName"
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Last Name</FormLabel>
<FormControl>
<Input placeholder="the Paperclip" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="mt-4 col-span-2">
<Button
className={clsx(
"w-full",
email &&
"bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 shadow-xl",
)}
>
Log on
</Button>
</div>
</form>
</Form>
<Separator className="my-8" />
<div className="text-xs text-muted-foreground space-y-2">
<p>
DummyIDP is a fake identity provider. It's a dummy stand-in for
something like Okta, Google Workspace, or Microsoft Entra.
</p>
<p>
In the real world, your customers would never see or interact
with DummyIDP in any way. In the real world, your customers
would see their own IDP, not this fake one.
</p>
<p>
We made DummyIDP because there doesn't exist any free, no-hassle
SAML Identity Provider out there that developers can test with.
</p>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -1,265 +0,0 @@
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(),
requiredDomain: z
.string()
.min(1, { message: "You must supply a required domain." }),
});
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,
requiredDomain: app?.requiredDomain || "",
},
});
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,
requiredDomain: values.requiredDomain,
},
},
});
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>
)}
/>
<FormField
control={form.control}
name="requiredDomain"
render={({ field }) => (
<FormItem>
<FormLabel>Email Domain</FormLabel>
<FormControl>
<Input placeholder="example.com" {...field} />
</FormControl>
<FormDescription>
When doing SSO logins from this app, this is the
domain your email must come from.
</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 className="text-sm col-span-1 text-muted-foreground">
Email Domain
</div>
<div className="text-sm col-span-3">{app.requiredDomain}</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://sso.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>
);
}

6
src/wordmark.svg Normal file
View File

@ -0,0 +1,6 @@
<svg width="985" height="256" viewBox="0 0 985 256" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M261.664 174V162.736H285.856C293.195 162.736 299.467 161.243 304.672 158.256C309.963 155.269 314.016 151.131 316.832 145.84C319.648 140.549 321.056 134.405 321.056 127.408C321.056 120.496 319.648 114.48 316.832 109.36C314.101 104.24 310.091 100.272 304.8 97.456C299.595 94.5547 293.28 93.104 285.856 93.104H261.792V81.84H285.856C295.755 81.84 304.373 83.7173 311.712 87.472C319.051 91.2267 324.725 96.56 328.736 103.472C332.832 110.299 334.88 118.363 334.88 127.664C334.88 136.965 332.832 145.115 328.736 152.112C324.64 159.024 318.923 164.4 311.584 168.24C304.331 172.08 295.797 174 285.984 174H261.664ZM253.472 174V81.84H267.04V174H253.472ZM370.342 175.28C365.99 175.28 362.15 174.384 358.822 172.592C355.494 170.715 352.891 168.155 351.014 164.912C349.222 161.669 348.326 157.957 348.326 153.776V108.72H361.638V150.704C361.638 155.227 362.747 158.597 364.966 160.816C367.185 163.035 370.342 164.144 374.438 164.144C378.107 164.144 381.35 163.291 384.166 161.584C387.067 159.877 389.329 157.531 390.95 154.544C392.657 151.472 393.51 147.931 393.51 143.92L395.174 159.536C393.041 164.315 389.755 168.155 385.318 171.056C380.966 173.872 375.974 175.28 370.342 175.28ZM394.022 174V158.64H393.51V108.72H406.694V174H394.022ZM425.344 174V108.72H438.016V124.08H438.656V174H425.344ZM469.376 174V131.376C469.376 126.939 468.352 123.696 466.304 121.648C464.256 119.6 461.312 118.576 457.472 118.576C453.888 118.576 450.645 119.429 447.744 121.136C444.928 122.757 442.709 125.061 441.088 128.048C439.467 130.949 438.656 134.405 438.656 138.416L436.992 123.184C439.125 118.32 442.325 114.48 446.592 111.664C450.944 108.848 455.808 107.44 461.184 107.44C467.669 107.44 472.832 109.317 476.672 113.072C480.597 116.827 482.56 121.776 482.56 127.92V174H469.376ZM513.408 174V131.376C513.408 126.939 512.341 123.696 510.208 121.648C508.16 119.6 505.216 118.576 501.376 118.576C497.877 118.576 494.677 119.429 491.776 121.136C488.96 122.757 486.699 125.061 484.992 128.048C483.371 130.949 482.56 134.405 482.56 138.416L479.872 123.184C482.091 118.32 485.419 114.48 489.856 111.664C494.379 108.848 499.413 107.44 504.96 107.44C511.531 107.44 516.779 109.36 520.704 113.2C524.629 116.955 526.592 121.989 526.592 128.304V174H513.408ZM544.469 174V108.72H557.141V124.08H557.781V174H544.469ZM588.501 174V131.376C588.501 126.939 587.477 123.696 585.429 121.648C583.381 119.6 580.437 118.576 576.597 118.576C573.013 118.576 569.77 119.429 566.869 121.136C564.053 122.757 561.834 125.061 560.213 128.048C558.592 130.949 557.781 134.405 557.781 138.416L556.117 123.184C558.25 118.32 561.45 114.48 565.717 111.664C570.069 108.848 574.933 107.44 580.309 107.44C586.794 107.44 591.957 109.317 595.797 113.072C599.722 116.827 601.685 121.776 601.685 127.92V174H588.501ZM632.533 174V131.376C632.533 126.939 631.466 123.696 629.333 121.648C627.285 119.6 624.341 118.576 620.501 118.576C617.002 118.576 613.802 119.429 610.901 121.136C608.085 122.757 605.824 125.061 604.117 128.048C602.496 130.949 601.685 134.405 601.685 138.416L598.997 123.184C601.216 118.32 604.544 114.48 608.981 111.664C613.504 108.848 618.538 107.44 624.085 107.44C630.656 107.44 635.904 109.36 639.829 113.2C643.754 116.955 645.717 121.989 645.717 128.304V174H632.533ZM665.708 200.24L681.452 164.272L684.14 160.048L703.98 108.72H717.804L679.404 200.24H665.708ZM680.812 174L652.908 108.72H667.372L691.052 167.472L680.812 174ZM728.847 174V81.84H742.415V174H728.847ZM769.539 174V162.736H793.731C801.07 162.736 807.342 161.243 812.547 158.256C817.838 155.269 821.891 151.131 824.707 145.84C827.523 140.549 828.931 134.405 828.931 127.408C828.931 120.496 827.523 114.48 824.707 109.36C821.976 104.24 817.966 100.272 812.675 97.456C807.47 94.5547 801.155 93.104 793.731 93.104H769.667V81.84H793.731C803.63 81.84 812.248 83.7173 819.587 87.472C826.926 91.2267 832.6 96.56 836.611 103.472C840.707 110.299 842.755 118.363 842.755 127.664C842.755 136.965 840.707 145.115 836.611 152.112C832.515 159.024 826.798 164.4 819.459 168.24C812.206 172.08 803.672 174 793.859 174H769.539ZM761.347 174V81.84H774.915V174H761.347ZM857.722 174V81.84H896.762C903.418 81.84 909.093 82.9493 913.786 85.168C918.479 87.3867 922.063 90.544 924.538 94.64C927.098 98.736 928.378 103.6 928.378 109.232C928.378 114.949 927.098 119.899 924.538 124.08C922.063 128.176 918.437 131.376 913.658 133.68C908.965 135.984 903.333 137.136 896.762 137.136H868.09V125.872H896.122C902.181 125.872 906.789 124.421 909.946 121.52C913.189 118.619 914.81 114.565 914.81 109.36C914.81 104.069 913.231 100.016 910.074 97.2C906.917 94.384 902.266 92.976 896.122 92.976H871.29V174H857.722Z" fill="black"/>
<path d="M146 136C146 129.635 143.471 123.53 138.971 119.029C134.47 114.529 128.365 112 122 112C115.635 112 109.53 114.529 105.029 119.029C100.529 123.53 98 129.635 98 136" stroke="black" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M58 188V68C58 62.6957 60.1071 57.6086 63.8579 53.8579C67.6086 50.1071 72.6957 48 78 48H178C180.122 48 182.157 48.8429 183.657 50.3431C185.157 51.8434 186 53.8783 186 56V200C186 202.122 185.157 204.157 183.657 205.657C182.157 207.157 180.122 208 178 208H78C72.6957 208 67.6086 205.893 63.8579 202.142C60.1071 198.391 58 193.304 58 188ZM58 188C58 182.696 60.1071 177.609 63.8579 173.858C67.6086 170.107 72.6957 168 78 168H186" stroke="black" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M122 112C130.837 112 138 104.837 138 96C138 87.1634 130.837 80 122 80C113.163 80 106 87.1634 106 96C106 104.837 113.163 112 122 112Z" stroke="black" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 5.6 KiB

View File

@ -1,22 +1,31 @@
import { fontFamily } from "tailwindcss/defaultTheme";
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ["class"],
content: ["./src/**/*.{ts,tsx}", "./public/index.html"],
prefix: "",
content: [
"./app/**/*.{js,ts,jsx,tsx,mdx}",
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
// Or if using `src` directory:
"./src/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
container: {
center: true,
center: "true",
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
fontFamily: {
sans: `"Inter", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"`,
mono: `"Roboto Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace`,
},
extend: {
colors: {
splash: {
1: "#ddd6fe",
2: "#99f6e4",
3: "#bfdbfe",
},
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
@ -56,14 +65,25 @@ module.exports = {
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
fontFamily: {
sans: ["var(--font-sans)", ...fontFamily.sans],
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
from: {
height: "0",
},
to: {
height: "var(--radix-accordion-content-height)",
},
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
from: {
height: "var(--radix-accordion-content-height)",
},
to: {
height: "0",
},
},
},
animation: {

View File

@ -2,16 +2,38 @@
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
"@/*": ["src/*"]
},
"rootDir": "src",
"module": "commonjs",
"target": "es2015",
"sourceMap": true,
"jsx": "react",
"allowSyntheticDefaultImports": true,
"lib": ["es2017", "dom"],
"strict": true
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"incremental": true,
"module": "esnext",
"esModuleInterop": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"plugins": [
{
"name": "next"
}
]
},
"exclude": ["node_modules"]
"include": [
"next-env.d.ts",
".next/types/**/*.ts",
"**/*.ts",
"**/*.tsx"
],
"exclude": [
"node_modules"
]
}