From 5927f92a1e5b95da32498b1d74b9bfdb78adf649 Mon Sep 17 00:00:00 2001 From: biersoeckli Date: Mon, 3 Aug 2026 20:56:14 +0000 Subject: [PATCH 1/5] feat: implement SSO provider for auth management with CRUD operations and UI components --- .../migration.sql | 16 ++ prisma/schema.prisma | 28 +- src/app/api/auth/[...nextauth]/route.ts | 14 +- src/app/auth/login-form.tsx | 25 +- src/app/settings/users/actions.ts | 17 ++ src/app/settings/users/page.tsx | 9 +- .../users/sso-provider-edit-overlay.tsx | 258 ++++++++++++++++++ .../settings/users/sso-providers-table.tsx | 68 +++++ src/server/services/sso-provider.service.ts | 111 ++++++++ src/server/services/user.service.ts | 24 ++ src/server/utils/action-wrapper.utils.ts | 4 +- src/server/utils/auth-options.ts | 196 ++++++++----- src/server/utils/cache-tag-generator.utils.ts | 4 + src/shared/model/generated-zod/index.ts | 1 + src/shared/model/generated-zod/ssoprovider.ts | 31 +++ src/shared/model/generated-zod/usergroup.ts | 4 +- src/shared/model/sso-provider.model.ts | 45 +++ 17 files changed, 769 insertions(+), 86 deletions(-) create mode 100644 prisma/migrations/20260802000000_add_sso_provider/migration.sql create mode 100644 src/app/settings/users/sso-provider-edit-overlay.tsx create mode 100644 src/app/settings/users/sso-providers-table.tsx create mode 100644 src/server/services/sso-provider.service.ts create mode 100644 src/shared/model/generated-zod/ssoprovider.ts create mode 100644 src/shared/model/sso-provider.model.ts diff --git a/prisma/migrations/20260802000000_add_sso_provider/migration.sql b/prisma/migrations/20260802000000_add_sso_provider/migration.sql new file mode 100644 index 00000000..77d3dffb --- /dev/null +++ b/prisma/migrations/20260802000000_add_sso_provider/migration.sql @@ -0,0 +1,16 @@ +-- CreateTable +CREATE TABLE "SsoProvider" ( + "id" TEXT NOT NULL PRIMARY KEY, + "type" TEXT NOT NULL, + "name" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT false, + "clientId" TEXT NOT NULL, + "clientSecretEnc" TEXT NOT NULL, + "issuer" TEXT, + "tenantId" TEXT, + "defaultUserGroupId" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "SsoProvider_defaultUserGroupId_fkey" FOREIGN KEY ("defaultUserGroupId") REFERENCES "UserGroup" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); +CREATE UNIQUE INDEX "SsoProvider_type_name_key" ON "SsoProvider"("type", "name"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 56e1519b..de4b8171 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -72,7 +72,7 @@ model User { name String? email String @unique emailVerified DateTime? - password String + password String @default("") twoFaSecret String? twoFaEnabled Boolean @default(false) apiOnlyUser Boolean @default(false) @@ -140,11 +140,37 @@ model UserGroup { users User[] roleProjectPermissions RoleProjectPermission[] + ssoProviders SsoProvider[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } +enum SsoProviderType { + OIDC + GOOGLE + AZURE_AD + GITHUB +} + +model SsoProvider { + id String @id @default(uuid()) + type SsoProviderType + name String + enabled Boolean @default(false) + clientId String + clientSecretEnc String + issuer String? + tenantId String? + defaultUserGroupId String + defaultUserGroup UserGroup @relation(fields: [defaultUserGroupId], references: [id], onDelete: Restrict) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([type, name]) +} + model RoleProjectPermission { id String @id @default(uuid()) userGroup UserGroup @relation(fields: [userGroupId], references: [id], onDelete: Cascade) diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts index d980466e..94cdd911 100644 --- a/src/app/api/auth/[...nextauth]/route.ts +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -1,7 +1,11 @@ -import NextAuth, { } from "next-auth" -import { authOptions } from "@/server/utils/auth-options"; +import NextAuth from "next-auth"; +import { buildAuthOptions } from "@/server/utils/auth-options"; +async function handler( + req: Request, + ctx: { params: Promise<{ nextauth: string[] }> }, +) { + return NextAuth(await buildAuthOptions())(req, { params: await ctx.params }); +} -const handler = NextAuth(authOptions) - -export { handler as GET, handler as POST } \ No newline at end of file +export { handler as GET, handler as POST }; diff --git a/src/app/auth/login-form.tsx b/src/app/auth/login-form.tsx index 3d2a8296..55fe5271 100644 --- a/src/app/auth/login-form.tsx +++ b/src/app/auth/login-form.tsx @@ -12,10 +12,11 @@ import { import { Input } from "@/components/ui/input" import { zodResolver } from "@hookform/resolvers/zod" import { useForm } from "react-hook-form" -import { useState } from "react"; +import { useEffect, useState } from "react"; import { AuthFormInputSchema, authFormInputSchemaZod } from "@/shared/model/auth-form" import { authUser } from "./actions" -import { signIn } from "next-auth/react"; +import { getProviders, signIn } from "next-auth/react"; +import type { ClientSafeProvider } from "next-auth/react"; import LoadingSpinner from "@/components/ui/loading-spinner" import { Button } from "@/components/ui/button" import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" @@ -29,6 +30,12 @@ export default function UserLoginForm() { const [errorMessages, setErrorMessages] = useState(undefined); const [loading, setLoading] = useState(false); const [authInput, setAuthInput] = useState(undefined); + const [providers, setProviders] = useState>({}); + const ssoProviders = Object.values(providers).filter((provider) => provider.id !== "credentials"); + + useEffect(() => { + getProviders().then((value) => setProviders(value ?? {})); + }, []); function redirectToProjects() { const currentUrl = window.location.href @@ -121,6 +128,20 @@ export default function UserLoginForm() { + {ssoProviders.length > 0 && ( + + {ssoProviders.map((provider) => ( + + ))} + + )} ) } diff --git a/src/app/settings/users/actions.ts b/src/app/settings/users/actions.ts index 11b4a8fe..54701319 100644 --- a/src/app/settings/users/actions.ts +++ b/src/app/settings/users/actions.ts @@ -12,6 +12,23 @@ import restApiKeyService from "@/server/services/rest-api-key.service"; import { RestApiKeyCreateModel, restApiKeyCreateZodModel } from "@/shared/model/rest-api-key.model"; import { CryptoUtils } from "@/server/utils/crypto.utils"; import { z } from "zod"; +import { revalidatePath } from "next/cache"; +import ssoProviderService from "@/server/services/sso-provider.service"; +import { SsoProviderEditModel, ssoProviderEditZodModel } from "@/shared/model/sso-provider.model"; + +export const saveSsoProvider = async (prevState: any, inputData: SsoProviderEditModel) => + saveFormAction(inputData, ssoProviderEditZodModel, async (validatedData) => { + await getAdminUserSession(); + await ssoProviderService.save(validatedData); + revalidatePath("/settings/users"); + }); + +export const deleteSsoProvider = async (id: string) => simpleAction(async () => { + await getAdminUserSession(); + await ssoProviderService.deleteById(id); + revalidatePath("/settings/users"); + return new SuccessActionResult(undefined, "SSO provider deleted"); +}); export const saveUser = async (prevState: any, inputData: UserEditModel) => saveFormAction(inputData, userEditZodModel, async (validatedData) => { diff --git a/src/app/settings/users/page.tsx b/src/app/settings/users/page.tsx index f5f5adb2..27b4096f 100644 --- a/src/app/settings/users/page.tsx +++ b/src/app/settings/users/page.tsx @@ -6,7 +6,7 @@ import BreadcrumbSetter from "@/components/breadcrumbs-setter"; import UsersTable from "./users-table"; import userService from "@/server/services/user.service"; import userGroupService from "@/server/services/user-group.service"; -import { CircleUser, UserRoundCog } from "lucide-react"; +import { CircleUser, KeyRound, UserRoundCog } from "lucide-react"; import { Tabs, TabsContent, @@ -15,6 +15,8 @@ import { } from "@/components/ui/tabs" import UserGroupsTable from "./user-groups-table"; import projectService from "@/server/services/project.service"; +import ssoProviderService from "@/server/services/sso-provider.service"; +import SsoProvidersTable from "./sso-providers-table"; export default async function UsersAndGroupsPage() { @@ -22,6 +24,7 @@ export default async function UsersAndGroupsPage() { const users = await userService.getAllUsers(); const userGroups = await userGroupService.getAll(); const allApps = await projectService.getAll(); + const ssoProviders = await ssoProviderService.getAll(); return (
Users Groups + SSO Providers @@ -42,6 +46,9 @@ export default async function UsersAndGroupsPage() { + + +
) diff --git a/src/app/settings/users/sso-provider-edit-overlay.tsx b/src/app/settings/users/sso-provider-edit-overlay.tsx new file mode 100644 index 00000000..35b5a502 --- /dev/null +++ b/src/app/settings/users/sso-provider-edit-overlay.tsx @@ -0,0 +1,258 @@ +"use client"; + +import type { z } from "zod"; +import { useActionState, useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { SubmitButton } from "@/components/custom/submit-button"; +import { FormUtils } from "@/frontend/utils/form.utilts"; +import { ServerActionResult } from "@/shared/model/server-action-error-return.model"; +import { + SsoProviderEditModel, + ssoProviderEditZodModel, + SsoProviderUiModel, + ssoProviderTypes, +} from "@/shared/model/sso-provider.model"; +import { UserGroupExtended } from "@/shared/model/sim-session.model"; +import { saveSsoProvider } from "./actions"; +import { toast } from "sonner"; + +export default function SsoProviderEditOverlay({ + children, + provider, + userGroups, +}: { + children: React.ReactNode; + provider?: SsoProviderUiModel; + userGroups: UserGroupExtended[]; +}) { + const [open, setOpen] = useState(false); + const form = useForm< + z.input, + unknown, + z.output + >({ + resolver: zodResolver(ssoProviderEditZodModel), + defaultValues: provider ?? { + type: "OIDC", + enabled: false, + clientSecret: "", + issuer: "", + tenantId: "", + }, + }); + const [state, formAction] = useActionState( + (state: ServerActionResult, payload: SsoProviderEditModel) => + saveSsoProvider(state, { ...payload, id: provider?.id }), + FormUtils.getInitialFormState(), + ); + const type = form.watch("type"); + useEffect(() => { + form.reset( + provider + ? { ...provider, clientSecret: "" } + : { + type: "OIDC", + enabled: false, + clientSecret: "", + issuer: "", + tenantId: "", + }, + ); + }, [form, provider, open]); + useEffect(() => { + if (state.status === "success") { + toast.success("SSO provider saved"); + setOpen(false); + } + FormUtils.mapValidationErrorsToForm( + state, + form, + ); + }, [form, state]); + return ( + <> +
setOpen(true)}>{children}
+ + + + {provider ? "Edit" : "Add"} SSO Provider + +
+ form.handleSubmit((data) => formAction(data))()} + className="space-y-4" + > + ( + + Type + + + + )} + /> + ( + + Display Name + + + + + + )} + /> + ( + + Client ID + + + + + + )} + /> + ( + + Client Secret + + + + + + )} + /> + {type === "OIDC" && ( + ( + + Issuer URL + + + + + + )} + /> + )} + {type === "AZURE_AD" && ( + ( + + Tenant ID + + + + + + )} + /> + )} + ( + + Default Group + + + + )} + /> + ( + + Enabled + + + + + )} + /> +

