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:
parent
cacfa55999
commit
7f173bcabf
5
.env.development.local
Normal file
5
.env.development.local
Normal 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"
|
||||||
@ -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
10
.gitignore
vendored
@ -1,9 +1,3 @@
|
|||||||
/node_modules
|
/node_modules
|
||||||
/public
|
/.next
|
||||||
!/public/index.html
|
.vercel
|
||||||
!/public/logo.png
|
|
||||||
!/public/wordart.png
|
|
||||||
!/public/loading.gif
|
|
||||||
|
|
||||||
# Local Netlify folder
|
|
||||||
.netlify
|
|
||||||
|
|||||||
2
.npmrc
2
.npmrc
@ -1,2 +0,0 @@
|
|||||||
@fortawesome:registry=https://npm.fontawesome.com/
|
|
||||||
//npm.fontawesome.com/:_authToken=${FONTAWESOME_NPM_AUTH_TOKEN}
|
|
||||||
29
build.mjs
29
build.mjs
@ -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();
|
|
||||||
}
|
|
||||||
@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://ui.shadcn.com/schema.json",
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
"style": "default",
|
"style": "new-york",
|
||||||
"rsc": false,
|
"rsc": true,
|
||||||
"tsx": true,
|
"tsx": true,
|
||||||
"tailwind": {
|
"tailwind": {
|
||||||
"config": "tailwind.config.js",
|
"config": "tailwind.config.js",
|
||||||
"css": "src/index.css",
|
"css": "src/app/global.css",
|
||||||
"baseColor": "slate",
|
"baseColor": "slate",
|
||||||
"cssVariables": true,
|
"cssVariables": false,
|
||||||
"prefix": ""
|
"prefix": ""
|
||||||
},
|
},
|
||||||
"aliases": {
|
"aliases": {
|
||||||
|
|||||||
@ -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);
|
|
||||||
19
dummyidp.crt
19
dummyidp.crt
@ -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-----
|
|
||||||
28
dummyidp.key
28
dummyidp.key
@ -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-----
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
[[redirects]]
|
|
||||||
from = "/*"
|
|
||||||
to = "/index.html"
|
|
||||||
status = 200
|
|
||||||
@ -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
5
next-env.d.ts
vendored
Normal 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
8
next.config.js
Normal 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
9964
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
87
package.json
87
package.json
@ -1,71 +1,40 @@
|
|||||||
{
|
{
|
||||||
"name": "app",
|
|
||||||
"version": "0.0.0",
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "npm-run-all tailwind esbuild",
|
"dev": "next",
|
||||||
"dev": "dotenv -- npm-run-all -l -p serve tailwind-watch esbuild-watch tsc-watch",
|
"build": "next build"
|
||||||
"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 ."
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bufbuild/buf": "^1.31.0",
|
"@floating-ui/react": "^0.26.24",
|
||||||
"@bufbuild/protobuf": "^1.9.0",
|
"@hookform/resolvers": "^3.9.0",
|
||||||
"@connectrpc/connect": "^1.4.0",
|
"@radix-ui/react-accordion": "^1.2.0",
|
||||||
"@connectrpc/connect-query": "^1.3.1",
|
"@radix-ui/react-icons": "^1.3.0",
|
||||||
"@connectrpc/connect-web": "^1.4.0",
|
"@radix-ui/react-label": "^2.1.0",
|
||||||
"@hookform/resolvers": "^3.3.4",
|
"@radix-ui/react-select": "^2.1.1",
|
||||||
"@netlify/functions": "^2.7.0",
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
"@radix-ui/react-alert-dialog": "^1.0.5",
|
"@vercel/kv": "^3.0.0",
|
||||||
"@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",
|
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dotenv-cli": "^7.4.1",
|
"highlight.js": "^11.10.0",
|
||||||
"eckles": "^1.4.1",
|
"lucide-react": "^0.446.0",
|
||||||
"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",
|
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
|
"next": "^14.2.13",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
"node-jose": "^2.2.0",
|
"prettier": "^3.3.3",
|
||||||
"npm-run-all": "^4.1.5",
|
"react-hook-form": "^7.53.0",
|
||||||
"prettier": "^3.2.5",
|
"sonner": "^1.5.0",
|
||||||
"react": "^18.3.1",
|
"tailwind-merge": "^2.5.2",
|
||||||
"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",
|
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"typescript": "^5.4.5",
|
"ulid": "^2.3.0",
|
||||||
"uuid": "^9.0.1",
|
"xml-formatter": "^3.6.3",
|
||||||
"xml-formatter": "^3.6.2",
|
|
||||||
"zod": "^3.23.8"
|
"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
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@ -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 |
BIN
public/logo.png
BIN
public/logo.png
Binary file not shown.
|
Before Width: | Height: | Size: 50 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 52 KiB |
22
src/App.tsx
22
src/App.tsx
@ -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
124
src/app/actions.ts
Normal 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
22
src/app/app.ts
Normal 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`;
|
||||||
|
}
|
||||||
91
src/app/apps/[id]/SCIMSettingsForm.tsx
Normal file
91
src/app/apps/[id]/SCIMSettingsForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
src/app/apps/[id]/SPSettingsForm.tsx
Normal file
86
src/app/apps/[id]/SPSettingsForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
src/app/apps/[id]/SimulateLoginButton.tsx
Normal file
60
src/app/apps/[id]/SimulateLoginButton.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
160
src/app/apps/[id]/UsersSettingsForm.tsx
Normal file
160
src/app/apps/[id]/UsersSettingsForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
src/app/apps/[id]/login/LoginCard.tsx
Normal file
107
src/app/apps/[id]/login/LoginCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
168
src/app/apps/[id]/login/LoginForm.tsx
Normal file
168
src/app/apps/[id]/login/LoginForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
src/app/apps/[id]/login/page.tsx
Normal file
72
src/app/apps/[id]/login/page.tsx
Normal 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
174
src/app/apps/[id]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
src/app/apps/[id]/sso/route.ts
Normal file
11
src/app/apps/[id]/sso/route.ts
Normal 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
81
src/app/globals.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/app/instant-setup/route.ts
Normal file
22
src/app/instant-setup/route.ts
Normal 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
47
src/app/layout.tsx
Normal 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&display=swap"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{children}
|
||||||
|
<Footer />
|
||||||
|
<Toaster />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
src/app/page.tsx
Normal file
50
src/app/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
src/components/CreateAppButton.tsx
Normal file
15
src/components/CreateAppButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
src/components/DocsLink.tsx
Normal file
14
src/components/DocsLink.tsx
Normal 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
114
src/components/Footer.tsx
Normal 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">
|
||||||
|
© 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
src/components/GradientBackground.tsx
Normal file
11
src/components/GradientBackground.tsx
Normal 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
58
src/components/Navbar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
85
src/components/PlusGrid.tsx
Normal file
85
src/components/PlusGrid.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
src/components/XmlCodeBlock.tsx
Normal file
20
src/components/XmlCodeBlock.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
src/components/ui/accordion.tsx
Normal file
57
src/components/ui/accordion.tsx
Normal 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 }
|
||||||
@ -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,
|
|
||||||
}
|
|
||||||
115
src/components/ui/breadcrumb.tsx
Normal file
115
src/components/ui/breadcrumb.tsx
Normal 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,
|
||||||
|
}
|
||||||
@ -5,25 +5,26 @@ import { cva, type VariantProps } from "class-variance-authority"
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const buttonVariants = cva(
|
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: {
|
variants: {
|
||||||
variant: {
|
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:
|
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:
|
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:
|
secondary:
|
||||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
"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-accent hover:text-accent-foreground",
|
ghost: "hover:bg-slate-100 hover:text-slate-900 dark:hover:bg-slate-800 dark:hover:text-slate-50",
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: "text-slate-900 underline-offset-4 hover:underline dark:text-slate-50",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "h-10 px-4 py-2",
|
default: "h-9 px-4 py-2",
|
||||||
sm: "h-9 rounded-md px-3",
|
sm: "h-8 rounded-md px-3 text-xs",
|
||||||
lg: "h-11 rounded-md px-8",
|
lg: "h-10 rounded-md px-8",
|
||||||
icon: "h-10 w-10",
|
icon: "h-9 w-9",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
|
|||||||
@ -9,7 +9,7 @@ const Card = React.forwardRef<
|
|||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@ -35,10 +35,7 @@ const CardTitle = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<h3
|
<h3
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||||
"text-2xl font-semibold leading-none tracking-tight",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
@ -50,7 +47,7 @@ const CardDescription = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<p
|
<p
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("text-sm text-muted-foreground", className)}
|
className={cn("text-sm text-slate-500 dark:text-slate-400", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
|
|||||||
@ -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 }
|
|
||||||
@ -1,3 +1,5 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
@ -93,7 +95,7 @@ const FormLabel = React.forwardRef<
|
|||||||
return (
|
return (
|
||||||
<Label
|
<Label
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(error && "text-destructive", className)}
|
className={cn(error && "text-red-500 dark:text-red-900", className)}
|
||||||
htmlFor={formItemId}
|
htmlFor={formItemId}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
@ -133,7 +135,7 @@ const FormDescription = React.forwardRef<
|
|||||||
<p
|
<p
|
||||||
ref={ref}
|
ref={ref}
|
||||||
id={formDescriptionId}
|
id={formDescriptionId}
|
||||||
className={cn("text-sm text-muted-foreground", className)}
|
className={cn("text-[0.8rem] text-slate-500 dark:text-slate-400", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@ -155,7 +157,7 @@ const FormMessage = React.forwardRef<
|
|||||||
<p
|
<p
|
||||||
ref={ref}
|
ref={ref}
|
||||||
id={formMessageId}
|
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}
|
{...props}
|
||||||
>
|
>
|
||||||
{body}
|
{body}
|
||||||
|
|||||||
@ -11,7 +11,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|||||||
164
src/components/ui/select.tsx
Normal file
164
src/components/ui/select.tsx
Normal 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,
|
||||||
|
}
|
||||||
@ -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 }
|
|
||||||
31
src/components/ui/sonner.tsx
Normal file
31
src/components/ui/sonner.tsx
Normal 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
120
src/components/ui/table.tsx
Normal 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,
|
||||||
|
}
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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 />);
|
|
||||||
33
src/key.ts
33
src/key.ts
@ -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
5
src/lib/insecure-cert.ts
Normal 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";
|
||||||
@ -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);
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { type ClassValue, clsx } from "clsx"
|
import { clsx, type ClassValue } from "clsx";
|
||||||
import { twMerge } from "tailwind-merge"
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>;
|
|
||||||
}
|
|
||||||
@ -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 <></>;
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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
6
src/wordmark.svg
Normal 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 |
@ -1,22 +1,31 @@
|
|||||||
|
import { fontFamily } from "tailwindcss/defaultTheme";
|
||||||
|
|
||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
darkMode: ["class"],
|
darkMode: ["class"],
|
||||||
content: ["./src/**/*.{ts,tsx}", "./public/index.html"],
|
content: [
|
||||||
prefix: "",
|
"./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: {
|
theme: {
|
||||||
container: {
|
container: {
|
||||||
center: true,
|
center: "true",
|
||||||
padding: "2rem",
|
padding: "2rem",
|
||||||
screens: {
|
screens: {
|
||||||
"2xl": "1400px",
|
"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: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
|
splash: {
|
||||||
|
1: "#ddd6fe",
|
||||||
|
2: "#99f6e4",
|
||||||
|
3: "#bfdbfe",
|
||||||
|
},
|
||||||
border: "hsl(var(--border))",
|
border: "hsl(var(--border))",
|
||||||
input: "hsl(var(--input))",
|
input: "hsl(var(--input))",
|
||||||
ring: "hsl(var(--ring))",
|
ring: "hsl(var(--ring))",
|
||||||
@ -56,14 +65,25 @@ module.exports = {
|
|||||||
md: "calc(var(--radius) - 2px)",
|
md: "calc(var(--radius) - 2px)",
|
||||||
sm: "calc(var(--radius) - 4px)",
|
sm: "calc(var(--radius) - 4px)",
|
||||||
},
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ["var(--font-sans)", ...fontFamily.sans],
|
||||||
|
},
|
||||||
keyframes: {
|
keyframes: {
|
||||||
"accordion-down": {
|
"accordion-down": {
|
||||||
from: { height: "0" },
|
from: {
|
||||||
to: { height: "var(--radix-accordion-content-height)" },
|
height: "0",
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
height: "var(--radix-accordion-content-height)",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"accordion-up": {
|
"accordion-up": {
|
||||||
from: { height: "var(--radix-accordion-content-height)" },
|
from: {
|
||||||
to: { height: "0" },
|
height: "var(--radix-accordion-content-height)",
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
height: "0",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
animation: {
|
animation: {
|
||||||
|
|||||||
@ -2,16 +2,38 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["src/*"]
|
||||||
},
|
},
|
||||||
"rootDir": "src",
|
|
||||||
"module": "commonjs",
|
|
||||||
"target": "es2015",
|
"target": "es2015",
|
||||||
"sourceMap": true,
|
"lib": [
|
||||||
"jsx": "react",
|
"dom",
|
||||||
"allowSyntheticDefaultImports": true,
|
"dom.iterable",
|
||||||
"lib": ["es2017", "dom"],
|
"esnext"
|
||||||
"strict": true
|
],
|
||||||
|
"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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user