Move UI pages for app and login into pages router (#12)

The app router is causing too much grief to be worthwhile here. The
pages for individual apps are fine to have in the pages router -- we're
fine with straight-up SPA stuff -- so just stop using app router rather
than figure out how to get Next.js to understand the data-fetching
lifecycle.
This commit is contained in:
Ulysse Carion 2024-10-29 15:02:10 -07:00 committed by GitHub
parent 8ea1061fb1
commit acbef8f1ef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 370 additions and 251 deletions

1
next-env.d.ts vendored
View File

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

27
package-lock.json generated
View File

@ -12,6 +12,7 @@
"@radix-ui/react-label": "^2.1.0", "@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-select": "^2.1.1", "@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-slot": "^1.1.0",
"@tanstack/react-query": "^5.59.16",
"@vercel/kv": "^3.0.0", "@vercel/kv": "^3.0.0",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@ -1129,6 +1130,32 @@
"tslib": "^2.4.0" "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": { "node_modules/@types/node": {
"version": "22.7.4", "version": "22.7.4",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.4.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.4.tgz",

View File

@ -11,6 +11,7 @@
"@radix-ui/react-label": "^2.1.0", "@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-select": "^2.1.1", "@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-slot": "^1.1.0",
"@tanstack/react-query": "^5.59.16",
"@vercel/kv": "^3.0.0", "@vercel/kv": "^3.0.0",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",

5
public/favicon.svg Normal file
View File

@ -0,0 +1,5 @@
<svg width="192" height="192" viewBox="0 0 192 192" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M120 104C120 97.6348 117.471 91.5303 112.971 87.0294C108.47 82.5286 102.365 80 96 80C89.6348 80 83.5303 82.5286 79.0294 87.0294C74.5286 91.5303 72 97.6348 72 104" stroke="black" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M32 156V36C32 30.6957 34.1071 25.6086 37.8579 21.8579C41.6086 18.1071 46.6957 16 52 16H152C154.122 16 156.157 16.8429 157.657 18.3431C159.157 19.8434 160 21.8783 160 24V168C160 170.122 159.157 172.157 157.657 173.657C156.157 175.157 154.122 176 152 176H52C46.6957 176 41.6086 173.893 37.8579 170.142C34.1071 166.391 32 161.304 32 156ZM32 156C32 150.696 34.1071 145.609 37.8579 141.858C41.6086 138.107 46.6957 136 52 136H160" stroke="black" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M96 80C104.837 80 112 72.8366 112 64C112 55.1634 104.837 48 96 48C87.1634 48 80 55.1634 80 64C80 72.8366 87.1634 80 96 80Z" stroke="black" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -1,123 +1,13 @@
"use server"; "use server";
import { kv } from "@vercel/kv";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { ulid } from "ulid"; import * as libapp from "@/lib/app";
import { App } from "@/app/app";
export async function createApp() { export async function createApp() {
const id = `app_${ulid().toLowerCase()}`; const id = await libapp.createApp();
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}`); redirect(`/apps/${id}`);
} }
export async function getApp(id: string): Promise<App | undefined> { export async function upsertApp(app: libapp.App): Promise<void> {
const result = await kv.hgetall(id); await libapp.upsertApp(app);
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;
} }

View File

@ -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`;
}

View File

@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getApp } from "@/app/actions"; import { getApp } from "@/lib/app";
import { appIdpEntityId, appIdpRedirectUrl, appLoginUrl } from "@/app/app"; import { appIdpEntityId, appIdpRedirectUrl, appLoginUrl } from "@/lib/app";
import { INSECURE_PUBLIC_CERTIFICATE } from "@/lib/insecure-cert"; import { INSECURE_PUBLIC_CERTIFICATE } from "@/lib/insecure-cert";
export async function GET( export async function GET(

View File

@ -1,9 +1,10 @@
import { Inter, Roboto_Mono } from "next/font/google"; import { Inter, Roboto_Mono } from "next/font/google";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import "./globals.css"; import "@/pages/globals.css";
import { Footer } from "@/components/Footer"; import { Footer } from "@/components/Footer";
import { Metadata } from "next"; import { Metadata } from "next";
import React from "react";
const inter = Inter({ const inter = Inter({
subsets: ["latin"], subsets: ["latin"],

37
src/components/Layout.tsx Normal file
View File

@ -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 (
<div className={`${inter.variable} ${robotoMono.variable}`}>
<Head>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
</Head>
<div className="overflow-hidden">
<GradientBackground />
<Navbar />
{children}
<Footer />
<Toaster />
</div>
</div>
);
}

View File

@ -1,5 +1,3 @@
"use client";
import { import {
Card, Card,
CardContent, CardContent,
@ -7,7 +5,7 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { LoginForm } from "@/app/apps/[id]/login/LoginForm"; import { LoginForm } from "@/components/LoginForm";
import Link from "next/link"; import Link from "next/link";
import { import {
Accordion, Accordion,
@ -17,7 +15,7 @@ import {
} from "@/components/ui/accordion"; } from "@/components/ui/accordion";
import { XmlCodeBlock } from "@/components/XmlCodeBlock"; import { XmlCodeBlock } from "@/components/XmlCodeBlock";
import formatXml from "xml-formatter"; import formatXml from "xml-formatter";
import { App } from "@/app/app"; import { App } from "@/lib/app";
import { useState } from "react"; import { useState } from "react";
import { DocsLink } from "@/components/DocsLink"; import { DocsLink } from "@/components/DocsLink";

View File

@ -1,5 +1,3 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
@ -22,7 +20,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { App, appIdpEntityId, AppUser } from "@/app/app"; import { App, appIdpEntityId, AppUser } from "@/lib/app";
import { import {
Accordion, Accordion,
AccordionContent, AccordionContent,

View File

@ -3,7 +3,6 @@ import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import wordmark from "@/wordmark.svg"; import wordmark from "@/wordmark.svg";
import React from "react"; import React from "react";
import { InlineCreateAppLink } from "@/components/CreateAppButton";
const links = [ const links = [
{ href: "https://ssoready.com/docs/dummyidp", label: "Docs" }, { href: "https://ssoready.com/docs/dummyidp", label: "Docs" },
@ -24,13 +23,6 @@ function DesktopNav() {
</Link> </Link>
</PlusGridItem> </PlusGridItem>
))} ))}
<PlusGridItem className="relative flex">
<InlineCreateAppLink>
<div className="flex items-center px-4 py-3 text-base font-medium text-gray-950 bg-blend-multiply hover:bg-black/[2.5%]">
Create a DummyIDP App
</div>
</InlineCreateAppLink>
</PlusGridItem>
</nav> </nav>
); );
} }

View File

@ -3,7 +3,7 @@
import { z } from "zod"; import { z } from "zod";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { App } from "@/app/app"; import { App } from "@/lib/app";
import { import {
Form, Form,
FormControl, FormControl,
@ -15,8 +15,8 @@ import {
} from "@/components/ui/form"; } from "@/components/ui/form";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { upsertApp } from "@/app/actions";
import { toast } from "sonner"; import { toast } from "sonner";
import { useUpsertApp } from "@/lib/hooks";
const formSchema = z.object({ const formSchema = z.object({
scimBaseUrl: z scimBaseUrl: z
@ -41,8 +41,9 @@ export function SCIMSettingsForm({ app }: { app: App }) {
}, },
}); });
const upsertApp = useUpsertApp();
async function onSubmit(values: z.infer<typeof formSchema>) { async function onSubmit(values: z.infer<typeof formSchema>) {
await upsertApp({ await upsertApp.mutateAsync({
...app, ...app,
scimBaseUrl: values.scimBaseUrl, scimBaseUrl: values.scimBaseUrl,
scimBearerToken: values.scimBearerToken, scimBearerToken: values.scimBearerToken,

View File

@ -3,7 +3,7 @@
import { z } from "zod"; import { z } from "zod";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { App } from "@/app/app"; import { App } from "@/lib/app";
import { import {
Form, Form,
FormControl, FormControl,
@ -15,8 +15,8 @@ import {
} from "@/components/ui/form"; } from "@/components/ui/form";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { upsertApp } from "@/app/actions";
import { toast } from "sonner"; import { toast } from "sonner";
import { useUpsertApp } from "@/lib/hooks";
const formSchema = z.object({ const formSchema = z.object({
spAcsUrl: z.string().min(1, { spAcsUrl: z.string().min(1, {
@ -36,8 +36,9 @@ export function SPSettingsForm({ app }: { app: App }) {
}, },
}); });
const upsertApp = useUpsertApp();
async function onSubmit(values: z.infer<typeof formSchema>) { async function onSubmit(values: z.infer<typeof formSchema>) {
await upsertApp({ await upsertApp.mutateAsync({
...app, ...app,
spAcsUrl: values.spAcsUrl, spAcsUrl: values.spAcsUrl,
spEntityId: values.spEntityId, spEntityId: values.spEntityId,

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { App } from "@/app/app"; import { App } from "@/lib/app";
import { import {
autoUpdate, autoUpdate,
offset, offset,
@ -12,10 +12,9 @@ import {
} from "@floating-ui/react"; } from "@floating-ui/react";
import { useState } from "react"; import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation";
export function SimulateLoginButton({ app }: { app: App }) { export function SimulateLoginButton({ app }: { app: App | undefined }) {
const disabled = !app.spAcsUrl || !app.spEntityId; const disabled = !app?.spAcsUrl || !app?.spEntityId;
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const { refs, floatingStyles, context } = useFloating({ const { refs, floatingStyles, context } = useFloating({
@ -35,7 +34,7 @@ export function SimulateLoginButton({ app }: { app: App }) {
<div ref={refs.setReference} {...getReferenceProps()}> <div ref={refs.setReference} {...getReferenceProps()}>
<Button asChild> <Button asChild>
<Link <Link
href={`/apps/${app.id}/login`} href={`/apps/${app?.id}/login`}
aria-disabled={disabled} aria-disabled={disabled}
className="aria-disabled:pointer-events-none aria-disabled:opacity-50" className="aria-disabled:pointer-events-none aria-disabled:opacity-50"
> >

View File

@ -3,7 +3,7 @@
import { z } from "zod"; import { z } from "zod";
import { useFieldArray, useForm } from "react-hook-form"; import { useFieldArray, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { App } from "@/app/app"; import { App } from "@/lib/app";
import { import {
Form, Form,
FormControl, FormControl,
@ -15,7 +15,6 @@ import {
} from "@/components/ui/form"; } from "@/components/ui/form";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { upsertApp } from "@/app/actions";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
Table, Table,
@ -26,6 +25,7 @@ import {
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { TrashIcon } from "@radix-ui/react-icons"; import { TrashIcon } from "@radix-ui/react-icons";
import { useUpsertApp } from "@/lib/hooks";
const formSchema = z.object({ const formSchema = z.object({
users: z.array( users: z.array(
@ -53,8 +53,9 @@ export function UsersSettingsForm({ app }: { app: App }) {
control: form.control, control: form.control,
}); });
const upsertApp = useUpsertApp();
async function onSubmit(values: z.infer<typeof formSchema>) { async function onSubmit(values: z.infer<typeof formSchema>) {
await upsertApp({ await upsertApp.mutateAsync({
...app, ...app,
users: values.users, users: values.users,
}); });

150
src/lib/app.ts Normal file
View File

@ -0,0 +1,150 @@
import { ulid } from "ulid";
import { kv } from "@vercel/kv";
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`;
}
export async function createApp(): Promise<string> {
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",
},
],
});
return 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;
}

33
src/lib/hooks.ts Normal file
View File

@ -0,0 +1,33 @@
import { App } from "@/lib/app";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
export function useApp(id: string | undefined): App | undefined {
const { data } = useQuery({
enabled: !!id,
queryKey: ["apps", id],
queryFn: async () => {
const response = await fetch(`/api/apps?id=${id}`);
if (!response.ok) {
return;
}
return response.json();
},
});
return data;
}
export function useUpsertApp() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (app: App) => {
await fetch(`/api/apps?id=${app.id}`, {
method: "POST",
body: JSON.stringify(app),
});
await queryClient.invalidateQueries({
queryKey: ["apps", app.id],
});
},
});
}

14
src/pages/_app.tsx Normal file
View File

@ -0,0 +1,14 @@
import type { AppProps } from "next/app";
import "./globals.css";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient();
export default function App({ Component, pageProps }: AppProps) {
return (
<QueryClientProvider client={queryClient}>
<Component {...pageProps} />
</QueryClientProvider>
);
}

36
src/pages/api/apps.ts Normal file
View File

@ -0,0 +1,36 @@
import { NextApiRequest, NextApiResponse } from "next";
import { App, getApp, upsertApp } from "@/lib/app";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
switch (req.method) {
case "GET":
await GET(req, res);
return;
case "POST":
await POST(req, res);
return;
default:
res.status(405);
return;
}
}
async function GET(req: NextApiRequest, res: NextApiResponse) {
const id = req.query["id"] as string;
const app = await getApp(id);
if (!app) {
res.status(404).send({});
}
res.status(200).json(app);
}
async function POST(req: NextApiRequest, res: NextApiResponse) {
const app = JSON.parse(req.body) as App;
await upsertApp(app);
res.status(200).send({});
}

View File

@ -1,7 +1,6 @@
import { getApp } from "@/app/actions"; import Layout from "@/components/Layout";
import { PlusGridItem, PlusGridRow } from "@/components/PlusGrid"; import { useApp } from "@/lib/hooks";
import Link from "next/link"; import { useRouter } from "next/router";
import Navbar from "@/components/Navbar";
import { import {
Card, Card,
CardContent, CardContent,
@ -23,36 +22,21 @@ import {
appIdpEntityId, appIdpEntityId,
appIdpMetadataUrl, appIdpMetadataUrl,
appIdpRedirectUrl, appIdpRedirectUrl,
} from "@/app/app"; } from "@/lib/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";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { SimulateLoginButton } from "@/components/SimulateLoginButton";
import { INSECURE_PUBLIC_CERTIFICATE } from "@/lib/insecure-cert";
import { SPSettingsForm } from "@/components/SPSettingsForm";
import { SCIMSettingsForm } from "@/components/SCIMSettingsForm";
import { UsersSettingsForm } from "@/components/UsersSettingsForm";
export const metadata: Metadata = { export default function Page() {
title: "App", const router = useRouter();
}; const app = useApp(router.query.id as string);
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;base64,${btoa(INSECURE_PUBLIC_CERTIFICATE)}`; const certificateDownloadURL = `data:text/plain;base64,${btoa(INSECURE_PUBLIC_CERTIFICATE)}`;
return ( return (
<div className="overflow-hidden"> <Layout>
<GradientBackground />
<Navbar />
<div className="px-8"> <div className="px-8">
<div className="mx-auto max-w-7xl"> <div className="mx-auto max-w-7xl">
<Breadcrumb className="mt-8"> <Breadcrumb className="mt-8">
@ -64,8 +48,8 @@ export default async function Page({ params }: { params: { id: string } }) {
<BreadcrumbItem>Apps</BreadcrumbItem> <BreadcrumbItem>Apps</BreadcrumbItem>
<BreadcrumbSeparator /> <BreadcrumbSeparator />
<BreadcrumbItem> <BreadcrumbItem>
<BreadcrumbLink href={`/apps/${app.id}`}> <BreadcrumbLink href={`/apps/${app?.id}`}>
{app.id} {app?.id}
</BreadcrumbLink> </BreadcrumbLink>
</BreadcrumbItem> </BreadcrumbItem>
</BreadcrumbList> </BreadcrumbList>
@ -73,7 +57,7 @@ export default async function Page({ params }: { params: { id: string } }) {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="mt-2 text-3xl font-semibold">{app.id}</h1> <h1 className="mt-2 text-3xl font-semibold">{app?.id}</h1>
<p className="mt-1 text-muted-foreground"> <p className="mt-1 text-muted-foreground">
A DummyIDP app lets you emulate your customer's identity A DummyIDP app lets you emulate your customer's identity
provider. provider.
@ -102,21 +86,21 @@ export default async function Page({ params }: { params: { id: string } }) {
<div> <div>
<Label>IDP Metadata URL</Label> <Label>IDP Metadata URL</Label>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{appIdpMetadataUrl(app)} {app && appIdpMetadataUrl(app)}
</div> </div>
</div> </div>
<div> <div>
<Label>IDP Entity ID</Label> <Label>IDP Entity ID</Label>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{appIdpEntityId(app)} {app && appIdpEntityId(app)}
</div> </div>
</div> </div>
<div> <div>
<Label>IDP Redirect URL</Label> <Label>IDP Redirect URL</Label>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{appIdpRedirectUrl(app)} {app && appIdpRedirectUrl(app)}
</div> </div>
</div> </div>
@ -145,16 +129,14 @@ export default async function Page({ params }: { params: { id: string } }) {
from your application into here. from your application into here.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>{app && <SPSettingsForm app={app} />}</CardContent>
<SPSettingsForm app={app} />
</CardContent>
</Card> </Card>
<Card className="col-span-2"> <Card className="col-span-2">
<CardHeader> <CardHeader>
<CardTitle> <CardTitle>
SCIM Settings SCIM Settings
<DocsLink to="https://ssoready.com/docs/dummyidp#scim-settings" /> <DocsLink to="https://ssoready.com/docs/dummyidp#scim-settings" />
{app.scimBaseUrl && app.scimBearerToken && ( {app?.scimBaseUrl && app?.scimBearerToken && (
<Badge className="ml-4" variant="outline"> <Badge className="ml-4" variant="outline">
Syncing Syncing
<span className="ml-2 relative flex h-3 w-3"> <span className="ml-2 relative flex h-3 w-3">
@ -168,9 +150,7 @@ export default async function Page({ params }: { params: { id: string } }) {
Settings for directory syncing over SCIM. Optional. Settings for directory syncing over SCIM. Optional.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>{app && <SCIMSettingsForm app={app} />}</CardContent>
<SCIMSettingsForm app={app} />
</CardContent>
</Card> </Card>
<Card className="col-span-4"> <Card className="col-span-4">
<CardHeader> <CardHeader>
@ -184,12 +164,12 @@ export default async function Page({ params }: { params: { id: string } }) {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<UsersSettingsForm app={app} /> {app && <UsersSettingsForm app={app} />}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</div> </div>
</div> </div>
</div> </Layout>
); );
} }

View File

@ -1,4 +1,3 @@
import Navbar from "@/components/Navbar";
import { import {
Breadcrumb, Breadcrumb,
BreadcrumbItem, BreadcrumbItem,
@ -6,37 +5,22 @@ import {
BreadcrumbList, BreadcrumbList,
BreadcrumbSeparator, BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"; } from "@/components/ui/breadcrumb";
import { getApp } from "@/app/actions"; import LoginCard from "@/components/LoginCard";
import { GradientBackground } from "@/components/GradientBackground";
import LoginCard from "@/app/apps/[id]/login/LoginCard";
import { Metadata } from "next";
import { DocsLink } from "@/components/DocsLink"; import { DocsLink } from "@/components/DocsLink";
import Layout from "@/components/Layout";
import { useRouter } from "next/router";
import { useApp } from "@/lib/hooks";
export const metadata: Metadata = { export default function Page() {
title: "Simulate SAML Login", const router = useRouter();
}; const app = useApp(router.query.id as string);
export default async function Page({ const samlRequest = router.query.SAMLRequest
params, ? atob(router.query.SAMLRequest as string)
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 ( return (
<div className="overflow-hidden"> <Layout>
<GradientBackground />
<Navbar />
<div className="px-8"> <div className="px-8">
<div className="mx-auto max-w-7xl"> <div className="mx-auto max-w-7xl">
<Breadcrumb className="mt-8"> <Breadcrumb className="mt-8">
@ -48,8 +32,8 @@ export default async function Page({
<BreadcrumbItem>Apps</BreadcrumbItem> <BreadcrumbItem>Apps</BreadcrumbItem>
<BreadcrumbSeparator /> <BreadcrumbSeparator />
<BreadcrumbItem> <BreadcrumbItem>
<BreadcrumbLink href={`/apps/${app.id}`}> <BreadcrumbLink href={`/apps/${app?.id}`}>
{app.id} {app?.id}
</BreadcrumbLink> </BreadcrumbLink>
</BreadcrumbItem> </BreadcrumbItem>
<BreadcrumbSeparator /> <BreadcrumbSeparator />
@ -64,9 +48,9 @@ export default async function Page({
<DocsLink to="https://ssoready.com/docs/dummyidp#simulating-saml-logins" /> <DocsLink to="https://ssoready.com/docs/dummyidp#simulating-saml-logins" />
</p> </p>
<LoginCard app={app} samlRequest={samlRequest} /> {app && <LoginCard app={app} samlRequest={samlRequest} />}
</div> </div>
</div> </div>
</div> </Layout>
); );
} }

View File

@ -78,4 +78,4 @@
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
} }