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:
parent
8ea1061fb1
commit
acbef8f1ef
1
next-env.d.ts
vendored
1
next-env.d.ts
vendored
@ -1,5 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference types="next/navigation-types/compat/navigation" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||
|
||||
27
package-lock.json
generated
27
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
5
public/favicon.svg
Normal file
5
public/favicon.svg
Normal 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 |
@ -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<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;
|
||||
export async function upsertApp(app: libapp.App): Promise<void> {
|
||||
await libapp.upsertApp(app);
|
||||
}
|
||||
|
||||
@ -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`;
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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"],
|
||||
|
||||
37
src/components/Layout.tsx
Normal file
37
src/components/Layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -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,
|
||||
@ -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() {
|
||||
</Link>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<typeof formSchema>) {
|
||||
await upsertApp({
|
||||
await upsertApp.mutateAsync({
|
||||
...app,
|
||||
scimBaseUrl: values.scimBaseUrl,
|
||||
scimBearerToken: values.scimBearerToken,
|
||||
@ -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<typeof formSchema>) {
|
||||
await upsertApp({
|
||||
await upsertApp.mutateAsync({
|
||||
...app,
|
||||
spAcsUrl: values.spAcsUrl,
|
||||
spEntityId: values.spEntityId,
|
||||
@ -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 }) {
|
||||
<div ref={refs.setReference} {...getReferenceProps()}>
|
||||
<Button asChild>
|
||||
<Link
|
||||
href={`/apps/${app.id}/login`}
|
||||
href={`/apps/${app?.id}/login`}
|
||||
aria-disabled={disabled}
|
||||
className="aria-disabled:pointer-events-none aria-disabled:opacity-50"
|
||||
>
|
||||
@ -3,7 +3,7 @@
|
||||
import { z } from "zod";
|
||||
import { useFieldArray, 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,7 +15,6 @@ 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 {
|
||||
Table,
|
||||
@ -26,6 +25,7 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { TrashIcon } from "@radix-ui/react-icons";
|
||||
import { useUpsertApp } from "@/lib/hooks";
|
||||
|
||||
const formSchema = z.object({
|
||||
users: z.array(
|
||||
@ -53,8 +53,9 @@ export function UsersSettingsForm({ app }: { app: App }) {
|
||||
control: form.control,
|
||||
});
|
||||
|
||||
const upsertApp = useUpsertApp();
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
await upsertApp({
|
||||
await upsertApp.mutateAsync({
|
||||
...app,
|
||||
users: values.users,
|
||||
});
|
||||
150
src/lib/app.ts
Normal file
150
src/lib/app.ts
Normal 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
33
src/lib/hooks.ts
Normal 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
14
src/pages/_app.tsx
Normal 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
36
src/pages/api/apps.ts
Normal 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({});
|
||||
}
|
||||
@ -1,7 +1,6 @@
|
||||
import { getApp } from "@/app/actions";
|
||||
import { PlusGridItem, PlusGridRow } from "@/components/PlusGrid";
|
||||
import Link from "next/link";
|
||||
import Navbar from "@/components/Navbar";
|
||||
import Layout from "@/components/Layout";
|
||||
import { useApp } from "@/lib/hooks";
|
||||
import { useRouter } from "next/router";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@ -23,36 +22,21 @@ import {
|
||||
appIdpEntityId,
|
||||
appIdpMetadataUrl,
|
||||
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";
|
||||
} from "@/lib/app";
|
||||
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 = {
|
||||
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>;
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const router = useRouter();
|
||||
const app = useApp(router.query.id as string);
|
||||
const certificateDownloadURL = `data:text/plain;base64,${btoa(INSECURE_PUBLIC_CERTIFICATE)}`;
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden">
|
||||
<GradientBackground />
|
||||
<Navbar />
|
||||
<Layout>
|
||||
<div className="px-8">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<Breadcrumb className="mt-8">
|
||||
@ -64,8 +48,8 @@ export default async function Page({ params }: { params: { id: string } }) {
|
||||
<BreadcrumbItem>Apps</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href={`/apps/${app.id}`}>
|
||||
{app.id}
|
||||
<BreadcrumbLink href={`/apps/${app?.id}`}>
|
||||
{app?.id}
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
@ -73,7 +57,7 @@ export default async function Page({ params }: { params: { id: string } }) {
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<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">
|
||||
A DummyIDP app lets you emulate your customer's identity
|
||||
provider.
|
||||
@ -102,21 +86,21 @@ export default async function Page({ params }: { params: { id: string } }) {
|
||||
<div>
|
||||
<Label>IDP Metadata URL</Label>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{appIdpMetadataUrl(app)}
|
||||
{app && appIdpMetadataUrl(app)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>IDP Entity ID</Label>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{appIdpEntityId(app)}
|
||||
{app && appIdpEntityId(app)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>IDP Redirect URL</Label>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{appIdpRedirectUrl(app)}
|
||||
{app && appIdpRedirectUrl(app)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -145,16 +129,14 @@ export default async function Page({ params }: { params: { id: string } }) {
|
||||
from your application into here.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<SPSettingsForm app={app} />
|
||||
</CardContent>
|
||||
<CardContent>{app && <SPSettingsForm app={app} />}</CardContent>
|
||||
</Card>
|
||||
<Card className="col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
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">
|
||||
Syncing
|
||||
<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.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<SCIMSettingsForm app={app} />
|
||||
</CardContent>
|
||||
<CardContent>{app && <SCIMSettingsForm app={app} />}</CardContent>
|
||||
</Card>
|
||||
<Card className="col-span-4">
|
||||
<CardHeader>
|
||||
@ -184,12 +164,12 @@ export default async function Page({ params }: { params: { id: string } }) {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<UsersSettingsForm app={app} />
|
||||
{app && <UsersSettingsForm app={app} />}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@ -1,4 +1,3 @@
|
||||
import Navbar from "@/components/Navbar";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
@ -6,37 +5,22 @@ import {
|
||||
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 LoginCard from "@/components/LoginCard";
|
||||
import { DocsLink } from "@/components/DocsLink";
|
||||
import Layout from "@/components/Layout";
|
||||
import { useRouter } from "next/router";
|
||||
import { useApp } from "@/lib/hooks";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Simulate SAML Login",
|
||||
};
|
||||
export default function Page() {
|
||||
const router = useRouter();
|
||||
const app = useApp(router.query.id as string);
|
||||
|
||||
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)
|
||||
const samlRequest = router.query.SAMLRequest
|
||||
? atob(router.query.SAMLRequest as string)
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden">
|
||||
<GradientBackground />
|
||||
<Navbar />
|
||||
|
||||
<Layout>
|
||||
<div className="px-8">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<Breadcrumb className="mt-8">
|
||||
@ -48,8 +32,8 @@ export default async function Page({
|
||||
<BreadcrumbItem>Apps</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href={`/apps/${app.id}`}>
|
||||
{app.id}
|
||||
<BreadcrumbLink href={`/apps/${app?.id}`}>
|
||||
{app?.id}
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
@ -64,9 +48,9 @@ export default async function Page({
|
||||
<DocsLink to="https://ssoready.com/docs/dummyidp#simulating-saml-logins" />
|
||||
</p>
|
||||
|
||||
<LoginCard app={app} samlRequest={samlRequest} />
|
||||
{app && <LoginCard app={app} samlRequest={samlRequest} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@ -78,4 +78,4 @@
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user