{state.message}

+ Save + + +
+
+ + ); +} diff --git a/src/app/settings/users/sso-providers-table.tsx b/src/app/settings/users/sso-providers-table.tsx new file mode 100644 index 00000000..6f4d69f1 --- /dev/null +++ b/src/app/settings/users/sso-providers-table.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { EditIcon, Plus, TrashIcon } from "lucide-react"; +import { SimpleDataTable } from "@/components/custom/simple-data-table"; +import { formatDateTime } from "@/frontend/utils/format.utils"; +import { Toast } from "@/frontend/utils/toast.utils"; +import { useConfirmDialog } from "@/frontend/states/zustand.states"; +import { deleteSsoProvider } from "./actions"; +import SsoProviderEditOverlay from "./sso-provider-edit-overlay"; +import { SsoProviderUiModel } from "@/shared/model/sso-provider.model"; +import { UserGroupExtended } from "@/shared/model/sim-session.model"; + +export default function SsoProvidersTable({ + ssoProviders, + userGroups, +}: { + ssoProviders: SsoProviderUiModel[]; + userGroups: UserGroupExtended[]; +}) { + const { openConfirmDialog } = useConfirmDialog(); + async function remove(id: string) { + if ( + await openConfirmDialog({ + title: "Delete SSO Provider", + description: "Remove this provider? Existing linked accounts remain, but new login attempts cannot use it.", + okButton: "Delete", + }) + ) { + await Toast.fromAction(() => deleteSsoProvider(id)); + } + } + return ( + <> + (item.enabled ? "Yes" : "No")], + ["defaultUserGroupId", "Default Group", true, (item) => + userGroups.find((group) => group.id === item.defaultUserGroupId)?.name ?? "—", + ], + ["createdAt", "Created", false, (item) => formatDateTime(item.createdAt)], + ]} + data={ssoProviders} + actionCol={(item) => ( +
+
+ + + + +
+ )} + /> + + + + + ); +} diff --git a/src/server/services/sso-provider.service.ts b/src/server/services/sso-provider.service.ts new file mode 100644 index 00000000..09803118 --- /dev/null +++ b/src/server/services/sso-provider.service.ts @@ -0,0 +1,111 @@ +import { SsoProvider, SsoProviderType } from "@prisma/client"; +import { revalidateTag } from "next/cache"; +import dataAccess from "@/server/adapter/db.client"; +import { CryptoUtils } from "@/server/utils/crypto.utils"; +import { Tags } from "@/server/utils/cache-tag-generator.utils"; +import { ServiceException } from "@/shared/model/service.exception.model"; +import { + SsoProviderEditModel, + SsoProviderUiModel, +} from "@/shared/model/sso-provider.model"; + +type AuthProvider = Omit & { + clientSecret: string; +}; + +let authCache: { expiresAt: number; providers: AuthProvider[] } | undefined; +const CACHE_TTL_MS = 30_000; + +class SsoProviderService { + + private clearAuthCache() { + authCache = undefined; + } + + private toUiModel(provider: SsoProvider): SsoProviderUiModel { + return { + id: provider.id, + type: provider.type, + name: provider.name, + enabled: provider.enabled, + clientId: provider.clientId, + issuer: provider.issuer ?? "", + tenantId: provider.tenantId ?? "", + defaultUserGroupId: provider.defaultUserGroupId, + hasClientSecret: !!provider.clientSecretEnc, + createdAt: provider.createdAt, + updatedAt: provider.updatedAt, + }; + } + + async getAll(): Promise { + const providers = await dataAccess.client.ssoProvider.findMany({ orderBy: { name: "asc" } }); + return providers.map((provider) => this.toUiModel(provider)); + } + + async getById(id: string) { + return dataAccess.client.ssoProvider.findUnique({ where: { id } }); + } + + async getEnabledForAuth(): Promise { + if (authCache && authCache.expiresAt > Date.now()) { + return authCache.providers; + } + const providers = await dataAccess.client.ssoProvider.findMany({ where: { enabled: true } }); + const decrypted = providers.map(({ clientSecretEnc, ...provider }) => ({ + ...provider, + clientSecret: CryptoUtils.decrypt(clientSecretEnc), + })); + authCache = { providers: decrypted, expiresAt: Date.now() + CACHE_TTL_MS }; + return decrypted; + } + + async save(input: SsoProviderEditModel) { + const enteredSecret = input.clientSecret.trim(); + if (!input.id && !enteredSecret) { + throw new ServiceException("Client secret is required."); + } + const clientSecretEnc = enteredSecret + ? CryptoUtils.encrypt(enteredSecret) + : ( + await dataAccess.client.ssoProvider.findUniqueOrThrow({ + where: { id: input.id! }, + select: { clientSecretEnc: true }, + }) + ).clientSecretEnc; + + const data = { + type: input.type as SsoProviderType, + name: input.name.trim(), + enabled: input.enabled, + clientId: input.clientId.trim(), + clientSecretEnc, + issuer: input.type === "OIDC" ? input.issuer.trim() : null, + tenantId: input.type === "AZURE_AD" ? input.tenantId.trim() : null, + defaultUserGroupId: input.defaultUserGroupId, + }; + + try { + const provider = input.id + ? await dataAccess.client.ssoProvider.update({ where: { id: input.id }, data }) + : await dataAccess.client.ssoProvider.create({ data }); + return this.toUiModel(provider); + } finally { + this.clearAuthCache(); + revalidateTag(Tags.ssoProviders()); + } + } + + async deleteById(id: string) { + try { + await dataAccess.client.ssoProvider.delete({ where: { id } }); + } finally { + this.clearAuthCache(); + revalidateTag(Tags.ssoProviders()); + } + } +} + +export const ssoProviderCacheTtlMs = CACHE_TTL_MS; +const ssoProviderService = new SsoProviderService(); +export default ssoProviderService; diff --git a/src/server/services/user.service.ts b/src/server/services/user.service.ts index 8077f1b1..d7633e7a 100644 --- a/src/server/services/user.service.ts +++ b/src/server/services/user.service.ts @@ -164,6 +164,30 @@ export class UserService { }); } + async findUserByEmail(email: string) { + return dataAccess.client.user.findUnique({ + where: { + email + } + }); + } + + async setUserGroup(userId: string, userGroupId: string) { + try { + await dataAccess.client.user.update({ + where: { + id: userId + }, + data: { + userGroupId + } + }); + } finally { + revalidateTag(Tags.users()); + revalidateTag(Tags.userGroups()); + } + } + async createNewTotpToken(userMail: string) { try { await this.getUserByEmail(userMail); diff --git a/src/server/utils/action-wrapper.utils.ts b/src/server/utils/action-wrapper.utils.ts index 59a77cb9..b5c979b6 100644 --- a/src/server/utils/action-wrapper.utils.ts +++ b/src/server/utils/action-wrapper.utils.ts @@ -5,7 +5,7 @@ import { ZodRawShape, ZodObject, z } from "zod"; import { redirect } from "next/navigation"; import { ServerActionResult } from "@/shared/model/server-action-error-return.model"; import { FormValidationException } from "@/shared/model/form-validation-exception.model"; -import { authOptions } from "@/server/utils/auth-options"; +import { buildAuthOptions } from "@/server/utils/auth-options"; import { NextResponse } from "next/server"; import userGroupService from "../services/user-group.service"; import { UserGroupUtils } from "../../shared/utils/role.utils"; @@ -27,7 +27,7 @@ import { WorkloadType, zodWorkloadType } from "@/shared/model/runtime-type.model * use getAuthUserSession() if you want to throw an error if no user is logged in */ export async function getUserSession(): Promise { - const session = await getServerSession(authOptions); + const session = await getServerSession(await buildAuthOptions()); if (!session) { return null; } diff --git a/src/server/utils/auth-options.ts b/src/server/utils/auth-options.ts index 1343e500..038913c4 100644 --- a/src/server/utils/auth-options.ts +++ b/src/server/utils/auth-options.ts @@ -1,87 +1,135 @@ import { User } from "@prisma/client"; -import { NextAuthOptions } from "next-auth" -import { PrismaAdapter } from "@next-auth/prisma-adapter" -import dataAccess from "@/server/adapter/db.client"; +import { NextAuthOptions } from "next-auth"; +import { AdapterUser } from "next-auth/adapters"; +import { PrismaAdapter } from "@next-auth/prisma-adapter"; import CredentialsProvider from "next-auth/providers/credentials"; +import GoogleProvider from "next-auth/providers/google"; +import AzureADProvider from "next-auth/providers/azure-ad"; +import GitHubProvider from "next-auth/providers/github"; +import dataAccess from "@/server/adapter/db.client"; import userService from "@/server/services/user.service"; +import ssoProviderService from "@/server/services/sso-provider.service"; import { UserSession } from "@/shared/model/sim-session.model"; -export const authOptions: NextAuthOptions = { - session: { - strategy: "jwt", - }, - pages: { - signIn: "/auth", - }, +export function mapToNextAuthProvider( + provider: Awaited< + ReturnType + >[number], +) { + const common = { + id: provider.id, + name: provider.name, + clientId: provider.clientId, + clientSecret: provider.clientSecret, + allowDangerousEmailAccountLinking: true, + }; + + switch (provider.type) { + case "OIDC": + return { + ...common, + type: "oauth" as const, + wellKnown: `${provider.issuer!.replace(/\/$/, "")}/.well-known/openid-configuration`, + authorization: { params: { scope: "openid email profile" } }, + profile(profile: Record) { + return { + id: profile.sub, + email: profile.email, + name: profile.name ?? profile.preferred_username, + }; + }, + }; + case "GOOGLE": + return GoogleProvider(common); + case "AZURE_AD": + return AzureADProvider({ ...common, tenantId: provider.tenantId! }); + case "GITHUB": + return GitHubProvider(common); + } +} + +export async function buildAuthOptions(): Promise { + const ssoProviders = await ssoProviderService.getEnabledForAuth(); + return { + session: { strategy: "jwt" }, + pages: { signIn: "/auth" }, providers: [ - CredentialsProvider({ - // The name to display on the sign in form (e.g. "Sign in with...") - name: "Credentials", - // `credentials` is used to generate a form on the sign in page. - // You can specify which fields should be submitted, by adding keys to the `credentials` object. - // e.g. domain, username, password, 2FA token, etc. - // You can pass any HTML attribute to the tag through the object. - credentials: { - username: { label: "Username", type: "text" }, - password: { label: "Password", type: "password" }, - totpToken: { label: "TOTP Token", type: "text" }, - }, - async authorize(credentials) { - if (!credentials) { - return null; - } - const authUserInfo = await userService.authorize(credentials); - if (!authUserInfo) { - return null; - } - const user = await userService.getUserByEmail(authUserInfo.email); - if (user.twoFaEnabled) { - if (!credentials.totpToken) { - return null; - } - const tokenValid = await userService.verifyTotpToken(authUserInfo.email, credentials.totpToken); - if (!tokenValid) { - return null; - } - } - return mapUser(user); - } - }) + CredentialsProvider({ + name: "Credentials", + credentials: { + username: { label: "Username", type: "text" }, + password: { label: "Password", type: "password" }, + totpToken: { label: "TOTP Token", type: "text" }, + }, + async authorize(credentials) { + if (!credentials) { + return null; + } + const authUserInfo = await userService.authorize( + credentials as Record<"password" | "username", string>, + ); + if (!authUserInfo) { + return null; + } + const user = await userService.getUserByEmail(authUserInfo.email); + if ( + user.twoFaEnabled && + (!credentials.totpToken || + !(await userService.verifyTotpToken( + authUserInfo.email, + credentials.totpToken, + ))) + ) { + return null; + } + return mapUser(user); + }, + }), + ...ssoProviders.map(mapToNextAuthProvider), ], callbacks: { - async jwt(data) { - // Initial sign in - store user info in token - if (data.token && data.token.email) { - const user = await userService.getUserByEmail(data.token.email); - const userId = user.id; - if (userId) { - data.token.userId = userId; - } - } - return data.token; - }, - async session({ session, token, user }) { - // Read user info from token and builds session object - if (token?.userId) { - const userSession = session.user as UserSession; - userSession.userId = token.userId as string; - session.user = userSession; - return session; - } - - console.error('Could not generate session - missing userId in token'); - console.error('session', session); - console.error('token', token); - console.error('user', user); - throw new Error("Could not generate session"); + async jwt(data) { + if (data.token?.email) { + const user = await userService.findUserByEmail(data.token.email); + if (user) { + data.token.userId = user.id; + } } + return data.token; + }, + async session({ session, token }) { + if (token?.userId) { + (session.user as UserSession).userId = token.userId as string; + return session; + } + throw new Error("Could not generate session"); + }, }, - adapter: PrismaAdapter(dataAccess.client), -}; + events: { + async linkAccount({ user, account }) { + if (account.provider === "credentials") { + return; + } + const provider = await ssoProviderService.getById(account.provider); + const existingUser = await userService.findUserByEmail(user.email!); + if (provider && existingUser && !existingUser.userGroupId) { + await userService.setUserGroup( + existingUser.id, + provider.defaultUserGroupId, + ); + } + }, + }, + // OAuth users do not have a local credential. Keep an empty password so + // the existing required User.password column works on upgraded databases. + adapter: { + ...PrismaAdapter(dataAccess.client), + createUser: (user: AdapterUser) => + dataAccess.client.user.create({ data: { ...user, password: "" } }), + }, + }; +} function mapUser(user: User) { - return { - id: user.id, - email: user.email - }; + return { id: user.id, email: user.email }; } diff --git a/src/server/utils/cache-tag-generator.utils.ts b/src/server/utils/cache-tag-generator.utils.ts index bd4f4756..faf54987 100644 --- a/src/server/utils/cache-tag-generator.utils.ts +++ b/src/server/utils/cache-tag-generator.utils.ts @@ -20,6 +20,10 @@ export class Tags { return `llm-gateways`; } + static ssoProviders() { + return `sso-providers`; + } + static volumeBackups() { return `volume-backups`; } diff --git a/src/shared/model/generated-zod/index.ts b/src/shared/model/generated-zod/index.ts index 8189f865..bd0420b6 100644 --- a/src/shared/model/generated-zod/index.ts +++ b/src/shared/model/generated-zod/index.ts @@ -5,6 +5,7 @@ export * from "./restapikey" export * from "./verificationtoken" export * from "./authenticator" export * from "./usergroup" +export * from "./ssoprovider" export * from "./roleprojectpermission" export * from "./roleapppermission" export * from "./roleagentpermission" diff --git a/src/shared/model/generated-zod/ssoprovider.ts b/src/shared/model/generated-zod/ssoprovider.ts new file mode 100644 index 00000000..074bb188 --- /dev/null +++ b/src/shared/model/generated-zod/ssoprovider.ts @@ -0,0 +1,31 @@ +import * as z from "zod" + +import { SsoProviderType } from "@prisma/client" +import { CompleteUserGroup, RelatedUserGroupModel } from "./index" + +export const SsoProviderModel = z.object({ + id: z.string(), + type: z.nativeEnum(SsoProviderType), + name: z.string(), + enabled: z.boolean(), + clientId: z.string(), + clientSecretEnc: z.string(), + issuer: z.string().nullish(), + tenantId: z.string().nullish(), + defaultUserGroupId: z.string(), + createdAt: z.date(), + updatedAt: z.date(), +}) + +export interface CompleteSsoProvider extends z.infer { + defaultUserGroup: CompleteUserGroup +} + +/** + * RelatedSsoProviderModel contains all relations on your model in addition to the scalars + * + * NOTE: Lazy required in case of potential circular dependencies within schema + */ +export const RelatedSsoProviderModel: z.ZodSchema = z.lazy(() => SsoProviderModel.extend({ + defaultUserGroup: RelatedUserGroupModel, +})) diff --git a/src/shared/model/generated-zod/usergroup.ts b/src/shared/model/generated-zod/usergroup.ts index 7190d224..49cb38fe 100644 --- a/src/shared/model/generated-zod/usergroup.ts +++ b/src/shared/model/generated-zod/usergroup.ts @@ -1,6 +1,6 @@ import * as z from "zod" -import { CompleteUser, RelatedUserModel, CompleteRoleProjectPermission, RelatedRoleProjectPermissionModel } from "./index" +import { CompleteUser, RelatedUserModel, CompleteRoleProjectPermission, RelatedRoleProjectPermissionModel, CompleteSsoProvider, RelatedSsoProviderModel } from "./index" export const UserGroupModel = z.object({ id: z.string(), @@ -14,6 +14,7 @@ export const UserGroupModel = z.object({ export interface CompleteUserGroup extends z.infer { users: CompleteUser[] roleProjectPermissions: CompleteRoleProjectPermission[] + ssoProviders: CompleteSsoProvider[] } /** @@ -24,4 +25,5 @@ export interface CompleteUserGroup extends z.infer { export const RelatedUserGroupModel: z.ZodSchema = z.lazy(() => UserGroupModel.extend({ users: RelatedUserModel.array(), roleProjectPermissions: RelatedRoleProjectPermissionModel.array(), + ssoProviders: RelatedSsoProviderModel.array(), })) diff --git a/src/shared/model/sso-provider.model.ts b/src/shared/model/sso-provider.model.ts new file mode 100644 index 00000000..d6f7ee2c --- /dev/null +++ b/src/shared/model/sso-provider.model.ts @@ -0,0 +1,45 @@ +import { z } from "zod"; + +export const ssoProviderTypes = [ + "OIDC", + "GOOGLE", + "AZURE_AD", + "GITHUB", +] as const; + +export const ssoProviderEditZodModel = z + .object({ + id: z.string().uuid().optional(), + type: z.enum(ssoProviderTypes), + name: z.string().trim().min(1, "Display name is required."), + enabled: z.boolean().default(false), + clientId: z.string().trim().min(1, "Client ID is required."), + clientSecret: z.string().optional().default(""), + issuer: z.string().trim().optional().default(""), + tenantId: z.string().trim().optional().default(""), + defaultUserGroupId: z.string().uuid("Default user group is required."), + }) + .superRefine((value, ctx) => { + if (value.type === "OIDC" && !value.issuer) { + ctx.addIssue({ + code: "custom", + path: ["issuer"], + message: "Issuer is required for OIDC.", + }); + } + if (value.type === "AZURE_AD" && !value.tenantId) { + ctx.addIssue({ + code: "custom", + path: ["tenantId"], + message: "Tenant ID is required for Azure AD.", + }); + } + }); + +export type SsoProviderEditModel = z.infer; +export type SsoProviderUiModel = Omit & { + id: string; + hasClientSecret: boolean; + createdAt: Date; + updatedAt: Date; +}; From 9f21b26311c74344425f91a5ca52ea65c1298807 Mon Sep 17 00:00:00 2001 From: biersoeckli Date: Wed, 19 Aug 2026 14:02:06 +0000 Subject: [PATCH 2/5] feat: enhance OIDC and Azure AD validation in SSO provider configuration --- src/app/settings/users/actions.ts | 11 ++++++ src/server/utils/auth-options.ts | 50 +++++++++++++++++++++----- src/shared/model/sso-provider.model.ts | 16 --------- 3 files changed, 52 insertions(+), 25 deletions(-) diff --git a/src/app/settings/users/actions.ts b/src/app/settings/users/actions.ts index 54701319..2ac00ff6 100644 --- a/src/app/settings/users/actions.ts +++ b/src/app/settings/users/actions.ts @@ -15,10 +15,21 @@ import { z } from "zod"; import { revalidatePath } from "next/cache"; import ssoProviderService from "@/server/services/sso-provider.service"; import { SsoProviderEditModel, ssoProviderEditZodModel } from "@/shared/model/sso-provider.model"; +import { FormValidationException } from "@/shared/model/form-validation-exception.model"; export const saveSsoProvider = async (prevState: any, inputData: SsoProviderEditModel) => saveFormAction(inputData, ssoProviderEditZodModel, async (validatedData) => { await getAdminUserSession(); + if (validatedData.type === "OIDC" && !validatedData.issuer) { + throw new FormValidationException("Please correct the errors in the form.", { + issuer: ["Issuer is required for OIDC."], + }); + } + if (validatedData.type === "AZURE_AD" && !validatedData.tenantId) { + throw new FormValidationException("Please correct the errors in the form.", { + tenantId: ["Tenant ID is required for Azure AD."], + }); + } await ssoProviderService.save(validatedData); revalidatePath("/settings/users"); }); diff --git a/src/server/utils/auth-options.ts b/src/server/utils/auth-options.ts index 038913c4..f4357f29 100644 --- a/src/server/utils/auth-options.ts +++ b/src/server/utils/auth-options.ts @@ -1,6 +1,6 @@ import { User } from "@prisma/client"; import { NextAuthOptions } from "next-auth"; -import { AdapterUser } from "next-auth/adapters"; +import { AdapterAccount, AdapterUser } from "next-auth/adapters"; import { PrismaAdapter } from "@next-auth/prisma-adapter"; import CredentialsProvider from "next-auth/providers/credentials"; import GoogleProvider from "next-auth/providers/google"; @@ -11,6 +11,22 @@ import userService from "@/server/services/user.service"; import ssoProviderService from "@/server/services/sso-provider.service"; import { UserSession } from "@/shared/model/sim-session.model"; +function mapOidcProfile(profile: Record) { + return { + id: profile.sub as string, + // Microsoft Entra ID does not guarantee an `email` claim for work and + // school accounts. Its `preferred_username` claim is normally the UPN. + email: + (profile.email as string | undefined) ?? + (profile.preferred_username as string | undefined) ?? + (profile.upn as string | undefined), + name: + (profile.name as string | undefined) ?? + (profile.preferred_username as string | undefined), + image: (profile.picture as string | null | undefined) ?? null, + }; +} + export function mapToNextAuthProvider( provider: Awaited< ReturnType @@ -31,18 +47,16 @@ export function mapToNextAuthProvider( type: "oauth" as const, wellKnown: `${provider.issuer!.replace(/\/$/, "")}/.well-known/openid-configuration`, authorization: { params: { scope: "openid email profile" } }, - profile(profile: Record) { - return { - id: profile.sub, - email: profile.email, - name: profile.name ?? profile.preferred_username, - }; - }, + profile: mapOidcProfile, }; case "GOOGLE": return GoogleProvider(common); case "AZURE_AD": - return AzureADProvider({ ...common, tenantId: provider.tenantId! }); + return AzureADProvider({ + ...common, + tenantId: provider.tenantId!, + profile: mapOidcProfile, + }); case "GITHUB": return GitHubProvider(common); } @@ -126,6 +140,24 @@ export async function buildAuthOptions(): Promise { ...PrismaAdapter(dataAccess.client), createUser: (user: AdapterUser) => dataAccess.client.user.create({ data: { ...user, password: "" } }), + // OIDC providers may return non-standard token fields (for example + // Azure's `ext_expires_in`). Prisma only accepts fields in Account. + linkAccount: (account: AdapterAccount) => + dataAccess.client.account.create({ + data: { + userId: account.userId, + type: account.type, + provider: account.provider, + providerAccountId: account.providerAccountId, + refresh_token: account.refresh_token, + access_token: account.access_token, + expires_at: account.expires_at, + token_type: account.token_type, + scope: account.scope, + id_token: account.id_token, + session_state: account.session_state, + }, + }), }, }; } diff --git a/src/shared/model/sso-provider.model.ts b/src/shared/model/sso-provider.model.ts index d6f7ee2c..91e95676 100644 --- a/src/shared/model/sso-provider.model.ts +++ b/src/shared/model/sso-provider.model.ts @@ -18,22 +18,6 @@ export const ssoProviderEditZodModel = z issuer: z.string().trim().optional().default(""), tenantId: z.string().trim().optional().default(""), defaultUserGroupId: z.string().uuid("Default user group is required."), - }) - .superRefine((value, ctx) => { - if (value.type === "OIDC" && !value.issuer) { - ctx.addIssue({ - code: "custom", - path: ["issuer"], - message: "Issuer is required for OIDC.", - }); - } - if (value.type === "AZURE_AD" && !value.tenantId) { - ctx.addIssue({ - code: "custom", - path: ["tenantId"], - message: "Tenant ID is required for Azure AD.", - }); - } }); export type SsoProviderEditModel = z.infer; From 6eaaac7c17a7c02e052f046856b6d7daac5da119 Mon Sep 17 00:00:00 2001 From: biersoeckli Date: Wed, 19 Aug 2026 14:59:36 +0000 Subject: [PATCH 3/5] fix: aligned sso implementation with common caching standards --- src/app/auth/login-form.tsx | 18 +++++------ src/app/auth/page.tsx | 8 +++-- src/app/settings/users/actions.ts | 3 -- src/server/services/sso-provider.service.ts | 34 ++++++++------------- 4 files changed, 26 insertions(+), 37 deletions(-) diff --git a/src/app/auth/login-form.tsx b/src/app/auth/login-form.tsx index 55fe5271..414d8548 100644 --- a/src/app/auth/login-form.tsx +++ b/src/app/auth/login-form.tsx @@ -12,17 +12,21 @@ import { import { Input } from "@/components/ui/input" import { zodResolver } from "@hookform/resolvers/zod" import { useForm } from "react-hook-form" -import { useEffect, useState } from "react"; +import { useState } from "react"; import { AuthFormInputSchema, authFormInputSchemaZod } from "@/shared/model/auth-form" import { authUser } from "./actions" -import { getProviders, signIn } from "next-auth/react"; -import type { ClientSafeProvider } from "next-auth/react"; +import { signIn } from "next-auth/react"; import LoadingSpinner from "@/components/ui/loading-spinner" import { Button } from "@/components/ui/button" import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" import TwoFaAuthForm from "./two-fa-auth" -export default function UserLoginForm() { +type SsoLoginProvider = { + id: string; + name: string; +}; + +export default function UserLoginForm({ ssoProviders }: { ssoProviders: SsoLoginProvider[] }) { const form = useForm, unknown, z.output>({ resolver: zodResolver(authFormInputSchemaZod) }); @@ -30,12 +34,6 @@ export default function UserLoginForm() { const [errorMessages, setErrorMessages] = useState(undefined); const [loading, setLoading] = useState(false); const [authInput, setAuthInput] = useState(undefined); - const [providers, setProviders] = useState>({}); - const ssoProviders = Object.values(providers).filter((provider) => provider.id !== "credentials"); - - useEffect(() => { - getProviders().then((value) => setProviders(value ?? {})); - }, []); function redirectToProjects() { const currentUrl = window.location.href diff --git a/src/app/auth/page.tsx b/src/app/auth/page.tsx index ddbbfb50..cfbcb736 100644 --- a/src/app/auth/page.tsx +++ b/src/app/auth/page.tsx @@ -5,6 +5,7 @@ import UserRegistrationForm from "./register-from"; import UserLoginForm from "./login-form"; import { getUserSession } from "@/server/utils/action-wrapper.utils"; import { redirect } from "next/navigation"; +import ssoProviderService from "@/server/services/sso-provider.service"; export default async function AuthPage() { const session = await getUserSession(); @@ -12,9 +13,12 @@ export default async function AuthPage() { redirect('/'); } const allUsers = await userService.getAllUsers(); + const ssoProviders = (await ssoProviderService.getAll()) + .filter((provider) => provider.enabled) + .map(({ id, name }) => ({ id, name })); return (
- {allUsers.length === 0 ? : } + {allUsers.length === 0 ? : }
) -} \ No newline at end of file +} diff --git a/src/app/settings/users/actions.ts b/src/app/settings/users/actions.ts index 2ac00ff6..f69d7aa6 100644 --- a/src/app/settings/users/actions.ts +++ b/src/app/settings/users/actions.ts @@ -12,7 +12,6 @@ import restApiKeyService from "@/server/services/rest-api-key.service"; import { RestApiKeyCreateModel, restApiKeyCreateZodModel } from "@/shared/model/rest-api-key.model"; import { CryptoUtils } from "@/server/utils/crypto.utils"; import { z } from "zod"; -import { revalidatePath } from "next/cache"; import ssoProviderService from "@/server/services/sso-provider.service"; import { SsoProviderEditModel, ssoProviderEditZodModel } from "@/shared/model/sso-provider.model"; import { FormValidationException } from "@/shared/model/form-validation-exception.model"; @@ -31,13 +30,11 @@ export const saveSsoProvider = async (prevState: any, inputData: SsoProviderEdit }); } await ssoProviderService.save(validatedData); - revalidatePath("/settings/users"); }); export const deleteSsoProvider = async (id: string) => simpleAction(async () => { await getAdminUserSession(); await ssoProviderService.deleteById(id); - revalidatePath("/settings/users"); return new SuccessActionResult(undefined, "SSO provider deleted"); }); diff --git a/src/server/services/sso-provider.service.ts b/src/server/services/sso-provider.service.ts index 09803118..264f2392 100644 --- a/src/server/services/sso-provider.service.ts +++ b/src/server/services/sso-provider.service.ts @@ -1,5 +1,5 @@ import { SsoProvider, SsoProviderType } from "@prisma/client"; -import { revalidateTag } from "next/cache"; +import { revalidateTag, unstable_cache } from "next/cache"; import dataAccess from "@/server/adapter/db.client"; import { CryptoUtils } from "@/server/utils/crypto.utils"; import { Tags } from "@/server/utils/cache-tag-generator.utils"; @@ -13,15 +13,7 @@ type AuthProvider = Omit & { clientSecret: string; }; -let authCache: { expiresAt: number; providers: AuthProvider[] } | undefined; -const CACHE_TTL_MS = 30_000; - class SsoProviderService { - - private clearAuthCache() { - authCache = undefined; - } - private toUiModel(provider: SsoProvider): SsoProviderUiModel { return { id: provider.id, @@ -48,16 +40,17 @@ class SsoProviderService { } async getEnabledForAuth(): Promise { - if (authCache && authCache.expiresAt > Date.now()) { - return authCache.providers; - } - const providers = await dataAccess.client.ssoProvider.findMany({ where: { enabled: true } }); - const decrypted = providers.map(({ clientSecretEnc, ...provider }) => ({ - ...provider, - clientSecret: CryptoUtils.decrypt(clientSecretEnc), - })); - authCache = { providers: decrypted, expiresAt: Date.now() + CACHE_TTL_MS }; - return decrypted; + return await unstable_cache( + async () => { + const providers = await dataAccess.client.ssoProvider.findMany({ where: { enabled: true } }); + return providers.map(({ clientSecretEnc, ...provider }) => ({ + ...provider, + clientSecret: CryptoUtils.decrypt(clientSecretEnc), + })); + }, + [Tags.ssoProviders(), "enabled-for-auth"], + { tags: [Tags.ssoProviders()] }, + )(); } async save(input: SsoProviderEditModel) { @@ -91,7 +84,6 @@ class SsoProviderService { : await dataAccess.client.ssoProvider.create({ data }); return this.toUiModel(provider); } finally { - this.clearAuthCache(); revalidateTag(Tags.ssoProviders()); } } @@ -100,12 +92,10 @@ class SsoProviderService { try { await dataAccess.client.ssoProvider.delete({ where: { id } }); } finally { - this.clearAuthCache(); revalidateTag(Tags.ssoProviders()); } } } -export const ssoProviderCacheTtlMs = CACHE_TTL_MS; const ssoProviderService = new SsoProviderService(); export default ssoProviderService; From 3aa8088e432f864a4589de348a9ed9026e0a9382 Mon Sep 17 00:00:00 2001 From: biersoeckli Date: Fri, 21 Aug 2026 06:55:28 +0000 Subject: [PATCH 4/5] feat: add SSO provider redirect URL dialog and enhance user model with OAuth provider IDs --- .devcontainer/devcontainer.json | 3 +- src/app/settings/users/actions.ts | 2 +- src/app/settings/users/page.tsx | 2 +- .../users/sso-provider-edit-overlay.tsx | 39 ++++++++++++++++++- .../settings/users/sso-providers-table.tsx | 23 +++++++++-- src/app/settings/users/users-table.tsx | 10 ++++- src/server/services/user.service.ts | 14 ++++++- src/shared/model/sso-provider.model.ts | 13 +++++++ src/shared/model/user-extended.model.ts | 1 + 9 files changed, 96 insertions(+), 11 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 3631ad91..0dde614b 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -21,7 +21,8 @@ "qwtel.sqlite-viewer", "ms-kubernetes-tools.vscode-kubernetes-tools", "vitest.explorer", - "ms-vscode.vscode-chat-customizations-evaluations" + "ms-vscode.vscode-chat-customizations-evaluations", + "TypeScriptTeam.native-preview" ] } }, diff --git a/src/app/settings/users/actions.ts b/src/app/settings/users/actions.ts index f69d7aa6..ead1b3e0 100644 --- a/src/app/settings/users/actions.ts +++ b/src/app/settings/users/actions.ts @@ -29,7 +29,7 @@ export const saveSsoProvider = async (prevState: any, inputData: SsoProviderEdit tenantId: ["Tenant ID is required for Azure AD."], }); } - await ssoProviderService.save(validatedData); + return await ssoProviderService.save(validatedData); }); export const deleteSsoProvider = async (id: string) => simpleAction(async () => { diff --git a/src/app/settings/users/page.tsx b/src/app/settings/users/page.tsx index 27b4096f..e9bd8a36 100644 --- a/src/app/settings/users/page.tsx +++ b/src/app/settings/users/page.tsx @@ -41,7 +41,7 @@ export default async function UsersAndGroupsPage() { SSO Providers - + diff --git a/src/app/settings/users/sso-provider-edit-overlay.tsx b/src/app/settings/users/sso-provider-edit-overlay.tsx index 35b5a502..4c4a384b 100644 --- a/src/app/settings/users/sso-provider-edit-overlay.tsx +++ b/src/app/settings/users/sso-provider-edit-overlay.tsx @@ -10,6 +10,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; import { Form, FormControl, @@ -35,11 +36,36 @@ import { ssoProviderEditZodModel, SsoProviderUiModel, ssoProviderTypes, + formatSsoProviderType, } from "@/shared/model/sso-provider.model"; import { UserGroupExtended } from "@/shared/model/sim-session.model"; import { saveSsoProvider } from "./actions"; +import { useDialog } from "@/frontend/states/zustand.states"; +import { useDialogContext } from "@/frontend/states/dialog-context"; import { toast } from "sonner"; +function SsoProviderRedirectUrlDialog({ redirectUrl }: { redirectUrl: string }) { + const { closeDialog } = useDialogContext(); + + return ( +
+
+

SSO Provider Created

+

+ Add this redirect URL to your identity provider. +

+
+ +
+ + +
+
+ ); +} + export default function SsoProviderEditOverlay({ children, provider, @@ -50,6 +76,7 @@ export default function SsoProviderEditOverlay({ userGroups: UserGroupExtended[]; }) { const [open, setOpen] = useState(false); + const { openDialog } = useDialog(); const form = useForm< z.input, unknown, @@ -87,12 +114,20 @@ export default function SsoProviderEditOverlay({ if (state.status === "success") { toast.success("SSO provider saved"); setOpen(false); + if (!provider && state.data?.id) { + void openDialog( + , + { maxWidth: "640px" }, + ); + } } FormUtils.mapValidationErrorsToForm( state, form, ); - }, [form, state]); + }, [form, openDialog, provider, state]); return ( <>
setOpen(true)}>{children}
@@ -121,7 +156,7 @@ export default function SsoProviderEditOverlay({ {ssoProviderTypes.map((value) => ( - {value} + {formatSsoProviderType(value)} ))} diff --git a/src/app/settings/users/sso-providers-table.tsx b/src/app/settings/users/sso-providers-table.tsx index 6f4d69f1..12cdb580 100644 --- a/src/app/settings/users/sso-providers-table.tsx +++ b/src/app/settings/users/sso-providers-table.tsx @@ -1,15 +1,17 @@ "use client"; import { Button } from "@/components/ui/button"; -import { EditIcon, Plus, TrashIcon } from "lucide-react"; +import { CopyIcon, EditIcon, Plus, TrashIcon } from "lucide-react"; import { SimpleDataTable } from "@/components/custom/simple-data-table"; import { formatDateTime } from "@/frontend/utils/format.utils"; import { Toast } from "@/frontend/utils/toast.utils"; import { useConfirmDialog } from "@/frontend/states/zustand.states"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { deleteSsoProvider } from "./actions"; import SsoProviderEditOverlay from "./sso-provider-edit-overlay"; -import { SsoProviderUiModel } from "@/shared/model/sso-provider.model"; +import { formatSsoProviderType, SsoProviderUiModel } from "@/shared/model/sso-provider.model"; import { UserGroupExtended } from "@/shared/model/sim-session.model"; +import { toast } from "sonner"; export default function SsoProvidersTable({ ssoProviders, @@ -30,12 +32,17 @@ export default function SsoProvidersTable({ await Toast.fromAction(() => deleteSsoProvider(id)); } } + function copyRedirectUrl(id: string) { + const redirectUrl = `${window.location.origin}/api/auth/callback/${id}`; + navigator.clipboard.writeText(redirectUrl); + toast.success("Redirect URL copied"); + } return ( <> formatSsoProviderType(item.type)], ["name", "Display Name", true], ["enabled", "Enabled", true, (item) => (item.enabled ? "Yes" : "No")], ["defaultUserGroupId", "Default Group", true, (item) => @@ -47,6 +54,16 @@ export default function SsoProvidersTable({ actionCol={(item) => (
+ + + + + + Copy redirect URL to clipboard + + + + {errorMessages && ( + + {errorMessages} + + )} + {ssoProviders.length > 0 && ( - + +
+ + or continue with + +
{ssoProviders.map((provider) => ( ))} diff --git a/src/app/auth/page.tsx b/src/app/auth/page.tsx index cfbcb736..ab2cf473 100644 --- a/src/app/auth/page.tsx +++ b/src/app/auth/page.tsx @@ -1,11 +1,15 @@ -'use server' - import userService from "@/server/services/user.service"; import UserRegistrationForm from "./register-from"; import UserLoginForm from "./login-form"; import { getUserSession } from "@/server/utils/action-wrapper.utils"; import { redirect } from "next/navigation"; import ssoProviderService from "@/server/services/sso-provider.service"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Authentication", + description: "Authentication", +}; export default async function AuthPage() { const session = await getUserSession(); @@ -15,10 +19,13 @@ export default async function AuthPage() { const allUsers = await userService.getAllUsers(); const ssoProviders = (await ssoProviderService.getAll()) .filter((provider) => provider.enabled) - .map(({ id, name }) => ({ id, name })); + .map(({ id, name, type }) => ({ id, name, type })); return ( -
- {allUsers.length === 0 ? : } -
+
+
+
+ {allUsers.length === 0 ? : } +
+
) } diff --git a/src/app/settings/users/users-table.tsx b/src/app/settings/users/users-table.tsx index d363b0f3..e936a49f 100644 --- a/src/app/settings/users/users-table.tsx +++ b/src/app/settings/users/users-table.tsx @@ -23,6 +23,8 @@ import UsersBulkRoleAssignment from "./users-table-bulk-role-assignment"; import { Actions } from "@/frontend/utils/nextjs-actions.utils"; import { useDialog } from "@/frontend/states/zustand.states"; import { UserApiKeysDialog } from "./user-api-keys-dialog"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { SsoProviderLogo } from "@/components/custom/sso-provider-logo"; export default function UsersTable({ users, userGroups, session, ssoProviders }: { users: UserExtended[]; @@ -82,10 +84,32 @@ export default function UsersTable({ users, userGroups, session, ssoProviders }: ['email', 'Mail', true, (item) => {item.email}{item.apiOnlyUser && API only}], ['userGroup.name', 'Group', true], ['oauthProviderIds', 'OAuth Providers', true, (item) => { - const providerNames = item.oauthProviderIds - .map((providerId) => ssoProviders.find((provider) => provider.id === providerId)?.name) - .filter((providerName): providerName is string => !!providerName); - return providerNames.length > 0 ? providerNames.join(", ") : "None"; + const providers = item.oauthProviderIds + .map((providerId) => ssoProviders.find((provider) => provider.id === providerId)) + .filter((provider): provider is SsoProviderUiModel => !!provider); + + if (providers.length === 0) return "None"; + + return ( + +
+ {providers.map((provider) => + provider.type !== "OIDC" ? ( + + + + + + + {provider.name} + + ) : ( + {provider.name} + ), + )} +
+
+ ); }], ["createdAt", "Created At", true, (item) => formatDateTime(item.createdAt)], ["updatedAt", "Updated At", false, (item) => formatDateTime(item.updatedAt)], diff --git a/src/components/custom/sso-provider-logo.tsx b/src/components/custom/sso-provider-logo.tsx new file mode 100644 index 00000000..5e6d1777 --- /dev/null +++ b/src/components/custom/sso-provider-logo.tsx @@ -0,0 +1,30 @@ +import Image from "next/image"; +import { SsoProviderType } from "@/shared/model/sso-provider.model"; + +const logoByType: Partial> = { + GOOGLE: "/sso-provider-logos/google.png", + AZURE_AD: "/sso-provider-logos/entra.svg", + GITHUB: "/sso-provider-logos/github.svg", +}; + +export function SsoProviderLogo({ + type, + className, +}: { + type: SsoProviderType; + className?: string; +}) { + const src = logoByType[type]; + if (!src) return null; + + return ( + + ); +}