diff --git a/next-env.d.ts b/next-env.d.ts index 40c3d68..725dd6f 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,5 +1,6 @@ /// /// +/// // NOTE: This file should not be edited // see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. diff --git a/package-lock.json b/package-lock.json index 074f6bf..245c744 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-select": "^2.1.1", "@radix-ui/react-slot": "^1.1.0", + "@tanstack/react-query": "^5.59.16", "@vercel/kv": "^3.0.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", @@ -1129,6 +1130,32 @@ "tslib": "^2.4.0" } }, + "node_modules/@tanstack/query-core": { + "version": "5.59.16", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.59.16.tgz", + "integrity": "sha512-crHn+G3ltqb5JG0oUv6q+PMz1m1YkjpASrXTU+sYWW9pLk0t2GybUHNRqYPZWhxgjPaVGC4yp92gSFEJgYEsPw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.59.16", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.59.16.tgz", + "integrity": "sha512-MuyWheG47h6ERd4PKQ6V8gDyBu3ThNG22e1fRVwvq6ap3EqsFhyuxCAwhNP/03m/mLg+DAb0upgbPaX6VB+CkQ==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.59.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@types/node": { "version": "22.7.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.4.tgz", diff --git a/package.json b/package.json index d200ee3..372a129 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-select": "^2.1.1", "@radix-ui/react-slot": "^1.1.0", + "@tanstack/react-query": "^5.59.16", "@vercel/kv": "^3.0.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..d6f3f63 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/app/actions.ts b/src/app/actions.ts index 08271ad..6a0336c 100644 --- a/src/app/actions.ts +++ b/src/app/actions.ts @@ -1,123 +1,13 @@ "use server"; -import { kv } from "@vercel/kv"; import { redirect } from "next/navigation"; -import { ulid } from "ulid"; -import { App } from "@/app/app"; +import * as libapp from "@/lib/app"; 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", - }, - ], - }); + const id = await libapp.createApp(); redirect(`/apps/${id}`); } -export async function getApp(id: string): Promise { - const result = await kv.hgetall(id); - if (!result) { - return undefined; - } - - return result as unknown as App; -} - -export async function upsertApp(app: App): Promise { - // 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 { - 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; +export async function upsertApp(app: libapp.App): Promise { + await libapp.upsertApp(app); } diff --git a/src/app/app.ts b/src/app/app.ts deleted file mode 100644 index 93e46a6..0000000 --- a/src/app/app.ts +++ /dev/null @@ -1,30 +0,0 @@ -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.DUMMYIDP_CUSTOM_DOMAIN || process.env.VERCEL_URL}/apps/${app.id}/sso`; -} - -export function appIdpMetadataUrl(app: App): string { - return `https://${process.env.DUMMYIDP_CUSTOM_DOMAIN || process.env.VERCEL_URL}/apps/${app.id}/metadata`; -} - -export function appLoginUrl(app: App): string { - return `https://${process.env.DUMMYIDP_CUSTOM_DOMAIN || process.env.VERCEL_URL}/apps/${app.id}/login`; -} diff --git a/src/app/apps/[id]/metadata/route.ts b/src/app/apps/[id]/metadata/route.ts index b1cdf11..c8e5a1a 100644 --- a/src/app/apps/[id]/metadata/route.ts +++ b/src/app/apps/[id]/metadata/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; -import { getApp } from "@/app/actions"; -import { appIdpEntityId, appIdpRedirectUrl, appLoginUrl } from "@/app/app"; +import { getApp } from "@/lib/app"; +import { appIdpEntityId, appIdpRedirectUrl, appLoginUrl } from "@/lib/app"; import { INSECURE_PUBLIC_CERTIFICATE } from "@/lib/insecure-cert"; export async function GET( diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 4de9ae8..834c29a 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,9 +1,10 @@ import { Inter, Roboto_Mono } from "next/font/google"; import { Toaster } from "@/components/ui/sonner"; -import "./globals.css"; +import "@/pages/globals.css"; import { Footer } from "@/components/Footer"; import { Metadata } from "next"; +import React from "react"; const inter = Inter({ subsets: ["latin"], diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx new file mode 100644 index 0000000..de5d183 --- /dev/null +++ b/src/components/Layout.tsx @@ -0,0 +1,37 @@ +import { Inter, Roboto_Mono } from "next/font/google"; +import { Toaster } from "@/components/ui/sonner"; +import { Footer } from "@/components/Footer"; +import Navbar from "@/components/Navbar"; +import { GradientBackground } from "@/components/GradientBackground"; +import Head from "next/head"; +import Link from "next/link"; + +const inter = Inter({ + subsets: ["latin"], + variable: "--font-sans", + display: "swap", +}); + +const robotoMono = Roboto_Mono({ + subsets: ["latin"], + variable: "--font-mono", + display: "swap", +}); + +export default function Layout({ children }: { children: React.ReactNode }) { + return ( +
+ + + + +
+ + + {children} +
+ +
+
+ ); +} diff --git a/src/app/apps/[id]/login/LoginCard.tsx b/src/components/LoginCard.tsx similarity index 96% rename from src/app/apps/[id]/login/LoginCard.tsx rename to src/components/LoginCard.tsx index 67585d2..49520c4 100644 --- a/src/app/apps/[id]/login/LoginCard.tsx +++ b/src/components/LoginCard.tsx @@ -1,5 +1,3 @@ -"use client"; - import { Card, CardContent, @@ -7,7 +5,7 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import { LoginForm } from "@/app/apps/[id]/login/LoginForm"; +import { LoginForm } from "@/components/LoginForm"; import Link from "next/link"; import { Accordion, @@ -17,7 +15,7 @@ import { } from "@/components/ui/accordion"; import { XmlCodeBlock } from "@/components/XmlCodeBlock"; import formatXml from "xml-formatter"; -import { App } from "@/app/app"; +import { App } from "@/lib/app"; import { useState } from "react"; import { DocsLink } from "@/components/DocsLink"; diff --git a/src/app/apps/[id]/login/LoginForm.tsx b/src/components/LoginForm.tsx similarity index 98% rename from src/app/apps/[id]/login/LoginForm.tsx rename to src/components/LoginForm.tsx index 2e01a7b..89dcac4 100644 --- a/src/app/apps/[id]/login/LoginForm.tsx +++ b/src/components/LoginForm.tsx @@ -1,5 +1,3 @@ -"use client"; - import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import { z } from "zod"; @@ -22,7 +20,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { App, appIdpEntityId, AppUser } from "@/app/app"; +import { App, appIdpEntityId, AppUser } from "@/lib/app"; import { Accordion, AccordionContent, diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 0da02a0..c5c929e 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -3,7 +3,6 @@ import Link from "next/link"; import Image from "next/image"; import wordmark from "@/wordmark.svg"; import React from "react"; -import { InlineCreateAppLink } from "@/components/CreateAppButton"; const links = [ { href: "https://ssoready.com/docs/dummyidp", label: "Docs" }, @@ -24,13 +23,6 @@ function DesktopNav() { ))} - - -
- Create a DummyIDP App -
-
-
); } diff --git a/src/app/apps/[id]/SCIMSettingsForm.tsx b/src/components/SCIMSettingsForm.tsx similarity index 93% rename from src/app/apps/[id]/SCIMSettingsForm.tsx rename to src/components/SCIMSettingsForm.tsx index 2e55274..f3cf0f7 100644 --- a/src/app/apps/[id]/SCIMSettingsForm.tsx +++ b/src/components/SCIMSettingsForm.tsx @@ -3,7 +3,7 @@ import { z } from "zod"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; -import { App } from "@/app/app"; +import { App } from "@/lib/app"; import { Form, FormControl, @@ -15,8 +15,8 @@ import { } 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 { useUpsertApp } from "@/lib/hooks"; const formSchema = z.object({ scimBaseUrl: z @@ -41,8 +41,9 @@ export function SCIMSettingsForm({ app }: { app: App }) { }, }); + const upsertApp = useUpsertApp(); async function onSubmit(values: z.infer) { - await upsertApp({ + await upsertApp.mutateAsync({ ...app, scimBaseUrl: values.scimBaseUrl, scimBearerToken: values.scimBearerToken, diff --git a/src/app/apps/[id]/SPSettingsForm.tsx b/src/components/SPSettingsForm.tsx similarity index 93% rename from src/app/apps/[id]/SPSettingsForm.tsx rename to src/components/SPSettingsForm.tsx index a8445a3..d45bfd6 100644 --- a/src/app/apps/[id]/SPSettingsForm.tsx +++ b/src/components/SPSettingsForm.tsx @@ -3,7 +3,7 @@ import { z } from "zod"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; -import { App } from "@/app/app"; +import { App } from "@/lib/app"; import { Form, FormControl, @@ -15,8 +15,8 @@ import { } 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 { useUpsertApp } from "@/lib/hooks"; const formSchema = z.object({ spAcsUrl: z.string().min(1, { @@ -36,8 +36,9 @@ export function SPSettingsForm({ app }: { app: App }) { }, }); + const upsertApp = useUpsertApp(); async function onSubmit(values: z.infer) { - await upsertApp({ + await upsertApp.mutateAsync({ ...app, spAcsUrl: values.spAcsUrl, spEntityId: values.spEntityId, diff --git a/src/app/apps/[id]/SimulateLoginButton.tsx b/src/components/SimulateLoginButton.tsx similarity index 84% rename from src/app/apps/[id]/SimulateLoginButton.tsx rename to src/components/SimulateLoginButton.tsx index 6e92e95..841b958 100644 --- a/src/app/apps/[id]/SimulateLoginButton.tsx +++ b/src/components/SimulateLoginButton.tsx @@ -1,7 +1,7 @@ "use client"; import { Button } from "@/components/ui/button"; -import { App } from "@/app/app"; +import { App } from "@/lib/app"; import { autoUpdate, offset, @@ -12,10 +12,9 @@ import { } 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; +export function SimulateLoginButton({ app }: { app: App | undefined }) { + const disabled = !app?.spAcsUrl || !app?.spEntityId; const [open, setOpen] = useState(false); const { refs, floatingStyles, context } = useFloating({ @@ -35,7 +34,7 @@ export function SimulateLoginButton({ app }: { app: App }) {