Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
}
},
Expand Down
16 changes: 16 additions & 0 deletions prisma/migrations/20260802000000_add_sso_provider/migration.sql
Original file line number Diff line number Diff line change
@@ -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");
28 changes: 27 additions & 1 deletion prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions public/sso-provider-logos/entra.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 10 additions & 0 deletions public/sso-provider-logos/github.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/sso-provider-logos/google.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 9 additions & 5 deletions src/app/api/auth/[...nextauth]/route.ts
Original file line number Diff line number Diff line change
@@ -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 }
export { handler as GET, handler as POST };
73 changes: 59 additions & 14 deletions src/app/auth/login-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,20 @@ import { authUser } from "./actions"
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 { Card, CardContent, CardFooter } from "@/components/ui/card"
import TwoFaAuthForm from "./two-fa-auth"
import { SsoProviderType } from "@/shared/model/sso-provider.model";
import { SsoProviderLogo } from "@/components/custom/sso-provider-logo";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Separator } from "@/components/ui/separator";

export default function UserLoginForm() {
type SsoLoginProvider = {
id: string;
name: string;
type: SsoProviderType;
};

export default function UserLoginForm({ ssoProviders }: { ssoProviders: SsoLoginProvider[] }) {
const form = useForm<z.input<typeof authFormInputSchemaZod>, unknown, z.output<typeof authFormInputSchemaZod>>({
resolver: zodResolver(authFormInputSchemaZod)
});
Expand Down Expand Up @@ -73,28 +83,30 @@ export default function UserLoginForm() {
}

return (
<Card className="w-[350px] mx-auto">
<CardHeader>
<CardTitle>Sign In</CardTitle>
<CardDescription>Enter your email and password to access your account.</CardDescription>
</CardHeader>
<Card className="w-full border-border/80 shadow-black/5">
<Form {...form}>
<form onSubmit={async (e) => {
e.preventDefault();
return form.handleSubmit(async (data) => {
await login(data);
})();
}} className="space-y-8">
}} className="space-y-6">

<CardContent className="space-y-4">
<CardContent className="space-y-5 pt-6">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>E-Mail</FormLabel>
<FormControl>
<Input {...field} value={field.value as string | number | readonly string[] | undefined} />
<Input
{...field}
autoComplete="email"
inputMode="email"
placeholder="name@example.com"
value={field.value as string | number | readonly string[] | undefined}
/>
</FormControl>
<FormMessage />
</FormItem>
Expand All @@ -108,19 +120,52 @@ export default function UserLoginForm() {
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" {...field} value={field.value as string | number | readonly string[] | undefined} />
<Input
type="password"
autoComplete="current-password"
placeholder="Enter your password"
{...field}
value={field.value as string | number | readonly string[] | undefined}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
<CardFooter>
<p className="text-red-500">{errorMessages}</p>
<Button type="submit" className="w-full" disabled={loading}>{loading ? <LoadingSpinner></LoadingSpinner> : 'Login'}</Button>
<CardFooter className="flex flex-col gap-4">
{errorMessages && (
<Alert variant="destructive" aria-live="polite">
<AlertDescription>{errorMessages}</AlertDescription>
</Alert>
)}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? <LoadingSpinner /> : 'Sign in'}
</Button>
</CardFooter>
</form>
</Form>
{ssoProviders.length > 0 && (
<CardFooter className="flex flex-col gap-4 pt-0">
<div className="flex w-full items-center gap-3 text-xs text-muted-foreground">
<Separator className="flex-1" />
<span>or continue with</span>
<Separator className="flex-1" />
</div>
{ssoProviders.map((provider) => (
<Button
key={provider.id}
variant="outline"
className="w-full"
type="button"
onClick={() => signIn(provider.id, { callbackUrl: "/" })}
>
<SsoProviderLogo type={provider.type} className="size-4" />
Continue with {provider.name}
</Button>
))}
</CardFooter>
)}
</Card>
)
}
23 changes: 17 additions & 6 deletions src/app/auth/page.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,31 @@
'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();
if (session) {
redirect('/');
}
const allUsers = await userService.getAllUsers();
const ssoProviders = (await ssoProviderService.getAll())
.filter((provider) => provider.enabled)
.map(({ id, name, type }) => ({ id, name, type }));
return (
<div className="flex items-center justify-center" style={{ height: '95vh' }}>
{allUsers.length === 0 ? <UserRegistrationForm /> : <UserLoginForm />}
</div>
<main className="relative left-1/2 grid min-h-[100dvh] w-screen -translate-x-1/2 place-items-center overflow-hidden px-4 py-10 sm:px-6">
<div className="absolute inset-x-0 top-0 h-80 " />
<div className="relative w-full max-w-md">
{allUsers.length === 0 ? <UserRegistrationForm /> : <UserLoginForm ssoProviders={ssoProviders} />}
</div>
</main>
)
}
}
25 changes: 25 additions & 0 deletions src/app/settings/users/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,31 @@ 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 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."],
});
}
return await ssoProviderService.save(validatedData);
});

export const deleteSsoProvider = async (id: string) => simpleAction(async () => {
await getAdminUserSession();
await ssoProviderService.deleteById(id);
return new SuccessActionResult(undefined, "SSO provider deleted");
});

export const saveUser = async (prevState: any, inputData: UserEditModel) =>
saveFormAction(inputData, userEditZodModel, async (validatedData) => {
Expand Down
11 changes: 9 additions & 2 deletions src/app/settings/users/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -15,13 +15,16 @@ 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() {

const session = await getAdminUserSession();
const users = await userService.getAllUsers();
const userGroups = await userGroupService.getAll();
const allApps = await projectService.getAll();
const ssoProviders = await ssoProviderService.getAll();
return (
<div className="flex-1 space-y-4 pt-6">
<PageTitle
Expand All @@ -35,13 +38,17 @@ export default async function UsersAndGroupsPage() {
<TabsList className="">
<TabsTrigger className="px-8 gap-1.5" value="users"><CircleUser className="w-3.5 h-3.5" /> Users</TabsTrigger>
<TabsTrigger className="px-8 gap-1.5" value="groups"><UserRoundCog className="w-3.5 h-3.5" /> Groups</TabsTrigger>
<TabsTrigger className="px-8 gap-1.5" value="sso"><KeyRound className="w-3.5 h-3.5" /> SSO Providers</TabsTrigger>
</TabsList>
<TabsContent value="users">
<UsersTable session={session} users={users} userGroups={userGroups} />
<UsersTable session={session} users={users} userGroups={userGroups} ssoProviders={ssoProviders} />
</TabsContent>
<TabsContent value="groups">
<UserGroupsTable projects={allApps} userGroups={userGroups} />
</TabsContent>
<TabsContent value="sso">
<SsoProvidersTable ssoProviders={ssoProviders} userGroups={userGroups} />
</TabsContent>
</Tabs>
</div>
)
Expand Down
Loading
Loading